# svelte-admin A config-driven admin panel framework for SvelteKit + Prisma. Define your models once in a registry and get full CRUD UI with per-model static pages, role-based access control, searchable tables, FK dropdowns, TipTap editor for rich text, and related-record panels. ## Architecture ``` 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 ├── crud.ts # CRUD action factory (load, upsert, delete) └── index.ts # Barrel exports src/routes/(protected)/admin/ # Per-model admin pages (no dynamic [model] routing) ├── +layout.server.ts # Role-based access guard ├── +layout.svelte # Layout shell ├── +page.server.ts # Admin home (model grid) ├── +page.svelte # Admin home UI ├── users/ # Each model gets its own page directory ├── profiles/ ├── addresses/ ├── user-roles/ ├── proclamations/ ├── solfeggio-frequencies/ ├── proclamation-categories/ ├── subscription-levels/ └── license-keys/ # listOnly — no create/edit modal src/lib/components/admin/ # Reusable admin UI components ├── AdminTable.svelte # Sortable, searchable data table ├── AdminForm.svelte # Dynamic form with type-aware inputs ├── AdminModal.svelte # Modal overlay for CRUD forms ├── TipTap.svelte # Rich-text editor (in components/tiptap/) ``` ## Adding a New Model 1. **Define the Prisma model** in `prisma/schema.prisma` and create a migration: ```bash npm run migrate -- --name add_my_model ``` 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. **Create the page directory** `src/routes/(protected)/admin/widgets/` with: - `+page.server.ts` — load records + form actions using `crud.ts` helpers - `+page.svelte` — render AdminTable + AdminModal with AdminForm 4. **Done.** Visit `/admin/widgets`. The page handles list, create, edit via modal. ## Configuration Reference ### AdminModelConfig | Field | Type | Required | Description | | --------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------ | | `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` | 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 and no modal. | | `fieldTypes` | `Record` | Yes | Maps field names to scalar types. Controls input rendering and FormData parsing. | | `relationOptions` | `Record` | No | FK fields that render as `` | `raw.toString()` | | `Int` | `` | `parseInt()`, empty -> `null` | | `Float` | `` | `parseFloat()`, empty -> `null` | | `Boolean` | `` | `'on'` or `'true'` -> `true` | | `DateTime` | `` | `new Date()`, empty -> `null` | | `Tiptap` | TipTap rich-text editor (hidden input)| `raw.toString()` | ### RelationOptionConfig ```ts { model: 'UserRole', // Prisma model to query for options labelField: 'name', // Field displayed in the dropdown valueField: 'id' // Field value submitted with 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 `` dropdown: ```ts { formFields: ['name', 'categoryId'], fieldTypes: { name: 'String', categoryId: 'Int' }, relationOptions: { categoryId: { model: 'ProclamationCategory', labelField: 'name', valueField: 'id' } } } ``` The `crud.ts` `loadRecords` function automatically queries the related model and passes options to the page as `relationOptionsData`. ### 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. ## 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; if (viewerRoleId <= ROLES.ADMIN) { return { roleId: { gte: ROLES.STAFF } }; } return { roleId: { gt: ROLES.STAFF } }; }; ``` ## How Static Pages Work 1. User visits `/admin/proclamations` 2. SvelteKit matches the static route `/admin/proclamations/+page.server.ts` 3. `+page.server.ts` calls `getModelConfig('proclamations')` to look up the config 4. `loadRecords(config)` calls `prisma.proclamation.findMany()` with the config's `include` and `accessFilter` 5. For each FK field with a `relationOptions`, it also queries the related model for dropdown options 6. Data + config are passed to `+page.svelte` 7. The page renders `AdminTable` with records and `AdminModal` + `AdminForm` for create/edit ## Component API ### AdminTable | Prop | Type | Description | | -------------- | --------------------------- | ------------------------------------ | | `records` | `Record[]` | Data rows | | `listFields` | `string[]` | Column keys (dot-notation supported) | | `searchFields` | `string[]` | Fields for client-side search | | `fieldTypes` | `Record` | Type mapping for cell formatting | | `singular` | `string` | Singular label for UI text | | `onRowClick` | `(record) => void?` | Click handler for edit modal | | `onNew` | `() => void?` | Click handler for create modal | Features: client-side search, click-to-sort columns, auto-formatted cells (dates, booleans, decimals), empty state. ### AdminForm | Prop | Type | Description | | --------------------- | --------------------------------------- | -------------------------------- | | `fields` | `string[]` | Field names to render | | `fieldTypes` | `Record` | Type mapping for inputs | | `relationOptions` | `Record?` | FK dropdown configs | | `relationOptionsData` | `Record?` | Loaded dropdown options | | `record` | `Record?` | Pre-fill values (edit mode) | | `readonlyFields` | `string[]?` | Read-only field names | | `action` | `string` | Form action (e.g., `'?/upsert'`) | | `submitLabel` | `string?` | Button text | ### AdminModal | Prop | Type | Description | | --------- | ----------------- | ------------------------ | | `open` | `boolean` | Show/hide modal | | `title` | `string` | Modal heading | | `onClose` | `() => void` | Close handler | | `children`| `Snippet` | Modal body content | ### TipTap | Prop | Type | Description | | ---------------- | -------- | --------------------------------- | | `initialContent` | `string` | Initial HTML content | | `name` | `string` | Hidden input name for form submit | ## 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` | | `loadRecords(locals, config)` | `crud.ts` | Fetches records + FK dropdown options for a model | | `upsertRecord(formData, config, locals)` | `crud.ts` | Creates or updates a record based on presence of `id` in form data | | `deleteRecord(formData, config, locals)` | `crud.ts` | Deletes a record by ID | ## Models in Admin | Registry Slug | Prisma Model | Notes | | -------------------------- | ---------------------- | ---------------------------------- | | `users` | User | Role assignment, view-only fields | | `profiles` | Profile | Linked to user FK | | `addresses` | Address | Linked to profile FK | | `user-roles` | UserRole | | | `proclamations` | Proclamation | TipTap rich-text editor for content| | `solfeggio-frequencies` | SolfeggioFrequency | | | `proclamation-categories` | ProclamationCategory | Parent category hierarchy | | `license-keys` | LicenseKey | listOnly — read-only table, no CRUD| | `subscription-levels` | SubscriptionLevel | | ### Excluded from Admin | Model | Reason | | ------------------ | -------------------------------------------- | | Session | Better Auth internal — not for manual editing| | Account | Better Auth internal | | Verification | Better Auth internal | | UserProclamations | Composite key — managed via other models | ## 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 }; } } ```