add categories admin page
This commit is contained in:
@@ -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>
|
||||
@@ -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 +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">← 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}
|
||||
@@ -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">
|
||||
← 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>
|
||||
Reference in New Issue
Block a user