sveltekit-admin 0.1.0

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.
@@ -0,0 +1,573 @@
1
+ <script lang="ts">
2
+ import type { PrismaField } from '../server/introspection/parser.js';
3
+
4
+ interface Column {
5
+ key: string;
6
+ label: string;
7
+ sortable?: boolean;
8
+ type?: string;
9
+ render?: (value: unknown, row: Record<string, unknown>) => string;
10
+ }
11
+
12
+ interface Props {
13
+ data: Record<string, unknown>[];
14
+ columns: Column[];
15
+ primaryKey?: string;
16
+ basePath: string;
17
+ modelName: string;
18
+
19
+ // Pagination
20
+ page?: number;
21
+ perPage?: number;
22
+ total?: number;
23
+
24
+ // Sorting
25
+ orderBy?: string;
26
+ orderDir?: 'asc' | 'desc';
27
+
28
+ // Search
29
+ searchable?: boolean;
30
+ searchValue?: string;
31
+
32
+ // Actions
33
+ onDelete?: (id: string | number) => Promise<void>;
34
+ }
35
+
36
+ let {
37
+ data = [],
38
+ columns = [],
39
+ primaryKey = 'id',
40
+ basePath,
41
+ modelName,
42
+ page = 1,
43
+ perPage = 20,
44
+ total = 0,
45
+ orderBy = 'id',
46
+ orderDir = 'desc',
47
+ searchable = true,
48
+ searchValue = '',
49
+ onDelete
50
+ }: Props = $props();
51
+
52
+ let searchInput = $state(searchValue);
53
+ let deleteConfirm = $state<string | number | null>(null);
54
+ let isDeleting = $state(false);
55
+
56
+ const totalPages = $derived(Math.ceil(total / perPage));
57
+ const startItem = $derived((page - 1) * perPage + 1);
58
+ const endItem = $derived(Math.min(page * perPage, total));
59
+
60
+ function formatValue(value: unknown, type?: string): string {
61
+ if (value === null || value === undefined) return '—';
62
+
63
+ if (type === 'datetime' || value instanceof Date) {
64
+ return new Date(value as string).toLocaleString();
65
+ }
66
+
67
+ if (type === 'boolean' || typeof value === 'boolean') {
68
+ return value ? '✓' : '✗';
69
+ }
70
+
71
+ if (typeof value === 'object') {
72
+ // Handle relations - show a display field
73
+ const obj = value as Record<string, unknown>;
74
+ return String(obj.name || obj.title || obj.email || obj.id || JSON.stringify(value));
75
+ }
76
+
77
+ return String(value);
78
+ }
79
+
80
+ function getEditUrl(row: Record<string, unknown>): string {
81
+ return `${basePath}/${modelName.toLowerCase()}/${row[primaryKey]}`;
82
+ }
83
+
84
+ function handleSearch(e: Event) {
85
+ e.preventDefault();
86
+ const url = new URL(window.location.href);
87
+ if (searchInput) {
88
+ url.searchParams.set('search', searchInput);
89
+ } else {
90
+ url.searchParams.delete('search');
91
+ }
92
+ url.searchParams.set('page', '1');
93
+ window.location.href = url.toString();
94
+ }
95
+
96
+ function handleSort(column: Column) {
97
+ if (!column.sortable) return;
98
+
99
+ const url = new URL(window.location.href);
100
+ const newDir = orderBy === column.key && orderDir === 'asc' ? 'desc' : 'asc';
101
+ url.searchParams.set('orderBy', column.key);
102
+ url.searchParams.set('orderDir', newDir);
103
+ window.location.href = url.toString();
104
+ }
105
+
106
+ function goToPage(newPage: number) {
107
+ const url = new URL(window.location.href);
108
+ url.searchParams.set('page', String(newPage));
109
+ window.location.href = url.toString();
110
+ }
111
+
112
+ async function handleDelete(id: string | number) {
113
+ if (!onDelete) return;
114
+
115
+ isDeleting = true;
116
+ try {
117
+ await onDelete(id);
118
+ deleteConfirm = null;
119
+ window.location.reload();
120
+ } catch (e) {
121
+ alert('Error deleting record');
122
+ } finally {
123
+ isDeleting = false;
124
+ }
125
+ }
126
+
127
+ const icons = {
128
+ search: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`,
129
+ edit: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>`,
130
+ trash: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`,
131
+ sortAsc: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 8 4-4 4 4"/><path d="M7 4v16"/></svg>`,
132
+ sortDesc: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 16 4 4 4-4"/><path d="M7 20V4"/></svg>`,
133
+ chevronLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>`,
134
+ chevronRight: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>`
135
+ };
136
+ </script>
137
+
138
+ <div class="ska-table-container">
139
+ <!-- Toolbar -->
140
+ <div class="ska-table-toolbar">
141
+ {#if searchable}
142
+ <form class="ska-search" onsubmit={handleSearch}>
143
+ <span class="ska-search__icon">{@html icons.search}</span>
144
+ <input
145
+ type="text"
146
+ class="ska-search__input"
147
+ placeholder="Search..."
148
+ bind:value={searchInput}
149
+ />
150
+ </form>
151
+ {/if}
152
+
153
+ <a href="{basePath}/{modelName.toLowerCase()}/new" class="ska-btn ska-btn--primary">
154
+ + New {modelName}
155
+ </a>
156
+ </div>
157
+
158
+ <!-- Table -->
159
+ <div class="ska-table-wrapper">
160
+ <table class="ska-table">
161
+ <thead>
162
+ <tr>
163
+ {#each columns as column}
164
+ <th
165
+ class:ska-table__th--sortable={column.sortable}
166
+ onclick={() => handleSort(column)}
167
+ >
168
+ <span class="ska-table__th-content">
169
+ {column.label}
170
+ {#if column.sortable && orderBy === column.key}
171
+ <span class="ska-table__sort-icon">
172
+ {@html orderDir === 'asc' ? icons.sortAsc : icons.sortDesc}
173
+ </span>
174
+ {/if}
175
+ </span>
176
+ </th>
177
+ {/each}
178
+ <th class="ska-table__th--actions">Actions</th>
179
+ </tr>
180
+ </thead>
181
+ <tbody>
182
+ {#each data as row}
183
+ <tr>
184
+ {#each columns as column}
185
+ <td>
186
+ {#if column.render}
187
+ {@html column.render(row[column.key], row)}
188
+ {:else}
189
+ {formatValue(row[column.key], column.type)}
190
+ {/if}
191
+ </td>
192
+ {/each}
193
+ <td class="ska-table__actions">
194
+ <a href={getEditUrl(row)} class="ska-table__action ska-table__action--edit" title="Edit">
195
+ {@html icons.edit}
196
+ </a>
197
+ {#if onDelete}
198
+ <button
199
+ class="ska-table__action ska-table__action--delete"
200
+ title="Delete"
201
+ onclick={() => deleteConfirm = row[primaryKey] as string | number}
202
+ >
203
+ {@html icons.trash}
204
+ </button>
205
+ {/if}
206
+ </td>
207
+ </tr>
208
+ {:else}
209
+ <tr>
210
+ <td colspan={columns.length + 1} class="ska-table__empty">
211
+ No records found
212
+ </td>
213
+ </tr>
214
+ {/each}
215
+ </tbody>
216
+ </table>
217
+ </div>
218
+
219
+ <!-- Pagination -->
220
+ {#if totalPages > 1}
221
+ <div class="ska-pagination">
222
+ <span class="ska-pagination__info">
223
+ Showing {startItem} to {endItem} of {total} results
224
+ </span>
225
+
226
+ <div class="ska-pagination__controls">
227
+ <button
228
+ class="ska-pagination__btn"
229
+ disabled={page <= 1}
230
+ onclick={() => goToPage(page - 1)}
231
+ >
232
+ {@html icons.chevronLeft}
233
+ </button>
234
+
235
+ {#each Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
236
+ // Show pages around current page
237
+ let p;
238
+ if (totalPages <= 5) {
239
+ p = i + 1;
240
+ } else if (page <= 3) {
241
+ p = i + 1;
242
+ } else if (page >= totalPages - 2) {
243
+ p = totalPages - 4 + i;
244
+ } else {
245
+ p = page - 2 + i;
246
+ }
247
+ return p;
248
+ }) as pageNum}
249
+ <button
250
+ class="ska-pagination__btn"
251
+ class:ska-pagination__btn--active={pageNum === page}
252
+ onclick={() => goToPage(pageNum)}
253
+ >
254
+ {pageNum}
255
+ </button>
256
+ {/each}
257
+
258
+ <button
259
+ class="ska-pagination__btn"
260
+ disabled={page >= totalPages}
261
+ onclick={() => goToPage(page + 1)}
262
+ >
263
+ {@html icons.chevronRight}
264
+ </button>
265
+ </div>
266
+ </div>
267
+ {/if}
268
+ </div>
269
+
270
+ <!-- Delete Confirmation Modal -->
271
+ {#if deleteConfirm !== null}
272
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
273
+ <div class="ska-modal-overlay" role="presentation" onclick={() => deleteConfirm = null}>
274
+ <div class="ska-modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
275
+ <h3 class="ska-modal__title">Confirm Delete</h3>
276
+ <p class="ska-modal__text">Are you sure you want to delete this record? This action cannot be undone.</p>
277
+ <div class="ska-modal__actions">
278
+ <button class="ska-btn ska-btn--secondary" onclick={() => deleteConfirm = null}>
279
+ Cancel
280
+ </button>
281
+ <button
282
+ class="ska-btn ska-btn--danger"
283
+ onclick={() => handleDelete(deleteConfirm!)}
284
+ disabled={isDeleting}
285
+ >
286
+ {isDeleting ? 'Deleting...' : 'Delete'}
287
+ </button>
288
+ </div>
289
+ </div>
290
+ </div>
291
+ {/if}
292
+
293
+ <style>
294
+ .ska-table-container {
295
+ background: white;
296
+ border-radius: 0.5rem;
297
+ border: 1px solid #e2e8f0;
298
+ overflow: hidden;
299
+ }
300
+
301
+ .ska-table-toolbar {
302
+ display: flex;
303
+ justify-content: space-between;
304
+ align-items: center;
305
+ padding: 1rem;
306
+ border-bottom: 1px solid #e2e8f0;
307
+ gap: 1rem;
308
+ flex-wrap: wrap;
309
+ }
310
+
311
+ .ska-search {
312
+ position: relative;
313
+ flex: 1;
314
+ max-width: 20rem;
315
+ }
316
+
317
+ .ska-search__icon {
318
+ position: absolute;
319
+ left: 0.75rem;
320
+ top: 50%;
321
+ transform: translateY(-50%);
322
+ color: #94a3b8;
323
+ pointer-events: none;
324
+ }
325
+
326
+ .ska-search__input {
327
+ width: 100%;
328
+ padding: 0.5rem 0.75rem 0.5rem 2.5rem;
329
+ border: 1px solid #e2e8f0;
330
+ border-radius: 0.375rem;
331
+ font-size: 0.875rem;
332
+ outline: none;
333
+ transition: border-color 0.15s, box-shadow 0.15s;
334
+ }
335
+
336
+ .ska-search__input:focus {
337
+ border-color: var(--ska-primary, #6366f1);
338
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
339
+ }
340
+
341
+ .ska-btn {
342
+ display: inline-flex;
343
+ align-items: center;
344
+ gap: 0.5rem;
345
+ padding: 0.5rem 1rem;
346
+ border-radius: 0.375rem;
347
+ font-size: 0.875rem;
348
+ font-weight: 500;
349
+ text-decoration: none;
350
+ border: none;
351
+ cursor: pointer;
352
+ transition: all 0.15s;
353
+ }
354
+
355
+ .ska-btn--primary {
356
+ background: var(--ska-primary, #6366f1);
357
+ color: white;
358
+ }
359
+
360
+ .ska-btn--primary:hover {
361
+ opacity: 0.9;
362
+ }
363
+
364
+ .ska-btn--secondary {
365
+ background: #f1f5f9;
366
+ color: #475569;
367
+ }
368
+
369
+ .ska-btn--secondary:hover {
370
+ background: #e2e8f0;
371
+ }
372
+
373
+ .ska-btn--danger {
374
+ background: #ef4444;
375
+ color: white;
376
+ }
377
+
378
+ .ska-btn--danger:hover {
379
+ background: #dc2626;
380
+ }
381
+
382
+ .ska-btn:disabled {
383
+ opacity: 0.5;
384
+ cursor: not-allowed;
385
+ }
386
+
387
+ .ska-table-wrapper {
388
+ overflow-x: auto;
389
+ }
390
+
391
+ .ska-table {
392
+ width: 100%;
393
+ border-collapse: collapse;
394
+ font-size: 0.875rem;
395
+ }
396
+
397
+ .ska-table th {
398
+ text-align: left;
399
+ padding: 0.75rem 1rem;
400
+ background: #f8fafc;
401
+ font-weight: 600;
402
+ color: #475569;
403
+ border-bottom: 1px solid #e2e8f0;
404
+ white-space: nowrap;
405
+ }
406
+
407
+ .ska-table__th--sortable {
408
+ cursor: pointer;
409
+ user-select: none;
410
+ }
411
+
412
+ .ska-table__th--sortable:hover {
413
+ background: #f1f5f9;
414
+ }
415
+
416
+ .ska-table__th-content {
417
+ display: inline-flex;
418
+ align-items: center;
419
+ gap: 0.375rem;
420
+ }
421
+
422
+ .ska-table__sort-icon {
423
+ color: var(--ska-primary, #6366f1);
424
+ }
425
+
426
+ .ska-table__th--actions {
427
+ width: 100px;
428
+ text-align: center;
429
+ }
430
+
431
+ .ska-table td {
432
+ padding: 0.75rem 1rem;
433
+ border-bottom: 1px solid #f1f5f9;
434
+ color: #1e293b;
435
+ }
436
+
437
+ .ska-table tbody tr:hover {
438
+ background: #f8fafc;
439
+ }
440
+
441
+ .ska-table__actions {
442
+ display: flex;
443
+ align-items: center;
444
+ justify-content: center;
445
+ gap: 0.5rem;
446
+ }
447
+
448
+ .ska-table__action {
449
+ display: flex;
450
+ align-items: center;
451
+ justify-content: center;
452
+ width: 1.75rem;
453
+ height: 1.75rem;
454
+ border-radius: 0.25rem;
455
+ border: none;
456
+ background: transparent;
457
+ cursor: pointer;
458
+ transition: all 0.15s;
459
+ text-decoration: none;
460
+ }
461
+
462
+ .ska-table__action--edit {
463
+ color: #6366f1;
464
+ }
465
+
466
+ .ska-table__action--edit:hover {
467
+ background: #eef2ff;
468
+ }
469
+
470
+ .ska-table__action--delete {
471
+ color: #ef4444;
472
+ }
473
+
474
+ .ska-table__action--delete:hover {
475
+ background: #fef2f2;
476
+ }
477
+
478
+ .ska-table__empty {
479
+ text-align: center;
480
+ color: #94a3b8;
481
+ padding: 3rem 1rem !important;
482
+ }
483
+
484
+ .ska-pagination {
485
+ display: flex;
486
+ justify-content: space-between;
487
+ align-items: center;
488
+ padding: 0.75rem 1rem;
489
+ border-top: 1px solid #e2e8f0;
490
+ flex-wrap: wrap;
491
+ gap: 1rem;
492
+ }
493
+
494
+ .ska-pagination__info {
495
+ font-size: 0.875rem;
496
+ color: #64748b;
497
+ }
498
+
499
+ .ska-pagination__controls {
500
+ display: flex;
501
+ gap: 0.25rem;
502
+ }
503
+
504
+ .ska-pagination__btn {
505
+ display: flex;
506
+ align-items: center;
507
+ justify-content: center;
508
+ min-width: 2rem;
509
+ height: 2rem;
510
+ padding: 0 0.5rem;
511
+ border: 1px solid #e2e8f0;
512
+ border-radius: 0.25rem;
513
+ background: white;
514
+ font-size: 0.875rem;
515
+ color: #475569;
516
+ cursor: pointer;
517
+ transition: all 0.15s;
518
+ }
519
+
520
+ .ska-pagination__btn:hover:not(:disabled) {
521
+ background: #f8fafc;
522
+ border-color: #cbd5e1;
523
+ }
524
+
525
+ .ska-pagination__btn--active {
526
+ background: var(--ska-primary, #6366f1);
527
+ border-color: var(--ska-primary, #6366f1);
528
+ color: white;
529
+ }
530
+
531
+ .ska-pagination__btn:disabled {
532
+ opacity: 0.5;
533
+ cursor: not-allowed;
534
+ }
535
+
536
+ .ska-modal-overlay {
537
+ position: fixed;
538
+ inset: 0;
539
+ background: rgba(0, 0, 0, 0.5);
540
+ display: flex;
541
+ align-items: center;
542
+ justify-content: center;
543
+ z-index: 50;
544
+ }
545
+
546
+ .ska-modal {
547
+ background: white;
548
+ border-radius: 0.5rem;
549
+ padding: 1.5rem;
550
+ max-width: 24rem;
551
+ width: 90%;
552
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
553
+ }
554
+
555
+ .ska-modal__title {
556
+ font-size: 1.125rem;
557
+ font-weight: 600;
558
+ color: #1e293b;
559
+ margin: 0 0 0.5rem;
560
+ }
561
+
562
+ .ska-modal__text {
563
+ color: #64748b;
564
+ font-size: 0.875rem;
565
+ margin: 0 0 1.5rem;
566
+ }
567
+
568
+ .ska-modal__actions {
569
+ display: flex;
570
+ justify-content: flex-end;
571
+ gap: 0.75rem;
572
+ }
573
+ </style>
@@ -0,0 +1,25 @@
1
+ interface Column {
2
+ key: string;
3
+ label: string;
4
+ sortable?: boolean;
5
+ type?: string;
6
+ render?: (value: unknown, row: Record<string, unknown>) => string;
7
+ }
8
+ interface Props {
9
+ data: Record<string, unknown>[];
10
+ columns: Column[];
11
+ primaryKey?: string;
12
+ basePath: string;
13
+ modelName: string;
14
+ page?: number;
15
+ perPage?: number;
16
+ total?: number;
17
+ orderBy?: string;
18
+ orderDir?: 'asc' | 'desc';
19
+ searchable?: boolean;
20
+ searchValue?: string;
21
+ onDelete?: (id: string | number) => Promise<void>;
22
+ }
23
+ declare const DataTable: import("svelte").Component<Props, {}, "">;
24
+ type DataTable = ReturnType<typeof DataTable>;
25
+ export default DataTable;
@@ -0,0 +1,3 @@
1
+ export { default as AdminLayout } from './AdminLayout.svelte';
2
+ export { default as DataTable } from './DataTable.svelte';
3
+ export { default as AdminForm } from './AdminForm.svelte';
@@ -0,0 +1,3 @@
1
+ export { default as AdminLayout } from './AdminLayout.svelte';
2
+ export { default as DataTable } from './DataTable.svelte';
3
+ export { default as AdminForm } from './AdminForm.svelte';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * SvelteKit Admin
3
+ * Django-like admin panel for SvelteKit + Prisma
4
+ */
5
+ export { createAdmin, createLayoutLoad, createDashboardLoad, createModelListLoad, createModelNewLoad, createModelNewAction, createModelEditLoad, createModelEditAction, createModelDeleteAction, createAdminGuard, type AdminConfig, type AdminContext } from './admin.js';
6
+ export { parsePrismaSchema, parseSchemaContent, getDisplayFields, getEditableFields, getInputType, fieldToLabel, type PrismaSchema, type PrismaModel, type PrismaField } from './server/introspection/parser.js';
7
+ export { createListOperation, createGetOperation, createCreateOperation, createUpdateOperation, createDeleteOperation, buildSearchWhere, buildFilterWhere, type ListOptions, type ListResult } from './server/crud/operations.js';
8
+ export { createAuthGuard, defaultAdminCheck, type AdminAuthConfig, type AdminSession } from './server/auth/guard.js';
9
+ export { default as AdminLayout } from './components/AdminLayout.svelte';
10
+ export { default as DataTable } from './components/DataTable.svelte';
11
+ export { default as AdminForm } from './components/AdminForm.svelte';
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * SvelteKit Admin
3
+ * Django-like admin panel for SvelteKit + Prisma
4
+ */
5
+ // Core admin factory and loaders
6
+ export { createAdmin, createLayoutLoad, createDashboardLoad, createModelListLoad, createModelNewLoad, createModelNewAction, createModelEditLoad, createModelEditAction, createModelDeleteAction, createAdminGuard } from './admin.js';
7
+ // Prisma introspection utilities
8
+ export { parsePrismaSchema, parseSchemaContent, getDisplayFields, getEditableFields, getInputType, fieldToLabel } from './server/introspection/parser.js';
9
+ // CRUD operations
10
+ export { createListOperation, createGetOperation, createCreateOperation, createUpdateOperation, createDeleteOperation, buildSearchWhere, buildFilterWhere } from './server/crud/operations.js';
11
+ // Auth utilities
12
+ export { createAuthGuard, defaultAdminCheck } from './server/auth/guard.js';
13
+ // Re-export components
14
+ export { default as AdminLayout } from './components/AdminLayout.svelte';
15
+ export { default as DataTable } from './components/DataTable.svelte';
16
+ export { default as AdminForm } from './components/AdminForm.svelte';
@@ -0,0 +1,36 @@
1
+ /**
2
+ * better-auth integration for admin panel
3
+ */
4
+ export interface AdminAuthConfig {
5
+ provider: 'better-auth';
6
+ adminRole?: string;
7
+ adminCheck?: (user: unknown) => boolean | Promise<boolean>;
8
+ }
9
+ /**
10
+ * Default admin check - looks for role field
11
+ */
12
+ export declare function defaultAdminCheck(user: unknown, adminRole?: string): boolean;
13
+ /**
14
+ * Create auth guard for server routes
15
+ */
16
+ export declare function createAuthGuard(config: AdminAuthConfig): (locals: Record<string, unknown>) => Promise<{
17
+ authorized: boolean;
18
+ user: unknown | null;
19
+ }>;
20
+ /**
21
+ * Type for admin session
22
+ */
23
+ export interface AdminSession {
24
+ user: {
25
+ id: string;
26
+ email: string;
27
+ name?: string;
28
+ role?: string;
29
+ [key: string]: unknown;
30
+ };
31
+ session: {
32
+ id: string;
33
+ expiresAt: Date;
34
+ [key: string]: unknown;
35
+ };
36
+ }