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
@@ -1,22 +1,23 @@
1
1
  {% extends "layouts/base.njk" %}
2
2
 
3
3
  {% block title %}{{ 'Edit' if isEdit else 'New' }} {{ resource.singular }}{% endblock %}
4
+
4
5
  {% block topbar_title %}
5
6
  <span class="icon icon-16" style="color:var(--text-muted)">
6
- <svg viewBox="0 0 24 24"><use href="#ic-{{ 'edit' if isEdit else 'plus' }}"/></svg>
7
+ <svg viewBox="0 0 24 24"><use href="#ic-{{ 'edit' if isEdit else 'file' }}"/></svg>
7
8
  </span>
8
- {{ 'Edit' if isEdit else 'New' }} {{ resource.singular }}
9
+ {{ ('Edit ' + resource.singular) if isEdit else ('New ' + resource.singular) }}
9
10
  {% endblock %}
10
11
 
11
12
  {% block topbar_actions %}
12
13
  {% if isEdit and resource.canView %}
13
14
  <a href="{{ adminPrefix }}/{{ resource.slug }}/{{ record.id }}" class="btn btn-ghost btn-sm">
14
- <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-eye"/></svg></span>
15
+ <span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-eye"/></svg></span>
15
16
  View
16
17
  </a>
17
18
  {% endif %}
18
19
  <a href="{{ adminPrefix }}/{{ resource.slug }}" class="btn btn-ghost btn-sm">
19
- <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-arrow-left"/></svg></span>
20
+ <span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-arrow-left"/></svg></span>
20
21
  Back
21
22
  </a>
22
23
  {% endblock %}
@@ -34,7 +35,7 @@
34
35
 
35
36
  <div style="max-width:780px">
36
37
 
37
- {# ── Build tab list from formFields ── #}
38
+ {# Build tab list from formFields #}
38
39
  {% set tabNames = [] %}
39
40
  {% for f in formFields %}
40
41
  {% if f.tab and f.tab not in tabNames %}
@@ -44,11 +45,10 @@
44
45
  {% set hasTabs = tabNames | length > 0 %}
45
46
 
46
47
  <form method="POST" action="{{ formAction }}" id="record-form" novalidate>
47
- {% if isEdit %}
48
- <input type="hidden" name="_method" value="PUT">
49
- {% endif %}
48
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
49
+ {% if isEdit %}<input type="hidden" name="_method" value="PUT">{% endif %}
50
50
 
51
- {# ── Validation errors summary ── #}
51
+ {# Validation errors summary #}
52
52
  {% if errors | length %}
53
53
  <div class="alert alert-error mb-4" id="error-summary">
54
54
  <span class="icon icon-16" style="flex-shrink:0"><svg viewBox="0 0 24 24"><use href="#ic-alert-circle"/></svg></span>
@@ -56,73 +56,56 @@
56
56
  <div class="fw-600" style="margin-bottom:4px">Please fix the following errors:</div>
57
57
  <ul style="padding-left:14px;list-style:disc">
58
58
  {% for field, msgs in errors %}
59
- <li style="font-size:12.5px;margin-top:2px">
60
- {{ msgs[0] if msgs is iterable else msgs }}
61
- </li>
59
+ <li style="font-size:12.5px;margin-top:2px">{{ msgs[0] if msgs is iterable else msgs }}</li>
62
60
  {% endfor %}
63
61
  </ul>
64
62
  </div>
65
63
  </div>
66
64
  {% endif %}
67
65
 
68
- {# ══════════════════════════════════════
69
- TABBED LAYOUT
70
- ══════════════════════════════════════ #}
71
66
  {% if hasTabs %}
72
-
73
- <div class="tab-nav mb-0" style="margin-bottom:0;border-radius:var(--radius-lg) var(--radius-lg) 0 0;background:var(--surface);border:1px solid var(--border);border-bottom:none;padding:0 4px">
74
- {# First tab is always "General" for ungrouped fields #}
67
+ {# Tab nav #}
68
+ <div class="tab-nav mb-0" style="border-radius:var(--radius-lg) var(--radius-lg) 0 0;background:var(--surface);border:1px solid var(--border);border-bottom:none;padding:0 4px">
75
69
  {% set hasUngrouped = false %}
76
70
  {% for f in formFields %}{% if not f.tab %}{% set hasUngrouped = true %}{% endif %}{% endfor %}
77
-
78
71
  {% if hasUngrouped %}
79
- <button type="button" class="tab-btn active" data-tab="__general__" onclick="switchFormTab('__general__', this)">
80
- General
81
- </button>
72
+ <button type="button" class="tab-btn active" data-tab="__general__" onclick="switchFormTab('__general__', this)">General</button>
82
73
  {% endif %}
83
74
  {% for name in tabNames %}
84
- <button type="button" class="tab-btn {% if not hasUngrouped and loop.first %}active{% endif %}" data-tab="{{ name }}" onclick="switchFormTab('{{ name }}', this)">
85
- {{ name }}
86
- </button>
75
+ <button type="button" class="tab-btn {% if not hasUngrouped and loop.first %}active{% endif %}" data-tab="{{ name }}" onclick="switchFormTab('{{ name }}', this)">{{ name }}</button>
87
76
  {% endfor %}
88
77
  </div>
89
78
 
90
- {# General (ungrouped) fields #}
79
+ {# General (ungrouped) tab panel #}
91
80
  {% if hasUngrouped %}
92
81
  <div class="card tab-form-panel active" id="fpanel-__general__" style="border-top-left-radius:0;border-top-right-radius:0">
93
- <div class="card-body">
94
- <div class="form-grid">
95
- {% for field in formFields %}
96
- {% if not field.tab %}
97
- {% include "partials/form-field.njk" ignore missing %}
98
- {{ _self.renderField(field, record, errors) }}
99
- {% endif %}
100
- {% endfor %}
101
- </div>
102
- </div>
82
+ <div class="card-body"><div class="form-grid">
83
+ {% for field in formFields %}{% if not field.tab %}
84
+ {% set val = record[field.name] if record[field.name] is defined else '' %}
85
+ {% set hasError = errors[field.name] is defined %}
86
+ {% include "partials/form-field.njk" %}
87
+ {% endif %}{% endfor %}
88
+ </div></div>
103
89
  </div>
104
90
  {% endif %}
105
91
 
106
92
  {# Named tab panels #}
107
93
  {% for name in tabNames %}
108
- <div class="card tab-form-panel {% if not hasUngrouped and loop.first %}active{% endif %}" id="fpanel-{{ name | replace(' ', '-') }}" style="border-top-left-radius:0;border-top-right-radius:0;{% if not loop.first or hasUngrouped %}display:none{% endif %}">
109
- <div class="card-body">
110
- <div class="form-grid">
111
- {% for field in formFields %}
112
- {% if field.tab == name %}
113
- {{ _self.renderField(field, record, errors) }}
114
- {% endif %}
115
- {% endfor %}
116
- </div>
117
- </div>
94
+ <div class="card tab-form-panel {% if not hasUngrouped and loop.first %}active{% endif %}"
95
+ id="fpanel-{{ name | tabId }}"
96
+ style="border-top-left-radius:0;border-top-right-radius:0;{% if not loop.first or hasUngrouped %}display:none{% endif %}">
97
+ <div class="card-body"><div class="form-grid">
98
+ {% for field in formFields %}{% if field.tab == name %}
99
+ {% set val = record[field.name] if record[field.name] is defined else '' %}
100
+ {% set hasError = errors[field.name] is defined %}
101
+ {% include "partials/form-field.njk" %}
102
+ {% endif %}{% endfor %}
103
+ </div></div>
118
104
  </div>
119
105
  {% endfor %}
120
106
 
121
- {# ══════════════════════════════════════
122
- FLAT LAYOUT (no tabs)
123
- ══════════════════════════════════════ #}
124
107
  {% else %}
125
-
108
+ {# Flat layout — no tabs #}
126
109
  <div class="card">
127
110
  <div class="card-header">
128
111
  <span class="card-title">
@@ -133,264 +116,20 @@
133
116
  </span>
134
117
  {% if isEdit %}<span class="badge badge-gray text-xs">ID: {{ record.id }}</span>{% endif %}
135
118
  </div>
136
- <div class="card-body">
137
- <div class="form-grid">
138
- {% for field in formFields %}
139
-
140
- {# ── Fieldset heading ── #}
141
- {% if field._isFieldset %}
142
- <div class="full" style="grid-column:1/-1;margin-top:8px">
143
- <div class="fieldset-heading">{{ field.label }}</div>
144
- </div>
145
-
146
- {# ── Readonly field ── #}
147
- {% elif field.isReadonly %}
148
- <div class="form-group {% if field.span == 'full' or field.type == 'textarea' or field.type == 'json' or field.type == 'richtext' %}full{% endif %}">
149
- <div class="form-label">{{ field.label }}</div>
150
- <div class="readonly-value">
151
- {% set val = record[field.name] %}
152
- {% if val is not none and val != '' %}
153
- {% if field.type == 'boolean' %}
154
- {% if val %}<span class="badge badge-green">Yes</span>{% else %}<span class="badge badge-gray">No</span>{% endif %}
155
- {% elif field.type == 'badge' %}
156
- <span class="badge badge-blue">{{ val }}</span>
157
- {% elif field.type == 'datetime' %}
158
- {{ val }}
159
- {% elif field.type == 'json' %}
160
- <pre class="readonly-pre">{{ val | dump }}</pre>
161
- {% elif field.type == 'image' %}
162
- <img src="{{ val }}" class="cell-image" style="width:48px;height:48px">
163
- {% elif field.type == 'url' %}
164
- <a href="{{ val }}" target="_blank" rel="noopener" style="color:var(--primary)">{{ val }}</a>
165
- {% elif field.type == 'color' %}
166
- <span style="display:inline-flex;align-items:center;gap:8px"><span style="width:18px;height:18px;border-radius:4px;background:{{ val }};border:1px solid var(--border)"></span>{{ val }}</span>
167
- {% else %}
168
- {{ val }}
169
- {% endif %}
170
- {% else %}
171
- <span class="cell-muted">—</span>
172
- {% endif %}
173
- </div>
174
- </div>
175
-
176
- {# ── Editable field ── #}
177
- {% else %}
178
- {% set val = record[field.name] if record[field.name] is defined else '' %}
179
- {% set hasError = errors[field.name] is defined %}
180
- <div class="form-group
181
- {% if field.span == 'full' %}full
182
- {% elif field.span == 'third' %}w-third
183
- {% elif field.type == 'textarea' or field.type == 'json' or field.type == 'richtext' %}full
184
- {% elif field.type == 'boolean' %}w-third
185
- {% endif %}"
186
- data-field="{{ field.name }}">
187
-
188
- {% if field.type != 'boolean' %}
189
- <label class="form-label" for="field-{{ field.name }}">
190
- {{ field.label }}
191
- {% if not field.nullable %}<span class="required">*</span>{% endif %}
192
- </label>
193
- {% endif %}
194
-
195
- {# ── Select ── #}
196
- {% if field.type == 'select' and field.options %}
197
- <select id="field-{{ field.name }}" name="{{ field.name }}"
198
- class="form-control{% if hasError %} error{% endif %}"
199
- {% if not field.nullable %}data-required="true"{% endif %}>
200
- <option value="">— Select —</option>
201
- {% for opt in field.options %}
202
- {% set optVal = opt.value if opt.value is defined else opt %}
203
- {% set optLabel = opt.label if opt.label is defined else opt %}
204
- <option value="{{ optVal }}" {% if val == optVal %}selected{% endif %}>{{ optLabel }}</option>
205
- {% endfor %}
206
- </select>
207
-
208
- {# ── Boolean ── #}
209
- {% elif field.type == 'boolean' %}
210
- <div style="padding:8px 0">
211
- <label class="check-group" for="field-{{ field.name }}" style="cursor:pointer">
212
- <input type="checkbox" id="field-{{ field.name }}" name="{{ field.name }}"
213
- class="check-input" value="1" {% if val %}checked{% endif %}>
214
- <span class="check-label">{{ field.label }}</span>
215
- </label>
216
- </div>
217
-
218
- {# ── Textarea ── #}
219
- {% elif field.type == 'textarea' %}
220
- <textarea id="field-{{ field.name }}" name="{{ field.name }}"
221
- class="form-control{% if hasError %} error{% endif %}"
222
- placeholder="{{ field.placeholder or '' }}"
223
- {% if not field.nullable %}data-required="true"{% endif %}
224
- rows="4">{{ val }}</textarea>
225
-
226
- {# ── JSON ── #}
227
- {% elif field.type == 'json' %}
228
- <textarea id="field-{{ field.name }}" name="{{ field.name }}"
229
- class="form-control{% if hasError %} error{% endif %}"
230
- placeholder='{"key": "value"}'
231
- style="font-family:'DM Mono',monospace;font-size:12px"
232
- data-validate="json"
233
- rows="5">{{ val | dump if val else '' }}</textarea>
234
-
235
- {# ── Richtext ── #}
236
- {% elif field.type == 'richtext' %}
237
- <div class="richtext-wrap">
238
- <div class="richtext-toolbar">
239
- <button type="button" onclick="rtCmd('bold')" title="Bold"><b>B</b></button>
240
- <button type="button" onclick="rtCmd('italic')" title="Italic"><i>I</i></button>
241
- <button type="button" onclick="rtCmd('underline')" title="Underline"><u>U</u></button>
242
- <span class="rt-sep"></span>
243
- <button type="button" onclick="rtCmd('insertUnorderedList')" title="Bullet list">≡</button>
244
- <button type="button" onclick="rtCmd('insertOrderedList')" title="Numbered list">1.</button>
245
- <span class="rt-sep"></span>
246
- <button type="button" onclick="rtLink()" title="Link">🔗</button>
247
- </div>
248
- <div id="rt-{{ field.name }}" class="richtext-editor form-control{% if hasError %} error{% endif %}"
249
- contenteditable="true"
250
- style="min-height:120px;height:auto">{{ val | safe }}</div>
251
- <input type="hidden" id="field-{{ field.name }}" name="{{ field.name }}" value="{{ val }}">
252
- </div>
253
-
254
- {# ── Password ── #}
255
- {% elif field.type == 'password' %}
256
- <div style="position:relative">
257
- <input type="password" id="field-{{ field.name }}" name="{{ field.name }}"
258
- class="form-control{% if hasError %} error{% endif %}"
259
- placeholder="{{ 'Leave blank to keep current' if isEdit else '' }}"
260
- {% if not field.nullable and not isEdit %}data-required="true"{% endif %}
261
- style="padding-right:40px">
262
- <button type="button" class="pw-toggle"
263
- onclick="this.previousElementSibling.type=this.previousElementSibling.type==='password'?'text':'password'">
264
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
265
- <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"/>
266
- </svg>
267
- </button>
268
- </div>
269
-
270
- {# ── Email ── #}
271
- {% elif field.type == 'email' %}
272
- <input type="email" id="field-{{ field.name }}" name="{{ field.name }}"
273
- value="{{ val }}"
274
- class="form-control{% if hasError %} error{% endif %}"
275
- placeholder="{{ field.placeholder or 'name@example.com' }}"
276
- data-validate="email"
277
- {% if not field.nullable %}data-required="true"{% endif %}>
278
-
279
- {# ── URL ── #}
280
- {% elif field.type == 'url' %}
281
- <input type="url" id="field-{{ field.name }}" name="{{ field.name }}"
282
- value="{{ val }}"
283
- class="form-control{% if hasError %} error{% endif %}"
284
- placeholder="{{ field.placeholder or 'https://' }}"
285
- data-validate="url"
286
- {% if not field.nullable %}data-required="true"{% endif %}>
287
-
288
- {# ── Phone ── #}
289
- {% elif field.type == 'phone' %}
290
- <input type="tel" id="field-{{ field.name }}" name="{{ field.name }}"
291
- value="{{ val }}"
292
- class="form-control{% if hasError %} error{% endif %}"
293
- placeholder="{{ field.placeholder or '+1 555 000 0000' }}"
294
- {% if not field.nullable %}data-required="true"{% endif %}>
295
-
296
- {# ── Color ── #}
297
- {% elif field.type == 'color' %}
298
- <div class="flex items-center gap-2">
299
- <input type="color" id="field-{{ field.name }}-picker"
300
- value="{{ val or '#000000' }}"
301
- style="width:40px;height:36px;border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;padding:2px"
302
- oninput="document.getElementById('field-{{ field.name }}').value=this.value">
303
- <input type="text" id="field-{{ field.name }}" name="{{ field.name }}"
304
- value="{{ val }}"
305
- class="form-control{% if hasError %} error{% endif %}"
306
- placeholder="#000000"
307
- style="width:120px"
308
- oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))document.getElementById('field-{{ field.name }}-picker').value=this.value">
309
- </div>
310
-
311
- {# ── Number ── #}
312
- {% elif field.type == 'number' %}
313
- <input type="number" id="field-{{ field.name }}" name="{{ field.name }}"
314
- value="{{ val }}"
315
- class="form-control{% if hasError %} error{% endif %}"
316
- placeholder="{{ field.placeholder or '0' }}"
317
- {% if field.min is not none %}min="{{ field.min }}" data-min="{{ field.min }}"{% endif %}
318
- {% if field.max is not none %}max="{{ field.max }}" data-max="{{ field.max }}"{% endif %}
319
- {% if not field.nullable %}data-required="true"{% endif %}>
320
-
321
- {# ── Date ── #}
322
- {% elif field.type == 'date' %}
323
- <input type="date" id="field-{{ field.name }}" name="{{ field.name }}"
324
- value="{{ val }}"
325
- class="form-control{% if hasError %} error{% endif %}"
326
- {% if not field.nullable %}data-required="true"{% endif %}>
327
-
328
- {# ── Datetime ── #}
329
- {% elif field.type == 'datetime' %}
330
- <input type="datetime-local" id="field-{{ field.name }}" name="{{ field.name }}"
331
- value="{{ val }}"
332
- class="form-control{% if hasError %} error{% endif %}"
333
- {% if not field.nullable %}data-required="true"{% endif %}>
334
-
335
- {# ── Default text ── #}
336
- {% else %}
337
- <input type="text" id="field-{{ field.name }}" name="{{ field.name }}"
338
- value="{{ val }}"
339
- class="form-control{% if hasError %} error{% endif %}"
340
- placeholder="{{ field.placeholder or '' }}"
341
- {% if field.max is not none %}maxlength="{{ field.max }}"{% endif %}
342
- {% if not field.nullable %}data-required="true"{% endif %}
343
- {% if field.prepopulate %}data-prepopulate="{{ field.prepopulate }}"{% endif %}>
344
- {% endif %}
345
-
346
- {# ── Error / help ── #}
347
- <div class="field-feedback" id="feedback-{{ field.name }}">
348
- {% if hasError %}
349
- <span class="form-error">
350
- <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>
351
- {{ errors[field.name][0] if errors[field.name] is iterable else errors[field.name] }}
352
- </span>
353
- {% elif field.help %}
354
- <span class="form-help">{{ field.help }}</span>
355
- {% endif %}
356
- </div>
357
- </div>
358
- {% endif %}
359
-
360
- {% endfor %}
361
- </div>
362
- </div>
119
+ <div class="card-body"><div class="form-grid">
120
+ {% for field in formFields %}
121
+ {% set val = record[field.name] if record[field.name] is defined else '' %}
122
+ {% set hasError = errors[field.name] is defined %}
123
+ {% include "partials/form-field.njk" %}
124
+ {% endfor %}
125
+ </div></div>
363
126
  </div>
364
127
  {% endif %}
365
128
 
366
- {# ── Form footer ── #}
367
- <div class="flex items-center gap-2" style="margin-top:16px;flex-wrap:wrap">
368
- <button type="submit" name="_submit" value="save" class="btn btn-primary" id="submit-btn">
369
- <span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-save"/></svg></span>
370
- {{ 'Save Changes' if isEdit else 'Create ' + resource.singular }}
371
- </button>
372
- <button type="submit" name="_submit" value="continue" class="btn btn-ghost btn-sm">
373
- Save and continue editing
374
- </button>
375
- {% if not isEdit %}
376
- <button type="submit" name="_submit" value="add_another" class="btn btn-ghost btn-sm">
377
- Save and add another
378
- </button>
379
- {% endif %}
380
- <a href="{{ adminPrefix }}/{{ resource.slug }}" class="btn btn-ghost">Cancel</a>
381
-
382
- {% if isEdit and resource.canDelete %}
383
- <button type="button" class="btn btn-danger" style="margin-left:auto"
384
- onclick="confirmDelete('{{ adminPrefix }}/{{ resource.slug }}/{{ record.id }}/delete', '{{ resource.singular }} #{{ record.id }}')">
385
- <span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
386
- Delete
387
- </button>
388
- {% endif %}
389
- </div>
390
-
129
+ {% include "partials/form-footer.njk" %}
391
130
  </form>
392
131
 
393
- {# ── Timestamps ── #}
132
+ {# Timestamps #}
394
133
  {% if isEdit and (record.created_at or record.updated_at) %}
395
134
  <div class="flex gap-4" style="margin-top:12px;padding:0 2px">
396
135
  {% if record.created_at %}
@@ -407,302 +146,11 @@
407
146
  {% endif %}
408
147
  </div>
409
148
  {% endif %}
410
- </div>
411
-
412
- <style>
413
- /* ── Tab nav (form) ── */
414
- .tab-nav {
415
- display: flex;
416
- gap: 0;
417
- overflow-x: auto;
418
- }
419
- .tab-btn {
420
- padding: 10px 18px;
421
- font-size: 13.5px;
422
- font-weight: 500;
423
- color: var(--text-muted);
424
- background: none;
425
- border: none;
426
- border-bottom: 2px solid transparent;
427
- margin-bottom: -2px;
428
- cursor: pointer;
429
- font-family: inherit;
430
- white-space: nowrap;
431
- transition: color .12s, border-color .12s;
432
- }
433
- .tab-btn:hover { color: var(--text-soft); }
434
- .tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); background: var(--primary-soft); }
435
-
436
- .tab-form-panel { display: none; }
437
- .tab-form-panel.active { display: block; }
438
-
439
- /* ── Readonly ── */
440
- .readonly-value {
441
- font-size: 13.5px;
442
- color: var(--text-soft);
443
- padding: 8px 12px;
444
- background: var(--surface2);
445
- border: 1px solid var(--border-soft);
446
- border-radius: var(--radius-sm);
447
- min-height: 36px;
448
- display: flex;
449
- align-items: center;
450
- gap: 8px;
451
- }
452
- .readonly-pre {
453
- background: var(--surface2);
454
- border: 1px solid var(--border);
455
- border-radius: var(--radius-sm);
456
- padding: 10px;
457
- font-family: 'DM Mono', monospace;
458
- font-size: 12px;
459
- overflow-x: auto;
460
- white-space: pre-wrap;
461
- margin: 0;
462
- color: var(--text-soft);
463
- }
464
-
465
- /* ── Password toggle ── */
466
- .pw-toggle {
467
- position: absolute;
468
- right: 10px;
469
- top: 50%;
470
- transform: translateY(-50%);
471
- background: none;
472
- border: none;
473
- cursor: pointer;
474
- color: var(--text-muted);
475
- display: flex;
476
- align-items: center;
477
- padding: 0;
478
- }
479
- .pw-toggle:hover { color: var(--text-soft); }
480
-
481
- /* ── Richtext ── */
482
- .richtext-wrap { border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
483
- .richtext-toolbar {
484
- display: flex;
485
- align-items: center;
486
- gap: 2px;
487
- padding: 6px 8px;
488
- background: var(--surface2);
489
- border-bottom: 1px solid var(--border);
490
- }
491
- .richtext-toolbar button {
492
- width: 28px; height: 28px;
493
- display: flex; align-items: center; justify-content: center;
494
- background: none;
495
- border: 1px solid transparent;
496
- border-radius: 4px;
497
- cursor: pointer;
498
- font-size: 13px;
499
- color: var(--text-soft);
500
- font-family: inherit;
501
- transition: all .1s;
502
- }
503
- .richtext-toolbar button:hover { background: var(--surface3); border-color: var(--border); }
504
- .rt-sep { width: 1px; height: 18px; background: var(--border); margin: 0 4px; }
505
- .richtext-editor {
506
- border: none !important;
507
- border-radius: 0 !important;
508
- outline: none !important;
509
- box-shadow: none !important;
510
- padding: 12px;
511
- line-height: 1.6;
512
- }
513
- .richtext-editor:focus { outline: none; }
514
-
515
- /* ── Field feedback ── */
516
- .field-feedback:empty { display: none; }
517
-
518
- @keyframes spin { to { transform: rotate(360deg); } }
519
- </style>
520
-
521
- <script>
522
- // ── Tab switching ──────────────────────────────────────────────
523
- function switchFormTab(name, btn) {
524
- document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
525
- document.querySelectorAll('.tab-form-panel').forEach(p => { p.classList.remove('active'); p.style.display = 'none'; });
526
- btn.classList.add('active');
527
- const safeId = name.replace(/\s+/g, '-');
528
- const panel = document.getElementById(`fpanel-${safeId}`);
529
- if (panel) { panel.classList.add('active'); panel.style.display = 'block'; }
530
- }
531
-
532
- // ── Richtext sync ─────────────────────────────────────────────
533
- document.querySelectorAll('.richtext-editor').forEach(editor => {
534
- const fieldName = editor.id.replace('rt-', '');
535
- const hidden = document.getElementById(`field-${fieldName}`);
536
- if (!hidden) return;
537
- editor.addEventListener('input', () => { hidden.value = editor.innerHTML; });
538
- });
539
- function rtCmd(cmd) {
540
- document.execCommand(cmd, false, null);
541
- }
542
- function rtLink() {
543
- const url = prompt('Enter URL:');
544
- if (url) document.execCommand('createLink', false, url);
545
- }
546
-
547
- // ── Client-side validation ─────────────────────────────────────
548
- const form = document.getElementById('record-form');
549
-
550
- function showFieldError(name, msg) {
551
- const input = document.getElementById(`field-${name}`) || document.querySelector(`[name="${name}"]`);
552
- if (input) input.classList.add('error');
553
- const fb = document.getElementById(`feedback-${name}`);
554
- if (fb) {
555
- fb.innerHTML = `<span class="form-error">
556
- <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>
557
- ${msg}
558
- </span>`;
559
- }
560
- }
561
-
562
- function clearFieldError(name) {
563
- const input = document.getElementById(`field-${name}`) || document.querySelector(`[name="${name}"]`);
564
- if (input) input.classList.remove('error');
565
- const fb = document.getElementById(`feedback-${name}`);
566
- if (fb) { const help = fb.dataset.help; fb.innerHTML = help ? `<span class="form-help">${help}</span>` : ''; }
567
- }
568
-
569
- // Live validation on blur
570
- document.querySelectorAll('.form-control').forEach(input => {
571
- input.addEventListener('blur', () => validateField(input));
572
- input.addEventListener('input', () => {
573
- if (input.classList.contains('error')) validateField(input);
574
- });
575
- });
576
-
577
- function validateField(input) {
578
- const name = input.name;
579
- if (!name) return true;
580
- const val = input.value.trim();
581
- const required = input.dataset.required === 'true';
582
- const validate = input.dataset.validate;
583
149
 
584
- clearFieldError(name);
585
-
586
- if (required && val === '') {
587
- const label = document.querySelector(`label[for="field-${name}"]`)?.textContent?.replace('*','').trim() || name;
588
- showFieldError(name, `${label} is required`);
589
- return false;
590
- }
591
-
592
- if (val && validate === 'email') {
593
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) {
594
- showFieldError(name, 'Please enter a valid email address');
595
- return false;
596
- }
597
- }
598
-
599
- if (val && validate === 'url') {
600
- try { new URL(val); } catch {
601
- showFieldError(name, 'Please enter a valid URL (include https://)');
602
- return false;
603
- }
604
- }
605
-
606
- if (val && validate === 'json') {
607
- try { JSON.parse(val); } catch {
608
- showFieldError(name, 'Invalid JSON format');
609
- return false;
610
- }
611
- }
612
-
613
- if (val && input.dataset.min !== undefined && Number(val) < Number(input.dataset.min)) {
614
- showFieldError(name, `Minimum value is ${input.dataset.min}`);
615
- return false;
616
- }
617
-
618
- if (val && input.dataset.max !== undefined && Number(val) > Number(input.dataset.max)) {
619
- showFieldError(name, `Maximum value is ${input.dataset.max}`);
620
- return false;
621
- }
622
-
623
- return true;
624
- }
625
-
626
- // Full form validation on submit
627
- form?.addEventListener('submit', function(e) {
628
- let valid = true;
629
- let firstError = null;
630
-
631
- document.querySelectorAll('.form-control').forEach(input => {
632
- if (!validateField(input)) {
633
- valid = false;
634
- if (!firstError) firstError = input;
635
- }
636
- });
637
-
638
- if (!valid) {
639
- e.preventDefault();
640
- firstError?.focus();
641
- firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' });
642
- return;
643
- }
644
-
645
- // Prevent double-submit
646
- const btn = document.getElementById('submit-btn');
647
- if (btn) {
648
- btn.disabled = true;
649
- btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation:spin 1s linear infinite"><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"/></svg> Saving…`;
650
- }
651
- });
652
-
653
- // ── Auto-switch to the tab containing the first error
654
- const firstErrorField = document.querySelector('.form-control.error');
655
- if (firstErrorField) {
656
- const group = firstErrorField.closest('[data-field]');
657
- if (group) {
658
- const panel = group.closest('.tab-form-panel');
659
- if (panel) {
660
- const panelId = panel.id.replace('fpanel-', '');
661
- const btn = document.querySelector(`.tab-btn[data-tab="${panelId}"]`);
662
- if (btn) switchFormTab(panelId, btn);
663
- }
664
- }
665
- }
666
-
667
- // ── Prepopulate (slug auto-fill from source field) ────────────
668
- (function() {
669
- const prepopMappings = {};
670
- document.querySelectorAll('[data-prepopulate]').forEach(el => {
671
- prepopMappings[el.name] = el.dataset.prepopulate;
672
- });
673
-
674
- function slugify(str) {
675
- return str.toLowerCase()
676
- .replace(/[^\w\s-]/g, '')
677
- .replace(/[\s_]+/g, '-')
678
- .replace(/^-+|-+$/g, '');
679
- }
680
-
681
- for (const [targetName, sourceName] of Object.entries(prepopMappings)) {
682
- const srcEl = document.querySelector(`[name="${sourceName}"]`);
683
- const tgtEl = document.querySelector(`[name="${targetName}"]`);
684
- if (!srcEl || !tgtEl) continue;
685
-
686
- let userEdited = !!tgtEl.value;
687
- tgtEl.addEventListener('input', () => { userEdited = true; });
688
- srcEl.addEventListener('input', () => {
689
- if (!userEdited) tgtEl.value = slugify(srcEl.value);
690
- });
691
- }
692
- })();
693
- </script>
150
+ </div>
694
151
 
695
- <style>
696
- .fieldset-heading {
697
- font-size: 11.5px;
698
- font-weight: 700;
699
- text-transform: uppercase;
700
- letter-spacing: .6px;
701
- color: var(--text-muted);
702
- padding: 16px 0 10px;
703
- border-bottom: 1px solid var(--border-soft);
704
- margin-bottom: 2px;
705
- grid-column: 1 / -1;
706
- }
707
- </style>
152
+ <script src="{{ adminPrefix }}/static/date-picker.js"></script>
153
+ {% include "partials/form-scripts.njk" %}
154
+ {% include "partials/json-dialog.njk" %}
155
+ <script src="{{ adminPrefix }}/static/json-editor.js"></script>
708
156
  {% endblock %}