millas 0.2.4 → 0.2.6

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.
@@ -9,9 +9,15 @@
9
9
  {% endblock %}
10
10
 
11
11
  {% block topbar_actions %}
12
+ {% if isEdit and resource.canView %}
13
+ <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
+ View
16
+ </a>
17
+ {% endif %}
12
18
  <a href="{{ adminPrefix }}/{{ resource.slug }}" class="btn btn-ghost btn-sm">
13
19
  <span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-arrow-left"/></svg></span>
14
- Back to {{ resource.label }}
20
+ Back
15
21
  </a>
16
22
  {% endblock %}
17
23
 
@@ -23,116 +29,239 @@
23
29
  <span class="breadcrumb-sep">›</span>
24
30
  <a href="{{ adminPrefix }}/{{ resource.slug }}">{{ resource.label }}</a>
25
31
  <span class="breadcrumb-sep">›</span>
26
- <span class="breadcrumb-current">{{ 'Edit #' + record.id if isEdit else 'New ' + resource.singular }}</span>
32
+ <span class="breadcrumb-current">{{ 'Edit #' + record.id if isEdit else 'New' }}</span>
27
33
  </div>
28
34
 
29
- <div style="max-width:760px">
30
- <div class="card">
31
- <div class="card-header">
32
- <span class="card-title">
33
- <span class="icon icon-15" style="color:var(--primary)">
34
- <svg viewBox="0 0 24 24"><use href="#ic-{{ 'edit' if isEdit else 'file' }}"/></svg>
35
- </span>
36
- {{ ('Edit ' + resource.singular + ' #' + record.id) if isEdit else ('Create ' + resource.singular) }}
37
- </span>
38
- {% if isEdit %}
39
- <span class="badge badge-gray text-xs">ID: {{ record.id }}</span>
35
+ <div style="max-width:780px">
36
+
37
+ {# ── Build tab list from formFields ── #}
38
+ {% set tabNames = [] %}
39
+ {% for f in formFields %}
40
+ {% if f.tab and f.tab not in tabNames %}
41
+ {% set tabNames = tabNames.concat([f.tab]) %}
42
+ {% endif %}
43
+ {% endfor %}
44
+ {% set hasTabs = tabNames | length > 0 %}
45
+
46
+ <form method="POST" action="{{ formAction }}" id="record-form" novalidate>
47
+ {% if isEdit %}
48
+ <input type="hidden" name="_method" value="PUT">
49
+ {% endif %}
50
+
51
+ {# ── Validation errors summary ── #}
52
+ {% if errors | length %}
53
+ <div class="alert alert-error mb-4" id="error-summary">
54
+ <span class="icon icon-16" style="flex-shrink:0"><svg viewBox="0 0 24 24"><use href="#ic-alert-circle"/></svg></span>
55
+ <div>
56
+ <div class="fw-600" style="margin-bottom:4px">Please fix the following errors:</div>
57
+ <ul style="padding-left:14px;list-style:disc">
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>
62
+ {% endfor %}
63
+ </ul>
64
+ </div>
65
+ </div>
66
+ {% endif %}
67
+
68
+ {# ══════════════════════════════════════
69
+ TABBED LAYOUT
70
+ ══════════════════════════════════════ #}
71
+ {% 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 #}
75
+ {% set hasUngrouped = false %}
76
+ {% for f in formFields %}{% if not f.tab %}{% set hasUngrouped = true %}{% endif %}{% endfor %}
77
+
78
+ {% if hasUngrouped %}
79
+ <button type="button" class="tab-btn active" data-tab="__general__" onclick="switchFormTab('__general__', this)">
80
+ General
81
+ </button>
40
82
  {% endif %}
83
+ {% 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>
87
+ {% endfor %}
41
88
  </div>
42
89
 
43
- <div class="card-body">
44
-
45
- {# ── Validation errors summary ── #}
46
- {% if errors | length %}
47
- <div class="alert alert-error mb-5">
48
- <span class="icon icon-16"><svg viewBox="0 0 24 24"><use href="#ic-alert-circle"/></svg></span>
49
- <div>
50
- <strong>Please fix the following errors:</strong>
51
- <ul style="margin-top:5px;padding-left:14px;list-style:disc">
52
- {% for field, msgs in errors %}
53
- <li style="font-size:12.5px;margin-top:2px">{{ msgs[0] if msgs is iterable and msgs[0] is string else msgs }}</li>
54
- {% endfor %}
55
- </ul>
90
+ {# General (ungrouped) fields #}
91
+ {% if hasUngrouped %}
92
+ <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 %}
56
101
  </div>
57
102
  </div>
58
- {% endif %}
103
+ </div>
104
+ {% endif %}
105
+
106
+ {# Named tab panels #}
107
+ {% 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>
118
+ </div>
119
+ {% endfor %}
59
120
 
60
- <form method="POST" action="{{ formAction }}" id="record-form" novalidate>
61
- {% if isEdit %}
62
- <input type="hidden" name="_method" value="PUT">
63
- {% endif %}
121
+ {# ══════════════════════════════════════
122
+ FLAT LAYOUT (no tabs)
123
+ ══════════════════════════════════════ #}
124
+ {% else %}
64
125
 
126
+ <div class="card">
127
+ <div class="card-header">
128
+ <span class="card-title">
129
+ <span class="icon icon-15" style="color:var(--primary)">
130
+ <svg viewBox="0 0 24 24"><use href="#ic-{{ 'edit' if isEdit else 'file' }}"/></svg>
131
+ </span>
132
+ {{ ('Edit ' + resource.singular + ' #' + record.id) if isEdit else ('New ' + resource.singular) }}
133
+ </span>
134
+ {% if isEdit %}<span class="badge badge-gray text-xs">ID: {{ record.id }}</span>{% endif %}
135
+ </div>
136
+ <div class="card-body">
65
137
  <div class="form-grid">
66
138
  {% for field in formFields %}
67
- <div class="form-group {% if field.type == 'textarea' or field.type == 'json' %}full{% endif %}">
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' %}
68
189
  <label class="form-label" for="field-{{ field.name }}">
69
190
  {{ field.label }}
70
- {% if field.required %}<span class="required">*</span>{% endif %}
191
+ {% if not field.nullable %}<span class="required">*</span>{% endif %}
71
192
  </label>
72
-
73
- {% set val = record[field.name] if record[field.name] is defined else '' %}
74
- {% set hasError = errors[field.name] is defined %}
193
+ {% endif %}
75
194
 
76
195
  {# ── Select ── #}
77
196
  {% if field.type == 'select' and field.options %}
78
- <select
79
- id="field-{{ field.name }}"
80
- name="{{ field.name }}"
81
- class="form-control{% if hasError %} error{% endif %}">
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 %}>
82
200
  <option value="">— Select —</option>
83
201
  {% for opt in field.options %}
84
- <option value="{{ opt }}" {% if val == opt %}selected{% endif %}>{{ opt }}</option>
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>
85
205
  {% endfor %}
86
206
  </select>
87
207
 
88
208
  {# ── Boolean ── #}
89
209
  {% elif field.type == 'boolean' %}
90
- <div class="check-group" style="padding:8px 0">
91
- <input
92
- type="checkbox"
93
- id="field-{{ field.name }}"
94
- name="{{ field.name }}"
95
- class="check-input"
96
- value="1"
97
- {% if val %}checked{% endif %}>
98
- <label class="check-label" for="field-{{ field.name }}">
99
- {{ field.label }}
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>
100
215
  </label>
101
216
  </div>
102
217
 
103
218
  {# ── Textarea ── #}
104
219
  {% elif field.type == 'textarea' %}
105
- <textarea
106
- id="field-{{ field.name }}"
107
- name="{{ field.name }}"
220
+ <textarea id="field-{{ field.name }}" name="{{ field.name }}"
108
221
  class="form-control{% if hasError %} error{% endif %}"
109
222
  placeholder="{{ field.placeholder or '' }}"
223
+ {% if not field.nullable %}data-required="true"{% endif %}
110
224
  rows="4">{{ val }}</textarea>
111
225
 
112
226
  {# ── JSON ── #}
113
227
  {% elif field.type == 'json' %}
114
- <textarea
115
- id="field-{{ field.name }}"
116
- name="{{ field.name }}"
228
+ <textarea id="field-{{ field.name }}" name="{{ field.name }}"
117
229
  class="form-control{% if hasError %} error{% endif %}"
118
230
  placeholder='{"key": "value"}'
119
231
  style="font-family:'DM Mono',monospace;font-size:12px"
232
+ data-validate="json"
120
233
  rows="5">{{ val | dump if val else '' }}</textarea>
121
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
+
122
254
  {# ── Password ── #}
123
255
  {% elif field.type == 'password' %}
124
256
  <div style="position:relative">
125
- <input
126
- type="password"
127
- id="field-{{ field.name }}"
128
- name="{{ field.name }}"
257
+ <input type="password" id="field-{{ field.name }}" name="{{ field.name }}"
129
258
  class="form-control{% if hasError %} error{% endif %}"
130
259
  placeholder="{{ 'Leave blank to keep current' if isEdit else '' }}"
260
+ {% if not field.nullable and not isEdit %}data-required="true"{% endif %}
131
261
  style="padding-right:40px">
132
- <button type="button"
133
- onclick="togglePassword(this)"
134
- style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:var(--text-muted);display:flex;align-items:center">
135
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
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">
136
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"/>
137
266
  </svg>
138
267
  </button>
@@ -140,89 +269,130 @@
140
269
 
141
270
  {# ── Email ── #}
142
271
  {% elif field.type == 'email' %}
143
- <input
144
- type="email"
145
- id="field-{{ field.name }}"
146
- name="{{ field.name }}"
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 }}"
147
291
  value="{{ val }}"
148
292
  class="form-control{% if hasError %} error{% endif %}"
149
- placeholder="{{ field.placeholder or 'email@example.com' }}">
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>
150
310
 
151
311
  {# ── Number ── #}
152
312
  {% elif field.type == 'number' %}
153
- <input
154
- type="number"
155
- id="field-{{ field.name }}"
156
- name="{{ field.name }}"
313
+ <input type="number" id="field-{{ field.name }}" name="{{ field.name }}"
157
314
  value="{{ val }}"
158
315
  class="form-control{% if hasError %} error{% endif %}"
159
- placeholder="{{ field.placeholder or '0' }}">
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 %}>
160
320
 
161
321
  {# ── Date ── #}
162
322
  {% elif field.type == 'date' %}
163
- <input
164
- type="date"
165
- id="field-{{ field.name }}"
166
- name="{{ field.name }}"
323
+ <input type="date" id="field-{{ field.name }}" name="{{ field.name }}"
167
324
  value="{{ val }}"
168
- class="form-control{% if hasError %} error{% endif %}">
325
+ class="form-control{% if hasError %} error{% endif %}"
326
+ {% if not field.nullable %}data-required="true"{% endif %}>
169
327
 
170
328
  {# ── Datetime ── #}
171
329
  {% elif field.type == 'datetime' %}
172
- <input
173
- type="datetime-local"
174
- id="field-{{ field.name }}"
175
- name="{{ field.name }}"
330
+ <input type="datetime-local" id="field-{{ field.name }}" name="{{ field.name }}"
176
331
  value="{{ val }}"
177
- class="form-control{% if hasError %} error{% endif %}">
332
+ class="form-control{% if hasError %} error{% endif %}"
333
+ {% if not field.nullable %}data-required="true"{% endif %}>
178
334
 
179
335
  {# ── Default text ── #}
180
336
  {% else %}
181
- <input
182
- type="text"
183
- id="field-{{ field.name }}"
184
- name="{{ field.name }}"
337
+ <input type="text" id="field-{{ field.name }}" name="{{ field.name }}"
185
338
  value="{{ val }}"
186
339
  class="form-control{% if hasError %} error{% endif %}"
187
- placeholder="{{ field.placeholder or '' }}">
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 %}>
188
344
  {% endif %}
189
345
 
190
346
  {# ── Error / help ── #}
191
- {% if hasError %}
192
- <span class="form-error">
193
- <svg width="12" height="12" 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>
194
- {{ errors[field.name][0] if errors[field.name] is iterable and errors[field.name][0] is string else errors[field.name] }}
195
- </span>
196
- {% elif field.help %}
197
- <span class="form-help">{{ field.help }}</span>
198
- {% endif %}
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>
199
357
  </div>
358
+ {% endif %}
359
+
200
360
  {% endfor %}
201
361
  </div>
362
+ </div>
363
+ </div>
364
+ {% endif %}
202
365
 
203
- {# ── Form footer ── #}
204
- <div class="flex items-center gap-2" style="margin-top:24px;padding-top:20px;border-top:1px solid var(--border-soft)">
205
- <button type="submit" class="btn btn-primary" id="submit-btn">
206
- <span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-save"/></svg></span>
207
- {{ 'Save Changes' if isEdit else 'Create ' + resource.singular }}
208
- </button>
209
- <a href="{{ adminPrefix }}/{{ resource.slug }}" class="btn btn-ghost">Cancel</a>
210
-
211
- {% if isEdit and resource.canDelete %}
212
- <button type="button" class="btn btn-danger" style="margin-left:auto"
213
- onclick="confirmDelete('{{ adminPrefix }}/{{ resource.slug }}/{{ record.id }}/delete', '{{ resource.singular }} #{{ record.id }}')">
214
- <span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
215
- Delete
216
- </button>
217
- {% endif %}
218
- </div>
219
- </form>
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 %}
220
389
  </div>
221
- </div>
222
390
 
223
- {# ── Timestamps readonly ── #}
391
+ </form>
392
+
393
+ {# ── Timestamps ── #}
224
394
  {% if isEdit and (record.created_at or record.updated_at) %}
225
- <div class="flex gap-4" style="margin-top:14px;padding:0 2px">
395
+ <div class="flex gap-4" style="margin-top:12px;padding:0 2px">
226
396
  {% if record.created_at %}
227
397
  <span class="text-xs text-muted flex items-center gap-1">
228
398
  <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"/><polyline points="12 6 12 12 16 14"/></svg>
@@ -239,24 +409,300 @@
239
409
  {% endif %}
240
410
  </div>
241
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
+
242
521
  <script>
243
- function togglePassword(btn) {
244
- const input = btn.previousElementSibling || btn.closest('div').querySelector('input');
245
- if (!input) return;
246
- input.type = input.type === 'password' ? 'text' : 'password';
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>` : ''; }
247
567
  }
248
568
 
249
- // Prevent double-submit
250
- document.getElementById('record-form')?.addEventListener('submit', function() {
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
+
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
251
646
  const btn = document.getElementById('submit-btn');
252
647
  if (btn) {
253
648
  btn.disabled = true;
254
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…`;
255
650
  }
256
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
+ })();
257
693
  </script>
258
694
 
259
695
  <style>
260
- @keyframes spin { to { transform: rotate(360deg); } }
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
+ }
261
707
  </style>
262
708
  {% endblock %}