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.
@@ -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 registry and HTTP handler.
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.register(PostResource);
16
- * Admin.register(OrderResource);
17
- *
18
- * Then mount in bootstrap/app.js:
15
+ * Admin.mount(route, expressApp);
19
16
  *
20
- * Admin.mount(app, expressApp);
21
- *
22
- * Access at: http://localhost:3000/admin
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(); // slug → AdminResource class
26
+ this._resources = new Map();
27
27
  this._config = {
28
- prefix: '/admin',
29
- title: 'Millas Admin',
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
- // Auto-wrap plain Model classes
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
- * Register multiple resources at once.
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 the Millas Route instance.
87
- * Call this in routes/api.js or bootstrap/app.js.
65
+ * Mount all admin routes onto express directly.
66
+ * Call this AFTER app.boot() in bootstrap/app.js:
88
67
  *
89
- * Admin.mount(route, expressApp);
68
+ * Admin.mount(expressApp);
90
69
  */
91
- mount(route, expressApp) {
70
+ mount(expressApp) {
92
71
  const prefix = this._config.prefix;
93
- const admin = this;
94
-
95
- // Serve the admin HTML shell (SPA-style)
96
- expressApp.get(`${prefix}`, (req, res) => this._serveShell(req, res));
97
- expressApp.get(`${prefix}/*`, (req, res) => this._serveShell(req, res));
98
-
99
- // JSON API for the panel
100
- route.group({ prefix: `${prefix}/api` }, () => {
101
-
102
- // Meta: list all resources for sidebar
103
- route.get('/resources', (req, res) => {
104
- res.json(admin.resources().map(r => ({
105
- slug: r.slug,
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
- // ─── Request Handlers ─────────────────────────────────────────────────────
89
+ // ─── Nunjucks setup ───────────────────────────────────────────────────────
125
90
 
126
- async _list(req, res) {
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 Resource = this._resolve(req.params.resource, res);
129
- if (!Resource) return;
130
-
131
- const page = Number(req.query.page) || 1;
132
- const search = req.query.search || '';
133
- const sort = req.query.sort || 'id';
134
- const order = req.query.order || 'desc';
135
- const filters = req.query.filters
136
- ? JSON.parse(req.query.filters)
137
- : {};
138
-
139
- const result = await Resource.fetchList({ page, search, sort, order, filters });
140
-
141
- // Attach field definitions for the frontend
142
- const fields = Resource.fields().map(f => f.toJSON());
143
- const fltrs = Resource.filters().map(f => f.toJSON());
144
- const sortable = Resource.sortable || [];
145
-
146
- res.json({
147
- ...result,
148
- fields,
149
- filters: fltrs,
150
- sortable,
151
- canCreate: Resource.canCreate,
152
- canEdit: Resource.canEdit,
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 _show(req, res) {
188
+ async _list(req, res) {
161
189
  try {
162
- const Resource = this._resolve(req.params.resource, res);
163
- if (!Resource) return;
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 record = await Resource.fetchOne(req.params.id);
166
- const fields = Resource.fields().map(f => f.toJSON());
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
- res.json({ data: record, fields });
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 Resource = this._resolve(req.params.resource, res);
177
- if (!Resource) return;
178
- if (!Resource.canCreate) return res.status(403).json({ error: 'Create not allowed' });
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
- const record = await Resource.create(req.body);
181
- res.status(201).json({ data: record, message: 'Created successfully' });
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 _update(req, res) {
291
+ async _edit(req, res) {
188
292
  try {
189
- const Resource = this._resolve(req.params.resource, res);
190
- if (!Resource) return;
191
- if (!Resource.canEdit) return res.status(403).json({ error: 'Edit not allowed' });
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
- const record = await Resource.update(req.params.id, req.body);
194
- res.json({ data: record, message: 'Updated successfully' });
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 Resource = this._resolve(req.params.resource, res);
203
- if (!Resource) return;
204
- if (!Resource.canDelete) return res.status(403).json({ error: 'Delete not allowed' });
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 Resource.destroy(req.params.id);
207
- res.json({ message: 'Deleted successfully' });
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
- // ─── HTML Shell ───────────────────────────────────────────────────────────
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
- res.setHeader('Content-Type', 'text/html');
224
- res.send(this._renderShell(title, resources));
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
- _renderShell(title, resources) {
228
- const navItems = resources.map(r =>
229
- `<a href="/admin/${r.slug}" class="nav-item" data-slug="${r.slug}">
230
- <span class="nav-icon">${r.icon}</span>
231
- <span class="nav-label">${r.label}</span>
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
- .sidebar-header { padding: 20px 16px; border-bottom: 1px solid var(--border); }
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
- // ── Modal ────────────────────────────────────────────────────────
544
- function showModal(title, bodyHtml, onConfirm, opts = {}) {
545
- document.getElementById('modal-root').innerHTML = \`
546
- <div class="modal-overlay" onclick="if(event.target===this)closeModal()">
547
- <div class="modal">
548
- <div class="modal-header">
549
- <span class="modal-title">\${title}</span>
550
- <button class="close-btn" onclick="closeModal()">×</button>
551
- </div>
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
- // ─── Internal ─────────────────────────────────────────────────────────────
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
- _resolve(slug, res) {
589
- const Resource = this._resources.get(slug);
590
- if (!Resource) {
591
- res.status(404).json({ error: `Resource "${slug}" not registered` });
592
- return null;
593
- }
594
- return Resource;
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
- _error(res, err) {
598
- const status = err.status || err.statusCode || 500;
599
- res.status(status).json({ error: err.message, status });
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 = ModelClass;
605
- R.label = ModelClass.name + 's';
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 = Admin;
422
+ module.exports.Admin = Admin;
615
423
  module.exports.AdminResource = AdminResource;
616
424
  module.exports.AdminField = AdminField;
617
425
  module.exports.AdminFilter = AdminFilter;