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