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
@@ -74,19 +74,19 @@
74
74
  </button>
75
75
  {% endif %}
76
76
 
77
- {# Export dropdown #}
78
- <div class="action-menu">
79
- <button class="btn btn-ghost btn-sm action-menu-btn">
77
+ {# Export dropdown — portal-rendered via UI.Dropdown #}
78
+ <div class="ui-menu" id="export-menu-wrap">
79
+ <button class="btn btn-ghost btn-sm" id="export-menu-btn" type="button">
80
80
  <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-download"/></svg></span>
81
81
  Export
82
82
  <span class="icon icon-12"><svg viewBox="0 0 24 24"><use href="#ic-chevron-down"/></svg></span>
83
83
  </button>
84
- <div class="action-dropdown" style="right:0;min-width:160px">
85
- <a href="{{ adminPrefix }}/{{ resource.slug }}/export.csv?search={{ search }}&sort={{ sort }}&order={{ order }}{% for key, val in activeFilters %}&filter[{{ key }}]={{ val }}{% endfor %}">
84
+ <div class="ui-menu-panel" id="export-menu-panel" style="display:none">
85
+ <a class="ui-menu-item" href="{{ adminPrefix }}/{{ resource.slug }}/export.csv?search={{ search }}&sort={{ sort }}&order={{ order }}{% for key, val in activeFilters %}&filter[{{ key }}]={{ val }}{% endfor %}">
86
86
  <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-download"/></svg></span>
87
87
  Export CSV
88
88
  </a>
89
- <a href="{{ adminPrefix }}/{{ resource.slug }}/export.json?search={{ search }}&sort={{ sort }}&order={{ order }}{% for key, val in activeFilters %}&filter[{{ key }}]={{ val }}{% endfor %}">
89
+ <a class="ui-menu-item" href="{{ adminPrefix }}/{{ resource.slug }}/export.json?search={{ search }}&sort={{ sort }}&order={{ order }}{% for key, val in activeFilters %}&filter[{{ key }}]={{ val }}{% endfor %}">
90
90
  <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-download"/></svg></span>
91
91
  Export JSON
92
92
  </a>
@@ -99,10 +99,18 @@
99
99
  <div class="bulk-bar" id="bulk-bar">
100
100
  <span class="bulk-count" id="bulk-count">0 selected</span>
101
101
  <span class="text-muted" style="font-size:12px">—</span>
102
+ {% for action in resource.actions %}
103
+ <button class="btn btn-ghost btn-sm" onclick="bulkAction({{ action.index }})">
104
+ {% if action.icon %}<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-{{ action.icon }}"/></svg></span>{% endif %}
105
+ {{ action.label }}
106
+ </button>
107
+ {% endfor %}
108
+ {% if resource.canDelete %}
102
109
  <button class="btn btn-ghost btn-sm btn-danger" onclick="bulkDelete()">
103
110
  <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
104
111
  Delete selected
105
112
  </button>
113
+ {% endif %}
106
114
  <button class="btn btn-ghost btn-sm" style="margin-left:auto" onclick="clearSelection()">
107
115
  <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-x"/></svg></span>
108
116
  Cancel
@@ -186,7 +194,7 @@
186
194
  {% endif %}
187
195
  </th>
188
196
  {% endfor %}
189
- {% if resource.canEdit or resource.canDelete %}
197
+ {% if resource.canEdit or resource.canDelete or resource.rowActions | length %}
190
198
  <th class="col-actions">Actions</th>
191
199
  {% endif %}
192
200
  </tr>
@@ -200,10 +208,10 @@
200
208
  {% for field in listFields %}
201
209
  <td class="{% if loop.first %}td-primary{% endif %}">{{ row[field.name] | adminCell(field) | safe }}</td>
202
210
  {% endfor %}
203
- {% if resource.canEdit or resource.canDelete %}
211
+ {% if resource.canEdit or resource.canDelete or resource.rowActions | length %}
204
212
  <td class="col-actions">
205
- <div class="action-menu">
206
- <button class="action-menu-btn">
213
+ <div class="ui-menu">
214
+ <button class="action-menu-btn ui-menu-trigger" type="button" aria-label="Row actions">
207
215
  <span class="icon icon-14">
208
216
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
209
217
  <circle cx="12" cy="5" r="1.2" fill="currentColor" stroke="none"/>
@@ -212,20 +220,36 @@
212
220
  </svg>
213
221
  </span>
214
222
  </button>
215
- <div class="action-dropdown">
223
+ <div class="ui-menu-panel" style="display:none">
216
224
  {% if resource.canEdit %}
217
- <a href="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/edit">
225
+ <a class="ui-menu-item" href="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/edit">
218
226
  <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-edit"/></svg></span>
219
227
  Edit
220
228
  </a>
221
229
  {% endif %}
222
- <a href="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}" style="display:none">
223
- <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-eye"/></svg></span>
224
- View
230
+ {% if row._rowActions | length %}
231
+ {% if resource.canEdit %}<div class="ui-menu-sep"></div>{% endif %}
232
+ {% for ra in row._rowActions %}
233
+ {% if ra.href %}
234
+ <a class="ui-menu-item" href="{{ ra.href }}" target="_blank" rel="noopener">
235
+ <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-{{ ra.icon or 'eye' }}"/></svg></span>
236
+ {{ ra.label }}
225
237
  </a>
238
+ {% elif ra.action %}
239
+ <button class="ui-menu-item" type="button"
240
+ data-row-action="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/action/{{ ra.action }}"
241
+ data-row-action-label="{{ ra.label }}">
242
+ <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-{{ ra.icon or 'check' }}"/></svg></span>
243
+ {{ ra.label }}
244
+ </button>
245
+ {% endif %}
246
+ {% endfor %}
247
+ {% endif %}
226
248
  {% if resource.canDelete %}
227
- <div class="sep"></div>
228
- <button class="danger" onclick="confirmDelete('{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/delete', '#{{ row.id }}')">
249
+ <div class="ui-menu-sep"></div>
250
+ <button class="ui-menu-item ui-menu-danger" type="button"
251
+ data-confirm-delete="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/delete"
252
+ data-confirm-label="#{{ row.id }}">
229
253
  <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
230
254
  Delete
231
255
  </button>
@@ -300,60 +324,244 @@
300
324
  </div>
301
325
 
302
326
  <script>
303
- // ── Filter panel toggle ──────────────────────────────────────
304
- const filterPanel = document.getElementById('filter-panel');
305
- {% if activeFilters | length %}
306
- if (filterPanel) filterPanel.style.display = 'flex';
307
- {% endif %}
327
+ $(function() {
328
+ var PREFIX = '{{ adminPrefix }}';
329
+ var SLUG = '{{ resource.slug }}';
308
330
 
309
- function toggleFilters() {
310
- if (!filterPanel) return;
311
- const shown = filterPanel.style.display !== 'none';
312
- filterPanel.style.display = shown ? 'none' : 'flex';
313
- }
331
+ // ── Filter panel ─────────────────────────────────────────────────────────
332
+ {% if activeFilters | length %}$('#filter-panel').show();{% endif %}
314
333
 
315
- // ── Live search on Enter ──────────────────────────────────────
316
- const searchForm = document.getElementById('search-form');
317
- if (searchForm) {
318
- searchForm.querySelector('input[name="search"]')?.addEventListener('keydown', e => {
319
- if (e.key === 'Enter') searchForm.submit();
334
+ window.toggleFilters = function() {
335
+ $('#filter-panel').toggle();
336
+ };
337
+
338
+ // ── Live search on Enter ──────────────────────────────────────────────────
339
+ $('#search-form input[name="search"]').on('keydown', function(e) {
340
+ if (e.key === 'Enter') $('#search-form').submit();
320
341
  });
321
- }
322
342
 
323
- // ── Bulk selection ────────────────────────────────────────────
324
- const bulkBar = document.getElementById('bulk-bar');
325
- const bulkCount = document.getElementById('bulk-count');
326
-
327
- function updateBulkBar() {
328
- const checked = document.querySelectorAll('.item-check:checked');
329
- const n = checked.length;
330
- bulkBar.classList.toggle('visible', n > 0);
331
- if (bulkCount) bulkCount.textContent = `${n} selected`;
332
- const all = document.getElementById('check-all');
333
- if (all) {
334
- const total = document.querySelectorAll('.item-check').length;
335
- all.indeterminate = n > 0 && n < total;
336
- all.checked = n > 0 && n === total;
343
+ // ── Bulk selection ────────────────────────────────────────────────────────
344
+ function updateBulkBar() {
345
+ var $checked = $('.item-check:checked');
346
+ var n = $checked.length;
347
+ var total = $('.item-check').length;
348
+ $('#bulk-bar').toggleClass('visible', n > 0);
349
+ $('#bulk-count').text(n + ' selected');
350
+ $('#check-all').prop('indeterminate', n > 0 && n < total)
351
+ .prop('checked', n > 0 && n === total);
352
+ }
353
+
354
+ window.toggleAll = function(master) {
355
+ $('.item-check').prop('checked', master.checked);
356
+ updateBulkBar();
357
+ };
358
+
359
+ window.clearSelection = function() {
360
+ $('.item-check, #check-all').prop('checked', false).prop('indeterminate', false);
361
+ updateBulkBar();
362
+ };
363
+
364
+ $(document).on('change', '.item-check', updateBulkBar);
365
+
366
+ window.bulkDelete = function() {
367
+ var ids = $('.item-check:checked').map(function() { return this.value; }).get();
368
+ if (!ids.length) return;
369
+ var label = ids.length + ' record' + (ids.length > 1 ? 's' : '');
370
+ confirmDeleteBulk(PREFIX + '/' + SLUG + '/bulk-delete', label, ids);
371
+ };
372
+
373
+ window.confirmDeleteBulk = function(url, label, ids) {
374
+ UI.Confirm.show({
375
+ title: 'Delete ' + label,
376
+ message: 'Delete <strong>' + label + '</strong>? This cannot be undone.',
377
+ confirm: 'Delete',
378
+ danger: true,
379
+ }).then(function(ok) {
380
+ if (!ok) return;
381
+ var csrf = $('meta[name="csrf-token"]').attr('content') || '';
382
+ var $form = $('<form method="POST">').attr('action', url);
383
+ $form.append('<input name="_csrf" value="' + csrf + '">');
384
+ $.each(ids, function(_, id) {
385
+ $form.append('<input type="hidden" name="ids[]" value="' + id + '">');
386
+ });
387
+ $form.appendTo('body').submit();
388
+ });
389
+ };
390
+
391
+ window.bulkAction = function(actionIndex) {
392
+ var ids = $('.item-check:checked').map(function() { return this.value; }).get();
393
+ if (!ids.length) return;
394
+ var csrf = $('meta[name="csrf-token"]').attr('content') || '';
395
+ var $form = $('<form method="POST">').attr('action', PREFIX + '/' + SLUG + '/bulk-action');
396
+ $form.append('<input name="_csrf" value="' + csrf + '">');
397
+ $form.append('<input type="hidden" name="actionIndex" value="' + actionIndex + '">');
398
+ $.each(ids, function(_, id) {
399
+ $form.append('<input type="hidden" name="ids[]" value="' + id + '">');
400
+ });
401
+ $form.appendTo('body').submit();
402
+ };
403
+
404
+ // ── UI.Dropdown — portal-rendered action menus ────────────────────────────
405
+ // Wire export menu
406
+ var $exportBtn = $('#export-menu-btn');
407
+ var $exportPanel = $('#export-menu-panel');
408
+ if ($exportBtn.length && $exportPanel.length) {
409
+ var exportDd = UI.Dropdown.create({
410
+ anchor: $exportBtn[0],
411
+ content: $exportPanel[0],
412
+ placement: 'bottom-end',
413
+ offset: 4,
414
+ });
415
+ $exportBtn.on('click', function() { exportDd.toggle(); });
337
416
  }
338
- }
339
417
 
340
- function toggleAll(master) {
341
- document.querySelectorAll('.item-check').forEach(c => { c.checked = master.checked; });
342
- updateBulkBar();
418
+ // Wire per-row action menus
419
+ $('.ui-menu').each(function() {
420
+ var $menu = $(this);
421
+ var $btn = $menu.find('.ui-menu-trigger');
422
+ var $panel = $menu.find('.ui-menu-panel');
423
+ if (!$btn.length || !$panel.length) return;
424
+
425
+ var dd = UI.Dropdown.create({
426
+ anchor: $btn[0],
427
+ content: $panel[0],
428
+ placement: 'bottom-end',
429
+ offset: 4,
430
+ });
431
+
432
+ $btn.on('click', function(e) {
433
+ e.stopPropagation();
434
+ dd.toggle();
435
+ });
436
+
437
+ $panel.find('[data-confirm-delete]').on('click', function() {
438
+ dd.close();
439
+ confirmDelete($(this).data('confirm-delete'), $(this).data('confirm-label'));
440
+ });
441
+
442
+ $panel.find('[data-row-action]').on('click', function() {
443
+ dd.close();
444
+ var url = $(this).data('row-action');
445
+ var label = $(this).data('row-action-label');
446
+ UI.Confirm.show({
447
+ title: label,
448
+ message: 'Run <strong>' + label + '</strong> on this record?',
449
+ confirm: label,
450
+ }).then(function(ok) {
451
+ if (!ok) return;
452
+ var csrf = $('meta[name="csrf-token"]').attr('content') || '';
453
+ var $form = $('<form method="POST">').attr('action', url);
454
+ $form.append('<input name="_csrf" value="' + csrf + '">');
455
+ $form.appendTo('body').submit();
456
+ });
457
+ });
458
+
459
+ // ── Right-click on the row opens a context menu at the cursor ──────────
460
+ (function($menuPanel) {
461
+ var $row = $menuPanel.closest('tr');
462
+
463
+ $row.on('contextmenu', function(e) {
464
+ e.preventDefault();
465
+ e.stopPropagation();
466
+
467
+ // Remove any existing portal
468
+ $('.context-menu-portal').remove();
469
+
470
+ // Build a fresh portal from the panel's inner HTML (avoids clone display:none)
471
+ var $p = $('<div class="ui-menu-panel context-menu-portal"></div>');
472
+ $p.html($menuPanel.html());
473
+ $p.css({
474
+ position: 'fixed',
475
+ top: Math.min(e.clientY, window.innerHeight - 240) + 'px',
476
+ left: Math.min(e.clientX, window.innerWidth - 190) + 'px',
477
+ zIndex: 9999,
478
+ });
479
+ $('body').append($p);
480
+
481
+ // Wire handlers on the portal
482
+ $p.find('[data-confirm-delete]').on('click', function() {
483
+ $p.remove();
484
+ confirmDelete($(this).data('confirm-delete'), $(this).data('confirm-label'));
485
+ });
486
+ $p.find('[data-row-action]').on('click', function() {
487
+ $p.remove();
488
+ var _url = $(this).data('row-action');
489
+ var _label = $(this).data('row-action-label');
490
+ UI.Confirm.show({
491
+ title: _label,
492
+ message: 'Run <strong>' + _label + '</strong> on this record?',
493
+ confirm: _label,
494
+ }).then(function(ok) {
495
+ if (!ok) return;
496
+ var csrf = $('meta[name="csrf-token"]').attr('content') || '';
497
+ var $form = $('<form method="POST">').attr('action', _url);
498
+ $form.append('<input name="_csrf" value="' + csrf + '">');
499
+ $form.appendTo('body').submit();
500
+ });
501
+ });
502
+ $p.find('a.ui-menu-item').on('click', function() { $p.remove(); });
503
+
504
+ // Dismiss on next click anywhere or Escape
505
+ setTimeout(function() {
506
+ $(document).one('click.ctxmenu', function() { $p.remove(); });
507
+ }, 0);
508
+ $(document).one('keydown.ctxmenu', function(ev) {
509
+ if (ev.key === 'Escape') { $p.remove(); }
510
+ });
511
+ });
512
+ })($panel);
513
+ });
514
+ });
515
+ </script>
516
+
517
+ <style>
518
+ /* ── UI Menu (portal dropdown) ── */
519
+ .ui-menu { position: relative; display: inline-block; }
520
+
521
+ .ui-menu-panel {
522
+ background: var(--surface);
523
+ border: 1px solid var(--border);
524
+ border-radius: var(--radius);
525
+ min-width: 150px;
526
+ overflow: hidden;
343
527
  }
344
528
 
345
- function clearSelection() {
346
- document.querySelectorAll('.item-check, #check-all').forEach(c => { c.checked = false; c.indeterminate = false; });
347
- updateBulkBar();
529
+ .ui-menu-item {
530
+ display: flex;
531
+ align-items: center;
532
+ gap: 8px;
533
+ padding: 8px 14px;
534
+ font-size: 13px;
535
+ color: var(--text-soft);
536
+ text-decoration: none;
537
+ background: none;
538
+ border: none;
539
+ width: 100%;
540
+ text-align: left;
541
+ cursor: pointer;
542
+ font-family: inherit;
543
+ transition: background .1s;
544
+ white-space: nowrap;
348
545
  }
546
+ .ui-menu-item:hover { background: var(--surface2); color: var(--text); }
547
+ .ui-menu-danger { color: var(--danger); }
548
+ .ui-menu-danger:hover { background: var(--danger-bg); color: var(--danger); }
349
549
 
350
- function bulkDelete() {
351
- const ids = [...document.querySelectorAll('.item-check:checked')].map(c => c.value);
352
- if (!ids.length) return;
353
- confirmDelete(
354
- '{{ adminPrefix }}/{{ resource.slug }}/bulk-delete',
355
- `${ids.length} record${ids.length > 1 ? 's' : ''}`
356
- );
550
+ .ui-menu-sep { height: 1px; background: var(--border-soft); margin: 3px 0; }
551
+
552
+ /* ── Context menu portal ── */
553
+ .context-menu-portal {
554
+ background: var(--surface);
555
+ border: 1px solid var(--border);
556
+ border-radius: var(--radius);
557
+ min-width: 160px;
558
+ overflow: hidden;
559
+ box-shadow: 0 4px 16px rgba(0,0,0,.12), 0 1px 4px rgba(0,0,0,.08);
357
560
  }
358
- </script>
561
+
562
+ /* ── Right-click cursor hint on rows ── */
563
+ #data-table tbody tr { cursor: context-menu; }
564
+ #data-table tbody tr:hover { background: var(--surface2); }
565
+ </style>
566
+
359
567
  {% endblock %}
@@ -0,0 +1,53 @@
1
+ {#
2
+ partials/form-field.njk
3
+ Renders a single editable form field — all widget types.
4
+ Expects in scope: field, val, hasError, isEdit, record, errors, adminPrefix
5
+ #}
6
+
7
+ {# ── Fieldset heading ── #}
8
+ {% if field._isFieldset %}
9
+ <div class="full" style="grid-column:1/-1;margin-top:8px">
10
+ <div class="fieldset-heading">{{ field.label }}</div>
11
+ </div>
12
+
13
+ {# ── Readonly ── #}
14
+ {% elif field.isReadonly %}
15
+ {% include "partials/form-readonly.njk" %}
16
+
17
+ {# ── Editable ── #}
18
+ {% else %}
19
+ {% set val = record[field.name] if record[field.name] is defined else '' %}
20
+ {% set hasError = errors[field.name] is defined %}
21
+ <div class="form-group
22
+ {% if field.span == 'full' %}full
23
+ {% elif field.span == 'third' %}w-third
24
+ {% elif field.type == 'textarea' or field.type == 'json' or field.type == 'richtext' %}full
25
+ {% elif field.type == 'checkbox' %}w-third
26
+ {% endif %}"
27
+ data-field="{{ field.name }}">
28
+
29
+ {# Label (not shown for checkbox — toggle has its own) #}
30
+ {% if field.type != 'checkbox' %}
31
+ <label class="form-label" for="field-{{ field.name }}">
32
+ {{ field.label }}
33
+ {% if not field.nullable %}<span class="required">*</span>{% endif %}
34
+ </label>
35
+ {% endif %}
36
+
37
+ {% include "partials/form-widget.njk" %}
38
+
39
+ {# Error / help #}
40
+ <div class="field-feedback" id="feedback-{{ field.name }}"
41
+ {% if field.help %}data-help="{{ field.help }}"{% endif %}>
42
+ {% if hasError %}
43
+ <span class="form-error">
44
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
45
+ {{ errors[field.name][0] if errors[field.name] is iterable else errors[field.name] }}
46
+ </span>
47
+ {% elif field.help %}
48
+ <span class="form-help">{{ field.help }}</span>
49
+ {% endif %}
50
+ </div>
51
+
52
+ </div>
53
+ {% endif %}
@@ -0,0 +1,28 @@
1
+ {#
2
+ partials/form-footer.njk
3
+ Submit buttons, cancel, delete, and timestamps.
4
+ Expects in scope: isEdit, resource, record, adminPrefix
5
+ #}
6
+ <div class="flex items-center gap-2" style="margin-top:16px;flex-wrap:wrap">
7
+ <button type="submit" name="_submit" value="save" class="btn btn-primary" id="submit-btn">
8
+ <span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-save"/></svg></span>
9
+ {{ 'Save Changes' if isEdit else 'Create ' + resource.singular }}
10
+ </button>
11
+ <button type="submit" name="_submit" value="continue" class="btn btn-ghost btn-sm">
12
+ Save and continue editing
13
+ </button>
14
+ {% if not isEdit %}
15
+ <button type="submit" name="_submit" value="add_another" class="btn btn-ghost btn-sm">
16
+ Save and add another
17
+ </button>
18
+ {% endif %}
19
+ <a href="{{ adminPrefix }}/{{ resource.slug }}" class="btn btn-ghost">Cancel</a>
20
+
21
+ {% if isEdit and resource.canDelete %}
22
+ <button type="button" class="btn btn-danger" style="margin-left:auto"
23
+ onclick="confirmDelete('{{ adminPrefix }}/{{ resource.slug }}/{{ record.id }}/delete', '{{ resource.singular }} #{{ record.id }}')">
24
+ <span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
25
+ Delete
26
+ </button>
27
+ {% endif %}
28
+ </div>
@@ -0,0 +1,114 @@
1
+ {#
2
+ partials/form-readonly.njk
3
+ Renders a single readonly field as disabled input.
4
+ Expects in scope: field, record
5
+ #}
6
+
7
+ <div class="form-group {% if field.span == 'full' or field.type == 'textarea' or field.type == 'json' or field.type == 'richtext' %}full{% elif field.span == 'third' %}w-third{% endif %}">
8
+ <label class="form-label" for="field-{{ field.name }}">{{ field.label }}</label>
9
+
10
+ {# ── Select ── #}
11
+ {% if field.type == 'select' and field.options %}
12
+ <select class="form-control" disabled>
13
+ {% if val %}
14
+ {% for opt in field.options %}
15
+ {% set optVal = opt.value if opt.value is defined else opt %}
16
+ {% set optLabel = opt.label if opt.label is defined else opt %}
17
+ <option {% if val == optVal %}selected{% endif %}>{{ optLabel }}</option>
18
+ {% endfor %}
19
+ {% else %}
20
+ <option>— Select —</option>
21
+ {% endif %}
22
+ </select>
23
+
24
+ {# ── Checkbox ── #}
25
+ {% elif field.type == 'checkbox' %}
26
+ <div class="toggle-field">
27
+ <label class="toggle-wrap" style="opacity:0.6;cursor:not-allowed">
28
+ <input type="checkbox" disabled {{ 'checked' if val else '' }} class="toggle-input">
29
+ <span class="toggle-track"><span class="toggle-thumb"></span></span>
30
+ <span class="toggle-label">{{ field.label }}</span>
31
+ </label>
32
+ </div>
33
+
34
+ {# ── Badge ── #}
35
+ {% elif field.type == 'badge' %}
36
+ {% set _bc = field.colors[val] if (field.colors and val and field.colors[val]) else 'gray' %}
37
+ <span class="badge badge-{{ _bc if val else 'gray' }}">{{ val if val else 'Not set' }}</span>
38
+
39
+ {# ── Textarea ── #}
40
+ {% elif field.type == 'textarea' %}
41
+ <textarea class="form-control" disabled readonly rows="4" placeholder="{{ field.placeholder or '' }}">{{ val }}</textarea>
42
+
43
+ {# ── JSON ── #}
44
+ {% elif field.type == 'json' %}
45
+ <textarea class="form-control" disabled readonly rows="6" style="font-family:monospace;font-size:12px">{{ val | dump if val else '' }}</textarea>
46
+
47
+ {# ── Richtext ── #}
48
+ {% elif field.type == 'richtext' %}
49
+ <div class="form-control" style="min-height:120px;background:var(--surface-muted);cursor:not-allowed" disabled>{{ val | safe if val else '' }}</div>
50
+
51
+ {# ── Date ── #}
52
+ {% elif field.type == 'date' %}
53
+ <div class="dp-wrap">
54
+ <input type="text" class="form-control dp-trigger" value="{{ val }}" placeholder="Select date…" disabled readonly>
55
+ <span class="dp-icon" style="opacity:0.5">
56
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
57
+ <rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
58
+ </svg>
59
+ </span>
60
+ </div>
61
+
62
+ {# ── Datetime ── #}
63
+ {% elif field.type == 'datetime' or field.type == 'datetime-local' %}
64
+ <div class="dp-wrap">
65
+ <input type="text" class="form-control dp-trigger" value="{{ val }}" placeholder="Select date and time…" disabled readonly>
66
+ <span class="dp-icon" style="opacity:0.5">
67
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
68
+ <rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
69
+ </svg>
70
+ </span>
71
+ </div>
72
+
73
+ {# ── Email ── #}
74
+ {% elif field.type == 'email' %}
75
+ <input type="email" class="form-control" value="{{ val }}" placeholder="{{ field.placeholder or 'name@example.com' }}" disabled readonly>
76
+
77
+ {# ── URL ── #}
78
+ {% elif field.type == 'url' %}
79
+ <input type="url" class="form-control" value="{{ val }}" placeholder="{{ field.placeholder or 'https://' }}" disabled readonly>
80
+
81
+ {# ── Phone ── #}
82
+ {% elif field.type == 'phone' %}
83
+ <input type="tel" class="form-control" value="{{ val }}" placeholder="{{ field.placeholder or '+1 555 000 0000' }}" disabled readonly>
84
+
85
+ {# ── Color ── #}
86
+ {% elif field.type == 'color' %}
87
+ <div class="flex items-center gap-2">
88
+ <input type="color" value="{{ val or '#000000' }}" disabled style="width:40px;height:36px;border:1px solid var(--border);border-radius:var(--radius-sm);cursor:not-allowed;padding:2px;opacity:0.6">
89
+ <input type="text" class="form-control" value="{{ val }}" placeholder="#000000" style="width:120px" disabled readonly>
90
+ </div>
91
+
92
+ {# ── Number ── #}
93
+ {% elif field.type == 'number' %}
94
+ <input type="number" class="form-control" value="{{ val }}" placeholder="{{ field.placeholder or '0' }}" disabled readonly>
95
+
96
+ {# ── Password ── #}
97
+ {% elif field.type == 'password' %}
98
+ <input type="password" class="form-control" value="{{ '••••••••' if val else '' }}" placeholder="{{ field.placeholder or '' }}" disabled readonly>
99
+
100
+ {# ── Image ── #}
101
+ {% elif field.type == 'image' %}
102
+ <div>
103
+ {% if val %}
104
+ <img src="{{ val }}" style="width:64px;height:64px;object-fit:cover;border-radius:var(--radius-sm);border:1px solid var(--border);display:block;margin-bottom:8px" alt="">
105
+ {% endif %}
106
+ <input type="text" class="form-control" value="{{ val }}" placeholder="https://example.com/image.jpg" disabled readonly>
107
+ </div>
108
+
109
+ {# ── Default text ── #}
110
+ {% else %}
111
+ <input type="text" class="form-control" value="{{ val }}" placeholder="{{ field.placeholder or '' }}" disabled readonly>
112
+
113
+ {% endif %}
114
+ </div>