millas 0.1.2 → 0.1.3
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/package.json +3 -2
- package/src/admin/Admin.js +311 -503
- package/src/admin/views/layouts/base.njk +468 -0
- package/src/admin/views/pages/dashboard.njk +84 -0
- package/src/admin/views/pages/form.njk +145 -0
- package/src/admin/views/pages/list.njk +164 -0
- package/src/container/Application.js +32 -1
- package/src/router/Router.js +48 -0
- package/src/scaffold/templates.js +22 -29
package/src/admin/Admin.js
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const nunjucks = require('nunjucks');
|
|
3
5
|
const { AdminResource, AdminField, AdminFilter } = require('./resources/AdminResource');
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Admin
|
|
7
9
|
*
|
|
8
|
-
* The Millas admin panel
|
|
9
|
-
*
|
|
10
|
-
* Usage in AppServiceProvider.boot():
|
|
11
|
-
*
|
|
12
|
-
* const { Admin } = require('millas/src');
|
|
10
|
+
* The Millas admin panel.
|
|
11
|
+
* Auto-mounts at /admin by default — no configuration needed.
|
|
13
12
|
*
|
|
13
|
+
* Basic usage (AppServiceProvider.boot):
|
|
14
14
|
* Admin.register(UserResource);
|
|
15
|
-
* Admin.
|
|
16
|
-
* Admin.register(OrderResource);
|
|
17
|
-
*
|
|
18
|
-
* Then mount in bootstrap/app.js:
|
|
15
|
+
* Admin.mount(route, expressApp);
|
|
19
16
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
17
|
+
* Custom resource:
|
|
18
|
+
* class UserResource extends AdminResource {
|
|
19
|
+
* static model = User;
|
|
20
|
+
* static label = 'Users';
|
|
21
|
+
* static fields() { return [AdminField.id(), AdminField.text('name')]; }
|
|
22
|
+
* }
|
|
23
23
|
*/
|
|
24
24
|
class Admin {
|
|
25
25
|
constructor() {
|
|
26
|
-
this._resources = new Map();
|
|
26
|
+
this._resources = new Map();
|
|
27
27
|
this._config = {
|
|
28
|
-
prefix:
|
|
29
|
-
title:
|
|
30
|
-
auth: false, // set to a middleware fn to protect
|
|
28
|
+
prefix: '/admin',
|
|
29
|
+
title: 'Millas Admin',
|
|
31
30
|
};
|
|
31
|
+
this._njk = null;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// ─── Configuration ─────────────────────────────────────────────────────────
|
|
@@ -40,42 +40,21 @@ class Admin {
|
|
|
40
40
|
|
|
41
41
|
// ─── Registration ─────────────────────────────────────────────────────────
|
|
42
42
|
|
|
43
|
-
/**
|
|
44
|
-
* Register a resource (AdminResource subclass or raw Model).
|
|
45
|
-
*
|
|
46
|
-
* Admin.register(UserResource)
|
|
47
|
-
* Admin.register(User) — auto-generates a basic resource
|
|
48
|
-
*/
|
|
49
43
|
register(ResourceOrModel) {
|
|
50
44
|
let Resource = ResourceOrModel;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (!ResourceOrModel.prototype && ResourceOrModel.fields !== undefined) {
|
|
45
|
+
if (ResourceOrModel.fields !== undefined &&
|
|
46
|
+
!(ResourceOrModel.prototype instanceof AdminResource)) {
|
|
54
47
|
Resource = this._autoResource(ResourceOrModel);
|
|
55
|
-
} else if (!(ResourceOrModel.prototype instanceof AdminResource) &&
|
|
56
|
-
ResourceOrModel !== AdminResource) {
|
|
57
|
-
// It's a Model class (has static fields property)
|
|
58
|
-
if (ResourceOrModel.fields !== undefined) {
|
|
59
|
-
Resource = this._autoResource(ResourceOrModel);
|
|
60
|
-
}
|
|
61
48
|
}
|
|
62
|
-
|
|
63
|
-
const slug = Resource.slug || Resource._getLabel?.().toLowerCase() || Resource.name.toLowerCase();
|
|
64
|
-
this._resources.set(slug, Resource);
|
|
49
|
+
this._resources.set(Resource.slug, Resource);
|
|
65
50
|
return this;
|
|
66
51
|
}
|
|
67
52
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
*/
|
|
71
|
-
registerMany(resources = []) {
|
|
72
|
-
resources.forEach(r => this.register(r));
|
|
53
|
+
registerMany(list = []) {
|
|
54
|
+
list.forEach(r => this.register(r));
|
|
73
55
|
return this;
|
|
74
56
|
}
|
|
75
57
|
|
|
76
|
-
/**
|
|
77
|
-
* Get all registered resources.
|
|
78
|
-
*/
|
|
79
58
|
resources() {
|
|
80
59
|
return [...this._resources.values()];
|
|
81
60
|
}
|
|
@@ -83,89 +62,200 @@ class Admin {
|
|
|
83
62
|
// ─── Mount ────────────────────────────────────────────────────────────────
|
|
84
63
|
|
|
85
64
|
/**
|
|
86
|
-
* Mount admin routes onto
|
|
87
|
-
* Call this
|
|
65
|
+
* Mount all admin routes onto express directly.
|
|
66
|
+
* Call this AFTER app.boot() in bootstrap/app.js:
|
|
88
67
|
*
|
|
89
|
-
*
|
|
68
|
+
* Admin.mount(expressApp);
|
|
90
69
|
*/
|
|
91
|
-
mount(
|
|
70
|
+
mount(expressApp) {
|
|
92
71
|
const prefix = this._config.prefix;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
expressApp.get(`${prefix}`,
|
|
97
|
-
expressApp.get(`${prefix}
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
label: r._getLabel ? r._getLabel() : r.label,
|
|
107
|
-
singular: r._getLabelSingular ? r._getLabelSingular() : r.labelSingular,
|
|
108
|
-
icon: r.icon,
|
|
109
|
-
canCreate: r.canCreate,
|
|
110
|
-
})));
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// Per-resource CRUD API
|
|
114
|
-
route.get('/:resource', (req, res) => admin._list(req, res));
|
|
115
|
-
route.get('/:resource/:id', (req, res) => admin._show(req, res));
|
|
116
|
-
route.post('/:resource', (req, res) => admin._store(req, res));
|
|
117
|
-
route.put('/:resource/:id', (req, res) => admin._update(req, res));
|
|
118
|
-
route.delete('/:resource/:id', (req, res) => admin._destroy(req, res));
|
|
119
|
-
});
|
|
72
|
+
this._njk = this._setupNunjucks(expressApp);
|
|
73
|
+
|
|
74
|
+
// Dashboard
|
|
75
|
+
expressApp.get(`${prefix}`, (q, s) => this._dashboard(q, s));
|
|
76
|
+
expressApp.get(`${prefix}/`, (q, s) => this._dashboard(q, s));
|
|
77
|
+
|
|
78
|
+
// Resource routes
|
|
79
|
+
expressApp.get (`${prefix}/:resource`, (q, s) => this._list(q, s));
|
|
80
|
+
expressApp.get (`${prefix}/:resource/create`, (q, s) => this._create(q, s));
|
|
81
|
+
expressApp.post (`${prefix}/:resource`, (q, s) => this._store(q, s));
|
|
82
|
+
expressApp.get (`${prefix}/:resource/:id/edit`, (q, s) => this._edit(q, s));
|
|
83
|
+
expressApp.post (`${prefix}/:resource/:id`, (q, s) => this._update(q, s));
|
|
84
|
+
expressApp.post (`${prefix}/:resource/:id/delete`, (q, s) => this._destroy(q, s));
|
|
120
85
|
|
|
121
86
|
return this;
|
|
122
87
|
}
|
|
123
88
|
|
|
124
|
-
// ───
|
|
89
|
+
// ─── Nunjucks setup ───────────────────────────────────────────────────────
|
|
125
90
|
|
|
126
|
-
|
|
91
|
+
_setupNunjucks(expressApp) {
|
|
92
|
+
const viewsDir = path.join(__dirname, 'views');
|
|
93
|
+
const env = nunjucks.configure(viewsDir, {
|
|
94
|
+
autoescape: true,
|
|
95
|
+
express: expressApp,
|
|
96
|
+
noCache: process.env.NODE_ENV !== 'production',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── Custom filters ───────────────────────────────────────────
|
|
100
|
+
env.addFilter('adminCell', (value, field) => {
|
|
101
|
+
if (value === null || value === undefined) return '<span class="cell-muted">—</span>';
|
|
102
|
+
switch (field.type) {
|
|
103
|
+
case 'boolean':
|
|
104
|
+
return value ? '<span class="bool-yes">✓</span>' : '<span class="bool-no">✗</span>';
|
|
105
|
+
case 'badge': {
|
|
106
|
+
const colorMap = { admin:'purple', user:'blue', active:'green', inactive:'gray', pending:'yellow', published:'green', draft:'gray' };
|
|
107
|
+
const c = (field.colors && field.colors[String(value)]) || colorMap[String(value)] || 'gray';
|
|
108
|
+
return `<span class="badge badge-${c}">${value}</span>`;
|
|
109
|
+
}
|
|
110
|
+
case 'datetime':
|
|
111
|
+
try { return new Date(value).toLocaleString(); } catch { return String(value); }
|
|
112
|
+
case 'date':
|
|
113
|
+
try { return new Date(value).toLocaleDateString(); } catch { return String(value); }
|
|
114
|
+
case 'password':
|
|
115
|
+
return '<span class="cell-muted">••••••••</span>';
|
|
116
|
+
case 'image':
|
|
117
|
+
return value ? `<img src="${value}" style="width:36px;height:36px;border-radius:6px;object-fit:cover">` : '<span class="cell-muted">—</span>';
|
|
118
|
+
case 'json':
|
|
119
|
+
return `<code style="font-size:11px;color:var(--text-muted)">${JSON.stringify(value).slice(0, 40)}…</code>`;
|
|
120
|
+
default: {
|
|
121
|
+
const str = String(value);
|
|
122
|
+
return str.length > 60 ? str.slice(0, 60) + '…' : str;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
env.addFilter('min', (arr) => Math.min(...arr));
|
|
128
|
+
env.addFilter('dump', (val) => {
|
|
129
|
+
try { return JSON.stringify(val, null, 2); } catch { return String(val); }
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return env;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Base render context ──────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
_ctx(req, extra = {}) {
|
|
138
|
+
return {
|
|
139
|
+
adminPrefix: this._config.prefix,
|
|
140
|
+
adminTitle: this._config.title,
|
|
141
|
+
resources: this.resources().map(r => ({
|
|
142
|
+
slug: r.slug,
|
|
143
|
+
label: r._getLabel(),
|
|
144
|
+
singular: r._getLabelSingular(),
|
|
145
|
+
icon: r.icon,
|
|
146
|
+
})),
|
|
147
|
+
flash: this._pullFlash(req),
|
|
148
|
+
activePage: extra.activePage || null,
|
|
149
|
+
activeResource: extra.activeResource || null,
|
|
150
|
+
...extra,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Pages ────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
async _dashboard(req, res) {
|
|
127
157
|
try {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
canDelete: Resource.canDelete,
|
|
154
|
-
});
|
|
158
|
+
const resourceData = await Promise.all(
|
|
159
|
+
this.resources().map(async (R) => {
|
|
160
|
+
let count = 0;
|
|
161
|
+
let recent = [];
|
|
162
|
+
try {
|
|
163
|
+
count = await R.model.count();
|
|
164
|
+
const result = await R.fetchList({ page: 1, perPage: 5 });
|
|
165
|
+
recent = result.data.map(r => r.toJSON ? r.toJSON() : r);
|
|
166
|
+
} catch {}
|
|
167
|
+
return {
|
|
168
|
+
slug: R.slug,
|
|
169
|
+
label: R._getLabel(),
|
|
170
|
+
icon: R.icon,
|
|
171
|
+
count,
|
|
172
|
+
recent,
|
|
173
|
+
listFields: R.fields().filter(f => !f._hidden && !f._detailOnly).slice(0, 4).map(f => f.toJSON()),
|
|
174
|
+
};
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
res.render('pages/dashboard.njk', this._ctx(req, {
|
|
179
|
+
pageTitle: 'Dashboard',
|
|
180
|
+
activePage: 'dashboard',
|
|
181
|
+
resources: resourceData,
|
|
182
|
+
}));
|
|
155
183
|
} catch (err) {
|
|
156
184
|
this._error(res, err);
|
|
157
185
|
}
|
|
158
186
|
}
|
|
159
187
|
|
|
160
|
-
async
|
|
188
|
+
async _list(req, res) {
|
|
161
189
|
try {
|
|
162
|
-
const
|
|
163
|
-
if (!
|
|
190
|
+
const R = this._resolve(req.params.resource, res);
|
|
191
|
+
if (!R) return;
|
|
192
|
+
|
|
193
|
+
const page = Number(req.query.page) || 1;
|
|
194
|
+
const search = req.query.search || '';
|
|
195
|
+
const sort = req.query.sort || 'id';
|
|
196
|
+
const order = req.query.order || 'desc';
|
|
197
|
+
|
|
198
|
+
// Collect active filters
|
|
199
|
+
const activeFilters = {};
|
|
200
|
+
if (req.query.filter) {
|
|
201
|
+
for (const [k, v] of Object.entries(req.query.filter)) {
|
|
202
|
+
if (v !== '') activeFilters[k] = v;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
164
205
|
|
|
165
|
-
const
|
|
166
|
-
const
|
|
206
|
+
const result = await R.fetchList({ page, search, sort, order, filters: activeFilters });
|
|
207
|
+
const rows = result.data.map(r => r.toJSON ? r.toJSON() : r);
|
|
208
|
+
|
|
209
|
+
const listFields = R.fields()
|
|
210
|
+
.filter(f => !f._hidden && !f._detailOnly)
|
|
211
|
+
.map(f => f.toJSON());
|
|
212
|
+
|
|
213
|
+
res.render('pages/list.njk', this._ctx(req, {
|
|
214
|
+
pageTitle: R._getLabel(),
|
|
215
|
+
activeResource: req.params.resource,
|
|
216
|
+
resource: {
|
|
217
|
+
slug: R.slug,
|
|
218
|
+
label: R._getLabel(),
|
|
219
|
+
singular: R._getLabelSingular(),
|
|
220
|
+
icon: R.icon,
|
|
221
|
+
canCreate: R.canCreate,
|
|
222
|
+
canEdit: R.canEdit,
|
|
223
|
+
canDelete: R.canDelete,
|
|
224
|
+
},
|
|
225
|
+
rows,
|
|
226
|
+
listFields,
|
|
227
|
+
filters: R.filters().map(f => f.toJSON()),
|
|
228
|
+
activeFilters,
|
|
229
|
+
sortable: R.sortable || [],
|
|
230
|
+
total: result.total,
|
|
231
|
+
page: result.page,
|
|
232
|
+
perPage: result.perPage,
|
|
233
|
+
lastPage: result.lastPage,
|
|
234
|
+
search,
|
|
235
|
+
sort,
|
|
236
|
+
order,
|
|
237
|
+
}));
|
|
238
|
+
} catch (err) {
|
|
239
|
+
this._error(res, err);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
167
242
|
|
|
168
|
-
|
|
243
|
+
async _create(req, res) {
|
|
244
|
+
try {
|
|
245
|
+
const R = this._resolve(req.params.resource, res);
|
|
246
|
+
if (!R) return;
|
|
247
|
+
if (!R.canCreate) return res.status(403).send('Not allowed');
|
|
248
|
+
|
|
249
|
+
res.render('pages/form.njk', this._ctx(req, {
|
|
250
|
+
pageTitle: `New ${R._getLabelSingular()}`,
|
|
251
|
+
activeResource: req.params.resource,
|
|
252
|
+
resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: false },
|
|
253
|
+
formFields: this._formFields(R),
|
|
254
|
+
formAction: `${this._config.prefix}/${R.slug}`,
|
|
255
|
+
isEdit: false,
|
|
256
|
+
record: {},
|
|
257
|
+
errors: {},
|
|
258
|
+
}));
|
|
169
259
|
} catch (err) {
|
|
170
260
|
this._error(res, err);
|
|
171
261
|
}
|
|
@@ -173,436 +263,154 @@ class Admin {
|
|
|
173
263
|
|
|
174
264
|
async _store(req, res) {
|
|
175
265
|
try {
|
|
176
|
-
const
|
|
177
|
-
if (!
|
|
178
|
-
if (!
|
|
266
|
+
const R = this._resolve(req.params.resource, res);
|
|
267
|
+
if (!R) return;
|
|
268
|
+
if (!R.canCreate) return res.status(403).send('Not allowed');
|
|
179
269
|
|
|
180
|
-
|
|
181
|
-
|
|
270
|
+
await R.create(req.body);
|
|
271
|
+
this._flash(req, 'success', `${R._getLabelSingular()} created successfully`);
|
|
272
|
+
this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
182
273
|
} catch (err) {
|
|
274
|
+
if (err.status === 422) {
|
|
275
|
+
const R = this._resources.get(req.params.resource);
|
|
276
|
+
return res.render('pages/form.njk', this._ctx(req, {
|
|
277
|
+
pageTitle: `New ${R._getLabelSingular()}`,
|
|
278
|
+
activeResource: req.params.resource,
|
|
279
|
+
resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: false },
|
|
280
|
+
formFields: this._formFields(R),
|
|
281
|
+
formAction: `${this._config.prefix}/${R.slug}`,
|
|
282
|
+
isEdit: false,
|
|
283
|
+
record: req.body,
|
|
284
|
+
errors: err.errors || {},
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
183
287
|
this._error(res, err);
|
|
184
288
|
}
|
|
185
289
|
}
|
|
186
290
|
|
|
187
|
-
async
|
|
291
|
+
async _edit(req, res) {
|
|
188
292
|
try {
|
|
189
|
-
const
|
|
190
|
-
if (!
|
|
191
|
-
if (!
|
|
293
|
+
const R = this._resolve(req.params.resource, res);
|
|
294
|
+
if (!R) return;
|
|
295
|
+
if (!R.canEdit) return res.status(403).send('Not allowed');
|
|
296
|
+
|
|
297
|
+
const record = await R.fetchOne(req.params.id);
|
|
298
|
+
const data = record.toJSON ? record.toJSON() : record;
|
|
299
|
+
|
|
300
|
+
res.render('pages/form.njk', this._ctx(req, {
|
|
301
|
+
pageTitle: `Edit ${R._getLabelSingular()} #${req.params.id}`,
|
|
302
|
+
activeResource: req.params.resource,
|
|
303
|
+
resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: R.canDelete },
|
|
304
|
+
formFields: this._formFields(R),
|
|
305
|
+
formAction: `${this._config.prefix}/${R.slug}/${req.params.id}`,
|
|
306
|
+
isEdit: true,
|
|
307
|
+
record: data,
|
|
308
|
+
errors: {},
|
|
309
|
+
}));
|
|
310
|
+
} catch (err) {
|
|
311
|
+
this._error(res, err);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
192
314
|
|
|
193
|
-
|
|
194
|
-
|
|
315
|
+
async _update(req, res) {
|
|
316
|
+
try {
|
|
317
|
+
const R = this._resolve(req.params.resource, res);
|
|
318
|
+
if (!R) return;
|
|
319
|
+
if (!R.canEdit) return res.status(403).send('Not allowed');
|
|
320
|
+
|
|
321
|
+
// Support method override
|
|
322
|
+
const method = req.body._method || 'POST';
|
|
323
|
+
if (method === 'PUT' || method === 'POST') {
|
|
324
|
+
await R.update(req.params.id, req.body);
|
|
325
|
+
this._flash(req, 'success', `${R._getLabelSingular()} updated successfully`);
|
|
326
|
+
this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
327
|
+
}
|
|
195
328
|
} catch (err) {
|
|
329
|
+
if (err.status === 422) {
|
|
330
|
+
const R = this._resources.get(req.params.resource);
|
|
331
|
+
return res.render('pages/form.njk', this._ctx(req, {
|
|
332
|
+
pageTitle: `Edit ${R._getLabelSingular()} #${req.params.id}`,
|
|
333
|
+
activeResource: req.params.resource,
|
|
334
|
+
resource: { slug: R.slug, label: R._getLabel(), singular: R._getLabelSingular(), icon: R.icon, canDelete: R.canDelete },
|
|
335
|
+
formFields: this._formFields(R),
|
|
336
|
+
formAction: `${this._config.prefix}/${R.slug}/${req.params.id}`,
|
|
337
|
+
isEdit: true,
|
|
338
|
+
record: { id: req.params.id, ...req.body },
|
|
339
|
+
errors: err.errors || {},
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
196
342
|
this._error(res, err);
|
|
197
343
|
}
|
|
198
344
|
}
|
|
199
345
|
|
|
200
346
|
async _destroy(req, res) {
|
|
201
347
|
try {
|
|
202
|
-
const
|
|
203
|
-
if (!
|
|
204
|
-
if (!
|
|
348
|
+
const R = this._resolve(req.params.resource, res);
|
|
349
|
+
if (!R) return;
|
|
350
|
+
if (!R.canDelete) return res.status(403).send('Not allowed');
|
|
205
351
|
|
|
206
|
-
await
|
|
207
|
-
|
|
352
|
+
await R.destroy(req.params.id);
|
|
353
|
+
this._flash(req, 'success', `${R._getLabelSingular()} deleted`);
|
|
354
|
+
this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
208
355
|
} catch (err) {
|
|
209
356
|
this._error(res, err);
|
|
210
357
|
}
|
|
211
358
|
}
|
|
212
359
|
|
|
213
|
-
// ───
|
|
214
|
-
|
|
215
|
-
_serveShell(req, res) {
|
|
216
|
-
const title = this._config.title;
|
|
217
|
-
const resources = this.resources().map(r => ({
|
|
218
|
-
slug: r.slug,
|
|
219
|
-
label: r._getLabel ? r._getLabel() : r.label || r.name,
|
|
220
|
-
icon: r.icon,
|
|
221
|
-
}));
|
|
360
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
222
361
|
|
|
223
|
-
|
|
224
|
-
|
|
362
|
+
_formFields(R) {
|
|
363
|
+
return R.fields()
|
|
364
|
+
.filter(f => f._type !== 'id' && !f._listOnly && !f._readonly)
|
|
365
|
+
.map(f => ({ ...f.toJSON(), required: !f._nullable }));
|
|
225
366
|
}
|
|
226
367
|
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
</a>`
|
|
233
|
-
).join('\n');
|
|
234
|
-
|
|
235
|
-
return `<!DOCTYPE html>
|
|
236
|
-
<html lang="en">
|
|
237
|
-
<head>
|
|
238
|
-
<meta charset="UTF-8">
|
|
239
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
240
|
-
<title>${title}</title>
|
|
241
|
-
<style>
|
|
242
|
-
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
243
|
-
:root {
|
|
244
|
-
--bg: #0f1117;
|
|
245
|
-
--surface: #1a1d27;
|
|
246
|
-
--surface2: #222535;
|
|
247
|
-
--border: #2e3146;
|
|
248
|
-
--primary: #6366f1;
|
|
249
|
-
--primary-h: #818cf8;
|
|
250
|
-
--text: #e2e8f0;
|
|
251
|
-
--text-muted:#64748b;
|
|
252
|
-
--success: #22c55e;
|
|
253
|
-
--danger: #ef4444;
|
|
254
|
-
--warning: #f59e0b;
|
|
255
|
-
--radius: 8px;
|
|
256
|
-
--font: 'Inter', system-ui, sans-serif;
|
|
257
|
-
}
|
|
258
|
-
body { font-family: var(--font); background: var(--bg); color: var(--text); display: flex; height: 100vh; overflow: hidden; }
|
|
259
|
-
/* ── Sidebar ── */
|
|
260
|
-
#sidebar {
|
|
261
|
-
width: 240px; min-width: 240px; background: var(--surface); border-right: 1px solid var(--border);
|
|
262
|
-
display: flex; flex-direction: column; overflow-y: auto;
|
|
368
|
+
_resolve(slug, res) {
|
|
369
|
+
const R = this._resources.get(slug);
|
|
370
|
+
if (!R) {
|
|
371
|
+
res.status(404).send(`Resource "${slug}" not registered in Admin`);
|
|
372
|
+
return null;
|
|
263
373
|
}
|
|
264
|
-
|
|
265
|
-
.sidebar-title { font-size: 16px; font-weight: 700; color: var(--primary); letter-spacing: -0.3px; }
|
|
266
|
-
.sidebar-subtitle { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
|
267
|
-
.nav-section { padding: 12px 8px 8px; }
|
|
268
|
-
.nav-section-label { font-size: 10px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.8px; padding: 0 8px 6px; }
|
|
269
|
-
.nav-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: var(--radius); color: var(--text-muted); text-decoration: none; font-size: 13px; font-weight: 500; transition: all .15s; cursor: pointer; }
|
|
270
|
-
.nav-item:hover, .nav-item.active { background: var(--surface2); color: var(--text); }
|
|
271
|
-
.nav-item.active { color: var(--primary-h); }
|
|
272
|
-
.nav-icon { font-size: 15px; width: 20px; text-align: center; }
|
|
273
|
-
/* ── Main ── */
|
|
274
|
-
#main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
275
|
-
#topbar { background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px; height: 56px; display: flex; align-items: center; justify-content: space-between; }
|
|
276
|
-
#page-title { font-size: 15px; font-weight: 600; }
|
|
277
|
-
#content { flex: 1; overflow-y: auto; padding: 24px; }
|
|
278
|
-
/* ── Table ── */
|
|
279
|
-
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
280
|
-
.card-header { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
281
|
-
.card-title { font-size: 14px; font-weight: 600; }
|
|
282
|
-
table { width: 100%; border-collapse: collapse; }
|
|
283
|
-
th { text-align: left; padding: 10px 16px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); background: var(--surface2); border-bottom: 1px solid var(--border); white-space: nowrap; cursor: pointer; user-select: none; }
|
|
284
|
-
th:hover { color: var(--text); }
|
|
285
|
-
td { padding: 12px 16px; font-size: 13px; border-bottom: 1px solid var(--border); }
|
|
286
|
-
tr:last-child td { border-bottom: none; }
|
|
287
|
-
tr:hover td { background: var(--surface2); }
|
|
288
|
-
/* ── Controls ── */
|
|
289
|
-
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; border: none; transition: all .15s; }
|
|
290
|
-
.btn-primary { background: var(--primary); color: #fff; }
|
|
291
|
-
.btn-primary:hover { background: var(--primary-h); }
|
|
292
|
-
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
|
|
293
|
-
.btn-ghost:hover { background: var(--surface2); color: var(--text); }
|
|
294
|
-
.btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--border); }
|
|
295
|
-
.btn-danger:hover { background: var(--danger); color: #fff; }
|
|
296
|
-
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
297
|
-
input, select, textarea { background: var(--surface2); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 8px 12px; font-size: 13px; width: 100%; outline: none; font-family: var(--font); }
|
|
298
|
-
input:focus, select:focus, textarea:focus { border-color: var(--primary); }
|
|
299
|
-
.search-input { width: 240px; }
|
|
300
|
-
/* ── Badge ── */
|
|
301
|
-
.badge { display: inline-block; padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
|
302
|
-
.badge-blue { background: #1e3a5f; color: #60a5fa; }
|
|
303
|
-
.badge-red { background: #3b1212; color: #f87171; }
|
|
304
|
-
.badge-green { background: #14342b; color: #4ade80; }
|
|
305
|
-
.badge-yellow { background: #3b2800; color: #fbbf24; }
|
|
306
|
-
.badge-gray { background: var(--surface2); color: var(--text-muted); }
|
|
307
|
-
/* ── Pagination ── */
|
|
308
|
-
.pagination { display: flex; align-items: center; gap: 6px; padding: 14px 20px; border-top: 1px solid var(--border); }
|
|
309
|
-
.page-info { font-size: 12px; color: var(--text-muted); margin-left: auto; }
|
|
310
|
-
.page-btn { width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; border: 1px solid var(--border); background: transparent; color: var(--text); cursor: pointer; font-size: 12px; }
|
|
311
|
-
.page-btn:hover { background: var(--surface2); }
|
|
312
|
-
.page-btn.active { background: var(--primary); border-color: var(--primary); }
|
|
313
|
-
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
314
|
-
/* ── Modal ── */
|
|
315
|
-
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
|
|
316
|
-
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; width: 100%; max-width: 540px; max-height: 90vh; overflow-y: auto; }
|
|
317
|
-
.modal-header { padding: 20px 24px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
|
318
|
-
.modal-title { font-size: 15px; font-weight: 600; }
|
|
319
|
-
.modal-body { padding: 20px 24px; display: flex; flex-direction: column; gap: 16px; }
|
|
320
|
-
.modal-footer { padding: 16px 24px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
|
|
321
|
-
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
|
322
|
-
.form-label { font-size: 12px; font-weight: 500; color: var(--text-muted); }
|
|
323
|
-
/* ── States ── */
|
|
324
|
-
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
|
325
|
-
.empty-icon { font-size: 36px; margin-bottom: 12px; }
|
|
326
|
-
.loading { opacity: .5; pointer-events: none; }
|
|
327
|
-
.bool-yes { color: var(--success); } .bool-no { color: var(--danger); }
|
|
328
|
-
.actions { display: flex; gap: 6px; }
|
|
329
|
-
.close-btn { background: none; border: none; color: var(--text-muted); font-size: 20px; cursor: pointer; line-height: 1; padding: 2px; }
|
|
330
|
-
.close-btn:hover { color: var(--text); }
|
|
331
|
-
.alert { padding: 10px 14px; border-radius: 6px; font-size: 13px; margin-bottom: 16px; }
|
|
332
|
-
.alert-success { background: #14342b; color: #4ade80; border: 1px solid #166534; }
|
|
333
|
-
.alert-error { background: #3b1212; color: #f87171; border: 1px solid #7f1d1d; }
|
|
334
|
-
</style>
|
|
335
|
-
</head>
|
|
336
|
-
<body>
|
|
337
|
-
<nav id="sidebar">
|
|
338
|
-
<div class="sidebar-header">
|
|
339
|
-
<div class="sidebar-title">⚡ ${title}</div>
|
|
340
|
-
<div class="sidebar-subtitle">Admin Panel</div>
|
|
341
|
-
</div>
|
|
342
|
-
<div class="nav-section">
|
|
343
|
-
<div class="nav-section-label">Resources</div>
|
|
344
|
-
${navItems}
|
|
345
|
-
</div>
|
|
346
|
-
</nav>
|
|
347
|
-
<div id="main">
|
|
348
|
-
<div id="topbar">
|
|
349
|
-
<span id="page-title">Dashboard</span>
|
|
350
|
-
<div style="display:flex;gap:8px;align-items:center">
|
|
351
|
-
<span style="font-size:12px;color:var(--text-muted)" id="record-count"></span>
|
|
352
|
-
</div>
|
|
353
|
-
</div>
|
|
354
|
-
<div id="content">
|
|
355
|
-
<div class="empty-state">
|
|
356
|
-
<div class="empty-icon">📋</div>
|
|
357
|
-
<p>Select a resource from the sidebar to get started.</p>
|
|
358
|
-
</div>
|
|
359
|
-
</div>
|
|
360
|
-
</div>
|
|
361
|
-
<div id="modal-root"></div>
|
|
362
|
-
|
|
363
|
-
<script>
|
|
364
|
-
const API = '/admin/api';
|
|
365
|
-
let state = { resource: null, page: 1, search: '', sort: 'id', order: 'desc', filters: {}, data: null };
|
|
366
|
-
|
|
367
|
-
// ── Navigation ──────────────────────────────────────────────────
|
|
368
|
-
document.querySelectorAll('.nav-item').forEach(el => {
|
|
369
|
-
el.addEventListener('click', e => {
|
|
370
|
-
e.preventDefault();
|
|
371
|
-
loadResource(el.dataset.slug);
|
|
372
|
-
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
373
|
-
el.classList.add('active');
|
|
374
|
-
});
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
// Handle browser back/forward
|
|
378
|
-
window.addEventListener('popstate', () => {
|
|
379
|
-
const slug = location.pathname.split('/admin/')[1]?.split('/')[0];
|
|
380
|
-
if (slug) loadResource(slug);
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
async function loadResource(slug) {
|
|
384
|
-
state = { ...state, resource: slug, page: 1, search: '', sort: 'id', order: 'desc', filters: {} };
|
|
385
|
-
history.pushState({}, '', '/admin/' + slug);
|
|
386
|
-
await fetchAndRender();
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// ── Fetch + Render ───────────────────────────────────────────────
|
|
390
|
-
async function fetchAndRender() {
|
|
391
|
-
const { resource, page, search, sort, order, filters } = state;
|
|
392
|
-
const params = new URLSearchParams({ page, sort, order });
|
|
393
|
-
if (search) params.set('search', search);
|
|
394
|
-
if (Object.keys(filters).length) params.set('filters', JSON.stringify(filters));
|
|
395
|
-
|
|
396
|
-
const res = await fetch(API + '/' + resource + '?' + params);
|
|
397
|
-
const json = await res.json();
|
|
398
|
-
state.data = json;
|
|
399
|
-
|
|
400
|
-
document.getElementById('page-title').textContent =
|
|
401
|
-
json.resource?.label || resource.replace(/-/g,' ').replace(/\\b\\w/g, c => c.toUpperCase());
|
|
402
|
-
document.getElementById('record-count').textContent = json.total + ' records';
|
|
403
|
-
|
|
404
|
-
renderTable(json);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function renderTable({ data, fields, total, page, perPage, lastPage, canCreate, canEdit, canDelete, sortable = [] }) {
|
|
408
|
-
const listFields = fields.filter(f => !f.detailOnly && !f.hidden);
|
|
409
|
-
const content = document.getElementById('content');
|
|
410
|
-
|
|
411
|
-
const searchBar = \`
|
|
412
|
-
<div class="card-header">
|
|
413
|
-
<span class="card-title">\${state.resource}</span>
|
|
414
|
-
<div style="display:flex;gap:8px;align-items:center;flex:1;justify-content:flex-end">
|
|
415
|
-
<input class="search-input" type="text" placeholder="Search..." value="\${state.search}"
|
|
416
|
-
oninput="debSearch(this.value)">
|
|
417
|
-
\${canCreate ? '<button class="btn btn-primary" onclick="openCreate()">+ New</button>' : ''}
|
|
418
|
-
</div>
|
|
419
|
-
</div>\`;
|
|
420
|
-
|
|
421
|
-
const thead = '<tr>' + listFields.map(f => {
|
|
422
|
-
const canSort = sortable.includes(f.name);
|
|
423
|
-
const arrow = state.sort === f.name ? (state.order === 'asc' ? ' ↑' : ' ↓') : '';
|
|
424
|
-
return \`<th onclick="\${canSort ? 'toggleSort("' + f.name + '")' : ''}"
|
|
425
|
-
style="\${canSort ? '' : 'cursor:default'}">\${f.label}\${arrow}</th>\`;
|
|
426
|
-
}).join('') + (canEdit || canDelete ? '<th style="cursor:default">Actions</th>' : '') + '</tr>';
|
|
427
|
-
|
|
428
|
-
const tbody = data.length === 0
|
|
429
|
-
? \`<tr><td colspan="\${listFields.length + 1}" style="text-align:center;padding:40px;color:var(--text-muted)">No records found</td></tr>\`
|
|
430
|
-
: data.map(row => {
|
|
431
|
-
const cells = listFields.map(f => \`<td>\${renderCell(f, row[f.name])}</td>\`).join('');
|
|
432
|
-
const actions = (canEdit || canDelete) ? \`<td><div class="actions">
|
|
433
|
-
\${canEdit ? \`<button class="btn btn-ghost btn-sm" onclick='openEdit(\${JSON.stringify(row.id)})'>Edit</button>\` : ''}
|
|
434
|
-
\${canDelete ? \`<button class="btn btn-danger btn-sm" onclick='confirmDelete(\${JSON.stringify(row.id)})'>Delete</button>\` : ''}
|
|
435
|
-
</div></td>\` : '';
|
|
436
|
-
return \`<tr>\${cells}\${actions}</tr>\`;
|
|
437
|
-
}).join('');
|
|
438
|
-
|
|
439
|
-
const pageNums = Array.from({ length: Math.min(lastPage, 7) }, (_, i) => i + 1)
|
|
440
|
-
.map(n => \`<button class="page-btn \${n === page ? 'active' : ''}" onclick="goPage(\${n})">\${n}</button>\`).join('');
|
|
441
|
-
|
|
442
|
-
content.innerHTML = \`
|
|
443
|
-
<div class="card">
|
|
444
|
-
\${searchBar}
|
|
445
|
-
<div style="overflow-x:auto"><table><thead>\${thead}</thead><tbody>\${tbody}</tbody></table></div>
|
|
446
|
-
<div class="pagination">
|
|
447
|
-
<button class="page-btn" \${page <= 1 ? 'disabled' : ''} onclick="goPage(\${page-1})">‹</button>
|
|
448
|
-
\${pageNums}
|
|
449
|
-
<button class="page-btn" \${page >= lastPage ? 'disabled' : ''} onclick="goPage(\${page+1})">›</button>
|
|
450
|
-
<span class="page-info">Showing \${(page-1)*perPage+1}–\${Math.min(page*perPage,total)} of \${total}</span>
|
|
451
|
-
</div>
|
|
452
|
-
</div>\`;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function renderCell(field, value) {
|
|
456
|
-
if (value === null || value === undefined) return '<span style="color:var(--text-muted)">—</span>';
|
|
457
|
-
if (field.type === 'boolean') return value
|
|
458
|
-
? '<span class="bool-yes">✓</span>'
|
|
459
|
-
: '<span class="bool-no">✗</span>';
|
|
460
|
-
if (field.type === 'badge') {
|
|
461
|
-
const colorMap = { admin:'red', user:'blue', active:'green', inactive:'gray', pending:'yellow' };
|
|
462
|
-
const c = (field.colors && field.colors[value]) || colorMap[value] || 'gray';
|
|
463
|
-
return \`<span class="badge badge-\${c}">\${value}</span>\`;
|
|
374
|
+
return R;
|
|
464
375
|
}
|
|
465
|
-
if (field.type === 'datetime' && value) return new Date(value).toLocaleString();
|
|
466
|
-
if (field.type === 'date' && value) return new Date(value).toLocaleDateString();
|
|
467
|
-
if (field.type === 'image' && value) return \`<img src="\${value}" style="width:32px;height:32px;border-radius:4px;object-fit:cover">\`;
|
|
468
|
-
if (field.type === 'password') return '••••••••';
|
|
469
|
-
const str = String(value);
|
|
470
|
-
return str.length > 60 ? str.slice(0, 60) + '…' : str;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// ── Sort / Page / Search ─────────────────────────────────────────
|
|
474
|
-
function toggleSort(col) {
|
|
475
|
-
if (state.sort === col) state.order = state.order === 'asc' ? 'desc' : 'asc';
|
|
476
|
-
else { state.sort = col; state.order = 'asc'; }
|
|
477
|
-
state.page = 1;
|
|
478
|
-
fetchAndRender();
|
|
479
|
-
}
|
|
480
|
-
function goPage(p) { state.page = p; fetchAndRender(); }
|
|
481
|
-
let _searchTimer;
|
|
482
|
-
function debSearch(v) { clearTimeout(_searchTimer); _searchTimer = setTimeout(() => { state.search = v; state.page = 1; fetchAndRender(); }, 300); }
|
|
483
|
-
|
|
484
|
-
// ── Create / Edit ────────────────────────────────────────────────
|
|
485
|
-
async function openCreate() {
|
|
486
|
-
const { fields } = state.data;
|
|
487
|
-
const editFields = fields.filter(f => f.type !== 'id' && !f.listOnly && !f.readonly);
|
|
488
|
-
showModal('New ' + state.resource, renderForm(editFields, {}), async () => {
|
|
489
|
-
const data = collectForm();
|
|
490
|
-
const res = await fetch(API + '/' + state.resource, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
|
|
491
|
-
if (res.ok) { closeModal(); showAlert('Created successfully', 'success'); fetchAndRender(); }
|
|
492
|
-
else { const e = await res.json(); showAlert(e.message || 'Error', 'error'); }
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
async function openEdit(id) {
|
|
497
|
-
const res = await fetch(API + '/' + state.resource + '/' + id);
|
|
498
|
-
const { data: record, fields } = await res.json();
|
|
499
|
-
const editFields = fields.filter(f => f.type !== 'id' && !f.listOnly && !f.readonly);
|
|
500
|
-
showModal('Edit ' + state.resource + ' #' + id, renderForm(editFields, record), async () => {
|
|
501
|
-
const data = collectForm();
|
|
502
|
-
const res2 = await fetch(API + '/' + state.resource + '/' + id, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
|
|
503
|
-
if (res2.ok) { closeModal(); showAlert('Updated successfully', 'success'); fetchAndRender(); }
|
|
504
|
-
else { const e = await res2.json(); showAlert(e.message || 'Error', 'error'); }
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
function renderForm(fields, record) {
|
|
509
|
-
return fields.map(f => {
|
|
510
|
-
const val = record[f.name] !== undefined ? record[f.name] : '';
|
|
511
|
-
let input;
|
|
512
|
-
if (f.type === 'boolean') {
|
|
513
|
-
input = \`<select name="\${f.name}"><option value="1" \${val ? 'selected' : ''}>Yes</option><option value="0" \${!val ? 'selected' : ''}>No</option></select>\`;
|
|
514
|
-
} else if (f.type === 'select' && f.options) {
|
|
515
|
-
const opts = f.options.map(o => \`<option value="\${o}" \${val===o?'selected':''}>\${o}</option>\`).join('');
|
|
516
|
-
input = \`<select name="\${f.name}">\${opts}</select>\`;
|
|
517
|
-
} else if (f.type === 'textarea') {
|
|
518
|
-
input = \`<textarea name="\${f.name}" rows="4">\${val}</textarea>\`;
|
|
519
|
-
} else {
|
|
520
|
-
const t = f.type === 'password' ? 'password' : f.type === 'email' ? 'email' : f.type === 'number' ? 'number' : 'text';
|
|
521
|
-
input = \`<input type="\${t}" name="\${f.name}" value="\${val}" placeholder="\${f.placeholder || ''}">\`;
|
|
522
|
-
}
|
|
523
|
-
return \`<div class="form-group"><label class="form-label">\${f.label}</label>\${input}\${f.help ? '<small style="color:var(--text-muted)">' + f.help + '</small>' : ''}</div>\`;
|
|
524
|
-
}).join('');
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function collectForm() {
|
|
528
|
-
const modal = document.querySelector('.modal');
|
|
529
|
-
const data = {};
|
|
530
|
-
modal.querySelectorAll('[name]').forEach(el => { data[el.name] = el.value; });
|
|
531
|
-
return data;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// ── Delete ───────────────────────────────────────────────────────
|
|
535
|
-
function confirmDelete(id) {
|
|
536
|
-
showModal('Confirm Delete', \`<p style="color:var(--text-muted)">Are you sure you want to delete record #\${id}? This cannot be undone.</p>\`, async () => {
|
|
537
|
-
const res = await fetch(API + '/' + state.resource + '/' + id, { method: 'DELETE' });
|
|
538
|
-
if (res.ok) { closeModal(); showAlert('Deleted', 'success'); fetchAndRender(); }
|
|
539
|
-
else { const e = await res.json(); showAlert(e.message || 'Error', 'error'); }
|
|
540
|
-
}, { confirmLabel: 'Delete', confirmClass: 'btn-danger' });
|
|
541
|
-
}
|
|
542
376
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
<
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
<div class="modal-body">\${bodyHtml}</div>
|
|
553
|
-
<div class="modal-footer">
|
|
554
|
-
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
|
555
|
-
<button class="btn \${opts.confirmClass || 'btn-primary'}" onclick="modalConfirm()">\${opts.confirmLabel || 'Save'}</button>
|
|
556
|
-
</div>
|
|
557
|
-
</div>
|
|
558
|
-
</div>\`;
|
|
559
|
-
window._modalConfirm = onConfirm;
|
|
560
|
-
}
|
|
561
|
-
function closeModal() { document.getElementById('modal-root').innerHTML = ''; }
|
|
562
|
-
function modalConfirm() { if (window._modalConfirm) window._modalConfirm(); }
|
|
563
|
-
|
|
564
|
-
// ── Alert ────────────────────────────────────────────────────────
|
|
565
|
-
function showAlert(msg, type = 'success') {
|
|
566
|
-
const el = document.createElement('div');
|
|
567
|
-
el.className = \`alert alert-\${type}\`;
|
|
568
|
-
el.textContent = msg;
|
|
569
|
-
document.getElementById('content').prepend(el);
|
|
570
|
-
setTimeout(() => el.remove(), 3000);
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// ── Init: load from URL ──────────────────────────────────────────
|
|
574
|
-
(function init() {
|
|
575
|
-
const slug = location.pathname.split('/admin/')[1]?.split('/')[0];
|
|
576
|
-
if (slug) {
|
|
577
|
-
const navEl = document.querySelector(\`.nav-item[data-slug="\${slug}"]\`);
|
|
578
|
-
if (navEl) { navEl.classList.add('active'); loadResource(slug); }
|
|
579
|
-
}
|
|
580
|
-
})();
|
|
581
|
-
</script>
|
|
582
|
-
</body>
|
|
583
|
-
</html>`;
|
|
377
|
+
_error(res, err) {
|
|
378
|
+
const status = err.status || 500;
|
|
379
|
+
res.status(status).send(`
|
|
380
|
+
<html><body style="font-family:system-ui;padding:40px;background:#0c0e14;color:#e2e8f0">
|
|
381
|
+
<h2 style="color:#ef4444">Admin Error</h2>
|
|
382
|
+
<pre style="background:#1c1f2e;padding:20px;border-radius:8px;color:#94a3b8">${err.stack || err.message}</pre>
|
|
383
|
+
<a href="javascript:history.back()" style="color:#6366f1">← Go back</a>
|
|
384
|
+
</body></html>
|
|
385
|
+
`);
|
|
584
386
|
}
|
|
585
387
|
|
|
586
|
-
// ───
|
|
388
|
+
// ─── Flash messages (stored in query string for stateless operation) ───────
|
|
389
|
+
_flash(req, type, message) {
|
|
390
|
+
// Store in a simple query param on redirect
|
|
391
|
+
req._flashType = type;
|
|
392
|
+
req._flashMessage = message;
|
|
393
|
+
}
|
|
587
394
|
|
|
588
|
-
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
}
|
|
594
|
-
return
|
|
395
|
+
_pullFlash(req) {
|
|
396
|
+
const type = req.query._flash_type;
|
|
397
|
+
const msg = req.query._flash_msg;
|
|
398
|
+
if (type && msg) return { [type]: decodeURIComponent(msg) };
|
|
399
|
+
// Check set by _flash helper (pre-redirect)
|
|
400
|
+
if (req._flashType) return { [req._flashType]: req._flashMessage };
|
|
401
|
+
return {};
|
|
595
402
|
}
|
|
596
403
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
404
|
+
// Override redirect to include flash in URL
|
|
405
|
+
_redirectWithFlash(res, url, type, message) {
|
|
406
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
407
|
+
res.redirect(`${url}${sep}_flash_type=${type}&_flash_msg=${encodeURIComponent(message)}`);
|
|
600
408
|
}
|
|
601
409
|
|
|
602
410
|
_autoResource(ModelClass) {
|
|
603
411
|
const R = class extends AdminResource {};
|
|
604
|
-
R.model
|
|
605
|
-
R.label
|
|
412
|
+
R.model = ModelClass;
|
|
413
|
+
R.label = ModelClass.name + 's';
|
|
606
414
|
R.labelSingular = ModelClass.name;
|
|
607
415
|
return R;
|
|
608
416
|
}
|
|
@@ -611,7 +419,7 @@ function showAlert(msg, type = 'success') {
|
|
|
611
419
|
// Singleton
|
|
612
420
|
const admin = new Admin();
|
|
613
421
|
module.exports = admin;
|
|
614
|
-
module.exports.Admin
|
|
422
|
+
module.exports.Admin = Admin;
|
|
615
423
|
module.exports.AdminResource = AdminResource;
|
|
616
424
|
module.exports.AdminField = AdminField;
|
|
617
425
|
module.exports.AdminFilter = AdminFilter;
|