millas 0.2.12-beta-1 → 0.2.13-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +516 -199
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +318 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +393 -97
- package/src/admin/static/admin.css +1422 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +87 -1046
- package/src/admin/views/pages/detail.njk +56 -21
- package/src/admin/views/pages/error.njk +65 -0
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +270 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +480 -0
- package/src/admin/views/partials/form-widget.njk +297 -0
- package/src/admin/views/partials/icons.njk +64 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/ai/AIManager.js +954 -0
- package/src/ai/AITokenBudget.js +250 -0
- package/src/ai/PromptGuard.js +216 -0
- package/src/ai/agents.js +218 -0
- package/src/ai/conversation.js +213 -0
- package/src/ai/drivers.js +734 -0
- package/src/ai/files.js +249 -0
- package/src/ai/media.js +303 -0
- package/src/ai/pricing.js +152 -0
- package/src/ai/provider_tools.js +114 -0
- package/src/ai/types.js +356 -0
- package/src/auth/Auth.js +18 -2
- package/src/auth/AuthUser.js +65 -44
- package/src/cli.js +3 -1
- package/src/commands/createsuperuser.js +267 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +3 -4
- package/src/container/AppInitializer.js +101 -20
- package/src/container/Application.js +31 -1
- package/src/container/MillasApp.js +10 -3
- package/src/container/MillasConfig.js +35 -6
- package/src/core/admin.js +5 -0
- package/src/core/db.js +2 -1
- package/src/core/foundation.js +2 -10
- package/src/core/lang.js +1 -0
- package/src/errors/HttpError.js +32 -16
- package/src/facades/AI.js +411 -0
- package/src/facades/Hash.js +67 -0
- package/src/facades/Process.js +144 -0
- package/src/hashing/Hash.js +262 -0
- package/src/http/HtmlEscape.js +162 -0
- package/src/http/MillasRequest.js +63 -7
- package/src/http/MillasResponse.js +70 -4
- package/src/http/ResponseDispatcher.js +21 -27
- package/src/http/SafeFilePath.js +195 -0
- package/src/http/SafeRedirect.js +62 -0
- package/src/http/SecurityBootstrap.js +70 -0
- package/src/http/helpers.js +40 -125
- package/src/http/index.js +10 -1
- package/src/http/middleware/CsrfMiddleware.js +258 -0
- package/src/http/middleware/RateLimiter.js +314 -0
- package/src/http/middleware/SecurityHeaders.js +281 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +643 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/logger/LogRedactor.js +247 -0
- package/src/logger/Logger.js +1 -1
- package/src/logger/formatters/JsonFormatter.js +11 -4
- package/src/logger/formatters/PrettyFormatter.js +103 -65
- package/src/logger/formatters/SimpleFormatter.js +14 -3
- package/src/middleware/ThrottleMiddleware.js +27 -4
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +143 -74
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/process/Process.js +333 -0
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +40 -5
- package/src/providers/CacheStorageServiceProvider.js +2 -2
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/LogServiceProvider.js +4 -1
- package/src/providers/MailServiceProvider.js +1 -1
- package/src/providers/QueueServiceProvider.js +1 -1
- package/src/router/MiddlewareRegistry.js +27 -2
- package/src/scaffold/templates.js +80 -21
- package/src/validation/Validator.js +348 -607
|
@@ -74,19 +74,19 @@
|
|
|
74
74
|
</button>
|
|
75
75
|
{% endif %}
|
|
76
76
|
|
|
77
|
-
{# Export dropdown #}
|
|
78
|
-
<div class="
|
|
79
|
-
<button class="btn btn-ghost btn-sm
|
|
77
|
+
{# Export dropdown — portal-rendered via UI.Dropdown #}
|
|
78
|
+
<div class="ui-menu" id="export-menu-wrap">
|
|
79
|
+
<button class="btn btn-ghost btn-sm" id="export-menu-btn" type="button">
|
|
80
80
|
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-download"/></svg></span>
|
|
81
81
|
Export
|
|
82
82
|
<span class="icon icon-12"><svg viewBox="0 0 24 24"><use href="#ic-chevron-down"/></svg></span>
|
|
83
83
|
</button>
|
|
84
|
-
<div class="
|
|
85
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}/export.csv?search={{ search }}&sort={{ sort }}&order={{ order }}{% for key, val in activeFilters %}&filter[{{ key }}]={{ val }}{% endfor %}">
|
|
84
|
+
<div class="ui-menu-panel" id="export-menu-panel" style="display:none">
|
|
85
|
+
<a class="ui-menu-item" href="{{ adminPrefix }}/{{ resource.slug }}/export.csv?search={{ search }}&sort={{ sort }}&order={{ order }}{% for key, val in activeFilters %}&filter[{{ key }}]={{ val }}{% endfor %}">
|
|
86
86
|
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-download"/></svg></span>
|
|
87
87
|
Export CSV
|
|
88
88
|
</a>
|
|
89
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}/export.json?search={{ search }}&sort={{ sort }}&order={{ order }}{% for key, val in activeFilters %}&filter[{{ key }}]={{ val }}{% endfor %}">
|
|
89
|
+
<a class="ui-menu-item" href="{{ adminPrefix }}/{{ resource.slug }}/export.json?search={{ search }}&sort={{ sort }}&order={{ order }}{% for key, val in activeFilters %}&filter[{{ key }}]={{ val }}{% endfor %}">
|
|
90
90
|
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-download"/></svg></span>
|
|
91
91
|
Export JSON
|
|
92
92
|
</a>
|
|
@@ -99,10 +99,18 @@
|
|
|
99
99
|
<div class="bulk-bar" id="bulk-bar">
|
|
100
100
|
<span class="bulk-count" id="bulk-count">0 selected</span>
|
|
101
101
|
<span class="text-muted" style="font-size:12px">—</span>
|
|
102
|
+
{% for action in resource.actions %}
|
|
103
|
+
<button class="btn btn-ghost btn-sm" onclick="bulkAction({{ action.index }})">
|
|
104
|
+
{% if action.icon %}<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-{{ action.icon }}"/></svg></span>{% endif %}
|
|
105
|
+
{{ action.label }}
|
|
106
|
+
</button>
|
|
107
|
+
{% endfor %}
|
|
108
|
+
{% if resource.canDelete %}
|
|
102
109
|
<button class="btn btn-ghost btn-sm btn-danger" onclick="bulkDelete()">
|
|
103
110
|
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
|
|
104
111
|
Delete selected
|
|
105
112
|
</button>
|
|
113
|
+
{% endif %}
|
|
106
114
|
<button class="btn btn-ghost btn-sm" style="margin-left:auto" onclick="clearSelection()">
|
|
107
115
|
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-x"/></svg></span>
|
|
108
116
|
Cancel
|
|
@@ -186,7 +194,7 @@
|
|
|
186
194
|
{% endif %}
|
|
187
195
|
</th>
|
|
188
196
|
{% endfor %}
|
|
189
|
-
{% if resource.canEdit or resource.canDelete %}
|
|
197
|
+
{% if resource.canEdit or resource.canDelete or resource.rowActions | length %}
|
|
190
198
|
<th class="col-actions">Actions</th>
|
|
191
199
|
{% endif %}
|
|
192
200
|
</tr>
|
|
@@ -200,10 +208,10 @@
|
|
|
200
208
|
{% for field in listFields %}
|
|
201
209
|
<td class="{% if loop.first %}td-primary{% endif %}">{{ row[field.name] | adminCell(field) | safe }}</td>
|
|
202
210
|
{% endfor %}
|
|
203
|
-
{% if resource.canEdit or resource.canDelete %}
|
|
211
|
+
{% if resource.canEdit or resource.canDelete or resource.rowActions | length %}
|
|
204
212
|
<td class="col-actions">
|
|
205
|
-
<div class="
|
|
206
|
-
<button class="action-menu-btn">
|
|
213
|
+
<div class="ui-menu">
|
|
214
|
+
<button class="action-menu-btn ui-menu-trigger" type="button" aria-label="Row actions">
|
|
207
215
|
<span class="icon icon-14">
|
|
208
216
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
209
217
|
<circle cx="12" cy="5" r="1.2" fill="currentColor" stroke="none"/>
|
|
@@ -212,20 +220,36 @@
|
|
|
212
220
|
</svg>
|
|
213
221
|
</span>
|
|
214
222
|
</button>
|
|
215
|
-
<div class="
|
|
223
|
+
<div class="ui-menu-panel" style="display:none">
|
|
216
224
|
{% if resource.canEdit %}
|
|
217
|
-
<a href="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/edit">
|
|
225
|
+
<a class="ui-menu-item" href="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/edit">
|
|
218
226
|
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-edit"/></svg></span>
|
|
219
227
|
Edit
|
|
220
228
|
</a>
|
|
221
229
|
{% endif %}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
230
|
+
{% if row._rowActions | length %}
|
|
231
|
+
{% if resource.canEdit %}<div class="ui-menu-sep"></div>{% endif %}
|
|
232
|
+
{% for ra in row._rowActions %}
|
|
233
|
+
{% if ra.href %}
|
|
234
|
+
<a class="ui-menu-item" href="{{ ra.href }}" target="_blank" rel="noopener">
|
|
235
|
+
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-{{ ra.icon or 'eye' }}"/></svg></span>
|
|
236
|
+
{{ ra.label }}
|
|
225
237
|
</a>
|
|
238
|
+
{% elif ra.action %}
|
|
239
|
+
<button class="ui-menu-item" type="button"
|
|
240
|
+
data-row-action="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/action/{{ ra.action }}"
|
|
241
|
+
data-row-action-label="{{ ra.label }}">
|
|
242
|
+
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-{{ ra.icon or 'check' }}"/></svg></span>
|
|
243
|
+
{{ ra.label }}
|
|
244
|
+
</button>
|
|
245
|
+
{% endif %}
|
|
246
|
+
{% endfor %}
|
|
247
|
+
{% endif %}
|
|
226
248
|
{% if resource.canDelete %}
|
|
227
|
-
<div class="sep"></div>
|
|
228
|
-
<button class="danger"
|
|
249
|
+
<div class="ui-menu-sep"></div>
|
|
250
|
+
<button class="ui-menu-item ui-menu-danger" type="button"
|
|
251
|
+
data-confirm-delete="{{ adminPrefix }}/{{ resource.slug }}/{{ row.id }}/delete"
|
|
252
|
+
data-confirm-label="#{{ row.id }}">
|
|
229
253
|
<span class="icon icon-13"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
|
|
230
254
|
Delete
|
|
231
255
|
</button>
|
|
@@ -300,60 +324,244 @@
|
|
|
300
324
|
</div>
|
|
301
325
|
|
|
302
326
|
<script>
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (filterPanel) filterPanel.style.display = 'flex';
|
|
307
|
-
{% endif %}
|
|
327
|
+
$(function() {
|
|
328
|
+
var PREFIX = '{{ adminPrefix }}';
|
|
329
|
+
var SLUG = '{{ resource.slug }}';
|
|
308
330
|
|
|
309
|
-
|
|
310
|
-
if (
|
|
311
|
-
const shown = filterPanel.style.display !== 'none';
|
|
312
|
-
filterPanel.style.display = shown ? 'none' : 'flex';
|
|
313
|
-
}
|
|
331
|
+
// ── Filter panel ─────────────────────────────────────────────────────────
|
|
332
|
+
{% if activeFilters | length %}$('#filter-panel').show();{% endif %}
|
|
314
333
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
334
|
+
window.toggleFilters = function() {
|
|
335
|
+
$('#filter-panel').toggle();
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// ── Live search on Enter ──────────────────────────────────────────────────
|
|
339
|
+
$('#search-form input[name="search"]').on('keydown', function(e) {
|
|
340
|
+
if (e.key === 'Enter') $('#search-form').submit();
|
|
320
341
|
});
|
|
321
|
-
}
|
|
322
342
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
343
|
+
// ── Bulk selection ────────────────────────────────────────────────────────
|
|
344
|
+
function updateBulkBar() {
|
|
345
|
+
var $checked = $('.item-check:checked');
|
|
346
|
+
var n = $checked.length;
|
|
347
|
+
var total = $('.item-check').length;
|
|
348
|
+
$('#bulk-bar').toggleClass('visible', n > 0);
|
|
349
|
+
$('#bulk-count').text(n + ' selected');
|
|
350
|
+
$('#check-all').prop('indeterminate', n > 0 && n < total)
|
|
351
|
+
.prop('checked', n > 0 && n === total);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
window.toggleAll = function(master) {
|
|
355
|
+
$('.item-check').prop('checked', master.checked);
|
|
356
|
+
updateBulkBar();
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
window.clearSelection = function() {
|
|
360
|
+
$('.item-check, #check-all').prop('checked', false).prop('indeterminate', false);
|
|
361
|
+
updateBulkBar();
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
$(document).on('change', '.item-check', updateBulkBar);
|
|
365
|
+
|
|
366
|
+
window.bulkDelete = function() {
|
|
367
|
+
var ids = $('.item-check:checked').map(function() { return this.value; }).get();
|
|
368
|
+
if (!ids.length) return;
|
|
369
|
+
var label = ids.length + ' record' + (ids.length > 1 ? 's' : '');
|
|
370
|
+
confirmDeleteBulk(PREFIX + '/' + SLUG + '/bulk-delete', label, ids);
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
window.confirmDeleteBulk = function(url, label, ids) {
|
|
374
|
+
UI.Confirm.show({
|
|
375
|
+
title: 'Delete ' + label,
|
|
376
|
+
message: 'Delete <strong>' + label + '</strong>? This cannot be undone.',
|
|
377
|
+
confirm: 'Delete',
|
|
378
|
+
danger: true,
|
|
379
|
+
}).then(function(ok) {
|
|
380
|
+
if (!ok) return;
|
|
381
|
+
var csrf = $('meta[name="csrf-token"]').attr('content') || '';
|
|
382
|
+
var $form = $('<form method="POST">').attr('action', url);
|
|
383
|
+
$form.append('<input name="_csrf" value="' + csrf + '">');
|
|
384
|
+
$.each(ids, function(_, id) {
|
|
385
|
+
$form.append('<input type="hidden" name="ids[]" value="' + id + '">');
|
|
386
|
+
});
|
|
387
|
+
$form.appendTo('body').submit();
|
|
388
|
+
});
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
window.bulkAction = function(actionIndex) {
|
|
392
|
+
var ids = $('.item-check:checked').map(function() { return this.value; }).get();
|
|
393
|
+
if (!ids.length) return;
|
|
394
|
+
var csrf = $('meta[name="csrf-token"]').attr('content') || '';
|
|
395
|
+
var $form = $('<form method="POST">').attr('action', PREFIX + '/' + SLUG + '/bulk-action');
|
|
396
|
+
$form.append('<input name="_csrf" value="' + csrf + '">');
|
|
397
|
+
$form.append('<input type="hidden" name="actionIndex" value="' + actionIndex + '">');
|
|
398
|
+
$.each(ids, function(_, id) {
|
|
399
|
+
$form.append('<input type="hidden" name="ids[]" value="' + id + '">');
|
|
400
|
+
});
|
|
401
|
+
$form.appendTo('body').submit();
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// ── UI.Dropdown — portal-rendered action menus ────────────────────────────
|
|
405
|
+
// Wire export menu
|
|
406
|
+
var $exportBtn = $('#export-menu-btn');
|
|
407
|
+
var $exportPanel = $('#export-menu-panel');
|
|
408
|
+
if ($exportBtn.length && $exportPanel.length) {
|
|
409
|
+
var exportDd = UI.Dropdown.create({
|
|
410
|
+
anchor: $exportBtn[0],
|
|
411
|
+
content: $exportPanel[0],
|
|
412
|
+
placement: 'bottom-end',
|
|
413
|
+
offset: 4,
|
|
414
|
+
});
|
|
415
|
+
$exportBtn.on('click', function() { exportDd.toggle(); });
|
|
337
416
|
}
|
|
338
|
-
}
|
|
339
417
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
418
|
+
// Wire per-row action menus
|
|
419
|
+
$('.ui-menu').each(function() {
|
|
420
|
+
var $menu = $(this);
|
|
421
|
+
var $btn = $menu.find('.ui-menu-trigger');
|
|
422
|
+
var $panel = $menu.find('.ui-menu-panel');
|
|
423
|
+
if (!$btn.length || !$panel.length) return;
|
|
424
|
+
|
|
425
|
+
var dd = UI.Dropdown.create({
|
|
426
|
+
anchor: $btn[0],
|
|
427
|
+
content: $panel[0],
|
|
428
|
+
placement: 'bottom-end',
|
|
429
|
+
offset: 4,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
$btn.on('click', function(e) {
|
|
433
|
+
e.stopPropagation();
|
|
434
|
+
dd.toggle();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
$panel.find('[data-confirm-delete]').on('click', function() {
|
|
438
|
+
dd.close();
|
|
439
|
+
confirmDelete($(this).data('confirm-delete'), $(this).data('confirm-label'));
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
$panel.find('[data-row-action]').on('click', function() {
|
|
443
|
+
dd.close();
|
|
444
|
+
var url = $(this).data('row-action');
|
|
445
|
+
var label = $(this).data('row-action-label');
|
|
446
|
+
UI.Confirm.show({
|
|
447
|
+
title: label,
|
|
448
|
+
message: 'Run <strong>' + label + '</strong> on this record?',
|
|
449
|
+
confirm: label,
|
|
450
|
+
}).then(function(ok) {
|
|
451
|
+
if (!ok) return;
|
|
452
|
+
var csrf = $('meta[name="csrf-token"]').attr('content') || '';
|
|
453
|
+
var $form = $('<form method="POST">').attr('action', url);
|
|
454
|
+
$form.append('<input name="_csrf" value="' + csrf + '">');
|
|
455
|
+
$form.appendTo('body').submit();
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// ── Right-click on the row opens a context menu at the cursor ──────────
|
|
460
|
+
(function($menuPanel) {
|
|
461
|
+
var $row = $menuPanel.closest('tr');
|
|
462
|
+
|
|
463
|
+
$row.on('contextmenu', function(e) {
|
|
464
|
+
e.preventDefault();
|
|
465
|
+
e.stopPropagation();
|
|
466
|
+
|
|
467
|
+
// Remove any existing portal
|
|
468
|
+
$('.context-menu-portal').remove();
|
|
469
|
+
|
|
470
|
+
// Build a fresh portal from the panel's inner HTML (avoids clone display:none)
|
|
471
|
+
var $p = $('<div class="ui-menu-panel context-menu-portal"></div>');
|
|
472
|
+
$p.html($menuPanel.html());
|
|
473
|
+
$p.css({
|
|
474
|
+
position: 'fixed',
|
|
475
|
+
top: Math.min(e.clientY, window.innerHeight - 240) + 'px',
|
|
476
|
+
left: Math.min(e.clientX, window.innerWidth - 190) + 'px',
|
|
477
|
+
zIndex: 9999,
|
|
478
|
+
});
|
|
479
|
+
$('body').append($p);
|
|
480
|
+
|
|
481
|
+
// Wire handlers on the portal
|
|
482
|
+
$p.find('[data-confirm-delete]').on('click', function() {
|
|
483
|
+
$p.remove();
|
|
484
|
+
confirmDelete($(this).data('confirm-delete'), $(this).data('confirm-label'));
|
|
485
|
+
});
|
|
486
|
+
$p.find('[data-row-action]').on('click', function() {
|
|
487
|
+
$p.remove();
|
|
488
|
+
var _url = $(this).data('row-action');
|
|
489
|
+
var _label = $(this).data('row-action-label');
|
|
490
|
+
UI.Confirm.show({
|
|
491
|
+
title: _label,
|
|
492
|
+
message: 'Run <strong>' + _label + '</strong> on this record?',
|
|
493
|
+
confirm: _label,
|
|
494
|
+
}).then(function(ok) {
|
|
495
|
+
if (!ok) return;
|
|
496
|
+
var csrf = $('meta[name="csrf-token"]').attr('content') || '';
|
|
497
|
+
var $form = $('<form method="POST">').attr('action', _url);
|
|
498
|
+
$form.append('<input name="_csrf" value="' + csrf + '">');
|
|
499
|
+
$form.appendTo('body').submit();
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
$p.find('a.ui-menu-item').on('click', function() { $p.remove(); });
|
|
503
|
+
|
|
504
|
+
// Dismiss on next click anywhere or Escape
|
|
505
|
+
setTimeout(function() {
|
|
506
|
+
$(document).one('click.ctxmenu', function() { $p.remove(); });
|
|
507
|
+
}, 0);
|
|
508
|
+
$(document).one('keydown.ctxmenu', function(ev) {
|
|
509
|
+
if (ev.key === 'Escape') { $p.remove(); }
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
})($panel);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
</script>
|
|
516
|
+
|
|
517
|
+
<style>
|
|
518
|
+
/* ── UI Menu (portal dropdown) ── */
|
|
519
|
+
.ui-menu { position: relative; display: inline-block; }
|
|
520
|
+
|
|
521
|
+
.ui-menu-panel {
|
|
522
|
+
background: var(--surface);
|
|
523
|
+
border: 1px solid var(--border);
|
|
524
|
+
border-radius: var(--radius);
|
|
525
|
+
min-width: 150px;
|
|
526
|
+
overflow: hidden;
|
|
343
527
|
}
|
|
344
528
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
529
|
+
.ui-menu-item {
|
|
530
|
+
display: flex;
|
|
531
|
+
align-items: center;
|
|
532
|
+
gap: 8px;
|
|
533
|
+
padding: 8px 14px;
|
|
534
|
+
font-size: 13px;
|
|
535
|
+
color: var(--text-soft);
|
|
536
|
+
text-decoration: none;
|
|
537
|
+
background: none;
|
|
538
|
+
border: none;
|
|
539
|
+
width: 100%;
|
|
540
|
+
text-align: left;
|
|
541
|
+
cursor: pointer;
|
|
542
|
+
font-family: inherit;
|
|
543
|
+
transition: background .1s;
|
|
544
|
+
white-space: nowrap;
|
|
348
545
|
}
|
|
546
|
+
.ui-menu-item:hover { background: var(--surface2); color: var(--text); }
|
|
547
|
+
.ui-menu-danger { color: var(--danger); }
|
|
548
|
+
.ui-menu-danger:hover { background: var(--danger-bg); color: var(--danger); }
|
|
349
549
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
);
|
|
550
|
+
.ui-menu-sep { height: 1px; background: var(--border-soft); margin: 3px 0; }
|
|
551
|
+
|
|
552
|
+
/* ── Context menu portal ── */
|
|
553
|
+
.context-menu-portal {
|
|
554
|
+
background: var(--surface);
|
|
555
|
+
border: 1px solid var(--border);
|
|
556
|
+
border-radius: var(--radius);
|
|
557
|
+
min-width: 160px;
|
|
558
|
+
overflow: hidden;
|
|
559
|
+
box-shadow: 0 4px 16px rgba(0,0,0,.12), 0 1px 4px rgba(0,0,0,.08);
|
|
357
560
|
}
|
|
358
|
-
|
|
561
|
+
|
|
562
|
+
/* ── Right-click cursor hint on rows ── */
|
|
563
|
+
#data-table tbody tr { cursor: context-menu; }
|
|
564
|
+
#data-table tbody tr:hover { background: var(--surface2); }
|
|
565
|
+
</style>
|
|
566
|
+
|
|
359
567
|
{% endblock %}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{#
|
|
2
|
+
partials/form-field.njk
|
|
3
|
+
Renders a single editable form field — all widget types.
|
|
4
|
+
Expects in scope: field, val, hasError, isEdit, record, errors, adminPrefix
|
|
5
|
+
#}
|
|
6
|
+
|
|
7
|
+
{# ── Fieldset heading ── #}
|
|
8
|
+
{% if field._isFieldset %}
|
|
9
|
+
<div class="full" style="grid-column:1/-1;margin-top:8px">
|
|
10
|
+
<div class="fieldset-heading">{{ field.label }}</div>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
{# ── Readonly ── #}
|
|
14
|
+
{% elif field.isReadonly %}
|
|
15
|
+
{% include "partials/form-readonly.njk" %}
|
|
16
|
+
|
|
17
|
+
{# ── Editable ── #}
|
|
18
|
+
{% else %}
|
|
19
|
+
{% set val = record[field.name] if record[field.name] is defined else '' %}
|
|
20
|
+
{% set hasError = errors[field.name] is defined %}
|
|
21
|
+
<div class="form-group
|
|
22
|
+
{% if field.span == 'full' %}full
|
|
23
|
+
{% elif field.span == 'third' %}w-third
|
|
24
|
+
{% elif field.type == 'textarea' or field.type == 'json' or field.type == 'richtext' %}full
|
|
25
|
+
{% elif field.type == 'checkbox' %}w-third
|
|
26
|
+
{% endif %}"
|
|
27
|
+
data-field="{{ field.name }}">
|
|
28
|
+
|
|
29
|
+
{# Label (not shown for checkbox — toggle has its own) #}
|
|
30
|
+
{% if field.type != 'checkbox' %}
|
|
31
|
+
<label class="form-label" for="field-{{ field.name }}">
|
|
32
|
+
{{ field.label }}
|
|
33
|
+
{% if not field.nullable %}<span class="required">*</span>{% endif %}
|
|
34
|
+
</label>
|
|
35
|
+
{% endif %}
|
|
36
|
+
|
|
37
|
+
{% include "partials/form-widget.njk" %}
|
|
38
|
+
|
|
39
|
+
{# Error / help #}
|
|
40
|
+
<div class="field-feedback" id="feedback-{{ field.name }}"
|
|
41
|
+
{% if field.help %}data-help="{{ field.help }}"{% endif %}>
|
|
42
|
+
{% if hasError %}
|
|
43
|
+
<span class="form-error">
|
|
44
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
45
|
+
{{ errors[field.name][0] if errors[field.name] is iterable else errors[field.name] }}
|
|
46
|
+
</span>
|
|
47
|
+
{% elif field.help %}
|
|
48
|
+
<span class="form-help">{{ field.help }}</span>
|
|
49
|
+
{% endif %}
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
</div>
|
|
53
|
+
{% endif %}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{#
|
|
2
|
+
partials/form-footer.njk
|
|
3
|
+
Submit buttons, cancel, delete, and timestamps.
|
|
4
|
+
Expects in scope: isEdit, resource, record, adminPrefix
|
|
5
|
+
#}
|
|
6
|
+
<div class="flex items-center gap-2" style="margin-top:16px;flex-wrap:wrap">
|
|
7
|
+
<button type="submit" name="_submit" value="save" class="btn btn-primary" id="submit-btn">
|
|
8
|
+
<span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-save"/></svg></span>
|
|
9
|
+
{{ 'Save Changes' if isEdit else 'Create ' + resource.singular }}
|
|
10
|
+
</button>
|
|
11
|
+
<button type="submit" name="_submit" value="continue" class="btn btn-ghost btn-sm">
|
|
12
|
+
Save and continue editing
|
|
13
|
+
</button>
|
|
14
|
+
{% if not isEdit %}
|
|
15
|
+
<button type="submit" name="_submit" value="add_another" class="btn btn-ghost btn-sm">
|
|
16
|
+
Save and add another
|
|
17
|
+
</button>
|
|
18
|
+
{% endif %}
|
|
19
|
+
<a href="{{ adminPrefix }}/{{ resource.slug }}" class="btn btn-ghost">Cancel</a>
|
|
20
|
+
|
|
21
|
+
{% if isEdit and resource.canDelete %}
|
|
22
|
+
<button type="button" class="btn btn-danger" style="margin-left:auto"
|
|
23
|
+
onclick="confirmDelete('{{ adminPrefix }}/{{ resource.slug }}/{{ record.id }}/delete', '{{ resource.singular }} #{{ record.id }}')">
|
|
24
|
+
<span class="icon icon-14"><svg viewBox="0 0 24 24"><use href="#ic-trash"/></svg></span>
|
|
25
|
+
Delete
|
|
26
|
+
</button>
|
|
27
|
+
{% endif %}
|
|
28
|
+
</div>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
{#
|
|
2
|
+
partials/form-readonly.njk
|
|
3
|
+
Renders a single readonly field as disabled input.
|
|
4
|
+
Expects in scope: field, record
|
|
5
|
+
#}
|
|
6
|
+
|
|
7
|
+
<div class="form-group {% if field.span == 'full' or field.type == 'textarea' or field.type == 'json' or field.type == 'richtext' %}full{% elif field.span == 'third' %}w-third{% endif %}">
|
|
8
|
+
<label class="form-label" for="field-{{ field.name }}">{{ field.label }}</label>
|
|
9
|
+
|
|
10
|
+
{# ── Select ── #}
|
|
11
|
+
{% if field.type == 'select' and field.options %}
|
|
12
|
+
<select class="form-control" disabled>
|
|
13
|
+
{% if val %}
|
|
14
|
+
{% for opt in field.options %}
|
|
15
|
+
{% set optVal = opt.value if opt.value is defined else opt %}
|
|
16
|
+
{% set optLabel = opt.label if opt.label is defined else opt %}
|
|
17
|
+
<option {% if val == optVal %}selected{% endif %}>{{ optLabel }}</option>
|
|
18
|
+
{% endfor %}
|
|
19
|
+
{% else %}
|
|
20
|
+
<option>— Select —</option>
|
|
21
|
+
{% endif %}
|
|
22
|
+
</select>
|
|
23
|
+
|
|
24
|
+
{# ── Checkbox ── #}
|
|
25
|
+
{% elif field.type == 'checkbox' %}
|
|
26
|
+
<div class="toggle-field">
|
|
27
|
+
<label class="toggle-wrap" style="opacity:0.6;cursor:not-allowed">
|
|
28
|
+
<input type="checkbox" disabled {{ 'checked' if val else '' }} class="toggle-input">
|
|
29
|
+
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
|
30
|
+
<span class="toggle-label">{{ field.label }}</span>
|
|
31
|
+
</label>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
{# ── Badge ── #}
|
|
35
|
+
{% elif field.type == 'badge' %}
|
|
36
|
+
{% set _bc = field.colors[val] if (field.colors and val and field.colors[val]) else 'gray' %}
|
|
37
|
+
<span class="badge badge-{{ _bc if val else 'gray' }}">{{ val if val else 'Not set' }}</span>
|
|
38
|
+
|
|
39
|
+
{# ── Textarea ── #}
|
|
40
|
+
{% elif field.type == 'textarea' %}
|
|
41
|
+
<textarea class="form-control" disabled readonly rows="4" placeholder="{{ field.placeholder or '' }}">{{ val }}</textarea>
|
|
42
|
+
|
|
43
|
+
{# ── JSON ── #}
|
|
44
|
+
{% elif field.type == 'json' %}
|
|
45
|
+
<textarea class="form-control" disabled readonly rows="6" style="font-family:monospace;font-size:12px">{{ val | dump if val else '' }}</textarea>
|
|
46
|
+
|
|
47
|
+
{# ── Richtext ── #}
|
|
48
|
+
{% elif field.type == 'richtext' %}
|
|
49
|
+
<div class="form-control" style="min-height:120px;background:var(--surface-muted);cursor:not-allowed" disabled>{{ val | safe if val else '' }}</div>
|
|
50
|
+
|
|
51
|
+
{# ── Date ── #}
|
|
52
|
+
{% elif field.type == 'date' %}
|
|
53
|
+
<div class="dp-wrap">
|
|
54
|
+
<input type="text" class="form-control dp-trigger" value="{{ val }}" placeholder="Select date…" disabled readonly>
|
|
55
|
+
<span class="dp-icon" style="opacity:0.5">
|
|
56
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
57
|
+
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
|
58
|
+
</svg>
|
|
59
|
+
</span>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{# ── Datetime ── #}
|
|
63
|
+
{% elif field.type == 'datetime' or field.type == 'datetime-local' %}
|
|
64
|
+
<div class="dp-wrap">
|
|
65
|
+
<input type="text" class="form-control dp-trigger" value="{{ val }}" placeholder="Select date and time…" disabled readonly>
|
|
66
|
+
<span class="dp-icon" style="opacity:0.5">
|
|
67
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
68
|
+
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
|
69
|
+
</svg>
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{# ── Email ── #}
|
|
74
|
+
{% elif field.type == 'email' %}
|
|
75
|
+
<input type="email" class="form-control" value="{{ val }}" placeholder="{{ field.placeholder or 'name@example.com' }}" disabled readonly>
|
|
76
|
+
|
|
77
|
+
{# ── URL ── #}
|
|
78
|
+
{% elif field.type == 'url' %}
|
|
79
|
+
<input type="url" class="form-control" value="{{ val }}" placeholder="{{ field.placeholder or 'https://' }}" disabled readonly>
|
|
80
|
+
|
|
81
|
+
{# ── Phone ── #}
|
|
82
|
+
{% elif field.type == 'phone' %}
|
|
83
|
+
<input type="tel" class="form-control" value="{{ val }}" placeholder="{{ field.placeholder or '+1 555 000 0000' }}" disabled readonly>
|
|
84
|
+
|
|
85
|
+
{# ── Color ── #}
|
|
86
|
+
{% elif field.type == 'color' %}
|
|
87
|
+
<div class="flex items-center gap-2">
|
|
88
|
+
<input type="color" value="{{ val or '#000000' }}" disabled style="width:40px;height:36px;border:1px solid var(--border);border-radius:var(--radius-sm);cursor:not-allowed;padding:2px;opacity:0.6">
|
|
89
|
+
<input type="text" class="form-control" value="{{ val }}" placeholder="#000000" style="width:120px" disabled readonly>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{# ── Number ── #}
|
|
93
|
+
{% elif field.type == 'number' %}
|
|
94
|
+
<input type="number" class="form-control" value="{{ val }}" placeholder="{{ field.placeholder or '0' }}" disabled readonly>
|
|
95
|
+
|
|
96
|
+
{# ── Password ── #}
|
|
97
|
+
{% elif field.type == 'password' %}
|
|
98
|
+
<input type="password" class="form-control" value="{{ '••••••••' if val else '' }}" placeholder="{{ field.placeholder or '' }}" disabled readonly>
|
|
99
|
+
|
|
100
|
+
{# ── Image ── #}
|
|
101
|
+
{% elif field.type == 'image' %}
|
|
102
|
+
<div>
|
|
103
|
+
{% if val %}
|
|
104
|
+
<img src="{{ val }}" style="width:64px;height:64px;object-fit:cover;border-radius:var(--radius-sm);border:1px solid var(--border);display:block;margin-bottom:8px" alt="">
|
|
105
|
+
{% endif %}
|
|
106
|
+
<input type="text" class="form-control" value="{{ val }}" placeholder="https://example.com/image.jpg" disabled readonly>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{# ── Default text ── #}
|
|
110
|
+
{% else %}
|
|
111
|
+
<input type="text" class="form-control" value="{{ val }}" placeholder="{{ field.placeholder or '' }}" disabled readonly>
|
|
112
|
+
|
|
113
|
+
{% endif %}
|
|
114
|
+
</div>
|