add categories admin page

This commit is contained in:
NeilClack
2026-05-04 09:40:34 -05:00
parent 1615f07974
commit 218a3a3cb0
13 changed files with 59 additions and 1020 deletions
-119
View File
@@ -1,119 +0,0 @@
<script lang="ts">
import { fieldNameToLabel } from '$lib/svelte-admin/introspect';
import type { PrismaScalarType, RelationOptionConfig } from '$lib/svelte-admin/types';
import ComboBox from './ComboBox.svelte';
import TipTap from '$lib/components/tiptap/TipTap.svelte';
type RelationOptionsData = Record<string, { label: string; value: string | number }[]>;
type Props = {
fields: string[];
fieldTypes: Record<string, PrismaScalarType>;
relationOptions?: Record<string, RelationOptionConfig>;
relationOptionsData?: RelationOptionsData;
record?: Record<string, unknown>;
form?: Record<string, unknown>;
action: string;
submitLabel?: string;
};
let {
fields,
fieldTypes,
relationOptions = {},
relationOptionsData = {},
record,
form,
action,
submitLabel = 'Save'
}: Props = $props();
$inspect(fields);
function getValue(field: string): unknown {
return form?.[field] ?? record?.[field] ?? '';
}
function isRelation(field: string): boolean {
return field in relationOptions;
}
</script>
<form method="POST" {action} class="space-y-5">
{#if record?.id || form?.id}
<input type="hidden" name="id" value={String(form?.id ?? record?.id)} />
{/if}
{#each fields as field}
{@const type = fieldTypes[field] ?? 'String'}
{@const label = fieldNameToLabel(field)}
<div>
<label for={field} class="mb-1 block text-sm font-medium text-gray-700">{label}</label>
{#if isRelation(field)}
<ComboBox
id={field}
name={field}
options={relationOptionsData[field] ?? []}
placeholder="Select {label}"
value={getValue(field) as string | number | undefined}
/>
{:else if type === 'Boolean'}
<input
type="checkbox"
id={field}
name={field}
checked={getValue(field) === true ||
getValue(field) === 'on' ||
getValue(field) === 'true'}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
{:else if type === 'DateTime'}
<input
type="datetime-local"
id={field}
name={field}
value={getValue(field) ? String(getValue(field)).slice(0, 16) : ''}
class="w-full rounded-lg border border-gray-300 px-3 py-2 transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
{:else if type === 'Int' || type === 'Float'}
{#if field === 'price'}
<input
type="number"
id={field}
name={field}
placeholder="0.99"
value={getValue(field) ?? '0.99'}
step={type === 'Float' ? '0.01' : '1'}
class="w-full rounded-lg border border-gray-300 px-3 py-2 transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
{:else}
<input
type="number"
id={field}
name={field}
value={getValue(field) ?? ''}
step={type === 'Float' ? '0.01' : '1'}
class="w-full rounded-lg border border-gray-300 px-3 py-2 transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
{/if}
{:else if type === 'String'}
<input
type="text"
id={field}
name={field}
value={getValue(field) ?? ''}
class="w-full rounded-lg border border-gray-300 px-3 py-2 transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
{:else}
<TipTap name={field} initialContent={String(getValue(field) ?? '')} />
{/if}
</div>
{/each}
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2.5 font-medium text-white transition-colors hover:bg-blue-700 focus:ring-4 focus:ring-blue-200"
>
{submitLabel}
</button>
</form>
-156
View File
@@ -1,156 +0,0 @@
<script lang="ts">
import { fieldNameToLabel, resolveFieldValue } from '$lib/svelte-admin/introspect';
type Props = {
records: Record<string, unknown>[];
listFields: string[];
searchFields: string[];
modelSlug: string;
listOnly?: boolean;
};
let { records, listFields, searchFields, modelSlug, listOnly = false }: Props = $props();
let search = $state('');
let defaultSortKey = $derived(listFields[0] ?? '');
let sortKey = $state('');
let sortDirection = $state<'asc' | 'desc'>('asc');
let effectiveSortKey = $derived(sortKey || defaultSortKey);
function formatCell(value: unknown): string {
if (value == null) return '—';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (value instanceof Date) return value.toLocaleString();
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return new Date(value).toLocaleString();
}
if (typeof value === 'number' && !Number.isInteger(value)) return value.toFixed(2);
return String(value);
}
const filteredAndSorted = $derived(() => {
let result = records;
if (search) {
const searchLower = search.toLowerCase();
result = result.filter((r) =>
searchFields.some((field) => {
const val = resolveFieldValue(r, field);
return val != null && String(val).toLowerCase().includes(searchLower);
})
);
}
result = [...result].sort((a, b) => {
const aVal = resolveFieldValue(a, effectiveSortKey);
const bVal = resolveFieldValue(b, effectiveSortKey);
const aStr = aVal == null ? '' : String(aVal).toLowerCase();
const bStr = bVal == null ? '' : String(bVal).toLowerCase();
const aNum = Number(aVal);
const bNum = Number(bVal);
if (!isNaN(aNum) && !isNaN(bNum)) {
return sortDirection === 'asc' ? aNum - bNum : bNum - aNum;
}
if (aStr < bStr) return sortDirection === 'asc' ? -1 : 1;
if (aStr > bStr) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
return result;
});
function toggleSort(key: string) {
if (effectiveSortKey === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDirection = 'asc';
}
}
function getSortIndicator(key: string) {
if (effectiveSortKey !== key) return '↕';
return sortDirection === 'asc' ? '↑' : '↓';
}
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search..."
bind:value={search}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
{#if search}
<button
type="button"
onclick={() => (search = '')}
class="rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-700"
>
Clear
</button>
{/if}
<span class="ml-auto text-sm text-gray-500">
{filteredAndSorted().length} of {records.length}
</span>
</div>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-left text-sm">
<thead>
<tr class="border-b border-gray-200 bg-gray-50">
{#each listFields as field}
<th class="px-4 py-3 font-medium text-gray-700">
<button
type="button"
class="flex items-center gap-1 hover:text-gray-900"
onclick={() => toggleSort(field)}
>
{fieldNameToLabel(field.includes('.') ? field.split('.')[0] : field)}
<span class="text-xs text-gray-400">{getSortIndicator(field)}</span>
</button>
</th>
{/each}
{#if !listOnly}
<th class="px-4 py-3 font-medium text-gray-700"></th>
{/if}
</tr>
</thead>
<tbody>
{#each filteredAndSorted() as record}
<tr class="border-b border-gray-100 hover:bg-gray-50">
{#each listFields as field}
<td class="px-4 py-3 text-gray-600">
{formatCell(resolveFieldValue(record, field))}
</td>
{/each}
{#if !listOnly}
<td class="px-4 py-3 text-right">
<a
href="/admin/{modelSlug}/{record.id}"
class="text-blue-600 hover:text-blue-800 hover:underline"
>
Edit
</a>
</td>
{/if}
</tr>
{:else}
<tr>
<td colspan={listFields.length + (listOnly ? 0 : 1)} class="px-4 py-8 text-center text-gray-500">
{#if search}
No records match your search
{:else}
No records yet
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
@@ -1,61 +0,0 @@
<script lang="ts">
import { fieldNameToLabel } from '$lib/svelte-admin/introspect';
let {
label,
records,
displayFields,
editSlug
}: {
label: string;
records: Record<string, unknown>[];
displayFields: string[];
editSlug?: string;
} = $props();
</script>
<div class="mt-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<h2 class="mb-2 text-sm font-medium text-gray-700">
{label}
<span class="font-normal text-gray-400">({records.length})</span>
</h2>
{#if records.length > 0}
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-gray-200">
{#each displayFields as field}
<th class="pb-2 pr-4 font-medium text-gray-500">
{fieldNameToLabel(field)}
</th>
{/each}
{#if editSlug}
<th class="pb-2 font-medium text-gray-500"></th>
{/if}
</tr>
</thead>
<tbody>
{#each records as record}
<tr class="border-b border-gray-100 last:border-0">
{#each displayFields as field}
<td class="py-1.5 pr-4 text-gray-900">{record[field] ?? '—'}</td>
{/each}
{#if editSlug}
<td class="py-1.5 text-right">
<a
href="/admin/{editSlug}/{record.id}"
class="text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
Edit
</a>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-sm text-gray-400">No {label.toLowerCase()} found</p>
{/if}
</div>
@@ -1,39 +0,0 @@
<script lang="ts">
import { fieldNameToLabel } from '$lib/svelte-admin/introspect';
let {
label,
record,
displayFields,
editSlug
}: {
label: string;
record: Record<string, unknown> | null;
displayFields: string[];
editSlug?: string;
} = $props();
</script>
<div class="mt-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="mb-2 flex items-center justify-between">
<h2 class="text-sm font-medium text-gray-700">{label}</h2>
{#if editSlug && record?.id}
<a
href="/admin/{editSlug}/{record.id}"
class="text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
Edit
</a>
{/if}
</div>
{#if record}
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
{#each displayFields as field}
<dt class="text-gray-500">{fieldNameToLabel(field)}</dt>
<dd class="text-gray-900">{record[field] ?? '—'}</dd>
{/each}
</dl>
{:else}
<p class="text-sm text-gray-400">No {label.toLowerCase()} found</p>
{/if}
</div>
@@ -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');
}
@@ -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 };
};
+1 -21
View File
@@ -1,21 +1 @@
<script lang="ts">
import type { PageData } from './$types';
let { data } = $props<{ data: PageData }>();
</script>
<div class="mb-4 flex h-full min-h-0 flex-col gap-6 p-6">
<h1 class="text-2xl font-semibold text-gray-900">Admin Panel</h1>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each data.models as model}
<a
href="/admin/{model.slug}"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
>
<h2 class="text-lg font-medium text-gray-900">{model.displayName}</h2>
<p class="mt-1 text-sm text-gray-500">Manage {model.displayName.toLowerCase()}</p>
</a>
{/each}
</div>
</div>
<h1>Admin Dashboard</h1>
@@ -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<string, unknown> | null | undefined) {
if (!category) return '';
const name = String(category.name ?? '');
const parent = category.parent as Record<string, unknown> | 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<number> {
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<number | null> {
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<string, RelationOptionConfig> | undefined
) {
const result: Record<string, { label: string; value: string | number }[]> = {};
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<string, unknown> = {
[config.valueField]: true,
[config.labelField]: true
};
if (shouldIncludeParent) {
(select as Record<string, unknown>).parent = { select: { name: true } };
}
const records = await delegate.findMany({
select
});
result[field] = records.map((r: Record<string, unknown>) => {
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<string, unknown>) => {
const category = r.category as Record<string, unknown> | undefined;
if (category) {
(category as Record<string, unknown>).displayName = getCategoryDisplayName(category);
}
return r;
});
}
if (config.modelName === 'ProclamationCategory') {
records = records.map((r: Record<string, unknown>) => {
(r as Record<string, unknown>).displayName = getCategoryDisplayName(
r as Record<string, unknown>
);
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<string, unknown>).key = generateLicenseKey();
}
if (config.modelName === 'Proclamation') {
const resolvedCategoryId = await resolveCategoryIdFromFormData(formData, 'categoryId');
if (resolvedCategoryId != null) {
(data as Record<string, unknown>).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)
});
}
}
};
@@ -1,85 +0,0 @@
<script lang="ts">
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import AdminForm from '$lib/components/admin/AdminForm.svelte';
import Center from '$lib/components/containers/Center.svelte';
import type { ActionData, PageData } from './$types';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let showModal = $state(false);
$effect(() => {
if (form?.success) {
showModal = false;
}
});
</script>
<div class="mb-4 flex h-full min-h-0 flex-col gap-6 p-6">
<div class="flex shrink-0 items-center justify-between">
<div class="flex items-center gap-3">
<a href="/admin" class="text-blue-600 hover:text-blue-800 hover:underline">&larr; Admin</a>
<h1 class="text-2xl font-semibold text-gray-900">{data.config.displayName}</h1>
</div>
<button
type="button"
class="rounded-lg bg-green-600 px-4 py-2 font-medium text-white transition-colors hover:bg-green-700"
onclick={() => (showModal = true)}
>
Create +
</button>
</div>
{#if data.records.length > 0}
<AdminTable
records={data.records}
listFields={data.config.listFields}
searchFields={data.config.searchFields}
modelSlug={data.config.slug}
listOnly={data.config.listOnly}
/>
{:else}
<Center>
<div class="text-center">
<p class="text-lg text-gray-500">No {data.config.displayName.toLowerCase()} yet</p>
<p class="mt-1 text-sm text-gray-400">Click "Create +" to add one</p>
</div>
</Center>
{/if}
</div>
{#if showModal}
<div class="fixed inset-0 z-50 overflow-y-auto bg-black/50">
<div class="flex min-h-full items-center justify-center p-4">
<div class="w-full max-w-2xl rounded-lg bg-white p-8 shadow-xl">
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">
Create {data.config.displayNameSingular}
</h2>
<button
type="button"
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
onclick={() => (showModal = false)}
>
&#10005;
</button>
</div>
{#if form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
<AdminForm
fields={data.config.formFields}
fieldTypes={data.config.fieldTypes}
relationOptions={data.config.relationOptions}
relationOptionsData={data.relationOptionsData}
{form}
action="?/create"
submitLabel="Create {data.config.displayNameSingular}"
/>
</div>
</div>
</div>
{/if}
@@ -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<string, unknown> | null | undefined) {
if (!category) return '';
const name = String(category.name ?? '');
const parent = category.parent as Record<string, unknown> | 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<number> {
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<number | null> {
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<string, RelationOptionConfig> | undefined
) {
const result: Record<string, { label: string; value: string | number }[]> = {};
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<string, unknown> = {
[config.valueField]: true,
[config.labelField]: true
};
if (shouldIncludeParent) {
(select as Record<string, unknown>).parent = { select: { name: true } };
}
const records = await delegate.findMany({
select
});
result[field] = records.map((r: Record<string, unknown>) => {
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<string, unknown>).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}`);
}
};
@@ -1,129 +0,0 @@
<script lang="ts">
import AdminForm from '$lib/components/admin/AdminForm.svelte';
import RelatedHasOne from '$lib/components/admin/RelatedHasOne.svelte';
import RelatedHasMany from '$lib/components/admin/RelatedHasMany.svelte';
import { fieldNameToLabel } from '$lib/svelte-admin/introspect';
import type { ActionData, PageData } from './$types';
import { onMount } from 'svelte';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let seconds = $state(0);
let remainder = $derived(5 - seconds);
let showDeleteConfirm = $state(false);
onMount(() => {
const interval = setInterval(() => {
if (form?.success) {
seconds += 1;
if (seconds === 6) {
window.location.href = `/admin/${data.config.slug}`;
}
}
}, 1000);
return () => clearInterval(interval);
});
</script>
<div class="mx-auto mt-12 mb-auto w-full max-w-2xl px-4">
<a href="/admin/{data.config.slug}" class="text-blue-600 hover:text-blue-800 hover:underline">
&larr; Back to {data.config.displayName}
</a>
<h1 class="mt-4 text-2xl font-semibold text-gray-900">
Edit {data.config.displayNameSingular}
</h1>
{#if form?.error}
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
{#if form?.success}
<div class="mt-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
Record updated successfully! Redirecting in {remainder} seconds.
</div>
{/if}
{#if data.config.readonlyFields && data.config.readonlyFields.length > 0}
<div class="mt-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<h2 class="mb-2 text-sm font-medium text-gray-700">Read-only Fields</h2>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
{#each data.config.readonlyFields as field}
<dt class="text-gray-500">{fieldNameToLabel(field)}</dt>
<dd class="text-gray-900">{data.record[field] ?? '—'}</dd>
{/each}
</dl>
</div>
{/if}
<div class="mt-6">
<AdminForm
fields={data.config.formFields}
fieldTypes={data.config.fieldTypes}
relationOptions={data.config.relationOptions}
relationOptionsData={data.relationOptionsData}
record={data.record}
{form}
action="?/update"
submitLabel="Save {data.config.displayNameSingular}"
/>
</div>
{#if data.config.relatedRecords}
{#each data.config.relatedRecords as rel}
{#if rel.type === 'has-one'}
<RelatedHasOne
label={rel.label}
record={data.record[rel.relationKey] ?? null}
displayFields={rel.displayFields}
editSlug={rel.editSlug}
/>
{:else}
<RelatedHasMany
label={rel.label}
records={data.record[rel.relationKey] ?? []}
displayFields={rel.displayFields}
editSlug={rel.editSlug}
/>
{/if}
{/each}
{/if}
<div class="mt-8 border-t border-gray-200 pt-6">
{#if showDeleteConfirm}
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="mb-3 text-sm text-red-700">
Are you sure you want to delete this {data.config.displayNameSingular.toLowerCase()}? This
action cannot be undone.
</p>
<div class="flex gap-3">
<form method="POST" action="?/delete">
<button
type="submit"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700"
>
Yes, Delete
</button>
</form>
<button
type="button"
onclick={() => (showDeleteConfirm = false)}
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
{:else}
<button
type="button"
onclick={() => (showDeleteConfirm = true)}
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50"
>
Delete {data.config.displayNameSingular}
</button>
{/if}
</div>
</div>
@@ -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 };
}
@@ -0,0 +1,39 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function getCategoryNameFromId(id: number): string {
if (data.categories) {
const target = data.categories.find((category) => category.id === id);
return target ? target.name : '';
}
return '';
}
</script>
<!-- Display a list of categories -->
<table>
<thead>
<tr>
<th class="hidden">ID</th>
<th>Name</th>
<th>Parent Category</th>
</tr>
</thead>
<tbody>
{#if data.categories}
{#each data.categories as category, i (category.id)}
<tr class="hover:bg-gray-300">
<td class="hidden">{i}</td>
<td>{category.name}</td>
{#if category.parentId}
<td>{getCategoryNameFromId(category.parentId)}</td>
{:else}
<td></td>
{/if}
</tr>
{/each}
{/if}
</tbody>
</table>