diff --git a/src/lib/components/admin/AdminForm.svelte b/src/lib/components/admin/AdminForm.svelte new file mode 100644 index 0000000..e78e902 --- /dev/null +++ b/src/lib/components/admin/AdminForm.svelte @@ -0,0 +1,104 @@ + + +
+ {#if record?.id || form?.id} + + {/if} + + {#each fields as field} + {@const type = fieldTypes[field] ?? 'String'} + {@const label = fieldNameToLabel(field)} + +
+ + + {#if isRelation(field)} + + {:else if type === 'Boolean'} + + {:else if type === 'DateTime'} + + {:else if type === 'Int' || type === 'Float'} + + {:else} + + {/if} +
+ {/each} + + +
diff --git a/src/lib/components/admin/AdminTable.svelte b/src/lib/components/admin/AdminTable.svelte new file mode 100644 index 0000000..2950893 --- /dev/null +++ b/src/lib/components/admin/AdminTable.svelte @@ -0,0 +1,156 @@ + + +
+
+ + + {#if search} + + {/if} + + + {filteredAndSorted().length} of {records.length} + +
+ +
+ + + + {#each listFields as field} + + {/each} + {#if !listOnly} + + {/if} + + + + {#each filteredAndSorted() as record} + + {#each listFields as field} + + {/each} + {#if !listOnly} + + {/if} + + {:else} + + + + {/each} + +
+ +
+ {formatCell(resolveFieldValue(record, field))} + + + Edit + +
+ {#if search} + No records match your search + {:else} + No records yet + {/if} +
+
+
diff --git a/src/lib/components/admin/ComboBox.svelte b/src/lib/components/admin/ComboBox.svelte new file mode 100644 index 0000000..31eaa4d --- /dev/null +++ b/src/lib/components/admin/ComboBox.svelte @@ -0,0 +1,160 @@ + + +
+ + + {#if selectedValue != null && selectedValue !== ''} +
+ + +
+ {:else} + + {/if} + + {#if open} + + {/if} +
diff --git a/src/lib/components/admin/RelatedHasMany.svelte b/src/lib/components/admin/RelatedHasMany.svelte new file mode 100644 index 0000000..e446632 --- /dev/null +++ b/src/lib/components/admin/RelatedHasMany.svelte @@ -0,0 +1,61 @@ + + +
+

+ {label} + ({records.length}) +

+ {#if records.length > 0} +
+ + + + {#each displayFields as field} + + {/each} + {#if editSlug} + + {/if} + + + + {#each records as record} + + {#each displayFields as field} + + {/each} + {#if editSlug} + + {/if} + + {/each} + +
+ {fieldNameToLabel(field)} +
{record[field] ?? '—'} + + Edit + +
+
+ {:else} +

No {label.toLowerCase()} found

+ {/if} +
diff --git a/src/lib/components/admin/RelatedHasOne.svelte b/src/lib/components/admin/RelatedHasOne.svelte new file mode 100644 index 0000000..5774ad7 --- /dev/null +++ b/src/lib/components/admin/RelatedHasOne.svelte @@ -0,0 +1,39 @@ + + +
+
+

{label}

+ {#if editSlug && record?.id} + + Edit + + {/if} +
+ {#if record} +
+ {#each displayFields as field} +
{fieldNameToLabel(field)}
+
{record[field] ?? '—'}
+ {/each} +
+ {:else} +

No {label.toLowerCase()} found

+ {/if} +
diff --git a/src/lib/svelte-admin/README.md b/src/lib/svelte-admin/README.md new file mode 100644 index 0000000..866b51e --- /dev/null +++ b/src/lib/svelte-admin/README.md @@ -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` | 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` | Yes | Maps field names to their Prisma scalar type. Controls form input type and form data parsing. | +| `relationOptions` | `Record` | No | Configures dropdown selects for foreign key fields. | +| `accessFilter` | `(viewerRoleId: number) => Record \| 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` | `` | `.toString()` | +| `Int` | `` | `parseInt()` | +| `Float` | `` | `parseFloat()` | +| `Boolean` | `` | `'on'` or `'true'` → `true` | +| `DateTime` | `` | `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 `