webspresso 0.0.74 → 0.0.75

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 (60) hide show
  1. package/README.md +41 -3
  2. package/bin/commands/orm-map.js +139 -0
  3. package/bin/commands/skill.js +22 -8
  4. package/bin/utils/orm-map-html.js +689 -0
  5. package/bin/utils/orm-map-load.js +85 -0
  6. package/bin/utils/orm-map-snapshot.js +179 -0
  7. package/bin/utils/resolve-webspresso-orm.js +23 -0
  8. package/bin/webspresso.js +2 -0
  9. package/core/auth/manager.js +14 -1
  10. package/core/kernel/app.js +96 -0
  11. package/core/kernel/base-repository.js +143 -0
  12. package/core/kernel/events.js +101 -0
  13. package/core/kernel/flow.js +22 -0
  14. package/core/kernel/index.js +17 -0
  15. package/core/kernel/plugin.js +23 -0
  16. package/core/kernel/plugins/sample-seo.js +26 -0
  17. package/core/kernel/run-demo.js +58 -0
  18. package/core/kernel/view.js +167 -0
  19. package/core/openapi/build-from-api-routes.js +8 -2
  20. package/core/orm/model.js +3 -1
  21. package/core/url-path-normalize.js +30 -0
  22. package/index.d.ts +168 -1
  23. package/index.js +20 -2
  24. package/package.json +11 -1
  25. package/plugins/admin-panel/api.js +43 -15
  26. package/plugins/admin-panel/client/README.md +39 -0
  27. package/plugins/admin-panel/client/load-parts.js +74 -0
  28. package/plugins/admin-panel/client/manifest.parts.json +12 -0
  29. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
  30. package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
  31. package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
  32. package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
  33. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
  34. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
  35. package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
  36. package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
  37. package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
  38. package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
  39. package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
  40. package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
  41. package/plugins/admin-panel/components.js +4 -2640
  42. package/plugins/admin-panel/core/api-extensions.js +100 -10
  43. package/plugins/admin-panel/index.js +3 -0
  44. package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
  45. package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
  46. package/plugins/admin-panel/modules/dashboard.js +3 -2
  47. package/plugins/admin-panel/modules/user-management.js +90 -20
  48. package/plugins/index.js +4 -0
  49. package/plugins/rate-limit/index.js +178 -0
  50. package/plugins/redirect/index.js +204 -0
  51. package/plugins/rest-resources/index.js +2 -1
  52. package/plugins/swagger.js +2 -1
  53. package/plugins/upload/local-file-provider.js +6 -2
  54. package/src/file-router.js +191 -50
  55. package/src/njk-frontmatter.js +156 -0
  56. package/src/plugin-manager.js +4 -2
  57. package/src/server.js +26 -9
  58. package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
  59. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
  60. package/templates/skills/webspresso-usage/SKILL.md +29 -278
@@ -1,2644 +1,8 @@
1
1
  /**
2
- * Admin Panel Components
3
- * Mithril.js components for admin panel UI
2
+ * Admin SPA source is split under {@link ./client/parts}; order is declared in {@link ./client/manifest.parts.json}.
3
+ * Built at runtime by {@link ./client/load-parts} (no bundler required for npm consumers).
4
4
  */
5
5
 
6
- module.exports = `
7
- // API helper
8
- const api = {
9
- async request(path, options = {}) {
10
- const adminPath = window.__ADMIN_PATH__ || '/_admin';
11
- const url = adminPath + '/api' + path;
12
- const response = await fetch(url, {
13
- ...options,
14
- headers: {
15
- 'Content-Type': 'application/json',
16
- ...options.headers,
17
- },
18
- credentials: 'include',
19
- });
20
-
21
- if (!response.ok) {
22
- const error = await response.json().catch(() => ({ error: 'Request failed' }));
23
- throw new Error(error.error || 'Request failed');
24
- }
25
-
26
- return response.json();
27
- },
28
-
29
- get(path) { return this.request(path, { method: 'GET' }); },
30
- post(path, data) { return this.request(path, { method: 'POST', body: JSON.stringify(data) }); },
31
- put(path, data) { return this.request(path, { method: 'PUT', body: JSON.stringify(data) }); },
32
- delete(path) { return this.request(path, { method: 'DELETE' }); },
33
- };
6
+ 'use strict';
34
7
 
35
- /** POST /data-exchange/export/:model — validates JSON errors vs .xlsx blob */
36
- async function downloadDataExchangeXlsx(modelName, payload) {
37
- const adminPath = window.__ADMIN_PATH__ || '/_admin';
38
- const res = await fetch(adminPath + '/api/data-exchange/export/' + modelName, {
39
- method: 'POST',
40
- credentials: 'include',
41
- headers: { 'Content-Type': 'application/json' },
42
- body: JSON.stringify(payload),
43
- });
44
- const ct = (res.headers.get('content-type') || '').toLowerCase();
45
- if (!res.ok || ct.indexOf('spreadsheet') === -1) {
46
- var msg = 'Export failed';
47
- try {
48
- if (ct.indexOf('json') !== -1) {
49
- var j = await res.json();
50
- msg = j.error || msg;
51
- } else {
52
- var t = await res.text();
53
- if (t) msg = t.slice(0, 300);
54
- }
55
- } catch (e) {}
56
- throw new Error(msg);
57
- }
58
- const blob = await res.blob();
59
- var url = URL.createObjectURL(blob);
60
- var a = document.createElement('a');
61
- a.href = url;
62
- a.download = modelName + '-export.xlsx';
63
- a.click();
64
- URL.revokeObjectURL(url);
65
- }
66
-
67
- // Helper: Capitalize first letter of each word
68
- function capitalizeWords(str) {
69
- if (!str) return '';
70
- return str.split(' ').map(function(word) {
71
- return word.charAt(0).toUpperCase() + word.slice(1);
72
- }).join(' ');
73
- }
74
-
75
- // Helper: Format column name to label
76
- function formatColumnLabel(name) {
77
- if (!name) return '';
78
- return capitalizeWords(name.replace(/_/g, ' '));
79
- }
80
-
81
- // State
82
- const state = {
83
- user: null,
84
- needsSetup: false,
85
- loading: false,
86
- error: null,
87
- models: [],
88
- currentModel: null,
89
- currentModelMeta: null, // Full model metadata with columns
90
- records: [],
91
- pagination: {
92
- page: 1,
93
- perPage: 20,
94
- total: 0,
95
- totalPages: 0,
96
- },
97
- currentRecord: null,
98
- formData: {}, // Form field values
99
- editing: false,
100
- filters: {}, // Active filters { column: { op, value, from, to } }
101
- filterPanelOpen: false, // Filter panel visibility (deprecated)
102
- filterDrawerOpen: false, // Filter drawer visibility
103
- bulkFields: [], // Bulk-updatable fields (enum/boolean)
104
- bulkFieldDropdownOpen: false, // Bulk field dropdown visibility
105
- selectedBulkField: null, // Currently selected bulk field for update
106
- selectAllMode: false, // true = all records selected (not just current page)
107
- };
108
-
109
- // Breadcrumb Component
110
- const Breadcrumb = {
111
- view: (vnode) => {
112
- const items = vnode.attrs.items || [];
113
- if (items.length === 0) return null;
114
-
115
- return m('nav.mb-4', { 'aria-label': 'Breadcrumb' }, [
116
- m('ol.flex.items-center.space-x-2.text-sm', [
117
- // Home link
118
- m('li', [
119
- m('a.text-gray-500 dark:text-slate-400.hover:text-gray-700 dark:hover:text-slate-200 dark:hover:text-slate-200', {
120
- href: '/',
121
- onclick: (e) => {
122
- e.preventDefault();
123
- m.route.set('/');
124
- }
125
- }, [
126
- m('svg.w-4.h-4', { fill: 'currentColor', viewBox: '0 0 20 20' }, [
127
- m('path', { d: 'M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z' }),
128
- ]),
129
- ]),
130
- ]),
131
- // Dynamic items
132
- ...items.map((item, idx) => [
133
- m('li.flex.items-center', [
134
- m('svg.w-4.h-4.text-gray-400 dark:text-slate-500.mx-1', { fill: 'currentColor', viewBox: '0 0 20 20' }, [
135
- m('path', { 'fill-rule': 'evenodd', d: 'M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z', 'clip-rule': 'evenodd' }),
136
- ]),
137
- idx === items.length - 1
138
- ? m('span.text-gray-700 dark:text-slate-300.font-medium', item.label)
139
- : m('a.text-gray-500 dark:text-slate-400.hover:text-gray-700 dark:hover:text-slate-200 dark:hover:text-slate-200', {
140
- href: item.href,
141
- onclick: (e) => {
142
- e.preventDefault();
143
- m.route.set(item.href);
144
- }
145
- }, item.label),
146
- ]),
147
- ]),
148
- ]),
149
- ]);
150
- },
151
- };
152
-
153
- // ==========================================
154
- // NEW FILTER COMPONENTS - Imported from filter-components.js
155
- // ==========================================
156
-
157
- // Filter Operator Labels
158
- const FILTER_OPERATORS = {
159
- string: [
160
- { value: 'contains', label: 'Contains' },
161
- { value: 'equals', label: 'Equals' },
162
- { value: 'starts_with', label: 'Starts with' },
163
- { value: 'ends_with', label: 'Ends with' },
164
- ],
165
- number: [
166
- { value: 'eq', label: 'Equals' },
167
- { value: 'gt', label: 'Greater than' },
168
- { value: 'gte', label: 'Greater or equal' },
169
- { value: 'lt', label: 'Less than' },
170
- { value: 'lte', label: 'Less or equal' },
171
- { value: 'between', label: 'Between' },
172
- ],
173
- date: [
174
- { value: 'eq', label: 'Equals' },
175
- { value: 'gt', label: 'After' },
176
- { value: 'gte', label: 'On or after' },
177
- { value: 'lt', label: 'Before' },
178
- { value: 'lte', label: 'On or before' },
179
- { value: 'between', label: 'Between' },
180
- ],
181
- };
182
-
183
- function getOperatorLabel(op, colType) {
184
- const ops = colType === 'date' || colType === 'datetime' || colType === 'timestamp'
185
- ? FILTER_OPERATORS.date
186
- : colType === 'integer' || colType === 'bigint' || colType === 'float' || colType === 'decimal'
187
- ? FILTER_OPERATORS.number
188
- : FILTER_OPERATORS.string;
189
-
190
- const found = ops.find(o => o.value === op);
191
- return found ? found.label : op;
192
- }
193
-
194
- // Filter Badge Component
195
- const FilterBadge = {
196
- view: (vnode) => {
197
- const { colName, filter, colMeta, onRemove } = vnode.attrs;
198
- const label = colMeta?.ui?.label || formatColumnLabel(colName);
199
- const opLabel = getOperatorLabel(filter.op, colMeta?.type);
200
-
201
- let displayValue = '';
202
- if (filter.op === 'between') {
203
- displayValue = (filter.from || '?') + ' - ' + (filter.to || '?');
204
- } else if (filter.op === 'in' && Array.isArray(filter.value)) {
205
- displayValue = filter.value.join(', ');
206
- } else {
207
- displayValue = String(filter.value || '');
208
- }
209
-
210
- if (displayValue.length > 20) {
211
- displayValue = displayValue.substring(0, 20) + '...';
212
- }
213
-
214
- return m('span.inline-flex.items-center.gap-1.px-2.5.py-1.rounded-full.text-xs.font-medium.bg-indigo-50.text-indigo-700.border.border-indigo-200', [
215
- m('span.font-semibold', label),
216
- m('span.text-indigo-400', opLabel.toLowerCase()),
217
- m('span', '"' + displayValue + '"'),
218
- m('button.ml-1.text-indigo-400.hover:text-indigo-600.focus:outline-none', {
219
- onclick: (e) => {
220
- e.stopPropagation();
221
- onRemove(colName);
222
- },
223
- type: 'button',
224
- }, [
225
- m('svg.w-3.5.h-3.5', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [
226
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M6 18L18 6M6 6l12 12' }),
227
- ]),
228
- ]),
229
- ]);
230
- },
231
- };
232
-
233
- // Active Filters Bar
234
- const ActiveFiltersBar = {
235
- view: (vnode) => {
236
- const { filters, modelMeta, onRemove, onClearAll } = vnode.attrs;
237
- if (!filters || Object.keys(filters).length === 0) return null;
238
-
239
- const filterEntries = Object.entries(filters).filter(([_, f]) =>
240
- f && (f.value !== '' || f.from || f.to)
241
- );
242
-
243
- if (filterEntries.length === 0) return null;
244
-
245
- return m('.flex.items-center.gap-2.py-2.flex-wrap', [
246
- m('span.text-xs.font-medium.text-gray-500 dark:text-slate-400.uppercase.tracking-wide', 'Active filters:'),
247
- ...filterEntries.map(([colName, filter]) => {
248
- const col = modelMeta?.columns?.find(c => c.name === colName);
249
- return m(FilterBadge, { colName, filter, colMeta: col, onRemove });
250
- }),
251
- filterEntries.length > 1 ? m('button.text-xs.text-gray-500 dark:text-slate-400.hover:text-gray-700 dark:hover:text-slate-200 dark:hover:text-slate-200.underline.ml-2', {
252
- onclick: onClearAll,
253
- type: 'button',
254
- }, 'Clear all') : null,
255
- ]);
256
- },
257
- };
258
-
259
- // Quick Filter Input
260
- const QuickFilterInput = {
261
- view: (vnode) => {
262
- const { placeholder, value, onChange, onClear } = vnode.attrs;
263
-
264
- return m('.relative.flex-1.max-w-xs', [
265
- m('div.absolute.inset-y-0.left-0.pl-3.flex.items-center.pointer-events-none', [
266
- m('svg.h-4.w-4.text-gray-400', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
267
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' }),
268
- ]),
269
- ]),
270
- m('input.block.w-full.pl-9.pr-8.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.bg-white dark:bg-slate-800.placeholder-gray-400 dark:placeholder-slate-500.focus:outline-none.focus:ring-2.focus:ring-indigo-500.focus:border-transparent', {
271
- type: 'text',
272
- placeholder: placeholder || 'Quick search...',
273
- value: value || '',
274
- oninput: (e) => onChange(e.target.value),
275
- }),
276
- value ? m('button.absolute.inset-y-0.right-0.pr-3.flex.items-center.text-gray-400 dark:text-slate-500.hover:text-gray-600 dark:hover:text-slate-300 dark:hover:text-slate-300', {
277
- onclick: onClear,
278
- type: 'button',
279
- }, [
280
- m('svg.h-4.w-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
281
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M6 18L18 6M6 6l12 12' }),
282
- ]),
283
- ]) : null,
284
- ]);
285
- },
286
- };
287
-
288
- // Quick Filters Bar
289
- const QuickFiltersBar = {
290
- view: (vnode) => {
291
- const { modelMeta, filters, onFilterChange, onOpenDrawer, activeFilterCount } = vnode.attrs;
292
-
293
- const searchableColumns = (modelMeta?.columns || []).filter(col =>
294
- (col.type === 'string' || col.type === 'text') &&
295
- !col.primary &&
296
- ['name', 'title', 'email', 'slug', 'username'].some(n => col.name.includes(n))
297
- );
298
-
299
- const enumColumns = (modelMeta?.columns || []).filter(col =>
300
- col.type === 'enum' && col.enumValues && col.enumValues.length <= 6
301
- );
302
-
303
- const quickSearchCol = searchableColumns[0];
304
- const quickSearchFilter = quickSearchCol ? filters[quickSearchCol.name] : null;
305
-
306
- return m('.bg-white dark:bg-slate-800.border.border-gray-200 dark:border-slate-600.rounded-lg.p-3.mb-4.shadow-sm', [
307
- m('.flex.items-center.gap-3.flex-wrap', [
308
- quickSearchCol ? m(QuickFilterInput, {
309
- placeholder: 'Search by ' + (quickSearchCol.ui?.label || formatColumnLabel(quickSearchCol.name)).toLowerCase() + '...',
310
- value: quickSearchFilter?.value || '',
311
- onChange: (value) => {
312
- if (value) {
313
- onFilterChange(quickSearchCol.name, { op: 'contains', value });
314
- } else {
315
- onFilterChange(quickSearchCol.name, null);
316
- }
317
- },
318
- onClear: () => onFilterChange(quickSearchCol.name, null),
319
- }) : null,
320
-
321
- ...enumColumns.slice(0, 2).map(col => {
322
- const currentFilter = filters[col.name];
323
- const currentValue = currentFilter?.value;
324
- const label = col.ui?.label || formatColumnLabel(col.name);
325
-
326
- return m('.flex.items-center.gap-1', [
327
- m('span.text-xs.font-medium.text-gray-500', label + ':'),
328
- m('.flex.gap-1', [
329
- m('button.px-2.py-1.text-xs.rounded-md.transition-colors', {
330
- class: !currentValue
331
- ? 'bg-gray-200 text-gray-800'
332
- : 'bg-gray-100 text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-600 dark:hover:bg-slate-600',
333
- onclick: () => onFilterChange(col.name, null),
334
- }, 'All'),
335
- ...col.enumValues.map(val =>
336
- m('button.px-2.py-1.text-xs.rounded-md.transition-colors', {
337
- class: currentValue === val
338
- ? 'bg-indigo-600 text-white'
339
- : 'bg-gray-100 text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-600 dark:hover:bg-slate-600',
340
- onclick: () => onFilterChange(col.name, { op: 'equals', value: val }),
341
- }, val)
342
- ),
343
- ]),
344
- ]);
345
- }),
346
-
347
- m('.flex-1'),
348
-
349
- m('button.inline-flex.items-center.gap-2.px-3.py-2.text-sm.font-medium.text-gray-700 dark:text-slate-300.bg-white dark:bg-slate-800.border.border-gray-300 dark:border-slate-600.rounded-lg.hover:bg-gray-50 dark:hover:bg-slate-800/50 dark:hover:bg-slate-800/50.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
350
- onclick: onOpenDrawer,
351
- type: 'button',
352
- }, [
353
- m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
354
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z' }),
355
- ]),
356
- 'All Filters',
357
- activeFilterCount > 0 ? m('span.inline-flex.items-center.justify-center.w-5.h-5.text-xs.font-bold.text-white.bg-indigo-600.rounded-full', activeFilterCount) : null,
358
- ]),
359
- ]),
360
- ]);
361
- },
362
- };
363
-
364
- // Filter Field Component
365
- const FilterField = {
366
- view: (vnode) => {
367
- const { col, filter, onChange } = vnode.attrs;
368
- const currentFilter = filter || {};
369
- const label = col.ui?.label || formatColumnLabel(col.name);
370
-
371
- if (col.type === 'boolean') {
372
- return m('.space-y-2', [
373
- m('label.block.text-sm.font-medium.text-gray-700', label),
374
- m('.flex.items-center.gap-4', [
375
- ['true', 'false', ''].map((val, idx) => {
376
- const labels = ['Yes', 'No', 'Any'];
377
- return m('label.inline-flex.items-center.cursor-pointer', [
378
- m('input.w-4.h-4.text-indigo-600.border-gray-300 dark:border-slate-600.focus:ring-indigo-500', {
379
- type: 'radio',
380
- name: 'filter_bool_' + col.name,
381
- checked: (currentFilter.value || '') === val,
382
- onchange: () => onChange(val ? { value: val } : null),
383
- }),
384
- m('span.ml-2.text-sm.text-gray-600', labels[idx]),
385
- ]);
386
- }),
387
- ]),
388
- ]);
389
- }
390
-
391
- if (col.type === 'enum' && col.enumValues) {
392
- const selectedValues = currentFilter.op === 'in' && Array.isArray(currentFilter.value)
393
- ? currentFilter.value
394
- : currentFilter.value ? [currentFilter.value] : [];
395
-
396
- return m('.space-y-2', [
397
- m('label.block.text-sm.font-medium.text-gray-700', label),
398
- m('.flex.flex-wrap.gap-2', [
399
- ...col.enumValues.map(val => {
400
- const isSelected = selectedValues.includes(val);
401
- return m('button.px-3.py-1.5.text-sm.rounded-md.border.transition-colors', {
402
- type: 'button',
403
- class: isSelected
404
- ? 'bg-indigo-100 border-indigo-300 text-indigo-700'
405
- : 'bg-white border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-800/50 dark:hover:bg-slate-800/50',
406
- onclick: () => {
407
- const newSelected = isSelected
408
- ? selectedValues.filter(v => v !== val)
409
- : [...selectedValues, val];
410
- onChange(newSelected.length > 0 ? { op: 'in', value: newSelected } : null);
411
- },
412
- }, val);
413
- }),
414
- ]),
415
- ]);
416
- }
417
-
418
- if (col.type === 'date' || col.type === 'datetime' || col.type === 'timestamp') {
419
- const inputType = col.type === 'date' ? 'date' : 'datetime-local';
420
- const ops = FILTER_OPERATORS.date;
421
-
422
- return m('.space-y-2', [
423
- m('label.block.text-sm.font-medium.text-gray-700', label),
424
- m('.flex.items-start.gap-2.flex-wrap', [
425
- m('select.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.bg-white dark:bg-slate-800.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
426
- value: currentFilter.op || 'eq',
427
- onchange: (e) => {
428
- const op = e.target.value;
429
- if (op === 'between') {
430
- onChange({ op, from: currentFilter.from || '', to: currentFilter.to || '' });
431
- } else {
432
- onChange({ op, value: currentFilter.value || '' });
433
- }
434
- },
435
- }, ops.map(o => m('option', { value: o.value }, o.label))),
436
- currentFilter.op === 'between' ? [
437
- m('input.flex-1.min-w-32.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
438
- type: inputType,
439
- value: currentFilter.from || '',
440
- oninput: (e) => onChange({ op: 'between', from: e.target.value, to: currentFilter.to || '' }),
441
- }),
442
- m('span.text-gray-400 dark:text-slate-500.self-center', 'to'),
443
- m('input.flex-1.min-w-32.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
444
- type: inputType,
445
- value: currentFilter.to || '',
446
- oninput: (e) => onChange({ op: 'between', from: currentFilter.from || '', to: e.target.value }),
447
- }),
448
- ] : m('input.flex-1.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
449
- type: inputType,
450
- value: currentFilter.value || '',
451
- oninput: (e) => onChange({ op: currentFilter.op || 'eq', value: e.target.value }),
452
- }),
453
- ]),
454
- ]);
455
- }
456
-
457
- if (col.type === 'integer' || col.type === 'bigint' || col.type === 'float' || col.type === 'decimal') {
458
- const ops = FILTER_OPERATORS.number;
459
-
460
- return m('.space-y-2', [
461
- m('label.block.text-sm.font-medium.text-gray-700', label),
462
- m('.flex.items-start.gap-2', [
463
- m('select.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.bg-white dark:bg-slate-800.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
464
- value: currentFilter.op || 'eq',
465
- onchange: (e) => {
466
- const op = e.target.value;
467
- if (op === 'between') {
468
- onChange({ op, from: currentFilter.from || '', to: currentFilter.to || '' });
469
- } else {
470
- onChange({ op, value: currentFilter.value || '' });
471
- }
472
- },
473
- }, ops.map(o => m('option', { value: o.value }, o.label))),
474
- currentFilter.op === 'between' ? [
475
- m('input.w-24.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
476
- type: 'number',
477
- value: currentFilter.from || '',
478
- placeholder: 'Min',
479
- oninput: (e) => onChange({ op: 'between', from: e.target.value, to: currentFilter.to || '' }),
480
- }),
481
- m('span.text-gray-400 dark:text-slate-500.self-center', 'to'),
482
- m('input.w-24.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
483
- type: 'number',
484
- value: currentFilter.to || '',
485
- placeholder: 'Max',
486
- oninput: (e) => onChange({ op: 'between', from: currentFilter.from || '', to: e.target.value }),
487
- }),
488
- ] : m('input.flex-1.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
489
- type: 'number',
490
- value: currentFilter.value || '',
491
- placeholder: 'Enter value',
492
- oninput: (e) => onChange({ op: currentFilter.op || 'eq', value: e.target.value }),
493
- }),
494
- ]),
495
- ]);
496
- }
497
-
498
- // String/Text field (default)
499
- const ops = FILTER_OPERATORS.string;
500
-
501
- return m('.space-y-2', [
502
- m('label.block.text-sm.font-medium.text-gray-700', label),
503
- m('.flex.items-center.gap-2', [
504
- m('select.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.bg-white dark:bg-slate-800.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
505
- value: currentFilter.op || 'contains',
506
- onchange: (e) => onChange({ op: e.target.value, value: currentFilter.value || '' }),
507
- }, ops.map(o => m('option', { value: o.value }, o.label))),
508
- m('input.flex-1.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
509
- type: 'text',
510
- value: currentFilter.value || '',
511
- placeholder: 'Enter search term',
512
- oninput: (e) => onChange({ op: currentFilter.op || 'contains', value: e.target.value }),
513
- }),
514
- ]),
515
- ]);
516
- },
517
- };
518
-
519
- // Filter Drawer Component
520
- const FilterDrawer = {
521
- view: (vnode) => {
522
- const { isOpen, modelMeta, filters, onFilterChange, onApply, onClear, onClose } = vnode.attrs;
523
-
524
- if (!isOpen) return null;
525
-
526
- const filterableColumns = (modelMeta?.columns || []).filter(col => {
527
- if (col.primary || col.autoIncrement) return false;
528
- if (col.auto === 'create' || col.auto === 'update') return false;
529
- if (col.type === 'json') return false;
530
- if (col.type === 'file') return false;
531
- return true;
532
- });
533
-
534
- const textColumns = filterableColumns.filter(c => c.type === 'string' || c.type === 'text');
535
- const numericColumns = filterableColumns.filter(c => ['integer', 'bigint', 'float', 'decimal'].includes(c.type));
536
- const dateColumns = filterableColumns.filter(c => ['date', 'datetime', 'timestamp'].includes(c.type));
537
- const boolEnumColumns = filterableColumns.filter(c => c.type === 'boolean' || c.type === 'enum');
538
-
539
- const renderGroup = (title, columns) => {
540
- if (columns.length === 0) return null;
541
- return m('.mb-6', [
542
- m('h4.text-xs.font-semibold.text-gray-400 dark:text-slate-500.uppercase.tracking-wider.mb-3', title),
543
- m('.space-y-4', columns.map(col =>
544
- m(FilterField, {
545
- col,
546
- filter: filters[col.name],
547
- onChange: (filter) => onFilterChange(col.name, filter),
548
- })
549
- )),
550
- ]);
551
- };
552
-
553
- return [
554
- m('.fixed.inset-0.bg-black.bg-opacity-25.z-40.transition-opacity', { onclick: onClose }),
555
- m('.fixed.inset-y-0.right-0.w-full.max-w-md.bg-white dark:bg-slate-800.shadow-xl.z-50.flex.flex-col', {
556
- style: 'animation: filterDrawerSlideIn 0.2s ease-out;',
557
- }, [
558
- m('.flex.items-center.justify-between.px-6.py-4.border-b.border-gray-200', [
559
- m('div', [
560
- m('h3.text-lg.font-semibold.text-gray-900', 'Advanced Filters'),
561
- m('p.text-sm.text-gray-500', 'Filter records by multiple criteria'),
562
- ]),
563
- m('button.p-2.text-gray-400 dark:text-slate-500.hover:text-gray-600 dark:hover:text-slate-300 dark:hover:text-slate-300.rounded-lg.hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700', {
564
- onclick: onClose,
565
- type: 'button',
566
- }, [
567
- m('svg.w-5.h-5', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
568
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M6 18L18 6M6 6l12 12' }),
569
- ]),
570
- ]),
571
- ]),
572
- m('.flex-1.overflow-y-auto.px-6.py-4', [
573
- renderGroup('Text Fields', textColumns),
574
- renderGroup('Options', boolEnumColumns),
575
- renderGroup('Numbers', numericColumns),
576
- renderGroup('Dates', dateColumns),
577
- ]),
578
- m('.flex.items-center.justify-between.gap-3.px-6.py-4.border-t.border-gray-200 dark:border-slate-600.bg-gray-50', [
579
- m('button.px-4.py-2.text-sm.font-medium.text-gray-700 dark:text-slate-300.hover:text-gray-900 dark:hover:text-slate-100 dark:hover:text-slate-100', {
580
- onclick: onClear,
581
- type: 'button',
582
- }, 'Clear all'),
583
- m('.flex.gap-3', [
584
- m('button.px-4.py-2.text-sm.font-medium.text-gray-700 dark:text-slate-300.bg-white dark:bg-slate-800.border.border-gray-300 dark:border-slate-600.rounded-lg.hover:bg-gray-50 dark:hover:bg-slate-800/50 dark:hover:bg-slate-800/50', {
585
- onclick: onClose,
586
- type: 'button',
587
- }, 'Cancel'),
588
- m('button.px-4.py-2.text-sm.font-medium.text-white.bg-indigo-600.rounded-lg.hover:bg-indigo-700', {
589
- onclick: () => {
590
- onApply();
591
- onClose();
592
- },
593
- type: 'button',
594
- }, 'Apply Filters'),
595
- ]),
596
- ]),
597
- ]),
598
- ];
599
- },
600
- };
601
-
602
- // Add drawer animation styles
603
- if (typeof document !== 'undefined' && !document.getElementById('filter-drawer-styles')) {
604
- const style = document.createElement('style');
605
- style.id = 'filter-drawer-styles';
606
- style.textContent = '@keyframes filterDrawerSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }';
607
- document.head.appendChild(style);
608
- }
609
-
610
- // Pagination Component
611
- const Pagination = {
612
- view: (vnode) => {
613
- const { page, totalPages, total, perPage, onPageChange } = vnode.attrs;
614
- if (totalPages <= 1) return null;
615
-
616
- const pages = [];
617
- const maxVisible = 5;
618
- let start = Math.max(1, page - Math.floor(maxVisible / 2));
619
- let end = Math.min(totalPages, start + maxVisible - 1);
620
- if (end - start < maxVisible - 1) {
621
- start = Math.max(1, end - maxVisible + 1);
622
- }
623
-
624
- for (let i = start; i <= end; i++) {
625
- pages.push(i);
626
- }
627
-
628
- return m('.flex.items-center.justify-between.px-4.py-3.bg-white dark:bg-slate-800.border-t', [
629
- m('.text-sm.text-gray-700', [
630
- 'Showing ',
631
- m('span.font-medium', ((page - 1) * perPage) + 1),
632
- ' to ',
633
- m('span.font-medium', Math.min(page * perPage, total)),
634
- ' of ',
635
- m('span.font-medium', total),
636
- ' results',
637
- ]),
638
- m('nav.flex.items-center.space-x-1', [
639
- // Previous button
640
- m('button.px-3.py-1.rounded.border.text-sm', {
641
- disabled: page <= 1,
642
- class: page <= 1 ? 'text-gray-300 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700',
643
- onclick: () => page > 1 && onPageChange(page - 1),
644
- }, '← Prev'),
645
-
646
- // Page numbers
647
- start > 1 ? [
648
- m('button.px-3.py-1.rounded.text-sm.text-gray-700 dark:text-slate-300.hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700', {
649
- onclick: () => onPageChange(1),
650
- }, '1'),
651
- start > 2 ? m('span.px-2.text-gray-400', '...') : null,
652
- ] : null,
653
-
654
- ...pages.map(p =>
655
- m('button.px-3.py-1.rounded.text-sm', {
656
- class: p === page
657
- ? 'bg-blue-600 text-white'
658
- : 'text-gray-700 hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700',
659
- onclick: () => onPageChange(p),
660
- }, p)
661
- ),
662
-
663
- end < totalPages ? [
664
- end < totalPages - 1 ? m('span.px-2.text-gray-400', '...') : null,
665
- m('button.px-3.py-1.rounded.text-sm.text-gray-700 dark:text-slate-300.hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700', {
666
- onclick: () => onPageChange(totalPages),
667
- }, totalPages),
668
- ] : null,
669
-
670
- // Next button
671
- m('button.px-3.py-1.rounded.border.text-sm', {
672
- disabled: page >= totalPages,
673
- class: page >= totalPages ? 'text-gray-300 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700',
674
- onclick: () => page < totalPages && onPageChange(page + 1),
675
- }, 'Next →'),
676
- ]),
677
- ]);
678
- },
679
- };
680
-
681
- // Field Renderers - render appropriate input based on column type
682
- const FieldRenderers = {
683
- // Text input (string)
684
- string: (col, value, onChange, readonly) => {
685
- const validations = col.validations || {};
686
- const ui = col.ui || {};
687
- const label = ui.label || formatColumnLabel(col.name);
688
- const inputType = ui.inputType || (validations.email ? 'email' : validations.url ? 'url' : 'text');
689
- const placeholder = ui.placeholder || '';
690
- const hint = ui.hint || '';
691
-
692
- return m('.mb-4', [
693
- m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: col.name }, label),
694
- m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.placeholder-gray-400.dark:placeholder-slate-500.focus:outline-none.focus:ring-2.focus:ring-blue-500.dark:focus:ring-blue-400', {
695
- id: col.name,
696
- name: col.name,
697
- type: inputType,
698
- value: value || '',
699
- placeholder: placeholder,
700
- minlength: validations.minLength || validations.min,
701
- maxlength: validations.maxLength || validations.max || col.maxLength || 255,
702
- pattern: validations.pattern || undefined,
703
- required: !col.nullable && !readonly,
704
- readonly: readonly,
705
- disabled: readonly,
706
- class: readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed' : '',
707
- oninput: (e) => onChange(e.target.value),
708
- }),
709
- hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
710
- ]);
711
- },
712
-
713
- // Textarea (text)
714
- text: (col, value, onChange, readonly) => {
715
- const validations = col.validations || {};
716
- const ui = col.ui || {};
717
- const label = ui.label || formatColumnLabel(col.name);
718
- const placeholder = ui.placeholder || '';
719
- const hint = ui.hint || '';
720
- const rows = ui.rows || 4;
721
-
722
- return m('.mb-4', [
723
- m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: col.name }, label),
724
- m('textarea.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.placeholder-gray-400.dark:placeholder-slate-500.focus:outline-none.focus:ring-2.focus:ring-blue-500.dark:focus:ring-blue-400', {
725
- id: col.name,
726
- name: col.name,
727
- rows: rows,
728
- placeholder: placeholder,
729
- minlength: validations.minLength || validations.min,
730
- maxlength: validations.maxLength || validations.max,
731
- required: !col.nullable && !readonly,
732
- readonly: readonly,
733
- disabled: readonly,
734
- class: readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed' : '',
735
- oninput: (e) => onChange(e.target.value),
736
- }, value || ''),
737
- hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
738
- ]);
739
- },
740
-
741
- // Number input (integer, bigint)
742
- integer: (col, value, onChange, readonly) => {
743
- const validations = col.validations || {};
744
- const ui = col.ui || {};
745
- const label = ui.label || formatColumnLabel(col.name);
746
- const placeholder = ui.placeholder || '';
747
- const hint = ui.hint || '';
748
-
749
- return m('.mb-4', [
750
- m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: col.name }, label),
751
- m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.placeholder-gray-400.dark:placeholder-slate-500.focus:outline-none.focus:ring-2.focus:ring-blue-500.dark:focus:ring-blue-400', {
752
- id: col.name,
753
- name: col.name,
754
- type: 'number',
755
- step: validations.step || '1',
756
- min: validations.min,
757
- max: validations.max,
758
- value: value !== null && value !== undefined ? value : '',
759
- placeholder: placeholder,
760
- required: !col.nullable && !readonly,
761
- readonly: readonly,
762
- disabled: readonly,
763
- class: readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed' : '',
764
- oninput: (e) => onChange(e.target.value === '' ? null : parseInt(e.target.value, 10)),
765
- }),
766
- hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
767
- ]);
768
- },
769
-
770
- // Float/Decimal input
771
- float: (col, value, onChange, readonly) => {
772
- const validations = col.validations || {};
773
- const ui = col.ui || {};
774
- const label = ui.label || formatColumnLabel(col.name);
775
- const placeholder = ui.placeholder || '';
776
- const hint = ui.hint || '';
777
-
778
- return m('.mb-4', [
779
- m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: col.name }, label),
780
- m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.placeholder-gray-400.dark:placeholder-slate-500.focus:outline-none.focus:ring-2.focus:ring-blue-500.dark:focus:ring-blue-400', {
781
- id: col.name,
782
- name: col.name,
783
- type: 'number',
784
- step: validations.step || '0.01',
785
- min: validations.min,
786
- max: validations.max,
787
- value: value !== null && value !== undefined ? value : '',
788
- placeholder: placeholder,
789
- required: !col.nullable && !readonly,
790
- readonly: readonly,
791
- disabled: readonly,
792
- class: readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed' : '',
793
- oninput: (e) => onChange(e.target.value === '' ? null : parseFloat(e.target.value)),
794
- }),
795
- hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
796
- ]);
797
- },
798
-
799
- // Boolean checkbox
800
- boolean: (col, value, onChange, readonly) => {
801
- const ui = col.ui || {};
802
- const label = ui.label || formatColumnLabel(col.name);
803
- const hint = ui.hint || '';
804
-
805
- return m('.mb-4', [
806
- m('label.flex.items-center.cursor-pointer', { class: readonly ? 'cursor-not-allowed' : '' }, [
807
- m('input.mr-2.w-4.h-4.rounded.border-gray-300.dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
808
- type: 'checkbox',
809
- name: col.name,
810
- checked: Boolean(value),
811
- disabled: readonly,
812
- onchange: (e) => onChange(e.target.checked),
813
- }),
814
- m('span.text-sm.font-medium.text-gray-700.dark:text-slate-300', label),
815
- ]),
816
- hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
817
- ]);
818
- },
819
-
820
- // Date input
821
- date: (col, value, onChange, readonly) => {
822
- const validations = col.validations || {};
823
- const ui = col.ui || {};
824
- const label = ui.label || formatColumnLabel(col.name);
825
- const placeholder = ui.placeholder || '';
826
- const hint = ui.hint || '';
827
- const dateValue = value ? new Date(value).toISOString().split('T')[0] : '';
828
-
829
- return m('.mb-4', [
830
- m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: col.name }, label),
831
- m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.placeholder-gray-400.dark:placeholder-slate-500.focus:outline-none.focus:ring-2.focus:ring-blue-500.dark:focus:ring-blue-400', {
832
- id: col.name,
833
- name: col.name,
834
- type: 'date',
835
- value: dateValue,
836
- placeholder: placeholder,
837
- min: validations.min,
838
- max: validations.max,
839
- required: !col.nullable && !readonly,
840
- readonly: readonly,
841
- disabled: readonly,
842
- class: readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed' : '',
843
- oninput: (e) => onChange(e.target.value),
844
- }),
845
- hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
846
- ]);
847
- },
848
-
849
- // DateTime input (datetime, timestamp)
850
- datetime: (col, value, onChange, readonly) => {
851
- const validations = col.validations || {};
852
- const ui = col.ui || {};
853
- const label = ui.label || formatColumnLabel(col.name);
854
- const placeholder = ui.placeholder || '';
855
- const hint = ui.hint || '';
856
- const dateTimeValue = value ? new Date(value).toISOString().slice(0, 16) : '';
857
-
858
- return m('.mb-4', [
859
- m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: col.name }, label),
860
- m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.placeholder-gray-400.dark:placeholder-slate-500.focus:outline-none.focus:ring-2.focus:ring-blue-500.dark:focus:ring-blue-400', {
861
- id: col.name,
862
- name: col.name,
863
- type: 'datetime-local',
864
- value: dateTimeValue,
865
- placeholder: placeholder,
866
- min: validations.min,
867
- max: validations.max,
868
- required: !col.nullable && !readonly,
869
- readonly: readonly,
870
- disabled: readonly,
871
- class: readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed' : '',
872
- oninput: (e) => onChange(e.target.value),
873
- }),
874
- hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
875
- ]);
876
- },
877
-
878
- // Enum select
879
- enum: (col, value, onChange, readonly) => {
880
- const ui = col.ui || {};
881
- const label = ui.label || formatColumnLabel(col.name);
882
- const hint = ui.hint || '';
883
- const options = col.enumValues || [];
884
-
885
- return m('.mb-4', [
886
- m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: col.name }, label),
887
- m('select.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.focus:outline-none.focus:ring-2.focus:ring-blue-500.dark:focus:ring-blue-400', {
888
- id: col.name,
889
- name: col.name,
890
- value: value || '',
891
- required: !col.nullable && !readonly,
892
- disabled: readonly,
893
- class: readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed' : '',
894
- onchange: (e) => onChange(e.target.value),
895
- }, [
896
- col.nullable ? m('option', { value: '' }, '-- Select --') : null,
897
- ...options.map(opt => m('option', { value: opt, selected: value === opt }, opt)),
898
- ]),
899
- hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
900
- ]);
901
- },
902
-
903
- // JSON textarea
904
- json: (col, value, onChange, readonly) => {
905
- const ui = col.ui || {};
906
- const label = ui.label || formatColumnLabel(col.name);
907
- const placeholder = ui.placeholder || '';
908
- const hint = ui.hint || '';
909
- const rows = ui.rows || 6;
910
- const jsonString = value ? (typeof value === 'string' ? value : JSON.stringify(value, null, 2)) : '';
911
-
912
- return m('.mb-4', [
913
- m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: col.name }, label),
914
- m('textarea.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.placeholder-gray-400.dark:placeholder-slate-500.font-mono.text-sm.focus:outline-none.focus:ring-2.focus:ring-blue-500.dark:focus:ring-blue-400', {
915
- id: col.name,
916
- name: col.name,
917
- rows: rows,
918
- placeholder: placeholder,
919
- required: !col.nullable && !readonly,
920
- readonly: readonly,
921
- disabled: readonly,
922
- class: readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed' : '',
923
- oninput: (e) => {
924
- try {
925
- const parsed = JSON.parse(e.target.value);
926
- onChange(parsed);
927
- } catch {
928
- onChange(e.target.value);
929
- }
930
- },
931
- }, jsonString),
932
- hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
933
- ]);
934
- },
935
-
936
- // Array (as JSON or tags)
937
- array: (col, value, onChange, readonly) => {
938
- const validations = col.validations || {};
939
- const ui = col.ui || {};
940
- const label = ui.label || formatColumnLabel(col.name);
941
- const placeholder = ui.placeholder || 'Comma-separated values';
942
- const hint = ui.hint || 'Enter comma-separated values';
943
- const arrayValue = Array.isArray(value) ? value.join(', ') : (value || '');
944
-
945
- return m('.mb-4', [
946
- m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: col.name }, label),
947
- m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.placeholder-gray-400.dark:placeholder-slate-500.focus:outline-none.focus:ring-2.focus:ring-blue-500.dark:focus:ring-blue-400', {
948
- id: col.name,
949
- name: col.name,
950
- type: 'text',
951
- placeholder: placeholder,
952
- value: arrayValue,
953
- minlength: validations.minLength || validations.min,
954
- maxlength: validations.maxLength || validations.max,
955
- required: !col.nullable && !readonly,
956
- readonly: readonly,
957
- disabled: readonly,
958
- class: readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed' : '',
959
- oninput: (e) => {
960
- const arr = e.target.value.split(',').map(s => s.trim()).filter(s => s);
961
- onChange(arr);
962
- },
963
- }),
964
- hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
965
- ]);
966
- },
967
- };
968
-
969
- // Rich Text Field Renderer Component
970
- const RichTextField = {
971
- oncreate: (vnode) => {
972
- const { name, value = '', onchange, readonly = false } = vnode.attrs;
973
-
974
- // Load Quill if not already loaded
975
- if (typeof window.Quill === 'undefined') {
976
- const link = document.createElement('link');
977
- link.rel = 'stylesheet';
978
- link.href = 'https://cdn.quilljs.com/1.3.6/quill.snow.css';
979
- document.head.appendChild(link);
980
-
981
- const script = document.createElement('script');
982
- script.src = 'https://cdn.quilljs.com/1.3.6/quill.js';
983
- script.onload = () => {
984
- initEditor(vnode);
985
- };
986
- document.head.appendChild(script);
987
- } else {
988
- initEditor(vnode);
989
- }
990
-
991
- function initEditor(vnode) {
992
- const editorId = 'quill-editor-' + name;
993
- const editorEl = document.getElementById(editorId);
994
- const hiddenInput = document.getElementById(name + '-value');
995
- const isReadonly = readonly || false;
996
-
997
- if (editorEl && !editorEl._quill) {
998
- const quill = new window.Quill(editorEl, {
999
- theme: 'snow',
1000
- readOnly: isReadonly,
1001
- modules: {
1002
- toolbar: isReadonly ? false : [
1003
- [{ 'header': [1, 2, 3, false] }],
1004
- ['bold', 'italic', 'underline', 'strike'],
1005
- [{ 'list': 'ordered'}, { 'list': 'bullet' }],
1006
- ['link', 'image'],
1007
- ['clean']
1008
- ]
1009
- }
1010
- });
1011
-
1012
- // Set initial content
1013
- if (value) {
1014
- quill.root.innerHTML = value;
1015
- if (hiddenInput) {
1016
- hiddenInput.value = value;
1017
- }
1018
- }
1019
-
1020
- // Handle content changes
1021
- if (!isReadonly) {
1022
- quill.on('text-change', () => {
1023
- const content = quill.root.innerHTML;
1024
- if (hiddenInput) {
1025
- hiddenInput.value = content;
1026
- }
1027
- if (onchange) {
1028
- onchange(content);
1029
- }
1030
- });
1031
- }
1032
-
1033
- editorEl._quill = quill;
1034
- }
1035
- }
1036
- },
1037
-
1038
- onupdate: (vnode) => {
1039
- // Update editor content if value changed externally
1040
- const { name, value = '', readonly = false } = vnode.attrs;
1041
- const editorId = 'quill-editor-' + name;
1042
- const editorEl = document.getElementById(editorId);
1043
- const hiddenInput = document.getElementById(name + '-value');
1044
-
1045
- if (editorEl && editorEl._quill) {
1046
- const currentContent = editorEl._quill.root.innerHTML;
1047
- const newValue = value || '';
1048
- if (currentContent !== newValue) {
1049
- editorEl._quill.root.innerHTML = newValue;
1050
- if (hiddenInput) {
1051
- hiddenInput.value = newValue;
1052
- }
1053
- }
1054
- }
1055
- },
1056
-
1057
- view: (vnode) => {
1058
- const { name, col, value = '', onChange, readonly } = vnode.attrs;
1059
- const ui = col.ui || {};
1060
- const label = ui.label || formatColumnLabel(col.name);
1061
- const hint = ui.hint || '';
1062
- const editorId = 'quill-editor-' + name;
1063
- const required = !col.nullable && !readonly;
1064
-
1065
- return m('.mb-4', [
1066
- m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: name },
1067
- label,
1068
- required ? m('span.text-red-500', ' *') : null
1069
- ),
1070
- m('div.border.border-gray-300.dark:border-slate-600.rounded.bg-white.dark:bg-slate-900/50', {
1071
- id: editorId,
1072
- class: readonly ? 'bg-gray-100 dark:bg-slate-800 opacity-50' : '',
1073
- style: 'min-height: 200px;'
1074
- }),
1075
- m('input[type=hidden]', {
1076
- name,
1077
- id: name + '-value',
1078
- value: value || '',
1079
- }),
1080
- hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
1081
- ]);
1082
- }
1083
- };
1084
-
1085
- // File upload field (multipart POST to settings.uploadUrl; field name "file")
1086
- const FileUploadField = {
1087
- oncreate: (vnode) => {
1088
- const col = vnode.attrs.col;
1089
- const readonly = vnode.attrs.readonly;
1090
- if (readonly) return;
1091
- var cfg = window.__ADMIN_CONFIG__;
1092
- var uploadUrl = (cfg && cfg.settings && cfg.settings.uploadUrl) ? String(cfg.settings.uploadUrl) : '';
1093
- if (!uploadUrl) return;
1094
- const dropZoneId = 'drop-zone-' + col.name;
1095
- const dropZone = document.getElementById(dropZoneId);
1096
- if (!dropZone) return;
1097
- const meta = col.ui || {};
1098
- const onChange = vnode.attrs.onChange;
1099
- ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (eventName) {
1100
- dropZone.addEventListener(eventName, function (e) {
1101
- e.preventDefault();
1102
- e.stopPropagation();
1103
- });
1104
- });
1105
- ['dragenter', 'dragover'].forEach(function (eventName) {
1106
- dropZone.addEventListener(eventName, function () {
1107
- dropZone.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
1108
- });
1109
- });
1110
- ['dragleave', 'drop'].forEach(function (eventName) {
1111
- dropZone.addEventListener(eventName, function () {
1112
- dropZone.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
1113
- });
1114
- });
1115
- dropZone.addEventListener('drop', function (e) {
1116
- var files = e.dataTransfer.files;
1117
- if (files.length > 0) {
1118
- handleAdminFileUpload(files[0], onChange, meta);
1119
- }
1120
- });
1121
- var fileInput = dropZone.querySelector('input[type=file]');
1122
- if (fileInput) {
1123
- fileInput.addEventListener('change', function (e) {
1124
- if (e.target.files.length > 0) {
1125
- handleAdminFileUpload(e.target.files[0], onChange, meta);
1126
- }
1127
- });
1128
- }
1129
- },
1130
- view: (vnode) => {
1131
- const col = vnode.attrs.col;
1132
- const value = vnode.attrs.value || '';
1133
- const onChange = vnode.attrs.onChange;
1134
- const readonly = vnode.attrs.readonly || false;
1135
- const meta = col.ui || {};
1136
- const label = meta.label || formatColumnLabel(col.name);
1137
- const hint = meta.hint || '';
1138
- const required = !col.nullable && !readonly;
1139
- var uploadUrl = '';
1140
- try {
1141
- var cfg2 = window.__ADMIN_CONFIG__;
1142
- uploadUrl = (cfg2 && cfg2.settings && cfg2.settings.uploadUrl) ? String(cfg2.settings.uploadUrl) : '';
1143
- } catch (e) { uploadUrl = ''; }
1144
- var maxSize = meta.maxSize || meta.maxBytes || (10 * 1024 * 1024);
1145
- var accept = meta.accept || '*/*';
1146
- var dropZoneId = 'drop-zone-' + col.name;
1147
- if (readonly) {
1148
- return m('.mb-4', [
1149
- m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label, required ? m('span.text-red-500', ' *') : null),
1150
- value ? m('a.text-indigo-600.dark:text-indigo-400.break-all', { href: value, target: '_blank', rel: 'noopener noreferrer' }, value) : m('span.text-gray-400.dark:text-slate-500', '—'),
1151
- hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
1152
- ]);
1153
- }
1154
- if (!uploadUrl) {
1155
- return m('.mb-4', [
1156
- m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', { for: col.name }, label, required ? m('span.text-red-500', ' *') : null),
1157
- m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured. Enter a public URL or path manually.'),
1158
- m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.placeholder-gray-400.dark:placeholder-slate-500', {
1159
- type: 'text',
1160
- id: col.name,
1161
- name: col.name,
1162
- value: value,
1163
- placeholder: 'https://… or /uploads/…',
1164
- required: required,
1165
- oninput: function (e) { if (onChange) onChange(e.target.value); },
1166
- }),
1167
- hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
1168
- ]);
1169
- }
1170
- return m('.mb-4', [
1171
- m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label, required ? m('span.text-red-500', ' *') : null),
1172
- m('div#' + dropZoneId + '.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded-lg.p-8.text-center.bg-gray-50.dark:bg-slate-900/40', { style: 'cursor: pointer;' }, [
1173
- m('input[type=file].hidden', {
1174
- id: 'file-input-' + col.name,
1175
- accept: accept,
1176
- onchange: function (e) {
1177
- if (e.target.files.length > 0) {
1178
- handleAdminFileUpload(e.target.files[0], onChange, meta);
1179
- }
1180
- },
1181
- }),
1182
- m('div', [
1183
- m('p.text-gray-600.dark:text-slate-400.mb-2', 'Drag and drop a file here, or'),
1184
- m('label.text-blue-600.hover:text-blue-800.dark:text-blue-400.cursor-pointer', { for: 'file-input-' + col.name }, 'browse'),
1185
- ]),
1186
- value ? m('.mt-4.text-left', [
1187
- m('p.text-sm.text-gray-600.dark:text-slate-400.break-all', 'Current: ' + value),
1188
- m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-sm.mt-2', {
1189
- type: 'button',
1190
- onclick: function () { if (onChange) onChange(''); },
1191
- }, 'Remove'),
1192
- ]) : null,
1193
- ]),
1194
- m('input[type=hidden]', { name: col.name, value: typeof value === 'string' ? value : '' }),
1195
- m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', 'Max ' + Math.round(maxSize / 1024 / 1024) + ' MB (server enforces limits)'),
1196
- hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
1197
- ]);
1198
- },
1199
- };
1200
-
1201
- async function handleAdminFileUpload(file, onChange, meta) {
1202
- var uploadUrl = '';
1203
- try {
1204
- var cfg = window.__ADMIN_CONFIG__;
1205
- uploadUrl = (cfg && cfg.settings && cfg.settings.uploadUrl) ? String(cfg.settings.uploadUrl) : '';
1206
- } catch (e) {}
1207
- if (!uploadUrl) {
1208
- alert('Upload URL is not configured.');
1209
- return;
1210
- }
1211
- var maxSize = meta.maxSize || meta.maxBytes || (10 * 1024 * 1024);
1212
- if (file.size > maxSize) {
1213
- alert('File too large (max ' + Math.round(maxSize / 1024 / 1024) + ' MB).');
1214
- return;
1215
- }
1216
- var fd = new FormData();
1217
- fd.append('file', file);
1218
- try {
1219
- var res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
1220
- var data = {};
1221
- try { data = await res.json(); } catch (e2) { data = {}; }
1222
- if (!res.ok) {
1223
- alert(data.message || data.error || ('Upload failed (' + res.status + ')'));
1224
- return;
1225
- }
1226
- var url = data.url || data.publicUrl || '';
1227
- if (onChange) onChange(url);
1228
- m.redraw();
1229
- } catch (err) {
1230
- alert(err.message || 'Upload failed');
1231
- }
1232
- }
1233
-
1234
- // Get appropriate renderer for a column type
1235
- function getFieldRenderer(col, modelMeta) {
1236
- // Check for custom field first
1237
- if (col.customField && col.customField.type) {
1238
- if (col.customField.type === 'rich-text') {
1239
- return (col, value, onChange, readonly) => {
1240
- return m(RichTextField, {
1241
- name: col.name,
1242
- col,
1243
- value: value || '',
1244
- onChange,
1245
- readonly: readonly || false,
1246
- });
1247
- };
1248
- }
1249
- if (col.customField.type === 'file-upload') {
1250
- return (col, value, onChange, readonly) => {
1251
- return m(FileUploadField, {
1252
- col,
1253
- value: value || '',
1254
- onChange,
1255
- readonly: readonly || false,
1256
- });
1257
- };
1258
- }
1259
- // Add other custom field types here if needed
1260
- }
1261
-
1262
- if (col.type === 'file') {
1263
- return (col, value, onChange, readonly) => {
1264
- return m(FileUploadField, {
1265
- col,
1266
- value: value || '',
1267
- onChange,
1268
- readonly: readonly || false,
1269
- });
1270
- };
1271
- }
1272
-
1273
- // Fallback to standard type renderers
1274
- const typeMap = {
1275
- string: 'string',
1276
- text: 'text',
1277
- integer: 'integer',
1278
- bigint: 'integer',
1279
- float: 'float',
1280
- decimal: 'float',
1281
- boolean: 'boolean',
1282
- date: 'date',
1283
- datetime: 'datetime',
1284
- timestamp: 'datetime',
1285
- enum: 'enum',
1286
- json: 'json',
1287
- array: 'array',
1288
- uuid: 'string',
1289
- nanoid: 'string',
1290
- };
1291
- return FieldRenderers[typeMap[col.type] || 'string'];
1292
- }
1293
-
1294
- // Check if a column is auto-generated (readonly)
1295
- function isAutoColumn(col) {
1296
- // Primary key with auto-increment is readonly
1297
- if (col.primary || col.autoIncrement) return 'primary';
1298
- // Auto timestamps are always readonly
1299
- if (col.auto === 'create' || col.auto === 'update') return 'auto';
1300
- // Common timestamp field names
1301
- if (col.name === 'created_at' || col.name === 'updated_at') return 'auto';
1302
- return false;
1303
- }
1304
-
1305
- // Check if rich-text content is empty
1306
- function isRichTextEmpty(value) {
1307
- if (!value) return true;
1308
- // Remove all HTML tags and check if only whitespace remains
1309
- const stripped = value.replace(/<[^>]*>/g, '').trim();
1310
- // Check for common empty Quill outputs
1311
- return stripped === '' || value === '<p><br></p>' || value === '<p></p>';
1312
- }
1313
-
1314
- // Login Form Component
1315
- const LoginForm = {
1316
- view: () => m('.min-h-screen.flex.items-center.justify-center.p-4.sm:p-6.bg-gradient-to-br.from-blue-600.via-indigo-600.to-purple-700', [
1317
- m('.w-full.max-w-md', [
1318
- m('.bg-white dark:bg-slate-800.rounded-2xl.shadow-2xl.p-6.sm:p-8', [
1319
- m('div.text-center.mb-6', [
1320
- m('h1.text-2xl.sm:text-3xl.font-bold.text-gray-900.dark:text-slate-50', 'Admin Login'),
1321
- m('p.text-gray-500.dark:text-slate-400.text-sm.mt-1', 'Sign in to your account'),
1322
- ]),
1323
- m('form', {
1324
- onsubmit: async (e) => {
1325
- e.preventDefault();
1326
- state.loading = true;
1327
- state.error = null;
1328
- try {
1329
- const data = new FormData(e.target);
1330
- const result = await api.post('/auth/login', {
1331
- email: data.get('email'),
1332
- password: data.get('password'),
1333
- });
1334
- state.user = result.user;
1335
- m.route.set('/');
1336
- } catch (err) {
1337
- state.error = err.message;
1338
- } finally {
1339
- state.loading = false;
1340
- }
1341
- }
1342
- }, [
1343
- state.error ? m('.bg-red-50.border.border-red-200.text-red-700.dark:bg-red-950/50.dark:border-red-800/80.dark:text-red-200.px-4.py-3.rounded-lg.mb-4.text-sm', state.error) : null,
1344
- m('.mb-4', [
1345
- m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-2', { for: 'email' }, 'Email'),
1346
- m('input#email.w-full.px-3.py-2.5.bg-white.text-gray-900.border.border-gray-300.rounded-lg.placeholder-gray-400.focus:ring-2.focus:ring-blue-500.focus:border-blue-500.transition-colors.dark:bg-slate-900/80.dark:border-slate-500.dark:text-slate-100.dark:placeholder-slate-500', {
1347
- type: 'email',
1348
- name: 'email',
1349
- required: true,
1350
- placeholder: 'admin@example.com',
1351
- }),
1352
- ]),
1353
- m('.mb-6', [
1354
- m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-2', { for: 'password' }, 'Password'),
1355
- m('input#password.w-full.px-3.py-2.5.bg-white.text-gray-900.border.border-gray-300.rounded-lg.focus:ring-2.focus:ring-blue-500.focus:border-blue-500.transition-colors.dark:bg-slate-900/80.dark:border-slate-500.dark:text-slate-100', {
1356
- type: 'password',
1357
- name: 'password',
1358
- required: true,
1359
- }),
1360
- ]),
1361
- m('button.w-full.bg-blue-600.text-white.py-2.5.px-4.rounded-lg.font-medium.hover:bg-blue-700.focus:ring-2.focus:ring-blue-500.focus:ring-offset-2.dark:focus:ring-offset-slate-800.disabled:opacity-50.transition-colors', {
1362
- type: 'submit',
1363
- disabled: state.loading,
1364
- }, state.loading ? 'Logging in...' : 'Sign in'),
1365
- ]),
1366
- ]),
1367
- ]),
1368
- ]),
1369
- };
1370
-
1371
- // Setup Form Component
1372
- const SetupForm = {
1373
- view: () => m('.min-h-screen.flex.items-center.justify-center.p-4.sm:p-6.bg-gradient-to-br.from-blue-600.via-indigo-600.to-purple-700', [
1374
- m('.w-full.max-w-md', [
1375
- m('.bg-white dark:bg-slate-800.rounded-2xl.shadow-2xl.p-6.sm:p-8', [
1376
- m('div.text-center.mb-6', [
1377
- m('h1.text-2xl.sm:text-3xl.font-bold.text-gray-900.dark:text-slate-50', 'Setup Admin Account'),
1378
- m('p.text-gray-500.dark:text-slate-400.text-sm.mt-1', 'Create the first admin user account.'),
1379
- ]),
1380
- m('form', {
1381
- onsubmit: async (e) => {
1382
- e.preventDefault();
1383
- state.loading = true;
1384
- state.error = null;
1385
- try {
1386
- const data = new FormData(e.target);
1387
- const result = await api.post('/auth/setup', {
1388
- email: data.get('email'),
1389
- password: data.get('password'),
1390
- name: data.get('name'),
1391
- });
1392
- state.user = result.user;
1393
- state.needsSetup = false;
1394
- m.route.set('/');
1395
- } catch (err) {
1396
- state.error = err.message;
1397
- } finally {
1398
- state.loading = false;
1399
- }
1400
- }
1401
- }, [
1402
- state.error ? m('.bg-red-50.border.border-red-200.text-red-700.dark:bg-red-950/50.dark:border-red-800/80.dark:text-red-200.px-4.py-3.rounded-lg.mb-4.text-sm', state.error) : null,
1403
- m('.mb-4', [
1404
- m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-2', { for: 'name' }, 'Name'),
1405
- m('input#name.w-full.px-3.py-2.5.bg-white.text-gray-900.border.border-gray-300.rounded-lg.focus:ring-2.focus:ring-blue-500.focus:border-blue-500.transition-colors.dark:bg-slate-900/80.dark:border-slate-500.dark:text-slate-100', {
1406
- type: 'text',
1407
- name: 'name',
1408
- required: true,
1409
- }),
1410
- ]),
1411
- m('.mb-4', [
1412
- m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-2', { for: 'email' }, 'Email'),
1413
- m('input#email.w-full.px-3.py-2.5.bg-white.text-gray-900.border.border-gray-300.rounded-lg.placeholder-gray-400.focus:ring-2.focus:ring-blue-500.focus:border-blue-500.transition-colors.dark:bg-slate-900/80.dark:border-slate-500.dark:text-slate-100.dark:placeholder-slate-500', {
1414
- type: 'email',
1415
- name: 'email',
1416
- required: true,
1417
- placeholder: 'admin@example.com',
1418
- }),
1419
- ]),
1420
- m('.mb-6', [
1421
- m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-2', { for: 'password' }, 'Password'),
1422
- m('input#password.w-full.px-3.py-2.5.bg-white.text-gray-900.border.border-gray-300.rounded-lg.focus:ring-2.focus:ring-blue-500.focus:border-blue-500.transition-colors.dark:bg-slate-900/80.dark:border-slate-500.dark:text-slate-100', {
1423
- type: 'password',
1424
- name: 'password',
1425
- required: true,
1426
- }),
1427
- ]),
1428
- m('button.w-full.bg-blue-600.text-white.py-2.5.px-4.rounded-lg.font-medium.hover:bg-blue-700.focus:ring-2.focus:ring-blue-500.focus:ring-offset-2.dark:focus:ring-offset-slate-800.disabled:opacity-50.transition-colors', {
1429
- type: 'submit',
1430
- disabled: state.loading,
1431
- }, state.loading ? 'Creating...' : 'Create Admin Account'),
1432
- ]),
1433
- ]),
1434
- ]),
1435
- ]),
1436
- };
1437
-
1438
- // Layout Component is defined in menu.js module (uses Sidebar)
1439
-
1440
- // Model List Component
1441
- const ModelList = {
1442
- _load() {
1443
- state.loading = true;
1444
- state.error = null;
1445
- m.redraw();
1446
- return api.get('/models')
1447
- .then(result => {
1448
- state.models = result.models || [];
1449
- })
1450
- .catch(err => {
1451
- state.error = err.message;
1452
- })
1453
- .finally(() => {
1454
- state.loading = false;
1455
- m.redraw();
1456
- });
1457
- },
1458
- oninit(vnode) {
1459
- vnode.state._stopPoll = null;
1460
- vnode.state.refreshing = false;
1461
- ModelList._load();
1462
- vnode.state._stopPoll = runAdminAutoRefresh(() => ModelList._load());
1463
- },
1464
- onremove(vnode) {
1465
- if (vnode.state._stopPoll) vnode.state._stopPoll();
1466
- },
1467
- view: (vnode) => m(Layout, [
1468
- m('.flex.items-center.justify-between.mb-6', [
1469
- m('h2.text-2xl.font-bold', 'Models'),
1470
- m(RefreshIconButton, {
1471
- title: 'Reload models',
1472
- spinning: vnode.state.refreshing || state.loading,
1473
- onclick: () => {
1474
- vnode.state.refreshing = true;
1475
- m.redraw();
1476
- ModelList._load().finally(() => {
1477
- vnode.state.refreshing = false;
1478
- m.redraw();
1479
- });
1480
- },
1481
- }),
1482
- ]),
1483
- state.error ? m('.bg-red-100.border.border-red-400.text-red-700.px-4.py-3.rounded.mb-4', state.error) : null,
1484
- state.loading
1485
- ? m('p.text-gray-600', 'Loading models...')
1486
- : state.models.length === 0
1487
- ? m('p.text-gray-600', 'No models enabled in admin panel. Make sure your models have admin: { enabled: true }')
1488
- : m('.grid.grid-cols-1.md:grid-cols-2.lg:grid-cols-3.gap-4',
1489
- state.models.map(model =>
1490
- m('a.bg-white dark:bg-slate-800.p-6.rounded.shadow.hover:shadow-lg.transition', {
1491
- href: '/models/' + model.name,
1492
- onclick: (e) => {
1493
- e.preventDefault();
1494
- m.route.set('/models/' + model.name);
1495
- }
1496
- }, [
1497
- model.icon ? m('span.text-2xl.mb-2.block', model.icon) : null,
1498
- m('h3.font-semibold.text-lg', model.label || model.name),
1499
- m('p.text-sm.text-gray-600 dark:text-slate-400.mt-2', model.table),
1500
- ])
1501
- )
1502
- ),
1503
- ]),
1504
- };
1505
-
1506
- // Format cell value based on column type
1507
- function formatCellValue(value, col) {
1508
- if (value === null || value === undefined) {
1509
- return m('span.text-gray-400 dark:text-slate-500.italic', 'null');
1510
- }
1511
-
1512
- switch (col?.type) {
1513
- case 'boolean':
1514
- return value
1515
- ? m('span.inline-flex.items-center.px-2.py-1.rounded-full.text-xs.font-medium.bg-green-100.text-green-800', '✓ Yes')
1516
- : m('span.inline-flex.items-center.px-2.py-1.rounded-full.text-xs.font-medium.bg-gray-100 dark:bg-slate-800.text-gray-600', '✗ No');
1517
-
1518
- case 'datetime':
1519
- case 'timestamp':
1520
- try {
1521
- const date = new Date(value);
1522
- return date.toLocaleString();
1523
- } catch { return String(value); }
1524
-
1525
- case 'date':
1526
- try {
1527
- const date = new Date(value);
1528
- return date.toLocaleDateString();
1529
- } catch { return String(value); }
1530
-
1531
- case 'json':
1532
- case 'array':
1533
- if (Array.isArray(value)) {
1534
- return value.length > 0
1535
- ? m('span.text-xs.bg-gray-100 dark:bg-slate-800.px-2.py-1.rounded', value.slice(0, 3).join(', ') + (value.length > 3 ? '...' : ''))
1536
- : m('span.text-gray-400', '[]');
1537
- }
1538
- if (typeof value === 'object') {
1539
- return m('span.text-xs.bg-gray-100 dark:bg-slate-800.px-2.py-1.rounded.font-mono', '{...}');
1540
- }
1541
- return String(value);
1542
-
1543
- case 'text': {
1544
- const textStr = String(value);
1545
- return textStr.length > 50 ? textStr.substring(0, 50) + '...' : textStr;
1546
- }
1547
-
1548
- case 'file': {
1549
- const s = String(value);
1550
- const short = s.length > 72 ? s.substring(0, 72) + '…' : s;
1551
- if (/^https?:\\/\\//.test(s) || s.startsWith('/')) {
1552
- return m('a.text-indigo-600.dark:text-indigo-400.hover:underline.break-all', {
1553
- href: s,
1554
- target: '_blank',
1555
- rel: 'noopener noreferrer',
1556
- }, short);
1557
- }
1558
- return short || m('span.text-gray-400', '—');
1559
- }
1560
-
1561
- default: {
1562
- const str = String(value);
1563
- return str.length > 100 ? str.substring(0, 100) + '...' : str;
1564
- }
1565
- }
1566
- }
1567
-
1568
- // Load bulk-updatable fields for a model
1569
- async function loadBulkFields(modelName) {
1570
- try {
1571
- const response = await api.get('/extensions/bulk-fields/' + modelName);
1572
- state.bulkFields = response.fields || [];
1573
- m.redraw();
1574
- } catch (err) {
1575
- console.error('Failed to load bulk fields:', err);
1576
- state.bulkFields = [];
1577
- }
1578
- }
1579
-
1580
- // Execute bulk field update
1581
- async function executeBulkFieldUpdate(modelName, field, value, ids) {
1582
- try {
1583
- const response = await api.post('/extensions/bulk-update/' + modelName, {
1584
- ids: ids,
1585
- field: field,
1586
- value: value,
1587
- });
1588
- return response;
1589
- } catch (err) {
1590
- throw err;
1591
- }
1592
- }
1593
-
1594
- // Execute bulk field update
1595
- async function executeBulkFieldUpdateWithSelectAll(modelName, field, value, selectedIds, selectAllMode, filters) {
1596
- try {
1597
- const payload = selectAllMode
1598
- ? { selectAll: true, filters: filters, field: field, value: value }
1599
- : { ids: selectedIds, field: field, value: value };
1600
- const response = await api.post('/extensions/bulk-update/' + modelName, payload);
1601
- return response;
1602
- } catch (err) {
1603
- throw err;
1604
- }
1605
- }
1606
-
1607
- // Bulk Field Update Dropdown Component
1608
- const BulkFieldUpdateDropdown = {
1609
- view: (vnode) => {
1610
- const { modelName, selectedIds, selectAllMode, filters, onComplete } = vnode.attrs;
1611
-
1612
- if (!state.bulkFields || state.bulkFields.length === 0) {
1613
- return null;
1614
- }
1615
-
1616
- return m('.relative.inline-block', [
1617
- // Dropdown trigger
1618
- m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-purple-600.bg-white dark:bg-slate-800.border.border-purple-200.rounded.hover:bg-purple-50.transition-colors', {
1619
- disabled: state.bulkActionInProgress,
1620
- onclick: (e) => {
1621
- e.stopPropagation();
1622
- state.bulkFieldDropdownOpen = !state.bulkFieldDropdownOpen;
1623
- state.selectedBulkField = null;
1624
- m.redraw();
1625
- },
1626
- }, [
1627
- m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1628
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z' })
1629
- ),
1630
- 'Set Field',
1631
- m('svg.w-4.h-4.ml-1', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1632
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M19 9l-7 7-7-7' })
1633
- ),
1634
- ]),
1635
-
1636
- // Dropdown menu
1637
- state.bulkFieldDropdownOpen && m('.absolute.z-50.mt-1.w-64.bg-white dark:bg-slate-800.rounded-lg.shadow-lg.border.border-gray-200 dark:border-slate-600.overflow-hidden', {
1638
- style: 'left: 0; top: 100%;',
1639
- onclick: (e) => e.stopPropagation(),
1640
- }, [
1641
- // Close button area click handler
1642
- m('.fixed.inset-0.z-40', {
1643
- onclick: () => {
1644
- state.bulkFieldDropdownOpen = false;
1645
- state.selectedBulkField = null;
1646
- m.redraw();
1647
- },
1648
- }),
1649
-
1650
- // Dropdown content
1651
- m('.relative.z-50.bg-white', [
1652
- // Header
1653
- m('.px-3.py-2.bg-gray-50 dark:bg-slate-900.border-b.border-gray-200', [
1654
- m('span.text-xs.font-medium.text-gray-500 dark:text-slate-400.uppercase.tracking-wider',
1655
- state.selectedBulkField ? 'Select Value' : 'Select Field'
1656
- ),
1657
- ]),
1658
-
1659
- // Field list or value list
1660
- m('.max-h-64.overflow-y-auto', [
1661
- state.selectedBulkField
1662
- // Show values for selected field
1663
- ? state.selectedBulkField.options.map(option =>
1664
- m('button.w-full.px-3.py-2.text-left.text-sm.hover:bg-purple-50.flex.items-center.justify-between.transition-colors', {
1665
- onclick: async () => {
1666
- state.bulkActionInProgress = true;
1667
- state.bulkFieldDropdownOpen = false;
1668
- m.redraw();
1669
-
1670
- try {
1671
- await executeBulkFieldUpdateWithSelectAll(modelName, state.selectedBulkField.name, option.value, selectedIds, selectAllMode, filters);
1672
- state.selectedBulkField = null;
1673
- if (onComplete) onComplete();
1674
- } catch (err) {
1675
- alert('Error: ' + err.message);
1676
- } finally {
1677
- state.bulkActionInProgress = false;
1678
- m.redraw();
1679
- }
1680
- },
1681
- }, [
1682
- m('span.text-gray-700', String(option.label)),
1683
- state.selectedBulkField.type === 'boolean' && m('span.ml-2',
1684
- option.value === true
1685
- ? m('span.inline-flex.items-center.px-2.py-0.5.rounded-full.text-xs.font-medium.bg-green-100.text-green-800', '✓')
1686
- : m('span.inline-flex.items-center.px-2.py-0.5.rounded-full.text-xs.font-medium.bg-gray-100 dark:bg-slate-800.text-gray-600', '✗')
1687
- ),
1688
- ])
1689
- )
1690
- // Show field list
1691
- : state.bulkFields.map(field =>
1692
- m('button.w-full.px-3.py-2.text-left.text-sm.hover:bg-purple-50.flex.items-center.justify-between.transition-colors', {
1693
- onclick: () => {
1694
- state.selectedBulkField = field;
1695
- m.redraw();
1696
- },
1697
- }, [
1698
- m('.flex.items-center.gap-2', [
1699
- m('span.text-gray-700', formatColumnLabel(field.label || field.name)),
1700
- m('span.text-xs.text-gray-400 dark:text-slate-500.uppercase', field.type),
1701
- ]),
1702
- m('svg.w-4.h-4.text-gray-400', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1703
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 5l7 7-7 7' })
1704
- ),
1705
- ])
1706
- ),
1707
-
1708
- // Back button when viewing values
1709
- state.selectedBulkField && m('button.w-full.px-3.py-2.text-left.text-sm.text-gray-500 dark:text-slate-400.hover:bg-gray-50 dark:hover:bg-slate-800/50 dark:hover:bg-slate-800/50.border-t.border-gray-100 dark:border-slate-700.flex.items-center.gap-1', {
1710
- onclick: () => {
1711
- state.selectedBulkField = null;
1712
- m.redraw();
1713
- },
1714
- }, [
1715
- m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1716
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M15 19l-7-7 7-7' })
1717
- ),
1718
- 'Back to fields',
1719
- ]),
1720
- ]),
1721
- ]),
1722
- ]),
1723
- ]);
1724
- },
1725
- };
1726
-
1727
- // Get columns to display in table (limit to reasonable number)
1728
- function getDisplayColumns(columns) {
1729
- if (!columns || columns.length === 0) return [];
1730
-
1731
- // Filter out hidden columns (password_hash, api_token, etc.)
1732
- const visible = [...columns].filter((col) => !col.hidden);
1733
-
1734
- // Prioritize: id, name/title, then others (excluding long text/json fields)
1735
- const priority = ['id', 'name', 'title', 'email', 'slug', 'status', 'published', 'created_at'];
1736
- const exclude = ['password', 'content', 'body', 'description']; // Usually too long
1737
-
1738
- const sorted = visible.sort((a, b) => {
1739
- const aIdx = priority.indexOf(a.name);
1740
- const bIdx = priority.indexOf(b.name);
1741
- if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
1742
- if (aIdx !== -1) return -1;
1743
- if (bIdx !== -1) return 1;
1744
- return 0;
1745
- });
1746
-
1747
- // Filter and limit
1748
- return sorted
1749
- .filter(col => !exclude.includes(col.name) && col.type !== 'text' && col.type !== 'json')
1750
- .slice(0, 6); // Max 6 columns for readability
1751
- }
1752
-
1753
- // Build query string from filters
1754
- function buildFilterQuery(filters) {
1755
- if (!filters || Object.keys(filters).length === 0) return '';
1756
-
1757
- const params = [];
1758
- for (const [col, filter] of Object.entries(filters)) {
1759
- if (!filter || (filter.value === '' && !filter.from && !filter.to)) continue;
1760
-
1761
- if (filter.op === 'between') {
1762
- params.push('filter[' + col + '][op]=between');
1763
- if (filter.from) params.push('filter[' + col + '][from]=' + encodeURIComponent(filter.from));
1764
- if (filter.to) params.push('filter[' + col + '][to]=' + encodeURIComponent(filter.to));
1765
- } else if (filter.op === 'in' && Array.isArray(filter.value)) {
1766
- params.push('filter[' + col + '][op]=in');
1767
- filter.value.forEach(v => params.push('filter[' + col + '][value]=' + encodeURIComponent(v)));
1768
- } else {
1769
- if (filter.op) params.push('filter[' + col + '][op]=' + encodeURIComponent(filter.op));
1770
- if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
1771
- params.push('filter[' + col + '][value]=' + encodeURIComponent(filter.value));
1772
- }
1773
- }
1774
- }
1775
-
1776
- return params.length > 0 ? '&' + params.join('&') : '';
1777
- }
1778
-
1779
- // Parse query string to filters
1780
- function parseFilterQuery(queryString) {
1781
- const filters = {};
1782
- if (!queryString) return filters;
1783
-
1784
- const params = new URLSearchParams(queryString);
1785
- const filterParams = {};
1786
-
1787
- // Group filter parameters using simple string parsing (no regex needed)
1788
- for (const [key, value] of params.entries()) {
1789
- // Parse filter[column][prop] format
1790
- if (key.startsWith('filter[')) {
1791
- const firstClose = key.indexOf(']');
1792
- const secondOpen = key.indexOf('[', firstClose);
1793
- const secondClose = key.indexOf(']', secondOpen);
1794
-
1795
- if (firstClose > 7 && secondOpen > firstClose && secondClose > secondOpen) {
1796
- const col = key.substring(7, firstClose);
1797
- const prop = key.substring(secondOpen + 1, secondClose);
1798
-
1799
- if (!filterParams[col]) filterParams[col] = {};
1800
- if (prop === 'value' && filterParams[col].value) {
1801
- // Multiple values for 'in' operator
1802
- if (!Array.isArray(filterParams[col].value)) {
1803
- filterParams[col].value = [filterParams[col].value];
1804
- }
1805
- filterParams[col].value.push(value);
1806
- } else {
1807
- filterParams[col][prop] = value;
1808
- }
1809
- }
1810
- }
1811
- }
1812
-
1813
- // Convert to filter format
1814
- for (const [col, data] of Object.entries(filterParams)) {
1815
- if (data.op === 'between') {
1816
- filters[col] = { op: 'between', from: data.from || '', to: data.to || '' };
1817
- } else if (data.op === 'in') {
1818
- filters[col] = { op: 'in', value: Array.isArray(data.value) ? data.value : [data.value] };
1819
- } else {
1820
- filters[col] = { op: data.op || 'contains', value: data.value || '' };
1821
- }
1822
- }
1823
-
1824
- return filters;
1825
- }
1826
-
1827
- // Load records with pagination and filters
1828
- function loadRecords(modelName, page = 1, filters = null) {
1829
- state.loading = true;
1830
- state.error = null;
1831
-
1832
- const perPage = state.pagination.perPage || 20;
1833
- const activeFilters = filters !== null ? filters : state.filters;
1834
- const filterQuery = buildFilterQuery(activeFilters);
1835
-
1836
- // Update URL with filters
1837
- const queryParams = new URLSearchParams();
1838
- queryParams.set('page', page);
1839
- if (state.trashedView) {
1840
- queryParams.set('trashed', 'only');
1841
- }
1842
- if (Object.keys(activeFilters).length > 0) {
1843
- for (const [col, filter] of Object.entries(activeFilters)) {
1844
- if (!filter || (filter.value === '' && !filter.from && !filter.to)) continue;
1845
- if (filter.op === 'between') {
1846
- queryParams.set('filter[' + col + '][op]', 'between');
1847
- if (filter.from) queryParams.set('filter[' + col + '][from]', filter.from);
1848
- if (filter.to) queryParams.set('filter[' + col + '][to]', filter.to);
1849
- } else if (filter.op === 'in' && Array.isArray(filter.value)) {
1850
- queryParams.set('filter[' + col + '][op]', 'in');
1851
- filter.value.forEach(v => queryParams.append('filter[' + col + '][value]', v));
1852
- } else {
1853
- if (filter.op) queryParams.set('filter[' + col + '][op]', filter.op);
1854
- if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
1855
- queryParams.set('filter[' + col + '][value]', filter.value);
1856
- }
1857
- }
1858
- }
1859
- }
1860
-
1861
- // Update browser URL without reload
1862
- const newUrl = window.location.pathname + (queryParams.toString() ? '?' + queryParams.toString() : '');
1863
- window.history.replaceState({}, '', newUrl);
1864
-
1865
- const trashedParam = state.trashedView ? '&trashed=only' : '';
1866
- return api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + trashedParam + filterQuery)
1867
- .then(result => {
1868
- state.records = result.data || [];
1869
- state.pagination = {
1870
- page: result.pagination?.page || page,
1871
- perPage: result.pagination?.perPage || perPage,
1872
- total: result.pagination?.total || state.records.length,
1873
- totalPages: result.pagination?.totalPages || Math.ceil((result.pagination?.total || state.records.length) / perPage),
1874
- };
1875
- })
1876
- .catch(err => {
1877
- state.error = err.message;
1878
- })
1879
- .finally(() => {
1880
- state.loading = false;
1881
- m.redraw();
1882
- });
1883
- }
1884
-
1885
- // Initialize model data
1886
- function initializeModelView(modelName) {
1887
- state.records = [];
1888
- state.currentModelMeta = null;
1889
- state.pagination = { page: 1, perPage: 20, total: 0, totalPages: 0 };
1890
- state.filterPanelOpen = false;
1891
- state.filterDrawerOpen = false;
1892
- state.selectedRecords = new Set(); // Bulk selection
1893
- state.selectAllMode = false; // Reset select all mode
1894
- state.trashedView = false; // Soft delete: show trashed records
1895
- state.bulkActionInProgress = false;
1896
- state.bulkFields = []; // Reset bulk fields
1897
- state.bulkFieldDropdownOpen = false;
1898
- state.selectedBulkField = null;
1899
- state._currentModelName = modelName;
1900
-
1901
- // Parse filters from URL query string
1902
- const urlParams = new URLSearchParams(window.location.search);
1903
- const page = parseInt(urlParams.get('page')) || 1;
1904
- state.filters = parseFilterQuery(window.location.search);
1905
-
1906
- // Load model metadata first, then records
1907
- state.loading = true;
1908
- api.get('/models/' + modelName)
1909
- .then(modelMeta => {
1910
- state.currentModelMeta = modelMeta;
1911
- state.currentModel = modelMeta;
1912
- // Load bulk-updatable fields for this model
1913
- loadBulkFields(modelName);
1914
- return loadRecords(modelName, page, state.filters);
1915
- })
1916
- .catch(err => {
1917
- state.error = err.message;
1918
- state.loading = false;
1919
- m.redraw();
1920
- });
1921
- }
1922
-
1923
- // Record List Component - displays records with dynamic columns
1924
- const RecordList = {
1925
- oninit(vnode) {
1926
- vnode.state._stopPoll = null;
1927
- vnode.state.manualRefresh = false;
1928
- const modelName = m.route.param('model');
1929
- initializeModelView(modelName);
1930
- vnode.state._stopPoll = runAdminAutoRefresh(() => {
1931
- const mName = m.route.param('model');
1932
- if (state._currentModelName !== mName || !state.currentModelMeta) return;
1933
- loadRecords(mName, state.pagination.page, state.filters);
1934
- });
1935
- },
1936
- onremove(vnode) {
1937
- if (vnode.state._stopPoll) vnode.state._stopPoll();
1938
- },
1939
- onbeforeupdate: () => {
1940
- // Check if model changed (navigation between different models)
1941
- const modelName = m.route.param('model');
1942
- if (state._currentModelName !== modelName) {
1943
- initializeModelView(modelName);
1944
- }
1945
- return true;
1946
- },
1947
- view: (vnode) => {
1948
- const modelName = m.route.param('model');
1949
- const modelMeta = state.currentModelMeta;
1950
- const displayColumns = modelMeta ? getDisplayColumns(modelMeta.columns) : [];
1951
- const primaryKey = modelMeta?.primaryKey || 'id';
1952
-
1953
- // Count active filters
1954
- const activeFilterCount = Object.values(state.filters || {}).filter(f =>
1955
- f && (f.value !== '' || f.from || f.to)
1956
- ).length;
1957
-
1958
- const breadcrumbs = [
1959
- { label: modelMeta?.label || modelName, href: '/models/' + modelName },
1960
- ];
1961
-
1962
- // Filter change handler with auto-apply for quick filters
1963
- const handleQuickFilterChange = (colName, filter) => {
1964
- const newFilters = { ...state.filters };
1965
- if (filter === null) {
1966
- delete newFilters[colName];
1967
- } else {
1968
- newFilters[colName] = filter;
1969
- }
1970
- state.filters = newFilters;
1971
- loadRecords(modelName, 1, newFilters);
1972
- };
1973
-
1974
- // Filter change handler for drawer (no auto-apply)
1975
- const handleDrawerFilterChange = (colName, filter) => {
1976
- const newFilters = { ...state.filters };
1977
- if (filter === null) {
1978
- delete newFilters[colName];
1979
- } else {
1980
- newFilters[colName] = filter;
1981
- }
1982
- state.filters = newFilters;
1983
- m.redraw();
1984
- };
1985
-
1986
- return m(Layout, { breadcrumbs }, [
1987
- // Header
1988
- m('.flex.items-center.justify-between.mb-4', [
1989
- m('.flex.items-center.gap-3', [
1990
- m('h2.text-2xl.font-bold', modelMeta?.label || modelName),
1991
- m(RefreshIconButton, {
1992
- title: 'Reload records',
1993
- spinning: vnode.state.manualRefresh || state.loading,
1994
- onclick: () => {
1995
- vnode.state.manualRefresh = true;
1996
- m.redraw();
1997
- loadRecords(modelName, state.pagination.page, state.filters).finally(() => {
1998
- vnode.state.manualRefresh = false;
1999
- m.redraw();
2000
- });
2001
- },
2002
- }),
2003
- modelMeta?.softDelete ? m('.flex.rounded-lg.border.border-gray-200 dark:border-slate-600.p-0.5', [
2004
- m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
2005
- class: !state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900 dark:hover:text-slate-100 dark:hover:text-slate-100',
2006
- onclick: () => {
2007
- state.trashedView = false;
2008
- loadRecords(modelName, 1);
2009
- },
2010
- }, 'Active'),
2011
- m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
2012
- class: state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900 dark:hover:text-slate-100 dark:hover:text-slate-100',
2013
- onclick: () => {
2014
- state.trashedView = true;
2015
- loadRecords(modelName, 1);
2016
- },
2017
- }, 'Trash'),
2018
- ]) : null,
2019
- ]),
2020
- m('.flex.flex-wrap.items-center.gap-2', [
2021
- m('button.inline-flex.items-center.gap-2.px-4.py-2.text-sm.font-medium.text-indigo-700.bg-white.dark:bg-slate-800.border.border-indigo-200.rounded-lg.hover:bg-indigo-50.transition-colors', {
2022
- onclick: async () => {
2023
- try {
2024
- const payload = { selectAll: true, filters: state.filters };
2025
- if (state.trashedView) payload.trashed = 'only';
2026
- await downloadDataExchangeXlsx(modelName, payload);
2027
- } catch (err) {
2028
- alert('Error: ' + err.message);
2029
- }
2030
- },
2031
- }, [
2032
- m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
2033
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' })
2034
- ),
2035
- 'Export Excel',
2036
- ]),
2037
- !state.trashedView ? m('input[type=file]', {
2038
- id: 'data-exchange-import-' + modelName,
2039
- style: 'display:none',
2040
- accept: '.csv,.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv',
2041
- onchange: async (e) => {
2042
- const file = e.target.files && e.target.files[0];
2043
- e.target.value = '';
2044
- if (!file) return;
2045
- const adminPath = window.__ADMIN_PATH__ || '/_admin';
2046
- const mode = window.confirm('OK = upsert by id, Cancel = insert only') ? 'upsert' : 'insert';
2047
- const upsertKey = 'id';
2048
- const fd = new FormData();
2049
- fd.append('file', file);
2050
- try {
2051
- const res = await fetch(
2052
- adminPath + '/api/data-exchange/import/' + modelName +
2053
- '?mode=' + encodeURIComponent(mode) + '&upsertKey=' + encodeURIComponent(upsertKey),
2054
- { method: 'POST', body: fd, credentials: 'include' }
2055
- );
2056
- const body = await res.json().catch(function () { return ({}); });
2057
- if (!res.ok) {
2058
- throw new Error(body.error || 'Import failed');
2059
- }
2060
- var msg = 'Import finished: created ' + body.created + ', updated ' + (body.updated || 0) + ', failed ' + (body.failed || 0);
2061
- if (body.errors && body.errors.length) {
2062
- msg += 'First errors: ' + body.errors.slice(0, 3).map(function (x) { return 'row ' + x.row + ': ' + x.message; }).join('; ');
2063
- }
2064
- alert(msg);
2065
- loadRecords(modelName, state.pagination.page, state.filters);
2066
- } catch (err) {
2067
- alert('Error: ' + err.message);
2068
- }
2069
- },
2070
- }) : null,
2071
- !state.trashedView ? m('button.inline-flex.items-center.gap-2.px-4.py-2.text-sm.font-medium.text-indigo-700.bg-white.dark:bg-slate-800.border.border-indigo-200.rounded-lg.hover:bg-indigo-50.transition-colors', {
2072
- onclick: function () {
2073
- var el = document.getElementById('data-exchange-import-' + modelName);
2074
- if (el) el.click();
2075
- },
2076
- }, [
2077
- m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
2078
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1M7 10l5 5m0 0l5-5m-5 5V4' })
2079
- ),
2080
- 'Import',
2081
- ]) : null,
2082
- !state.trashedView ? m('button.inline-flex.items-center.gap-2.px-4.py-2.text-sm.font-medium.text-white.bg-indigo-600.rounded-lg.hover:bg-indigo-700.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
2083
- onclick: () => {
2084
- state.currentRecord = null;
2085
- state.editing = true;
2086
- m.route.set('/models/' + modelName + '/new');
2087
- },
2088
- }, [
2089
- m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
2090
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M12 4v16m8-8H4' }),
2091
- ]),
2092
- 'New Record',
2093
- ]) : null,
2094
- ]),
2095
- ]),
2096
-
2097
- // Quick Filters Bar
2098
- m(QuickFiltersBar, {
2099
- modelMeta: modelMeta,
2100
- filters: state.filters,
2101
- onFilterChange: handleQuickFilterChange,
2102
- onOpenDrawer: () => {
2103
- state.filterDrawerOpen = true;
2104
- m.redraw();
2105
- },
2106
- activeFilterCount: activeFilterCount,
2107
- }),
2108
-
2109
- // Active filters badges
2110
- m(ActiveFiltersBar, {
2111
- filters: state.filters,
2112
- modelMeta: modelMeta,
2113
- onRemove: (colName) => {
2114
- const newFilters = { ...state.filters };
2115
- delete newFilters[colName];
2116
- state.filters = newFilters;
2117
- loadRecords(modelName, 1, newFilters);
2118
- },
2119
- onClearAll: () => {
2120
- state.filters = {};
2121
- loadRecords(modelName, 1, {});
2122
- },
2123
- }),
2124
-
2125
- // Filter Drawer
2126
- m(FilterDrawer, {
2127
- isOpen: state.filterDrawerOpen,
2128
- modelMeta: modelMeta,
2129
- filters: state.filters,
2130
- onFilterChange: handleDrawerFilterChange,
2131
- onApply: () => {
2132
- loadRecords(modelName, 1, state.filters);
2133
- },
2134
- onClear: () => {
2135
- state.filters = {};
2136
- m.redraw();
2137
- },
2138
- onClose: () => {
2139
- state.filterDrawerOpen = false;
2140
- m.redraw();
2141
- },
2142
- }),
2143
-
2144
- state.error ? m('.bg-red-100.border.border-red-400.text-red-700.px-4.py-3.rounded.mb-4', state.error) : null,
2145
- state.loading
2146
- ? m('.flex.items-center.justify-center.py-12', [
2147
- m('.animate-spin.rounded-full.h-8.w-8.border-b-2.border-indigo-600'),
2148
- ])
2149
- : state.records.length === 0
2150
- ? m('.bg-white dark:bg-slate-800.rounded-lg.shadow-sm.border.border-gray-200 dark:border-slate-600.p-12.text-center', [
2151
- m('svg.w-12.h-12.mx-auto.text-gray-400 dark:text-slate-500.mb-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
2152
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '1.5', d: 'M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4' }),
2153
- ]),
2154
- m('h3.text-lg.font-medium.text-gray-900 dark:text-slate-100.mb-1', 'No records found'),
2155
- m('p.text-gray-500', activeFilterCount > 0 ? 'Try adjusting your filters' : 'Get started by creating your first record'),
2156
- ])
2157
- : m('.bg-white dark:bg-slate-800.rounded-lg.shadow-sm.border.border-gray-200 dark:border-slate-600.overflow-hidden', [
2158
- // Bulk Actions Toolbar (shown when items selected)
2159
- (state.selectedRecords && state.selectedRecords.size > 0) || state.selectAllMode ? m('.bg-indigo-50.border-b.border-indigo-100.px-4.py-3.flex.items-center.justify-between', [
2160
- m('.flex.items-center.gap-3', [
2161
- m('span.text-sm.text-indigo-700.font-medium',
2162
- state.selectAllMode
2163
- ? 'All ' + state.pagination.total + ' records selected'
2164
- : state.selectedRecords.size + ' record' + (state.selectedRecords.size > 1 ? 's' : '') + ' selected'
2165
- ),
2166
- // Show "Select all X records" option when current page is fully selected
2167
- !state.selectAllMode && state.selectedRecords.size === state.records.length && state.pagination.total > state.records.length && m('button.text-sm.text-indigo-600.hover:text-indigo-800.underline.font-medium', {
2168
- onclick: () => {
2169
- state.selectAllMode = true;
2170
- m.redraw();
2171
- },
2172
- }, 'Select all ' + state.pagination.total + ' records'),
2173
- // Show "Select only this page" when in selectAllMode
2174
- state.selectAllMode && m('button.text-sm.text-indigo-600.hover:text-indigo-800.underline', {
2175
- onclick: () => {
2176
- state.selectAllMode = false;
2177
- m.redraw();
2178
- },
2179
- }, 'Select only this page (' + state.selectedRecords.size + ')'),
2180
- ]),
2181
- m('.flex.items-center.gap-2', [
2182
- state.trashedView && modelMeta?.softDelete
2183
- ? m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-green-600.bg-white dark:bg-slate-800.border.border-green-200.rounded.hover:bg-green-50.transition-colors', {
2184
- disabled: state.bulkActionInProgress,
2185
- onclick: async () => {
2186
- if (!confirm('Restore the selected records?')) return;
2187
- state.bulkActionInProgress = true;
2188
- m.redraw();
2189
- try {
2190
- const payload = state.selectAllMode
2191
- ? { selectAll: true, filters: state.filters, trashed: true }
2192
- : { ids: Array.from(state.selectedRecords), trashed: true };
2193
- await api.post('/extensions/bulk-actions/bulk-restore/' + modelName, payload);
2194
- state.selectedRecords = new Set();
2195
- state.selectAllMode = false;
2196
- loadRecords(modelName, 1);
2197
- } catch (err) {
2198
- alert('Error: ' + err.message);
2199
- } finally {
2200
- state.bulkActionInProgress = false;
2201
- m.redraw();
2202
- }
2203
- },
2204
- }, [
2205
- m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
2206
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M5 13l4 4L19 7' })
2207
- ),
2208
- 'Restore',
2209
- ])
2210
- : m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-red-600.bg-white dark:bg-slate-800.border.border-red-200.rounded.hover:bg-red-50.transition-colors', {
2211
- disabled: state.bulkActionInProgress,
2212
- onclick: async () => {
2213
- const count = state.selectAllMode ? state.pagination.total : state.selectedRecords.size;
2214
- if (!confirm('Are you sure you want to delete ' + count + ' records? This action cannot be undone.')) return;
2215
- state.bulkActionInProgress = true;
2216
- m.redraw();
2217
- try {
2218
- const payload = state.selectAllMode
2219
- ? { selectAll: true, filters: state.filters }
2220
- : { ids: Array.from(state.selectedRecords) };
2221
- await api.post('/extensions/bulk-actions/bulk-delete/' + modelName, payload);
2222
- state.selectedRecords = new Set();
2223
- state.selectAllMode = false;
2224
- loadRecords(modelName, 1);
2225
- } catch (err) {
2226
- alert('Error: ' + err.message);
2227
- } finally {
2228
- state.bulkActionInProgress = false;
2229
- m.redraw();
2230
- }
2231
- },
2232
- }, [
2233
- m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
2234
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16' })
2235
- ),
2236
- 'Delete',
2237
- ]),
2238
- !state.trashedView ? m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-blue-600.bg-white dark:bg-slate-800.border.border-blue-200.rounded.hover:bg-blue-50.transition-colors', {
2239
- disabled: state.bulkActionInProgress,
2240
- onclick: async () => {
2241
- state.bulkActionInProgress = true;
2242
- m.redraw();
2243
- try {
2244
- const payload = state.selectAllMode
2245
- ? { selectAll: true, filters: state.filters }
2246
- : { ids: Array.from(state.selectedRecords) };
2247
- const response = await api.post('/extensions/export?model=' + modelName + '&format=json', payload);
2248
- // Download as file
2249
- const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: 'application/json' });
2250
- const url = URL.createObjectURL(blob);
2251
- const a = document.createElement('a');
2252
- a.href = url;
2253
- a.download = modelName + '-export.json';
2254
- a.click();
2255
- URL.revokeObjectURL(url);
2256
- } catch (err) {
2257
- alert('Error: ' + err.message);
2258
- } finally {
2259
- state.bulkActionInProgress = false;
2260
- m.redraw();
2261
- }
2262
- },
2263
- }, [
2264
- m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
2265
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' })
2266
- ),
2267
- 'Export JSON',
2268
- ]) : null,
2269
- !state.trashedView ? m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-green-600.bg-white dark:bg-slate-800.border.border-green-200.rounded.hover:bg-green-50.transition-colors', {
2270
- disabled: state.bulkActionInProgress,
2271
- onclick: async () => {
2272
- state.bulkActionInProgress = true;
2273
- m.redraw();
2274
- try {
2275
- const payload = state.selectAllMode
2276
- ? { selectAll: true, filters: state.filters }
2277
- : { ids: Array.from(state.selectedRecords) };
2278
- const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', payload);
2279
- // Download as file
2280
- const blob = new Blob([response.data], { type: 'text/csv' });
2281
- const url = URL.createObjectURL(blob);
2282
- const a = document.createElement('a');
2283
- a.href = url;
2284
- a.download = modelName + '-export.csv';
2285
- a.click();
2286
- URL.revokeObjectURL(url);
2287
- } catch (err) {
2288
- alert('Error: ' + err.message);
2289
- } finally {
2290
- state.bulkActionInProgress = false;
2291
- m.redraw();
2292
- }
2293
- },
2294
- }, [
2295
- m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
2296
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' })
2297
- ),
2298
- 'Export CSV',
2299
- ]) : null,
2300
- m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-violet-600.bg-white dark:bg-slate-800.border.border-violet-200.rounded.hover:bg-violet-50.transition-colors', {
2301
- disabled: state.bulkActionInProgress,
2302
- onclick: async () => {
2303
- state.bulkActionInProgress = true;
2304
- m.redraw();
2305
- try {
2306
- const payload = state.selectAllMode
2307
- ? { selectAll: true, filters: state.filters }
2308
- : { ids: Array.from(state.selectedRecords) };
2309
- if (state.trashedView) payload.trashed = 'only';
2310
- await downloadDataExchangeXlsx(modelName, payload);
2311
- } catch (err) {
2312
- alert('Error: ' + err.message);
2313
- } finally {
2314
- state.bulkActionInProgress = false;
2315
- m.redraw();
2316
- }
2317
- },
2318
- }, [
2319
- m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
2320
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' })
2321
- ),
2322
- 'Export Excel',
2323
- ]),
2324
- !state.trashedView ? m(BulkFieldUpdateDropdown, {
2325
- modelName: modelName,
2326
- selectedIds: state.selectAllMode ? null : Array.from(state.selectedRecords),
2327
- selectAllMode: state.selectAllMode,
2328
- filters: state.filters,
2329
- onComplete: () => {
2330
- state.selectedRecords = new Set();
2331
- state.selectAllMode = false;
2332
- loadRecords(modelName, state.pagination.page);
2333
- },
2334
- }) : null,
2335
- m('button.px-3.py-1.5.text-sm.text-gray-500 dark:text-slate-400.hover:text-gray-700 dark:hover:text-slate-200 dark:hover:text-slate-200', {
2336
- onclick: () => {
2337
- state.selectedRecords = new Set();
2338
- state.selectAllMode = false;
2339
- state.bulkFieldDropdownOpen = false;
2340
- state.selectedBulkField = null;
2341
- m.redraw();
2342
- },
2343
- }, 'Clear'),
2344
- ]),
2345
- ]) : null,
2346
- // Table container with sticky header, fixed columns, and overflow scroll
2347
- m('.overflow-auto.max-h-[calc(100vh-380px)]', { style: 'position: relative;' }, [
2348
- m('table.w-full.border-collapse', { style: 'min-width: 100%;' }, [
2349
- // Sticky header
2350
- m('thead.bg-gray-50.dark:bg-slate-900', { style: 'position: sticky; top: 0; z-index: 20;' }, [
2351
- m('tr', [
2352
- // Checkbox column header (sticky left, box-shadow on right)
2353
- m('th.px-4.py-3.text-left.bg-gray-50 dark:bg-slate-900.border-b.border-gray-200 dark:border-slate-700', { style: 'width: 40px; position: sticky; left: 0; z-index: 15; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' }, [
2354
- m('input[type=checkbox].rounded.border-gray-300 dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
2355
- checked: state.records.length > 0 && state.selectedRecords && state.selectedRecords.size === state.records.length,
2356
- indeterminate: state.selectedRecords && state.selectedRecords.size > 0 && state.selectedRecords.size < state.records.length,
2357
- onchange: (e) => {
2358
- if (e.target.checked) {
2359
- state.selectedRecords = new Set(state.records.map(r => r[primaryKey]));
2360
- } else {
2361
- state.selectedRecords = new Set();
2362
- }
2363
- m.redraw();
2364
- },
2365
- }),
2366
- ]),
2367
- // Dynamic column headers (first column sticky left with box-shadow)
2368
- ...displayColumns.map((col, i) =>
2369
- m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500 dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50 dark:bg-slate-900.border-b.border-gray-200 dark:border-slate-700',
2370
- i === 0 ? { style: 'position: sticky; left: 40px; z-index: 15; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
2371
- formatColumnLabel(col.name)
2372
- )
2373
- ),
2374
- // Sticky actions header (sticky right, box-shadow on left)
2375
- m('th.px-4.py-3.text-right.text-xs.font-medium.text-gray-500 dark:text-slate-400.uppercase.tracking-wider.bg-gray-50 dark:bg-slate-900.border-b.border-gray-200 dark:border-slate-700', {
2376
- style: 'position: sticky; right: 0; min-width: 120px; z-index: 15; box-shadow: -4px 0 8px -4px rgba(0,0,0,0.08);',
2377
- }, 'Actions'),
2378
- ]),
2379
- ]),
2380
- m('tbody.divide-y.divide-gray-100.dark:divide-slate-700', state.records.map(record =>
2381
- m('tr.hover:bg-gray-50 dark:hover:bg-slate-800/50.transition-colors', {
2382
- class: state.selectedRecords && state.selectedRecords.has(record[primaryKey]) ? 'bg-indigo-50 dark:bg-indigo-950/50' : '',
2383
- }, [
2384
- // Checkbox cell (sticky left, box-shadow on right)
2385
- m('td.px-4.py-3.bg-white.dark:bg-slate-800', {
2386
- style: 'position: sticky; left: 0; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);',
2387
- }, [
2388
- m('input[type=checkbox].rounded.border-gray-300 dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
2389
- checked: state.selectedRecords && state.selectedRecords.has(record[primaryKey]),
2390
- onchange: (e) => {
2391
- if (!state.selectedRecords) state.selectedRecords = new Set();
2392
- if (e.target.checked) {
2393
- state.selectedRecords.add(record[primaryKey]);
2394
- } else {
2395
- state.selectedRecords.delete(record[primaryKey]);
2396
- }
2397
- m.redraw();
2398
- },
2399
- }),
2400
- ]),
2401
- // Dynamic cell values (first column sticky left with box-shadow)
2402
- ...displayColumns.map((col, i) =>
2403
- m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700 dark:text-slate-300.bg-white dark:bg-slate-800',
2404
- i === 0 ? { style: 'position: sticky; left: 40px; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
2405
- formatCellValue(record[col.name], col)
2406
- )
2407
- ),
2408
- // Sticky actions cell (sticky right, box-shadow on left)
2409
- m('td.px-4.py-3.text-sm.text-right.whitespace-nowrap.text-gray-700 dark:text-slate-300.bg-white dark:bg-slate-800', {
2410
- style: 'position: sticky; right: 0; z-index: 5; box-shadow: -4px 0 8px -4px rgba(0,0,0,0.08);',
2411
- }, [
2412
- state.trashedView && modelMeta?.softDelete
2413
- ? m('button.inline-flex.items-center.px-2.py-1.text-sm.text-green-600.dark:text-green-400.hover:text-green-800.dark:hover:text-green-300.hover:bg-green-50.dark:hover:bg-green-950/40.rounded.transition-colors', {
2414
- onclick: async () => {
2415
- try {
2416
- await api.post('/models/' + modelName + '/records/' + record[primaryKey] + '/restore');
2417
- loadRecords(modelName, state.pagination.page);
2418
- } catch (err) {
2419
- alert('Error: ' + err.message);
2420
- }
2421
- },
2422
- }, 'Restore')
2423
- : [
2424
- m('button.inline-flex.items-center.px-2.py-1.text-sm.text-indigo-600.dark:text-indigo-400.hover:text-indigo-800.dark:hover:text-indigo-300.hover:bg-indigo-50.dark:hover:bg-indigo-950/40.rounded.mr-1.transition-colors', {
2425
- onclick: () => {
2426
- state.currentRecord = record;
2427
- state.editing = true;
2428
- m.route.set('/models/' + modelName + '/edit/' + record[primaryKey]);
2429
- },
2430
- }, 'Edit'),
2431
- m('button.inline-flex.items-center.px-2.py-1.text-sm.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.hover:bg-red-50.dark:hover:bg-red-950/40.rounded.transition-colors', {
2432
- onclick: async () => {
2433
- if (confirm('Are you sure you want to delete this record?')) {
2434
- try {
2435
- await api.delete('/models/' + modelName + '/records/' + record[primaryKey]);
2436
- loadRecords(modelName, state.pagination.page);
2437
- } catch (err) {
2438
- alert('Error: ' + err.message);
2439
- }
2440
- }
2441
- },
2442
- }, 'Delete'),
2443
- ],
2444
- ]),
2445
- ])
2446
- )),
2447
- ]),
2448
- ]),
2449
- // Pagination
2450
- m(Pagination, {
2451
- page: state.pagination.page,
2452
- perPage: state.pagination.perPage,
2453
- total: state.pagination.total,
2454
- totalPages: state.pagination.totalPages,
2455
- onPageChange: (newPage) => loadRecords(modelName, newPage),
2456
- }),
2457
- ]),
2458
- ]);
2459
- },
2460
- };
2461
-
2462
- // Record Form Component - renders fields based on model schema
2463
- const RecordForm = {
2464
- oninit: () => {
2465
- const modelName = m.route.param('model');
2466
- const id = m.route.param('id');
2467
- state.error = null;
2468
- state.loading = true;
2469
- state.formData = {};
2470
- state.currentModelMeta = null;
2471
-
2472
- // Load model metadata first
2473
- api.get('/models/' + modelName)
2474
- .then(modelMeta => {
2475
- state.currentModelMeta = modelMeta;
2476
-
2477
- // Initialize form data with defaults
2478
- modelMeta.columns.forEach(col => {
2479
- if (col.default !== undefined) {
2480
- state.formData[col.name] = col.default;
2481
- }
2482
- });
2483
-
2484
- // If editing, load the record
2485
- if (id && id !== 'new') {
2486
- return api.get('/models/' + modelName + '/records/' + id)
2487
- .then(result => {
2488
- state.currentRecord = result.data;
2489
- // Populate form data with record values
2490
- Object.keys(result.data).forEach(key => {
2491
- state.formData[key] = result.data[key];
2492
- });
2493
- });
2494
- } else {
2495
- state.currentRecord = null;
2496
- }
2497
- })
2498
- .catch(err => {
2499
- state.error = err.message;
2500
- })
2501
- .finally(() => {
2502
- state.loading = false;
2503
- m.redraw();
2504
- });
2505
- },
2506
- view: () => {
2507
- const modelName = m.route.param('model');
2508
- const id = m.route.param('id');
2509
- const isNew = !id || id === 'new';
2510
- const modelMeta = state.currentModelMeta;
2511
-
2512
- const breadcrumbs = [
2513
- { label: modelMeta?.label || modelName, href: '/models/' + modelName },
2514
- { label: isNew ? 'New' : 'Edit #' + id, href: '#' },
2515
- ];
2516
-
2517
- return m(Layout, { breadcrumbs }, [
2518
- m('.flex.items-center.justify-between.mb-6', [
2519
- m('h2.text-2xl.font-bold.text-gray-900.dark:text-slate-100', isNew ? 'New Record' : 'Edit Record'),
2520
- modelMeta ? m('span.text-gray-500.dark:text-slate-400', modelMeta.label || modelMeta.name) : null,
2521
- ]),
2522
-
2523
- state.loading ? m('p.text-gray-600.dark:text-slate-400', 'Loading...') :
2524
- state.error && !modelMeta ? m('.bg-red-50.dark:bg-red-950/40.border.border-red-200.dark:border-red-800.text-red-800.dark:text-red-200.px-4.py-3.rounded-lg', state.error) :
2525
-
2526
- m('form.bg-white.dark:bg-slate-800.rounded-lg.shadow-sm.border.border-gray-200.dark:border-slate-700.flex.flex-col', {
2527
- style: 'min-height: calc(100vh - 280px);',
2528
- onsubmit: async (e) => {
2529
- e.preventDefault();
2530
- state.loading = true;
2531
- state.error = null;
2532
- try {
2533
- // Validate rich-text fields first
2534
- if (modelMeta && modelMeta.columns) {
2535
- for (const col of modelMeta.columns) {
2536
- if (col.customField && col.customField.type === 'rich-text' && !col.nullable) {
2537
- const hiddenInput = document.getElementById(col.name + '-value');
2538
- const value = hiddenInput ? hiddenInput.value : state.formData[col.name];
2539
- if (isRichTextEmpty(value)) {
2540
- state.error = (col.ui?.label || col.name) + ' is required';
2541
- state.loading = false;
2542
- return;
2543
- }
2544
- }
2545
- }
2546
- }
2547
-
2548
- // Build payload, excluding auto-generated fields
2549
- const payload = {};
2550
- if (modelMeta && modelMeta.columns) {
2551
- modelMeta.columns.forEach(col => {
2552
- const autoType = isAutoColumn(col);
2553
- // Skip primary key and auto timestamps in payload
2554
- if (autoType === 'primary' || autoType === 'auto') return;
2555
-
2556
- // For rich-text fields, get value from hidden input
2557
- let value = state.formData[col.name];
2558
- if (col.customField && col.customField.type === 'rich-text') {
2559
- const hiddenInput = document.getElementById(col.name + '-value');
2560
- if (hiddenInput) {
2561
- value = hiddenInput.value;
2562
- }
2563
- // Skip empty rich-text values (normalize to null if nullable)
2564
- if (isRichTextEmpty(value)) {
2565
- if (col.nullable) {
2566
- payload[col.name] = null;
2567
- }
2568
- return;
2569
- }
2570
- }
2571
-
2572
- if (value !== undefined && value !== null && value !== '') {
2573
- payload[col.name] = value;
2574
- } else if (value === null && col.nullable) {
2575
- payload[col.name] = null;
2576
- }
2577
- });
2578
- }
2579
-
2580
- if (isNew) {
2581
- await api.post('/models/' + modelName + '/records', payload);
2582
- } else {
2583
- await api.put('/models/' + modelName + '/records/' + id, payload);
2584
- }
2585
- m.route.set('/models/' + modelName);
2586
- } catch (err) {
2587
- state.error = err.message;
2588
- } finally {
2589
- state.loading = false;
2590
- }
2591
- }
2592
- }, [
2593
- // Form content (scrollable)
2594
- m('.p-6.flex-1.overflow-y-auto', [
2595
- state.error ? m('.bg-red-50.dark:bg-red-950/40.border.border-red-200.dark:border-red-800.text-red-800.dark:text-red-200.px-4.py-3.rounded-lg.mb-4', state.error) : null,
2596
-
2597
- // Render form fields based on model columns
2598
- modelMeta && modelMeta.columns ? modelMeta.columns.map(col => {
2599
- const autoType = isAutoColumn(col);
2600
-
2601
- // Hide primary key in new mode
2602
- if (autoType === 'primary' && isNew) return null;
2603
-
2604
- // Hide hidden fields
2605
- if (col.ui && col.ui.hidden) return null;
2606
-
2607
- const isReadonly = !!autoType || (col.ui && col.ui.readonly);
2608
- const renderer = getFieldRenderer(col, modelMeta);
2609
- const value = state.formData[col.name];
2610
- const onChange = (newValue) => {
2611
- state.formData[col.name] = newValue;
2612
- };
2613
-
2614
- return renderer(col, value, onChange, isReadonly);
2615
- }) : m('p.text-gray-600 dark:text-slate-400.mb-4', 'Loading form fields...'),
2616
- ]),
2617
-
2618
- // Sticky footer buttons
2619
- m('.flex.gap-4.p-4.border-t.border-gray-200.dark:border-slate-700.bg-gray-50.dark:bg-slate-900.sticky.bottom-0', [
2620
- m('button.bg-blue-600.dark:bg-blue-500.text-white.px-6.py-2.rounded-lg.hover:bg-blue-700.dark:hover:bg-blue-600.disabled:opacity-50', {
2621
- type: 'submit',
2622
- disabled: state.loading,
2623
- }, state.loading ? 'Saving...' : 'Save'),
2624
- m('button.bg-gray-200 dark:bg-slate-700.text-gray-800 dark:text-slate-200.px-6.py-2.rounded.hover:bg-gray-300 dark:hover:bg-slate-600 dark:hover:bg-slate-600[type=button]', {
2625
- onclick: () => m.route.set('/models/' + modelName),
2626
- }, 'Cancel'),
2627
- ]),
2628
- ]),
2629
- ]);
2630
- },
2631
- };
2632
-
2633
- // Export components
2634
- window.__ADMIN_COMPONENTS__ = {
2635
- LoginForm,
2636
- SetupForm,
2637
- Layout,
2638
- ModelList,
2639
- RecordList,
2640
- RecordForm,
2641
- api,
2642
- state,
2643
- };
2644
- `;
8
+ module.exports = require('./client/load-parts').buildComponentsBody();