Files
portal/src/lib/svelte-admin
NeilClack 3c201f9b66 Add sub-categories to affirmation categories
Add self-referential parent/child relation on AffirmationCategory for
   one-level hierarchy. Update AffirmationForm with two-level category and
   sub-category dropdowns, server actions with find-or-create hierarchy
   logic, AffirmationList with Parent
2026-02-23 10:53:15 -06: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-23 10:21:42 -06:00
2026-02-23 10:21:42 -06:00
2026-02-23 10:21:42 -06:00

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:
{
  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',
    },
  },
}
  1. 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

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

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:

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.