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,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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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='{"title": "Welcome", "body": "..."}'></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>
|
||||||
@@ -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>
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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} <{msg.email}></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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user