Build Admin pages

This commit is contained in:
NeilClack
2026-02-23 10:21:42 -06:00
parent bec8f57a93
commit a230a1e36d
24 changed files with 1591 additions and 8 deletions
+6 -1
View File
@@ -3,8 +3,13 @@ import { error } from '@sveltejs/kit';
import { prisma } from '$lib/db';
export const load: LayoutServerLoad = async ({ locals }) => {
const user = await prisma.user.findUnique({ where: { id: locals.user?.id } });
const user = await prisma.user.findUnique({
where: { id: locals.user?.id },
select: { id: true, roleId: true }
});
if (!user) {
throw error(403, 'not authorized');
}
return { userRoleId: user.roleId };
};
+32 -7
View File
@@ -1,13 +1,20 @@
<!-- This layout is used for all pages where a user must be authenticated to visit.
<!-- This layout is used for all pages where a user must be authenticated to visit.
Below the navigation bar, there should be two sections. A sidebar, containing links
to common "pages", and a window to the right of that sidebar, with it's contents expanded
to fill the entire area, to display the current "page".
to fill the entire area, to display the current "page".
This layout also acts as a sort of 'gatekeeper' in that it is also the authorization layer.
-->
<script lang="ts">
const { children } = $props();
import { canAccessAdmin } from '$lib/svelte-admin/roles';
import { getAllModelConfigs } from '$lib/svelte-admin/registry';
import type { LayoutData } from './$types';
let { children, data } = $props<{ children: any; data: LayoutData }>();
let adminExpanded = $state(true);
const isAdmin = $derived(canAccessAdmin(data.userRoleId));
const adminModels = getAllModelConfigs();
</script>
<!-- Main Area -->
@@ -16,11 +23,11 @@
<div class="mt-12 mr-12 flex flex-col gap-1">
<!-- Sidebar links -->
<a href="/dashboard" class="text-blue-600">Dashboard</a>
<div class="flex flex-col">
<button
class="flex items-center gap-1 text-left text-gray-700 hover:text-gray-900"
onclick={() => adminExpanded = !adminExpanded}
onclick={() => (adminExpanded = !adminExpanded)}
>
<span class="w-4 text-xs">{adminExpanded ? '▼' : '▶'}</span>
Admin
@@ -28,10 +35,28 @@
{#if adminExpanded}
<div class="ml-5 flex flex-col">
<a href="/affirmations" class="text-blue-600">Affirmations</a>
<a href="/frequencies" class="text-blue-600">Solfeggio Frequencies</a>
<a href="/frequencies" class="text-blue-600">Solfeggio Frequencies</a>
</div>
{/if}
</div>
{#if isAdmin}
<div class="mt-2 flex flex-col">
<a
href="/admin"
class="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900"
>
Admin Panel
</a>
<div class="ml-5 flex flex-col">
{#each adminModels as model}
<a href="/admin/{model.slug}" class="text-sm text-blue-600 hover:text-blue-800">
{model.displayName}
</a>
{/each}
</div>
</div>
{/if}
<!-- End Sidebar links -->
</div>
<!-- End Sidebar -->
@@ -39,4 +64,4 @@
<div class="min-h-0 flex-1 overflow-auto">
{@render children()}
</div>
</div>
</div>
@@ -0,0 +1,24 @@
import type { LayoutServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { canAccessAdmin } from '$lib/svelte-admin/roles';
export const load: LayoutServerLoad = async ({ locals }) => {
const user = await prisma.user.findUnique({
where: { id: locals.user?.id },
select: { id: true, name: true, roleId: true, role: { select: { name: true } } }
});
if (!user || !canAccessAdmin(user.roleId)) {
throw error(403, 'not authorized');
}
return {
adminUser: {
id: user.id,
name: user.name,
roleId: user.roleId!,
roleName: user.role?.name ?? 'Unknown'
}
};
};
@@ -0,0 +1,5 @@
<script lang="ts">
const { children } = $props();
</script>
{@render children()}
@@ -0,0 +1,11 @@
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 };
};
+21
View File
@@ -0,0 +1,21 @@
<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>
@@ -0,0 +1,92 @@
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';
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 records = await delegate.findMany({
select: { [config.valueField]: true, [config.labelField]: true }
});
result[field] = records.map((r: Record<string, unknown>) => ({
label: String(r[config.labelField]),
value: r[config.valueField] as string | number
}));
}
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;
const records = await delegate.findMany({
...(config.include ? { include: config.include } : {}),
...(where ? { where } : {})
});
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);
if (config.slug === 'license-keys') {
(data as Record<string, unknown>).key = generateLicenseKey();
}
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)
});
}
}
};
@@ -0,0 +1,85 @@
<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}
@@ -0,0 +1,105 @@
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';
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 records = await delegate.findMany({
select: { [config.valueField]: true, [config.labelField]: true }
});
result[field] = records.map((r: Record<string, unknown>) => ({
label: String(r[config.labelField]),
value: r[config.valueField] as string | number
}));
}
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);
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}`);
}
};
@@ -0,0 +1,129 @@
<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>