Build user pages

This commit is contained in:
NeilClack
2026-02-25 13:19:26 -06:00
parent 12190d01dc
commit cc51f40591
53 changed files with 5204 additions and 4817 deletions
+318 -142
View File
@@ -1,194 +1,370 @@
# 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.
A config-driven admin panel framework for SvelteKit + Prisma. Define your models once in a
registry and get full CRUD UI with dynamic routing, role-based access control, searchable
dropdowns, and related-record panels — no per-model route files needed.
## 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/svelte-admin/ # Framework core
├── types.ts # AdminModelConfig and related types
├── registry.ts # Model configurations (the single source of truth)
├── roles.ts # Role hierarchy and access helpers
├── prisma-delegate.ts # Dynamic Prisma delegate lookup
├── introspect.ts # Field label generation and dot-notation resolution
├── form-data.ts # FormData → typed object parser
└── index.ts # Barrel 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/ # Dynamic admin routes
├── +layout.server.ts # Role-based access guard
├── +layout.svelte # Layout shell
├── +page.server.ts # Admin home (model grid)
├── +page.svelte # Admin home UI
├── [model]/
│ ├── +page.server.ts # List view + create action
│ ├── +page.svelte # List view UI
│ └── [id]/
│ ├── +page.server.ts # Edit view + update/delete actions
│ └── +page.svelte # Edit view UI
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
src/lib/components/admin/ # Reusable admin UI components
├── AdminTable.svelte # Sortable, searchable data table
├── AdminForm.svelte # Dynamic form with type-aware inputs
├── ComboBox.svelte # Searchable dropdown for FK fields
├── RelatedHasOne.svelte # Display panel for 1:1 related records
└── RelatedHasMany.svelte # Display table for 1:N related records
```
## Adding a New Model
1. Open `src/lib/svelte-admin/registry.ts`.
2. Add a new object to the `models` array:
1. **Define the Prisma model** in `prisma/schema.prisma` and create a migration:
```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',
},
},
}
```
```bash
npm run migrate -- --name add_my_model
```
3. That's it. The dynamic `[model]` routes handle everything else.
2. **Add a config object** to the `models` array in `registry.ts`:
```ts
{
slug: 'widgets', // URL segment: /admin/widgets
modelName: 'Widget', // Prisma model name (PascalCase, singular)
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. **Done.** Visit `/admin/widgets`. The dynamic `[model]` routes handle list, create, edit,
and delete automatically.
No new route files, no new components. The registry is the only thing you touch.
## Configuration Reference
### `AdminModelConfig`
### 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. |
|---|---|---|---|
| `slug` | `string` | Yes | URL segment (kebab-case). Used in `/admin/{slug}`. |
| `modelName` | `string` | Yes | Prisma model name (PascalCase, **singular**). Maps to `prisma[lcfirst(modelName)]`. |
| `displayName` | `string` | Yes | Plural display name for headings and navigation. |
| `displayNameSingular` | `string` | Yes | Singular name for edit page headings. |
| `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. |
| `include` | `Record<string, boolean \| { include: ... }>` | No | Prisma `include` clause for eager-loading relations. |
| `listFields` | `string[]` | Yes | Table columns. Supports dot-notation for relations (e.g., `'role.name'`). |
| `searchFields` | `string[]` | Yes | Fields searched by the client-side search filter. |
| `formFields` | `string[]` | Yes | Fields rendered in create/edit forms, in display order. |
| `readonlyFields` | `string[]` | No | Fields shown as read-only on the edit page. |
| `listOnly` | `boolean` | No | If `true`, suppresses edit links in the table. |
| `fieldTypes` | `Record<string, PrismaScalarType>` | Yes | Maps field names to scalar types. Controls input rendering and FormData parsing. |
| `relationOptions` | `Record<string, RelationOptionConfig>` | No | FK fields that render as ComboBox dropdowns. |
| `relatedRecords` | `RelatedRecordConfig[]` | No | Has-one/has-many panels shown on edit pages. |
| `accessFilter` | `(viewerRoleId: number) => where \| undefined` | No | Returns a Prisma `where` clause to filter records by viewer role. `undefined` = no filter. |
### `PrismaScalarType`
### PrismaScalarType
`'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime'`
Controls both the HTML input type in forms and how `FormData` values are parsed:
Controls both form input rendering and FormData parsing:
| Type | Input | Parsing |
|------|-------|---------|
| `String` | `<input type="text">` | `.toString()` |
| `Int` | `<input type="number" step="1">` | `parseInt()` |
| `Float` | `<input type="number" step="0.01">` | `parseFloat()` |
| Type | HTML Input | Parse Logic |
|---|---|---|
| `String` | `<input type="text">` | `raw.toString()` |
| `Int` | `<input type="number" step="1">` | `parseInt()`, empty → `null` |
| `Float` | `<input type="number" step="0.01">` | `parseFloat()`, empty → `null` |
| `Boolean` | `<input type="checkbox">` | `'on'` or `'true'` → `true` |
| `DateTime` | `<input type="datetime-local">` | `new Date()` |
| `DateTime` | `<input type="datetime-local">` | `new Date()`, empty → `null` |
### `RelationOptionConfig`
### 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
labelField: 'name', // Field displayed 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.
### RelatedRecordConfig
```ts
{
relationKey: 'profile', // Key on parent record (must match a key in `include`)
label: 'Profile', // Section heading on edit page
type: 'has-one', // 'has-one' or 'has-many'
displayFields: ['firstName', 'lastName'], // Fields shown in the panel/table
editSlug: 'profiles' // Optional: admin slug to link for editing
}
```
## Field Types & Form Rendering
The `fieldTypes` mapping is the central mechanism connecting Prisma scalar types to HTML inputs
and form data parsing. Every field in `formFields` must have a corresponding entry in `fieldTypes`.
When a field also has a `relationOptions` entry, the framework renders a `ComboBox` (searchable
dropdown) instead of the default input for that type.
## Relations
To display a related field in the list table (e.g. show a user's role name):
### Foreign Key Dropdowns (relationOptions)
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`:
When a form field is a foreign key, add a `relationOptions` entry to render a searchable ComboBox:
```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
{
formFields: ['name', 'categoryId'],
fieldTypes: { name: 'String', categoryId: 'Int' },
relationOptions: {
categoryId: {
model: 'ProclamationCategory',
labelField: 'name',
valueField: 'id'
}
}
return { roleId: { gt: ROLES.STAFF } }; // Staff sees only Users
}
```
## Customizing the UI
The route loader automatically queries the related model and passes options to the ComboBox.
### Eager Loading (include)
Use `include` to load related data for display in list tables or edit pages:
```ts
{
include: { category: { include: { parent: true } } },
listFields: ['name', 'category.name'] // dot-notation resolves nested values
}
```
## Related Records
Display has-one or has-many related records as panels on the edit page using `relatedRecords`:
```ts
relatedRecords: [
{
relationKey: 'profile',
label: 'Profile',
type: 'has-one',
displayFields: ['firstName', 'lastName'],
editSlug: 'profiles' // links to /admin/profiles/{id}
},
{
relationKey: 'licenseKeys',
label: 'License Keys',
type: 'has-many',
displayFields: ['key', 'active', 'level']
}
]
```
The `relationKey` must match a key in `include` so the data is actually loaded. `has-one` renders
a key-value panel via `RelatedHasOne`; `has-many` renders a table via `RelatedHasMany`. Both
support an optional `editSlug` to link to the related record's edit page.
## Access Control
### Role Hierarchy
Defined in `roles.ts`. Lower numbers = higher privilege:
| Constant | Value | Access Level |
|---|---|---|
| `ROLES.SUPER` | 1 | Full access, sees all users |
| `ROLES.ADMIN` | 2 | Admin access, sees staff and below |
| `ROLES.STAFF` | 3 | Staff access (minimum for admin panel) |
| `ROLES.USER` | 4 | Regular user (no admin access) |
### Layout Guard
`+layout.server.ts` calls `canAccessAdmin(user.roleId)` which returns `true` when
`roleId <= ROLES.STAFF`. Users with `ROLES.USER` (4) or no role get a 403.
### Per-Model Access Filtering
Use `accessFilter` to restrict which records a viewer can see:
```ts
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= ROLES.SUPER) return undefined; // no filter
if (viewerRoleId <= ROLES.ADMIN) {
return { roleId: { gte: ROLES.STAFF } }; // can't see other admins
}
return { roleId: { gt: ROLES.STAFF } }; // staff sees only regular users
}
```
## How Dynamic Routing Works
1. User visits `/admin/proclamations` (or `/admin/proclamations/abc123`)
2. SvelteKit matches the `[model]` (and `[id]`) route params
3. `+page.server.ts` calls `getModelConfig(params.model)` to look up the config
4. `getDelegate(config)` returns `prisma[lcfirst(config.modelName)]` (e.g., `prisma.proclamation`)
5. The delegate runs `findMany` (list), `findUnique` (edit), `create`, `update`, or `delete`
6. Data + config are passed to the Svelte page component
7. Components (`AdminTable`, `AdminForm`, etc.) render based on config
## Component API
### AdminTable
Props: `records`, `listFields`, `searchFields`, `modelSlug`, `listOnly?`
| Prop | Type | Description |
|---|---|---|
| `records` | `Record<string, unknown>[]` | Data rows |
| `listFields` | `string[]` | Column keys (dot-notation supported) |
| `searchFields` | `string[]` | Fields for client-side search |
| `modelSlug` | `string` | Used for building edit links |
| `listOnly` | `boolean?` | Suppress edit links |
Features:
- Client-side search across `searchFields`
- Click column headers to sort (ascending/descending toggle)
- Edit links per row (suppressed when `listOnly` is true)
Features: client-side search, click-to-sort columns, auto-formatted cells (dates, booleans,
decimals), filtered count display.
### AdminForm
Props: `fields`, `fieldTypes`, `relationOptions?`, `relationOptionsData?`, `record?`, `form?`, `action`, `submitLabel?`
| Prop | Type | Description |
|---|---|---|
| `fields` | `string[]` | Field names to render |
| `fieldTypes` | `Record<string, PrismaScalarType>` | Type mapping for inputs |
| `relationOptions` | `Record<string, RelationOptionConfig>?` | FK dropdown configs |
| `relationOptionsData` | `Record<string, { label; value }[]>?` | Loaded dropdown options |
| `record` | `Record<string, unknown>?` | Pre-fill values (edit mode) |
| `form` | `ActionData?` | Carries validation errors |
| `action` | `string` | Form action (e.g., `'?/create'`) |
| `submitLabel` | `string?` | Button text |
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.
### ComboBox
| Prop | Type | Description |
|---|---|---|
| `id` | `string` | Element ID |
| `name` | `string` | Form field name |
| `options` | `{ label; value }[]` | Dropdown items |
| `placeholder` | `string?` | Placeholder text |
| `value` | `string \| number?` | Pre-selected value |
Features: type-to-search filtering, keyboard navigation (ArrowUp/Down, Enter, Escape),
clear button, scroll-to-highlight.
### RelatedHasOne
| Prop | Type | Description |
|---|---|---|
| `label` | `string` | Section heading |
| `record` | `Record<string, unknown> \| null` | Related record |
| `displayFields` | `string[]` | Fields to display as key-value pairs |
| `editSlug` | `string?` | Links to edit the related record |
### RelatedHasMany
| Prop | Type | Description |
|---|---|---|
| `label` | `string` | Section heading (shows count) |
| `records` | `Record<string, unknown>[]` | Related records |
| `displayFields` | `string[]` | Table columns |
| `editSlug` | `string?` | Per-row edit links |
## Utility Functions
| Function | Module | Description |
|---|---|---|
| `fieldNameToLabel(name)` | `introspect.ts` | `'solfeggioFrequencyId'` → `'Solfeggio Frequency'` (strips `Id`, adds spaces) |
| `resolveFieldValue(record, path)` | `introspect.ts` | Resolves dot-notation paths like `'category.name'` on nested objects |
| `extractFormData(formData, fields, types)` | `form-data.ts` | Parses `FormData` with type-aware conversion per `PrismaScalarType` |
| `getDelegate(config)` | `prisma-delegate.ts` | Returns `prisma[lcfirst(modelName)]` delegate |
| `parseId(config, raw)` | `prisma-delegate.ts` | Parses string to `number` when `idType` is `'Int'` |
| `getModelConfig(slug)` | `registry.ts` | Looks up config by URL slug |
| `getAllModelConfigs()` | `registry.ts` | Returns all registered model configs |
| `hasRole(roleId, required)` | `roles.ts` | `true` if `roleId <= required` |
| `canAccessAdmin(roleId)` | `roles.ts` | `true` if `roleId <= ROLES.STAFF` |
## Complete Example
A hypothetical `Product` model showing all configuration features:
```ts
{
slug: 'products',
modelName: 'Product',
displayName: 'Products',
displayNameSingular: 'Product',
idType: 'Int',
include: { category: true, variants: true, createdBy: true },
listFields: ['name', 'category.name', 'price', 'active', 'createdAt'],
searchFields: ['name', 'sku'],
formFields: ['name', 'sku', 'price', 'active', 'categoryId'],
readonlyFields: ['id', 'createdAt', 'updatedAt'],
fieldTypes: {
name: 'String',
sku: 'String',
price: 'Float',
active: 'Boolean',
categoryId: 'Int',
createdAt: 'DateTime',
updatedAt: 'DateTime'
},
relationOptions: {
categoryId: { model: 'Category', labelField: 'name', valueField: 'id' }
},
relatedRecords: [
{
relationKey: 'variants',
label: 'Variants',
type: 'has-many',
displayFields: ['size', 'color', 'stock'],
editSlug: 'variants'
},
{
relationKey: 'createdBy',
label: 'Created By',
type: 'has-one',
displayFields: ['name', 'email']
}
],
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= 2) return undefined;
return { active: true }; // staff only sees active products
}
}
```