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
@@ -0,0 +1,297 @@
1
+ {#
2
+ partials/form-widget.njk
3
+ All widget HTML — one block per field type.
4
+ Expects in scope: field, val, hasError, isEdit
5
+ #}
6
+
7
+ {# ── Select ── #}
8
+ {% if field.type == 'select' and field.options %}
9
+ <select id="field-{{ field.name }}" name="{{ field.name }}"
10
+ class="form-control{% if hasError %} error{% endif %}"
11
+ {% if not field.nullable %}data-required="true"{% endif %}>
12
+ <option value="">— Select —</option>
13
+ {% for opt in field.options %}
14
+ {% set optVal = opt.value if opt.value is defined else opt %}
15
+ {% set optLabel = opt.label if opt.label is defined else opt %}
16
+ <option value="{{ optVal }}" {% if val == optVal %}selected{% endif %}>{{ optLabel }}</option>
17
+ {% endfor %}
18
+ </select>
19
+
20
+ {# ── FK — async paginated dropdown ── #}
21
+ {% elif field.type == 'fk' %}
22
+ <div class="fk-widget" id="fkw-{{ field.name }}"
23
+ data-name="{{ field.name }}"
24
+ data-resource="{{ field.fkResource }}"
25
+ data-nullable="{{ 'true' if field.nullable else 'false' }}"
26
+ data-required="{{ 'true' if not field.nullable else 'false' }}"
27
+ data-current-id="{{ val }}"
28
+ data-current-label=""
29
+ data-fk-field="{{ field.name }}">
30
+ <input type="hidden"
31
+ id="field-{{ field.name }}"
32
+ name="{{ field.name }}"
33
+ value="{{ val }}"
34
+ {% if not field.nullable %}data-required="true"{% endif %}>
35
+ <button type="button"
36
+ class="fk-trigger form-control{% if hasError %} error{% endif %}"
37
+ id="fktrig-{{ field.name }}"
38
+ aria-haspopup="listbox"
39
+ aria-expanded="false"
40
+ aria-controls="fkpanel-{{ field.name }}">
41
+ <span class="fk-trigger-label">
42
+ {% if val %}
43
+ <span class="fk-id-chip">#{{ val }}</span>
44
+ <span class="fk-loading-label">Loading…</span>
45
+ {% else %}
46
+ <span class="fk-placeholder">— Select —</span>
47
+ {% endif %}
48
+ </span>
49
+ <svg class="fk-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
50
+ </button>
51
+ <div class="fk-panel" id="fkpanel-{{ field.name }}" role="listbox" style="display:none">
52
+ <div class="fk-search-wrap">
53
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
54
+ <input type="text" class="fk-search" id="fksearch-{{ field.name }}"
55
+ placeholder="Search…" autocomplete="off" spellcheck="false">
56
+ <button type="button" class="fk-search-clear" hidden aria-label="Clear search">
57
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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>
58
+ </button>
59
+ </div>
60
+ <div class="fk-list" id="fklist-{{ field.name }}" role="listbox">
61
+ <div class="fk-spinner-row">
62
+ <svg class="fk-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
63
+ Loading…
64
+ </div>
65
+ </div>
66
+ <div class="fk-footer" id="fkfoot-{{ field.name }}" hidden>
67
+ <span class="fk-count" id="fkcount-{{ field.name }}"></span>
68
+ <div class="fk-pages">
69
+ <button type="button" class="fk-page-btn" id="fkprev-{{ field.name }}" aria-label="Previous page" disabled>
70
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
71
+ </button>
72
+ <span class="fk-page-info" id="fkpageinfo-{{ field.name }}"></span>
73
+ <button type="button" class="fk-page-btn" id="fknext-{{ field.name }}" aria-label="Next page">
74
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
75
+ </button>
76
+ </div>
77
+ </div>
78
+ {% if field.nullable %}
79
+ <div class="fk-clear-row">
80
+ <button type="button" class="fk-clear-btn" id="fkclear-{{ field.name }}">
81
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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>
82
+ Clear selection
83
+ </button>
84
+ </div>
85
+ {% endif %}
86
+ </div>
87
+ </div>
88
+
89
+ {# ── M2M — dual list ── #}
90
+ {% elif field.type == 'm2m' %}
91
+ <div class="m2m-widget" id="m2m-{{ field.name }}">
92
+ <div style="display:flex;gap:8px;align-items:flex-start">
93
+ <div style="flex:1">
94
+ <div class="form-label" style="font-size:11px;color:var(--text-soft)">Available</div>
95
+ <select class="form-control" size="6" id="m2m-available-{{ field.name }}" style="width:100%" multiple></select>
96
+ </div>
97
+ <div style="display:flex;flex-direction:column;gap:6px;padding-top:24px">
98
+ <button type="button" class="btn btn-ghost btn-sm" onclick="m2mMove('{{ field.name }}','available','chosen')">→</button>
99
+ <button type="button" class="btn btn-ghost btn-sm" onclick="m2mMove('{{ field.name }}','chosen','available')">←</button>
100
+ </div>
101
+ <div style="flex:1">
102
+ <div class="form-label" style="font-size:11px;color:var(--text-soft)">Chosen</div>
103
+ <select class="form-control" name="{{ field.name }}[]" size="6"
104
+ id="m2m-chosen-{{ field.name }}" style="width:100%" multiple>
105
+ {% if val %}{% for v in val %}<option value="{{ v }}" selected>{{ v }}</option>{% endfor %}{% endif %}
106
+ </select>
107
+ </div>
108
+ </div>
109
+ </div>
110
+
111
+ {# ── Boolean — toggle switch ── #}
112
+ {% elif field.type == 'checkbox' %}
113
+ <div class="toggle-field">
114
+ <label class="toggle-wrap" for="field-{{ field.name }}">
115
+ <input type="checkbox"
116
+ id="field-{{ field.name }}"
117
+ name="{{ field.name }}"
118
+ class="toggle-input"
119
+ value="1"{{ ' checked' if val else '' }}>
120
+ <span class="toggle-track"><span class="toggle-thumb"></span></span>
121
+ <span class="toggle-label">{{ field.label }}</span>
122
+ </label>
123
+ {% if field.help %}
124
+ <div class="form-help" style="margin-top:4px;margin-left:52px">{{ field.help }}</div>
125
+ {% endif %}
126
+ </div>
127
+
128
+ {# ── Textarea ── #}
129
+ {% elif field.type == 'textarea' %}
130
+ <textarea id="field-{{ field.name }}" name="{{ field.name }}"
131
+ class="form-control{% if hasError %} error{% endif %}"
132
+ placeholder="{{ field.placeholder or '' }}"
133
+ {% if not field.nullable %}data-required="true"{% endif %}
134
+ rows="4">{{ val }}</textarea>
135
+
136
+ {# ── JSON — dialog editor ── #}
137
+ {% elif field.type == 'json' %}
138
+ {% include "partials/json-editor.njk" %}
139
+
140
+ {# ── Richtext ── #}
141
+ {% elif field.type == 'richtext' %}
142
+ <div class="richtext-wrap">
143
+ <div class="richtext-toolbar">
144
+ <button type="button" onclick="rtCmd('bold')" title="Bold"><b>B</b></button>
145
+ <button type="button" onclick="rtCmd('italic')" title="Italic"><i>I</i></button>
146
+ <button type="button" onclick="rtCmd('underline')" title="Underline"><u>U</u></button>
147
+ <span class="rt-sep"></span>
148
+ <button type="button" onclick="rtCmd('insertUnorderedList')" title="Bullet list">≡</button>
149
+ <button type="button" onclick="rtCmd('insertOrderedList')" title="Numbered list">1.</button>
150
+ <span class="rt-sep"></span>
151
+ <button type="button" onclick="rtLink()" title="Link">🔗</button>
152
+ </div>
153
+ <div id="rt-{{ field.name }}" class="richtext-editor form-control{% if hasError %} error{% endif %}"
154
+ contenteditable="true"
155
+ style="min-height:120px;height:auto">{{ val | safe }}</div>
156
+ <input type="hidden" id="field-{{ field.name }}" name="{{ field.name }}" value="{{ val }}">
157
+ </div>
158
+
159
+ {# ── Password ── #}
160
+ {% elif field.type == 'password' %}
161
+ <div style="position:relative">
162
+ <input type="password" id="field-{{ field.name }}" name="{{ field.name }}"
163
+ class="form-control{% if hasError %} error{% endif %}"
164
+ placeholder="{{ 'Leave blank to keep current' if isEdit else '' }}"
165
+ {% if not field.nullable and not isEdit %}data-required="true"{% endif %}
166
+ style="padding-right:40px">
167
+ <button type="button" class="pw-toggle"
168
+ onclick="this.previousElementSibling.type=this.previousElementSibling.type==='password'?'text':'password'">
169
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
170
+ <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"/>
171
+ </svg>
172
+ </button>
173
+ </div>
174
+
175
+ {# ── Email ── #}
176
+ {% elif field.type == 'email' %}
177
+ <input type="email" id="field-{{ field.name }}" name="{{ field.name }}"
178
+ value="{{ val }}"
179
+ class="form-control{% if hasError %} error{% endif %}"
180
+ placeholder="{{ field.placeholder or 'name@example.com' }}"
181
+ data-validate="email"
182
+ {% if not field.nullable %}data-required="true"{% endif %}>
183
+
184
+ {# ── URL ── #}
185
+ {% elif field.type == 'url' %}
186
+ <input type="url" id="field-{{ field.name }}" name="{{ field.name }}"
187
+ value="{{ val }}"
188
+ class="form-control{% if hasError %} error{% endif %}"
189
+ placeholder="{{ field.placeholder or 'https://' }}"
190
+ data-validate="url"
191
+ {% if not field.nullable %}data-required="true"{% endif %}>
192
+
193
+ {# ── Phone ── #}
194
+ {% elif field.type == 'phone' %}
195
+ <input type="tel" id="field-{{ field.name }}" name="{{ field.name }}"
196
+ value="{{ val }}"
197
+ class="form-control{% if hasError %} error{% endif %}"
198
+ placeholder="{{ field.placeholder or '+1 555 000 0000' }}"
199
+ {% if not field.nullable %}data-required="true"{% endif %}>
200
+
201
+ {# ── Color ── #}
202
+ {% elif field.type == 'color' %}
203
+ <div class="flex items-center gap-2">
204
+ <input type="color" id="field-{{ field.name }}-picker"
205
+ value="{{ val or '#000000' }}"
206
+ style="width:40px;height:36px;border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;padding:2px"
207
+ oninput="document.getElementById('field-{{ field.name }}').value=this.value">
208
+ <input type="text" id="field-{{ field.name }}" name="{{ field.name }}"
209
+ value="{{ val }}"
210
+ class="form-control{% if hasError %} error{% endif %}"
211
+ placeholder="#000000"
212
+ style="width:120px"
213
+ oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))document.getElementById('field-{{ field.name }}-picker').value=this.value">
214
+ </div>
215
+
216
+ {# ── Number ── #}
217
+ {% elif field.type == 'number' %}
218
+ <input type="number" id="field-{{ field.name }}" name="{{ field.name }}"
219
+ value="{{ val }}"
220
+ class="form-control{% if hasError %} error{% endif %}"
221
+ placeholder="{{ field.placeholder or '0' }}"
222
+ {% if field.min is not none %}min="{{ field.min }}" data-min="{{ field.min }}"{% endif %}
223
+ {% if field.max is not none %}max="{{ field.max }}" data-max="{{ field.max }}"{% endif %}
224
+ {% if not field.nullable %}data-required="true"{% endif %}>
225
+
226
+ {# ── Date — custom picker ── #}
227
+ {% elif field.type == 'date' %}
228
+ <div class="dp-wrap">
229
+ {# Hidden input carries the real ISO value on form submit #}
230
+ <input type="hidden" id="field-{{ field.name }}" name="{{ field.name }}"
231
+ value="{{ val }}"
232
+ {% if not field.nullable %}data-required="true"{% endif %}>
233
+ {# Visible trigger — readonly, opens the calendar via DatePicker #}
234
+ <input type="text"
235
+ class="form-control dp-trigger{% if hasError %} error{% endif %}"
236
+ id="dpt-{{ field.name }}"
237
+ placeholder="Select date…"
238
+ autocomplete="off"
239
+ data-target="field-{{ field.name }}"
240
+ data-mode="date">
241
+ <span class="dp-icon">
242
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
243
+ <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"/>
244
+ </svg>
245
+ </span>
246
+ </div>
247
+
248
+ {# ── Datetime — custom picker with time ── #}
249
+ {% elif field.type == 'datetime' or field.type == 'datetime-local' %}
250
+ <div class="dp-wrap">
251
+ <input type="hidden" id="field-{{ field.name }}" name="{{ field.name }}"
252
+ value="{{ val }}"
253
+ {% if not field.nullable %}data-required="true"{% endif %}>
254
+ <input type="text"
255
+ class="form-control dp-trigger{% if hasError %} error{% endif %}"
256
+ id="dpt-{{ field.name }}"
257
+ placeholder="Select date and time…"
258
+ autocomplete="off"
259
+ data-target="field-{{ field.name }}"
260
+ data-mode="datetime">
261
+ <span class="dp-icon">
262
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
263
+ <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"/>
264
+ </svg>
265
+ </span>
266
+ </div>
267
+
268
+ {# ── Badge — display only, no input ── #}
269
+ {% elif field.type == 'badge' %}
270
+ {% set _bc = field.colors[val] if (field.colors and val and field.colors[val]) else 'gray' %}
271
+ <div class="readonly-value">
272
+ {% if val %}<span class="badge badge-{{ _bc }}">{{ val }}</span>{% else %}<span class="cell-muted">—</span>{% endif %}
273
+ </div>
274
+
275
+ {# ── Image — display URL as text + preview ── #}
276
+ {% elif field.type == 'image' %}
277
+ <div>
278
+ {% if val %}
279
+ <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="">
280
+ {% endif %}
281
+ <input type="text" id="field-{{ field.name }}" name="{{ field.name }}"
282
+ value="{{ val }}"
283
+ class="form-control{% if hasError %} error{% endif %}"
284
+ placeholder="https://example.com/image.jpg"
285
+ {% if not field.nullable %}data-required="true"{% endif %}>
286
+ </div>
287
+
288
+ {# ── Default text ── #}
289
+ {% else %}
290
+ <input type="text" id="field-{{ field.name }}" name="{{ field.name }}"
291
+ value="{{ val }}"
292
+ class="form-control{% if hasError %} error{% endif %}"
293
+ placeholder="{{ field.placeholder or '' }}"
294
+ {% if field.max is not none %}maxlength="{{ field.max }}"{% endif %}
295
+ {% if not field.nullable %}data-required="true"{% endif %}
296
+ {% if field.prepopulate %}data-prepopulate="{{ field.prepopulate }}"{% endif %}>
297
+ {% endif %}
@@ -0,0 +1,64 @@
1
+ {# ══════════════════════════════════════
2
+ SVG ICON SPRITE
3
+ ══════════════════════════════════════
4
+ Hidden SVG sprite — all icons referenced via <use href="#ic-name"/>.
5
+ To add a new icon: add a <symbol> here with id="ic-<name>".
6
+ All paths use stroke="currentColor" — colour is set via CSS.
7
+ ViewBox is always "0 0 24 24". Stroke-width 2, linecap/linejoin round.
8
+ #}
9
+ <svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
10
+
11
+ {# ── Navigation & Layout ── #}
12
+ <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>
13
+ <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>
14
+ <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>
15
+ <symbol id="ic-chevron-left" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></symbol>
16
+ <symbol id="ic-chevron-right"viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></symbol>
17
+ <symbol id="ic-chevron-down" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></symbol>
18
+ <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>
19
+
20
+ {# ── Data & Content ── #}
21
+ <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>
22
+ <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>
23
+ <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>
24
+ <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>
25
+ <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>
26
+ <symbol id="ic-activity" viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></symbol>
27
+
28
+ {# ── Users & Auth ── #}
29
+ <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>
30
+ <symbol id="ic-user" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
31
+ <symbol id="ic-shield" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></symbol>
32
+ <symbol id="ic-shield-check" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/></symbol>
33
+ <symbol id="ic-lock" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></symbol>
34
+ <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>
35
+
36
+ {# ── Actions ── #}
37
+ <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>
38
+ <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>
39
+ <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>
40
+ <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>
41
+ <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>
42
+ <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>
43
+ <symbol id="ic-ban" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></symbol>
44
+
45
+ {# ── Status & Feedback ── #}
46
+ <symbol id="ic-check" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></symbol>
47
+ <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>
48
+ <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>
49
+ <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>
50
+
51
+ {# ── Search & Filter ── #}
52
+ <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>
53
+ <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>
54
+
55
+ {# ── Domain ── #}
56
+ <symbol id="ic-key" viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></symbol>
57
+ <symbol id="ic-calendar" viewBox="0 0 24 24"><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"/></symbol>
58
+ <symbol id="ic-credit-card" viewBox="0 0 24 24"><rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/></symbol>
59
+ <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>
60
+ <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>
61
+ <symbol id="ic-eye-off" viewBox="0 0 24 24"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></symbol>
62
+ <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>
63
+
64
+ </svg>
@@ -0,0 +1,80 @@
1
+ {#
2
+ partials/json-dialog.njk — Phase 6
3
+ Adds undo/redo buttons in the toolbar.
4
+ #}
5
+ <div id="je-dialog-content" style="display:none">
6
+
7
+ {# Toolbar #}
8
+ <div class="je-toolbar">
9
+ <div style="display:flex;align-items:center;gap:12px;flex:1;min-width:0">
10
+ <span class="je-toolbar-title" id="je-title">JSON</span>
11
+ <div id="je-root-type-wrap" class="je-root-type-wrap"></div>
12
+ </div>
13
+ <div class="je-toolbar-right">
14
+
15
+ {# Phase 6: undo / redo buttons #}
16
+ <div class="je-undo-wrap">
17
+ <button type="button" class="je-btn-icon" id="je-undo-btn"
18
+ onclick="JsonEditor._undo()" title="Undo (Ctrl+Z)" disabled>
19
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
20
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
21
+ <polyline points="9 14 4 9 9 4"/>
22
+ <path d="M20 20v-7a4 4 0 00-4-4H4"/>
23
+ </svg>
24
+ </button>
25
+ <button type="button" class="je-btn-icon" id="je-redo-btn"
26
+ onclick="JsonEditor._redo()" title="Redo (Ctrl+Y)" disabled>
27
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
28
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
29
+ <polyline points="15 14 20 9 15 4"/>
30
+ <path d="M4 20v-7a4 4 0 014-4h12"/>
31
+ </svg>
32
+ </button>
33
+ </div>
34
+
35
+ <div class="je-view-tabs">
36
+ <button type="button" class="je-view-tab active" id="je-tab-pretty"
37
+ onclick="JsonEditor._setView('pretty')">Pretty</button>
38
+ <button type="button" class="je-view-tab" id="je-tab-raw"
39
+ onclick="JsonEditor._setView('raw')">Raw</button>
40
+ </div>
41
+ <button type="button" class="je-btn-icon" id="je-format-btn"
42
+ onclick="JsonEditor._format()" title="Format / beautify" style="display:none">
43
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
44
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
45
+ <line x1="21" y1="6" x2="3" y2="6"/>
46
+ <line x1="21" y1="10" x2="7" y2="10"/>
47
+ <line x1="21" y1="14" x2="3" y2="14"/>
48
+ <line x1="21" y1="18" x2="13" y2="18"/>
49
+ </svg>
50
+ </button>
51
+ </div>
52
+ </div>
53
+
54
+ {# Pretty view #}
55
+ <div id="je-pretty-view" class="je-pretty-view">
56
+ <div class="je-tree" id="je-tree"></div>
57
+ </div>
58
+
59
+ {# Raw view #}
60
+ <div id="je-raw-view" class="je-raw-view" style="display:none">
61
+ <textarea id="je-raw-ta" class="je-raw-ta" spellcheck="false"
62
+ placeholder='{"key": "value"}'></textarea>
63
+ <div class="je-raw-hint" id="je-raw-hint"></div>
64
+ </div>
65
+
66
+ {# Footer #}
67
+ <div class="je-footer">
68
+ <span class="je-count" id="je-count"></span>
69
+ <div style="display:flex;gap:8px;align-items:center">
70
+ <button type="button" class="btn btn-ghost btn-sm" onclick="JsonEditor._cancel()">Cancel</button>
71
+ <button type="button" class="btn btn-primary btn-sm" onclick="JsonEditor._apply()">
72
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
73
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
74
+ <polyline points="20 6 9 17 4 12"/>
75
+ </svg>
76
+ Apply
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </div>
@@ -0,0 +1,37 @@
1
+ {#
2
+ partials/json-editor.njk
3
+ Replaces the JSON textarea in the form with a compact trigger + preview.
4
+ Clicking opens the full Postman-style dialog.
5
+
6
+ Expects in scope: field, val, hasError, record
7
+ #}
8
+ {% set _jv = val | dump if (val is not none and val != '') else '{}' %}
9
+
10
+ <input type="hidden"
11
+ id="field-{{ field.name }}"
12
+ name="{{ field.name }}"
13
+ value="{{ _jv | escape }}"
14
+ {% if not field.nullable %}data-required="true"{% endif %}>
15
+
16
+ <div class="je-trigger {% if hasError %}error{% endif %}"
17
+ id="jet-{{ field.name }}"
18
+ tabindex="0"
19
+ role="button"
20
+ aria-label="Edit {{ field.label }} JSON"
21
+ data-field="{{ field.name }}"
22
+ onclick="JsonEditor.open('{{ field.name }}')"
23
+ onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();JsonEditor.open('{{ field.name }}');}">
24
+
25
+ <div class="je-preview-wrap" id="jep-{{ field.name }}">
26
+ <span class="je-preview-placeholder">Click to edit…</span>
27
+ </div>
28
+
29
+ <button type="button" class="je-open-btn" tabindex="-1"
30
+ onclick="event.stopPropagation();JsonEditor.open('{{ field.name }}')">
31
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
32
+ <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
33
+ <path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
34
+ </svg>
35
+ Edit JSON
36
+ </button>
37
+ </div>