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:
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user