millas 0.2.13 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/package.json +6 -3
  2. package/src/admin/Admin.js +107 -1027
  3. package/src/admin/AdminAuth.js +1 -1
  4. package/src/admin/ViewContext.js +1 -1
  5. package/src/admin/handlers/ActionHandler.js +103 -0
  6. package/src/admin/handlers/ApiHandler.js +113 -0
  7. package/src/admin/handlers/AuthHandler.js +76 -0
  8. package/src/admin/handlers/ExportHandler.js +70 -0
  9. package/src/admin/handlers/InlineHandler.js +71 -0
  10. package/src/admin/handlers/PageHandler.js +351 -0
  11. package/src/admin/resources/AdminResource.js +22 -1
  12. package/src/admin/static/SelectFilter2.js +34 -0
  13. package/src/admin/static/actions.js +201 -0
  14. package/src/admin/static/admin.css +7 -0
  15. package/src/admin/static/change_form.js +585 -0
  16. package/src/admin/static/core.js +128 -0
  17. package/src/admin/static/login.js +76 -0
  18. package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
  19. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
  20. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
  21. package/src/admin/static/vendor/jquery.min.js +2 -0
  22. package/src/admin/views/layouts/base.njk +30 -113
  23. package/src/admin/views/pages/detail.njk +10 -9
  24. package/src/admin/views/pages/form.njk +4 -4
  25. package/src/admin/views/pages/list.njk +11 -193
  26. package/src/admin/views/pages/login.njk +19 -64
  27. package/src/admin/views/partials/form-field.njk +1 -1
  28. package/src/admin/views/partials/form-scripts.njk +4 -478
  29. package/src/admin/views/partials/form-widget.njk +10 -10
  30. package/src/ai/AITokenBudget.js +1 -1
  31. package/src/auth/Auth.js +112 -3
  32. package/src/auth/AuthMiddleware.js +18 -15
  33. package/src/auth/Hasher.js +15 -43
  34. package/src/cli.js +3 -0
  35. package/src/commands/call.js +190 -0
  36. package/src/commands/createsuperuser.js +3 -4
  37. package/src/commands/key.js +97 -0
  38. package/src/commands/make.js +16 -2
  39. package/src/commands/new.js +16 -1
  40. package/src/commands/serve.js +5 -5
  41. package/src/console/Command.js +337 -0
  42. package/src/console/CommandLoader.js +165 -0
  43. package/src/console/index.js +6 -0
  44. package/src/container/AppInitializer.js +48 -1
  45. package/src/container/Application.js +3 -1
  46. package/src/container/HttpServer.js +0 -1
  47. package/src/container/MillasConfig.js +48 -0
  48. package/src/controller/Controller.js +13 -11
  49. package/src/core/docs.js +6 -0
  50. package/src/core/foundation.js +8 -0
  51. package/src/core/http.js +20 -10
  52. package/src/core/validation.js +58 -27
  53. package/src/docs/Docs.js +268 -0
  54. package/src/docs/DocsServiceProvider.js +80 -0
  55. package/src/docs/SchemaInferrer.js +131 -0
  56. package/src/docs/handlers/ApiHandler.js +305 -0
  57. package/src/docs/handlers/PageHandler.js +47 -0
  58. package/src/docs/index.js +13 -0
  59. package/src/docs/resources/ApiResource.js +402 -0
  60. package/src/docs/static/docs.css +723 -0
  61. package/src/docs/static/docs.js +1181 -0
  62. package/src/encryption/Encrypter.js +381 -0
  63. package/src/facades/Auth.js +5 -2
  64. package/src/facades/Crypt.js +166 -0
  65. package/src/facades/Docs.js +43 -0
  66. package/src/facades/Mail.js +1 -1
  67. package/src/http/MillasRequest.js +7 -31
  68. package/src/http/RequestContext.js +11 -7
  69. package/src/http/SecurityBootstrap.js +24 -2
  70. package/src/http/Shape.js +168 -0
  71. package/src/http/adapters/ExpressAdapter.js +9 -5
  72. package/src/middleware/CorsMiddleware.js +3 -0
  73. package/src/middleware/ThrottleMiddleware.js +10 -7
  74. package/src/orm/model/Model.js +14 -1
  75. package/src/providers/EncryptionServiceProvider.js +66 -0
  76. package/src/router/MiddlewareRegistry.js +79 -54
  77. package/src/router/Route.js +9 -4
  78. package/src/router/RouteEntry.js +91 -0
  79. package/src/router/Router.js +71 -1
  80. package/src/scaffold/maker.js +138 -1
  81. package/src/scaffold/templates.js +12 -0
  82. package/src/serializer/Serializer.js +239 -0
  83. package/src/support/Str.js +1080 -0
  84. package/src/validation/BaseValidator.js +45 -5
  85. package/src/validation/Validator.js +67 -61
  86. package/src/validation/types.js +490 -0
  87. package/src/middleware/AuthMiddleware.js +0 -46
  88. package/src/middleware/MiddlewareRegistry.js +0 -106
@@ -1,480 +1,6 @@
1
1
  <script>
2
- // ── Tab switching ──────────────────────────────────────────────
3
- // Convert a tab name to a CSS-safe id fragment.
4
- // Strips everything that is not a letter, digit, underscore, or hyphen.
5
- // 'Role & Access' → 'Role--Access' → 'role--access' (safe in selectors)
6
- function _tabId(name) {
7
- return name.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
8
- }
9
-
10
- window.switchFormTab = function(name, btn) {
11
- $('.tab-btn').removeClass('active');
12
- $('.tab-form-panel').removeClass('active').hide();
13
- $(btn).addClass('active');
14
- $('#fpanel-' + _tabId(name)).addClass('active').show();
15
- };
16
-
17
- // ── Richtext sync ─────────────────────────────────────────────
18
- window.rtCmd = function(cmd) { document.execCommand(cmd, false, null); };
19
- window.rtLink = function() {
20
- var url = prompt('Enter URL:');
21
- if (url) document.execCommand('createLink', false, url);
22
- };
23
-
24
- $('.richtext-editor').each(function() {
25
- var fieldName = this.id.replace('rt-', '');
26
- var $hidden = $('#field-' + fieldName);
27
- $(this).on('input', function() { $hidden.val(this.innerHTML); });
28
- });
29
-
30
- // ── Client-side validation ─────────────────────────────────────
31
- function showFieldError(name, msg) {
32
- var errorIcon = '<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>';
33
- $('#field-' + name + ', [name="' + name + '"]').first().addClass('error');
34
- $('#feedback-' + name).html('<span class="form-error">' + errorIcon + ' ' + msg + '</span>');
35
- }
36
-
37
- function clearFieldError(name) {
38
- $('#field-' + name + ', [name="' + name + '"]').first().removeClass('error');
39
- var $fb = $('#feedback-' + name);
40
- var help = $fb.data('help');
41
- $fb.html(help ? '<span class="form-help">' + help + '</span>' : '');
42
- }
43
-
44
- function validateField(input) {
45
- var $input = $(input);
46
- var name = $input.attr('name');
47
- if (!name) return true;
48
- var val = $.trim($input.val());
49
- var required = $input.data('required') === true || $input.data('required') === 'true';
50
- var validate = $input.data('validate');
51
- clearFieldError(name);
52
-
53
- if (required && val === '') {
54
- var label = $('label[for="field-' + name + '"]').text().replace('*','').trim() || name;
55
- showFieldError(name, label + ' is required');
56
- return false;
57
- }
58
- if (val && validate === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) {
59
- showFieldError(name, 'Please enter a valid email address'); return false;
60
- }
61
- if (val && validate === 'url') {
62
- try { new URL(val); } catch(e) { showFieldError(name, 'Please enter a valid URL (include https://)'); return false; }
63
- }
64
- if (val && validate === 'json') {
65
- try { JSON.parse(val); } catch(e) { showFieldError(name, 'Invalid JSON format'); return false; }
66
- }
67
- var min = $input.data('min'), max = $input.data('max');
68
- if (val && min !== undefined && Number(val) < Number(min)) {
69
- showFieldError(name, 'Minimum value is ' + min); return false;
70
- }
71
- if (val && max !== undefined && Number(val) > Number(max)) {
72
- showFieldError(name, 'Maximum value is ' + max); return false;
73
- }
74
- return true;
75
- }
76
-
77
- // Live validation
78
- $(document).on('blur', '.form-control', function() { validateField(this); });
79
- $(document).on('input', '.form-control', function() {
80
- if ($(this).hasClass('error')) validateField(this);
81
- });
82
-
83
- // Submit validation
84
- $('#record-form').on('submit', function(e) {
85
- var valid = true, $first = null;
86
- $(this).find('.form-control').each(function() {
87
- if (!validateField(this)) { valid = false; if (!$first) $first = $(this); }
88
- });
89
- if (!valid) {
90
- e.preventDefault();
91
- $first.focus();
92
- $first[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
93
- return;
94
- }
95
- var $btn = $('#submit-btn');
96
- $btn.prop('disabled', true).html(
97
- '<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…'
98
- );
99
- });
100
-
101
- // Auto-switch to tab with first error
102
- var $firstErr = $('.form-control.error').first();
103
- if ($firstErr.length) {
104
- var $panel = $firstErr.closest('.tab-form-panel');
105
- if ($panel.length) {
106
- // Reconstruct the original tab name from data-tab attribute — not from the id.
107
- // The id has special chars stripped; data-tab has the original name.
108
- var $tabBtn = $('.tab-btn').filter(function() {
109
- return _tabId($(this).data('tab')) === $panel.attr('id').replace('fpanel-', '');
110
- }).first();
111
- if ($tabBtn.length) switchFormTab($tabBtn.data('tab'), $tabBtn[0]);
112
- }
113
- }
114
-
115
- // ── Prepopulate (slug auto-fill) ───────────────────────────────
116
- function slugify(str) {
117
- return str.toLowerCase().replace(/[^\w\s-]/g, '').replace(/[\s_]+/g, '-').replace(/^-+|-+$/g, '');
118
- }
119
- $('[data-prepopulate]').each(function() {
120
- var targetName = this.name;
121
- var sourceName = $(this).data('prepopulate');
122
- var $src = $('[name="' + sourceName + '"]');
123
- var $tgt = $('[name="' + targetName + '"]');
124
- if (!$src.length || !$tgt.length) return;
125
- var userEdited = !!$tgt.val();
126
- $tgt.on('input', function() { userEdited = true; });
127
- $src.on('input', function() { if (!userEdited) $tgt.val(slugify($src.val())); });
128
- });
129
-
130
- // ══════════════════════════════════════════════════════════════
131
- // FK DROPDOWN WIDGET
132
- // Self-contained — one instance per FK field on the page.
133
- // Supabase-style: async, paginated, searchable, keyboard nav.
134
- // ══════════════════════════════════════════════════════════════
135
- (function () {
136
- const ADMIN_PREFIX = {{ adminPrefix | dump | safe }};
137
- const SOURCE_RESOURCE = {{ resource.slug | dump | safe }};
138
- const PER_PAGE = 20;
139
- const DEBOUNCE_MS = 220;
140
-
141
- // Initialise every fk-widget — deferred so ui.js is guaranteed loaded.
142
- function _initAllFKWidgets() {
143
- document.querySelectorAll('.fk-widget').forEach(widget => initFKWidget(widget));
144
- }
145
- if (document.readyState === 'loading') {
146
- document.addEventListener('DOMContentLoaded', _initAllFKWidgets);
147
- } else {
148
- _initAllFKWidgets();
149
- }
150
-
151
- function initFKWidget(widget) {
152
- const name = widget.dataset.name;
153
- const resource = widget.dataset.resource;
154
- const fkField = widget.dataset.fkField || name;
155
- const nullable = widget.dataset.nullable === 'true';
156
- const currentId = widget.dataset.currentId || '';
157
-
158
- const hidden = document.getElementById(`field-${name}`);
159
- const trigger = document.getElementById(`fktrig-${name}`);
160
- const panel = document.getElementById(`fkpanel-${name}`);
161
- const list = document.getElementById(`fklist-${name}`);
162
- const search = document.getElementById(`fksearch-${name}`);
163
- const clearBtn = document.getElementById(`fkclear-${name}`);
164
- const footer = document.getElementById(`fkfoot-${name}`);
165
- const countEl = document.getElementById(`fkcount-${name}`);
166
- const pageInfo = document.getElementById(`fkpageinfo-${name}`);
167
- const prevBtn = document.getElementById(`fkprev-${name}`);
168
- const nextBtn = document.getElementById(`fknext-${name}`);
169
- const searchClear = widget.querySelector('.fk-search-clear');
170
-
171
- if (!resource) {
172
- console.warn('[FK Widget] field "' + name + '" has no fkResource — cannot load dropdown. Check that the referenced model is registered with Admin.register().');
173
- return;
174
- }
175
- if (!hidden || !trigger || !panel) {
176
- console.warn('[FK Widget] field "' + name + '" is missing DOM elements — widget will not initialise.');
177
- return;
178
- }
179
-
180
- // State
181
- let state = {
182
- open: false,
183
- query: '',
184
- page: 1,
185
- total: 0,
186
- data: [],
187
- loading: false,
188
- focusIndex: -1,
189
- selectedId: currentId,
190
- selectedLabel: '',
191
- };
192
-
193
- let debounceTimer = null;
194
-
195
- // ── If there's a current value, resolve its label immediately ──────────
196
- if (currentId) {
197
- fetch(`${ADMIN_PREFIX}/api/${resource}/options?q=&page=1&limit=100&field=${fkField}&from=${SOURCE_RESOURCE}`)
198
- .then(r => r.json())
199
- .then(json => {
200
- const match = (json.data || []).find(r => String(r.id) === String(currentId));
201
- if (match) {
202
- state.selectedLabel = match.label;
203
- renderTriggerLabel();
204
- }
205
- })
206
- .catch(() => {});
207
- }
208
-
209
- // ── Portal dropdown via UI.Dropdown ──────────────────────────────────────
210
- // Move the panel into the portal so it is never clipped by overflow:hidden
211
- // parents or broken by CSS transforms on ancestor elements.
212
- var dropdown = UI.Dropdown.create({
213
- anchor: trigger,
214
- content: panel,
215
- placement: 'bottom-start',
216
- offset: 0,
217
- minWidth: true,
218
- maxHeight: 320,
219
- className: 'fk-panel',
220
- onClose: function() {
221
- state.open = false;
222
- trigger.setAttribute('aria-expanded', 'false');
223
- state.focusIndex = -1;
224
- document.removeEventListener('keydown', globalKeydown);
225
- },
226
- });
227
-
228
- function open() {
229
- if (state.open) return;
230
- state.open = true;
231
- state.page = 1;
232
- trigger.setAttribute('aria-expanded', 'true');
233
- search.value = '';
234
- searchClear.hidden = true;
235
- state.query = '';
236
- dropdown.open();
237
- fetchOptions();
238
- requestAnimationFrame(function() { search.focus(); });
239
- document.addEventListener('keydown', globalKeydown);
240
- }
241
-
242
- function close() {
243
- if (!state.open) return;
244
- dropdown.close(); // onClose callback sets state.open = false
245
- }
246
-
247
- trigger.addEventListener('click', function() { state.open ? close() : open(); });
248
- trigger.addEventListener('keydown', function(e) {
249
- if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
250
- e.preventDefault();
251
- open();
252
- }
253
- });
254
-
255
- // ── Search ──────────────────────────────────────────────────────────────
256
- search.addEventListener('input', () => {
257
- const q = search.value;
258
- searchClear.hidden = !q;
259
- clearTimeout(debounceTimer);
260
- debounceTimer = setTimeout(() => {
261
- state.query = q;
262
- state.page = 1;
263
- fetchOptions();
264
- }, DEBOUNCE_MS);
265
- });
266
-
267
- searchClear.addEventListener('click', () => {
268
- search.value = '';
269
- searchClear.hidden = true;
270
- state.query = '';
271
- state.page = 1;
272
- fetchOptions();
273
- search.focus();
274
- });
275
-
276
- // ── Pagination ──────────────────────────────────────────────────────────
277
- prevBtn && prevBtn.addEventListener('click', () => {
278
- if (state.page > 1) { state.page--; fetchOptions(); }
279
- });
280
- nextBtn && nextBtn.addEventListener('click', () => {
281
- const totalPages = Math.ceil(state.total / PER_PAGE);
282
- if (state.page < totalPages) { state.page++; fetchOptions(); }
283
- });
284
-
285
- // ── Clear selection ─────────────────────────────────────────────────────
286
- clearBtn && clearBtn.addEventListener('click', () => {
287
- select(null, '');
288
- close();
289
- });
290
-
291
- // ── Fetch options from API ───────────────────────────────────────────────
292
- function fetchOptions() {
293
- state.loading = true;
294
- renderList();
295
-
296
- const params = new URLSearchParams({
297
- q: state.query,
298
- page: state.page,
299
- limit: PER_PAGE,
300
- field: fkField,
301
- from: SOURCE_RESOURCE,
302
- });
303
-
304
- fetch(`${ADMIN_PREFIX}/api/${resource}/options?${params}`)
305
- .then(r => {
306
- if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
307
- return r.json();
308
- })
309
- .then(json => {
310
- state.data = json.data || [];
311
- state.total = json.total || 0;
312
- state.loading = false;
313
- state.focusIndex = -1;
314
- // Update search placeholder with the resolved label column
315
- if (json.labelCol && search) {
316
- var col = json.labelCol.replace(/_/g, ' ');
317
- search.placeholder = 'Search by ' + col + '…';
318
- }
319
- renderList();
320
- renderFooter();
321
- })
322
- .catch((err) => {
323
- state.loading = false;
324
- state.data = [];
325
- console.error('[FK Widget] fetch error for resource "' + resource + '":', err);
326
- list.innerHTML = '<div class="fk-empty-row">Failed to load — check console</div>';
327
- });
328
- }
329
-
330
- // ── Render list ──────────────────────────────────────────────────────────
331
- function renderList() {
332
- if (state.loading) {
333
- // Skeleton loader — 3 placeholder rows that pulse
334
- list.innerHTML =
335
- '<div class="fk-skeleton-row"><span class="fk-skel fk-skel-chip"></span><span class="fk-skel fk-skel-text"></span></div>' +
336
- '<div class="fk-skeleton-row"><span class="fk-skel fk-skel-chip"></span><span class="fk-skel fk-skel-text" style="width:55%"></span></div>' +
337
- '<div class="fk-skeleton-row"><span class="fk-skel fk-skel-chip"></span><span class="fk-skel fk-skel-text" style="width:70%"></span></div>';
338
- return;
339
- }
340
-
341
- if (!state.data.length) {
342
- var emptyMsg = state.query
343
- ? 'No results for <strong style="color:var(--text)">&ldquo;' + escapeHtml(state.query) + '&rdquo;</strong>'
344
- : 'No records found';
345
- list.innerHTML =
346
- '<div class="fk-empty-row">' +
347
- '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--border);flex-shrink:0"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>' +
348
- '<span>' + emptyMsg + '</span>' +
349
- '</div>';
350
- return;
351
- }
352
-
353
- list.innerHTML = state.data.map((row, i) => {
354
- const isSelected = String(row.id) === String(state.selectedId);
355
- const isFocused = i === state.focusIndex;
356
- return `
357
- <div class="fk-option ${isSelected ? 'fk-selected' : ''} ${isFocused ? 'fk-focused' : ''}"
358
- role="option"
359
- aria-selected="${isSelected}"
360
- data-id="${row.id}"
361
- data-label="${escapeAttr(String(row.label ?? row.id))}">
362
- <span class="fk-id-chip">#${row.id}</span>
363
- <span class="fk-opt-label">${escapeHtml(String(row.label ?? row.id))}</span>
364
- ${isSelected ? `<svg class="fk-check" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>` : ''}
365
- </div>`;
366
- }).join('');
367
-
368
- // Attach click handlers
369
- list.querySelectorAll('.fk-option').forEach(opt => {
370
- opt.addEventListener('mousedown', e => {
371
- e.preventDefault(); // don't steal focus from search
372
- select(opt.dataset.id, opt.dataset.label);
373
- close();
374
- });
375
- opt.addEventListener('mousemove', () => {
376
- const idx = [...list.querySelectorAll('.fk-option')].indexOf(opt);
377
- setFocus(idx);
378
- });
379
- });
380
- }
381
-
382
- // ── Render footer ────────────────────────────────────────────────────────
383
- function renderFooter() {
384
- if (!footer) return;
385
- const totalPages = Math.ceil(state.total / PER_PAGE);
386
- if (state.total === 0) { footer.hidden = true; return; }
387
-
388
- footer.hidden = false;
389
- const from = (state.page - 1) * PER_PAGE + 1;
390
- const to = Math.min(state.page * PER_PAGE, state.total);
391
- countEl.textContent = `${from}–${to} of ${state.total}`;
392
- pageInfo.textContent = `${state.page} / ${totalPages}`;
393
- if (prevBtn) prevBtn.disabled = state.page <= 1;
394
- if (nextBtn) nextBtn.disabled = state.page >= totalPages;
395
- }
396
-
397
- // ── Select a value ───────────────────────────────────────────────────────
398
- function select(id, label) {
399
- state.selectedId = id || '';
400
- state.selectedLabel = label || '';
401
- hidden.value = state.selectedId;
402
- renderTriggerLabel();
403
- // Trigger change event so other listeners can react
404
- hidden.dispatchEvent(new Event('change', { bubbles: true }));
405
- }
406
-
407
- function renderTriggerLabel() {
408
- const labelEl = trigger.querySelector('.fk-trigger-label');
409
- if (!labelEl) return;
410
- if (state.selectedId) {
411
- labelEl.innerHTML = `
412
- <span class="fk-id-chip">#${state.selectedId}</span>
413
- <span class="fk-selected-label">${escapeHtml(state.selectedLabel || state.selectedId)}</span>`;
414
- } else {
415
- labelEl.innerHTML = `<span class="fk-placeholder">— Select —</span>`;
416
- }
417
- }
418
-
419
- // ── Keyboard navigation ──────────────────────────────────────────────────
420
- function globalKeydown(e) {
421
- if (!state.open) return;
422
- const opts = [...list.querySelectorAll('.fk-option')];
423
-
424
- switch (e.key) {
425
- case 'ArrowDown':
426
- e.preventDefault();
427
- setFocus(Math.min(state.focusIndex + 1, opts.length - 1));
428
- break;
429
- case 'ArrowUp':
430
- e.preventDefault();
431
- setFocus(Math.max(state.focusIndex - 1, 0));
432
- break;
433
- case 'Enter':
434
- e.preventDefault();
435
- if (state.focusIndex >= 0 && opts[state.focusIndex]) {
436
- const opt = opts[state.focusIndex];
437
- select(opt.dataset.id, opt.dataset.label);
438
- close();
439
- }
440
- break;
441
- case 'Escape':
442
- e.preventDefault();
443
- close();
444
- trigger.focus();
445
- break;
446
- case 'Tab':
447
- close();
448
- break;
449
- case 'PageDown':
450
- e.preventDefault();
451
- if (nextBtn && !nextBtn.disabled) { state.page++; fetchOptions(); }
452
- break;
453
- case 'PageUp':
454
- e.preventDefault();
455
- if (prevBtn && !prevBtn.disabled) { state.page--; fetchOptions(); }
456
- break;
457
- }
458
- }
459
-
460
- function setFocus(idx) {
461
- const opts = [...list.querySelectorAll('.fk-option')];
462
- opts.forEach(o => o.classList.remove('fk-focused'));
463
- state.focusIndex = idx;
464
- if (idx >= 0 && opts[idx]) {
465
- opts[idx].classList.add('fk-focused');
466
- opts[idx].scrollIntoView({ block: 'nearest' });
467
- }
468
- }
469
-
470
- // ── Helpers ──────────────────────────────────────────────────────────────
471
- function escapeHtml(str) {
472
- return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
473
- }
474
- function escapeAttr(str) {
475
- return str.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
476
- }
477
- }
478
- })();
479
-
2
+ window.MILLAS_ADMIN_PREFIX = {{ adminPrefix | dump | safe }};
3
+ window.MILLAS_RESOURCE_SLUG = {{ resource.slug | dump | safe }};
480
4
  </script>
5
+ <script src="{{ adminPrefix }}/static/SelectFilter2.js?v=1"></script>
6
+ <script src="{{ adminPrefix }}/static/change_form.js?v=1"></script>
@@ -95,8 +95,8 @@
95
95
  <select class="form-control" size="6" id="m2m-available-{{ field.name }}" style="width:100%" multiple></select>
96
96
  </div>
97
97
  <div style="display:flex;flex-direction:column;gap:6px;padding-top:24px">
98
- <button type="button" class="btn btn-ghost btn-sm" onclick="m2mMove('{{ field.name }}','available','chosen')">→</button>
99
- <button type="button" class="btn btn-ghost btn-sm" onclick="m2mMove('{{ field.name }}','chosen','available')">←</button>
98
+ <button type="button" class="btn btn-ghost btn-sm" data-m2m-move data-m2m-field="{{ field.name }}" data-m2m-from="available" data-m2m-to="chosen">→</button>
99
+ <button type="button" class="btn btn-ghost btn-sm" data-m2m-move data-m2m-field="{{ field.name }}" data-m2m-from="chosen" data-m2m-to="available">←</button>
100
100
  </div>
101
101
  <div style="flex:1">
102
102
  <div class="form-label" style="font-size:11px;color:var(--text-soft)">Chosen</div>
@@ -141,14 +141,14 @@
141
141
  {% elif field.type == 'richtext' %}
142
142
  <div class="richtext-wrap">
143
143
  <div class="richtext-toolbar">
144
- <button type="button" onclick="rtCmd('bold')" title="Bold"><b>B</b></button>
145
- <button type="button" onclick="rtCmd('italic')" title="Italic"><i>I</i></button>
146
- <button type="button" onclick="rtCmd('underline')" title="Underline"><u>U</u></button>
144
+ <button type="button" data-rt-cmd="bold" title="Bold"><b>B</b></button>
145
+ <button type="button" data-rt-cmd="italic" title="Italic"><i>I</i></button>
146
+ <button type="button" data-rt-cmd="underline" title="Underline"><u>U</u></button>
147
147
  <span class="rt-sep"></span>
148
- <button type="button" onclick="rtCmd('insertUnorderedList')" title="Bullet list">≡</button>
149
- <button type="button" onclick="rtCmd('insertOrderedList')" title="Numbered list">1.</button>
148
+ <button type="button" data-rt-cmd="insertUnorderedList" title="Bullet list">≡</button>
149
+ <button type="button" data-rt-cmd="insertOrderedList" title="Numbered list">1.</button>
150
150
  <span class="rt-sep"></span>
151
- <button type="button" onclick="rtLink()" title="Link">🔗</button>
151
+ <button type="button" data-rt-link title="Link">🔗</button>
152
152
  </div>
153
153
  <div id="rt-{{ field.name }}" class="richtext-editor form-control{% if hasError %} error{% endif %}"
154
154
  contenteditable="true"
@@ -159,13 +159,13 @@
159
159
  {# ── Password ── #}
160
160
  {% elif field.type == 'password' %}
161
161
  <div style="position:relative">
162
- <input type="password" id="field-{{ field.name }}" name="{{ field.name }}"
162
+ <input autocomplete="new-password" type="password" id="field-{{ field.name }}" name="{{ field.name }}"
163
163
  class="form-control{% if hasError %} error{% endif %}"
164
164
  placeholder="{{ 'Leave blank to keep current' if isEdit else '' }}"
165
165
  {% if not field.nullable and not isEdit %}data-required="true"{% endif %}
166
166
  style="padding-right:40px">
167
167
  <button type="button" class="pw-toggle"
168
- onclick="this.previousElementSibling.type=this.previousElementSibling.type==='password'?'text':'password'">
168
+ data-pw-toggle>
169
169
  <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
170
170
  <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
171
171
  </svg>
@@ -31,7 +31,7 @@
31
31
  * Route.post('/ai/chat', [
32
32
  * AITokenBudget.perUser({ daily: 100_000 }).middleware(),
33
33
  * ], async (req) => {
34
- * const res = await AI.chat(req.validated.message, { userId: req.user.id });
34
+ * const res = await AI.chat(body.message, { userId: user.id });
35
35
  * await req.deductTokens(res.totalTokens); // <-- deduct after response
36
36
  * return jsonify({ reply: res.text });
37
37
  * });