sveltekit-admin 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # sveltekit-admin
2
2
 
3
- 🎛ïļ A Django-like admin panel for SvelteKit applications with Prisma and better-auth.
3
+ 🎛ïļ A Django-like admin panel for SvelteKit applications with Prisma.
4
4
 
5
5
  ![Version](https://img.shields.io/npm/v/sveltekit-admin)
6
6
  ![License](https://img.shields.io/npm/l/sveltekit-admin)
@@ -9,9 +9,9 @@
9
9
 
10
10
  - 🔍 **Auto-introspection** of Prisma schema
11
11
  - 📝 **CRUD operations** auto-generated for all models
12
- - 🔐 **better-auth integration** for admin authentication
13
- - ðŸŽĻ **Standalone UI** - no Tailwind or other CSS framework required
14
- - ⚡ **Zero-config** - just add the plugin and you're ready
12
+ - ðŸŽĻ **Standalone UI** - no external CSS required
13
+ - ⚡ **Zero routes** - everything handled via a single hook
14
+ - ðŸŠķ **3 lines of code** to setup
15
15
  - 🔧 **Customizable** - hide fields, set readonly, custom labels
16
16
 
17
17
  ## Installation
@@ -24,95 +24,48 @@ bun add sveltekit-admin
24
24
  pnpm add sveltekit-admin
25
25
  ```
26
26
 
27
- ## Quick Start
28
-
29
- ### 1. Add the Vite plugin
30
-
31
- ```typescript
32
- // vite.config.ts
33
- import { sveltekit } from '@sveltejs/kit/vite';
34
- import { svelteKitAdmin } from 'sveltekit-admin/plugin';
35
- import { defineConfig } from 'vite';
36
-
37
- export default defineConfig({
38
- plugins: [
39
- sveltekit(),
40
- svelteKitAdmin({
41
- prismaSchemaPath: './prisma/schema.prisma',
42
- basePath: '/admin',
43
- auth: {
44
- provider: 'better-auth',
45
- adminRole: 'admin'
46
- }
47
- })
48
- ]
49
- });
50
- ```
51
-
52
- ### 2. Add the auth hook (optional, for protected admin)
27
+ ## Quick Start (3 lines!)
53
28
 
54
29
  ```typescript
55
30
  // src/hooks.server.ts
56
- import { createAdminHandle } from 'sveltekit-admin';
57
- import { sequence } from '@sveltejs/kit/hooks';
58
-
59
- const adminHandle = createAdminHandle({
60
- basePath: '/admin',
61
- auth: {
62
- provider: 'better-auth',
63
- adminRole: 'admin'
64
- }
65
- });
31
+ import { createAdminHandler } from 'sveltekit-admin';
32
+ import { prisma } from '$lib/server/prisma';
66
33
 
67
- export const handle = sequence(
68
- // your auth handle first
69
- adminHandle
70
- );
34
+ export const handle = createAdminHandler({ prisma });
71
35
  ```
72
36
 
73
- ### 3. Access your admin panel
74
-
75
- Navigate to `/admin` and you'll see:
76
- - Dashboard with model statistics
77
- - List views with pagination, search, and sorting
37
+ That's it! Navigate to `/admin` and you'll see:
38
+ - Dashboard with model statistics
39
+ - List views with pagination
78
40
  - Create/Edit forms auto-generated from your Prisma schema
79
41
  - Delete with confirmation
80
42
 
81
43
  ## Configuration
82
44
 
83
45
  ```typescript
84
- svelteKitAdmin({
46
+ createAdminHandler({
47
+ // Required: your Prisma client
48
+ prisma,
49
+
85
50
  // Path to your Prisma schema (default: './prisma/schema.prisma')
86
51
  prismaSchemaPath: './prisma/schema.prisma',
87
52
 
88
53
  // Base path for admin routes (default: '/admin')
89
54
  basePath: '/admin',
90
55
 
91
- // Authentication configuration
92
- auth: {
93
- provider: 'better-auth',
94
- adminRole: 'admin', // Role required to access admin
95
- // Or custom check function:
96
- adminCheck: async (user) => user.isAdmin === true
56
+ // Authentication check (optional)
57
+ authCheck: async (event) => {
58
+ const session = event.locals.session;
59
+ return session?.user?.role === 'admin';
97
60
  },
98
61
 
99
62
  // Per-model configuration
100
63
  models: {
101
64
  User: {
102
- // Fields to hide from all views
103
65
  hidden: ['password', 'hashedPassword'],
104
- // Fields that cannot be edited
105
66
  readonly: ['id', 'createdAt', 'updatedAt'],
106
- // Fields to show in list view (default: auto-detect)
107
67
  listFields: ['email', 'name', 'role', 'createdAt'],
108
- // Custom label for the model
109
- label: 'Users',
110
- // Icon name (Lucide icon)
111
- icon: 'users'
112
- },
113
- Session: {
114
- // Completely exclude this model from admin
115
- hidden: true
68
+ label: 'Users'
116
69
  }
117
70
  },
118
71
 
@@ -125,29 +78,66 @@ svelteKitAdmin({
125
78
  logo: '/logo.svg',
126
79
  primaryColor: '#6366f1'
127
80
  }
128
- })
81
+ });
82
+ ```
83
+
84
+ ## With Authentication
85
+
86
+ If you already have an auth handler, use `sequence`:
87
+
88
+ ```typescript
89
+ import { createAdminHandler } from 'sveltekit-admin';
90
+ import { sequence } from '@sveltejs/kit/hooks';
91
+ import { prisma } from '$lib/server/prisma';
92
+
93
+ const authHandle = async ({ event, resolve }) => {
94
+ // Your auth logic here
95
+ event.locals.session = await getSession(event);
96
+ return resolve(event);
97
+ };
98
+
99
+ const adminHandle = createAdminHandler({
100
+ prisma,
101
+ authCheck: (event) => {
102
+ return event.locals.session?.user?.role === 'admin';
103
+ }
104
+ });
105
+
106
+ export const handle = sequence(authHandle, adminHandle);
129
107
  ```
130
108
 
131
- ## Components
132
-
133
- You can also use the admin components directly in your own pages:
134
-
135
- ```svelte
136
- <script>
137
- import { AdminLayout, DataTable, AdminForm } from 'sveltekit-admin/components';
138
- </script>
139
-
140
- <AdminLayout title="Custom Admin" models={[...]}>
141
- <DataTable
142
- data={users}
143
- columns={[
144
- { key: 'email', label: 'Email', sortable: true },
145
- { key: 'name', label: 'Name', sortable: true }
146
- ]}
147
- basePath="/admin"
148
- modelName="User"
149
- />
150
- </AdminLayout>
109
+ ## How It Works
110
+
111
+ The admin handler intercepts all requests to `/admin/*` and:
112
+
113
+ 1. Parses your Prisma schema to discover models
114
+ 2. Generates HTML pages on-the-fly (no Svelte routing needed)
115
+ 3. Handles all CRUD operations via form submissions
116
+
117
+ Routes handled:
118
+ - `/admin` → Dashboard
119
+ - `/admin/user` → List all users
120
+ - `/admin/user/new` → Create user form
121
+ - `/admin/user/123` → Edit user form
122
+
123
+ ## Model Configuration
124
+
125
+ ```typescript
126
+ models: {
127
+ User: {
128
+ // Fields to hide from all views
129
+ hidden: ['password', 'hashedPassword', 'twoFactorSecret'],
130
+
131
+ // Fields that cannot be edited (shown as readonly)
132
+ readonly: ['id', 'createdAt', 'updatedAt', 'emailVerified'],
133
+
134
+ // Fields to show in list view (default: first 6 non-hidden fields)
135
+ listFields: ['email', 'name', 'role', 'createdAt'],
136
+
137
+ // Custom display name for the model
138
+ label: 'Users'
139
+ }
140
+ }
151
141
  ```
152
142
 
153
143
  ## Prisma Schema Introspection
@@ -156,16 +146,24 @@ The admin automatically parses your Prisma schema and:
156
146
 
157
147
  - Extracts all models and their fields
158
148
  - Detects field types and generates appropriate form inputs
159
- - Handles relations (1-1, 1-N, N-N)
149
+ - Handles relations (excluded from forms for now)
160
150
  - Respects field attributes (@id, @unique, @default, @updatedAt)
161
- - Hides sensitive fields by name pattern (password, hash, secret)
151
+ - Auto-hides common sensitive fields (password, hash, secret, token)
152
+
153
+ ## Supported Field Types
154
+
155
+ | Prisma Type | Form Input |
156
+ |-------------|------------|
157
+ | String | text input (textarea for description/content/body) |
158
+ | Int, Float, Decimal, BigInt | number input |
159
+ | Boolean | checkbox |
160
+ | DateTime | datetime-local input |
161
+ | Json | textarea with JSON |
162
162
 
163
163
  ## Requirements
164
164
 
165
165
  - SvelteKit 2.x
166
- - Svelte 5.x
167
166
  - Prisma 5.x or 6.x
168
- - better-auth 1.x (for authentication)
169
167
 
170
168
  ## License
171
169
 
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * SvelteKit Admin
3
3
  * Django-like admin panel for SvelteKit + Prisma
4
4
  */
5
+ export { createAdminHandler, type AdminHandlerConfig } from './server/handler.js';
5
6
  export { createAdmin, createLayoutLoad, createDashboardLoad, createModelListLoad, createModelNewLoad, createModelNewAction, createModelEditLoad, createModelEditAction, createModelDeleteAction, createAdminGuard, type AdminConfig, type AdminContext } from './admin.js';
6
7
  export { parsePrismaSchema, parseSchemaContent, getDisplayFields, getEditableFields, getInputType, fieldToLabel, type PrismaSchema, type PrismaModel, type PrismaField } from './server/introspection/parser.js';
7
8
  export { createListOperation, createGetOperation, createCreateOperation, createUpdateOperation, createDeleteOperation, buildSearchWhere, buildFilterWhere, type ListOptions, type ListResult } from './server/crud/operations.js';
package/dist/index.js CHANGED
@@ -2,7 +2,9 @@
2
2
  * SvelteKit Admin
3
3
  * Django-like admin panel for SvelteKit + Prisma
4
4
  */
5
- // Core admin factory and loaders
5
+ // Standalone handler (recommended - zero config!)
6
+ export { createAdminHandler } from './server/handler.js';
7
+ // Core admin factory and loaders (legacy)
6
8
  export { createAdmin, createLayoutLoad, createDashboardLoad, createModelListLoad, createModelNewLoad, createModelNewAction, createModelEditLoad, createModelEditAction, createModelDeleteAction, createAdminGuard } from './admin.js';
7
9
  // Prisma introspection utilities
8
10
  export { parsePrismaSchema, parseSchemaContent, getDisplayFields, getEditableFields, getInputType, fieldToLabel } from './server/introspection/parser.js';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * SvelteKit Admin - Standalone Handler
3
+ * Zero files needed in routes - everything handled via hook
4
+ */
5
+ export interface AdminHandlerConfig {
6
+ /** Prisma client instance */
7
+ prisma: any;
8
+ /** Path to Prisma schema file */
9
+ prismaSchemaPath?: string;
10
+ /** Base path for admin routes (default: /admin) */
11
+ basePath?: string;
12
+ /** Authentication check - return true if user can access admin */
13
+ authCheck?: (event: any) => boolean | Promise<boolean>;
14
+ /** Per-model configuration */
15
+ models?: Record<string, {
16
+ hidden?: string[];
17
+ readonly?: string[];
18
+ listFields?: string[];
19
+ label?: string;
20
+ icon?: string;
21
+ }>;
22
+ /** Models to exclude from admin */
23
+ exclude?: string[];
24
+ /** Custom branding */
25
+ branding?: {
26
+ title?: string;
27
+ logo?: string;
28
+ primaryColor?: string;
29
+ };
30
+ }
31
+ export declare function createAdminHandler(config: AdminHandlerConfig): ({ event, resolve }: {
32
+ event: any;
33
+ resolve: Function;
34
+ }) => Promise<any>;
@@ -0,0 +1,902 @@
1
+ /**
2
+ * SvelteKit Admin - Standalone Handler
3
+ * Zero files needed in routes - everything handled via hook
4
+ */
5
+ import { parsePrismaSchema } from './introspection/parser.js';
6
+ function parseRoute(pathname, basePath) {
7
+ const path = pathname.slice(basePath.length).replace(/^\/+|\/+$/g, '');
8
+ if (!path) {
9
+ return { view: 'dashboard' };
10
+ }
11
+ const segments = path.split('/').filter(Boolean);
12
+ if (segments.length === 1) {
13
+ return { view: 'list', model: segments[0] };
14
+ }
15
+ if (segments.length === 2) {
16
+ if (segments[1] === 'new') {
17
+ return { view: 'create', model: segments[0] };
18
+ }
19
+ return { view: 'edit', model: segments[0], id: segments[1] };
20
+ }
21
+ return { view: 'dashboard' };
22
+ }
23
+ function toLabel(name) {
24
+ return name.replace(/([A-Z])/g, ' $1').trim();
25
+ }
26
+ function toPrismaModel(name) {
27
+ return name.charAt(0).toLowerCase() + name.slice(1);
28
+ }
29
+ // ============================================
30
+ // HTML Templates
31
+ // ============================================
32
+ function baseLayout(content, config, models, currentModel) {
33
+ const { branding = {} } = config;
34
+ const title = branding.title || 'Admin';
35
+ const primaryColor = branding.primaryColor || '#6366f1';
36
+ const basePath = config.basePath || '/admin';
37
+ return `<!DOCTYPE html>
38
+ <html lang="en">
39
+ <head>
40
+ <meta charset="UTF-8">
41
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
42
+ <title>${title}</title>
43
+ <style>
44
+ :root {
45
+ --ska-primary: ${primaryColor};
46
+ --ska-primary-hover: ${adjustColor(primaryColor, -15)};
47
+ }
48
+
49
+ * { box-sizing: border-box; margin: 0; padding: 0; }
50
+
51
+ body {
52
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
53
+ background: #f8fafc;
54
+ color: #1e293b;
55
+ line-height: 1.5;
56
+ }
57
+
58
+ .ska-layout {
59
+ display: flex;
60
+ min-height: 100vh;
61
+ }
62
+
63
+ .ska-sidebar {
64
+ width: 260px;
65
+ background: white;
66
+ border-right: 1px solid #e2e8f0;
67
+ padding: 1.5rem;
68
+ position: fixed;
69
+ height: 100vh;
70
+ overflow-y: auto;
71
+ }
72
+
73
+ .ska-logo {
74
+ font-size: 1.25rem;
75
+ font-weight: 700;
76
+ color: var(--ska-primary);
77
+ text-decoration: none;
78
+ display: block;
79
+ margin-bottom: 2rem;
80
+ }
81
+
82
+ .ska-nav { list-style: none; }
83
+
84
+ .ska-nav__item {
85
+ margin-bottom: 0.25rem;
86
+ }
87
+
88
+ .ska-nav__link {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 0.75rem;
92
+ padding: 0.625rem 0.875rem;
93
+ color: #64748b;
94
+ text-decoration: none;
95
+ border-radius: 0.375rem;
96
+ font-size: 0.875rem;
97
+ transition: all 0.15s;
98
+ }
99
+
100
+ .ska-nav__link:hover {
101
+ background: #f1f5f9;
102
+ color: #1e293b;
103
+ }
104
+
105
+ .ska-nav__link--active {
106
+ background: #eef2ff;
107
+ color: var(--ska-primary);
108
+ font-weight: 500;
109
+ }
110
+
111
+ .ska-main {
112
+ flex: 1;
113
+ margin-left: 260px;
114
+ padding: 2rem;
115
+ }
116
+
117
+ .ska-card {
118
+ background: white;
119
+ border: 1px solid #e2e8f0;
120
+ border-radius: 0.5rem;
121
+ padding: 1.5rem;
122
+ }
123
+
124
+ .ska-btn {
125
+ display: inline-flex;
126
+ align-items: center;
127
+ gap: 0.5rem;
128
+ padding: 0.5rem 1rem;
129
+ font-size: 0.875rem;
130
+ font-weight: 500;
131
+ border-radius: 0.375rem;
132
+ border: none;
133
+ cursor: pointer;
134
+ text-decoration: none;
135
+ transition: all 0.15s;
136
+ }
137
+
138
+ .ska-btn--primary {
139
+ background: var(--ska-primary);
140
+ color: white;
141
+ }
142
+
143
+ .ska-btn--primary:hover {
144
+ background: var(--ska-primary-hover);
145
+ }
146
+
147
+ .ska-btn--secondary {
148
+ background: #f1f5f9;
149
+ color: #475569;
150
+ }
151
+
152
+ .ska-btn--secondary:hover {
153
+ background: #e2e8f0;
154
+ }
155
+
156
+ .ska-btn--danger {
157
+ background: #fef2f2;
158
+ color: #dc2626;
159
+ }
160
+
161
+ .ska-btn--danger:hover {
162
+ background: #fee2e2;
163
+ }
164
+
165
+ .ska-btn--sm {
166
+ padding: 0.375rem 0.75rem;
167
+ font-size: 0.75rem;
168
+ }
169
+
170
+ h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem; }
171
+ h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; }
172
+
173
+ .ska-subtitle { color: #64748b; font-size: 0.875rem; margin-bottom: 1.5rem; }
174
+
175
+ /* Table styles */
176
+ .ska-table-wrap { overflow-x: auto; }
177
+
178
+ .ska-table {
179
+ width: 100%;
180
+ border-collapse: collapse;
181
+ font-size: 0.875rem;
182
+ }
183
+
184
+ .ska-table th {
185
+ text-align: left;
186
+ padding: 0.75rem 1rem;
187
+ background: #f8fafc;
188
+ border-bottom: 1px solid #e2e8f0;
189
+ font-weight: 600;
190
+ color: #64748b;
191
+ font-size: 0.75rem;
192
+ text-transform: uppercase;
193
+ letter-spacing: 0.05em;
194
+ }
195
+
196
+ .ska-table td {
197
+ padding: 0.75rem 1rem;
198
+ border-bottom: 1px solid #e2e8f0;
199
+ }
200
+
201
+ .ska-table tr:hover {
202
+ background: #f8fafc;
203
+ }
204
+
205
+ .ska-table__actions {
206
+ display: flex;
207
+ gap: 0.5rem;
208
+ }
209
+
210
+ /* Form styles */
211
+ .ska-form { max-width: 600px; }
212
+
213
+ .ska-field {
214
+ margin-bottom: 1.25rem;
215
+ }
216
+
217
+ .ska-label {
218
+ display: block;
219
+ font-size: 0.875rem;
220
+ font-weight: 500;
221
+ color: #374151;
222
+ margin-bottom: 0.375rem;
223
+ }
224
+
225
+ .ska-input {
226
+ width: 100%;
227
+ padding: 0.625rem 0.875rem;
228
+ font-size: 0.875rem;
229
+ border: 1px solid #d1d5db;
230
+ border-radius: 0.375rem;
231
+ transition: all 0.15s;
232
+ }
233
+
234
+ .ska-input:focus {
235
+ outline: none;
236
+ border-color: var(--ska-primary);
237
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
238
+ }
239
+
240
+ .ska-input[readonly] {
241
+ background: #f9fafb;
242
+ color: #6b7280;
243
+ }
244
+
245
+ .ska-checkbox-wrap {
246
+ display: flex;
247
+ align-items: center;
248
+ gap: 0.5rem;
249
+ }
250
+
251
+ .ska-checkbox {
252
+ width: 1rem;
253
+ height: 1rem;
254
+ }
255
+
256
+ .ska-form__actions {
257
+ display: flex;
258
+ gap: 0.75rem;
259
+ margin-top: 1.5rem;
260
+ padding-top: 1.5rem;
261
+ border-top: 1px solid #e2e8f0;
262
+ }
263
+
264
+ /* Stats grid */
265
+ .ska-stats {
266
+ display: grid;
267
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
268
+ gap: 1rem;
269
+ margin-bottom: 2rem;
270
+ }
271
+
272
+ .ska-stat {
273
+ background: white;
274
+ border: 1px solid #e2e8f0;
275
+ border-radius: 0.5rem;
276
+ padding: 1.25rem;
277
+ display: flex;
278
+ align-items: center;
279
+ gap: 1rem;
280
+ }
281
+
282
+ .ska-stat__icon {
283
+ width: 3rem;
284
+ height: 3rem;
285
+ background: #eef2ff;
286
+ border-radius: 0.5rem;
287
+ display: flex;
288
+ align-items: center;
289
+ justify-content: center;
290
+ color: var(--ska-primary);
291
+ }
292
+
293
+ .ska-stat__value {
294
+ font-size: 1.5rem;
295
+ font-weight: 700;
296
+ }
297
+
298
+ .ska-stat__label {
299
+ font-size: 0.875rem;
300
+ color: #64748b;
301
+ }
302
+
303
+ /* Models grid */
304
+ .ska-models {
305
+ display: grid;
306
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
307
+ gap: 1rem;
308
+ }
309
+
310
+ .ska-model-card {
311
+ background: white;
312
+ border: 1px solid #e2e8f0;
313
+ border-radius: 0.5rem;
314
+ padding: 1.25rem;
315
+ text-decoration: none;
316
+ transition: all 0.15s;
317
+ display: flex;
318
+ flex-direction: column;
319
+ justify-content: space-between;
320
+ min-height: 100px;
321
+ }
322
+
323
+ .ska-model-card:hover {
324
+ border-color: var(--ska-primary);
325
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
326
+ }
327
+
328
+ .ska-model-card__name {
329
+ font-weight: 600;
330
+ color: #1e293b;
331
+ margin-bottom: 0.25rem;
332
+ }
333
+
334
+ .ska-model-card__count {
335
+ font-size: 0.75rem;
336
+ color: #64748b;
337
+ }
338
+
339
+ .ska-model-card__footer {
340
+ color: var(--ska-primary);
341
+ font-size: 0.875rem;
342
+ font-weight: 500;
343
+ }
344
+
345
+ /* Header with actions */
346
+ .ska-header {
347
+ display: flex;
348
+ justify-content: space-between;
349
+ align-items: flex-start;
350
+ margin-bottom: 1.5rem;
351
+ }
352
+
353
+ /* Pagination */
354
+ .ska-pagination {
355
+ display: flex;
356
+ align-items: center;
357
+ gap: 0.5rem;
358
+ margin-top: 1rem;
359
+ padding-top: 1rem;
360
+ border-top: 1px solid #e2e8f0;
361
+ }
362
+
363
+ .ska-pagination__info {
364
+ font-size: 0.875rem;
365
+ color: #64748b;
366
+ margin-right: auto;
367
+ }
368
+
369
+ /* Search */
370
+ .ska-search {
371
+ margin-bottom: 1rem;
372
+ }
373
+
374
+ .ska-search__input {
375
+ padding: 0.5rem 1rem;
376
+ border: 1px solid #e2e8f0;
377
+ border-radius: 0.375rem;
378
+ font-size: 0.875rem;
379
+ width: 300px;
380
+ }
381
+
382
+ /* Back link */
383
+ .ska-back {
384
+ display: inline-flex;
385
+ align-items: center;
386
+ gap: 0.25rem;
387
+ color: #64748b;
388
+ text-decoration: none;
389
+ font-size: 0.875rem;
390
+ margin-bottom: 0.5rem;
391
+ }
392
+
393
+ .ska-back:hover { color: #475569; }
394
+
395
+ /* Alert */
396
+ .ska-alert {
397
+ padding: 1rem;
398
+ border-radius: 0.375rem;
399
+ margin-bottom: 1rem;
400
+ }
401
+
402
+ .ska-alert--error {
403
+ background: #fef2f2;
404
+ color: #dc2626;
405
+ border: 1px solid #fecaca;
406
+ }
407
+
408
+ .ska-alert--success {
409
+ background: #f0fdf4;
410
+ color: #16a34a;
411
+ border: 1px solid #bbf7d0;
412
+ }
413
+ </style>
414
+ </head>
415
+ <body>
416
+ <div class="ska-layout">
417
+ <aside class="ska-sidebar">
418
+ <a href="${basePath}" class="ska-logo">${title}</a>
419
+ <nav>
420
+ <ul class="ska-nav">
421
+ <li class="ska-nav__item">
422
+ <a href="${basePath}" class="ska-nav__link ${!currentModel ? 'ska-nav__link--active' : ''}">
423
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
424
+ Dashboard
425
+ </a>
426
+ </li>
427
+ ${models.map(m => `
428
+ <li class="ska-nav__item">
429
+ <a href="${basePath}/${m.name.toLowerCase()}" class="ska-nav__link ${currentModel?.toLowerCase() === m.name.toLowerCase() ? 'ska-nav__link--active' : ''}">
430
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7V4h16v3M9 20h6M12 4v16"/></svg>
431
+ ${m.label}
432
+ </a>
433
+ </li>
434
+ `).join('')}
435
+ </ul>
436
+ </nav>
437
+ </aside>
438
+ <main class="ska-main">
439
+ ${content}
440
+ </main>
441
+ </div>
442
+ </body>
443
+ </html>`;
444
+ }
445
+ function adjustColor(hex, percent) {
446
+ const num = parseInt(hex.replace('#', ''), 16);
447
+ const amt = Math.round(2.55 * percent);
448
+ const R = Math.max(0, Math.min(255, (num >> 16) + amt));
449
+ const G = Math.max(0, Math.min(255, ((num >> 8) & 0x00FF) + amt));
450
+ const B = Math.max(0, Math.min(255, (num & 0x0000FF) + amt));
451
+ return `#${(0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1)}`;
452
+ }
453
+ function dashboardView(models, stats, basePath) {
454
+ return `
455
+ <h1>Dashboard</h1>
456
+ <p class="ska-subtitle">Welcome to your admin panel</p>
457
+
458
+ <div class="ska-stats">
459
+ <div class="ska-stat">
460
+ <div class="ska-stat__icon">
461
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/></svg>
462
+ </div>
463
+ <div>
464
+ <div class="ska-stat__value">${stats.models}</div>
465
+ <div class="ska-stat__label">Models</div>
466
+ </div>
467
+ </div>
468
+ <div class="ska-stat">
469
+ <div class="ska-stat__icon">
470
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>
471
+ </div>
472
+ <div>
473
+ <div class="ska-stat__value">${stats.total}</div>
474
+ <div class="ska-stat__label">Total Records</div>
475
+ </div>
476
+ </div>
477
+ </div>
478
+
479
+ <h2>Models</h2>
480
+ <div class="ska-models">
481
+ ${models.map(m => `
482
+ <a href="${basePath}/${m.name.toLowerCase()}" class="ska-model-card">
483
+ <div>
484
+ <div class="ska-model-card__name">${m.label}</div>
485
+ <div class="ska-model-card__count">${m.count} records</div>
486
+ </div>
487
+ <div class="ska-model-card__footer">Manage →</div>
488
+ </a>
489
+ `).join('')}
490
+ </div>
491
+ `;
492
+ }
493
+ function listView(model, items, pagination, basePath, config) {
494
+ const modelConfig = config.models?.[model.name] || {};
495
+ const hidden = modelConfig.hidden || [];
496
+ const listFields = modelConfig.listFields;
497
+ let displayFields = model.fields.filter(f => !hidden.includes(f.name) &&
498
+ !f.relation &&
499
+ !['Json', 'Bytes'].includes(f.type));
500
+ if (listFields?.length) {
501
+ displayFields = displayFields.filter(f => listFields.includes(f.name));
502
+ }
503
+ displayFields = displayFields.slice(0, 6);
504
+ const totalPages = Math.ceil(pagination.total / pagination.perPage);
505
+ return `
506
+ <div class="ska-header">
507
+ <div>
508
+ <h1>${model.label}</h1>
509
+ <p class="ska-subtitle">${pagination.total} records</p>
510
+ </div>
511
+ <a href="${basePath}/${model.name.toLowerCase()}/new" class="ska-btn ska-btn--primary">
512
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
513
+ Add ${model.label}
514
+ </a>
515
+ </div>
516
+
517
+ <div class="ska-card">
518
+ <div class="ska-table-wrap">
519
+ <table class="ska-table">
520
+ <thead>
521
+ <tr>
522
+ ${displayFields.map(f => `<th>${toLabel(f.name)}</th>`).join('')}
523
+ <th>Actions</th>
524
+ </tr>
525
+ </thead>
526
+ <tbody>
527
+ ${items.length === 0 ? `
528
+ <tr><td colspan="${displayFields.length + 1}" style="text-align: center; color: #64748b; padding: 2rem;">No records found</td></tr>
529
+ ` : items.map(item => `
530
+ <tr>
531
+ ${displayFields.map(f => `<td>${formatValue(item[f.name], f.type)}</td>`).join('')}
532
+ <td class="ska-table__actions">
533
+ <a href="${basePath}/${model.name.toLowerCase()}/${item[model.primaryKey]}" class="ska-btn ska-btn--secondary ska-btn--sm">Edit</a>
534
+ <form method="POST" action="${basePath}/${model.name.toLowerCase()}/${item[model.primaryKey]}" style="display:inline" onsubmit="return confirm('Delete this item?')">
535
+ <input type="hidden" name="_action" value="delete">
536
+ <button type="submit" class="ska-btn ska-btn--danger ska-btn--sm">Delete</button>
537
+ </form>
538
+ </td>
539
+ </tr>
540
+ `).join('')}
541
+ </tbody>
542
+ </table>
543
+ </div>
544
+
545
+ ${totalPages > 1 ? `
546
+ <div class="ska-pagination">
547
+ <span class="ska-pagination__info">
548
+ Showing ${(pagination.page - 1) * pagination.perPage + 1} to ${Math.min(pagination.page * pagination.perPage, pagination.total)} of ${pagination.total}
549
+ </span>
550
+ ${pagination.page > 1 ? `<a href="?page=${pagination.page - 1}" class="ska-btn ska-btn--secondary ska-btn--sm">Previous</a>` : ''}
551
+ ${pagination.page < totalPages ? `<a href="?page=${pagination.page + 1}" class="ska-btn ska-btn--secondary ska-btn--sm">Next</a>` : ''}
552
+ </div>
553
+ ` : ''}
554
+ </div>
555
+ `;
556
+ }
557
+ function createView(model, basePath, config, error) {
558
+ const modelConfig = config.models?.[model.name] || {};
559
+ const hidden = modelConfig.hidden || [];
560
+ const formFields = model.fields.filter(f => !hidden.includes(f.name) &&
561
+ !f.isId &&
562
+ !f.isCreatedAt &&
563
+ !f.isUpdatedAt &&
564
+ !f.relation &&
565
+ !f.hasDefault);
566
+ return `
567
+ <a href="${basePath}/${model.name.toLowerCase()}" class="ska-back">← Back to list</a>
568
+ <h1>Create ${model.label}</h1>
569
+
570
+ ${error ? `<div class="ska-alert ska-alert--error">${error}</div>` : ''}
571
+
572
+ <div class="ska-card">
573
+ <form method="POST" class="ska-form">
574
+ <input type="hidden" name="_action" value="create">
575
+ ${formFields.map(f => fieldInput(f, null, false)).join('')}
576
+ <div class="ska-form__actions">
577
+ <button type="submit" class="ska-btn ska-btn--primary">Create</button>
578
+ <a href="${basePath}/${model.name.toLowerCase()}" class="ska-btn ska-btn--secondary">Cancel</a>
579
+ </div>
580
+ </form>
581
+ </div>
582
+ `;
583
+ }
584
+ function editView(model, item, basePath, config, error) {
585
+ const modelConfig = config.models?.[model.name] || {};
586
+ const hidden = modelConfig.hidden || [];
587
+ const readonly = modelConfig.readonly || [];
588
+ const formFields = model.fields.filter(f => !hidden.includes(f.name) &&
589
+ !f.relation);
590
+ const id = item[model.primaryKey];
591
+ return `
592
+ <a href="${basePath}/${model.name.toLowerCase()}" class="ska-back">← Back to list</a>
593
+ <h1>Edit ${model.label}</h1>
594
+ <p class="ska-subtitle">ID: ${id}</p>
595
+
596
+ ${error ? `<div class="ska-alert ska-alert--error">${error}</div>` : ''}
597
+
598
+ <div class="ska-card">
599
+ <form method="POST" class="ska-form">
600
+ <input type="hidden" name="_action" value="update">
601
+ ${formFields.map(f => fieldInput(f, item[f.name], f.isId || f.isCreatedAt || f.isUpdatedAt || readonly.includes(f.name))).join('')}
602
+ <div class="ska-form__actions">
603
+ <button type="submit" class="ska-btn ska-btn--primary">Save Changes</button>
604
+ <a href="${basePath}/${model.name.toLowerCase()}" class="ska-btn ska-btn--secondary">Cancel</a>
605
+ </div>
606
+ </form>
607
+ </div>
608
+ `;
609
+ }
610
+ function fieldInput(field, value, isReadonly) {
611
+ const label = toLabel(field.name);
612
+ const required = field.isRequired && !field.hasDefault && !isReadonly;
613
+ if (field.type === 'Boolean') {
614
+ return `
615
+ <div class="ska-field">
616
+ <label class="ska-checkbox-wrap">
617
+ <input type="checkbox" name="${field.name}" class="ska-checkbox" ${value ? 'checked' : ''} ${isReadonly ? 'disabled' : ''}>
618
+ <span class="ska-label">${label}</span>
619
+ </label>
620
+ </div>
621
+ `;
622
+ }
623
+ let inputType = 'text';
624
+ let inputValue = value ?? '';
625
+ switch (field.type) {
626
+ case 'Int':
627
+ case 'Float':
628
+ case 'Decimal':
629
+ case 'BigInt':
630
+ inputType = 'number';
631
+ break;
632
+ case 'DateTime':
633
+ inputType = 'datetime-local';
634
+ if (value) {
635
+ inputValue = new Date(value).toISOString().slice(0, 16);
636
+ }
637
+ break;
638
+ case 'Json':
639
+ return `
640
+ <div class="ska-field">
641
+ <label class="ska-label">${label}${required ? ' *' : ''}</label>
642
+ <textarea name="${field.name}" class="ska-input" rows="4" ${isReadonly ? 'readonly' : ''} ${required ? 'required' : ''}>${value ? JSON.stringify(value, null, 2) : ''}</textarea>
643
+ </div>
644
+ `;
645
+ }
646
+ // Handle String fields that might be long
647
+ if (field.type === 'String' && (field.name.includes('description') || field.name.includes('content') || field.name.includes('body'))) {
648
+ return `
649
+ <div class="ska-field">
650
+ <label class="ska-label">${label}${required ? ' *' : ''}</label>
651
+ <textarea name="${field.name}" class="ska-input" rows="4" ${isReadonly ? 'readonly' : ''} ${required ? 'required' : ''}>${inputValue}</textarea>
652
+ </div>
653
+ `;
654
+ }
655
+ return `
656
+ <div class="ska-field">
657
+ <label class="ska-label">${label}${required ? ' *' : ''}</label>
658
+ <input type="${inputType}" name="${field.name}" value="${escapeHtml(String(inputValue))}" class="ska-input" ${isReadonly ? 'readonly' : ''} ${required ? 'required' : ''}>
659
+ </div>
660
+ `;
661
+ }
662
+ function formatValue(value, type) {
663
+ if (value === null || value === undefined)
664
+ return '<span style="color:#94a3b8">—</span>';
665
+ if (type === 'DateTime') {
666
+ return new Date(value).toLocaleString();
667
+ }
668
+ if (type === 'Boolean') {
669
+ return value ? '✓' : '✗';
670
+ }
671
+ const str = String(value);
672
+ if (str.length > 50) {
673
+ return escapeHtml(str.slice(0, 50)) + '...';
674
+ }
675
+ return escapeHtml(str);
676
+ }
677
+ function escapeHtml(str) {
678
+ return str
679
+ .replace(/&/g, '&amp;')
680
+ .replace(/</g, '&lt;')
681
+ .replace(/>/g, '&gt;')
682
+ .replace(/"/g, '&quot;');
683
+ }
684
+ function notFoundView(message) {
685
+ return `
686
+ <h1>Not Found</h1>
687
+ <p class="ska-subtitle">${message}</p>
688
+ <a href="" class="ska-btn ska-btn--secondary">← Back to Dashboard</a>
689
+ `;
690
+ }
691
+ // ============================================
692
+ // Main Handler
693
+ // ============================================
694
+ export function createAdminHandler(config) {
695
+ const { prisma, prismaSchemaPath = './prisma/schema.prisma', basePath = '/admin', authCheck, exclude = [], models: modelsConfig = {}, branding = {} } = config;
696
+ // Parse schema once at startup
697
+ let schema = null;
698
+ try {
699
+ schema = parsePrismaSchema(prismaSchemaPath);
700
+ }
701
+ catch (e) {
702
+ console.warn('[sveltekit-admin] Could not parse Prisma schema:', e);
703
+ }
704
+ const filteredModels = schema?.models.filter(m => !exclude.includes(m.name)) || [];
705
+ const modelList = filteredModels.map(m => ({
706
+ name: m.name,
707
+ label: modelsConfig[m.name]?.label || toLabel(m.name)
708
+ }));
709
+ return async ({ event, resolve }) => {
710
+ const { pathname } = event.url;
711
+ // Only handle admin routes
712
+ if (!pathname.startsWith(basePath)) {
713
+ return resolve(event);
714
+ }
715
+ // Auth check
716
+ if (authCheck) {
717
+ const allowed = await authCheck(event);
718
+ if (!allowed) {
719
+ return new Response('Unauthorized', { status: 401 });
720
+ }
721
+ }
722
+ const route = parseRoute(pathname, basePath);
723
+ let content = '';
724
+ let currentModel;
725
+ try {
726
+ // Handle POST requests (create, update, delete)
727
+ if (event.request.method === 'POST') {
728
+ const formData = await event.request.formData();
729
+ const action = formData.get('_action');
730
+ if (route.model) {
731
+ const schemaModel = filteredModels.find(m => m.name.toLowerCase() === route.model?.toLowerCase());
732
+ if (!schemaModel) {
733
+ throw new Error(`Model "${route.model}" not found`);
734
+ }
735
+ const prismaModelName = toPrismaModel(schemaModel.name);
736
+ const primaryKey = schemaModel.fields.find(f => f.isId)?.name || 'id';
737
+ if (action === 'delete' && route.id) {
738
+ const parsedId = /^\d+$/.test(route.id) ? parseInt(route.id) : route.id;
739
+ await prisma[prismaModelName].delete({
740
+ where: { [primaryKey]: parsedId }
741
+ });
742
+ return new Response(null, {
743
+ status: 303,
744
+ headers: { Location: `${basePath}/${route.model.toLowerCase()}` }
745
+ });
746
+ }
747
+ if (action === 'create' || action === 'update') {
748
+ const data = {};
749
+ for (const field of schemaModel.fields) {
750
+ if (field.isId || field.isUpdatedAt || field.isCreatedAt || field.relation)
751
+ continue;
752
+ const value = formData.get(field.name);
753
+ if (value === null) {
754
+ if (field.type === 'Boolean') {
755
+ data[field.name] = false;
756
+ }
757
+ continue;
758
+ }
759
+ switch (field.type) {
760
+ case 'Int':
761
+ case 'BigInt':
762
+ data[field.name] = value ? parseInt(value.toString()) : null;
763
+ break;
764
+ case 'Float':
765
+ case 'Decimal':
766
+ data[field.name] = value ? parseFloat(value.toString()) : null;
767
+ break;
768
+ case 'Boolean':
769
+ data[field.name] = value === 'on' || value === 'true' || value === '1';
770
+ break;
771
+ case 'DateTime':
772
+ data[field.name] = value ? new Date(value.toString()) : null;
773
+ break;
774
+ case 'Json':
775
+ try {
776
+ data[field.name] = value ? JSON.parse(value.toString()) : null;
777
+ }
778
+ catch {
779
+ data[field.name] = null;
780
+ }
781
+ break;
782
+ default:
783
+ data[field.name] = value.toString();
784
+ }
785
+ }
786
+ if (action === 'create') {
787
+ await prisma[prismaModelName].create({ data });
788
+ }
789
+ else if (route.id) {
790
+ const parsedId = /^\d+$/.test(route.id) ? parseInt(route.id) : route.id;
791
+ await prisma[prismaModelName].update({
792
+ where: { [primaryKey]: parsedId },
793
+ data
794
+ });
795
+ }
796
+ return new Response(null, {
797
+ status: 303,
798
+ headers: { Location: `${basePath}/${route.model.toLowerCase()}` }
799
+ });
800
+ }
801
+ }
802
+ }
803
+ // GET requests - render views
804
+ if (route.view === 'dashboard') {
805
+ const modelsWithCounts = await Promise.all(filteredModels.map(async (m) => {
806
+ const prismaModelName = toPrismaModel(m.name);
807
+ let count = 0;
808
+ try {
809
+ count = await prisma[prismaModelName].count();
810
+ }
811
+ catch (e) { }
812
+ return {
813
+ name: m.name,
814
+ label: modelsConfig[m.name]?.label || toLabel(m.name),
815
+ count
816
+ };
817
+ }));
818
+ const totalRecords = modelsWithCounts.reduce((sum, m) => sum + m.count, 0);
819
+ content = dashboardView(modelsWithCounts, { total: totalRecords, models: modelsWithCounts.length }, basePath);
820
+ }
821
+ else if (route.view === 'list' && route.model) {
822
+ currentModel = route.model;
823
+ const schemaModel = filteredModels.find(m => m.name.toLowerCase() === route.model?.toLowerCase());
824
+ if (!schemaModel) {
825
+ content = notFoundView(`Model "${route.model}" not found`);
826
+ }
827
+ else {
828
+ const prismaModelName = toPrismaModel(schemaModel.name);
829
+ const primaryKey = schemaModel.fields.find(f => f.isId)?.name || 'id';
830
+ const page = parseInt(event.url.searchParams.get('page') || '1');
831
+ const perPage = 20;
832
+ const [items, total] = await Promise.all([
833
+ prisma[prismaModelName].findMany({
834
+ skip: (page - 1) * perPage,
835
+ take: perPage,
836
+ orderBy: { [primaryKey]: 'desc' }
837
+ }),
838
+ prisma[prismaModelName].count()
839
+ ]);
840
+ content = listView({
841
+ name: schemaModel.name,
842
+ label: modelsConfig[schemaModel.name]?.label || toLabel(schemaModel.name),
843
+ fields: schemaModel.fields,
844
+ primaryKey
845
+ }, items, { page, perPage, total }, basePath, config);
846
+ }
847
+ }
848
+ else if (route.view === 'create' && route.model) {
849
+ currentModel = route.model;
850
+ const schemaModel = filteredModels.find(m => m.name.toLowerCase() === route.model?.toLowerCase());
851
+ if (!schemaModel) {
852
+ content = notFoundView(`Model "${route.model}" not found`);
853
+ }
854
+ else {
855
+ const primaryKey = schemaModel.fields.find(f => f.isId)?.name || 'id';
856
+ content = createView({
857
+ name: schemaModel.name,
858
+ label: modelsConfig[schemaModel.name]?.label || toLabel(schemaModel.name),
859
+ fields: schemaModel.fields,
860
+ primaryKey
861
+ }, basePath, config);
862
+ }
863
+ }
864
+ else if (route.view === 'edit' && route.model && route.id) {
865
+ currentModel = route.model;
866
+ const schemaModel = filteredModels.find(m => m.name.toLowerCase() === route.model?.toLowerCase());
867
+ if (!schemaModel) {
868
+ content = notFoundView(`Model "${route.model}" not found`);
869
+ }
870
+ else {
871
+ const prismaModelName = toPrismaModel(schemaModel.name);
872
+ const primaryKey = schemaModel.fields.find(f => f.isId)?.name || 'id';
873
+ const parsedId = /^\d+$/.test(route.id) ? parseInt(route.id) : route.id;
874
+ const item = await prisma[prismaModelName].findUnique({
875
+ where: { [primaryKey]: parsedId }
876
+ });
877
+ if (!item) {
878
+ content = notFoundView(`${schemaModel.name} with ID "${route.id}" not found`);
879
+ }
880
+ else {
881
+ content = editView({
882
+ name: schemaModel.name,
883
+ label: modelsConfig[schemaModel.name]?.label || toLabel(schemaModel.name),
884
+ fields: schemaModel.fields,
885
+ primaryKey
886
+ }, item, basePath, config);
887
+ }
888
+ }
889
+ }
890
+ }
891
+ catch (e) {
892
+ console.error('[sveltekit-admin] Error:', e);
893
+ content = `<div class="ska-alert ska-alert--error">Error: ${escapeHtml(e.message || 'Unknown error')}</div>`;
894
+ }
895
+ const html = baseLayout(content, config, modelList, currentModel);
896
+ return new Response(html, {
897
+ headers: {
898
+ 'Content-Type': 'text/html; charset=utf-8'
899
+ }
900
+ });
901
+ };
902
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sveltekit-admin",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Django-like admin panel for SvelteKit + Prisma + better-auth",
5
5
  "type": "module",
6
6
  "svelte": "./dist/index.js",
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "scripts": {
30
30
  "dev": "vite dev",
31
- "build": "vite build && npm run package",
31
+ "build": "npm run package",
32
32
  "package": "svelte-kit sync && svelte-package -o dist",
33
33
  "prepublishOnly": "npm run package",
34
34
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",