diff --git a/src/routes/admin/content/+page.server.ts b/src/routes/admin/content/+page.server.ts new file mode 100644 index 0000000..7cd5010 --- /dev/null +++ b/src/routes/admin/content/+page.server.ts @@ -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 }) + .where(eq(contentBlocks.id, existing[0].id)); + } else { + await db.insert(contentBlocks).values({ page, sectionKey, content: parsed as Record }); + } + + await logAudit({ + userId: locals.user!.id, + action: 'update_content_block', + entity: 'content_block', + diff: { page, sectionKey } + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/content/+page.svelte b/src/routes/admin/content/+page.svelte new file mode 100644 index 0000000..88d93d9 --- /dev/null +++ b/src/routes/admin/content/+page.svelte @@ -0,0 +1,126 @@ + + +Content Blocks — Admin + +
+
+

Content Blocks

+
+ + {#if form?.error} +
{form.error}
+ {/if} + + {#if showSuccess} +
+ + Content saved. +
+ {/if} + +
+ +
+

Add / Update Content Block

+
handleEnhance('new')}> +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ + + {#each [...blocksByPage.entries()] as [pageName, blocks]} +
+

Page: {pageName}

+
+ {#each blocks as block} + {@const key = `${block.page}:${block.sectionKey}`} +
+
+ {block.sectionKey} + +
+ {#if editingKey === key} +
handleEnhance(key)}> + + + + +
+ {:else} +
{getContentDisplay(block.content)}
+ {/if} +
+ {/each} +
+
+ {/each} + + {#if data.blocks.length === 0} +
+

No content blocks yet. Add one above.

+
+ {/if} +
+
diff --git a/src/routes/admin/events/+page.server.ts b/src/routes/admin/events/+page.server.ts new file mode 100644 index 0000000..a0794a6 --- /dev/null +++ b/src/routes/admin/events/+page.server.ts @@ -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 }; +}; diff --git a/src/routes/admin/events/+page.svelte b/src/routes/admin/events/+page.svelte new file mode 100644 index 0000000..7d0b7e4 --- /dev/null +++ b/src/routes/admin/events/+page.svelte @@ -0,0 +1,57 @@ + + +Events — Admin + +
+
+

Events

+ + Add Event + +
+ + {#if data.events.length === 0} +
+

No events yet.

+
+ {:else} +
+ + + + + + + + + + + + {#each data.events as event} + + + + + + + + {/each} + +
TitleLocationDateStatusActions
{event.title}{event.location ?? '—'}{formatDate(event.startDate)} + {#if event.published} + Published + {:else} + Draft + {/if} + + Edit +
+
+ {/if} +
diff --git a/src/routes/admin/events/[id]/+page.server.ts b/src/routes/admin/events/[id]/+page.server.ts new file mode 100644 index 0000000..02c3436 --- /dev/null +++ b/src/routes/admin/events/[id]/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/admin/events/[id]/+page.svelte b/src/routes/admin/events/[id]/+page.svelte new file mode 100644 index 0000000..b03b99b --- /dev/null +++ b/src/routes/admin/events/[id]/+page.svelte @@ -0,0 +1,118 @@ + + +{event.title} — Admin + +
+
+ + + +

{event.title}

+
+ + {#if form?.error} +
{form.error}
+ {/if} + {#if showSuccess} +
+ + Event saved. +
+ {/if} + +
+
handleEnhance('update')}> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+
+ +
+
+
+ +
+

Danger Zone

+ {#if confirmDelete} +

Are you sure? This cannot be undone.

+
handleEnhance('delete')}> +
+ + +
+
+ {:else} + + {/if} +
+
diff --git a/src/routes/admin/events/new/+page.server.ts b/src/routes/admin/events/new/+page.server.ts new file mode 100644 index 0000000..31b907e --- /dev/null +++ b/src/routes/admin/events/new/+page.server.ts @@ -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}`); + } +}; diff --git a/src/routes/admin/events/new/+page.svelte b/src/routes/admin/events/new/+page.svelte new file mode 100644 index 0000000..1d2c62d --- /dev/null +++ b/src/routes/admin/events/new/+page.svelte @@ -0,0 +1,74 @@ + + +New Event — Admin + +
+
+ + + +

New Event

+
+ + {#if form?.error} +
{form.error}
+ {/if} + +
+
{ + submitting = true; + return async ({ update }) => { submitting = false; await update(); }; + }}> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+
+ + Cancel +
+
+
+
diff --git a/src/routes/admin/happy-tails/+page.server.ts b/src/routes/admin/happy-tails/+page.server.ts new file mode 100644 index 0000000..24e48e0 --- /dev/null +++ b/src/routes/admin/happy-tails/+page.server.ts @@ -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 }; +}; diff --git a/src/routes/admin/happy-tails/+page.svelte b/src/routes/admin/happy-tails/+page.svelte new file mode 100644 index 0000000..5e39b24 --- /dev/null +++ b/src/routes/admin/happy-tails/+page.svelte @@ -0,0 +1,64 @@ + + +Happy Tails — Admin + +
+
+

Happy Tails

+ + Add Story + +
+ + {#if data.stories.length === 0} +
+

No happy tails stories yet.

+
+ {:else} +
+ + + + + + + + + + + + + {#each data.stories as story} + + + + + + + + + {/each} + +
TitleAdopterPetStatusDateActions
{story.title}{story.adopterName ?? '—'}{story.petName ?? '—'} +
+ {#if story.published} + Published + {:else} + Draft + {/if} + {#if story.featured} + Featured + {/if} +
+
{formatDate(story.createdAt)} + Edit +
+
+ {/if} +
diff --git a/src/routes/admin/happy-tails/[id]/+page.server.ts b/src/routes/admin/happy-tails/[id]/+page.server.ts new file mode 100644 index 0000000..9a80540 --- /dev/null +++ b/src/routes/admin/happy-tails/[id]/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/admin/happy-tails/[id]/+page.svelte b/src/routes/admin/happy-tails/[id]/+page.svelte new file mode 100644 index 0000000..a5ec368 --- /dev/null +++ b/src/routes/admin/happy-tails/[id]/+page.svelte @@ -0,0 +1,114 @@ + + +{story.title} — Admin + +
+
+ + + +

{story.title}

+
+ + {#if form?.error} +
{form.error}
+ {/if} + {#if showSuccess} +
+ + Story saved. +
+ {/if} + +
+
handleEnhance('update')}> +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ +
+

Danger Zone

+ {#if confirmDelete} +

Are you sure? This cannot be undone.

+
handleEnhance('delete')}> +
+ + +
+
+ {:else} + + {/if} +
+
diff --git a/src/routes/admin/happy-tails/new/+page.server.ts b/src/routes/admin/happy-tails/new/+page.server.ts new file mode 100644 index 0000000..2c8c66b --- /dev/null +++ b/src/routes/admin/happy-tails/new/+page.server.ts @@ -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}`); + } +}; diff --git a/src/routes/admin/happy-tails/new/+page.svelte b/src/routes/admin/happy-tails/new/+page.svelte new file mode 100644 index 0000000..25d2663 --- /dev/null +++ b/src/routes/admin/happy-tails/new/+page.svelte @@ -0,0 +1,87 @@ + + +New Happy Tail — Admin + +
+
+ + + +

New Happy Tail

+
+ + {#if form?.error} +
{form.error}
+ {/if} + +
+
{ + submitting = true; + return async ({ update }) => { submitting = false; await update(); }; + }}> +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + Cancel +
+
+
+
diff --git a/src/routes/admin/messages/+page.server.ts b/src/routes/admin/messages/+page.server.ts new file mode 100644 index 0000000..3050e5d --- /dev/null +++ b/src/routes/admin/messages/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/admin/messages/+page.svelte b/src/routes/admin/messages/+page.svelte new file mode 100644 index 0000000..3091628 --- /dev/null +++ b/src/routes/admin/messages/+page.svelte @@ -0,0 +1,94 @@ + + +Messages — Admin + +
+
+
+

Contact Messages

+ {#if data.unreadCount > 0} +

{data.unreadCount} unread

+ {/if} +
+
+ + {#if data.messages.length === 0} +
+

No messages yet.

+
+ {:else} +
+ {#each data.messages as msg} +
+
toggleExpand(msg.id)} role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && toggleExpand(msg.id)}> +
+ {#if !msg.read} + + {:else} + + {/if} +
+

{msg.name} <{msg.email}>

+

{msg.subject ?? '(no subject)'}

+
+
+
+ {formatDate(msg.createdAt)} + +
+
+ + {#if expanded.has(msg.id)} +
+
{msg.message}
+
+ Reply + {#if msg.read} +
{ + submitting = `unread-${msg.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
+ {:else} +
{ + submitting = `read-${msg.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
+ {/if} +
{ + submitting = `delete-${msg.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
+
+
+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/src/routes/admin/newsletter/+page.server.ts b/src/routes/admin/newsletter/+page.server.ts new file mode 100644 index 0000000..bdf82f8 --- /dev/null +++ b/src/routes/admin/newsletter/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/admin/newsletter/+page.svelte b/src/routes/admin/newsletter/+page.svelte new file mode 100644 index 0000000..8e790f9 --- /dev/null +++ b/src/routes/admin/newsletter/+page.svelte @@ -0,0 +1,113 @@ + + +Newsletter Subscribers — Admin + +
+
+
+

Newsletter Subscribers

+

{data.activeCount} active subscribers

+
+ +
+ + {#if form?.error} +
{form.error}
+ {/if} + + {#if showAddForm} +
+
{ + submitting = 'add'; + return async ({ update, result }) => { + submitting = null; + await update(); + if (result.type === 'success') showAddForm = false; + }; + }}> +
+
+ + +
+
+ + +
+
+ +
+
+ {/if} + + {#if activeSubscribers.length === 0} +
+

No active subscribers.

+
+ {:else} +
+ + + + + + + + + + + {#each activeSubscribers as sub} + + + + + + + {/each} + +
EmailNameSubscribedActions
{sub.email}{sub.name ?? '—'}{formatDate(sub.subscribedAt)} +
{ + submitting = `remove-${sub.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
+
+
+ {/if} + + {#if unsubscribed.length > 0} +
+ Unsubscribed ({unsubscribed.length}) +
+ {#each unsubscribed as sub} +
+ {sub.email} + {sub.unsubscribedAt ? formatDate(sub.unsubscribedAt) : ''} +
+ {/each} +
+
+ {/if} +