From 218a3a3cb03dd19ae7567282b4f1d10b2cd287b1 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Mon, 4 May 2026 09:40:34 -0500 Subject: [PATCH] add categories admin page --- src/lib/components/admin/AdminForm.svelte | 119 ---------- src/lib/components/admin/AdminTable.svelte | 156 ------------- .../components/admin/RelatedHasMany.svelte | 61 ----- src/lib/components/admin/RelatedHasOne.svelte | 39 ---- .../(protected)/admin/+layout.server.ts | 9 + src/routes/(protected)/admin/+page.server.ts | 11 - src/routes/(protected)/admin/+page.svelte | 22 +- .../(protected)/admin/[model]/+page.server.ts | 208 ------------------ .../(protected)/admin/[model]/+page.svelte | 85 ------- .../admin/[model]/[id]/+page.server.ts | 191 ---------------- .../admin/[model]/[id]/+page.svelte | 129 ----------- .../admin/categories/+page.server.ts | 10 + .../(protected)/admin/categories/+page.svelte | 39 ++++ 13 files changed, 59 insertions(+), 1020 deletions(-) delete mode 100644 src/lib/components/admin/AdminForm.svelte delete mode 100644 src/lib/components/admin/AdminTable.svelte delete mode 100644 src/lib/components/admin/RelatedHasMany.svelte delete mode 100644 src/lib/components/admin/RelatedHasOne.svelte delete mode 100644 src/routes/(protected)/admin/+page.server.ts delete mode 100644 src/routes/(protected)/admin/[model]/+page.server.ts delete mode 100644 src/routes/(protected)/admin/[model]/+page.svelte delete mode 100644 src/routes/(protected)/admin/[model]/[id]/+page.server.ts delete mode 100644 src/routes/(protected)/admin/[model]/[id]/+page.svelte create mode 100644 src/routes/(protected)/admin/categories/+page.server.ts create mode 100644 src/routes/(protected)/admin/categories/+page.svelte diff --git a/src/lib/components/admin/AdminForm.svelte b/src/lib/components/admin/AdminForm.svelte deleted file mode 100644 index 90a3df0..0000000 --- a/src/lib/components/admin/AdminForm.svelte +++ /dev/null @@ -1,119 +0,0 @@ - - -
- {#if record?.id || form?.id} - - {/if} - - {#each fields as field} - {@const type = fieldTypes[field] ?? 'String'} - {@const label = fieldNameToLabel(field)} - -
- - - {#if isRelation(field)} - - {:else if type === 'Boolean'} - - {:else if type === 'DateTime'} - - {:else if type === 'Int' || type === 'Float'} - {#if field === 'price'} - - {:else} - - {/if} - {:else if type === 'String'} - - {:else} - - {/if} -
- {/each} - - -
diff --git a/src/lib/components/admin/AdminTable.svelte b/src/lib/components/admin/AdminTable.svelte deleted file mode 100644 index e2a8b73..0000000 --- a/src/lib/components/admin/AdminTable.svelte +++ /dev/null @@ -1,156 +0,0 @@ - - -
-
- - - {#if search} - - {/if} - - - {filteredAndSorted().length} of {records.length} - -
- -
- - - - {#each listFields as field} - - {/each} - {#if !listOnly} - - {/if} - - - - {#each filteredAndSorted() as record} - - {#each listFields as field} - - {/each} - {#if !listOnly} - - {/if} - - {:else} - - - - {/each} - -
- -
- {formatCell(resolveFieldValue(record, field))} - - - Edit - -
- {#if search} - No records match your search - {:else} - No records yet - {/if} -
-
-
diff --git a/src/lib/components/admin/RelatedHasMany.svelte b/src/lib/components/admin/RelatedHasMany.svelte deleted file mode 100644 index e446632..0000000 --- a/src/lib/components/admin/RelatedHasMany.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - -
-

- {label} - ({records.length}) -

- {#if records.length > 0} -
- - - - {#each displayFields as field} - - {/each} - {#if editSlug} - - {/if} - - - - {#each records as record} - - {#each displayFields as field} - - {/each} - {#if editSlug} - - {/if} - - {/each} - -
- {fieldNameToLabel(field)} -
{record[field] ?? '—'} - - Edit - -
-
- {:else} -

No {label.toLowerCase()} found

- {/if} -
diff --git a/src/lib/components/admin/RelatedHasOne.svelte b/src/lib/components/admin/RelatedHasOne.svelte deleted file mode 100644 index 5774ad7..0000000 --- a/src/lib/components/admin/RelatedHasOne.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - -
-
-

{label}

- {#if editSlug && record?.id} - - Edit - - {/if} -
- {#if record} -
- {#each displayFields as field} -
{fieldNameToLabel(field)}
-
{record[field] ?? '—'}
- {/each} -
- {:else} -

No {label.toLowerCase()} found

- {/if} -
diff --git a/src/routes/(protected)/admin/+layout.server.ts b/src/routes/(protected)/admin/+layout.server.ts index 0dafd31..92d4a97 100644 --- a/src/routes/(protected)/admin/+layout.server.ts +++ b/src/routes/(protected)/admin/+layout.server.ts @@ -3,6 +3,14 @@ import { error } from '@sveltejs/kit'; import { prisma } from '$lib/db'; import { canAccessAdmin } from '$lib/svelte-admin/roles'; +/** + * This Layout Server-Side page checks if the user is logged in + * and if that user has a sufficient permission level to view any pages + * rendering this layout, redirecting them to a 403 error page if not. + * It also makes the `adminUser` (the currently logged in user) available + * to all of those pages via page data. + */ + export const load: LayoutServerLoad = async ({ locals }) => { const user = await prisma.user.findUnique({ where: { id: locals.user?.id }, @@ -10,6 +18,7 @@ export const load: LayoutServerLoad = async ({ locals }) => { }); if (!user || !canAccessAdmin(user.roleId)) { + console.log(`Attempted to access /admin via ${user?.id} : ${user?.name} with a role of ${user?.role}`); throw error(403, 'not authorized'); } diff --git a/src/routes/(protected)/admin/+page.server.ts b/src/routes/(protected)/admin/+page.server.ts deleted file mode 100644 index 697d2fa..0000000 --- a/src/routes/(protected)/admin/+page.server.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getAllModelConfigs } from '$lib/svelte-admin/registry'; - -export const load: PageServerLoad = async () => { - const models = getAllModelConfigs().map((m) => ({ - slug: m.slug, - displayName: m.displayName - })); - - return { models }; -}; diff --git a/src/routes/(protected)/admin/+page.svelte b/src/routes/(protected)/admin/+page.svelte index 7209b51..4caf1c0 100644 --- a/src/routes/(protected)/admin/+page.svelte +++ b/src/routes/(protected)/admin/+page.svelte @@ -1,21 +1 @@ - - -
-

Admin Panel

- - -
+

Admin Dashboard

\ No newline at end of file diff --git a/src/routes/(protected)/admin/[model]/+page.server.ts b/src/routes/(protected)/admin/[model]/+page.server.ts deleted file mode 100644 index 0d59094..0000000 --- a/src/routes/(protected)/admin/[model]/+page.server.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { error, fail, type Actions, type RequestEvent } from '@sveltejs/kit'; -import { getModelConfig } from '$lib/svelte-admin/registry'; -import { getDelegate } from '$lib/svelte-admin/prisma-delegate'; -import { extractFormData } from '$lib/svelte-admin/form-data'; -import { prisma } from '$lib/db'; -import type { RelationOptionConfig } from '$lib/svelte-admin/types'; -import { generateLicenseKey } from '$lib/utils'; - -function getCategoryDisplayName(category: Record | null | undefined) { - if (!category) return ''; - const name = String(category.name ?? ''); - const parent = category.parent as Record | undefined; - if (parent?.name) return `${String(parent.name)}/${name}`; - return name; -} - -function parseIntId(value: unknown): number | null { - if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value; - if (typeof value === 'string' && value.trim() !== '') { - const parsed = parseInt(value, 10); - return Number.isNaN(parsed) || parsed <= 0 ? null : parsed; - } - return null; -} - -async function findOrCreateCategoryByLabel(label: string): Promise { - const normalized = label.trim(); - if (!normalized) throw new Error('Invalid category label'); - const [parentName, childName] = normalized.split('/').map((v) => v.trim()); - - if (childName) { - let parent = await prisma.proclamationCategory.findFirst({ - where: { name: parentName, parentId: null } - }); - if (!parent) { - parent = await prisma.proclamationCategory.create({ data: { name: parentName } }); - } - - let child = await prisma.proclamationCategory.findFirst({ - where: { name: childName, parentId: parent.id } - }); - if (!child) { - child = await prisma.proclamationCategory.create({ - data: { name: childName, parentId: parent.id } - }); - } - return child.id; - } - - let category = await prisma.proclamationCategory.findFirst({ - where: { name: normalized, parentId: null } - }); - if (!category) { - category = await prisma.proclamationCategory.create({ data: { name: normalized } }); - } - return category.id; -} - -async function resolveCategoryIdFromFormData( - formData: FormData, - categoryField: string -): Promise { - const categoryValue = formData.get(categoryField); - const categoryLabelValue = formData.get(`${categoryField}Label`); - - const parsedId = parseIntId(categoryValue); - if (parsedId) return parsedId; - - const label = String(categoryLabelValue ?? categoryValue ?? '').trim(); - if (!label) return null; - - return await findOrCreateCategoryByLabel(label); -} - -async function loadRelationOptions( - relationOptions: Record | undefined -) { - const result: Record = {}; - if (!relationOptions) return result; - - for (const [field, config] of Object.entries(relationOptions)) { - const key = config.model.charAt(0).toLowerCase() + config.model.slice(1); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const delegate = (prisma as any)[key]; - if (!delegate) continue; - - const shouldIncludeParent = config.model === 'ProclamationCategory'; - const select: Record = { - [config.valueField]: true, - [config.labelField]: true - }; - if (shouldIncludeParent) { - (select as Record).parent = { select: { name: true } }; - } - - const records = await delegate.findMany({ - select - }); - - result[field] = records.map((r: Record) => { - const value = r[config.valueField] as string | number; - const label = - config.model === 'ProclamationCategory' - ? getCategoryDisplayName(r) - : String(r[config.labelField]); - return { - label, - value - }; - }); - } - - return result; -} - -export const load: PageServerLoad = async ({ params, parent }) => { - const config = getModelConfig(params.model); - if (!config) throw error(404, `Model "${params.model}" not found`); - - const { adminUser } = await parent(); - const delegate = getDelegate(config); - - const where = config.accessFilter ? config.accessFilter(adminUser.roleId) : undefined; - - let records = await delegate.findMany({ - ...(config.include ? { include: config.include } : {}), - ...(where ? { where } : {}) - }); - - if (config.modelName === 'Proclamation') { - records = records.map((r: Record) => { - const category = r.category as Record | undefined; - if (category) { - (category as Record).displayName = getCategoryDisplayName(category); - } - return r; - }); - } - - if (config.modelName === 'ProclamationCategory') { - records = records.map((r: Record) => { - (r as Record).displayName = getCategoryDisplayName( - r as Record - ); - return r; - }); - } - - const relationOptionsData = await loadRelationOptions(config.relationOptions); - - return { - config: { - slug: config.slug, - displayName: config.displayName, - displayNameSingular: config.displayNameSingular, - listFields: config.listFields, - searchFields: config.searchFields, - formFields: config.formFields, - fieldTypes: config.fieldTypes, - relationOptions: config.relationOptions, - listOnly: config.listOnly ?? false - }, - records: JSON.parse(JSON.stringify(records)), - relationOptionsData - }; -}; - -export const actions: Actions = { - create: async ({ request, params }: RequestEvent) => { - const config = getModelConfig(params.model!); - if (!config) return fail(400, { error: 'Invalid model' }); - - const formData = await request.formData(); - const data = extractFormData(formData, config.formFields, config.fieldTypes); - - console.log(data); - - if (data.price === null) { - data.price = 0.99; - } - - console.log(data); - - if (config.slug === 'license-keys') { - (data as Record).key = generateLicenseKey(); - } - - if (config.modelName === 'Proclamation') { - const resolvedCategoryId = await resolveCategoryIdFromFormData(formData, 'categoryId'); - if (resolvedCategoryId != null) { - (data as Record).categoryId = resolvedCategoryId; - } - } - - try { - const delegate = getDelegate(config); - await delegate.create({ data }); - return { success: true }; - } catch (err) { - console.error('Admin create error:', err); - return fail(500, { - error: 'Failed to create record. Please check your input and try again.', - ...Object.fromEntries(formData) - }); - } - } -}; diff --git a/src/routes/(protected)/admin/[model]/+page.svelte b/src/routes/(protected)/admin/[model]/+page.svelte deleted file mode 100644 index 57a7f36..0000000 --- a/src/routes/(protected)/admin/[model]/+page.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - -
-
-
- ← Admin -

{data.config.displayName}

-
- -
- - {#if data.records.length > 0} - - {:else} -
-
-

No {data.config.displayName.toLowerCase()} yet

-

Click "Create +" to add one

-
-
- {/if} -
- -{#if showModal} -
-
-
-
-

- Create {data.config.displayNameSingular} -

- -
- - {#if form?.error} -
- {form.error} -
- {/if} - - -
-
-
-{/if} diff --git a/src/routes/(protected)/admin/[model]/[id]/+page.server.ts b/src/routes/(protected)/admin/[model]/[id]/+page.server.ts deleted file mode 100644 index b9eab3e..0000000 --- a/src/routes/(protected)/admin/[model]/[id]/+page.server.ts +++ /dev/null @@ -1,191 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { error, fail, redirect, type Actions, type RequestEvent } from '@sveltejs/kit'; -import { getModelConfig } from '$lib/svelte-admin/registry'; -import { getDelegate, parseId } from '$lib/svelte-admin/prisma-delegate'; -import { extractFormData } from '$lib/svelte-admin/form-data'; -import { prisma } from '$lib/db'; -import type { RelationOptionConfig } from '$lib/svelte-admin/types'; - -function getCategoryDisplayName(category: Record | null | undefined) { - if (!category) return ''; - const name = String(category.name ?? ''); - const parent = category.parent as Record | undefined; - if (parent?.name) return `${String(parent.name)}/${name}`; - return name; -} - -function parseIntId(value: unknown): number | null { - if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value; - if (typeof value === 'string' && value.trim() !== '') { - const parsed = parseInt(value, 10); - return Number.isNaN(parsed) || parsed <= 0 ? null : parsed; - } - return null; -} - -async function findOrCreateCategoryByLabel(label: string): Promise { - const normalized = label.trim(); - if (!normalized) throw new Error('Invalid category label'); - const [parentName, childName] = normalized.split('/').map((v) => v.trim()); - - if (childName) { - let parent = await prisma.proclamationCategory.findFirst({ - where: { name: parentName, parentId: null } - }); - if (!parent) { - parent = await prisma.proclamationCategory.create({ data: { name: parentName } }); - } - - let child = await prisma.proclamationCategory.findFirst({ - where: { name: childName, parentId: parent.id } - }); - if (!child) { - child = await prisma.proclamationCategory.create({ - data: { name: childName, parentId: parent.id } - }); - } - return child.id; - } - - let category = await prisma.proclamationCategory.findFirst({ - where: { name: normalized, parentId: null } - }); - if (!category) { - category = await prisma.proclamationCategory.create({ data: { name: normalized } }); - } - return category.id; -} - -async function resolveCategoryIdFromFormData(formData: FormData, categoryField: string): Promise { - const categoryValue = formData.get(categoryField); - const categoryLabelValue = formData.get(`${categoryField}Label`); - - const parsedId = parseIntId(categoryValue); - if (parsedId) return parsedId; - - const label = String(categoryLabelValue ?? categoryValue ?? '').trim(); - if (!label) return null; - - return await findOrCreateCategoryByLabel(label); -} - -async function loadRelationOptions( - relationOptions: Record | undefined -) { - const result: Record = {}; - if (!relationOptions) return result; - - for (const [field, config] of Object.entries(relationOptions)) { - const key = config.model.charAt(0).toLowerCase() + config.model.slice(1); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const delegate = (prisma as any)[key]; - if (!delegate) continue; - - const shouldIncludeParent = config.model === 'ProclamationCategory'; - const select: Record = { - [config.valueField]: true, - [config.labelField]: true - }; - if (shouldIncludeParent) { - (select as Record).parent = { select: { name: true } }; - } - - const records = await delegate.findMany({ - select - }); - - result[field] = records.map((r: Record) => { - const value = r[config.valueField] as string | number; - const label = - config.model === 'ProclamationCategory' - ? getCategoryDisplayName(r) - : String(r[config.labelField]); - return { - label, - value - }; - }); - } - - return result; -} - -export const load: PageServerLoad = async ({ params }) => { - const config = getModelConfig(params.model); - if (!config) throw error(404, `Model "${params.model}" not found`); - - const delegate = getDelegate(config); - const id = parseId(config, params.id); - - const record = await delegate.findUnique({ - where: { id }, - ...(config.include ? { include: config.include } : {}) - }); - - if (!record) throw error(404, 'Record not found'); - - const relationOptionsData = await loadRelationOptions(config.relationOptions); - - return { - config: { - slug: config.slug, - displayName: config.displayName, - displayNameSingular: config.displayNameSingular, - listFields: config.listFields, - formFields: config.formFields, - readonlyFields: config.readonlyFields, - fieldTypes: config.fieldTypes, - relationOptions: config.relationOptions, - relatedRecords: config.relatedRecords - }, - record: JSON.parse(JSON.stringify(record)), - relationOptionsData - }; -}; - -export const actions: Actions = { - update: async ({ request, params }: RequestEvent) => { - const config = getModelConfig(params.model!); - if (!config) return fail(400, { error: 'Invalid model' }); - - const formData = await request.formData(); - const id = parseId(config, params.id!); - const data = extractFormData(formData, config.formFields, config.fieldTypes); - - if (config.modelName === 'Proclamation') { - const resolvedCategoryId = await resolveCategoryIdFromFormData(formData, 'categoryId'); - if (resolvedCategoryId != null) { - (data as Record).categoryId = resolvedCategoryId; - } - } - - try { - const delegate = getDelegate(config); - await delegate.update({ where: { id }, data }); - return { success: true }; - } catch (err) { - console.error('Admin update error:', err); - return fail(500, { - error: 'Failed to update record. Please check your input and try again.', - ...Object.fromEntries(formData) - }); - } - }, - - delete: async ({ params }: RequestEvent) => { - const config = getModelConfig(params.model!); - if (!config) return fail(400, { error: 'Invalid model' }); - - const id = parseId(config, params.id!); - - try { - const delegate = getDelegate(config); - await delegate.delete({ where: { id } }); - } catch (err) { - console.error('Admin delete error:', err); - return fail(500, { error: 'Failed to delete record.' }); - } - - redirect(303, `/admin/${config.slug}`); - } -}; diff --git a/src/routes/(protected)/admin/[model]/[id]/+page.svelte b/src/routes/(protected)/admin/[model]/[id]/+page.svelte deleted file mode 100644 index e0258b4..0000000 --- a/src/routes/(protected)/admin/[model]/[id]/+page.svelte +++ /dev/null @@ -1,129 +0,0 @@ - - -
- - ← Back to {data.config.displayName} - - -

- Edit {data.config.displayNameSingular} -

- - {#if form?.error} -
- {form.error} -
- {/if} - - {#if form?.success} -
- Record updated successfully! Redirecting in {remainder} seconds. -
- {/if} - - {#if data.config.readonlyFields && data.config.readonlyFields.length > 0} -
-

Read-only Fields

-
- {#each data.config.readonlyFields as field} -
{fieldNameToLabel(field)}
-
{data.record[field] ?? '—'}
- {/each} -
-
- {/if} - -
- -
- - {#if data.config.relatedRecords} - {#each data.config.relatedRecords as rel} - {#if rel.type === 'has-one'} - - {:else} - - {/if} - {/each} - {/if} - -
- {#if showDeleteConfirm} -
-

- Are you sure you want to delete this {data.config.displayNameSingular.toLowerCase()}? This - action cannot be undone. -

-
-
- -
- -
-
- {:else} - - {/if} -
-
diff --git a/src/routes/(protected)/admin/categories/+page.server.ts b/src/routes/(protected)/admin/categories/+page.server.ts new file mode 100644 index 0000000..e92438f --- /dev/null +++ b/src/routes/(protected)/admin/categories/+page.server.ts @@ -0,0 +1,10 @@ +import type { PageServerLoad } from "./$types"; +import { prisma } from "$lib/db"; + +export const load: PageServerLoad = async ({ locals }) => { + console.log(locals.user?.name); + // Load proclamation categories + const categories = await prisma.proclamationCategory.findMany(); + console.log(categories); + return { categories }; +} \ No newline at end of file diff --git a/src/routes/(protected)/admin/categories/+page.svelte b/src/routes/(protected)/admin/categories/+page.svelte new file mode 100644 index 0000000..01f0348 --- /dev/null +++ b/src/routes/(protected)/admin/categories/+page.svelte @@ -0,0 +1,39 @@ + + + + + + + + + + + + + {#if data.categories} + {#each data.categories as category, i (category.id)} + + + + {#if category.parentId} + + {:else} + + {/if} + + {/each} + {/if} + +
NameParent Category
{category.name}{getCategoryNameFromId(category.parentId)}