millas 0.2.12-beta → 0.2.12-beta-2

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 (116) hide show
  1. package/package.json +3 -16
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +400 -167
  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 +309 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +383 -97
  12. package/src/admin/static/admin.css +1341 -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 +65 -1013
  19. package/src/admin/views/pages/detail.njk +40 -16
  20. package/src/admin/views/pages/form.njk +47 -599
  21. package/src/admin/views/pages/list.njk +145 -62
  22. package/src/admin/views/partials/form-field.njk +53 -0
  23. package/src/admin/views/partials/form-footer.njk +28 -0
  24. package/src/admin/views/partials/form-readonly.njk +114 -0
  25. package/src/admin/views/partials/form-scripts.njk +476 -0
  26. package/src/admin/views/partials/form-widget.njk +296 -0
  27. package/src/admin/views/partials/json-dialog.njk +80 -0
  28. package/src/admin/views/partials/json-editor.njk +37 -0
  29. package/src/admin.zip +0 -0
  30. package/src/auth/Auth.js +31 -10
  31. package/src/auth/AuthController.js +3 -1
  32. package/src/auth/AuthUser.js +119 -0
  33. package/src/cli.js +4 -2
  34. package/src/commands/createsuperuser.js +254 -0
  35. package/src/commands/lang.js +589 -0
  36. package/src/commands/migrate.js +154 -81
  37. package/src/commands/serve.js +82 -110
  38. package/src/container/AppInitializer.js +215 -0
  39. package/src/container/Application.js +278 -253
  40. package/src/container/HttpServer.js +156 -0
  41. package/src/container/MillasApp.js +29 -279
  42. package/src/container/MillasConfig.js +192 -0
  43. package/src/core/admin.js +5 -0
  44. package/src/core/auth.js +9 -0
  45. package/src/core/db.js +9 -0
  46. package/src/core/foundation.js +59 -0
  47. package/src/core/http.js +11 -0
  48. package/src/core/lang.js +1 -0
  49. package/src/core/mail.js +6 -0
  50. package/src/core/queue.js +7 -0
  51. package/src/core/validation.js +29 -0
  52. package/src/facades/Admin.js +1 -1
  53. package/src/facades/Auth.js +22 -39
  54. package/src/facades/Cache.js +21 -10
  55. package/src/facades/Database.js +1 -1
  56. package/src/facades/Events.js +18 -17
  57. package/src/facades/Facade.js +197 -0
  58. package/src/facades/Http.js +42 -45
  59. package/src/facades/Log.js +25 -49
  60. package/src/facades/Mail.js +27 -32
  61. package/src/facades/Queue.js +22 -15
  62. package/src/facades/Storage.js +18 -10
  63. package/src/facades/Url.js +53 -0
  64. package/src/http/HttpClient.js +673 -0
  65. package/src/http/ResponseDispatcher.js +18 -111
  66. package/src/http/UrlGenerator.js +375 -0
  67. package/src/http/WelcomePage.js +273 -0
  68. package/src/http/adapters/ExpressAdapter.js +315 -0
  69. package/src/http/adapters/HttpAdapter.js +168 -0
  70. package/src/http/adapters/index.js +9 -0
  71. package/src/i18n/I18nServiceProvider.js +91 -0
  72. package/src/i18n/Translator.js +635 -0
  73. package/src/i18n/defaults.js +122 -0
  74. package/src/i18n/index.js +164 -0
  75. package/src/i18n/locales/en.js +55 -0
  76. package/src/i18n/locales/sw.js +48 -0
  77. package/src/index.js +5 -144
  78. package/src/logger/formatters/PrettyFormatter.js +103 -57
  79. package/src/logger/internal.js +2 -2
  80. package/src/logger/patchConsole.js +91 -81
  81. package/src/middleware/MiddlewareRegistry.js +62 -82
  82. package/src/migrations/system/0001_users.js +21 -0
  83. package/src/migrations/system/0002_admin_log.js +25 -0
  84. package/src/migrations/system/0003_sessions.js +23 -0
  85. package/src/orm/fields/index.js +210 -188
  86. package/src/orm/migration/DefaultValueParser.js +325 -0
  87. package/src/orm/migration/InteractiveResolver.js +191 -0
  88. package/src/orm/migration/Makemigrations.js +312 -0
  89. package/src/orm/migration/MigrationGraph.js +227 -0
  90. package/src/orm/migration/MigrationRunner.js +202 -108
  91. package/src/orm/migration/MigrationWriter.js +463 -0
  92. package/src/orm/migration/ModelInspector.js +412 -344
  93. package/src/orm/migration/ModelScanner.js +225 -0
  94. package/src/orm/migration/ProjectState.js +213 -0
  95. package/src/orm/migration/RenameDetector.js +175 -0
  96. package/src/orm/migration/SchemaBuilder.js +8 -81
  97. package/src/orm/migration/operations/base.js +57 -0
  98. package/src/orm/migration/operations/column.js +191 -0
  99. package/src/orm/migration/operations/fields.js +252 -0
  100. package/src/orm/migration/operations/index.js +55 -0
  101. package/src/orm/migration/operations/models.js +152 -0
  102. package/src/orm/migration/operations/registry.js +131 -0
  103. package/src/orm/migration/operations/special.js +51 -0
  104. package/src/orm/migration/utils.js +208 -0
  105. package/src/orm/model/Model.js +81 -13
  106. package/src/providers/AdminServiceProvider.js +66 -9
  107. package/src/providers/AuthServiceProvider.js +46 -7
  108. package/src/providers/CacheStorageServiceProvider.js +5 -3
  109. package/src/providers/DatabaseServiceProvider.js +3 -2
  110. package/src/providers/EventServiceProvider.js +2 -1
  111. package/src/providers/LogServiceProvider.js +7 -3
  112. package/src/providers/MailServiceProvider.js +4 -3
  113. package/src/providers/QueueServiceProvider.js +4 -3
  114. package/src/router/Router.js +119 -152
  115. package/src/scaffold/templates.js +83 -26
  116. package/src/facades/Validation.js +0 -69
@@ -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>
@@ -202,8 +202,8 @@
202
202
  {% endfor %}
203
203
  {% if resource.canEdit or resource.canDelete %}
204
204
  <td class="col-actions">
205
- <div class="action-menu">
206
- <button class="action-menu-btn">
205
+ <div class="ui-menu">
206
+ <button class="action-menu-btn ui-menu-trigger" type="button" aria-label="Row actions">
207
207
  <span class="icon icon-14">
208
208
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
209
209
  <circle cx="12" cy="5" r="1.2" fill="currentColor" stroke="none"/>
@@ -212,20 +212,18 @@
212
212
  </svg>
213
213
  </span>
214
214
  </button>
215
- <div class="action-dropdown">
215
+ <div class="ui-menu-panel" style="display:none">
216
216
  {% if resource.canEdit %}
217
- <a href="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/edit">
217
+ <a class="ui-menu-item" href="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/edit">
218
218
  <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-edit"/></svg></span>
219
219
  Edit
220
220
  </a>
221
221
  {% 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
225
- </a>
226
222
  {% if resource.canDelete %}
227
- <div class="sep"></div>
228
- <button class="danger" onclick="confirmDelete('{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/delete', '#{{ row.id }}')">
223
+ <div class="ui-menu-sep"></div>
224
+ <button class="ui-menu-item ui-menu-danger" type="button"
225
+ data-confirm-delete="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/delete"
226
+ data-confirm-label="#{{ row.id }}">
229
227
  <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
230
228
  Delete
231
229
  </button>
@@ -300,60 +298,145 @@
300
298
  </div>
301
299
 
302
300
  <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 %}
301
+ $(function() {
302
+ var PREFIX = '{{ adminPrefix }}';
303
+ var SLUG = '{{ resource.slug }}';
308
304
 
309
- function toggleFilters() {
310
- if (!filterPanel) return;
311
- const shown = filterPanel.style.display !== 'none';
312
- filterPanel.style.display = shown ? 'none' : 'flex';
313
- }
305
+ // ── Filter panel ─────────────────────────────────────────────────────────
306
+ {% if activeFilters | length %}$('#filter-panel').show();{% endif %}
314
307
 
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();
308
+ window.toggleFilters = function() {
309
+ $('#filter-panel').toggle();
310
+ };
311
+
312
+ // ── Live search on Enter ──────────────────────────────────────────────────
313
+ $('#search-form input[name="search"]').on('keydown', function(e) {
314
+ if (e.key === 'Enter') $('#search-form').submit();
320
315
  });
321
- }
322
316
 
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;
317
+ // ── Bulk selection ────────────────────────────────────────────────────────
318
+ function updateBulkBar() {
319
+ var $checked = $('.item-check:checked');
320
+ var n = $checked.length;
321
+ var total = $('.item-check').length;
322
+ $('#bulk-bar').toggleClass('visible', n > 0);
323
+ $('#bulk-count').text(n + ' selected');
324
+ $('#check-all').prop('indeterminate', n > 0 && n < total)
325
+ .prop('checked', n > 0 && n === total);
337
326
  }
338
- }
339
327
 
340
- function toggleAll(master) {
341
- document.querySelectorAll('.item-check').forEach(c => { c.checked = master.checked; });
342
- updateBulkBar();
343
- }
328
+ window.toggleAll = function(master) {
329
+ $('.item-check').prop('checked', master.checked);
330
+ updateBulkBar();
331
+ };
332
+
333
+ window.clearSelection = function() {
334
+ $('.item-check, #check-all').prop('checked', false).prop('indeterminate', false);
335
+ updateBulkBar();
336
+ };
337
+
338
+ $(document).on('change', '.item-check', updateBulkBar);
344
339
 
345
- function clearSelection() {
346
- document.querySelectorAll('.item-check, #check-all').forEach(c => { c.checked = false; c.indeterminate = false; });
347
- updateBulkBar();
340
+ window.bulkDelete = function() {
341
+ var ids = $('.item-check:checked').map(function() { return this.value; }).get();
342
+ if (!ids.length) return;
343
+ var label = ids.length + ' record' + (ids.length > 1 ? 's' : '');
344
+ confirmDeleteBulk(PREFIX + '/' + SLUG + '/bulk-delete', label, ids);
345
+ };
346
+
347
+ window.confirmDeleteBulk = function(url, label, ids) {
348
+ UI.Confirm.show({
349
+ title: 'Delete ' + label,
350
+ message: 'Delete <strong>' + label + '</strong>? This cannot be undone.',
351
+ confirm: 'Delete',
352
+ danger: true,
353
+ }).then(function(ok) {
354
+ if (!ok) return;
355
+ var csrf = $('meta[name="csrf-token"]').attr('content') || '';
356
+ var $form = $('<form method="POST">').attr('action', url);
357
+ $form.append('<input name="_csrf" value="' + csrf + '">');
358
+ $.each(ids, function(_, id) {
359
+ $form.append('<input type="hidden" name="ids[]" value="' + id + '">');
360
+ });
361
+ $form.appendTo('body').submit();
362
+ });
363
+ };
364
+
365
+ // ── UI.Dropdown — portal-rendered action menus ────────────────────────────
366
+ // Wire export menu
367
+ var $exportBtn = $('#export-menu-btn');
368
+ var $exportPanel = $('#export-menu-panel');
369
+ if ($exportBtn.length && $exportPanel.length) {
370
+ var exportDd = UI.Dropdown.create({
371
+ anchor: $exportBtn[0],
372
+ content: $exportPanel[0],
373
+ placement: 'bottom-end',
374
+ offset: 4,
375
+ });
376
+ $exportBtn.on('click', function() { exportDd.toggle(); });
377
+ }
378
+
379
+ // Wire per-row action menus
380
+ $('.ui-menu').each(function() {
381
+ var $menu = $(this);
382
+ var $btn = $menu.find('.ui-menu-trigger');
383
+ var $panel = $menu.find('.ui-menu-panel');
384
+ if (!$btn.length || !$panel.length) return;
385
+
386
+ var dd = UI.Dropdown.create({
387
+ anchor: $btn[0],
388
+ content: $panel[0],
389
+ placement: 'bottom-end',
390
+ offset: 4,
391
+ });
392
+
393
+ $btn.on('click', function(e) {
394
+ e.stopPropagation();
395
+ dd.toggle();
396
+ });
397
+
398
+ $panel.find('[data-confirm-delete]').on('click', function() {
399
+ dd.close();
400
+ confirmDelete($(this).data('confirm-delete'), $(this).data('confirm-label'));
401
+ });
402
+ });
403
+ });
404
+ </script>
405
+
406
+ <style>
407
+ /* ── UI Menu (portal dropdown) ── */
408
+ .ui-menu { position: relative; display: inline-block; }
409
+
410
+ .ui-menu-panel {
411
+ background: var(--surface);
412
+ border: 1px solid var(--border);
413
+ border-radius: var(--radius);
414
+ min-width: 150px;
415
+ overflow: hidden;
348
416
  }
349
417
 
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
- );
418
+ .ui-menu-item {
419
+ display: flex;
420
+ align-items: center;
421
+ gap: 8px;
422
+ padding: 8px 14px;
423
+ font-size: 13px;
424
+ color: var(--text-soft);
425
+ text-decoration: none;
426
+ background: none;
427
+ border: none;
428
+ width: 100%;
429
+ text-align: left;
430
+ cursor: pointer;
431
+ font-family: inherit;
432
+ transition: background .1s;
433
+ white-space: nowrap;
357
434
  }
358
- </script>
435
+ .ui-menu-item:hover { background: var(--surface2); color: var(--text); }
436
+ .ui-menu-danger { color: var(--danger); }
437
+ .ui-menu-danger:hover { background: var(--danger-bg); color: var(--danger); }
438
+
439
+ .ui-menu-sep { height: 1px; background: var(--border-soft); margin: 3px 0; }
440
+ </style>
441
+
359
442
  {% 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>