webspresso 0.0.73 → 0.0.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +44 -4
  2. package/bin/commands/orm-map.js +139 -0
  3. package/bin/commands/skill.js +22 -8
  4. package/bin/commands/upgrade.js +146 -0
  5. package/bin/utils/orm-map-html.js +689 -0
  6. package/bin/utils/orm-map-load.js +85 -0
  7. package/bin/utils/orm-map-snapshot.js +179 -0
  8. package/bin/utils/resolve-webspresso-orm.js +23 -0
  9. package/bin/webspresso.js +4 -0
  10. package/core/auth/manager.js +14 -1
  11. package/core/kernel/app.js +96 -0
  12. package/core/kernel/base-repository.js +143 -0
  13. package/core/kernel/events.js +101 -0
  14. package/core/kernel/flow.js +22 -0
  15. package/core/kernel/index.js +17 -0
  16. package/core/kernel/plugin.js +23 -0
  17. package/core/kernel/plugins/sample-seo.js +26 -0
  18. package/core/kernel/run-demo.js +58 -0
  19. package/core/kernel/view.js +167 -0
  20. package/core/openapi/build-from-api-routes.js +8 -2
  21. package/core/orm/model.js +3 -1
  22. package/core/url-path-normalize.js +30 -0
  23. package/index.d.ts +168 -1
  24. package/index.js +20 -2
  25. package/package.json +11 -1
  26. package/plugins/admin-panel/api.js +43 -15
  27. package/plugins/admin-panel/app.js +109 -0
  28. package/plugins/admin-panel/client/README.md +39 -0
  29. package/plugins/admin-panel/client/load-parts.js +74 -0
  30. package/plugins/admin-panel/client/manifest.parts.json +12 -0
  31. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
  32. package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
  33. package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
  34. package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
  35. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
  36. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
  37. package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
  38. package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
  39. package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
  40. package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
  41. package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
  42. package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
  43. package/plugins/admin-panel/components.js +4 -2640
  44. package/plugins/admin-panel/core/api-extensions.js +100 -10
  45. package/plugins/admin-panel/index.js +3 -0
  46. package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
  47. package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
  48. package/plugins/admin-panel/modules/dashboard.js +17 -13
  49. package/plugins/admin-panel/modules/user-management.js +118 -27
  50. package/plugins/data-exchange/export-xlsx.js +3 -0
  51. package/plugins/data-exchange/record-selection.js +21 -5
  52. package/plugins/index.js +4 -0
  53. package/plugins/rate-limit/index.js +178 -0
  54. package/plugins/redirect/index.js +204 -0
  55. package/plugins/rest-resources/index.js +2 -1
  56. package/plugins/site-analytics/admin-component.js +88 -78
  57. package/plugins/swagger.js +2 -1
  58. package/plugins/upload/local-file-provider.js +6 -2
  59. package/src/file-router.js +270 -53
  60. package/src/njk-frontmatter.js +156 -0
  61. package/src/plugin-manager.js +4 -2
  62. package/src/server.js +28 -9
  63. package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
  64. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
  65. package/templates/skills/webspresso-usage/SKILL.md +29 -275
@@ -0,0 +1,596 @@
1
+ // Model List Component
2
+ const ModelList = {
3
+ _load() {
4
+ state.loading = true;
5
+ state.error = null;
6
+ m.redraw();
7
+ return api.get('/models')
8
+ .then(result => {
9
+ state.models = result.models || [];
10
+ })
11
+ .catch(err => {
12
+ state.error = err.message;
13
+ })
14
+ .finally(() => {
15
+ state.loading = false;
16
+ m.redraw();
17
+ });
18
+ },
19
+ oninit(vnode) {
20
+ vnode.state._stopPoll = null;
21
+ vnode.state.refreshing = false;
22
+ ModelList._load();
23
+ vnode.state._stopPoll = runAdminAutoRefresh(() => ModelList._load());
24
+ },
25
+ onremove(vnode) {
26
+ if (vnode.state._stopPoll) vnode.state._stopPoll();
27
+ },
28
+ view: (vnode) => m(Layout, [
29
+ m('.flex.items-center.justify-between.mb-6', [
30
+ m('h2.text-2xl.font-bold', 'Models'),
31
+ m(RefreshIconButton, {
32
+ title: 'Reload models',
33
+ spinning: vnode.state.refreshing || state.loading,
34
+ onclick: () => {
35
+ vnode.state.refreshing = true;
36
+ m.redraw();
37
+ ModelList._load().finally(() => {
38
+ vnode.state.refreshing = false;
39
+ m.redraw();
40
+ });
41
+ },
42
+ }),
43
+ ]),
44
+ state.error ? m('.bg-red-100.border.border-red-400.text-red-700.px-4.py-3.rounded.mb-4', state.error) : null,
45
+ state.loading
46
+ ? m('p.text-gray-600', 'Loading models...')
47
+ : state.models.length === 0
48
+ ? m('p.text-gray-600', 'No models enabled in admin panel. Make sure your models have admin: { enabled: true }')
49
+ : m('.grid.grid-cols-1.md:grid-cols-2.lg:grid-cols-3.gap-4',
50
+ state.models.map(model =>
51
+ m('a.bg-white dark:bg-slate-800.p-6.rounded.shadow.hover:shadow-lg.transition', {
52
+ href: '/models/' + model.name,
53
+ onclick: (e) => {
54
+ e.preventDefault();
55
+ m.route.set('/models/' + model.name);
56
+ }
57
+ }, [
58
+ model.icon ? m('span.text-2xl.mb-2.block', model.icon) : null,
59
+ m('h3.font-semibold.text-lg', model.label || model.name),
60
+ m('p.text-sm.text-gray-600 dark:text-slate-400.mt-2', model.table),
61
+ ])
62
+ )
63
+ ),
64
+ ]),
65
+ };
66
+
67
+ // Format cell value based on column type
68
+ function formatCellValue(value, col) {
69
+ if (value === null || value === undefined) {
70
+ return m('span.text-gray-400 dark:text-slate-500.italic', 'null');
71
+ }
72
+
73
+ switch (col?.type) {
74
+ case 'boolean':
75
+ return value
76
+ ? m('span.inline-flex.items-center.px-2.py-1.rounded-full.text-xs.font-medium.bg-green-100.text-green-800', '✓ Yes')
77
+ : 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');
78
+
79
+ case 'datetime':
80
+ case 'timestamp':
81
+ try {
82
+ const date = new Date(value);
83
+ return date.toLocaleString();
84
+ } catch { return String(value); }
85
+
86
+ case 'date':
87
+ try {
88
+ const date = new Date(value);
89
+ return date.toLocaleDateString();
90
+ } catch { return String(value); }
91
+
92
+ case 'json':
93
+ case 'array':
94
+ if (Array.isArray(value)) {
95
+ return value.length > 0
96
+ ? m('span.text-xs.bg-gray-100 dark:bg-slate-800.px-2.py-1.rounded', value.slice(0, 3).join(', ') + (value.length > 3 ? '...' : ''))
97
+ : m('span.text-gray-400', '[]');
98
+ }
99
+ if (typeof value === 'object') {
100
+ return m('span.text-xs.bg-gray-100 dark:bg-slate-800.px-2.py-1.rounded.font-mono', '{...}');
101
+ }
102
+ return String(value);
103
+
104
+ case 'text': {
105
+ const textStr = String(value);
106
+ return textStr.length > 50 ? textStr.substring(0, 50) + '...' : textStr;
107
+ }
108
+
109
+ case 'file': {
110
+ const s = String(value);
111
+ const short = s.length > 72 ? s.substring(0, 72) + '…' : s;
112
+ if (/^https?:\/\//.test(s) || s.startsWith('/')) {
113
+ return m('a.text-indigo-600.dark:text-indigo-400.hover:underline.break-all', {
114
+ href: s,
115
+ target: '_blank',
116
+ rel: 'noopener noreferrer',
117
+ }, short);
118
+ }
119
+ return short || m('span.text-gray-400', '—');
120
+ }
121
+
122
+ default: {
123
+ const str = String(value);
124
+ return str.length > 100 ? str.substring(0, 100) + '...' : str;
125
+ }
126
+ }
127
+ }
128
+
129
+ /** Bulk field definitions with type date / datetime / timestamp (no predefined options). */
130
+ function bulkFieldUsesTemporalInput(field) {
131
+ const t = field?.type;
132
+ return t === 'date' || t === 'datetime' || t === 'timestamp';
133
+ }
134
+
135
+ // Load bulk-updatable fields for a model
136
+ async function loadBulkFields(modelName) {
137
+ try {
138
+ const response = await api.get('/extensions/bulk-fields/' + modelName);
139
+ state.bulkFields = response.fields || [];
140
+ m.redraw();
141
+ } catch (err) {
142
+ console.error('Failed to load bulk fields:', err);
143
+ state.bulkFields = [];
144
+ }
145
+ }
146
+
147
+ // Execute bulk field update
148
+ async function executeBulkFieldUpdate(modelName, field, value, ids) {
149
+ try {
150
+ const response = await api.post('/extensions/bulk-update/' + modelName, {
151
+ ids: ids,
152
+ field: field,
153
+ value: value,
154
+ });
155
+ return response;
156
+ } catch (err) {
157
+ throw err;
158
+ }
159
+ }
160
+
161
+ // Execute bulk field update
162
+ async function executeBulkFieldUpdateWithSelectAll(modelName, field, value, selectedIds, selectAllMode, filters) {
163
+ try {
164
+ const payload = selectAllMode
165
+ ? { selectAll: true, filters: filters, field: field, value: value }
166
+ : { ids: selectedIds, field: field, value: value };
167
+ const response = await api.post('/extensions/bulk-update/' + modelName, payload);
168
+ return response;
169
+ } catch (err) {
170
+ throw err;
171
+ }
172
+ }
173
+
174
+ // Bulk Field Update Dropdown Component
175
+ const BulkFieldUpdateDropdown = {
176
+ oninit(vnode) {
177
+ vnode.state.bulkTemporalInput = '';
178
+ vnode.state._bulkFieldName = null;
179
+ },
180
+ view: (vnode) => {
181
+ const { modelName, selectedIds, selectAllMode, filters, onComplete } = vnode.attrs;
182
+
183
+ const sf = state.selectedBulkField;
184
+ if (sf && bulkFieldUsesTemporalInput(sf)) {
185
+ if (vnode.state._bulkFieldName !== sf.name) {
186
+ vnode.state._bulkFieldName = sf.name;
187
+ vnode.state.bulkTemporalInput = '';
188
+ }
189
+ }
190
+
191
+ if (!state.bulkFields || state.bulkFields.length === 0) {
192
+ return null;
193
+ }
194
+
195
+ const menuWide = !!(sf && bulkFieldUsesTemporalInput(sf));
196
+
197
+ return m('.relative.inline-block', [
198
+ // Dropdown trigger
199
+ 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', {
200
+ disabled: state.bulkActionInProgress,
201
+ onclick: (e) => {
202
+ e.stopPropagation();
203
+ state.bulkFieldDropdownOpen = !state.bulkFieldDropdownOpen;
204
+ state.selectedBulkField = null;
205
+ m.redraw();
206
+ },
207
+ }, [
208
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
209
+ 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' })
210
+ ),
211
+ 'Set Field',
212
+ m('svg.w-4.h-4.ml-1', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
213
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M19 9l-7 7-7-7' })
214
+ ),
215
+ ]),
216
+
217
+ // Dropdown menu
218
+ state.bulkFieldDropdownOpen && m(menuWide ? '.absolute.z-50.mt-1.w-72.bg-white dark:bg-slate-800.rounded-lg.shadow-lg.border.border-gray-200 dark:border-slate-600.overflow-hidden' : '.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', {
219
+ style: 'left: 0; top: 100%;',
220
+ onclick: (e) => e.stopPropagation(),
221
+ }, [
222
+ // Close button area click handler
223
+ m('.fixed.inset-0.z-40', {
224
+ onclick: () => {
225
+ state.bulkFieldDropdownOpen = false;
226
+ state.selectedBulkField = null;
227
+ m.redraw();
228
+ },
229
+ }),
230
+
231
+ // Dropdown content
232
+ m('.relative.z-50.bg-white dark:bg-slate-800', [
233
+ // Header
234
+ m('.px-3.py-2.bg-gray-50 dark:bg-slate-900.border-b.border-gray-200 dark:border-slate-700', [
235
+ m('span.text-xs.font-medium.text-gray-500 dark:text-slate-400.uppercase.tracking-wider',
236
+ state.selectedBulkField
237
+ ? (bulkFieldUsesTemporalInput(state.selectedBulkField)
238
+ ? 'Set date / time'
239
+ : 'Select Value')
240
+ : 'Select Field'
241
+ ),
242
+ ]),
243
+
244
+ // Field list or value list or temporal inputs
245
+ m('.max-h-64.overflow-y-auto', [
246
+ state.selectedBulkField && bulkFieldUsesTemporalInput(state.selectedBulkField)
247
+ ? m('.px-3.py-3.space-y-2', [
248
+ state.selectedBulkField.nullable
249
+ ? m('button.w-full.px-3.py-2.text-left.text-sm.rounded.border.border-gray-200.dark:border-slate-600.text-gray-700.dark:text-slate-200.hover:bg-purple-50.dark:hover:bg-slate-700.transition-colors', {
250
+ onclick: async () => {
251
+ state.bulkActionInProgress = true;
252
+ state.bulkFieldDropdownOpen = false;
253
+ m.redraw();
254
+ try {
255
+ await executeBulkFieldUpdateWithSelectAll(
256
+ modelName,
257
+ state.selectedBulkField.name,
258
+ null,
259
+ selectedIds,
260
+ selectAllMode,
261
+ filters,
262
+ );
263
+ state.selectedBulkField = null;
264
+ vnode.state.bulkTemporalInput = '';
265
+ vnode.state._bulkFieldName = null;
266
+ if (onComplete) onComplete();
267
+ } catch (err) {
268
+ alert('Error: ' + err.message);
269
+ } finally {
270
+ state.bulkActionInProgress = false;
271
+ m.redraw();
272
+ }
273
+ },
274
+ }, 'Clear (set null)')
275
+ : null,
276
+ m('label.block.text-xs.text-gray-500.dark:text-slate-400', 'Value'),
277
+ m('input.w-full.px-2.py-1.5.text-sm.border.border-gray-200.dark:border-slate-600.rounded.bg-white.dark:bg-slate-900.text-gray-900.dark:text-slate-100', {
278
+ type: state.selectedBulkField.type === 'date' ? 'date' : 'datetime-local',
279
+ value: vnode.state.bulkTemporalInput,
280
+ oninput: (e) => {
281
+ vnode.state.bulkTemporalInput = e.target.value;
282
+ },
283
+ }),
284
+ m('button.w-full.mt-1.px-3.py-2.text-sm.font-medium.text-white.bg-purple-600.rounded.hover:bg-purple-700.transition-colors', {
285
+ onclick: async () => {
286
+ const trimmed = vnode.state.bulkTemporalInput && String(vnode.state.bulkTemporalInput).trim();
287
+ if (!trimmed) {
288
+ if (state.selectedBulkField.nullable) {
289
+ alert('Enter a date/time or use Clear (set null).');
290
+ } else {
291
+ alert('Please enter a value.');
292
+ }
293
+ return;
294
+ }
295
+ state.bulkActionInProgress = true;
296
+ state.bulkFieldDropdownOpen = false;
297
+ m.redraw();
298
+ try {
299
+ await executeBulkFieldUpdateWithSelectAll(
300
+ modelName,
301
+ state.selectedBulkField.name,
302
+ trimmed,
303
+ selectedIds,
304
+ selectAllMode,
305
+ filters,
306
+ );
307
+ state.selectedBulkField = null;
308
+ vnode.state.bulkTemporalInput = '';
309
+ vnode.state._bulkFieldName = null;
310
+ if (onComplete) onComplete();
311
+ } catch (err) {
312
+ alert('Error: ' + err.message);
313
+ } finally {
314
+ state.bulkActionInProgress = false;
315
+ m.redraw();
316
+ }
317
+ },
318
+ }, 'Apply to selected'),
319
+ ])
320
+ : state.selectedBulkField
321
+ ? state.selectedBulkField.options.map(option =>
322
+ m('button.w-full.px-3.py-2.text-left.text-sm.hover:bg-purple-50.flex.items-center.justify-between.transition-colors', {
323
+ onclick: async () => {
324
+ state.bulkActionInProgress = true;
325
+ state.bulkFieldDropdownOpen = false;
326
+ m.redraw();
327
+
328
+ try {
329
+ await executeBulkFieldUpdateWithSelectAll(modelName, state.selectedBulkField.name, option.value, selectedIds, selectAllMode, filters);
330
+ state.selectedBulkField = null;
331
+ if (onComplete) onComplete();
332
+ } catch (err) {
333
+ alert('Error: ' + err.message);
334
+ } finally {
335
+ state.bulkActionInProgress = false;
336
+ m.redraw();
337
+ }
338
+ },
339
+ }, [
340
+ m('span.text-gray-700', String(option.label)),
341
+ state.selectedBulkField.type === 'boolean' && m('span.ml-2',
342
+ option.value === true
343
+ ? m('span.inline-flex.items-center.px-2.py-0.5.rounded-full.text-xs.font-medium.bg-green-100.text-green-800', '✓')
344
+ : 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', '✗')
345
+ ),
346
+ ])
347
+ )
348
+ : state.bulkFields.map(field =>
349
+ m('button.w-full.px-3.py-2.text-left.text-sm.hover:bg-purple-50.flex.items-center.justify-between.transition-colors', {
350
+ onclick: () => {
351
+ state.selectedBulkField = field;
352
+ m.redraw();
353
+ },
354
+ }, [
355
+ m('.flex.items-center.gap-2', [
356
+ m('span.text-gray-700', formatColumnLabel(field.label || field.name)),
357
+ m('span.text-xs.text-gray-400.dark:text-slate-500.uppercase', field.type),
358
+ ]),
359
+ m('svg.w-4.h-4.text-gray-400', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
360
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 5l7 7-7 7' })
361
+ ),
362
+ ])
363
+ ),
364
+
365
+ // Back button when viewing values
366
+ 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', {
367
+ onclick: () => {
368
+ state.selectedBulkField = null;
369
+ m.redraw();
370
+ },
371
+ }, [
372
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
373
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M15 19l-7-7 7-7' })
374
+ ),
375
+ 'Back to fields',
376
+ ]),
377
+ ]),
378
+ ]),
379
+ ]),
380
+ ]);
381
+ },
382
+ };
383
+
384
+ // Get columns to display in table (limit to reasonable number)
385
+ function getDisplayColumns(columns) {
386
+ if (!columns || columns.length === 0) return [];
387
+
388
+ // Filter out hidden columns (password_hash, api_token, etc.)
389
+ const visible = [...columns].filter((col) => !col.hidden);
390
+
391
+ // Prioritize: id, name/title, then others (excluding long text/json fields)
392
+ const priority = ['id', 'name', 'title', 'email', 'slug', 'status', 'published', 'created_at'];
393
+ const exclude = ['password', 'content', 'body', 'description']; // Usually too long
394
+
395
+ const sorted = visible.sort((a, b) => {
396
+ const aIdx = priority.indexOf(a.name);
397
+ const bIdx = priority.indexOf(b.name);
398
+ if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
399
+ if (aIdx !== -1) return -1;
400
+ if (bIdx !== -1) return 1;
401
+ return 0;
402
+ });
403
+
404
+ // Filter and limit
405
+ return sorted
406
+ .filter(col => !exclude.includes(col.name) && col.type !== 'text' && col.type !== 'json')
407
+ .slice(0, 6); // Max 6 columns for readability
408
+ }
409
+
410
+ /** Sent to records API / URL when applying filters */
411
+ function filterPayloadIsActive(f) {
412
+ if (!f) return false;
413
+ if (f.op === 'is_null' || f.op === 'is_not_null') return true;
414
+ if (Array.isArray(f.value) && f.value.length > 0) return true;
415
+ return f.value !== '' || !!f.from || !!f.to;
416
+ }
417
+
418
+ // Build query string from filters
419
+ function buildFilterQuery(filters) {
420
+ if (!filters || Object.keys(filters).length === 0) return '';
421
+
422
+ const params = [];
423
+ for (const [col, filter] of Object.entries(filters)) {
424
+ if (!filter || (!filterPayloadIsActive(filter))) continue;
425
+
426
+ if (filter.op === 'is_null' || filter.op === 'is_not_null') {
427
+ params.push('filter[' + col + '][op]=' + encodeURIComponent(filter.op));
428
+ continue;
429
+ }
430
+ if (filter.op === 'between') {
431
+ params.push('filter[' + col + '][op]=between');
432
+ if (filter.from) params.push('filter[' + col + '][from]=' + encodeURIComponent(filter.from));
433
+ if (filter.to) params.push('filter[' + col + '][to]=' + encodeURIComponent(filter.to));
434
+ } else if (filter.op === 'in' && Array.isArray(filter.value)) {
435
+ params.push('filter[' + col + '][op]=in');
436
+ filter.value.forEach(v => params.push('filter[' + col + '][value]=' + encodeURIComponent(v)));
437
+ } else {
438
+ if (filter.op) params.push('filter[' + col + '][op]=' + encodeURIComponent(filter.op));
439
+ if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
440
+ params.push('filter[' + col + '][value]=' + encodeURIComponent(filter.value));
441
+ }
442
+ }
443
+ }
444
+
445
+ return params.length > 0 ? '&' + params.join('&') : '';
446
+ }
447
+
448
+ // Parse query string to filters
449
+ function parseFilterQuery(queryString) {
450
+ const filters = {};
451
+ if (!queryString) return filters;
452
+
453
+ const params = new URLSearchParams(queryString);
454
+ const filterParams = {};
455
+
456
+ // Group filter parameters using simple string parsing (no regex needed)
457
+ for (const [key, value] of params.entries()) {
458
+ // Parse filter[column][prop] format
459
+ if (key.startsWith('filter[')) {
460
+ const firstClose = key.indexOf(']');
461
+ const secondOpen = key.indexOf('[', firstClose);
462
+ const secondClose = key.indexOf(']', secondOpen);
463
+
464
+ if (firstClose > 7 && secondOpen > firstClose && secondClose > secondOpen) {
465
+ const col = key.substring(7, firstClose);
466
+ const prop = key.substring(secondOpen + 1, secondClose);
467
+
468
+ if (!filterParams[col]) filterParams[col] = {};
469
+ if (prop === 'value' && filterParams[col].value) {
470
+ // Multiple values for 'in' operator
471
+ if (!Array.isArray(filterParams[col].value)) {
472
+ filterParams[col].value = [filterParams[col].value];
473
+ }
474
+ filterParams[col].value.push(value);
475
+ } else {
476
+ filterParams[col][prop] = value;
477
+ }
478
+ }
479
+ }
480
+ }
481
+
482
+ // Convert to filter format
483
+ for (const [col, data] of Object.entries(filterParams)) {
484
+ if (data.op === 'between') {
485
+ filters[col] = { op: 'between', from: data.from || '', to: data.to || '' };
486
+ } else if (data.op === 'in') {
487
+ filters[col] = { op: 'in', value: Array.isArray(data.value) ? data.value : [data.value] };
488
+ } else if (data.op === 'is_null' || data.op === 'is_not_null') {
489
+ filters[col] = { op: data.op };
490
+ } else {
491
+ filters[col] = { op: data.op || 'contains', value: data.value || '' };
492
+ }
493
+ }
494
+
495
+ return filters;
496
+ }
497
+
498
+ // Load records with pagination and filters
499
+ function loadRecords(modelName, page = 1, filters = null) {
500
+ state.loading = true;
501
+ state.error = null;
502
+
503
+ const perPage = state.pagination.perPage || 20;
504
+ const activeFilters = filters !== null ? filters : state.filters;
505
+ const filterQuery = buildFilterQuery(activeFilters);
506
+
507
+ // Update URL with filters
508
+ const queryParams = new URLSearchParams();
509
+ queryParams.set('page', page);
510
+ if (state.trashedView) {
511
+ queryParams.set('trashed', 'only');
512
+ }
513
+ if (Object.keys(activeFilters).length > 0) {
514
+ for (const [col, filter] of Object.entries(activeFilters)) {
515
+ if (!filter || !filterPayloadIsActive(filter)) continue;
516
+ if (filter.op === 'is_null' || filter.op === 'is_not_null') {
517
+ queryParams.set('filter[' + col + '][op]', filter.op);
518
+ continue;
519
+ }
520
+ if (filter.op === 'between') {
521
+ queryParams.set('filter[' + col + '][op]', 'between');
522
+ if (filter.from) queryParams.set('filter[' + col + '][from]', filter.from);
523
+ if (filter.to) queryParams.set('filter[' + col + '][to]', filter.to);
524
+ } else if (filter.op === 'in' && Array.isArray(filter.value)) {
525
+ queryParams.set('filter[' + col + '][op]', 'in');
526
+ filter.value.forEach(v => queryParams.append('filter[' + col + '][value]', v));
527
+ } else {
528
+ if (filter.op) queryParams.set('filter[' + col + '][op]', filter.op);
529
+ if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
530
+ queryParams.set('filter[' + col + '][value]', filter.value);
531
+ }
532
+ }
533
+ }
534
+ }
535
+
536
+ // Update browser URL without reload
537
+ const newUrl = window.location.pathname + (queryParams.toString() ? '?' + queryParams.toString() : '');
538
+ window.history.replaceState({}, '', newUrl);
539
+
540
+ const trashedParam = state.trashedView ? '&trashed=only' : '';
541
+ return api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + trashedParam + filterQuery)
542
+ .then(result => {
543
+ state.records = result.data || [];
544
+ state.pagination = {
545
+ page: result.pagination?.page || page,
546
+ perPage: result.pagination?.perPage || perPage,
547
+ total: result.pagination?.total || state.records.length,
548
+ totalPages: result.pagination?.totalPages || Math.ceil((result.pagination?.total || state.records.length) / perPage),
549
+ };
550
+ })
551
+ .catch(err => {
552
+ state.error = err.message;
553
+ })
554
+ .finally(() => {
555
+ state.loading = false;
556
+ m.redraw();
557
+ });
558
+ }
559
+
560
+ // Initialize model data
561
+ function initializeModelView(modelName) {
562
+ state.records = [];
563
+ state.currentModelMeta = null;
564
+ state.pagination = { page: 1, perPage: 20, total: 0, totalPages: 0 };
565
+ state.filterPanelOpen = false;
566
+ state.filterDrawerOpen = false;
567
+ state.selectedRecords = new Set(); // Bulk selection
568
+ state.selectAllMode = false; // Reset select all mode
569
+ state.trashedView = false; // Soft delete: show trashed records
570
+ state.bulkActionInProgress = false;
571
+ state.bulkFields = []; // Reset bulk fields
572
+ state.bulkFieldDropdownOpen = false;
573
+ state.selectedBulkField = null;
574
+ state._currentModelName = modelName;
575
+
576
+ // Parse filters from URL query string
577
+ const urlParams = new URLSearchParams(window.location.search);
578
+ const page = parseInt(urlParams.get('page')) || 1;
579
+ state.filters = parseFilterQuery(window.location.search);
580
+
581
+ // Load model metadata first, then records
582
+ state.loading = true;
583
+ api.get('/models/' + modelName)
584
+ .then(modelMeta => {
585
+ state.currentModelMeta = modelMeta;
586
+ state.currentModel = modelMeta;
587
+ // Load bulk-updatable fields for this model
588
+ loadBulkFields(modelName);
589
+ return loadRecords(modelName, page, state.filters);
590
+ })
591
+ .catch(err => {
592
+ state.error = err.message;
593
+ state.loading = false;
594
+ m.redraw();
595
+ });
596
+ }