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 +90 -92
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/server/handler.d.ts +34 -0
- package/dist/server/handler.js +902 -0
- package/package.json +2 -2
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
|
|
3
|
+
ðïļ A Django-like admin panel for SvelteKit applications with Prisma.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|

|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
- ð **Auto-introspection** of Prisma schema
|
|
11
11
|
- ð **CRUD operations** auto-generated for all models
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
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 {
|
|
57
|
-
import {
|
|
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 =
|
|
68
|
-
// your auth handle first
|
|
69
|
-
adminHandle
|
|
70
|
-
);
|
|
34
|
+
export const handle = createAdminHandler({ prisma });
|
|
71
35
|
```
|
|
72
36
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 (
|
|
149
|
+
- Handles relations (excluded from forms for now)
|
|
160
150
|
- Respects field attributes (@id, @unique, @default, @updatedAt)
|
|
161
|
-
-
|
|
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
|
-
//
|
|
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, '&')
|
|
680
|
+
.replace(/</g, '<')
|
|
681
|
+
.replace(/>/g, '>')
|
|
682
|
+
.replace(/"/g, '"');
|
|
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
|
|
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": "
|
|
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",
|