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