feat(task-36): admin Content section — content blocks, newsletter, happy tails, events, messages

CMS content block editor grouped by page, newsletter subscriber management,
happy tails CRUD, events CRUD, and contact message inbox with read/unread tracking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:42:50 -05:00
co-authored by Claude Opus 4.6
parent 416b25fca9
commit 1ae4c1be2c
18 changed files with 1339 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { contentBlocks } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
import { logAudit } from '$lib/server/audit';
export const load: PageServerLoad = async ({ locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const blocks = await db.select().from(contentBlocks).orderBy(contentBlocks.page, contentBlocks.sectionKey);
return { blocks };
};
export const actions: Actions = {
save: async ({ request, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const fd = await request.formData();
const page = fd.get('page')?.toString().trim() ?? '';
const sectionKey = fd.get('sectionKey')?.toString().trim() ?? '';
const content = fd.get('content')?.toString() ?? '';
if (!page || !sectionKey) return fail(400, { error: 'Page and section key are required' });
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
// Treat as plain text content object
parsed = { text: content };
}
const existing = await db.select({ id: contentBlocks.id }).from(contentBlocks)
.where((await import('drizzle-orm')).and(
eq(contentBlocks.page, page),
eq(contentBlocks.sectionKey, sectionKey)
))
.limit(1);
if (existing.length > 0) {
await db.update(contentBlocks)
.set({ content: parsed as Record<string, unknown> })
.where(eq(contentBlocks.id, existing[0].id));
} else {
await db.insert(contentBlocks).values({ page, sectionKey, content: parsed as Record<string, unknown> });
}
await logAudit({
userId: locals.user!.id,
action: 'update_content_block',
entity: 'content_block',
diff: { page, sectionKey }
});
return { success: true };
}
};
+126
View File
@@ -0,0 +1,126 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
let submitting = $state<string | null>(null);
let showSuccess = $state(false);
let editingKey = $state<string | null>(null);
let newPage = $state('');
let newSectionKey = $state('');
let newContent = $state('');
// Group blocks by page
const blocksByPage = $derived.by(() => {
const map = new Map<string, typeof data.blocks>();
for (const block of data.blocks) {
if (!map.has(block.page)) map.set(block.page, []);
map.get(block.page)!.push(block);
}
return map;
});
function getContentDisplay(content: unknown): string {
if (typeof content === 'string') return content;
return JSON.stringify(content, null, 2);
}
function handleEnhance(key: string) {
submitting = key;
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string } }) => {
submitting = null;
await update({ reset: false });
if (result.type === 'success') {
showSuccess = true;
editingKey = null;
setTimeout(() => showSuccess = false, 3000);
}
};
}
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-gray-brand placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand focus:border-teal-brand text-sm';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
</script>
<svelte:head><title>Content Blocks — Admin</title></svelte:head>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Content Blocks</h1>
</div>
{#if form?.error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{form.error}</div>
{/if}
{#if showSuccess}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700 flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
Content saved.
</div>
{/if}
<div class="grid grid-cols-1 gap-6">
<!-- Add new block -->
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Add / Update Content Block</h2>
<form method="POST" action="?/save" use:enhance={() => handleEnhance('new')}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="page" class={labelClass}>Page</label>
<input id="page" name="page" type="text" required bind:value={newPage} class={inputClass} placeholder="e.g. home, about, adopt" />
</div>
<div>
<label for="sectionKey" class={labelClass}>Section Key</label>
<input id="sectionKey" name="sectionKey" type="text" required bind:value={newSectionKey} class={inputClass} placeholder="e.g. hero, mission" />
</div>
</div>
<div class="mb-4">
<label for="content" class={labelClass}>Content (JSON or plain text)</label>
<textarea id="content" name="content" rows="6" bind:value={newContent} class={inputClass} placeholder='&#123;"title": "Welcome", "body": "..."&#125;'></textarea>
</div>
<button type="submit" disabled={submitting === 'new'} class="px-4 py-2.5 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer">
{submitting === 'new' ? 'Saving...' : 'Save Block'}
</button>
</form>
</div>
<!-- Existing blocks grouped by page -->
{#each [...blocksByPage.entries()] as [pageName, blocks]}
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Page: <span class="font-mono text-teal-brand">{pageName}</span></h2>
<div class="space-y-4">
{#each blocks as block}
{@const key = `${block.page}:${block.sectionKey}`}
<div class="border border-gray-soft rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<code class="text-xs text-gray-500">{block.sectionKey}</code>
<button onclick={() => editingKey = editingKey === key ? null : key} class="text-xs text-teal-brand hover:text-teal-dark cursor-pointer">
{editingKey === key ? 'Cancel' : 'Edit'}
</button>
</div>
{#if editingKey === key}
<form method="POST" action="?/save" use:enhance={() => handleEnhance(key)}>
<input type="hidden" name="page" value={block.page} />
<input type="hidden" name="sectionKey" value={block.sectionKey} />
<textarea name="content" rows="6" class="{inputClass} font-mono text-xs mb-3">{getContentDisplay(block.content)}</textarea>
<button type="submit" disabled={submitting === key} class="px-3 py-1.5 text-xs font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark disabled:opacity-50 cursor-pointer">
{submitting === key ? 'Saving...' : 'Save'}
</button>
</form>
{:else}
<pre class="text-xs text-gray-600 bg-gray-50 rounded p-2 overflow-auto max-h-32">{getContentDisplay(block.content)}</pre>
{/if}
</div>
{/each}
</div>
</div>
{/each}
{#if data.blocks.length === 0}
<div class="text-center py-12 text-gray-400">
<p class="text-sm font-medium">No content blocks yet. Add one above.</p>
</div>
{/if}
</div>
</div>
+26
View File
@@ -0,0 +1,26 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { events } from '$lib/server/schema';
import { desc } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
export const load: PageServerLoad = async ({ locals }) => {
if (!hasPermission(locals.user!.role as Role, 'events')) error(403, 'Access denied');
const eventList = await db
.select({
id: events.id,
title: events.title,
location: events.location,
startDate: events.startDate,
endDate: events.endDate,
published: events.published,
createdAt: events.createdAt
})
.from(events)
.orderBy(desc(events.startDate));
return { events: eventList };
};
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
let { data } = $props();
function formatDate(d: string | Date): string {
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
}
</script>
<svelte:head><title>Events — Admin</title></svelte:head>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Events</h1>
<a href="/admin/events/new" class="px-4 py-2 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors">
Add Event
</a>
</div>
{#if data.events.length === 0}
<div class="text-center py-12 text-gray-400">
<p class="text-sm font-medium">No events yet.</p>
</div>
{:else}
<div class="bg-white rounded-xl shadow-sm border border-gray-soft overflow-hidden">
<table class="w-full text-sm text-left">
<thead>
<tr class="border-b border-gray-soft bg-gray-50">
<th class="px-6 py-3 font-semibold text-gray-600">Title</th>
<th class="px-6 py-3 font-semibold text-gray-600">Location</th>
<th class="px-6 py-3 font-semibold text-gray-600">Date</th>
<th class="px-6 py-3 font-semibold text-gray-600">Status</th>
<th class="px-6 py-3 font-semibold text-gray-600 text-right">Actions</th>
</tr>
</thead>
<tbody>
{#each data.events as event}
<tr class="border-b border-gray-soft last:border-0 hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 font-medium text-gray-900">{event.title}</td>
<td class="px-6 py-4 text-gray-600">{event.location ?? '—'}</td>
<td class="px-6 py-4 text-gray-600 text-xs">{formatDate(event.startDate)}</td>
<td class="px-6 py-4">
{#if event.published}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Published</span>
{:else}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">Draft</span>
{/if}
</td>
<td class="px-6 py-4 text-right">
<a href="/admin/events/{event.id}" class="text-sm font-medium text-teal-brand hover:text-teal-dark">Edit</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,74 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { events } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
import { logAudit } from '$lib/server/audit';
export const load: PageServerLoad = async ({ params, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'events')) error(403, 'Access denied');
const id = parseInt(params.id, 10);
if (isNaN(id)) error(404, 'Event not found');
const [event] = await db.select().from(events).where(eq(events.id, id)).limit(1);
if (!event) error(404, 'Event not found');
return { event };
};
export const actions: Actions = {
update: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'events')) error(403, 'Access denied');
const id = parseInt(params.id, 10);
const fd = await request.formData();
const title = fd.get('title')?.toString().trim() ?? '';
const description = fd.get('description')?.toString().trim() || null;
const location = fd.get('location')?.toString().trim() || null;
const startDateStr = fd.get('startDate')?.toString() ?? '';
const endDateStr = fd.get('endDate')?.toString() || null;
const imageUrl = fd.get('imageUrl')?.toString().trim() || null;
const facebookEventId = fd.get('facebookEventId')?.toString().trim() || null;
const published = fd.get('published') === 'on';
if (!title) return fail(400, { error: 'Title is required' });
if (!startDateStr) return fail(400, { error: 'Start date is required' });
await db.update(events).set({
title,
description,
location,
startDate: new Date(startDateStr),
endDate: endDateStr ? new Date(endDateStr) : null,
imageUrl,
facebookEventId,
published
}).where(eq(events.id, id));
await logAudit({
userId: locals.user!.id,
action: 'update_event',
entity: 'event',
entityId: id,
diff: { title, published }
});
return { success: true };
},
delete: async ({ params, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'events')) error(403, 'Access denied');
const id = parseInt(params.id, 10);
await db.delete(events).where(eq(events.id, id));
await logAudit({
userId: locals.user!.id,
action: 'delete_event',
entity: 'event',
entityId: id
});
return { success: true, deleted: true };
}
};
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
let { data, form } = $props();
const event = $derived(data.event);
let submitting = $state<string | null>(null);
let showSuccess = $state(false);
let confirmDelete = $state(false);
function toDatetimeLocal(d: string | Date | null): string {
if (!d) return '';
const dt = new Date(d);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
}
function handleEnhance(action: string) {
submitting = action;
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string; data?: Record<string, unknown> } }) => {
submitting = null;
if (result.type === 'success' && result.data?.deleted) {
goto('/admin/events');
return;
}
await update({ reset: false });
if (result.type === 'success') { showSuccess = true; setTimeout(() => showSuccess = false, 3000); }
};
}
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-gray-brand placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand focus:border-teal-brand text-sm';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
</script>
<svelte:head><title>{event.title} — Admin</title></svelte:head>
<div class="max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<a href="/admin/events" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</a>
<h1 class="text-2xl font-bold text-gray-900">{event.title}</h1>
</div>
{#if form?.error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{form.error}</div>
{/if}
{#if showSuccess}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700 flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
Event saved.
</div>
{/if}
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6 mb-6">
<form method="POST" action="?/update" use:enhance={() => handleEnhance('update')}>
<div class="space-y-4">
<div>
<label for="title" class={labelClass}>Title</label>
<input id="title" name="title" type="text" required value={event.title} class={inputClass} />
</div>
<div>
<label for="description" class={labelClass}>Description</label>
<textarea id="description" name="description" rows="4" class={inputClass}>{event.description ?? ''}</textarea>
</div>
<div>
<label for="location" class={labelClass}>Location</label>
<input id="location" name="location" type="text" value={event.location ?? ''} class={inputClass} />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="startDate" class={labelClass}>Start Date & Time</label>
<input id="startDate" name="startDate" type="datetime-local" required value={toDatetimeLocal(event.startDate)} class={inputClass} />
</div>
<div>
<label for="endDate" class={labelClass}>End Date & Time</label>
<input id="endDate" name="endDate" type="datetime-local" value={toDatetimeLocal(event.endDate)} class={inputClass} />
</div>
</div>
<div>
<label for="imageUrl" class={labelClass}>Image URL</label>
<input id="imageUrl" name="imageUrl" type="url" value={event.imageUrl ?? ''} class={inputClass} />
</div>
<div>
<label for="facebookEventId" class={labelClass}>Facebook Event ID</label>
<input id="facebookEventId" name="facebookEventId" type="text" value={event.facebookEventId ?? ''} class={inputClass} />
</div>
<label class="flex items-center gap-2">
<input name="published" type="checkbox" checked={event.published} class="rounded border-gray-soft text-teal-brand focus:ring-teal-brand" />
<span class="text-sm text-gray-700">Published</span>
</label>
</div>
<div class="mt-6">
<button type="submit" disabled={submitting === 'update'} class="px-6 py-2.5 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer">
{submitting === 'update' ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
<div class="bg-white rounded-xl shadow-sm border border-red-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-2">Danger Zone</h2>
{#if confirmDelete}
<p class="text-sm text-red-600 mb-3">Are you sure? This cannot be undone.</p>
<form method="POST" action="?/delete" use:enhance={() => handleEnhance('delete')}>
<div class="flex gap-3">
<button type="submit" disabled={submitting === 'delete'} class="px-4 py-2 text-sm font-medium rounded-lg bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 cursor-pointer">
{submitting === 'delete' ? 'Deleting...' : 'Yes, Delete'}
</button>
<button type="button" onclick={() => confirmDelete = false} class="px-4 py-2 text-sm rounded-lg border border-gray-soft text-gray-700 hover:bg-gray-50 cursor-pointer">Cancel</button>
</div>
</form>
{:else}
<button onclick={() => confirmDelete = true} class="px-4 py-2 text-sm font-medium rounded-lg border border-red-300 text-red-600 hover:bg-red-50 cursor-pointer">Delete Event</button>
{/if}
</div>
</div>
@@ -0,0 +1,54 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { events } from '$lib/server/schema';
import { error, fail, redirect } from '@sveltejs/kit';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
import { logAudit } from '$lib/server/audit';
export const load: PageServerLoad = async ({ locals }) => {
if (!hasPermission(locals.user!.role as Role, 'events')) error(403, 'Access denied');
return {};
};
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'events')) error(403, 'Access denied');
const fd = await request.formData();
const title = fd.get('title')?.toString().trim() ?? '';
const description = fd.get('description')?.toString().trim() || null;
const location = fd.get('location')?.toString().trim() || null;
const startDateStr = fd.get('startDate')?.toString() ?? '';
const endDateStr = fd.get('endDate')?.toString() || null;
const imageUrl = fd.get('imageUrl')?.toString().trim() || null;
const facebookEventId = fd.get('facebookEventId')?.toString().trim() || null;
const published = fd.get('published') === 'on';
if (!title) return fail(400, { error: 'Title is required' });
if (!startDateStr) return fail(400, { error: 'Start date is required' });
const startDate = new Date(startDateStr);
const endDate = endDateStr ? new Date(endDateStr) : null;
const [result] = await db.insert(events).values({
title,
description,
location,
startDate,
endDate,
imageUrl,
facebookEventId,
published
}).$returningId();
await logAudit({
userId: locals.user!.id,
action: 'create_event',
entity: 'event',
entityId: result.id,
diff: { title, published }
});
redirect(303, `/admin/events/${result.id}`);
}
};
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
let submitting = $state(false);
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-gray-brand placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand focus:border-teal-brand text-sm';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
</script>
<svelte:head><title>New Event — Admin</title></svelte:head>
<div class="max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<a href="/admin/events" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</a>
<h1 class="text-2xl font-bold text-gray-900">New Event</h1>
</div>
{#if form?.error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{form.error}</div>
{/if}
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
<form method="POST" use:enhance={() => {
submitting = true;
return async ({ update }) => { submitting = false; await update(); };
}}>
<div class="space-y-4">
<div>
<label for="title" class={labelClass}>Title</label>
<input id="title" name="title" type="text" required class={inputClass} placeholder="Adoption Event at PetSmart" />
</div>
<div>
<label for="description" class={labelClass}>Description</label>
<textarea id="description" name="description" rows="4" class={inputClass} placeholder="Event details..."></textarea>
</div>
<div>
<label for="location" class={labelClass}>Location</label>
<input id="location" name="location" type="text" class={inputClass} placeholder="123 Main St, Anytown" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="startDate" class={labelClass}>Start Date & Time</label>
<input id="startDate" name="startDate" type="datetime-local" required class={inputClass} />
</div>
<div>
<label for="endDate" class={labelClass}>End Date & Time (optional)</label>
<input id="endDate" name="endDate" type="datetime-local" class={inputClass} />
</div>
</div>
<div>
<label for="imageUrl" class={labelClass}>Image URL</label>
<input id="imageUrl" name="imageUrl" type="url" class={inputClass} placeholder="https://..." />
</div>
<div>
<label for="facebookEventId" class={labelClass}>Facebook Event ID</label>
<input id="facebookEventId" name="facebookEventId" type="text" class={inputClass} placeholder="123456789" />
</div>
<label class="flex items-center gap-2">
<input name="published" type="checkbox" checked class="rounded border-gray-soft text-teal-brand focus:ring-teal-brand" />
<span class="text-sm text-gray-700">Published</span>
</label>
</div>
<div class="mt-6 flex gap-3">
<button type="submit" disabled={submitting} class="px-6 py-2.5 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer">
{submitting ? 'Creating...' : 'Create Event'}
</button>
<a href="/admin/events" class="px-6 py-2.5 text-sm font-medium rounded-lg border border-gray-soft text-gray-700 hover:bg-gray-50 transition-colors">Cancel</a>
</div>
</form>
</div>
</div>
@@ -0,0 +1,28 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { happyTails, pets } from '$lib/server/schema';
import { eq, desc } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
export const load: PageServerLoad = async ({ locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const stories = await db
.select({
id: happyTails.id,
title: happyTails.title,
adopterName: happyTails.adopterName,
petId: happyTails.petId,
petName: pets.name,
featured: happyTails.featured,
published: happyTails.published,
createdAt: happyTails.createdAt
})
.from(happyTails)
.leftJoin(pets, eq(happyTails.petId, pets.id))
.orderBy(desc(happyTails.createdAt));
return { stories };
};
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
let { data } = $props();
function formatDate(d: string | Date): string {
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
</script>
<svelte:head><title>Happy Tails — Admin</title></svelte:head>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Happy Tails</h1>
<a href="/admin/happy-tails/new" class="px-4 py-2 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors">
Add Story
</a>
</div>
{#if data.stories.length === 0}
<div class="text-center py-12 text-gray-400">
<p class="text-sm font-medium">No happy tails stories yet.</p>
</div>
{:else}
<div class="bg-white rounded-xl shadow-sm border border-gray-soft overflow-hidden">
<table class="w-full text-sm text-left">
<thead>
<tr class="border-b border-gray-soft bg-gray-50">
<th class="px-6 py-3 font-semibold text-gray-600">Title</th>
<th class="px-6 py-3 font-semibold text-gray-600">Adopter</th>
<th class="px-6 py-3 font-semibold text-gray-600">Pet</th>
<th class="px-6 py-3 font-semibold text-gray-600">Status</th>
<th class="px-6 py-3 font-semibold text-gray-600">Date</th>
<th class="px-6 py-3 font-semibold text-gray-600 text-right">Actions</th>
</tr>
</thead>
<tbody>
{#each data.stories as story}
<tr class="border-b border-gray-soft last:border-0 hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 font-medium text-gray-900">{story.title}</td>
<td class="px-6 py-4 text-gray-600">{story.adopterName ?? '—'}</td>
<td class="px-6 py-4 text-gray-600">{story.petName ?? '—'}</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
{#if story.published}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Published</span>
{:else}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">Draft</span>
{/if}
{#if story.featured}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">Featured</span>
{/if}
</div>
</td>
<td class="px-6 py-4 text-gray-500 text-xs">{formatDate(story.createdAt)}</td>
<td class="px-6 py-4 text-right">
<a href="/admin/happy-tails/{story.id}" class="text-sm font-medium text-teal-brand hover:text-teal-dark">Edit</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,78 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { happyTails, pets } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
import { logAudit } from '$lib/server/audit';
export const load: PageServerLoad = async ({ params, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const id = parseInt(params.id, 10);
if (isNaN(id)) error(404, 'Story not found');
const [story] = await db.select().from(happyTails).where(eq(happyTails.id, id)).limit(1);
if (!story) error(404, 'Story not found');
const petList = await db
.select({ id: pets.id, name: pets.name })
.from(pets)
.where(eq(pets.status, 'adopted'))
.orderBy(pets.name);
return { story, pets: petList };
};
export const actions: Actions = {
update: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const id = parseInt(params.id, 10);
const fd = await request.formData();
const title = fd.get('title')?.toString().trim() ?? '';
const story = fd.get('story')?.toString().trim() ?? '';
const adopterName = fd.get('adopterName')?.toString().trim() || null;
const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId')!.toString(), 10) : null;
const imageUrl = fd.get('imageUrl')?.toString().trim() || null;
const featured = fd.get('featured') === 'on';
const published = fd.get('published') === 'on';
if (!title) return fail(400, { error: 'Title is required' });
if (!story) return fail(400, { error: 'Story is required' });
await db.update(happyTails).set({
title,
story,
adopterName,
petId: petId && !isNaN(petId) ? petId : null,
imageUrl,
featured,
published
}).where(eq(happyTails.id, id));
await logAudit({
userId: locals.user!.id,
action: 'update_happy_tail',
entity: 'happy_tail',
entityId: id,
diff: { title, published }
});
return { success: true };
},
delete: async ({ params, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const id = parseInt(params.id, 10);
await db.delete(happyTails).where(eq(happyTails.id, id));
await logAudit({
userId: locals.user!.id,
action: 'delete_happy_tail',
entity: 'happy_tail',
entityId: id
});
return { success: true, deleted: true };
}
};
@@ -0,0 +1,114 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
let { data, form } = $props();
const story = $derived(data.story);
let submitting = $state<string | null>(null);
let showSuccess = $state(false);
let confirmDelete = $state(false);
function handleEnhance(action: string) {
submitting = action;
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string; data?: Record<string, unknown> } }) => {
submitting = null;
if (result.type === 'success' && result.data?.deleted) {
goto('/admin/happy-tails');
return;
}
await update({ reset: false });
if (result.type === 'success') { showSuccess = true; setTimeout(() => showSuccess = false, 3000); }
};
}
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-gray-brand placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand focus:border-teal-brand text-sm';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
</script>
<svelte:head><title>{story.title} — Admin</title></svelte:head>
<div class="max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<a href="/admin/happy-tails" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</a>
<h1 class="text-2xl font-bold text-gray-900">{story.title}</h1>
</div>
{#if form?.error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{form.error}</div>
{/if}
{#if showSuccess}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700 flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
Story saved.
</div>
{/if}
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6 mb-6">
<form method="POST" action="?/update" use:enhance={() => handleEnhance('update')}>
<div class="space-y-4">
<div>
<label for="title" class={labelClass}>Title</label>
<input id="title" name="title" type="text" required value={story.title} class={inputClass} />
</div>
<div>
<label for="story" class={labelClass}>Story</label>
<textarea id="story" name="story" rows="10" required class={inputClass}>{story.story}</textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="adopterName" class={labelClass}>Adopter Name</label>
<input id="adopterName" name="adopterName" type="text" value={story.adopterName ?? ''} class={inputClass} />
</div>
<div>
<label for="petId" class={labelClass}>Pet (optional)</label>
<select id="petId" name="petId" class={inputClass}>
<option value="">-- None --</option>
{#each data.pets as pet}
<option value={pet.id} selected={story.petId === pet.id}>{pet.name}</option>
{/each}
</select>
</div>
</div>
<div>
<label for="imageUrl" class={labelClass}>Image URL</label>
<input id="imageUrl" name="imageUrl" type="url" value={story.imageUrl ?? ''} class={inputClass} />
</div>
<div class="flex items-center gap-6">
<label class="flex items-center gap-2">
<input name="published" type="checkbox" checked={story.published} class="rounded border-gray-soft text-teal-brand focus:ring-teal-brand" />
<span class="text-sm text-gray-700">Published</span>
</label>
<label class="flex items-center gap-2">
<input name="featured" type="checkbox" checked={story.featured} class="rounded border-gray-soft text-teal-brand focus:ring-teal-brand" />
<span class="text-sm text-gray-700">Featured</span>
</label>
</div>
</div>
<div class="mt-6">
<button type="submit" disabled={submitting === 'update'} class="px-6 py-2.5 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer">
{submitting === 'update' ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
<div class="bg-white rounded-xl shadow-sm border border-red-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-2">Danger Zone</h2>
{#if confirmDelete}
<p class="text-sm text-red-600 mb-3">Are you sure? This cannot be undone.</p>
<form method="POST" action="?/delete" use:enhance={() => handleEnhance('delete')}>
<div class="flex gap-3">
<button type="submit" disabled={submitting === 'delete'} class="px-4 py-2 text-sm font-medium rounded-lg bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 cursor-pointer">
{submitting === 'delete' ? 'Deleting...' : 'Yes, Delete'}
</button>
<button type="button" onclick={() => confirmDelete = false} class="px-4 py-2 text-sm rounded-lg border border-gray-soft text-gray-700 hover:bg-gray-50 cursor-pointer">Cancel</button>
</div>
</form>
{:else}
<button onclick={() => confirmDelete = true} class="px-4 py-2 text-sm font-medium rounded-lg border border-red-300 text-red-600 hover:bg-red-50 cursor-pointer">Delete Story</button>
{/if}
</div>
</div>
@@ -0,0 +1,61 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { happyTails, pets } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
import { logAudit } from '$lib/server/audit';
export const load: PageServerLoad = async ({ locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const petList = await db
.select({ id: pets.id, name: pets.name })
.from(pets)
.where(eq(pets.status, 'adopted'))
.orderBy(pets.name);
return { pets: petList };
};
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const fd = await request.formData();
const title = fd.get('title')?.toString().trim() ?? '';
const story = fd.get('story')?.toString().trim() ?? '';
const adopterName = fd.get('adopterName')?.toString().trim() ?? '';
const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId')!.toString(), 10) : null;
const imageUrl = fd.get('imageUrl')?.toString().trim() || null;
const featured = fd.get('featured') === 'on';
const published = fd.get('published') === 'on';
const submittedBy = fd.get('submittedBy')?.toString().trim() || null;
const submittedEmail = fd.get('submittedEmail')?.toString().trim() || null;
if (!title) return fail(400, { error: 'Title is required' });
if (!story) return fail(400, { error: 'Story is required' });
const [result] = await db.insert(happyTails).values({
title,
story,
adopterName: adopterName || null,
petId: petId && !isNaN(petId) ? petId : null,
imageUrl,
featured,
published,
submittedBy,
submittedEmail
}).$returningId();
await logAudit({
userId: locals.user!.id,
action: 'create_happy_tail',
entity: 'happy_tail',
entityId: result.id,
diff: { title, published }
});
redirect(303, `/admin/happy-tails/${result.id}`);
}
};
@@ -0,0 +1,87 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
let submitting = $state(false);
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-gray-brand placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand focus:border-teal-brand text-sm';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
</script>
<svelte:head><title>New Happy Tail — Admin</title></svelte:head>
<div class="max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<a href="/admin/happy-tails" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</a>
<h1 class="text-2xl font-bold text-gray-900">New Happy Tail</h1>
</div>
{#if form?.error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{form.error}</div>
{/if}
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
<form method="POST" use:enhance={() => {
submitting = true;
return async ({ update }) => { submitting = false; await update(); };
}}>
<div class="space-y-4">
<div>
<label for="title" class={labelClass}>Title</label>
<input id="title" name="title" type="text" required class={inputClass} placeholder="Buddy Finds His Forever Home" />
</div>
<div>
<label for="story" class={labelClass}>Story</label>
<textarea id="story" name="story" rows="8" required class={inputClass} placeholder="Tell their adoption story..."></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="adopterName" class={labelClass}>Adopter Name</label>
<input id="adopterName" name="adopterName" type="text" class={inputClass} placeholder="The Smith Family" />
</div>
<div>
<label for="petId" class={labelClass}>Pet (optional)</label>
<select id="petId" name="petId" class={inputClass}>
<option value="">-- Select adopted pet --</option>
{#each data.pets as pet}
<option value={pet.id}>{pet.name}</option>
{/each}
</select>
</div>
</div>
<div>
<label for="imageUrl" class={labelClass}>Image URL</label>
<input id="imageUrl" name="imageUrl" type="url" class={inputClass} placeholder="https://..." />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="submittedBy" class={labelClass}>Submitted By</label>
<input id="submittedBy" name="submittedBy" type="text" class={inputClass} />
</div>
<div>
<label for="submittedEmail" class={labelClass}>Submitter Email</label>
<input id="submittedEmail" name="submittedEmail" type="email" class={inputClass} />
</div>
</div>
<div class="flex items-center gap-6">
<label class="flex items-center gap-2">
<input name="published" type="checkbox" class="rounded border-gray-soft text-teal-brand focus:ring-teal-brand" />
<span class="text-sm text-gray-700">Published</span>
</label>
<label class="flex items-center gap-2">
<input name="featured" type="checkbox" class="rounded border-gray-soft text-teal-brand focus:ring-teal-brand" />
<span class="text-sm text-gray-700">Featured</span>
</label>
</div>
</div>
<div class="mt-6 flex gap-3">
<button type="submit" disabled={submitting} class="px-6 py-2.5 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer">
{submitting ? 'Creating...' : 'Create Story'}
</button>
<a href="/admin/happy-tails" class="px-6 py-2.5 text-sm font-medium rounded-lg border border-gray-soft text-gray-700 hover:bg-gray-50 transition-colors">Cancel</a>
</div>
</form>
</div>
</div>
+53
View File
@@ -0,0 +1,53 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { contactMessages } from '$lib/server/schema';
import { eq, desc, count } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
export const load: PageServerLoad = async ({ locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const messages = await db
.select()
.from(contactMessages)
.orderBy(desc(contactMessages.createdAt));
const [unreadRow] = await db.select({ total: count() }).from(contactMessages).where(eq(contactMessages.read, false));
const unreadCount = unreadRow?.total ?? 0;
return { messages, unreadCount };
};
export const actions: Actions = {
markRead: async ({ request, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const fd = await request.formData();
const id = parseInt(fd.get('id')?.toString() ?? '', 10);
if (isNaN(id)) return fail(400, { error: 'Invalid ID' });
await db.update(contactMessages).set({ read: true }).where(eq(contactMessages.id, id));
return { success: true };
},
markUnread: async ({ request, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const fd = await request.formData();
const id = parseInt(fd.get('id')?.toString() ?? '', 10);
if (isNaN(id)) return fail(400, { error: 'Invalid ID' });
await db.update(contactMessages).set({ read: false }).where(eq(contactMessages.id, id));
return { success: true };
},
delete: async ({ request, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const fd = await request.formData();
const id = parseInt(fd.get('id')?.toString() ?? '', 10);
if (isNaN(id)) return fail(400, { error: 'Invalid ID' });
await db.delete(contactMessages).where(eq(contactMessages.id, id));
return { success: true };
}
};
+94
View File
@@ -0,0 +1,94 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data } = $props();
let submitting = $state<string | null>(null);
let expanded = $state<Set<number>>(new Set());
function toggleExpand(id: number) {
const next = new Set(expanded);
if (next.has(id)) { next.delete(id); } else { next.add(id); }
expanded = next;
}
function formatDate(d: string | Date): string {
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
}
</script>
<svelte:head><title>Messages — Admin</title></svelte:head>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Contact Messages</h1>
{#if data.unreadCount > 0}
<p class="text-sm text-amber-600 mt-1">{data.unreadCount} unread</p>
{/if}
</div>
</div>
{#if data.messages.length === 0}
<div class="text-center py-12 text-gray-400">
<p class="text-sm font-medium">No messages yet.</p>
</div>
{:else}
<div class="space-y-3">
{#each data.messages as msg}
<div class="bg-white rounded-xl shadow-sm border {msg.read ? 'border-gray-soft' : 'border-teal-brand'} overflow-hidden">
<div class="flex items-center justify-between p-4 cursor-pointer" onclick={() => toggleExpand(msg.id)} role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && toggleExpand(msg.id)}>
<div class="flex items-center gap-3 min-w-0">
{#if !msg.read}
<span class="flex-shrink-0 w-2 h-2 rounded-full bg-teal-brand"></span>
{:else}
<span class="flex-shrink-0 w-2 h-2"></span>
{/if}
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-900 truncate">{msg.name} &lt;{msg.email}&gt;</p>
<p class="text-xs text-gray-500 truncate">{msg.subject ?? '(no subject)'}</p>
</div>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span class="text-xs text-gray-400">{formatDate(msg.createdAt)}</span>
<svg class="w-4 h-4 text-gray-400 transition-transform {expanded.has(msg.id) ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
</div>
</div>
{#if expanded.has(msg.id)}
<div class="px-6 pb-4 border-t border-gray-soft">
<div class="py-4 text-sm text-gray-700 whitespace-pre-wrap">{msg.message}</div>
<div class="flex items-center gap-3">
<a href="mailto:{msg.email}" class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-soft text-teal-brand hover:bg-gray-50">Reply</a>
{#if msg.read}
<form method="POST" action="?/markUnread" use:enhance={() => {
submitting = `unread-${msg.id}`;
return async ({ update }) => { submitting = null; await update(); };
}}>
<input type="hidden" name="id" value={msg.id} />
<button type="submit" disabled={submitting === `unread-${msg.id}`} class="px-3 py-1.5 text-xs rounded-lg border border-gray-soft text-gray-600 hover:bg-gray-50 cursor-pointer disabled:opacity-50">Mark Unread</button>
</form>
{:else}
<form method="POST" action="?/markRead" use:enhance={() => {
submitting = `read-${msg.id}`;
return async ({ update }) => { submitting = null; await update(); };
}}>
<input type="hidden" name="id" value={msg.id} />
<button type="submit" disabled={submitting === `read-${msg.id}`} class="px-3 py-1.5 text-xs rounded-lg border border-gray-soft text-gray-600 hover:bg-gray-50 cursor-pointer disabled:opacity-50">Mark Read</button>
</form>
{/if}
<form method="POST" action="?/delete" use:enhance={() => {
submitting = `delete-${msg.id}`;
return async ({ update }) => { submitting = null; await update(); };
}}>
<input type="hidden" name="id" value={msg.id} />
<button type="submit" disabled={submitting === `delete-${msg.id}`} class="px-3 py-1.5 text-xs rounded-lg text-red-500 hover:text-red-700 cursor-pointer disabled:opacity-50">Delete</button>
</form>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,58 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { newsletterSubscribers } from '$lib/server/schema';
import { isNull, isNotNull, desc, count } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
export const load: PageServerLoad = async ({ locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const subscribers = await db
.select({
id: newsletterSubscribers.id,
email: newsletterSubscribers.email,
name: newsletterSubscribers.name,
subscribedAt: newsletterSubscribers.subscribedAt,
unsubscribedAt: newsletterSubscribers.unsubscribedAt
})
.from(newsletterSubscribers)
.orderBy(desc(newsletterSubscribers.subscribedAt));
const [activeRow] = await db.select({ total: count() }).from(newsletterSubscribers).where(isNull(newsletterSubscribers.unsubscribedAt));
const activeCount = activeRow?.total ?? 0;
return { subscribers, activeCount };
};
export const actions: Actions = {
add: async ({ request, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const fd = await request.formData();
const email = fd.get('email')?.toString().trim().toLowerCase() ?? '';
const name = fd.get('name')?.toString().trim() ?? '';
if (!email) return fail(400, { error: 'Email is required' });
await db.insert(newsletterSubscribers)
.values({ email, name: name || null })
.onDuplicateKeyUpdate({ set: { unsubscribedAt: null, name: name || null } });
return { success: true };
},
remove: async ({ request, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied');
const fd = await request.formData();
const id = parseInt(fd.get('id')?.toString() ?? '', 10);
if (isNaN(id)) return fail(400, { error: 'Invalid ID' });
const { eq } = await import('drizzle-orm');
await db.update(newsletterSubscribers)
.set({ unsubscribedAt: new Date() })
.where(eq(newsletterSubscribers.id, id));
return { success: true };
}
};
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
let submitting = $state<string | null>(null);
let showAddForm = $state(false);
function formatDate(d: string | Date): string {
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
const activeSubscribers = $derived(data.subscribers.filter(s => !s.unsubscribedAt));
const unsubscribed = $derived(data.subscribers.filter(s => s.unsubscribedAt));
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-gray-brand placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand focus:border-teal-brand text-sm';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
</script>
<svelte:head><title>Newsletter Subscribers — Admin</title></svelte:head>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Newsletter Subscribers</h1>
<p class="text-sm text-gray-500 mt-1">{data.activeCount} active subscribers</p>
</div>
<button onclick={() => showAddForm = !showAddForm} class="px-4 py-2 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors cursor-pointer">
{showAddForm ? 'Cancel' : 'Add Subscriber'}
</button>
</div>
{#if form?.error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{form.error}</div>
{/if}
{#if showAddForm}
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6 mb-6">
<form method="POST" action="?/add" use:enhance={() => {
submitting = 'add';
return async ({ update, result }) => {
submitting = null;
await update();
if (result.type === 'success') showAddForm = false;
};
}}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="email" class={labelClass}>Email</label>
<input id="email" name="email" type="email" required class={inputClass} placeholder="subscriber@example.com" />
</div>
<div>
<label for="name" class={labelClass}>Name (optional)</label>
<input id="name" name="name" type="text" class={inputClass} placeholder="Jane Smith" />
</div>
</div>
<button type="submit" disabled={submitting === 'add'} class="px-4 py-2.5 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer">
{submitting === 'add' ? 'Adding...' : 'Add Subscriber'}
</button>
</form>
</div>
{/if}
{#if activeSubscribers.length === 0}
<div class="text-center py-12 text-gray-400">
<p class="text-sm font-medium">No active subscribers.</p>
</div>
{:else}
<div class="bg-white rounded-xl shadow-sm border border-gray-soft overflow-hidden mb-6">
<table class="w-full text-sm text-left">
<thead>
<tr class="border-b border-gray-soft bg-gray-50">
<th class="px-6 py-3 font-semibold text-gray-600">Email</th>
<th class="px-6 py-3 font-semibold text-gray-600">Name</th>
<th class="px-6 py-3 font-semibold text-gray-600">Subscribed</th>
<th class="px-6 py-3 font-semibold text-gray-600 text-right">Actions</th>
</tr>
</thead>
<tbody>
{#each activeSubscribers as sub}
<tr class="border-b border-gray-soft last:border-0 hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 text-gray-900">{sub.email}</td>
<td class="px-6 py-4 text-gray-600">{sub.name ?? '—'}</td>
<td class="px-6 py-4 text-gray-500 text-xs">{formatDate(sub.subscribedAt)}</td>
<td class="px-6 py-4 text-right">
<form method="POST" action="?/remove" use:enhance={() => {
submitting = `remove-${sub.id}`;
return async ({ update }) => { submitting = null; await update(); };
}}>
<input type="hidden" name="id" value={sub.id} />
<button type="submit" disabled={submitting === `remove-${sub.id}`} class="text-sm text-red-500 hover:text-red-700 cursor-pointer disabled:opacity-50">Unsubscribe</button>
</form>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if unsubscribed.length > 0}
<details class="bg-white rounded-xl shadow-sm border border-gray-soft p-4">
<summary class="text-sm font-medium text-gray-600 cursor-pointer">Unsubscribed ({unsubscribed.length})</summary>
<div class="mt-4 space-y-2">
{#each unsubscribed as sub}
<div class="flex items-center justify-between text-sm text-gray-400">
<span>{sub.email}</span>
<span class="text-xs">{sub.unsubscribedAt ? formatDate(sub.unsubscribedAt) : ''}</span>
</div>
{/each}
</div>
</details>
{/if}
</div>