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
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import { fieldNameToLabel } from '$lib/svelte-admin/introspect';
import type { PrismaScalarType, RelationOptionConfig } from '$lib/svelte-admin/types';
import ComboBox from './ComboBox.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();
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'}
<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"
/>
{:else}
<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"
/>
{/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
@@ -0,0 +1,156 @@
<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('.').pop() ?? field) : 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>
+160
View File
@@ -0,0 +1,160 @@
<script lang="ts">
type Option = { label: string; value: string | number };
type Props = {
id: string;
name: string;
options: Option[];
placeholder?: string;
value?: string | number;
};
let { id, name, options, placeholder = 'Search...', value }: Props = $props();
let search = $state('');
let open = $state(false);
let highlightIndex = $state(-1);
let inputEl = $state<HTMLInputElement | null>(null);
let listEl = $state<HTMLUListElement | null>(null);
let selectedValue = $state<string | number | undefined>(undefined);
$effect(() => {
if (value != null && value !== '') {
selectedValue = value;
}
});
let selectedLabel = $derived(() => {
if (selectedValue == null || selectedValue === '') return '';
const match = options.find((o) => String(o.value) === String(selectedValue));
return match?.label ?? '';
});
let filtered = $derived(() => {
if (!search) return options;
const q = search.toLowerCase();
return options.filter((o) => o.label.toLowerCase().includes(q));
});
function select(opt: Option) {
selectedValue = opt.value;
search = '';
open = false;
highlightIndex = -1;
}
function clear() {
selectedValue = undefined;
search = '';
open = true;
inputEl?.focus();
}
function handleInputFocus() {
open = true;
highlightIndex = -1;
}
function handleInputBlur(e: FocusEvent) {
const related = e.relatedTarget as HTMLElement | null;
if (related?.closest(`[data-combobox="${id}"]`)) return;
open = false;
search = '';
highlightIndex = -1;
}
function handleKeydown(e: KeyboardEvent) {
const items = filtered();
if (e.key === 'ArrowDown') {
e.preventDefault();
open = true;
highlightIndex = Math.min(highlightIndex + 1, items.length - 1);
scrollToHighlighted();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIndex = Math.max(highlightIndex - 1, 0);
scrollToHighlighted();
} else if (e.key === 'Enter') {
e.preventDefault();
if (open && highlightIndex >= 0 && highlightIndex < items.length) {
select(items[highlightIndex]);
}
} else if (e.key === 'Escape') {
open = false;
search = '';
highlightIndex = -1;
}
}
function scrollToHighlighted() {
requestAnimationFrame(() => {
const el = listEl?.children[highlightIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
});
}
</script>
<div class="relative" data-combobox={id}>
<input type="hidden" {name} value={selectedValue ?? ''} />
{#if selectedValue != null && selectedValue !== ''}
<div
class="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 transition-shadow outline-none focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500"
>
<button
type="button"
class="flex-1 cursor-pointer text-left text-gray-900"
onclick={() => clear()}
>
{selectedLabel()}
</button>
<button
type="button"
class="ml-2 shrink-0 text-gray-400 hover:text-gray-600"
onclick={() => clear()}
tabindex={-1}
>
&#10005;
</button>
</div>
{:else}
<input
type="text"
{id}
bind:this={inputEl}
bind:value={search}
{placeholder}
autocomplete="off"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
onfocus={handleInputFocus}
onblur={handleInputBlur}
onkeydown={handleKeydown}
/>
{/if}
{#if open}
<ul
bind:this={listEl}
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
role="listbox"
>
{#each filtered() as opt, i}
<li role="option" aria-selected={highlightIndex === i}>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm {highlightIndex === i
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-50'}"
onmousedown={(e: MouseEvent) => { e.preventDefault(); select(opt); }}
onmouseenter={() => (highlightIndex = i)}
>
{opt.label}
</button>
</li>
{:else}
<li class="px-3 py-2 text-sm text-gray-400">No matches</li>
{/each}
</ul>
{/if}
</div>
@@ -0,0 +1,61 @@
<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>
@@ -0,0 +1,39 @@
<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>
+194
View File
@@ -0,0 +1,194 @@
# svelte-admin
A lightweight, configuration-driven admin panel framework for SvelteKit + Prisma applications. Models are registered once in a central registry and automatically get list views, create modals, edit pages, and delete functionality — all wired to Prisma via dynamic delegates.
## Architecture
```
src/lib/svelte-admin/ # Framework core
types.ts # AdminModelConfig type + supporting types
registry.ts # Model configurations (the main config file)
roles.ts # Role constants + access helpers
introspect.ts # Field label formatting + nested value resolution
prisma-delegate.ts # Dynamic Prisma delegate lookup + ID parsing
form-data.ts # FormData → typed object extraction
index.ts # Barrel re-exports
src/lib/components/admin/ # UI components
AdminTable.svelte # Sortable, searchable list table
AdminForm.svelte # Dynamic form (creates inputs from field config)
src/routes/(protected)/admin/ # SvelteKit routes
+layout.server.ts # Auth guard (requires STAFF role or higher)
+layout.svelte # Admin shell layout
+page.svelte # Home grid (cards linking to each model)
+page.server.ts # Loads all model configs for the home grid
[model]/+page.svelte # List view + create modal for a model
[model]/+page.server.ts # List loader + create action
[model]/[id]/+page.svelte # Edit view + delete for a single record
[model]/[id]/+page.server.ts # Record loader + update/delete actions
```
## Adding a New Model
1. Open `src/lib/svelte-admin/registry.ts`.
2. Add a new object to the `models` array:
```ts
{
slug: 'widgets', // URL segment: /admin/widgets
modelName: 'Widget', // Prisma model name (PascalCase)
displayName: 'Widgets', // Plural display name
displayNameSingular: 'Widget', // Singular display name
idType: 'String', // 'String' or 'Int' — matches Prisma @id type
include: { category: true }, // Optional: Prisma include for relations
listFields: ['name', 'category.name', 'price'],
searchFields: ['name'],
formFields: ['name', 'categoryId', 'price'],
readonlyFields: ['id', 'createdAt'],
fieldTypes: {
name: 'String',
categoryId: 'Int',
price: 'Float',
createdAt: 'DateTime',
},
relationOptions: {
categoryId: {
model: 'Category',
labelField: 'name',
valueField: 'id',
},
},
}
```
3. That's it. The dynamic `[model]` routes handle everything else.
## Configuration Reference
### `AdminModelConfig`
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `slug` | `string` | Yes | URL path segment. Use kebab-case (e.g. `'license-keys'`). |
| `modelName` | `string` | Yes | Prisma model name in PascalCase (e.g. `'LicenseKey'`). Used to look up the Prisma delegate. |
| `displayName` | `string` | Yes | Plural name shown in headings and the admin home grid. |
| `displayNameSingular` | `string` | Yes | Singular name for create/edit headings and button labels. |
| `idType` | `'String' \| 'Int'` | Yes | Primary key type. Controls how the `id` URL param is parsed. |
| `include` | `Record<string, boolean>` | No | Prisma `include` clause for eager-loading relations in list and edit views. |
| `listFields` | `string[]` | Yes | Fields shown as columns in the list table. Supports dot-notation for relations (e.g. `'user.name'`). |
| `searchFields` | `string[]` | Yes | Fields searched by the table's search box. Should be scalar fields on the model. |
| `formFields` | `string[]` | Yes | Fields shown in the create/edit form. Order determines form field order. |
| `readonlyFields` | `string[]` | No | Fields shown as read-only text on the edit page (not editable). Typically `['id', 'createdAt', 'updatedAt']`. |
| `listOnly` | `boolean` | No | When `true`, suppresses edit links in the table. The create modal still works. Use for models where records are auto-generated or shouldn't be edited individually. |
| `fieldTypes` | `Record<string, PrismaScalarType>` | Yes | Maps field names to their Prisma scalar type. Controls form input type and form data parsing. |
| `relationOptions` | `Record<string, RelationOptionConfig>` | No | Configures dropdown selects for foreign key fields. |
| `accessFilter` | `(viewerRoleId: number) => Record<string, unknown> \| undefined` | No | Returns a Prisma `where` clause to filter list results based on the viewer's role. Return `undefined` for no filter. |
### `PrismaScalarType`
`'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime'`
Controls both the HTML input type in forms and how `FormData` values are parsed:
| Type | Input | Parsing |
|------|-------|---------|
| `String` | `<input type="text">` | `.toString()` |
| `Int` | `<input type="number" step="1">` | `parseInt()` |
| `Float` | `<input type="number" step="0.01">` | `parseFloat()` |
| `Boolean` | `<input type="checkbox">` | `'on'` or `'true'` → `true` |
| `DateTime` | `<input type="datetime-local">` | `new Date()` |
### `RelationOptionConfig`
```ts
{
model: 'UserRole', // Prisma model to query for options
labelField: 'name', // Field to display in the dropdown
valueField: 'id', // Field value submitted with the form
}
```
The framework runs `findMany` on the related model, selecting only `labelField` and `valueField`, and renders a `<select>` dropdown in the form.
## Relations
To display a related field in the list table (e.g. show a user's role name):
1. Add the relation to `include`: `{ role: true }`
2. Use dot-notation in `listFields`: `'role.name'`
To make a foreign key editable as a dropdown:
1. Add the FK field to `formFields`: `'roleId'`
2. Add its type to `fieldTypes`: `roleId: 'Int'`
3. Configure `relationOptions`:
```ts
relationOptions: {
roleId: { model: 'UserRole', labelField: 'name', valueField: 'id' }
}
```
## List-Only Models
Set `listOnly: true` on a model config to suppress edit links in the list table. Records still appear in the table and the create modal still works, but there's no way to click into an individual record to edit or delete it.
This is useful for models like license keys where records are auto-generated and shouldn't be manually edited.
## Read-Only Fields
Fields listed in `readonlyFields` are displayed as plain text on the edit page (above the form) but cannot be modified. Common use: `['id', 'createdAt', 'updatedAt']`.
If you want the edit page to show mostly read-only data with only one or two editable fields, move most fields from `formFields` to `readonlyFields`. For example, the Users config has `formFields: ['roleId']` with everything else in `readonlyFields`.
## Access Control
### Admin layout guard
The admin layout (`admin/+layout.server.ts`) checks `canAccessAdmin(user.roleId)` — which requires `roleId <= ROLES.STAFF` (i.e. Super, Admin, or Staff). Users with higher `roleId` values get a 403.
### Role constants
Defined in `roles.ts`:
```ts
ROLES.SUPER = 1 // Full access
ROLES.ADMIN = 2 // Admin access
ROLES.STAFF = 3 // Staff access (minimum for admin panel)
ROLES.USER = 4 // Regular user (no admin access)
```
Lower numbers = higher privilege.
### Per-model filtering with `accessFilter`
The `accessFilter` function receives the viewer's `roleId` and returns a Prisma `where` clause that's merged into the list query. This lets you restrict which records different roles can see.
Example from the Users config:
```ts
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= ROLES.SUPER) return undefined; // Super sees all
if (viewerRoleId <= ROLES.ADMIN) {
return { roleId: { gte: ROLES.STAFF } }; // Admin sees Staff+User
}
return { roleId: { gt: ROLES.STAFF } }; // Staff sees only Users
}
```
## Customizing the UI
### AdminTable
Props: `records`, `listFields`, `searchFields`, `modelSlug`, `listOnly?`
Features:
- Client-side search across `searchFields`
- Click column headers to sort (ascending/descending toggle)
- Edit links per row (suppressed when `listOnly` is true)
### AdminForm
Props: `fields`, `fieldTypes`, `relationOptions?`, `relationOptionsData?`, `record?`, `form?`, `action`, `submitLabel?`
Renders a `<form>` with inputs determined by `fieldTypes` and `relationOptions`. When `record` is provided, fields are pre-filled. The `form` prop carries SvelteKit action data for error display and field re-population after failed submissions.
+42
View File
@@ -0,0 +1,42 @@
import type { PrismaScalarType } from './types';
export function extractFormData(
formData: FormData,
formFields: string[],
fieldTypes: Record<string, PrismaScalarType>
): Record<string, unknown> {
const data: Record<string, unknown> = {};
for (const field of formFields) {
const type = fieldTypes[field] ?? 'String';
const raw = formData.get(field);
switch (type) {
case 'Int': {
const val = raw?.toString() ?? '';
data[field] = val === '' ? null : parseInt(val, 10);
break;
}
case 'Float': {
const val = raw?.toString() ?? '';
data[field] = val === '' ? null : parseFloat(val);
break;
}
case 'Boolean': {
data[field] = raw === 'on' || raw === 'true';
break;
}
case 'DateTime': {
const val = raw?.toString() ?? '';
data[field] = val === '' ? null : new Date(val);
break;
}
default: {
data[field] = raw?.toString() ?? '';
break;
}
}
}
return data;
}
+6
View File
@@ -0,0 +1,6 @@
export type { AdminModelConfig, RelationOptionConfig, AdminField, PrismaScalarType } from './types';
export { ROLES, hasRole, canAccessAdmin } from './roles';
export { fieldNameToLabel, resolveFieldValue } from './introspect';
export { getDelegate, parseId } from './prisma-delegate';
export { extractFormData } from './form-data';
export { getModelConfig, getAllModelConfigs } from './registry';
+15
View File
@@ -0,0 +1,15 @@
export function fieldNameToLabel(fieldName: string): string {
const withoutId = fieldName.replace(/Id$/, '');
const spaced = withoutId.replace(/([a-z])([A-Z])/g, '$1 $2');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
export function resolveFieldValue(record: Record<string, unknown>, path: string): unknown {
const parts = path.split('.');
let current: unknown = record;
for (const part of parts) {
if (current == null || typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
+26
View File
@@ -0,0 +1,26 @@
import { prisma } from '$lib/db';
import type { AdminModelConfig } from './types';
function lcfirst(str: string): string {
return str.charAt(0).toLowerCase() + str.slice(1);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getDelegate(config: AdminModelConfig): any {
const key = lcfirst(config.modelName);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const delegate = (prisma as any)[key];
if (!delegate) {
throw new Error(`Prisma delegate not found for model: ${config.modelName} (key: ${key})`);
}
return delegate;
}
export function parseId(config: AdminModelConfig, raw: string): string | number {
if (config.idType === 'Int') {
const parsed = parseInt(raw, 10);
if (isNaN(parsed)) throw new Error(`Invalid integer ID: ${raw}`);
return parsed;
}
return raw;
}
+208
View File
@@ -0,0 +1,208 @@
import type { AdminModelConfig } from './types';
import { ROLES } from './roles';
const models: AdminModelConfig[] = [
{
slug: 'users',
modelName: 'User',
displayName: 'Users',
displayNameSingular: 'User',
idType: 'String',
include: { role: true, profile: true, licenseKeys: true },
listFields: ['name', 'email', 'emailVerified', 'role.name', 'createdAt'],
searchFields: ['name', 'email'],
formFields: ['roleId'],
readonlyFields: ['id', 'name', 'email', 'emailVerified', 'createdAt', 'updatedAt'],
fieldTypes: {
name: 'String',
email: 'String',
emailVerified: 'Boolean',
roleId: 'Int',
createdAt: 'DateTime',
updatedAt: 'DateTime'
},
relationOptions: {
roleId: { model: 'UserRole', labelField: 'name', valueField: 'id' }
},
relatedRecords: [
{
relationKey: 'profile',
label: 'Profile',
type: 'has-one',
displayFields: ['firstName', 'lastName', 'phoneNumber'],
editSlug: 'profiles'
},
{
relationKey: 'licenseKeys',
label: 'License Keys',
type: 'has-many',
displayFields: ['key', 'active', 'level']
}
],
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= ROLES.SUPER) return undefined;
if (viewerRoleId <= ROLES.ADMIN) {
return { roleId: { gte: ROLES.STAFF } };
}
return { roleId: { gt: ROLES.STAFF } };
}
},
{
slug: 'profiles',
modelName: 'Profile',
displayName: 'Profiles',
displayNameSingular: 'Profile',
idType: 'String',
include: { user: true, addresses: true },
listFields: ['firstName', 'lastName', 'phoneNumber', 'user.name'],
searchFields: ['firstName', 'lastName', 'phoneNumber'],
formFields: ['userId', 'firstName', 'lastName', 'phoneNumber'],
readonlyFields: ['id'],
fieldTypes: {
userId: 'String',
firstName: 'String',
lastName: 'String',
phoneNumber: 'String'
},
relationOptions: {
userId: { model: 'User', labelField: 'name', valueField: 'id' }
},
relatedRecords: [
{
relationKey: 'addresses',
label: 'Addresses',
type: 'has-many',
displayFields: ['street', 'city', 'state', 'zip']
}
]
},
{
slug: 'user-roles',
modelName: 'UserRole',
displayName: 'User Roles',
displayNameSingular: 'User Role',
idType: 'Int',
listFields: ['id', 'name'],
searchFields: ['name'],
formFields: ['name'],
readonlyFields: ['id'],
fieldTypes: {
name: 'String'
}
},
{
slug: 'affirmations',
modelName: 'Affirmation',
displayName: 'Affirmations',
displayNameSingular: 'Affirmation',
idType: 'String',
include: { solfeggioFrequency: true, category: true },
listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'price'],
searchFields: ['name', 'content'],
formFields: ['name', 'content', 'solfeggioFrequencyId', 'categoryId', 'price'],
readonlyFields: ['id'],
fieldTypes: {
name: 'String',
content: 'String',
solfeggioFrequencyId: 'Int',
categoryId: 'Int',
price: 'Float'
},
relationOptions: {
solfeggioFrequencyId: {
model: 'SolfeggioFrequency',
labelField: 'name',
valueField: 'id'
},
categoryId: {
model: 'AffirmationCategory',
labelField: 'name',
valueField: 'id'
}
}
},
{
slug: 'solfeggio-frequencies',
modelName: 'SolfeggioFrequency',
displayName: 'Solfeggio Frequencies',
displayNameSingular: 'Solfeggio Frequency',
idType: 'Int',
include: { affirmations: true },
listFields: ['name', 'value'],
searchFields: ['name'],
formFields: ['name', 'value'],
readonlyFields: ['id'],
fieldTypes: {
name: 'String',
value: 'Float'
},
relatedRecords: [
{
relationKey: 'affirmations',
label: 'Affirmations',
type: 'has-many',
displayFields: ['name', 'price'],
editSlug: 'affirmations'
}
]
},
{
slug: 'affirmation-categories',
modelName: 'AffirmationCategory',
displayName: 'Affirmation Categories',
displayNameSingular: 'Affirmation Category',
idType: 'Int',
include: { affirmations: true },
listFields: ['name'],
searchFields: ['name'],
formFields: ['name'],
readonlyFields: ['id'],
fieldTypes: {
name: 'String'
},
relatedRecords: [
{
relationKey: 'affirmations',
label: 'Affirmations',
type: 'has-many',
displayFields: ['name', 'price'],
editSlug: 'affirmations'
}
]
},
{
slug: 'license-keys',
modelName: 'LicenseKey',
displayName: 'License Keys',
displayNameSingular: 'License Key',
idType: 'String',
include: { user: true },
listOnly: true,
listFields: ['key', 'active', 'level', 'user.name'],
searchFields: ['key', 'level'],
formFields: ['active', 'level', 'userId'],
readonlyFields: ['id'],
fieldTypes: {
key: 'String',
active: 'Boolean',
level: 'String',
userId: 'String'
},
relationOptions: {
userId: { model: 'User', labelField: 'name', valueField: 'id' }
}
}
];
const modelsBySlug = new Map<string, AdminModelConfig>();
for (const model of models) {
modelsBySlug.set(model.slug, model);
}
export function getModelConfig(slug: string): AdminModelConfig | undefined {
return modelsBySlug.get(slug);
}
export function getAllModelConfigs(): AdminModelConfig[] {
return models;
}
+15
View File
@@ -0,0 +1,15 @@
export const ROLES = {
SUPER: 1,
ADMIN: 2,
STAFF: 3,
USER: 4
} as const;
export function hasRole(userRoleId: number | null | undefined, requiredRoleId: number): boolean {
if (userRoleId == null) return false;
return userRoleId <= requiredRoleId;
}
export function canAccessAdmin(userRoleId: number | null | undefined): boolean {
return hasRole(userRoleId, ROLES.STAFF);
}
+38
View File
@@ -0,0 +1,38 @@
export type PrismaScalarType = 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime';
export type RelationOptionConfig = {
model: string;
labelField: string;
valueField: string;
};
export type AdminField = {
name: string;
label?: string;
};
export type RelatedRecordConfig = {
relationKey: string;
label: string;
type: 'has-one' | 'has-many';
displayFields: string[];
editSlug?: string;
};
export type AdminModelConfig = {
slug: string;
modelName: string;
displayName: string;
displayNameSingular: string;
idType: 'String' | 'Int';
include?: Record<string, boolean>;
listFields: string[];
searchFields: string[];
formFields: string[];
readonlyFields?: string[];
listOnly?: boolean;
fieldTypes: Record<string, PrismaScalarType>;
relationOptions?: Record<string, RelationOptionConfig>;
relatedRecords?: RelatedRecordConfig[];
accessFilter?: (viewerRoleId: number) => Record<string, unknown> | undefined;
};
+17
View File
@@ -43,3 +43,20 @@ export function clientFormatPhoneNumber(number: string): string {
export function serverFormatPhoneNumber(number: string): string {
return number.replace(/\D/g, '');
}
/**
* Generates a license key in the format XXXX-XXXX-XXXX-XXXX
* using uppercase alphanumeric characters.
*/
export function generateLicenseKey(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const segments: string[] = [];
for (let s = 0; s < 4; s++) {
let segment = '';
for (let i = 0; i < 4; i++) {
segment += chars[Math.floor(Math.random() * chars.length)];
}
segments.push(segment);
}
return segments.join('-');
}
+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>