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.
- package/package.json +1 -1
- package/src/admin/Admin.js +241 -62
- package/src/admin/AdminAuth.js +281 -0
- package/src/admin/index.js +6 -1
- package/src/admin/resources/AdminResource.js +180 -29
- package/src/admin/views/layouts/base.njk +38 -1
- package/src/admin/views/pages/detail.njk +322 -0
- package/src/admin/views/pages/form.njk +571 -125
- package/src/admin/views/pages/list.njk +454 -0
- package/src/admin/views/pages/login.njk +354 -0
|
@@ -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
|
|
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
|
|
32
|
+
<span class="breadcrumb-current">{{ 'Edit #' + record.id if isEdit else 'New' }}</span>
|
|
27
33
|
</div>
|
|
28
34
|
|
|
29
|
-
<div style="max-width:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
<
|
|
92
|
-
type="checkbox"
|
|
93
|
-
|
|
94
|
-
|
|
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="
|
|
134
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 '
|
|
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
|
-
{
|
|
192
|
-
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
//
|
|
250
|
-
document.
|
|
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
|
-
|
|
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 %}
|