webspresso 0.0.74 → 0.0.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +41 -3
  2. package/bin/commands/orm-map.js +139 -0
  3. package/bin/commands/skill.js +22 -8
  4. package/bin/utils/orm-map-html.js +689 -0
  5. package/bin/utils/orm-map-load.js +85 -0
  6. package/bin/utils/orm-map-snapshot.js +179 -0
  7. package/bin/utils/resolve-webspresso-orm.js +23 -0
  8. package/bin/webspresso.js +2 -0
  9. package/core/auth/manager.js +14 -1
  10. package/core/kernel/app.js +96 -0
  11. package/core/kernel/base-repository.js +143 -0
  12. package/core/kernel/events.js +101 -0
  13. package/core/kernel/flow.js +22 -0
  14. package/core/kernel/index.js +17 -0
  15. package/core/kernel/plugin.js +23 -0
  16. package/core/kernel/plugins/sample-seo.js +26 -0
  17. package/core/kernel/run-demo.js +58 -0
  18. package/core/kernel/view.js +167 -0
  19. package/core/openapi/build-from-api-routes.js +8 -2
  20. package/core/orm/model.js +3 -1
  21. package/core/url-path-normalize.js +30 -0
  22. package/index.d.ts +168 -1
  23. package/index.js +20 -2
  24. package/package.json +11 -1
  25. package/plugins/admin-panel/api.js +43 -15
  26. package/plugins/admin-panel/client/README.md +39 -0
  27. package/plugins/admin-panel/client/load-parts.js +74 -0
  28. package/plugins/admin-panel/client/manifest.parts.json +12 -0
  29. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
  30. package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
  31. package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
  32. package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
  33. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
  34. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
  35. package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
  36. package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
  37. package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
  38. package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
  39. package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
  40. package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
  41. package/plugins/admin-panel/components.js +4 -2640
  42. package/plugins/admin-panel/core/api-extensions.js +100 -10
  43. package/plugins/admin-panel/index.js +3 -0
  44. package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
  45. package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
  46. package/plugins/admin-panel/modules/dashboard.js +3 -2
  47. package/plugins/admin-panel/modules/user-management.js +90 -20
  48. package/plugins/index.js +4 -0
  49. package/plugins/rate-limit/index.js +178 -0
  50. package/plugins/redirect/index.js +204 -0
  51. package/plugins/rest-resources/index.js +2 -1
  52. package/plugins/swagger.js +2 -1
  53. package/plugins/upload/local-file-provider.js +6 -2
  54. package/src/file-router.js +191 -50
  55. package/src/njk-frontmatter.js +156 -0
  56. package/src/plugin-manager.js +4 -2
  57. package/src/server.js +26 -9
  58. package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
  59. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
  60. package/templates/skills/webspresso-usage/SKILL.md +29 -278
@@ -0,0 +1,536 @@
1
+ // Record List Component - displays records with dynamic columns
2
+ const RecordList = {
3
+ oninit(vnode) {
4
+ vnode.state._stopPoll = null;
5
+ vnode.state.manualRefresh = false;
6
+ const modelName = m.route.param('model');
7
+ initializeModelView(modelName);
8
+ vnode.state._stopPoll = runAdminAutoRefresh(() => {
9
+ const mName = m.route.param('model');
10
+ if (state._currentModelName !== mName || !state.currentModelMeta) return;
11
+ loadRecords(mName, state.pagination.page, state.filters);
12
+ });
13
+ },
14
+ onremove(vnode) {
15
+ if (vnode.state._stopPoll) vnode.state._stopPoll();
16
+ },
17
+ onbeforeupdate: () => {
18
+ // Check if model changed (navigation between different models)
19
+ const modelName = m.route.param('model');
20
+ if (state._currentModelName !== modelName) {
21
+ initializeModelView(modelName);
22
+ }
23
+ return true;
24
+ },
25
+ view: (vnode) => {
26
+ const modelName = m.route.param('model');
27
+ const modelMeta = state.currentModelMeta;
28
+ const displayColumns = modelMeta ? getDisplayColumns(modelMeta.columns) : [];
29
+ const primaryKey = modelMeta?.primaryKey || 'id';
30
+
31
+ // Count active filters
32
+ const activeFilterCount = Object.values(state.filters || {}).filter(filterPayloadIsActive).length;
33
+
34
+ const breadcrumbs = [
35
+ { label: modelMeta?.label || modelName, href: '/models/' + modelName },
36
+ ];
37
+
38
+ // Filter change handler with auto-apply for quick filters
39
+ const handleQuickFilterChange = (colName, filter) => {
40
+ const newFilters = { ...state.filters };
41
+ if (filter === null) {
42
+ delete newFilters[colName];
43
+ } else {
44
+ newFilters[colName] = filter;
45
+ }
46
+ state.filters = newFilters;
47
+ loadRecords(modelName, 1, newFilters);
48
+ };
49
+
50
+ // Filter change handler for drawer (no auto-apply)
51
+ const handleDrawerFilterChange = (colName, filter) => {
52
+ const newFilters = { ...state.filters };
53
+ if (filter === null) {
54
+ delete newFilters[colName];
55
+ } else {
56
+ newFilters[colName] = filter;
57
+ }
58
+ state.filters = newFilters;
59
+ m.redraw();
60
+ };
61
+
62
+ return m(Layout, { breadcrumbs }, [
63
+ // Header
64
+ m('.flex.items-center.justify-between.mb-4', [
65
+ m('.flex.items-center.gap-3', [
66
+ m('h2.text-2xl.font-bold', modelMeta?.label || modelName),
67
+ m(RefreshIconButton, {
68
+ title: 'Reload records',
69
+ spinning: vnode.state.manualRefresh || state.loading,
70
+ onclick: () => {
71
+ vnode.state.manualRefresh = true;
72
+ m.redraw();
73
+ loadRecords(modelName, state.pagination.page, state.filters).finally(() => {
74
+ vnode.state.manualRefresh = false;
75
+ m.redraw();
76
+ });
77
+ },
78
+ }),
79
+ modelMeta?.softDelete ? m('.flex.rounded-lg.border.border-gray-200 dark:border-slate-600.p-0.5', [
80
+ m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
81
+ 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',
82
+ onclick: () => {
83
+ state.trashedView = false;
84
+ loadRecords(modelName, 1);
85
+ },
86
+ }, 'Active'),
87
+ m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
88
+ 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',
89
+ onclick: () => {
90
+ state.trashedView = true;
91
+ loadRecords(modelName, 1);
92
+ },
93
+ }, 'Trash'),
94
+ ]) : null,
95
+ ]),
96
+ m('.flex.flex-wrap.items-center.gap-2', [
97
+ 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', {
98
+ onclick: async () => {
99
+ try {
100
+ const payload = { selectAll: true, filters: state.filters };
101
+ if (state.trashedView) payload.trashed = 'only';
102
+ await downloadDataExchangeXlsx(modelName, payload);
103
+ } catch (err) {
104
+ alert('Error: ' + err.message);
105
+ }
106
+ },
107
+ }, [
108
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
109
+ 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' })
110
+ ),
111
+ 'Export Excel',
112
+ ]),
113
+ !state.trashedView ? m('input[type=file]', {
114
+ id: 'data-exchange-import-' + modelName,
115
+ style: 'display:none',
116
+ accept: '.csv,.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv',
117
+ onchange: async (e) => {
118
+ const file = e.target.files && e.target.files[0];
119
+ e.target.value = '';
120
+ if (!file) return;
121
+ const adminPath = window.__ADMIN_PATH__ || '/_admin';
122
+ const mode = window.confirm('OK = upsert by id, Cancel = insert only') ? 'upsert' : 'insert';
123
+ const upsertKey = 'id';
124
+ const fd = new FormData();
125
+ fd.append('file', file);
126
+ try {
127
+ const res = await fetch(
128
+ adminPath + '/api/data-exchange/import/' + modelName +
129
+ '?mode=' + encodeURIComponent(mode) + '&upsertKey=' + encodeURIComponent(upsertKey),
130
+ { method: 'POST', body: fd, credentials: 'include' }
131
+ );
132
+ const body = await res.json().catch(function () { return ({}); });
133
+ if (!res.ok) {
134
+ throw new Error(body.error || 'Import failed');
135
+ }
136
+ var msg = 'Import finished: created ' + body.created + ', updated ' + (body.updated || 0) + ', failed ' + (body.failed || 0);
137
+ if (body.errors && body.errors.length) {
138
+ msg += 'First errors: ' + body.errors.slice(0, 3).map(function (x) { return 'row ' + x.row + ': ' + x.message; }).join('; ');
139
+ }
140
+ alert(msg);
141
+ loadRecords(modelName, state.pagination.page, state.filters);
142
+ } catch (err) {
143
+ alert('Error: ' + err.message);
144
+ }
145
+ },
146
+ }) : null,
147
+ !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', {
148
+ onclick: function () {
149
+ var el = document.getElementById('data-exchange-import-' + modelName);
150
+ if (el) el.click();
151
+ },
152
+ }, [
153
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
154
+ 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' })
155
+ ),
156
+ 'Import',
157
+ ]) : null,
158
+ !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', {
159
+ onclick: () => {
160
+ state.currentRecord = null;
161
+ state.editing = true;
162
+ m.route.set('/models/' + modelName + '/new');
163
+ },
164
+ }, [
165
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
166
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M12 4v16m8-8H4' }),
167
+ ]),
168
+ 'New Record',
169
+ ]) : null,
170
+ ]),
171
+ ]),
172
+
173
+ // Quick Filters Bar
174
+ m(QuickFiltersBar, {
175
+ modelMeta: modelMeta,
176
+ filters: state.filters,
177
+ onFilterChange: handleQuickFilterChange,
178
+ onOpenDrawer: () => {
179
+ state.filterDrawerOpen = true;
180
+ m.redraw();
181
+ },
182
+ activeFilterCount: activeFilterCount,
183
+ }),
184
+
185
+ // Active filters badges
186
+ m(ActiveFiltersBar, {
187
+ filters: state.filters,
188
+ modelMeta: modelMeta,
189
+ onRemove: (colName) => {
190
+ const newFilters = { ...state.filters };
191
+ delete newFilters[colName];
192
+ state.filters = newFilters;
193
+ loadRecords(modelName, 1, newFilters);
194
+ },
195
+ onClearAll: () => {
196
+ state.filters = {};
197
+ loadRecords(modelName, 1, {});
198
+ },
199
+ }),
200
+
201
+ // Filter Drawer
202
+ m(FilterDrawer, {
203
+ isOpen: state.filterDrawerOpen,
204
+ modelMeta: modelMeta,
205
+ filters: state.filters,
206
+ onFilterChange: handleDrawerFilterChange,
207
+ onApply: () => {
208
+ loadRecords(modelName, 1, state.filters);
209
+ },
210
+ onClear: () => {
211
+ state.filters = {};
212
+ m.redraw();
213
+ },
214
+ onClose: () => {
215
+ state.filterDrawerOpen = false;
216
+ m.redraw();
217
+ },
218
+ }),
219
+
220
+ state.error ? m('.bg-red-100.border.border-red-400.text-red-700.px-4.py-3.rounded.mb-4', state.error) : null,
221
+ state.loading
222
+ ? m('.flex.items-center.justify-center.py-12', [
223
+ m('.animate-spin.rounded-full.h-8.w-8.border-b-2.border-indigo-600'),
224
+ ])
225
+ : state.records.length === 0
226
+ ? m('.bg-white dark:bg-slate-800.rounded-lg.shadow-sm.border.border-gray-200 dark:border-slate-600.p-12.text-center', [
227
+ 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' }, [
228
+ 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' }),
229
+ ]),
230
+ m('h3.text-lg.font-medium.text-gray-900 dark:text-slate-100.mb-1', 'No records found'),
231
+ m('p.text-gray-500', activeFilterCount > 0 ? 'Try adjusting your filters' : 'Get started by creating your first record'),
232
+ ])
233
+ : m('.bg-white dark:bg-slate-800.rounded-lg.shadow-sm.border.border-gray-200 dark:border-slate-600.overflow-hidden', [
234
+ // Bulk Actions Toolbar (shown when items selected)
235
+ (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', [
236
+ m('.flex.items-center.gap-3', [
237
+ m('span.text-sm.text-indigo-700.font-medium',
238
+ state.selectAllMode
239
+ ? 'All ' + state.pagination.total + ' records selected'
240
+ : state.selectedRecords.size + ' record' + (state.selectedRecords.size > 1 ? 's' : '') + ' selected'
241
+ ),
242
+ // Show "Select all X records" option when current page is fully selected
243
+ !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', {
244
+ onclick: () => {
245
+ state.selectAllMode = true;
246
+ m.redraw();
247
+ },
248
+ }, 'Select all ' + state.pagination.total + ' records'),
249
+ // Show "Select only this page" when in selectAllMode
250
+ state.selectAllMode && m('button.text-sm.text-indigo-600.hover:text-indigo-800.underline', {
251
+ onclick: () => {
252
+ state.selectAllMode = false;
253
+ m.redraw();
254
+ },
255
+ }, 'Select only this page (' + state.selectedRecords.size + ')'),
256
+ ]),
257
+ m('.flex.items-center.gap-2', [
258
+ state.trashedView && modelMeta?.softDelete
259
+ ? 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', {
260
+ disabled: state.bulkActionInProgress,
261
+ onclick: async () => {
262
+ if (!confirm('Restore the selected records?')) return;
263
+ state.bulkActionInProgress = true;
264
+ m.redraw();
265
+ try {
266
+ const payload = state.selectAllMode
267
+ ? { selectAll: true, filters: state.filters, trashed: true }
268
+ : { ids: Array.from(state.selectedRecords), trashed: true };
269
+ await api.post('/extensions/bulk-actions/bulk-restore/' + modelName, payload);
270
+ state.selectedRecords = new Set();
271
+ state.selectAllMode = false;
272
+ loadRecords(modelName, 1);
273
+ } catch (err) {
274
+ alert('Error: ' + err.message);
275
+ } finally {
276
+ state.bulkActionInProgress = false;
277
+ m.redraw();
278
+ }
279
+ },
280
+ }, [
281
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
282
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M5 13l4 4L19 7' })
283
+ ),
284
+ 'Restore',
285
+ ])
286
+ : 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', {
287
+ disabled: state.bulkActionInProgress,
288
+ onclick: async () => {
289
+ const count = state.selectAllMode ? state.pagination.total : state.selectedRecords.size;
290
+ if (!confirm('Are you sure you want to delete ' + count + ' records? This action cannot be undone.')) return;
291
+ state.bulkActionInProgress = true;
292
+ m.redraw();
293
+ try {
294
+ const payload = state.selectAllMode
295
+ ? { selectAll: true, filters: state.filters }
296
+ : { ids: Array.from(state.selectedRecords) };
297
+ await api.post('/extensions/bulk-actions/bulk-delete/' + modelName, payload);
298
+ state.selectedRecords = new Set();
299
+ state.selectAllMode = false;
300
+ loadRecords(modelName, 1);
301
+ } catch (err) {
302
+ alert('Error: ' + err.message);
303
+ } finally {
304
+ state.bulkActionInProgress = false;
305
+ m.redraw();
306
+ }
307
+ },
308
+ }, [
309
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
310
+ 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' })
311
+ ),
312
+ 'Delete',
313
+ ]),
314
+ !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', {
315
+ disabled: state.bulkActionInProgress,
316
+ onclick: async () => {
317
+ state.bulkActionInProgress = true;
318
+ m.redraw();
319
+ try {
320
+ const payload = state.selectAllMode
321
+ ? { selectAll: true, filters: state.filters }
322
+ : { ids: Array.from(state.selectedRecords) };
323
+ const response = await api.post('/extensions/export?model=' + modelName + '&format=json', payload);
324
+ // Download as file
325
+ const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: 'application/json' });
326
+ const url = URL.createObjectURL(blob);
327
+ const a = document.createElement('a');
328
+ a.href = url;
329
+ a.download = modelName + '-export.json';
330
+ a.click();
331
+ URL.revokeObjectURL(url);
332
+ } catch (err) {
333
+ alert('Error: ' + err.message);
334
+ } finally {
335
+ state.bulkActionInProgress = false;
336
+ m.redraw();
337
+ }
338
+ },
339
+ }, [
340
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
341
+ 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' })
342
+ ),
343
+ 'Export JSON',
344
+ ]) : null,
345
+ !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', {
346
+ disabled: state.bulkActionInProgress,
347
+ onclick: async () => {
348
+ state.bulkActionInProgress = true;
349
+ m.redraw();
350
+ try {
351
+ const payload = state.selectAllMode
352
+ ? { selectAll: true, filters: state.filters }
353
+ : { ids: Array.from(state.selectedRecords) };
354
+ const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', payload);
355
+ // Download as file
356
+ const blob = new Blob([response.data], { type: 'text/csv' });
357
+ const url = URL.createObjectURL(blob);
358
+ const a = document.createElement('a');
359
+ a.href = url;
360
+ a.download = modelName + '-export.csv';
361
+ a.click();
362
+ URL.revokeObjectURL(url);
363
+ } catch (err) {
364
+ alert('Error: ' + err.message);
365
+ } finally {
366
+ state.bulkActionInProgress = false;
367
+ m.redraw();
368
+ }
369
+ },
370
+ }, [
371
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
372
+ 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' })
373
+ ),
374
+ 'Export CSV',
375
+ ]) : null,
376
+ 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', {
377
+ disabled: state.bulkActionInProgress,
378
+ onclick: async () => {
379
+ state.bulkActionInProgress = true;
380
+ m.redraw();
381
+ try {
382
+ const payload = state.selectAllMode
383
+ ? { selectAll: true, filters: state.filters }
384
+ : { ids: Array.from(state.selectedRecords) };
385
+ if (state.trashedView) payload.trashed = 'only';
386
+ await downloadDataExchangeXlsx(modelName, payload);
387
+ } catch (err) {
388
+ alert('Error: ' + err.message);
389
+ } finally {
390
+ state.bulkActionInProgress = false;
391
+ m.redraw();
392
+ }
393
+ },
394
+ }, [
395
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
396
+ 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' })
397
+ ),
398
+ 'Export Excel',
399
+ ]),
400
+ !state.trashedView ? m(BulkFieldUpdateDropdown, {
401
+ modelName: modelName,
402
+ selectedIds: state.selectAllMode ? null : Array.from(state.selectedRecords),
403
+ selectAllMode: state.selectAllMode,
404
+ filters: state.filters,
405
+ onComplete: () => {
406
+ state.selectedRecords = new Set();
407
+ state.selectAllMode = false;
408
+ loadRecords(modelName, state.pagination.page);
409
+ },
410
+ }) : null,
411
+ 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', {
412
+ onclick: () => {
413
+ state.selectedRecords = new Set();
414
+ state.selectAllMode = false;
415
+ state.bulkFieldDropdownOpen = false;
416
+ state.selectedBulkField = null;
417
+ m.redraw();
418
+ },
419
+ }, 'Clear'),
420
+ ]),
421
+ ]) : null,
422
+ // Table container with sticky header, fixed columns, and overflow scroll
423
+ m('.overflow-auto.max-h-[calc(100vh-380px)]', { style: 'position: relative;' }, [
424
+ m('table.w-full.border-collapse', { style: 'min-width: 100%;' }, [
425
+ // Sticky header
426
+ m('thead.bg-gray-50.dark:bg-slate-900', { style: 'position: sticky; top: 0; z-index: 20;' }, [
427
+ m('tr', [
428
+ // Checkbox column header (sticky left, box-shadow on right)
429
+ 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);' }, [
430
+ m('input[type=checkbox].rounded.border-gray-300 dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
431
+ checked: state.records.length > 0 && state.selectedRecords && state.selectedRecords.size === state.records.length,
432
+ indeterminate: state.selectedRecords && state.selectedRecords.size > 0 && state.selectedRecords.size < state.records.length,
433
+ onchange: (e) => {
434
+ if (e.target.checked) {
435
+ state.selectedRecords = new Set(state.records.map(r => r[primaryKey]));
436
+ } else {
437
+ state.selectedRecords = new Set();
438
+ }
439
+ m.redraw();
440
+ },
441
+ }),
442
+ ]),
443
+ // Dynamic column headers (first column sticky left with box-shadow)
444
+ ...displayColumns.map((col, i) =>
445
+ 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',
446
+ i === 0 ? { style: 'position: sticky; left: 40px; z-index: 15; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
447
+ formatColumnLabel(col.name)
448
+ )
449
+ ),
450
+ // Sticky actions header (sticky right, box-shadow on left)
451
+ 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', {
452
+ style: 'position: sticky; right: 0; min-width: 120px; z-index: 15; box-shadow: -4px 0 8px -4px rgba(0,0,0,0.08);',
453
+ }, 'Actions'),
454
+ ]),
455
+ ]),
456
+ m('tbody.divide-y.divide-gray-100.dark:divide-slate-700', state.records.map(record =>
457
+ m('tr.hover:bg-gray-50 dark:hover:bg-slate-800/50.transition-colors', {
458
+ class: state.selectedRecords && state.selectedRecords.has(record[primaryKey]) ? 'bg-indigo-50 dark:bg-indigo-950/50' : '',
459
+ }, [
460
+ // Checkbox cell (sticky left, box-shadow on right)
461
+ m('td.px-4.py-3.bg-white.dark:bg-slate-800', {
462
+ style: 'position: sticky; left: 0; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);',
463
+ }, [
464
+ m('input[type=checkbox].rounded.border-gray-300 dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
465
+ checked: state.selectedRecords && state.selectedRecords.has(record[primaryKey]),
466
+ onchange: (e) => {
467
+ if (!state.selectedRecords) state.selectedRecords = new Set();
468
+ if (e.target.checked) {
469
+ state.selectedRecords.add(record[primaryKey]);
470
+ } else {
471
+ state.selectedRecords.delete(record[primaryKey]);
472
+ }
473
+ m.redraw();
474
+ },
475
+ }),
476
+ ]),
477
+ // Dynamic cell values (first column sticky left with box-shadow)
478
+ ...displayColumns.map((col, i) =>
479
+ m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700 dark:text-slate-300.bg-white dark:bg-slate-800',
480
+ i === 0 ? { style: 'position: sticky; left: 40px; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
481
+ formatCellValue(record[col.name], col)
482
+ )
483
+ ),
484
+ // Sticky actions cell (sticky right, box-shadow on left)
485
+ 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', {
486
+ style: 'position: sticky; right: 0; z-index: 5; box-shadow: -4px 0 8px -4px rgba(0,0,0,0.08);',
487
+ }, [
488
+ state.trashedView && modelMeta?.softDelete
489
+ ? 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', {
490
+ onclick: async () => {
491
+ try {
492
+ await api.post('/models/' + modelName + '/records/' + record[primaryKey] + '/restore');
493
+ loadRecords(modelName, state.pagination.page);
494
+ } catch (err) {
495
+ alert('Error: ' + err.message);
496
+ }
497
+ },
498
+ }, 'Restore')
499
+ : [
500
+ 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', {
501
+ onclick: () => {
502
+ state.currentRecord = record;
503
+ state.editing = true;
504
+ m.route.set('/models/' + modelName + '/edit/' + record[primaryKey]);
505
+ },
506
+ }, 'Edit'),
507
+ 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', {
508
+ onclick: async () => {
509
+ if (confirm('Are you sure you want to delete this record?')) {
510
+ try {
511
+ await api.delete('/models/' + modelName + '/records/' + record[primaryKey]);
512
+ loadRecords(modelName, state.pagination.page);
513
+ } catch (err) {
514
+ alert('Error: ' + err.message);
515
+ }
516
+ }
517
+ },
518
+ }, 'Delete'),
519
+ ],
520
+ ]),
521
+ ])
522
+ )),
523
+ ]),
524
+ ]),
525
+ // Pagination
526
+ m(Pagination, {
527
+ page: state.pagination.page,
528
+ perPage: state.pagination.perPage,
529
+ total: state.pagination.total,
530
+ totalPages: state.pagination.totalPages,
531
+ onPageChange: (newPage) => loadRecords(modelName, newPage),
532
+ }),
533
+ ]),
534
+ ]);
535
+ },
536
+ };