millas 0.2.3 → 0.2.4
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 +1 -1
- package/src/admin/ActivityLog.js +95 -0
- package/src/admin/Admin.js +336 -20
- package/src/admin/resources/AdminResource.js +140 -82
- package/src/admin/views/layouts/base.njk +96 -1
- package/src/admin/views/pages/dashboard.njk +273 -61
- package/src/admin/views/pages/list.njk +19 -0
- package/src/admin/views/pages/search.njk +139 -0
package/package.json
CHANGED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AdminActivityLog
|
|
5
|
+
*
|
|
6
|
+
* Lightweight in-process activity log for the admin panel.
|
|
7
|
+
* Stores the last N actions in a ring buffer — no database required.
|
|
8
|
+
*
|
|
9
|
+
* Each entry:
|
|
10
|
+
* { id, action, resource, recordId, label, user, at, meta }
|
|
11
|
+
*
|
|
12
|
+
* Usage (automatic — Admin.js calls this internally):
|
|
13
|
+
* ActivityLog.record('create', 'users', 5, 'Alice Smith');
|
|
14
|
+
* ActivityLog.record('update', 'posts', 12, 'Hello World');
|
|
15
|
+
* ActivityLog.record('delete', 'comments', 7, '#7');
|
|
16
|
+
*
|
|
17
|
+
* Read:
|
|
18
|
+
* ActivityLog.recent(20) // last 20 entries, newest first
|
|
19
|
+
* ActivityLog.forResource('users', 10)
|
|
20
|
+
*/
|
|
21
|
+
class AdminActivityLog {
|
|
22
|
+
constructor(maxSize = 200) {
|
|
23
|
+
this._log = [];
|
|
24
|
+
this._maxSize = maxSize;
|
|
25
|
+
this._seq = 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Record an admin action.
|
|
30
|
+
* @param {'create'|'update'|'delete'} action
|
|
31
|
+
* @param {string} resource — resource slug
|
|
32
|
+
* @param {*} recordId
|
|
33
|
+
* @param {string} label — human-readable name of the record
|
|
34
|
+
* @param {string} [user] — who performed the action (optional)
|
|
35
|
+
*/
|
|
36
|
+
record(action, resource, recordId, label, user = null) {
|
|
37
|
+
this._seq++;
|
|
38
|
+
const entry = {
|
|
39
|
+
id: this._seq,
|
|
40
|
+
action,
|
|
41
|
+
resource,
|
|
42
|
+
recordId,
|
|
43
|
+
label: label || `#${recordId}`,
|
|
44
|
+
user: user || 'Admin',
|
|
45
|
+
at: new Date().toISOString(),
|
|
46
|
+
_ts: Date.now(),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
this._log.unshift(entry);
|
|
50
|
+
|
|
51
|
+
// Trim to max size
|
|
52
|
+
if (this._log.length > this._maxSize) {
|
|
53
|
+
this._log.length = this._maxSize;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Return the most recent N entries, newest first.
|
|
59
|
+
*/
|
|
60
|
+
recent(n = 20) {
|
|
61
|
+
return this._log.slice(0, n);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Return recent entries for a specific resource slug.
|
|
66
|
+
*/
|
|
67
|
+
forResource(slug, n = 10) {
|
|
68
|
+
return this._log.filter(e => e.resource === slug).slice(0, n);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Return totals by action type.
|
|
73
|
+
* { create: N, update: N, delete: N }
|
|
74
|
+
*/
|
|
75
|
+
totals() {
|
|
76
|
+
const t = { create: 0, update: 0, delete: 0 };
|
|
77
|
+
for (const e of this._log) {
|
|
78
|
+
if (t[e.action] !== undefined) t[e.action]++;
|
|
79
|
+
}
|
|
80
|
+
return t;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Clear all log entries.
|
|
85
|
+
*/
|
|
86
|
+
clear() {
|
|
87
|
+
this._log = [];
|
|
88
|
+
this._seq = 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Singleton
|
|
93
|
+
const activityLog = new AdminActivityLog();
|
|
94
|
+
module.exports = activityLog;
|
|
95
|
+
module.exports.AdminActivityLog = AdminActivityLog;
|
package/src/admin/Admin.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const path
|
|
4
|
-
const nunjucks
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const nunjucks = require('nunjucks');
|
|
5
|
+
const ActivityLog = require('./ActivityLog');
|
|
5
6
|
const { AdminResource, AdminField, AdminFilter } = require('./resources/AdminResource');
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -75,13 +76,19 @@ class Admin {
|
|
|
75
76
|
expressApp.get(`${prefix}`, (q, s) => this._dashboard(q, s));
|
|
76
77
|
expressApp.get(`${prefix}/`, (q, s) => this._dashboard(q, s));
|
|
77
78
|
|
|
79
|
+
// Global search
|
|
80
|
+
expressApp.get(`${prefix}/search`, (q, s) => this._search(q, s));
|
|
81
|
+
|
|
78
82
|
// Resource routes
|
|
79
|
-
expressApp.get (`${prefix}/:resource`,
|
|
80
|
-
expressApp.get (`${prefix}/:resource/
|
|
81
|
-
expressApp.
|
|
82
|
-
expressApp.
|
|
83
|
-
expressApp.
|
|
84
|
-
expressApp.
|
|
83
|
+
expressApp.get (`${prefix}/:resource`, (q, s) => this._list(q, s));
|
|
84
|
+
expressApp.get (`${prefix}/:resource/export.:format`, (q, s) => this._export(q, s));
|
|
85
|
+
expressApp.get (`${prefix}/:resource/create`, (q, s) => this._create(q, s));
|
|
86
|
+
expressApp.post (`${prefix}/:resource`, (q, s) => this._store(q, s));
|
|
87
|
+
expressApp.get (`${prefix}/:resource/:id/edit`, (q, s) => this._edit(q, s));
|
|
88
|
+
expressApp.get (`${prefix}/:resource/:id`, (q, s) => this._detail(q, s));
|
|
89
|
+
expressApp.post (`${prefix}/:resource/:id`, (q, s) => this._update(q, s));
|
|
90
|
+
expressApp.post (`${prefix}/:resource/:id/delete`, (q, s) => this._destroy(q, s));
|
|
91
|
+
expressApp.post (`${prefix}/:resource/bulk-delete`, (q, s) => this._bulkDestroy(q, s));
|
|
85
92
|
|
|
86
93
|
return this;
|
|
87
94
|
}
|
|
@@ -139,11 +146,67 @@ class Admin {
|
|
|
139
146
|
}
|
|
140
147
|
});
|
|
141
148
|
|
|
142
|
-
env.addFilter('
|
|
149
|
+
env.addFilter('adminDetail', (value, field) => {
|
|
150
|
+
if (value === null || value === undefined || value === '') {
|
|
151
|
+
return '<span class="cell-muted">—</span>';
|
|
152
|
+
}
|
|
153
|
+
switch (field.type) {
|
|
154
|
+
case 'boolean':
|
|
155
|
+
return value
|
|
156
|
+
? '<span class="badge badge-green">Yes</span>'
|
|
157
|
+
: '<span class="badge badge-gray">No</span>';
|
|
158
|
+
case 'badge': {
|
|
159
|
+
const colorMap = { admin:'purple', user:'blue', active:'green', inactive:'gray', pending:'yellow', published:'green', draft:'gray', banned:'red' };
|
|
160
|
+
const c = (field.colors && field.colors[String(value)]) || colorMap[String(value)] || 'gray';
|
|
161
|
+
return `<span class="badge badge-${c}">${value}</span>`;
|
|
162
|
+
}
|
|
163
|
+
case 'datetime':
|
|
164
|
+
try {
|
|
165
|
+
const d = new Date(value);
|
|
166
|
+
return `<span title="${d.toISOString()}">${d.toLocaleString()}</span>`;
|
|
167
|
+
} catch { return String(value); }
|
|
168
|
+
case 'date':
|
|
169
|
+
try { return new Date(value).toLocaleDateString(); } catch { return String(value); }
|
|
170
|
+
case 'password':
|
|
171
|
+
return '<span class="cell-muted" style="letter-spacing:2px">••••••</span>';
|
|
172
|
+
case 'image':
|
|
173
|
+
return `<img src="${value}" style="width:64px;height:64px;border-radius:8px;object-fit:cover;border:1px solid var(--border)" alt="">`;
|
|
174
|
+
case 'url':
|
|
175
|
+
return `<a href="${value}" target="_blank" rel="noopener" style="color:var(--primary);word-break:break-all">${value} <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>`;
|
|
176
|
+
case 'email':
|
|
177
|
+
return `<a href="mailto:${value}" style="color:var(--primary)">${value}</a>`;
|
|
178
|
+
case 'color':
|
|
179
|
+
return `<span style="display:inline-flex;align-items:center;gap:8px"><span style="width:20px;height:20px;border-radius:4px;background:${value};border:1px solid var(--border);flex-shrink:0"></span><span class="cell-mono">${value}</span></span>`;
|
|
180
|
+
case 'json':
|
|
181
|
+
try {
|
|
182
|
+
const pretty = JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value, null, 2);
|
|
183
|
+
return `<pre style="background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px;font-family:'DM Mono',monospace;font-size:12px;overflow-x:auto;white-space:pre-wrap;margin:0;color:var(--text-soft)">${pretty}</pre>`;
|
|
184
|
+
} catch { return String(value); }
|
|
185
|
+
case 'richtext':
|
|
186
|
+
return `<div style="line-height:1.6;color:var(--text-soft)">${value}</div>`;
|
|
187
|
+
default: {
|
|
188
|
+
const str = String(value);
|
|
189
|
+
return str;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
143
193
|
env.addFilter('dump', (val) => {
|
|
144
194
|
try { return JSON.stringify(val, null, 2); } catch { return String(val); }
|
|
145
195
|
});
|
|
146
196
|
|
|
197
|
+
env.addFilter('min', (arr) => Math.min(...arr));
|
|
198
|
+
|
|
199
|
+
env.addFilter('relativeTime', (iso) => {
|
|
200
|
+
try {
|
|
201
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
202
|
+
const s = Math.floor(diff / 1000);
|
|
203
|
+
if (s < 60) return 'just now';
|
|
204
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
205
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
206
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
207
|
+
} catch { return String(iso); }
|
|
208
|
+
});
|
|
209
|
+
|
|
147
210
|
return env;
|
|
148
211
|
}
|
|
149
212
|
|
|
@@ -153,11 +216,13 @@ class Admin {
|
|
|
153
216
|
return {
|
|
154
217
|
adminPrefix: this._config.prefix,
|
|
155
218
|
adminTitle: this._config.title,
|
|
156
|
-
resources: this.resources().map(r => ({
|
|
219
|
+
resources: this.resources().map((r, idx) => ({
|
|
157
220
|
slug: r.slug,
|
|
158
221
|
label: r._getLabel(),
|
|
159
222
|
singular: r._getLabelSingular(),
|
|
160
223
|
icon: r.icon,
|
|
224
|
+
canView: r.canView,
|
|
225
|
+
index: idx + 1,
|
|
161
226
|
})),
|
|
162
227
|
flash: this._pullFlash(req),
|
|
163
228
|
activePage: extra.activePage || null,
|
|
@@ -174,26 +239,42 @@ class Admin {
|
|
|
174
239
|
this.resources().map(async (R) => {
|
|
175
240
|
let count = 0;
|
|
176
241
|
let recent = [];
|
|
242
|
+
let recentCount = 0;
|
|
177
243
|
try {
|
|
178
244
|
count = await R.model.count();
|
|
179
245
|
const result = await R.fetchList({ page: 1, perPage: 5 });
|
|
180
246
|
recent = result.data.map(r => r.toJSON ? r.toJSON() : r);
|
|
247
|
+
// Count records created in last 7 days for trend indicator
|
|
248
|
+
try {
|
|
249
|
+
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
250
|
+
recentCount = await R.model.where('created_at__gte', since).count();
|
|
251
|
+
} catch { /* model may not have created_at */ }
|
|
181
252
|
} catch {}
|
|
182
253
|
return {
|
|
183
|
-
slug:
|
|
184
|
-
label:
|
|
185
|
-
|
|
254
|
+
slug: R.slug,
|
|
255
|
+
label: R._getLabel(),
|
|
256
|
+
singular: R._getLabelSingular(),
|
|
257
|
+
icon: R.icon,
|
|
186
258
|
count,
|
|
259
|
+
recentCount,
|
|
187
260
|
recent,
|
|
188
|
-
listFields:
|
|
261
|
+
listFields: R.fields()
|
|
262
|
+
.filter(f => f._type !== 'tab' && !f._hidden && !f._detailOnly)
|
|
263
|
+
.slice(0, 4)
|
|
264
|
+
.map(f => f.toJSON()),
|
|
189
265
|
};
|
|
190
266
|
})
|
|
191
267
|
);
|
|
192
268
|
|
|
269
|
+
const activityData = ActivityLog.recent(25);
|
|
270
|
+
const activityTotals = ActivityLog.totals();
|
|
271
|
+
|
|
193
272
|
res.render('pages/dashboard.njk', this._ctx(req, {
|
|
194
|
-
pageTitle:
|
|
195
|
-
activePage:
|
|
196
|
-
resources:
|
|
273
|
+
pageTitle: 'Dashboard',
|
|
274
|
+
activePage: 'dashboard',
|
|
275
|
+
resources: resourceData,
|
|
276
|
+
activity: activityData,
|
|
277
|
+
activityTotals,
|
|
197
278
|
}));
|
|
198
279
|
} catch (err) {
|
|
199
280
|
this._error(res, err);
|
|
@@ -236,6 +317,7 @@ class Admin {
|
|
|
236
317
|
canCreate: R.canCreate,
|
|
237
318
|
canEdit: R.canEdit,
|
|
238
319
|
canDelete: R.canDelete,
|
|
320
|
+
canView: R.canView,
|
|
239
321
|
},
|
|
240
322
|
rows,
|
|
241
323
|
listFields,
|
|
@@ -283,6 +365,7 @@ class Admin {
|
|
|
283
365
|
if (!R.canCreate) return res.status(403).send('Not allowed');
|
|
284
366
|
|
|
285
367
|
await R.create(req.body);
|
|
368
|
+
ActivityLog.record('create', R.slug, null, `New ${R._getLabelSingular()}`);
|
|
286
369
|
this._flash(req, 'success', `${R._getLabelSingular()} created successfully`);
|
|
287
370
|
this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
288
371
|
} catch (err) {
|
|
@@ -337,6 +420,7 @@ class Admin {
|
|
|
337
420
|
const method = req.body._method || 'POST';
|
|
338
421
|
if (method === 'PUT' || method === 'POST') {
|
|
339
422
|
await R.update(req.params.id, req.body);
|
|
423
|
+
ActivityLog.record('update', R.slug, req.params.id, `${R._getLabelSingular()} #${req.params.id}`);
|
|
340
424
|
this._flash(req, 'success', `${R._getLabelSingular()} updated successfully`);
|
|
341
425
|
this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
342
426
|
}
|
|
@@ -365,6 +449,7 @@ class Admin {
|
|
|
365
449
|
if (!R.canDelete) return res.status(403).send('Not allowed');
|
|
366
450
|
|
|
367
451
|
await R.destroy(req.params.id);
|
|
452
|
+
ActivityLog.record('delete', R.slug, req.params.id, `${R._getLabelSingular()} #${req.params.id}`);
|
|
368
453
|
this._flash(req, 'success', `${R._getLabelSingular()} deleted`);
|
|
369
454
|
this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
370
455
|
} catch (err) {
|
|
@@ -372,12 +457,243 @@ class Admin {
|
|
|
372
457
|
}
|
|
373
458
|
}
|
|
374
459
|
|
|
460
|
+
// ─── Detail view (readonly) ───────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
async _detail(req, res) {
|
|
463
|
+
try {
|
|
464
|
+
const R = this._resolve(req.params.resource, res);
|
|
465
|
+
if (!R) return;
|
|
466
|
+
if (!R.canView) {
|
|
467
|
+
if (R.canEdit) return res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}/edit`);
|
|
468
|
+
return res.status(403).send('Not allowed');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const record = await R.fetchOne(req.params.id);
|
|
472
|
+
const data = record.toJSON ? record.toJSON() : record;
|
|
473
|
+
|
|
474
|
+
// Build detail field groups (all non-hidden fields, grouped by tab)
|
|
475
|
+
const detailFields = R.fields()
|
|
476
|
+
.filter(f => f._type !== '__tab__' && !f._hidden && !f._listOnly)
|
|
477
|
+
.map(f => f.toJSON());
|
|
478
|
+
|
|
479
|
+
const tabs = this._buildTabs(R.fields());
|
|
480
|
+
|
|
481
|
+
res.render('pages/detail.njk', this._ctx(req, {
|
|
482
|
+
pageTitle: `${R._getLabelSingular()} #${req.params.id}`,
|
|
483
|
+
activeResource: req.params.resource,
|
|
484
|
+
resource: {
|
|
485
|
+
slug: R.slug,
|
|
486
|
+
label: R._getLabel(),
|
|
487
|
+
singular: R._getLabelSingular(),
|
|
488
|
+
icon: R.icon,
|
|
489
|
+
canEdit: R.canEdit,
|
|
490
|
+
canDelete: R.canDelete,
|
|
491
|
+
canCreate: R.canCreate,
|
|
492
|
+
},
|
|
493
|
+
record: data,
|
|
494
|
+
detailFields,
|
|
495
|
+
tabs,
|
|
496
|
+
hasTabs: tabs.length > 1,
|
|
497
|
+
}));
|
|
498
|
+
} catch (err) {
|
|
499
|
+
this._error(res, err);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ─── Bulk delete ──────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
async _bulkDestroy(req, res) {
|
|
506
|
+
try {
|
|
507
|
+
const R = this._resolve(req.params.resource, res);
|
|
508
|
+
if (!R) return;
|
|
509
|
+
if (!R.canDelete) return res.status(403).send('Not allowed');
|
|
510
|
+
|
|
511
|
+
const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids].filter(Boolean);
|
|
512
|
+
if (!ids.length) {
|
|
513
|
+
this._flash(req, 'error', 'No records selected.');
|
|
514
|
+
return this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
await R.model.destroy(...ids);
|
|
518
|
+
ActivityLog.record('delete', R.slug, null, `${ids.length} ${R._getLabel()} (bulk)`);
|
|
519
|
+
this._flash(req, 'success', `Deleted ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
|
|
520
|
+
this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
this._error(res, err);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ─── Global search ────────────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
async _search(req, res) {
|
|
529
|
+
try {
|
|
530
|
+
const q = (req.query.q || '').trim();
|
|
531
|
+
|
|
532
|
+
if (!q) {
|
|
533
|
+
return res.render('pages/search.njk', this._ctx(req, {
|
|
534
|
+
pageTitle: 'Search',
|
|
535
|
+
activePage: 'search',
|
|
536
|
+
query: '',
|
|
537
|
+
results: [],
|
|
538
|
+
total: 0,
|
|
539
|
+
}));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const results = await Promise.all(
|
|
543
|
+
this.resources().map(async (R) => {
|
|
544
|
+
if (!R.searchable || !R.searchable.length) return null;
|
|
545
|
+
try {
|
|
546
|
+
const result = await R.fetchList({ page: 1, perPage: 8, search: q });
|
|
547
|
+
if (!result.data.length) return null;
|
|
548
|
+
return {
|
|
549
|
+
slug: R.slug,
|
|
550
|
+
label: R._getLabel(),
|
|
551
|
+
singular: R._getLabelSingular(),
|
|
552
|
+
icon: R.icon,
|
|
553
|
+
total: result.total,
|
|
554
|
+
rows: result.data.map(r => r.toJSON ? r.toJSON() : r),
|
|
555
|
+
listFields: R.fields()
|
|
556
|
+
.filter(f => f._type !== 'tab' && !f._hidden && !f._detailOnly)
|
|
557
|
+
.slice(0, 4)
|
|
558
|
+
.map(f => f.toJSON()),
|
|
559
|
+
};
|
|
560
|
+
} catch { return null; }
|
|
561
|
+
})
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
const filtered = results.filter(Boolean);
|
|
565
|
+
const total = filtered.reduce((s, r) => s + r.total, 0);
|
|
566
|
+
|
|
567
|
+
res.render('pages/search.njk', this._ctx(req, {
|
|
568
|
+
pageTitle: `Search: ${q}`,
|
|
569
|
+
activePage: 'search',
|
|
570
|
+
query: q,
|
|
571
|
+
results: filtered,
|
|
572
|
+
total,
|
|
573
|
+
}));
|
|
574
|
+
} catch (err) {
|
|
575
|
+
this._error(res, err);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ─── Export ───────────────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
async _export(req, res) {
|
|
582
|
+
try {
|
|
583
|
+
const R = this._resolve(req.params.resource, res);
|
|
584
|
+
if (!R) return;
|
|
585
|
+
|
|
586
|
+
const format = req.params.format; // 'csv' or 'json'
|
|
587
|
+
const search = req.query.search || '';
|
|
588
|
+
const sort = req.query.sort || 'id';
|
|
589
|
+
const order = req.query.order || 'desc';
|
|
590
|
+
|
|
591
|
+
const activeFilters = {};
|
|
592
|
+
if (req.query.filter) {
|
|
593
|
+
for (const [k, v] of Object.entries(req.query.filter)) {
|
|
594
|
+
if (v !== '') activeFilters[k] = v;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Fetch all records (no pagination)
|
|
599
|
+
const result = await R.fetchList({
|
|
600
|
+
page: 1, perPage: 100000, search, sort, order, filters: activeFilters,
|
|
601
|
+
});
|
|
602
|
+
const rows = result.data.map(r => r.toJSON ? r.toJSON() : r);
|
|
603
|
+
|
|
604
|
+
const fields = R.fields()
|
|
605
|
+
.filter(f => f._type !== 'tab' && !f._hidden)
|
|
606
|
+
.map(f => f.toJSON());
|
|
607
|
+
|
|
608
|
+
const filename = `${R.slug}-${new Date().toISOString().slice(0, 10)}`;
|
|
609
|
+
|
|
610
|
+
if (format === 'json') {
|
|
611
|
+
res.setHeader('Content-Type', 'application/json');
|
|
612
|
+
res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
|
|
613
|
+
return res.json(rows);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// CSV
|
|
617
|
+
const header = fields.map(f => `"${f.label}"`).join(',');
|
|
618
|
+
const csvRows = rows.map(row =>
|
|
619
|
+
fields.map(f => {
|
|
620
|
+
const v = row[f.name];
|
|
621
|
+
if (v === null || v === undefined) return '';
|
|
622
|
+
const s = String(typeof v === 'object' ? JSON.stringify(v) : v);
|
|
623
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
624
|
+
}).join(',')
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
res.setHeader('Content-Type', 'text/csv');
|
|
628
|
+
res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
|
|
629
|
+
res.send([header, ...csvRows].join('\r\n'));
|
|
630
|
+
} catch (err) {
|
|
631
|
+
this._error(res, err);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
375
635
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
376
636
|
|
|
637
|
+
/**
|
|
638
|
+
* Build form fields with tab metadata injected.
|
|
639
|
+
* readonly fields (from readonlyFields[] or field.readonly()) are
|
|
640
|
+
* passed through with a _isReadonly flag so the template can render
|
|
641
|
+
* them as text instead of inputs.
|
|
642
|
+
*/
|
|
377
643
|
_formFields(R) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
644
|
+
const readonlySet = new Set(R.readonlyFields || []);
|
|
645
|
+
let currentTab = null;
|
|
646
|
+
const result = [];
|
|
647
|
+
|
|
648
|
+
for (const f of R.fields()) {
|
|
649
|
+
// Tab separator — update current tab context, don't add to form fields
|
|
650
|
+
if (f._type === 'tab') {
|
|
651
|
+
currentTab = f._label;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Skip id, list-only, and fully hidden fields
|
|
656
|
+
if (f._type === 'id' || f._listOnly || f._hidden) continue;
|
|
657
|
+
|
|
658
|
+
const json = f.toJSON();
|
|
659
|
+
json.tab = currentTab;
|
|
660
|
+
json.required = !f._nullable;
|
|
661
|
+
|
|
662
|
+
// Mark as readonly if in readonlyFields array or flagged on field itself
|
|
663
|
+
if (readonlySet.has(f._name) || f._readonly) {
|
|
664
|
+
json.isReadonly = true;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
result.push(json);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return result;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Build tab structure for tabbed form/detail rendering.
|
|
675
|
+
* Returns [{ label, fields }] — one entry per tab.
|
|
676
|
+
* If no tabs defined, returns a single unnamed tab with all fields.
|
|
677
|
+
*/
|
|
678
|
+
_buildTabs(fields) {
|
|
679
|
+
const tabs = [];
|
|
680
|
+
let current = null;
|
|
681
|
+
|
|
682
|
+
for (const f of fields) {
|
|
683
|
+
if (f._type === 'tab') {
|
|
684
|
+
current = { label: f._label, fields: [] };
|
|
685
|
+
tabs.push(current);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (f._hidden || f._listOnly) continue;
|
|
689
|
+
if (!current) {
|
|
690
|
+
current = { label: null, fields: [] };
|
|
691
|
+
tabs.push(current);
|
|
692
|
+
}
|
|
693
|
+
current.fields.push(f.toJSON());
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return tabs;
|
|
381
697
|
}
|
|
382
698
|
|
|
383
699
|
_resolve(slug, res) {
|