Build Admin pages
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
@@ -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">← 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)}
|
||||
>
|
||||
✕
|
||||
</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">
|
||||
← 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>
|
||||
Reference in New Issue
Block a user