millas 0.2.12-beta-2 → 0.2.13

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.
Files changed (57) hide show
  1. package/package.json +3 -2
  2. package/src/admin/Admin.js +122 -38
  3. package/src/admin/ViewContext.js +12 -3
  4. package/src/admin/resources/AdminResource.js +10 -0
  5. package/src/admin/static/admin.css +95 -14
  6. package/src/admin/views/layouts/base.njk +23 -34
  7. package/src/admin/views/pages/detail.njk +16 -5
  8. package/src/admin/views/pages/error.njk +65 -0
  9. package/src/admin/views/pages/list.njk +127 -2
  10. package/src/admin/views/partials/form-scripts.njk +7 -3
  11. package/src/admin/views/partials/form-widget.njk +2 -1
  12. package/src/admin/views/partials/icons.njk +64 -0
  13. package/src/ai/AIManager.js +954 -0
  14. package/src/ai/AITokenBudget.js +250 -0
  15. package/src/ai/PromptGuard.js +216 -0
  16. package/src/ai/agents.js +218 -0
  17. package/src/ai/conversation.js +213 -0
  18. package/src/ai/drivers.js +734 -0
  19. package/src/ai/files.js +249 -0
  20. package/src/ai/media.js +303 -0
  21. package/src/ai/pricing.js +152 -0
  22. package/src/ai/provider_tools.js +114 -0
  23. package/src/ai/types.js +356 -0
  24. package/src/commands/createsuperuser.js +17 -4
  25. package/src/commands/serve.js +2 -4
  26. package/src/container/AppInitializer.js +39 -15
  27. package/src/container/Application.js +31 -1
  28. package/src/core/foundation.js +1 -1
  29. package/src/errors/HttpError.js +32 -16
  30. package/src/facades/AI.js +411 -0
  31. package/src/facades/Hash.js +67 -0
  32. package/src/facades/Process.js +144 -0
  33. package/src/hashing/Hash.js +262 -0
  34. package/src/http/HtmlEscape.js +162 -0
  35. package/src/http/MillasRequest.js +63 -7
  36. package/src/http/MillasResponse.js +70 -4
  37. package/src/http/ResponseDispatcher.js +21 -27
  38. package/src/http/SafeFilePath.js +195 -0
  39. package/src/http/SafeRedirect.js +62 -0
  40. package/src/http/SecurityBootstrap.js +70 -0
  41. package/src/http/helpers.js +40 -125
  42. package/src/http/index.js +10 -1
  43. package/src/http/middleware/CsrfMiddleware.js +258 -0
  44. package/src/http/middleware/RateLimiter.js +314 -0
  45. package/src/http/middleware/SecurityHeaders.js +281 -0
  46. package/src/i18n/Translator.js +10 -2
  47. package/src/logger/LogRedactor.js +247 -0
  48. package/src/logger/Logger.js +1 -1
  49. package/src/logger/formatters/JsonFormatter.js +11 -4
  50. package/src/logger/formatters/PrettyFormatter.js +3 -1
  51. package/src/logger/formatters/SimpleFormatter.js +14 -3
  52. package/src/middleware/ThrottleMiddleware.js +27 -4
  53. package/src/process/Process.js +333 -0
  54. package/src/router/MiddlewareRegistry.js +27 -2
  55. package/src/scaffold/templates.js +3 -0
  56. package/src/validation/Validator.js +348 -607
  57. package/src/admin.zip +0 -0
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "millas",
3
- "version": "0.2.12-beta-2",
3
+ "version": "0.2.13",
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": {
7
7
  ".": "./src/index.js",
8
8
  "./core/*": "./src/core/*.js",
9
+ "./middleware/*": "./src/middleware/*.js",
9
10
  "./facades/*": "./src/facades/*.js"
10
11
  },
11
12
  "bin": {
@@ -47,7 +48,7 @@
47
48
  "nodemailer": "^6.9.0",
48
49
  "nunjucks": "^3.2.4",
49
50
  "ora": "5.4.1",
50
- "sqlite3": "^5.1.7"
51
+ "sqlite": "^5.1.1"
51
52
  },
52
53
  "peerDependencies": {
53
54
  "express": "^4.18.0"
@@ -8,6 +8,8 @@ const { FormGenerator } = require('./FormGenerator');
8
8
  const { ViewContext } = require('./ViewContext');
9
9
  const AdminAuth = require('./AdminAuth');
10
10
  const { AdminResource, AdminField, AdminFilter, AdminInline } = require('./resources/AdminResource');
11
+ const LookupParser = require('../orm/query/LookupParser');
12
+ const Facade = require('../facades/Facade');
11
13
 
12
14
  /**
13
15
  * Admin
@@ -148,6 +150,17 @@ class Admin {
148
150
  });
149
151
 
150
152
  // ── Custom filters ───────────────────────────────────────────
153
+
154
+ // Resolve a fkResource table name to the registered admin slug (or null)
155
+ const resolveFkSlug = (tableName) => {
156
+ if (!tableName) return null;
157
+ if (this._resources.has(tableName)) return tableName;
158
+ for (const R of this._resources.values()) {
159
+ if (R.model && R.model.table === tableName) return R.slug;
160
+ }
161
+ return null;
162
+ };
163
+
151
164
  env.addFilter('adminCell', (value, field) => {
152
165
  if (value === null || value === undefined) return '<span class="cell-muted">—</span>';
153
166
  switch (field.type) {
@@ -189,6 +202,14 @@ class Admin {
189
202
  return `<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:16px;height:16px;border-radius:3px;background:${value};border:1px solid var(--border);flex-shrink:0"></span><span class="cell-mono">${value}</span></span>`;
190
203
  case 'richtext':
191
204
  return `<div style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-soft)">${String(value).replace(/<[^>]+>/g, '').slice(0, 80)}</div>`;
205
+ case 'fk': {
206
+ const fkSlug = resolveFkSlug(field.fkResource);
207
+ const prefix = this._config.prefix || '/admin';
208
+ if (fkSlug) {
209
+ return `<span class="fk-cell">${value}<a class="fk-arrow-btn" href="${prefix}/${fkSlug}/${value}" title="View record #${value}"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></span>`;
210
+ }
211
+ return String(value);
212
+ }
192
213
  default: {
193
214
  const str = String(value);
194
215
  return str.length > 60
@@ -243,6 +264,14 @@ class Admin {
243
264
  const c2 = (field.colors && field.colors[String(value)]) || colorMap2[String(value)] || 'gray';
244
265
  return `<span class="badge badge-${c2}">${value}</span>`;
245
266
  }
267
+ case 'fk': {
268
+ const fkSlug = resolveFkSlug(field.fkResource);
269
+ const prefix = this._config.prefix || '/admin';
270
+ if (fkSlug) {
271
+ return `<span class="fk-cell fk-cell-detail">${value}<a class="fk-arrow-btn" href="${prefix}/${fkSlug}/${value}" title="View record #${value}"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a></span>`;
272
+ }
273
+ return String(value);
274
+ }
246
275
  default: {
247
276
  const str = String(value);
248
277
  return str;
@@ -279,6 +308,26 @@ class Admin {
279
308
  // ─── Base render context ──────────────────────────────────────────────────
280
309
 
281
310
  _ctx(req, extra = {}) {
311
+ // Resolve the auth user model from the container so we can tag its
312
+ // resource as 'auth' in the sidebar — automatic, no dev config needed.
313
+ let authUserModel = null;
314
+ try {
315
+ const container = Facade._container;
316
+ if (container) {
317
+ const auth = container.make('auth');
318
+ authUserModel = auth?._UserModel || null;
319
+ }
320
+ } catch { /* container not booted yet or auth not registered */ }
321
+
322
+ // A resource is in the 'auth' category if:
323
+ // 1. Its model is the configured auth_user model, OR
324
+ // 2. The developer explicitly set static authCategory = 'auth'
325
+ const isAuthResource = (r) => {
326
+ if (r.authCategory === 'auth') return true;
327
+ if (authUserModel && r.model && r.model === authUserModel) return true;
328
+ return false;
329
+ };
330
+
282
331
  return {
283
332
  csrfToken: AdminAuth.enabled ? AdminAuth.csrfToken(req) : 'disabled',
284
333
  adminPrefix: this._config.prefix,
@@ -294,6 +343,7 @@ class Admin {
294
343
  icon: r.icon,
295
344
  canView: r.hasPermission(req.adminUser || null, 'view'),
296
345
  index: idx + 1,
346
+ category: isAuthResource(r) ? 'auth' : 'app',
297
347
  })),
298
348
  flash: extra._flash || {},
299
349
  activePage: extra.activePage || null,
@@ -406,7 +456,7 @@ class Admin {
406
456
  activityTotals,
407
457
  }));
408
458
  } catch (err) {
409
- this._error(res, err);
459
+ this._error(req, res, err);
410
460
  }
411
461
  }
412
462
 
@@ -449,7 +499,7 @@ class Admin {
449
499
  baseCtx: this._ctxWithFlash(req, res, {}),
450
500
  }), R);
451
501
  } catch (err) {
452
- this._error(res, err);
502
+ this._error(req, res, err);
453
503
  }
454
504
  }
455
505
 
@@ -465,7 +515,7 @@ class Admin {
465
515
  baseCtx: this._ctxWithFlash(req, res, {}),
466
516
  }), R);
467
517
  } catch (err) {
468
- this._error(res, err);
518
+ this._error(req, res, err);
469
519
  }
470
520
  }
471
521
 
@@ -502,7 +552,7 @@ class Admin {
502
552
  baseCtx: this._ctxWithFlash(req, res, {}),
503
553
  }), R);
504
554
  }
505
- this._error(res, err);
555
+ this._error(req, res, err);
506
556
  }
507
557
  }
508
558
 
@@ -524,7 +574,7 @@ class Admin {
524
574
  baseCtx: this._ctxWithFlash(req, res, {}),
525
575
  }), R);
526
576
  } catch (err) {
527
- this._error(res, err);
577
+ this._error(req, res, err);
528
578
  }
529
579
  }
530
580
 
@@ -563,7 +613,7 @@ class Admin {
563
613
  baseCtx: this._ctxWithFlash(req, res, {}),
564
614
  }), R);
565
615
  }
566
- this._error(res, err);
616
+ this._error(req, res, err);
567
617
  }
568
618
  }
569
619
 
@@ -579,7 +629,7 @@ class Admin {
579
629
  this._flash(req, 'success', `${R._getLabelSingular()} deleted`);
580
630
  this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
581
631
  } catch (err) {
582
- this._error(res, err);
632
+ this._error(req, res, err);
583
633
  }
584
634
  }
585
635
 
@@ -618,7 +668,7 @@ class Admin {
618
668
  baseCtx: this._ctxWithFlash(req, res, {}),
619
669
  }), R);
620
670
  } catch (err) {
621
- this._error(res, err);
671
+ this._error(req, res, err);
622
672
  }
623
673
  }
624
674
 
@@ -642,7 +692,7 @@ class Admin {
642
692
  this._flash(req, 'success', `Deleted ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
643
693
  this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
644
694
  } catch (err) {
645
- this._error(res, err);
695
+ this._error(req, res, err);
646
696
  }
647
697
  }
648
698
 
@@ -673,7 +723,7 @@ class Admin {
673
723
  this._flash(req, 'success', `Action "${action.label}" applied to ${ids.length} record${ids.length > 1 ? 's' : ''}.`);
674
724
  this._redirectWithFlash(res, `${this._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
675
725
  } catch (err) {
676
- this._error(res, err);
726
+ this._error(req, res, err);
677
727
  }
678
728
  }
679
729
 
@@ -704,7 +754,7 @@ class Admin {
704
754
  this._flash(req, 'success', rowAction.successMessage || `Action "${rowAction.label}" completed.`);
705
755
  this._redirectWithFlash(res, redirect, req._flashType, req._flashMessage);
706
756
  } catch (err) {
707
- this._error(res, err);
757
+ this._error(req, res, err);
708
758
  }
709
759
  }
710
760
 
@@ -772,17 +822,49 @@ class Admin {
772
822
  }
773
823
  labelCol = labelCol || pk;
774
824
 
825
+ // Resolve fkWhere — look up the field on the SOURCE resource (the one
826
+ // that owns the FK field), not the target resource being queried.
827
+ // e.g. TenantOwnershipResource.tenant_id has .where({ role: 'tenant' })
828
+ // but we're currently querying UserResource — wrong place to look.
829
+ const fieldName = (req.query.field || '').trim();
830
+ const fromSlug = (req.query.from || '').trim();
831
+ let fkWhere = null;
832
+ if (fieldName && fromSlug) {
833
+ const sourceResource = this._resources.get(fromSlug)
834
+ || [...this._resources.values()].find(r => r.model?.table === fromSlug);
835
+ if (sourceResource) {
836
+ const fieldDef = (sourceResource.fields() || []).find(f => f._name === fieldName);
837
+ if (fieldDef && fieldDef._fkWhere) fkWhere = fieldDef._fkWhere;
838
+ }
839
+ }
840
+
841
+ // Helper: apply fkWhere constraints to a knex query builder.
842
+ // Plain object keys are run through LookupParser so __ syntax works:
843
+ // { role: 'tenant' } → WHERE role = 'tenant'
844
+ // { age__gte: 18 } → WHERE age >= 18
845
+ // { role__in: ['a','b'] } → WHERE role IN ('a','b')
846
+ // { name__icontains: 'alice' } → WHERE name ILIKE '%alice%'
847
+ const applyScope = (q) => {
848
+ if (!fkWhere) return q;
849
+ if (typeof fkWhere === 'function') return fkWhere(q) || q;
850
+ // Plain object — run each key through LookupParser for __ support
851
+ for (const [key, value] of Object.entries(fkWhere)) {
852
+ LookupParser.apply(q, key, value, R.model);
853
+ }
854
+ return q;
855
+ };
856
+
775
857
  // Call _db() separately for each query — knex builders are mutable,
776
858
  // reusing the same instance across count + select corrupts both queries.
777
- let countQ = R.model._db().count(`${pk} as total`);
859
+ let countQ = applyScope(R.model._db().count(`${pk} as total`));
778
860
  if (search) countQ = countQ.where(labelCol, 'like', `%${search}%`);
779
861
  const [{ total }] = await countQ;
780
862
 
781
- let rowQ = R.model._db()
863
+ let rowQ = applyScope(R.model._db()
782
864
  .select([`${pk} as id`, `${labelCol} as label`])
783
865
  .orderBy(labelCol, 'asc')
784
866
  .limit(perPage)
785
- .offset(offset);
867
+ .offset(offset));
786
868
  if (search) rowQ = rowQ.where(labelCol, 'like', `%${search}%`);
787
869
  const rows = await rowQ;
788
870
 
@@ -834,7 +916,7 @@ class Admin {
834
916
  AdminAuth.setFlash(res, 'success', `${inline.label} added.`);
835
917
  res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}`);
836
918
  } catch (err) {
837
- this._error(res, err);
919
+ this._error(req, res, err);
838
920
  }
839
921
  }
840
922
 
@@ -860,7 +942,7 @@ class Admin {
860
942
  AdminAuth.setFlash(res, 'success', 'Record deleted.');
861
943
  res.redirect(`${this._config.prefix}/${R.slug}/${req.params.id}`);
862
944
  } catch (err) {
863
- this._error(res, err);
945
+ this._error(req, res, err);
864
946
  }
865
947
  }
866
948
 
@@ -907,7 +989,7 @@ class Admin {
907
989
  baseCtx: this._ctxWithFlash(req, res, { activePage: 'search' }),
908
990
  }));
909
991
  } catch (err) {
910
- this._error(res, err);
992
+ this._error(req, res, err);
911
993
  }
912
994
  }
913
995
 
@@ -963,7 +1045,7 @@ class Admin {
963
1045
  res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
964
1046
  res.send([header, ...csvRows].join('\r\n'));
965
1047
  } catch (err) {
966
- this._error(res, err);
1048
+ this._error(req, res, err);
967
1049
  }
968
1050
  }
969
1051
 
@@ -1084,7 +1166,7 @@ class Admin {
1084
1166
  finalCtx = hookCtx.templateCtx || ctx;
1085
1167
  } catch (err) {
1086
1168
  // before_render errors abort the render — surface as a 500
1087
- return this._error(res, err);
1169
+ return this._error(req, res, err);
1088
1170
  }
1089
1171
 
1090
1172
  // ── Render ──────────────────────────────────────────────────────────
@@ -1121,25 +1203,27 @@ class Admin {
1121
1203
  return R;
1122
1204
  }
1123
1205
 
1124
- _error(res, err) {
1125
- const status = err.status || 500;
1126
- res.status(status).send(`
1127
- <html><body style="font-family:'DM Sans',system-ui,sans-serif;padding:48px;background:#f4f5f7;color:#111827">
1128
- <div style="max-width:640px;margin:0 auto">
1129
- <div style="display:flex;align-items:center;gap:10px;margin-bottom:24px">
1130
- <div style="width:36px;height:36px;background:#fef2f2;border-radius:8px;display:flex;align-items:center;justify-content:center">
1131
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
1132
- </div>
1133
- <h2 style="font-size:17px;font-weight:600;color:#dc2626">Admin Error ${status}</h2>
1134
- </div>
1135
- <pre style="background:#fff;border:1px solid #e3e6ec;padding:20px;border-radius:8px;color:#374151;font-size:12.5px;overflow-x:auto;line-height:1.6">${err.stack || err.message}</pre>
1136
- <a href="javascript:history.back()" style="display:inline-flex;align-items:center;gap:6px;margin-top:16px;color:#2563eb;font-size:13px;text-decoration:none">
1137
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
1138
- Go back
1139
- </a>
1140
- </div>
1141
- </body></html>
1142
- `);
1206
+ _error(req, res, err) {
1207
+ const status = err.status || 500;
1208
+ const is404 = status === 404;
1209
+ const title = is404 ? 'Not found' : `Error ${status}`;
1210
+ const message = err.message || 'An unexpected error occurred.';
1211
+ const stack = process.env.NODE_ENV !== 'production' && !is404 ? (err.stack || '') : '';
1212
+
1213
+ try {
1214
+ const ctx = this._ctxWithFlash(req, res, {
1215
+ pageTitle: title,
1216
+ errorStatus: status,
1217
+ errorTitle: title,
1218
+ errorMsg: message,
1219
+ errorStack: stack,
1220
+ });
1221
+ res.status(status);
1222
+ return this._render(req, res, 'pages/error.njk', ctx);
1223
+ } catch (_renderErr) {
1224
+ // Fallback if template itself fails
1225
+ res.status(status).send(`<pre>${message}</pre>`);
1226
+ }
1143
1227
  }
1144
1228
 
1145
1229
  // ─── Flash (cookie-based) ─────────────────────────────────────────────────
@@ -136,7 +136,13 @@ class ViewContext {
136
136
  dateHierarchy: Resource.dateHierarchy || null,
137
137
  prepopulatedFields: Resource.prepopulatedFields || {},
138
138
  },
139
- rows,
139
+ rows: rows.map(row => ({
140
+ ...row,
141
+ _rowActions: (Resource.rowActions || []).map(ra => ({
142
+ ...ra,
143
+ href: typeof ra.href === 'function' ? ra.href(row) : ra.href,
144
+ })),
145
+ })),
140
146
  listFields,
141
147
  filters: Resource.filters().map(f => f.toJSON()),
142
148
  activeFilters,
@@ -246,7 +252,10 @@ class ViewContext {
246
252
  canEdit: perms.canEdit,
247
253
  canDelete: perms.canDelete,
248
254
  canCreate: perms.canCreate,
249
- rowActions: Resource.rowActions || [],
255
+ rowActions: (Resource.rowActions || []).map(ra => ({
256
+ ...ra,
257
+ href: typeof ra.href === 'function' ? ra.href(record) : ra.href,
258
+ })),
250
259
  },
251
260
  record,
252
261
  detailFields,
@@ -306,4 +315,4 @@ class ViewContext {
306
315
  }
307
316
  }
308
317
 
309
- module.exports = { ViewContext };
318
+ module.exports = { ViewContext };
@@ -641,6 +641,15 @@ class AdminField {
641
641
  * e.g. AdminField.text('slug').prepopulate('title')
642
642
  */
643
643
  prepopulate(src) { this._prepopulate = src; return this; }
644
+ /**
645
+ * Scope the FK dropdown to records matching these constraints.
646
+ * Accepts a plain object (col = val pairs) or a function for advanced queries.
647
+ *
648
+ * AdminField.fk('tenant_id', 'users').where({ role: 'tenant' })
649
+ * AdminField.fk('landlord_id','users').where({ role: 'landlord', is_active: true })
650
+ * AdminField.fk('user_id', 'users').where(q => q.whereIn('role', ['admin', 'moderator']))
651
+ */
652
+ where(constraints) { this._fkWhere = constraints; return this; }
644
653
 
645
654
  // ─── Serialise ─────────────────────────────────────────────────────────────
646
655
 
@@ -665,6 +674,7 @@ class AdminField {
665
674
  max: this._max,
666
675
  isLink: this._isLink || false,
667
676
  fkResource: this._fkResource || null,
677
+ fkWhere: this._fkWhere || null,
668
678
  m2mResource: this._m2mResource || null,
669
679
  prepopulate: this._prepopulate || null,
670
680
  };
@@ -333,18 +333,18 @@
333
333
  ════════════════════════════════════════ */
334
334
  .stats-grid {
335
335
  display: grid;
336
- grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
337
- gap: 14px;
338
- margin-bottom: 22px;
336
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
337
+ gap: 10px;
338
+ margin-bottom: 18px;
339
339
  }
340
340
  .stat-card {
341
341
  background: var(--surface);
342
342
  border: 1px solid var(--border);
343
- border-radius: var(--radius-lg);
344
- padding: 18px 20px;
343
+ border-radius: var(--radius);
344
+ padding: 10px 12px;
345
345
  display: flex;
346
346
  flex-direction: column;
347
- gap: 6px;
347
+ gap: 4px;
348
348
  transition: box-shadow .15s, border-color .15s;
349
349
  text-decoration: none;
350
350
  box-shadow: var(--shadow-sm);
@@ -354,28 +354,28 @@
354
354
  border-color: var(--primary-dim);
355
355
  }
356
356
  .stat-icon-wrap {
357
- width: 36px; height: 36px;
358
- border-radius: 9px;
357
+ width: 28px; height: 28px;
358
+ border-radius: 7px;
359
359
  background: linear-gradient(135deg, #fff4e6 0%, #ffe4c4 100%);
360
360
  display: flex; align-items: center; justify-content: center;
361
361
  color: var(--primary);
362
- margin-bottom: 4px;
362
+ margin-bottom: 2px;
363
363
  }
364
364
  .stat-label {
365
- font-size: 11.5px;
365
+ font-size: 10.5px;
366
366
  color: var(--text-muted);
367
367
  font-weight: 500;
368
368
  text-transform: uppercase;
369
- letter-spacing: 0.4px;
369
+ letter-spacing: 0.3px;
370
370
  }
371
371
  .stat-value {
372
- font-size: 26px;
372
+ font-size: 20px;
373
373
  font-weight: 700;
374
374
  color: var(--text);
375
375
  line-height: 1;
376
- letter-spacing: -0.5px;
376
+ letter-spacing: -0.3px;
377
377
  }
378
- .stat-sub { font-size: 12px; color: var(--text-muted); }
378
+ .stat-sub { font-size: 11px; color: var(--text-muted); }
379
379
 
380
380
  /* ════════════════════════════════════════
381
381
  TABLE
@@ -1338,4 +1338,85 @@
1338
1338
  }
1339
1339
  .toggle-input:disabled ~ .toggle-label {
1340
1340
  opacity: .5;
1341
+ }
1342
+
1343
+ /* ─────────────────────────────────────────────────────────────────
1344
+ Thin themed scrollbars
1345
+ ───────────────────────────────────────────────────────────────── */
1346
+
1347
+ /* Webkit (Chrome, Edge, Safari) */
1348
+ ::-webkit-scrollbar {
1349
+ width: 5px;
1350
+ height: 5px;
1351
+ }
1352
+ ::-webkit-scrollbar-track {
1353
+ background: transparent;
1354
+ }
1355
+ ::-webkit-scrollbar-thumb {
1356
+ background: var(--primary-dim);
1357
+ border-radius: 99px;
1358
+ }
1359
+ ::-webkit-scrollbar-thumb:hover {
1360
+ background: var(--primary);
1361
+ }
1362
+ ::-webkit-scrollbar-corner {
1363
+ background: transparent;
1364
+ }
1365
+
1366
+ /* Firefox */
1367
+ * {
1368
+ scrollbar-width: thin;
1369
+ scrollbar-color: var(--primary-dim) transparent;
1370
+ }
1371
+ *:hover {
1372
+ scrollbar-color: var(--primary) transparent;
1373
+ }
1374
+
1375
+
1376
+ /* ─────────────────────────────────────────────────────────────────
1377
+ FK cells — floating arrow button appears on cell hover
1378
+ ───────────────────────────────────────────────────────────────── */
1379
+
1380
+ /* The td needs position:relative — applied via the wrapper span signal */
1381
+ td:has(.fk-cell) {
1382
+ position: relative;
1383
+ }
1384
+
1385
+ .fk-cell {
1386
+ display: block;
1387
+ /* leave room so text never sits under the arrow */
1388
+ padding-right: 24px;
1389
+ }
1390
+
1391
+ .fk-arrow-btn {
1392
+ position: absolute;
1393
+ right: 8px;
1394
+ top: 50%;
1395
+ transform: translateY(-50%) translateX(4px);
1396
+ opacity: 0;
1397
+ transition: opacity .12s ease, transform .12s ease;
1398
+ color: var(--primary);
1399
+ display: inline-flex;
1400
+ align-items: center;
1401
+ justify-content: center;
1402
+ width: 22px;
1403
+ height: 22px;
1404
+ border-radius: var(--radius-sm);
1405
+ text-decoration: none;
1406
+ background: var(--primary-soft);
1407
+ border: 1px solid var(--primary-dim);
1408
+ pointer-events: none;
1409
+ }
1410
+
1411
+ /* Show on td hover */
1412
+ td:hover .fk-arrow-btn {
1413
+ opacity: 1;
1414
+ transform: translateY(-50%) translateX(0);
1415
+ pointer-events: auto;
1416
+ }
1417
+
1418
+ /* Detail page variant */
1419
+ .fk-cell-detail .fk-arrow-btn {
1420
+ width: 24px;
1421
+ height: 24px;
1341
1422
  }
@@ -8,7 +8,7 @@
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
10
  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
11
- <link rel="stylesheet" href="{{ adminPrefix }}/static/admin.css?v=15">
11
+ <link rel="stylesheet" href="{{ adminPrefix }}/static/admin.css?v=21">
12
12
  <link rel="stylesheet" href="{{ adminPrefix }}/static/json-editor.css?v=4">
13
13
  <link rel="stylesheet" href="{{ adminPrefix }}/static/date-picker.css?v=3">
14
14
  <script src="https://code.jquery.com/jquery-3.7.1.min.js" crossorigin="anonymous"></script>
@@ -19,38 +19,7 @@
19
19
  {# ══════════════════════════════════════
20
20
  SVG ICON DEFINITIONS (hidden sprite)
21
21
  ══════════════════════════════════════ #}
22
- <svg xmlns="http://www.w3.org/2000/svg" style="display:none">
23
- <symbol id="ic-home" viewBox="0 0 24 24"><path d="M3 9.5L12 3l9 6.5V20a1 1 0 01-1 1H5a1 1 0 01-1-1V9.5z"/><path d="M9 21V12h6v9"/></symbol>
24
- <symbol id="ic-table" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 3v18"/></symbol>
25
- <symbol id="ic-users" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></symbol>
26
- <symbol id="ic-file" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></symbol>
27
- <symbol id="ic-tag" viewBox="0 0 24 24"><path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z"/><circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none"/></symbol>
28
- <symbol id="ic-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></symbol>
29
- <symbol id="ic-plus" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></symbol>
30
- <symbol id="ic-edit" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></symbol>
31
- <symbol id="ic-trash" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></symbol>
32
- <symbol id="ic-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></symbol>
33
- <symbol id="ic-filter" viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></symbol>
34
- <symbol id="ic-chevron-left" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></symbol>
35
- <symbol id="ic-chevron-right" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></symbol>
36
- <symbol id="ic-chevron-down" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></symbol>
37
- <symbol id="ic-x" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></symbol>
38
- <symbol id="ic-check" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></symbol>
39
- <symbol id="ic-alert-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></symbol>
40
- <symbol id="ic-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></symbol>
41
- <symbol id="ic-eye" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></symbol>
42
- <symbol id="ic-download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></symbol>
43
- <symbol id="ic-upload" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></symbol>
44
- <symbol id="ic-refresh" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></symbol>
45
- <symbol id="ic-more-vertical" viewBox="0 0 24 24"><circle cx="12" cy="5" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="19" r="1" fill="currentColor" stroke="none"/></symbol>
46
- <symbol id="ic-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></symbol>
47
- <symbol id="ic-list" viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></symbol>
48
- <symbol id="ic-save" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></symbol>
49
- <symbol id="ic-arrow-left" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></symbol>
50
- <symbol id="ic-database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></symbol>
51
- <symbol id="ic-activity" viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></symbol>
52
- <symbol id="ic-log-out" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></symbol>
53
- </svg>
22
+ {% include "partials/icons.njk" %}
54
23
 
55
24
  {# ══════════════════════════════════════
56
25
  SIDEBAR
@@ -80,14 +49,34 @@
80
49
  <div class="nav-section">
81
50
  <div class="nav-label">Resources</div>
82
51
  {% for resource in resources %}
52
+ {% if resource.category != 'auth' %}
83
53
  <a href="{{ adminPrefix }}/{{ resource.slug }}" class="nav-item {% if activeResource == resource.slug %}active{% endif %}">
84
54
  <span class="nav-icon icon icon-16">
85
- <svg viewBox="0 0 24 24"><use href="#ic-table"/></svg>
55
+ <svg viewBox="0 0 24 24"><use href="#ic-{{ resource.icon or 'table' }}"/></svg>
86
56
  </span>
87
57
  {{ resource.label }}
88
58
  </a>
59
+ {% endif %}
89
60
  {% endfor %}
90
61
  </div>
62
+
63
+ {% set hasAuth = false %}
64
+ {% for resource in resources %}{% if resource.category == 'auth' %}{% set hasAuth = true %}{% endif %}{% endfor %}
65
+ {% if hasAuth %}
66
+ <div class="nav-section">
67
+ <div class="nav-label">Auth</div>
68
+ {% for resource in resources %}
69
+ {% if resource.category == 'auth' %}
70
+ <a href="{{ adminPrefix }}/{{ resource.slug }}" class="nav-item {% if activeResource == resource.slug %}active{% endif %}">
71
+ <span class="nav-icon icon icon-16">
72
+ <svg viewBox="0 0 24 24"><use href="#ic-{{ resource.icon or 'user' }}"/></svg>
73
+ </span>
74
+ {{ resource.label }}
75
+ </a>
76
+ {% endif %}
77
+ {% endfor %}
78
+ </div>
79
+ {% endif %}
91
80
  {% endif %}
92
81
 
93
82
  <div class="sidebar-footer">