millas 0.2.12-beta-1 → 0.2.13-beta

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 (120) hide show
  1. package/package.json +3 -2
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +516 -199
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +318 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +393 -97
  12. package/src/admin/static/admin.css +1422 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +87 -1046
  19. package/src/admin/views/pages/detail.njk +56 -21
  20. package/src/admin/views/pages/error.njk +65 -0
  21. package/src/admin/views/pages/form.njk +47 -599
  22. package/src/admin/views/pages/list.njk +270 -62
  23. package/src/admin/views/partials/form-field.njk +53 -0
  24. package/src/admin/views/partials/form-footer.njk +28 -0
  25. package/src/admin/views/partials/form-readonly.njk +114 -0
  26. package/src/admin/views/partials/form-scripts.njk +480 -0
  27. package/src/admin/views/partials/form-widget.njk +297 -0
  28. package/src/admin/views/partials/icons.njk +64 -0
  29. package/src/admin/views/partials/json-dialog.njk +80 -0
  30. package/src/admin/views/partials/json-editor.njk +37 -0
  31. package/src/ai/AIManager.js +954 -0
  32. package/src/ai/AITokenBudget.js +250 -0
  33. package/src/ai/PromptGuard.js +216 -0
  34. package/src/ai/agents.js +218 -0
  35. package/src/ai/conversation.js +213 -0
  36. package/src/ai/drivers.js +734 -0
  37. package/src/ai/files.js +249 -0
  38. package/src/ai/media.js +303 -0
  39. package/src/ai/pricing.js +152 -0
  40. package/src/ai/provider_tools.js +114 -0
  41. package/src/ai/types.js +356 -0
  42. package/src/auth/Auth.js +18 -2
  43. package/src/auth/AuthUser.js +65 -44
  44. package/src/cli.js +3 -1
  45. package/src/commands/createsuperuser.js +267 -0
  46. package/src/commands/lang.js +589 -0
  47. package/src/commands/migrate.js +154 -81
  48. package/src/commands/serve.js +3 -4
  49. package/src/container/AppInitializer.js +101 -20
  50. package/src/container/Application.js +31 -1
  51. package/src/container/MillasApp.js +10 -3
  52. package/src/container/MillasConfig.js +35 -6
  53. package/src/core/admin.js +5 -0
  54. package/src/core/db.js +2 -1
  55. package/src/core/foundation.js +2 -10
  56. package/src/core/lang.js +1 -0
  57. package/src/errors/HttpError.js +32 -16
  58. package/src/facades/AI.js +411 -0
  59. package/src/facades/Hash.js +67 -0
  60. package/src/facades/Process.js +144 -0
  61. package/src/hashing/Hash.js +262 -0
  62. package/src/http/HtmlEscape.js +162 -0
  63. package/src/http/MillasRequest.js +63 -7
  64. package/src/http/MillasResponse.js +70 -4
  65. package/src/http/ResponseDispatcher.js +21 -27
  66. package/src/http/SafeFilePath.js +195 -0
  67. package/src/http/SafeRedirect.js +62 -0
  68. package/src/http/SecurityBootstrap.js +70 -0
  69. package/src/http/helpers.js +40 -125
  70. package/src/http/index.js +10 -1
  71. package/src/http/middleware/CsrfMiddleware.js +258 -0
  72. package/src/http/middleware/RateLimiter.js +314 -0
  73. package/src/http/middleware/SecurityHeaders.js +281 -0
  74. package/src/i18n/I18nServiceProvider.js +91 -0
  75. package/src/i18n/Translator.js +643 -0
  76. package/src/i18n/defaults.js +122 -0
  77. package/src/i18n/index.js +164 -0
  78. package/src/i18n/locales/en.js +55 -0
  79. package/src/i18n/locales/sw.js +48 -0
  80. package/src/logger/LogRedactor.js +247 -0
  81. package/src/logger/Logger.js +1 -1
  82. package/src/logger/formatters/JsonFormatter.js +11 -4
  83. package/src/logger/formatters/PrettyFormatter.js +103 -65
  84. package/src/logger/formatters/SimpleFormatter.js +14 -3
  85. package/src/middleware/ThrottleMiddleware.js +27 -4
  86. package/src/migrations/system/0001_users.js +21 -0
  87. package/src/migrations/system/0002_admin_log.js +25 -0
  88. package/src/migrations/system/0003_sessions.js +23 -0
  89. package/src/orm/fields/index.js +210 -188
  90. package/src/orm/migration/DefaultValueParser.js +325 -0
  91. package/src/orm/migration/InteractiveResolver.js +191 -0
  92. package/src/orm/migration/Makemigrations.js +312 -0
  93. package/src/orm/migration/MigrationGraph.js +227 -0
  94. package/src/orm/migration/MigrationRunner.js +202 -108
  95. package/src/orm/migration/MigrationWriter.js +463 -0
  96. package/src/orm/migration/ModelInspector.js +143 -74
  97. package/src/orm/migration/ModelScanner.js +225 -0
  98. package/src/orm/migration/ProjectState.js +213 -0
  99. package/src/orm/migration/RenameDetector.js +175 -0
  100. package/src/orm/migration/SchemaBuilder.js +8 -81
  101. package/src/orm/migration/operations/base.js +57 -0
  102. package/src/orm/migration/operations/column.js +191 -0
  103. package/src/orm/migration/operations/fields.js +252 -0
  104. package/src/orm/migration/operations/index.js +55 -0
  105. package/src/orm/migration/operations/models.js +152 -0
  106. package/src/orm/migration/operations/registry.js +131 -0
  107. package/src/orm/migration/operations/special.js +51 -0
  108. package/src/orm/migration/utils.js +208 -0
  109. package/src/orm/model/Model.js +81 -13
  110. package/src/process/Process.js +333 -0
  111. package/src/providers/AdminServiceProvider.js +66 -9
  112. package/src/providers/AuthServiceProvider.js +40 -5
  113. package/src/providers/CacheStorageServiceProvider.js +2 -2
  114. package/src/providers/DatabaseServiceProvider.js +3 -2
  115. package/src/providers/LogServiceProvider.js +4 -1
  116. package/src/providers/MailServiceProvider.js +1 -1
  117. package/src/providers/QueueServiceProvider.js +1 -1
  118. package/src/router/MiddlewareRegistry.js +27 -2
  119. package/src/scaffold/templates.js +80 -21
  120. package/src/validation/Validator.js +348 -607
@@ -3,878 +3,15 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="csrf-token" content="{{ csrfToken }}">
6
7
  <title>{% block title %}{{ pageTitle }}{% endblock %} — {{ adminTitle }}</title>
7
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
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">
10
- <style>
11
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
12
-
13
- :root {
14
- /* ── Palette ── */
15
- --bg: #f4f5f7;
16
- --surface: #ffffff;
17
- --surface2: #f8f9fb;
18
- --surface3: #eef0f4;
19
- --border: #e3e6ec;
20
- --border-soft: #edf0f5;
21
-
22
- /* ── Brand ── */
23
- --primary: #2563eb;
24
- --primary-h: #1d4ed8;
25
- --primary-soft: #eff4ff;
26
- --primary-dim: #dbeafe;
27
-
28
- /* ── Text ── */
29
- --text: #111827;
30
- --text-soft: #374151;
31
- --text-muted: #6b7280;
32
- --text-xmuted: #9ca3af;
33
-
34
- /* ── Semantic ── */
35
- --success: #16a34a;
36
- --success-bg: #f0fdf4;
37
- --success-border:#bbf7d0;
38
- --danger: #dc2626;
39
- --danger-bg: #fef2f2;
40
- --danger-border:#fecaca;
41
- --warning: #d97706;
42
- --warning-bg: #fffbeb;
43
- --warning-border:#fed7aa;
44
- --info: #0284c7;
45
- --info-bg: #f0f9ff;
46
- --info-border: #bae6fd;
47
-
48
- /* ── Shape ── */
49
- --radius: 8px;
50
- --radius-sm: 6px;
51
- --radius-lg: 12px;
52
- --shadow-sm: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04);
53
- --shadow: 0 4px 12px rgba(0,0,0,.08), 0 2px 4px rgba(0,0,0,.04);
54
- --shadow-lg: 0 12px 32px rgba(0,0,0,.12), 0 4px 8px rgba(0,0,0,.06);
55
-
56
- /* ── Sidebar ── */
57
- --sidebar-w: 232px;
58
- }
59
-
60
- body {
61
- font-family: 'DM Sans', system-ui, sans-serif;
62
- background: var(--bg);
63
- color: var(--text);
64
- display: flex;
65
- height: 100vh;
66
- overflow: hidden;
67
- font-size: 14px;
68
- line-height: 1.5;
69
- -webkit-font-smoothing: antialiased;
70
- }
71
-
72
- /* ════════════════════════════════════════
73
- ICONS (inline SVG sprite system)
74
- ════════════════════════════════════════ */
75
- .icon {
76
- display: inline-flex;
77
- align-items: center;
78
- justify-content: center;
79
- flex-shrink: 0;
80
- }
81
- .icon svg {
82
- width: 1em;
83
- height: 1em;
84
- fill: none;
85
- stroke: currentColor;
86
- stroke-width: 1.75;
87
- stroke-linecap: round;
88
- stroke-linejoin: round;
89
- }
90
- .icon-sm svg { stroke-width: 2; }
91
- .icon-14 { font-size: 14px; }
92
- .icon-15 { font-size: 15px; }
93
- .icon-16 { font-size: 16px; }
94
- .icon-18 { font-size: 18px; }
95
- .icon-20 { font-size: 20px; }
96
-
97
- /* ════════════════════════════════════════
98
- SIDEBAR
99
- ════════════════════════════════════════ */
100
- #sidebar {
101
- width: var(--sidebar-w);
102
- min-width: var(--sidebar-w);
103
- background: var(--surface);
104
- border-right: 1px solid var(--border);
105
- display: flex;
106
- flex-direction: column;
107
- overflow-y: auto;
108
- overflow-x: hidden;
109
- }
110
-
111
- .sidebar-brand {
112
- padding: 18px 16px 16px;
113
- border-bottom: 1px solid var(--border-soft);
114
- display: flex;
115
- align-items: center;
116
- gap: 10px;
117
- }
118
- .brand-logo {
119
- width: 34px; height: 34px;
120
- background: var(--primary);
121
- border-radius: 9px;
122
- display: flex; align-items: center; justify-content: center;
123
- color: #fff;
124
- font-size: 16px;
125
- flex-shrink: 0;
126
- box-shadow: 0 2px 8px rgba(37,99,235,.35);
127
- }
128
- .brand-text { line-height: 1.25; overflow: hidden; }
129
- .brand-name {
130
- font-size: 13.5px;
131
- font-weight: 700;
132
- color: var(--text);
133
- white-space: nowrap;
134
- overflow: hidden;
135
- text-overflow: ellipsis;
136
- }
137
- .brand-sub { font-size: 11px; color: var(--text-muted); }
138
-
139
- .nav-section { padding: 10px 10px 4px; }
140
- .nav-label {
141
- font-size: 10.5px;
142
- font-weight: 600;
143
- color: var(--text-xmuted);
144
- text-transform: uppercase;
145
- letter-spacing: 0.7px;
146
- padding: 0 8px 6px;
147
- }
148
- .nav-item {
149
- display: flex;
150
- align-items: center;
151
- gap: 9px;
152
- padding: 7px 10px;
153
- border-radius: var(--radius-sm);
154
- color: var(--text-muted);
155
- text-decoration: none;
156
- font-size: 13.5px;
157
- font-weight: 500;
158
- transition: background .12s, color .12s;
159
- cursor: pointer;
160
- border: none;
161
- background: none;
162
- width: 100%;
163
- text-align: left;
164
- }
165
- .nav-item:hover { background: var(--surface2); color: var(--text-soft); }
166
- .nav-item.active { background: var(--primary-soft); color: var(--primary); }
167
- .nav-item.active .nav-icon { color: var(--primary); }
168
-
169
- .nav-icon {
170
- color: var(--text-xmuted);
171
- transition: color .12s;
172
- width: 18px;
173
- display: flex;
174
- align-items: center;
175
- justify-content: center;
176
- flex-shrink: 0;
177
- }
178
- .nav-item:hover .nav-icon { color: var(--text-soft); }
179
- .nav-item.active .nav-icon { color: var(--primary); }
180
-
181
- .nav-count {
182
- margin-left: auto;
183
- font-size: 11px;
184
- font-weight: 500;
185
- background: var(--surface3);
186
- color: var(--text-muted);
187
- padding: 1px 6px;
188
- border-radius: 99px;
189
- min-width: 20px;
190
- text-align: center;
191
- }
192
-
193
- .sidebar-footer {
194
- margin-top: auto;
195
- padding: 12px 16px;
196
- border-top: 1px solid var(--border-soft);
197
- }
198
- .sidebar-version {
199
- font-size: 11px;
200
- color: var(--text-xmuted);
201
- }
202
-
203
- /* ── User row ── */
204
- .user-row {
205
- display: flex; align-items: center; gap: 9px;
206
- }
207
- .user-avatar {
208
- width: 30px; height: 30px; border-radius: 8px;
209
- background: var(--primary-dim); color: var(--primary);
210
- display: flex; align-items: center; justify-content: center;
211
- font-size: 13px; font-weight: 700; flex-shrink: 0;
212
- }
213
- .user-info { flex: 1; min-width: 0; }
214
- .user-name { font-size: 12.5px; font-weight: 600; color: var(--text-soft); }
215
- .user-email { font-size: 11px; color: var(--text-muted); }
216
- .logout-btn {
217
- flex-shrink: 0; padding: 5px;
218
- border-radius: 5px; color: var(--text-muted);
219
- display: flex; align-items: center;
220
- transition: background .1s, color .1s;
221
- }
222
- .logout-btn:hover { background: var(--surface3); color: var(--danger); }
223
-
224
- /* ════════════════════════════════════════
225
- MAIN AREA
226
- ════════════════════════════════════════ */
227
- #main {
228
- flex: 1;
229
- display: flex;
230
- flex-direction: column;
231
- overflow: hidden;
232
- min-width: 0;
233
- }
234
-
235
- /* ── Topbar ── */
236
- #topbar {
237
- background: var(--surface);
238
- border-bottom: 1px solid var(--border);
239
- padding: 0 24px;
240
- height: 54px;
241
- display: flex;
242
- align-items: center;
243
- gap: 12px;
244
- flex-shrink: 0;
245
- box-shadow: var(--shadow-sm);
246
- }
247
- .topbar-title {
248
- font-size: 15px;
249
- font-weight: 600;
250
- flex: 1;
251
- color: var(--text);
252
- display: flex;
253
- align-items: center;
254
- gap: 8px;
255
- }
256
- .topbar-actions { display: flex; gap: 8px; align-items: center; }
257
-
258
- /* ── Content ── */
259
- #content {
260
- flex: 1;
261
- overflow-y: auto;
262
- padding: 24px;
263
- }
264
-
265
- /* ════════════════════════════════════════
266
- BREADCRUMB
267
- ════════════════════════════════════════ */
268
- .breadcrumb {
269
- display: flex;
270
- align-items: center;
271
- gap: 5px;
272
- font-size: 12px;
273
- color: var(--text-muted);
274
- margin-bottom: 18px;
275
- }
276
- .breadcrumb a { color: var(--text-muted); text-decoration: none; }
277
- .breadcrumb a:hover { color: var(--primary); }
278
- .breadcrumb-sep { color: var(--border); }
279
- .breadcrumb-current { color: var(--text-soft); font-weight: 500; }
280
-
281
- /* ════════════════════════════════════════
282
- ALERTS
283
- ════════════════════════════════════════ */
284
- .alert {
285
- padding: 11px 16px;
286
- border-radius: var(--radius-sm);
287
- font-size: 13px;
288
- margin-bottom: 18px;
289
- display: flex;
290
- align-items: center;
291
- gap: 9px;
292
- border: 1px solid transparent;
293
- }
294
- .alert-success { background: var(--success-bg); color: var(--success); border-color: var(--success-border); }
295
- .alert-error { background: var(--danger-bg); color: var(--danger); border-color: var(--danger-border); }
296
- .alert-warning { background: var(--warning-bg); color: var(--warning); border-color: var(--warning-border); }
297
- .alert-info { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
298
- .alert-close {
299
- margin-left: auto;
300
- background: none;
301
- border: none;
302
- cursor: pointer;
303
- color: inherit;
304
- opacity: .6;
305
- padding: 0;
306
- line-height: 1;
307
- font-size: 16px;
308
- }
309
- .alert-close:hover { opacity: 1; }
310
-
311
- /* ════════════════════════════════════════
312
- CARDS
313
- ════════════════════════════════════════ */
314
- .card {
315
- background: var(--surface);
316
- border: 1px solid var(--border);
317
- border-radius: var(--radius-lg);
318
- overflow: hidden;
319
- box-shadow: var(--shadow-sm);
320
- }
321
- .card-header {
322
- padding: 14px 20px;
323
- border-bottom: 1px solid var(--border-soft);
324
- display: flex;
325
- align-items: center;
326
- justify-content: space-between;
327
- gap: 12px;
328
- flex-wrap: wrap;
329
- background: var(--surface);
330
- }
331
- .card-title {
332
- font-size: 13.5px;
333
- font-weight: 600;
334
- color: var(--text);
335
- display: flex;
336
- align-items: center;
337
- gap: 7px;
338
- }
339
- .card-body { padding: 20px; }
340
-
341
- /* ════════════════════════════════════════
342
- STAT CARDS
343
- ════════════════════════════════════════ */
344
- .stats-grid {
345
- display: grid;
346
- grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
347
- gap: 14px;
348
- margin-bottom: 22px;
349
- }
350
- .stat-card {
351
- background: var(--surface);
352
- border: 1px solid var(--border);
353
- border-radius: var(--radius-lg);
354
- padding: 18px 20px;
355
- display: flex;
356
- flex-direction: column;
357
- gap: 6px;
358
- transition: box-shadow .15s, border-color .15s;
359
- text-decoration: none;
360
- box-shadow: var(--shadow-sm);
361
- }
362
- .stat-card:hover {
363
- box-shadow: var(--shadow);
364
- border-color: var(--primary-dim);
365
- }
366
- .stat-icon-wrap {
367
- width: 36px; height: 36px;
368
- border-radius: 9px;
369
- background: var(--primary-soft);
370
- display: flex; align-items: center; justify-content: center;
371
- color: var(--primary);
372
- margin-bottom: 4px;
373
- }
374
- .stat-label {
375
- font-size: 11.5px;
376
- color: var(--text-muted);
377
- font-weight: 500;
378
- text-transform: uppercase;
379
- letter-spacing: 0.4px;
380
- }
381
- .stat-value {
382
- font-size: 26px;
383
- font-weight: 700;
384
- color: var(--text);
385
- line-height: 1;
386
- letter-spacing: -0.5px;
387
- }
388
- .stat-sub { font-size: 12px; color: var(--text-muted); }
389
-
390
- /* ════════════════════════════════════════
391
- TABLE
392
- ════════════════════════════════════════ */
393
- .table-wrap { overflow-x: auto; }
394
- table { width: 100%; border-collapse: collapse; }
395
- th {
396
- text-align: left;
397
- padding: 9px 16px;
398
- font-size: 11px;
399
- font-weight: 600;
400
- text-transform: uppercase;
401
- letter-spacing: 0.5px;
402
- color: var(--text-muted);
403
- background: var(--surface2);
404
- border-bottom: 1px solid var(--border);
405
- white-space: nowrap;
406
- }
407
- th.sortable { cursor: pointer; user-select: none; }
408
- th.sortable:hover { color: var(--text-soft); }
409
- th.sort-active { color: var(--primary); }
410
- .sort-indicator { display: inline-flex; flex-direction: column; gap: 1px; margin-left: 4px; vertical-align: middle; }
411
- .sort-indicator span { width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; opacity: .3; }
412
- .sort-indicator .up { border-bottom: 5px solid currentColor; }
413
- .sort-indicator .down { border-top: 5px solid currentColor; }
414
- th.sort-asc .sort-indicator .up { opacity: 1; }
415
- th.sort-desc .sort-indicator .down { opacity: 1; }
416
- td {
417
- padding: 11px 16px;
418
- font-size: 13px;
419
- border-bottom: 1px solid var(--border-soft);
420
- vertical-align: middle;
421
- color: var(--text-soft);
422
- }
423
- tr:last-child td { border-bottom: none; }
424
- tr:hover td { background: var(--surface2); }
425
- .col-check { width: 44px; }
426
- .col-actions { width: 100px; text-align: right; }
427
- .td-primary { color: var(--text); font-weight: 500; }
428
-
429
- /* ── Row checkbox ── */
430
- .row-check {
431
- width: 16px; height: 16px;
432
- cursor: pointer;
433
- accent-color: var(--primary);
434
- }
435
-
436
- /* ── Action menu ── */
437
- .action-menu { position: relative; display: inline-block; }
438
- .action-menu-btn {
439
- background: none;
440
- border: 1px solid var(--border);
441
- border-radius: var(--radius-sm);
442
- padding: 4px 8px;
443
- cursor: pointer;
444
- color: var(--text-muted);
445
- display: flex;
446
- align-items: center;
447
- gap: 3px;
448
- font-size: 12px;
449
- transition: all .12s;
450
- }
451
- .action-menu-btn:hover { background: var(--surface2); color: var(--text-soft); border-color: var(--border); }
452
- .action-dropdown {
453
- position: absolute;
454
- right: 0;
455
- top: calc(100% + 4px);
456
- background: var(--surface);
457
- border: 1px solid var(--border);
458
- border-radius: var(--radius);
459
- box-shadow: var(--shadow-lg);
460
- min-width: 140px;
461
- z-index: 50;
462
- overflow: hidden;
463
- display: none;
464
- }
465
- .action-dropdown.open { display: block; }
466
- .action-dropdown a,
467
- .action-dropdown button {
468
- display: flex;
469
- align-items: center;
470
- gap: 8px;
471
- padding: 8px 14px;
472
- font-size: 13px;
473
- color: var(--text-soft);
474
- text-decoration: none;
475
- background: none;
476
- border: none;
477
- width: 100%;
478
- text-align: left;
479
- cursor: pointer;
480
- font-family: inherit;
481
- transition: background .1s;
482
- }
483
- .action-dropdown a:hover,
484
- .action-dropdown button:hover { background: var(--surface2); }
485
- .action-dropdown .sep { height: 1px; background: var(--border-soft); margin: 3px 0; }
486
- .action-dropdown .danger { color: var(--danger); }
487
-
488
- /* ════════════════════════════════════════
489
- BADGES
490
- ════════════════════════════════════════ */
491
- .badge {
492
- display: inline-flex;
493
- align-items: center;
494
- gap: 4px;
495
- padding: 2px 8px;
496
- border-radius: 99px;
497
- font-size: 11.5px;
498
- font-weight: 600;
499
- white-space: nowrap;
500
- border: 1px solid transparent;
501
- }
502
- .badge-blue { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
503
- .badge-red { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
504
- .badge-green { background: #f0fdf4; color: #15803d; border-color: #bbf7d0; }
505
- .badge-yellow { background: #fffbeb; color: #b45309; border-color: #fde68a; }
506
- .badge-purple { background: #faf5ff; color: #7e22ce; border-color: #e9d5ff; }
507
- .badge-gray { background: #f9fafb; color: #6b7280; border-color: #e5e7eb; }
508
- .badge-orange { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
509
-
510
- /* ════════════════════════════════════════
511
- BUTTONS
512
- ════════════════════════════════════════ */
513
- .btn {
514
- display: inline-flex;
515
- align-items: center;
516
- gap: 6px;
517
- padding: 7px 14px;
518
- border-radius: var(--radius-sm);
519
- font-size: 13px;
520
- font-weight: 500;
521
- cursor: pointer;
522
- border: 1px solid transparent;
523
- transition: all .12s;
524
- text-decoration: none;
525
- font-family: inherit;
526
- line-height: 1;
527
- white-space: nowrap;
528
- }
529
- .btn-primary {
530
- background: var(--primary);
531
- color: #fff;
532
- border-color: var(--primary);
533
- box-shadow: 0 1px 3px rgba(37,99,235,.3);
534
- }
535
- .btn-primary:hover { background: var(--primary-h); border-color: var(--primary-h); }
536
- .btn-ghost {
537
- background: transparent;
538
- color: var(--text-soft);
539
- border-color: var(--border);
540
- }
541
- .btn-ghost:hover { background: var(--surface2); color: var(--text); }
542
- .btn-danger {
543
- background: transparent;
544
- color: var(--danger);
545
- border-color: var(--border);
546
- }
547
- .btn-danger:hover { background: var(--danger-bg); border-color: var(--danger-border); }
548
- .btn-success {
549
- background: var(--success);
550
- color: #fff;
551
- border-color: var(--success);
552
- }
553
- .btn-success:hover { opacity: .9; }
554
- .btn-sm { padding: 5px 10px; font-size: 12px; }
555
- .btn-xs { padding: 3px 8px; font-size: 11px; }
556
- .btn-icon { padding: 6px; }
557
- .btn:disabled { opacity: .5; cursor: not-allowed; pointer-events: none; }
558
-
559
- /* ════════════════════════════════════════
560
- FORMS
561
- ════════════════════════════════════════ */
562
- .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
563
- .form-group { display: flex; flex-direction: column; gap: 5px; }
564
- .form-group.full { grid-column: 1 / -1; }
565
- .form-group.w-third { grid-column: span 1; }
566
- .form-label { font-size: 12.5px; font-weight: 500; color: var(--text-soft); }
567
- .form-label .required { color: var(--danger); margin-left: 2px; }
568
- .form-control {
569
- background: var(--surface);
570
- border: 1px solid var(--border);
571
- color: var(--text);
572
- border-radius: var(--radius-sm);
573
- padding: 8px 11px;
574
- font-size: 13.5px;
575
- width: 100%;
576
- outline: none;
577
- font-family: inherit;
578
- transition: border .12s, box-shadow .12s;
579
- }
580
- .form-control:hover { border-color: #c4c9d4; }
581
- .form-control:focus {
582
- border-color: var(--primary);
583
- box-shadow: 0 0 0 3px var(--primary-soft);
584
- }
585
- .form-control.error {
586
- border-color: var(--danger);
587
- box-shadow: 0 0 0 3px var(--danger-bg);
588
- }
589
- .form-control::placeholder { color: var(--text-xmuted); }
590
- select.form-control { cursor: pointer; }
591
- textarea.form-control { resize: vertical; min-height: 90px; }
592
- .form-help { font-size: 11.5px; color: var(--text-muted); }
593
- .form-error { font-size: 11.5px; color: var(--danger); display: flex; align-items: center; gap: 4px; }
594
-
595
- /* ── Checkbox / radio ── */
596
- .check-group { display: flex; align-items: center; gap: 8px; }
597
- .check-input {
598
- width: 16px; height: 16px;
599
- accent-color: var(--primary);
600
- cursor: pointer;
601
- }
602
- .check-label { font-size: 13.5px; color: var(--text-soft); cursor: pointer; }
603
-
604
- /* ── Search ── */
605
- .search-wrap { position: relative; }
606
- .search-icon-inner {
607
- position: absolute;
608
- left: 10px;
609
- top: 50%;
610
- transform: translateY(-50%);
611
- color: var(--text-muted);
612
- pointer-events: none;
613
- font-size: 14px;
614
- display: flex;
615
- }
616
- .search-input { padding-left: 34px !important; width: 220px; }
617
-
618
- /* ════════════════════════════════════════
619
- TOOLBAR (list page)
620
- ════════════════════════════════════════ */
621
- .toolbar {
622
- display: flex;
623
- align-items: center;
624
- gap: 8px;
625
- padding: 12px 18px;
626
- border-bottom: 1px solid var(--border-soft);
627
- flex-wrap: wrap;
628
- }
629
- .toolbar-left { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
630
- .toolbar-right { display: flex; align-items: center; gap: 8px; }
631
-
632
- /* ── Bulk action bar ── */
633
- .bulk-bar {
634
- display: none;
635
- align-items: center;
636
- gap: 10px;
637
- padding: 10px 18px;
638
- background: var(--primary-soft);
639
- border-bottom: 1px solid var(--primary-dim);
640
- font-size: 13px;
641
- }
642
- .bulk-bar.visible { display: flex; }
643
- .bulk-count { font-weight: 600; color: var(--primary); }
644
-
645
- /* ════════════════════════════════════════
646
- FILTER PANEL
647
- ════════════════════════════════════════ */
648
- .filter-row {
649
- display: flex;
650
- align-items: flex-end;
651
- gap: 10px;
652
- padding: 12px 18px;
653
- border-bottom: 1px solid var(--border-soft);
654
- flex-wrap: wrap;
655
- background: var(--surface2);
656
- }
657
- .filter-group { display: flex; flex-direction: column; gap: 4px; }
658
- .filter-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .4px; color: var(--text-muted); }
659
- .filter-control {
660
- background: var(--surface);
661
- border: 1px solid var(--border);
662
- color: var(--text);
663
- border-radius: var(--radius-sm);
664
- padding: 6px 10px;
665
- font-size: 12.5px;
666
- outline: none;
667
- font-family: inherit;
668
- transition: border .12s;
669
- }
670
- .filter-control:focus { border-color: var(--primary); }
671
-
672
- /* ════════════════════════════════════════
673
- PAGINATION
674
- ════════════════════════════════════════ */
675
- .pagination {
676
- display: flex;
677
- align-items: center;
678
- gap: 3px;
679
- padding: 12px 18px;
680
- border-top: 1px solid var(--border-soft);
681
- flex-wrap: wrap;
682
- }
683
- .page-info { font-size: 12px; color: var(--text-muted); margin-left: auto; }
684
- .page-btn {
685
- min-width: 30px;
686
- height: 30px;
687
- padding: 0 6px;
688
- display: inline-flex;
689
- align-items: center;
690
- justify-content: center;
691
- border-radius: var(--radius-sm);
692
- border: 1px solid var(--border);
693
- background: transparent;
694
- color: var(--text-soft);
695
- cursor: pointer;
696
- font-size: 12px;
697
- font-family: inherit;
698
- transition: all .12s;
699
- text-decoration: none;
700
- }
701
- .page-btn:hover:not(:disabled) { background: var(--surface2); color: var(--text); border-color: #c4c9d4; }
702
- .page-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; }
703
- .page-btn:disabled { opacity: .35; cursor: not-allowed; }
704
- .page-ellipsis { color: var(--text-muted); font-size: 12px; padding: 0 3px; }
705
-
706
- /* ════════════════════════════════════════
707
- MODAL
708
- ════════════════════════════════════════ */
709
- .modal-overlay {
710
- position: fixed;
711
- inset: 0;
712
- background: rgba(17,24,39,.5);
713
- display: flex;
714
- align-items: center;
715
- justify-content: center;
716
- z-index: 200;
717
- padding: 24px;
718
- opacity: 0;
719
- pointer-events: none;
720
- transition: opacity .18s;
721
- backdrop-filter: blur(2px);
722
- }
723
- .modal-overlay.open { opacity: 1; pointer-events: all; }
724
- .modal {
725
- background: var(--surface);
726
- border: 1px solid var(--border);
727
- border-radius: var(--radius-lg);
728
- width: 100%;
729
- max-width: 520px;
730
- max-height: 90vh;
731
- overflow-y: auto;
732
- transform: translateY(10px) scale(.99);
733
- transition: transform .2s;
734
- box-shadow: var(--shadow-lg);
735
- }
736
- .modal-overlay.open .modal { transform: translateY(0) scale(1); }
737
- .modal-sm { max-width: 400px; }
738
- .modal-lg { max-width: 700px; }
739
- .modal-header {
740
- padding: 18px 22px;
741
- border-bottom: 1px solid var(--border-soft);
742
- display: flex;
743
- justify-content: space-between;
744
- align-items: center;
745
- position: sticky;
746
- top: 0;
747
- background: var(--surface);
748
- z-index: 1;
749
- }
750
- .modal-title { font-size: 15px; font-weight: 600; color: var(--text); }
751
- .modal-body { padding: 20px 22px; }
752
- .modal-footer {
753
- padding: 14px 22px;
754
- border-top: 1px solid var(--border-soft);
755
- display: flex;
756
- justify-content: flex-end;
757
- gap: 8px;
758
- position: sticky;
759
- bottom: 0;
760
- background: var(--surface);
761
- }
762
- .close-btn {
763
- background: none;
764
- border: none;
765
- color: var(--text-muted);
766
- cursor: pointer;
767
- padding: 4px;
768
- line-height: 1;
769
- border-radius: 4px;
770
- display: flex;
771
- align-items: center;
772
- transition: background .1s, color .1s;
773
- }
774
- .close-btn:hover { background: var(--surface3); color: var(--text); }
775
-
776
- /* ════════════════════════════════════════
777
- EMPTY STATE
778
- ════════════════════════════════════════ */
779
- .empty-state {
780
- text-align: center;
781
- padding: 56px 24px;
782
- color: var(--text-muted);
783
- }
784
- .empty-icon {
785
- width: 52px;
786
- height: 52px;
787
- background: var(--surface3);
788
- border-radius: 14px;
789
- display: flex;
790
- align-items: center;
791
- justify-content: center;
792
- margin: 0 auto 16px;
793
- color: var(--text-xmuted);
794
- }
795
- .empty-title { font-size: 15px; font-weight: 600; color: var(--text-soft); margin-bottom: 7px; }
796
- .empty-desc { font-size: 13px; max-width: 320px; margin: 0 auto 20px; line-height: 1.6; }
797
-
798
- /* ════════════════════════════════════════
799
- CELL TYPES
800
- ════════════════════════════════════════ */
801
- .bool-yes {
802
- display: inline-flex; align-items: center; justify-content: center;
803
- width: 20px; height: 20px; border-radius: 99px;
804
- background: var(--success-bg); color: var(--success);
805
- }
806
- .bool-no {
807
- display: inline-flex; align-items: center; justify-content: center;
808
- width: 20px; height: 20px; border-radius: 99px;
809
- background: var(--danger-bg); color: var(--danger);
810
- }
811
- .cell-muted { color: var(--text-xmuted); font-style: italic; }
812
- .cell-mono { font-family: 'DM Mono', monospace; font-size: 12px; }
813
- .cell-image { width: 34px; height: 34px; border-radius: 7px; object-fit: cover; border: 1px solid var(--border); }
814
-
815
- /* ════════════════════════════════════════
816
- TOAST
817
- ════════════════════════════════════════ */
818
- #toast-root {
819
- position: fixed;
820
- bottom: 22px;
821
- right: 22px;
822
- display: flex;
823
- flex-direction: column;
824
- gap: 8px;
825
- z-index: 500;
826
- pointer-events: none;
827
- }
828
- .toast {
829
- background: var(--text);
830
- color: #fff;
831
- border-radius: var(--radius);
832
- padding: 11px 16px;
833
- font-size: 13px;
834
- max-width: 320px;
835
- box-shadow: var(--shadow-lg);
836
- pointer-events: all;
837
- display: flex;
838
- align-items: center;
839
- gap: 9px;
840
- animation: toastIn .2s ease;
841
- }
842
- .toast-success .toast-dot { color: #4ade80; }
843
- .toast-error .toast-dot { color: #f87171; }
844
- @keyframes toastIn { from { transform: translateX(16px); opacity: 0; } }
845
-
846
- /* ════════════════════════════════════════
847
- UTILITIES
848
- ════════════════════════════════════════ */
849
- .flex { display: flex; }
850
- .flex-col { flex-direction: column; }
851
- .items-center { align-items: center; }
852
- .justify-between { justify-content: space-between; }
853
- .gap-1 { gap: 4px; }
854
- .gap-2 { gap: 8px; }
855
- .gap-3 { gap: 12px; }
856
- .gap-4 { gap: 16px; }
857
- .ml-auto { margin-left: auto; }
858
- .text-muted { color: var(--text-muted); }
859
- .text-sm { font-size: 12px; }
860
- .text-xs { font-size: 11px; }
861
- .fw-500 { font-weight: 500; }
862
- .fw-600 { font-weight: 600; }
863
- .mb-3 { margin-bottom: 12px; }
864
- .mb-4 { margin-bottom: 16px; }
865
- .mb-5 { margin-bottom: 20px; }
866
- .mb-6 { margin-bottom: 24px; }
867
- .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
868
-
869
- /* ════════════════════════════════════════
870
- RESPONSIVE
871
- ════════════════════════════════════════ */
872
- @media (max-width: 768px) {
873
- #sidebar { display: none; }
874
- .form-grid { grid-template-columns: 1fr; }
875
- .search-input { width: 160px; }
876
- }
877
- </style>
11
+ <link rel="stylesheet" href="{{ adminPrefix }}/static/admin.css?v=21">
12
+ <link rel="stylesheet" href="{{ adminPrefix }}/static/json-editor.css?v=4">
13
+ <link rel="stylesheet" href="{{ adminPrefix }}/static/date-picker.css?v=3">
14
+ <script src="https://code.jquery.com/jquery-3.7.1.min.js" crossorigin="anonymous"></script>
878
15
  {% block head %}{% endblock %}
879
16
  </head>
880
17
  <body>
@@ -882,38 +19,7 @@
882
19
  {# ══════════════════════════════════════
883
20
  SVG ICON DEFINITIONS (hidden sprite)
884
21
  ══════════════════════════════════════ #}
885
- <svg xmlns="http://www.w3.org/2000/svg" style="display:none">
886
- <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>
887
- <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>
888
- <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>
889
- <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>
890
- <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>
891
- <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>
892
- <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>
893
- <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>
894
- <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>
895
- <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>
896
- <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>
897
- <symbol id="ic-chevron-left" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></symbol>
898
- <symbol id="ic-chevron-right" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></symbol>
899
- <symbol id="ic-chevron-down" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></symbol>
900
- <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>
901
- <symbol id="ic-check" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></symbol>
902
- <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>
903
- <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>
904
- <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>
905
- <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>
906
- <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>
907
- <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>
908
- <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>
909
- <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>
910
- <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>
911
- <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>
912
- <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>
913
- <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>
914
- <symbol id="ic-activity" viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></symbol>
915
- <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>
916
- </svg>
22
+ {% include "partials/icons.njk" %}
917
23
 
918
24
  {# ══════════════════════════════════════
919
25
  SIDEBAR
@@ -943,15 +49,35 @@
943
49
  <div class="nav-section">
944
50
  <div class="nav-label">Resources</div>
945
51
  {% for resource in resources %}
52
+ {% if resource.category != 'auth' %}
53
+ <a href="{{ adminPrefix }}/{{ resource.slug }}" class="nav-item {% if activeResource == resource.slug %}active{% endif %}">
54
+ <span class="nav-icon icon icon-16">
55
+ <svg viewBox="0 0 24 24"><use href="#ic-{{ resource.icon or 'table' }}"/></svg>
56
+ </span>
57
+ {{ resource.label }}
58
+ </a>
59
+ {% endif %}
60
+ {% endfor %}
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' %}
946
70
  <a href="{{ adminPrefix }}/{{ resource.slug }}" class="nav-item {% if activeResource == resource.slug %}active{% endif %}">
947
71
  <span class="nav-icon icon icon-16">
948
- <svg viewBox="0 0 24 24"><use href="#ic-table"/></svg>
72
+ <svg viewBox="0 0 24 24"><use href="#ic-{{ resource.icon or 'user' }}"/></svg>
949
73
  </span>
950
74
  {{ resource.label }}
951
75
  </a>
76
+ {% endif %}
952
77
  {% endfor %}
953
78
  </div>
954
79
  {% endif %}
80
+ {% endif %}
955
81
 
956
82
  <div class="sidebar-footer">
957
83
  {% if authEnabled and adminUser %}
@@ -1035,186 +161,101 @@
1035
161
 
1036
162
  <div id="toast-root"></div>
1037
163
 
164
+ <script src="{{ adminPrefix }}/static/ui.js?v=13"></script>
1038
165
  <script>
1039
- // ── Toast ────────────────────────────────────────────────────
1040
- function toast(msg, type = 'success') {
1041
- const icons = {
1042
- success: '<polyline points="20 6 9 17 4 12"/>',
1043
- error: '<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"/>',
1044
- };
1045
- const el = document.createElement('div');
1046
- el.className = `toast toast-${type}`;
1047
- el.innerHTML = `
1048
- <span class="toast-dot icon icon-15" style="flex-shrink:0">
1049
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1050
- ${icons[type] || icons.success}
1051
- </svg>
1052
- </span>
1053
- <span>${msg}</span>`;
1054
- document.getElementById('toast-root').appendChild(el);
1055
- setTimeout(() => el.style.opacity = '0', 3000);
1056
- setTimeout(() => el.remove(), 3300);
166
+ // ── Global helpers ─────────────────────────────────────────────────────────
167
+ function toast(msg, type) { UI.Toast.show(msg, type || 'success'); }
168
+ function openModal(id) { $('#' + id).addClass('open'); }
169
+ function closeModal(id) { id ? $('#' + id).removeClass('open') : $('.modal-overlay').removeClass('open'); }
170
+
171
+ // ── Delete confirmation ─────────────────────────────────────────────────────
172
+ function confirmDelete(url, label) {
173
+ UI.Confirm.show({
174
+ title: 'Delete ' + label,
175
+ message: 'Are you sure you want to delete <strong>' + label + '</strong>? This cannot be undone.',
176
+ confirm: 'Delete',
177
+ danger: true,
178
+ }).then(function(ok) { if (ok) submitDelete(url); });
179
+ }
180
+ function submitDelete(url) {
181
+ var csrf = $('meta[name="csrf-token"]').attr('content') || '';
182
+ $('<form method="POST">')
183
+ .attr('action', url)
184
+ .append('<input name="_method" value="DELETE">')
185
+ .append('<input name="_csrf" value="' + csrf + '">')
186
+ .appendTo('body')
187
+ .submit();
1057
188
  }
1058
189
 
1059
- // ── Modal ────────────────────────────────────────────────────
1060
- function openModal(id) { document.getElementById(id)?.classList.add('open'); }
1061
- function closeModal(id) {
1062
- if (id) document.getElementById(id)?.classList.remove('open');
1063
- else document.querySelectorAll('.modal-overlay').forEach(m => m.classList.remove('open'));
190
+ // ── M2M dual-list helpers ───────────────────────────────────────────────────
191
+ function m2mMove(fieldName, fromId, toId) {
192
+ $('#m2m-' + fromId + '-' + fieldName + ' option:selected')
193
+ .appendTo('#m2m-' + toId + '-' + fieldName);
1064
194
  }
1065
- document.addEventListener('click', e => {
1066
- if (e.target.classList.contains('modal-overlay')) closeModal();
1067
- });
1068
- document.addEventListener('keydown', e => {
1069
- if (e.key === 'Escape') closeModal();
1070
- });
1071
195
 
1072
- // ── Action dropdowns ────────────────────────────────────────
1073
- document.addEventListener('click', e => {
1074
- const btn = e.target.closest('.action-menu-btn');
1075
- if (btn) {
1076
- e.stopPropagation();
1077
- const menu = btn.nextElementSibling;
1078
- const isOpen = menu.classList.contains('open');
1079
- document.querySelectorAll('.action-dropdown.open').forEach(d => d.classList.remove('open'));
1080
- if (!isOpen) menu.classList.add('open');
1081
- return;
1082
- }
1083
- if (!e.target.closest('.action-dropdown')) {
1084
- document.querySelectorAll('.action-dropdown.open').forEach(d => d.classList.remove('open'));
196
+ $(function() {
197
+ // ── M2M: select all chosen before submit ──────────────────────────────────
198
+ $('form').on('submit', function() {
199
+ $('[id^="m2m-chosen-"] option').prop('selected', true);
200
+ });
201
+
202
+ // ── Flash auto-dismiss ────────────────────────────────────────────────────
203
+ var $flash = $('#flash-alert');
204
+ if ($flash.length) {
205
+ setTimeout(function() {
206
+ $flash.fadeTo(400, 0, function() { $flash.remove(); });
207
+ }, 5000);
1085
208
  }
1086
- });
1087
209
 
1088
- // ── Delete confirmation ──────────────────────────────────────
1089
- function confirmDelete(url, label) {
1090
- const overlay = document.createElement('div');
1091
- overlay.className = 'modal-overlay open';
1092
- overlay.innerHTML = `
1093
- <div class="modal modal-sm">
1094
- <div class="modal-header">
1095
- <span class="modal-title flex items-center gap-2">
1096
- <span class="icon icon-16" style="color:var(--danger)">
1097
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1098
- <polyline points="3 6 5 6 21 6"/>
1099
- <path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6"/>
1100
- <path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/>
1101
- </svg>
1102
- </span>
1103
- Delete ${label}
1104
- </span>
1105
- <button class="close-btn" onclick="this.closest('.modal-overlay').remove()">
1106
- <span class="icon icon-16"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
1107
- </button>
1108
- </div>
1109
- <div class="modal-body">
1110
- <p style="color:var(--text-soft);line-height:1.6">
1111
- Are you sure you want to delete <strong style="color:var(--text)">${label}</strong>?
1112
- This action cannot be undone.
1113
- </p>
1114
- </div>
1115
- <div class="modal-footer">
1116
- <button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
1117
- <button class="btn btn-danger" onclick="submitDelete('${url}')">
1118
- <span class="icon icon-14"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/></svg></span>
1119
- Delete
1120
- </button>
1121
- </div>
1122
- </div>`;
1123
- overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
1124
- document.body.appendChild(overlay);
1125
- }
1126
- function submitDelete(url) {
1127
- const form = document.createElement('form');
1128
- form.method = 'POST';
1129
- form.action = url;
1130
- form.innerHTML = '<input name="_method" value="DELETE">';
1131
- document.body.appendChild(form);
1132
- form.submit();
1133
- }
210
+ // ── Search placeholder hint ───────────────────────────────────────────────
211
+ $('#global-search-input')
212
+ .on('focus', function() { $(this).attr('placeholder', 'Search everything…'); })
213
+ .on('blur', function() { $(this).attr('placeholder', 'Search everything… (/)'); });
1134
214
 
1135
- // ── Keyboard shortcuts ───────────────────────────────────────
1136
- (function() {
1137
- const prefix = '{{ adminPrefix }}';
1138
- const resources = [
215
+ // ── Keyboard shortcuts ────────────────────────────────────────────────────
216
+ var prefix = '{{ adminPrefix }}';
217
+ var resources = [
1139
218
  {% for r in resources %}
1140
- { slug: '{{ r.slug }}', index: {{ r.index | default('null')}} }{% if not loop.last %},{% endif %}
219
+ { slug: '{{ r.slug }}', index: {{ r.index | default('null') }} }{% if not loop.last %},{% endif %}
1141
220
  {% endfor %}
1142
221
  ];
222
+ var gPressed = false, gTimer;
1143
223
 
1144
- let gPressed = false;
1145
- let gTimer = null;
1146
-
1147
- document.addEventListener('keydown', function(e) {
1148
- // Don't fire when typing in inputs
1149
- const tag = document.activeElement?.tagName;
224
+ $(document).on('keydown', function(e) {
225
+ var tag = document.activeElement && document.activeElement.tagName;
1150
226
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
1151
- // Allow Escape to blur
1152
- if (e.key === 'Escape') document.activeElement.blur();
227
+ if (e.key === 'Escape') $(document.activeElement).blur();
1153
228
  return;
1154
229
  }
1155
-
1156
- // / → focus global search
1157
230
  if (e.key === '/') {
1158
231
  e.preventDefault();
1159
- document.getElementById('global-search-input')?.focus();
232
+ $('#global-search-input').focus();
1160
233
  return;
1161
234
  }
1162
-
1163
- // N → new record (only on list pages)
1164
235
  if (e.key === 'n' || e.key === 'N') {
1165
- const newBtn = document.querySelector('a[href*="/create"].btn-primary');
1166
- if (newBtn) { e.preventDefault(); window.location.href = newBtn.href; }
1167
- return;
1168
- }
1169
-
1170
- // Escape → close modals (already handled), clear selection
1171
- if (e.key === 'Escape') {
1172
- document.querySelectorAll('.modal-overlay').forEach(m => m.classList.remove('open'));
236
+ var $btn = $('a[href*="/create"].btn-primary').first();
237
+ if ($btn.length) { e.preventDefault(); location.href = $btn.attr('href'); }
1173
238
  return;
1174
239
  }
1175
-
1176
- // G → start chord
240
+ if (e.key === 'Escape') { $('.modal-overlay').removeClass('open'); return; }
1177
241
  if (e.key === 'g' || e.key === 'G') {
1178
242
  if (gPressed) return;
1179
243
  gPressed = true;
1180
244
  clearTimeout(gTimer);
1181
- gTimer = setTimeout(() => { gPressed = false; }, 800);
245
+ gTimer = setTimeout(function() { gPressed = false; }, 800);
1182
246
  return;
1183
247
  }
1184
-
1185
- // G+D → dashboard
1186
248
  if (gPressed && (e.key === 'd' || e.key === 'D')) {
1187
- gPressed = false;
1188
- window.location.href = prefix + '/';
1189
- return;
249
+ gPressed = false; location.href = prefix + '/'; return;
1190
250
  }
1191
-
1192
- // G+1…9 → jump to resource
1193
251
  if (gPressed && e.key >= '1' && e.key <= '9') {
1194
252
  gPressed = false;
1195
- const idx = parseInt(e.key);
1196
- const r = resources.find(x => x.index === idx);
1197
- if (r) window.location.href = `${prefix}/${r.slug}`;
1198
- return;
253
+ var idx = parseInt(e.key);
254
+ var r = $.grep(resources, function(x) { return x.index === idx; })[0];
255
+ if (r) location.href = prefix + '/' + r.slug;
1199
256
  }
1200
257
  });
1201
- })();
1202
-
1203
- // ── Shortcut hint tooltip ────────────────────────────────────
1204
- document.getElementById('global-search-input')?.addEventListener('focus', function() {
1205
- this.placeholder = 'Search everything…';
1206
258
  });
1207
- document.getElementById('global-search-input')?.addEventListener('blur', function() {
1208
- this.placeholder = 'Search everything… (/)';
1209
- });
1210
- const flashAlert = document.getElementById('flash-alert');
1211
- if (flashAlert) {
1212
- setTimeout(() => {
1213
- flashAlert.style.transition = 'opacity .4s';
1214
- flashAlert.style.opacity = '0';
1215
- setTimeout(() => flashAlert.remove(), 400);
1216
- }, 5000);
1217
- }
1218
259
  </script>
1219
260
  {% block scripts %}{% endblock %}
1220
261
  </body>