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