Files
portal/src/lib/svelte-admin
NeilClack 38d1752f5b admin: Superforms + Zod validation, three-address-defaults schema, profiles toast fix
- Add Superforms + Zod 4 for all admin form validation
- Replace address isDefault with three separate boolean defaults (Mailing, Billing, Shipping)
- Update profiles page with three-checkbox address form and toast improvements
- Convert all admin page server actions to pass request directly to crud
- Export superFormErrorsToFieldErrors helper for profiles server actions
- Regenerate Prisma client for new schema fields
2026-07-07 13:36:23 -05:00
..
2026-02-23 10:21:42 -06:00
2026-02-23 10:21:42 -06:00
2026-02-23 10:21:42 -06:00
2026-02-25 13:19:26 -06:00

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:

    npm run migrate -- --name add_my_model
    
  2. Add a config object to the models array in registry.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<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 and no modal.
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 <select> dropdowns.
relatedRecords RelatedRecordConfig[] No Has-one/has-many panels shown on edit pages (display only via related records table).
accessFilter (viewerRoleId: number) => where | undefined No Returns a Prisma where clause to filter records by viewer role. undefined = no filter.

PrismaScalarType

'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Tiptap'

Controls both form input rendering and FormData parsing:

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(), empty -> null
Tiptap TipTap rich-text editor (hidden input) raw.toString()

RelationOptionConfig

{
  model: 'UserRole',      // Prisma model to query for options
  labelField: 'name',     // Field displayed in the dropdown
  valueField: 'id'        // Field value submitted with the form
}

RelatedRecordConfig

{
  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 <select> dropdown instead of the default input for that type. The options are fetched server-side by crud.ts and passed to the client as relationOptionsData.

For Tiptap fields, the TipTap rich-text editor is rendered. The editor content is stored in a hidden input and submitted with the form.

Relations

Foreign Key Dropdowns (relationOptions)

When a form field is a foreign key, add a relationOptions entry to render a <select> dropdown:

{
  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:

{
  include: { category: { include: { parent: true } } },
  listFields: ['name', 'category.name']  // dot-notation resolves nested values
}

Display has-one or has-many related records as panels on the edit page using relatedRecords:

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:

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<string, unknown>[] Data rows
listFields string[] Column keys (dot-notation supported)
searchFields string[] Fields for client-side search
fieldTypes Record<string, PrismaScalarType> 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<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)
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:

{
  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 };
  }
}