Build Admin pages
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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('-');
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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