millas 0.2.3 → 0.2.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "millas",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "A modern batteries-included backend framework for Node.js — built on Express, inspired by Laravel, Django, and FastAPI",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -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;
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
- const path = require('path');
4
- const nunjucks = require('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`, (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));
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('min', (arr) => Math.min(...arr));
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: R.slug,
184
- label: R._getLabel(),
185
- icon: R.icon,
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: R.fields().filter(f => !f._hidden && !f._detailOnly).slice(0, 4).map(f => f.toJSON()),
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: 'Dashboard',
195
- activePage: 'dashboard',
196
- resources: resourceData,
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
- return R.fields()
379
- .filter(f => f._type !== 'id' && !f._listOnly && !f._readonly)
380
- .map(f => ({ ...f.toJSON(), required: !f._nullable }));
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) {