webspresso 0.0.97 → 0.0.98

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 (100) hide show
  1. package/.agents/ADMIN.md +153 -5
  2. package/.agents/ARCHITECTURE.md +10 -0
  3. package/.agents/CLI.md +195 -1
  4. package/.agents/PLUGINS.md +153 -9
  5. package/.agents/QUEUE.md +43 -0
  6. package/bin/commands/add-esbuild.js +80 -0
  7. package/bin/commands/add-tailwind.js +76 -31
  8. package/bin/commands/analytics-prune.js +57 -0
  9. package/bin/commands/api.js +207 -15
  10. package/bin/commands/build.js +99 -0
  11. package/bin/commands/dev.js +64 -35
  12. package/bin/commands/doctor.js +26 -0
  13. package/bin/commands/email-prune.js +47 -0
  14. package/bin/commands/make-job.js +86 -0
  15. package/bin/commands/make-model.js +250 -0
  16. package/bin/commands/new.js +45 -28
  17. package/bin/commands/openapi.js +102 -0
  18. package/bin/commands/page.js +6 -3
  19. package/bin/commands/queue-prune.js +59 -0
  20. package/bin/commands/routes.js +52 -0
  21. package/bin/commands/service.js +6 -3
  22. package/bin/commands/typegen.js +106 -0
  23. package/bin/commands/upload-prune.js +79 -0
  24. package/bin/webspresso.js +22 -0
  25. package/core/applySchema.js +13 -6
  26. package/core/auth/hash.js +21 -12
  27. package/core/compression/middleware.js +23 -17
  28. package/core/openapi/build-from-api-routes.js +160 -29
  29. package/core/orm/cache/fingerprint.js +15 -1
  30. package/core/orm/cache/memory-provider.js +27 -2
  31. package/core/orm/index.js +12 -7
  32. package/core/orm/model.js +49 -7
  33. package/core/queue/adapters/database.js +94 -0
  34. package/core/queue/adapters/memory.js +62 -0
  35. package/core/queue/adapters/redis.js +80 -0
  36. package/core/queue/manager.js +123 -0
  37. package/core/ssr/stream.js +11 -0
  38. package/index.d.ts +660 -10
  39. package/index.js +44 -96
  40. package/package.json +1 -1
  41. package/plugins/admin-panel/api.js +279 -17
  42. package/plugins/admin-panel/app.js +114 -22
  43. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  44. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
  46. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  47. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  48. package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
  49. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  50. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  51. package/plugins/admin-panel/core/admin-module.js +13 -7
  52. package/plugins/admin-panel/core/api-extensions.js +30 -2
  53. package/plugins/admin-panel/core/registry.js +96 -16
  54. package/plugins/admin-panel/index.js +96 -0
  55. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  56. package/plugins/admin-panel/modules/admin-users.js +16 -1
  57. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  58. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  59. package/plugins/admin-panel/modules/dashboard.js +283 -0
  60. package/plugins/admin-panel/modules/menu.js +56 -18
  61. package/plugins/admin-panel/modules/user-management.js +24 -6
  62. package/plugins/audit-log/admin-component.js +11 -0
  63. package/plugins/audit-log/api-handlers.js +34 -0
  64. package/plugins/audit-log/index.js +129 -3
  65. package/plugins/audit-log/purge.js +60 -10
  66. package/plugins/dashboard/app.js +152 -27
  67. package/plugins/dashboard/index.js +143 -39
  68. package/plugins/dashboard/styles.js +78 -0
  69. package/plugins/data-exchange/export-xlsx.js +9 -2
  70. package/plugins/data-exchange/import.js +9 -2
  71. package/plugins/data-exchange/index.js +6 -3
  72. package/plugins/email/index.js +132 -2
  73. package/plugins/email/purge.js +60 -10
  74. package/plugins/index.js +4 -0
  75. package/plugins/orm-cache-admin/index.js +4 -2
  76. package/plugins/queue/index.js +11 -0
  77. package/plugins/schema-explorer/admin-component.js +714 -0
  78. package/plugins/schema-explorer/relations-graph.js +205 -0
  79. package/plugins/schema-explorer.js +248 -69
  80. package/plugins/site-analytics/admin-component.js +23 -0
  81. package/plugins/site-analytics/api-handlers.js +42 -0
  82. package/plugins/site-analytics/index.js +147 -2
  83. package/plugins/site-analytics/purge.js +168 -0
  84. package/plugins/swagger.js +261 -13
  85. package/plugins/upload/index.js +26 -0
  86. package/plugins/upload/orphan-pruner.js +292 -0
  87. package/src/api/api-loader.js +3 -1
  88. package/src/bundle/index.js +517 -0
  89. package/src/bundle/manifest.js +165 -0
  90. package/src/dev/banner.js +296 -0
  91. package/src/dev/colors.js +70 -0
  92. package/src/dev/index.js +62 -0
  93. package/src/dev/profiler.js +354 -0
  94. package/src/dev/typegen.js +497 -0
  95. package/src/dev-reload/index.js +611 -0
  96. package/src/file-router.js +210 -62
  97. package/src/helpers.js +958 -98
  98. package/src/njk-frontmatter.js +12 -0
  99. package/src/pages/page-loader.js +61 -11
  100. package/src/server.js +629 -63
@@ -84,7 +84,27 @@ function formatCellValue(value, col, record = null) {
84
84
  return String(value);
85
85
  }
86
86
  }
87
-
87
+
88
+ // Badge formatting for status / enum / mapped values
89
+ const badgeMap = (col && col.badge) || (col && col.customField && col.customField.badge);
90
+ if (badgeMap && typeof badgeMap === 'object') {
91
+ const valStr = String(value);
92
+ const color = badgeMap[valStr] || badgeMap['*'] || 'gray';
93
+ const colorClasses = {
94
+ green: 'bg-green-100 text-green-800 dark:bg-green-950/60 dark:text-green-300 border border-green-200 dark:border-green-800',
95
+ red: 'bg-red-100 text-red-800 dark:bg-red-950/60 dark:text-red-300 border border-red-200 dark:border-red-800',
96
+ blue: 'bg-blue-100 text-blue-800 dark:bg-blue-950/60 dark:text-blue-300 border border-blue-200 dark:border-blue-800',
97
+ amber: 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300 border border-amber-200 dark:border-amber-800',
98
+ yellow: 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300 border border-amber-200 dark:border-amber-800',
99
+ purple: 'bg-purple-100 text-purple-800 dark:bg-purple-950/60 dark:text-purple-300 border border-purple-200 dark:border-purple-800',
100
+ violet: 'bg-purple-100 text-purple-800 dark:bg-purple-950/60 dark:text-purple-300 border border-purple-200 dark:border-purple-800',
101
+ indigo: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-950/60 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800',
102
+ gray: 'bg-gray-100 text-gray-800 dark:bg-slate-800 dark:text-slate-300 border border-gray-200 dark:border-slate-700',
103
+ }[color] || 'bg-gray-100 text-gray-800 dark:bg-slate-800 dark:text-slate-300 border border-gray-200 dark:border-slate-700';
104
+
105
+ return m('span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.' + colorClasses, valStr);
106
+ }
107
+
88
108
  switch (col?.type) {
89
109
  case 'boolean':
90
110
  return value
@@ -298,7 +318,7 @@ const BulkFieldUpdateDropdown = {
298
318
  vnode.state._bulkFieldName = null;
299
319
  if (onComplete) onComplete();
300
320
  } catch (err) {
301
- alert('Error: ' + err.message);
321
+ notifyError('Error: ' + err.message);
302
322
  } finally {
303
323
  state.bulkActionInProgress = false;
304
324
  m.redraw();
@@ -319,9 +339,9 @@ const BulkFieldUpdateDropdown = {
319
339
  const trimmed = vnode.state.bulkTemporalInput && String(vnode.state.bulkTemporalInput).trim();
320
340
  if (!trimmed) {
321
341
  if (state.selectedBulkField.nullable) {
322
- alert('Enter a date/time or use Clear (set null).');
342
+ notifyWarning('Enter a date/time or use Clear (set null).');
323
343
  } else {
324
- alert('Please enter a value.');
344
+ notifyWarning('Please enter a value.');
325
345
  }
326
346
  return;
327
347
  }
@@ -342,7 +362,7 @@ const BulkFieldUpdateDropdown = {
342
362
  vnode.state._bulkFieldName = null;
343
363
  if (onComplete) onComplete();
344
364
  } catch (err) {
345
- alert('Error: ' + err.message);
365
+ notifyError('Error: ' + err.message);
346
366
  } finally {
347
367
  state.bulkActionInProgress = false;
348
368
  m.redraw();
@@ -363,7 +383,7 @@ const BulkFieldUpdateDropdown = {
363
383
  state.selectedBulkField = null;
364
384
  if (onComplete) onComplete();
365
385
  } catch (err) {
366
- alert('Error: ' + err.message);
386
+ notifyError('Error: ' + err.message);
367
387
  } finally {
368
388
  state.bulkActionInProgress = false;
369
389
  m.redraw();
@@ -414,13 +434,27 @@ const BulkFieldUpdateDropdown = {
414
434
  },
415
435
  };
416
436
 
417
- // Get columns to display in table (limit to reasonable number)
418
- function getDisplayColumns(columns) {
437
+ // Get columns to display in table (respects listColumns if configured, else limits to reasonable number)
438
+ function getDisplayColumns(columns, modelMeta = null) {
419
439
  if (!columns || columns.length === 0) return [];
420
440
 
421
441
  // Filter out hidden columns (password_hash, api_token, etc.)
422
442
  const visible = [...columns].filter((col) => !col.hidden);
423
443
 
444
+ // If model defines listColumns, respect the explicit column list and ordering
445
+ if (modelMeta?.listColumns && Array.isArray(modelMeta.listColumns) && modelMeta.listColumns.length > 0) {
446
+ const colMap = new Map(visible.map((c) => [c.name, c]));
447
+ const customList = [];
448
+ for (const name of modelMeta.listColumns) {
449
+ if (colMap.has(name)) {
450
+ customList.push(colMap.get(name));
451
+ }
452
+ }
453
+ if (customList.length > 0) {
454
+ return customList;
455
+ }
456
+ }
457
+
424
458
  // Prioritize: id, name/title, then others (excluding long text/json fields)
425
459
  const priority = ['id', 'name', 'title', 'email', 'slug', 'status', 'published', 'created_at'];
426
460
  const exclude = ['password', 'content', 'body', 'description']; // Usually too long
@@ -633,6 +667,13 @@ function initializeModelView(modelName) {
633
667
  .then(modelMeta => {
634
668
  state.currentModelMeta = modelMeta;
635
669
  state.currentModel = modelMeta;
670
+ if (typeof setAdminPageTitle === 'function') {
671
+ setAdminPageTitle(modelMeta?.label || modelName);
672
+ }
673
+ if (!state.sortColumn && modelMeta?.defaultSort?.column) {
674
+ state.sortColumn = modelMeta.defaultSort.column;
675
+ state.sortDirection = (modelMeta.defaultSort.order || 'desc').toLowerCase();
676
+ }
636
677
  // Load bulk-updatable fields for this model
637
678
  loadBulkFields(modelName);
638
679
  return loadRecords(modelName, page, state.filters, state.sortColumn, state.sortDirection);
@@ -1,3 +1,183 @@
1
+ // Helper to extract custom actions for a model
2
+ function getModelActions(modelName, modelMeta) {
3
+ const fromMeta = Array.isArray(modelMeta?.actions) ? modelMeta.actions : [];
4
+ const fromConfig = Array.isArray(window.__ADMIN_CONFIG__?.actions)
5
+ ? window.__ADMIN_CONFIG__.actions.filter(a => {
6
+ if (!a.models || a.models === '*') return true;
7
+ if (Array.isArray(a.models)) return a.models.includes(modelName);
8
+ return a.models === modelName;
9
+ })
10
+ : [];
11
+
12
+ const map = new Map();
13
+ for (const a of fromConfig) map.set(a.id, a);
14
+ for (const a of fromMeta) map.set(a.id, a);
15
+ return Array.from(map.values());
16
+ }
17
+
18
+ // Helper to extract custom bulk actions for a model
19
+ function getModelBulkActions(modelName, modelMeta) {
20
+ const fromMeta = Array.isArray(modelMeta?.bulkActions) ? modelMeta.bulkActions : [];
21
+ const fromConfig = Array.isArray(window.__ADMIN_CONFIG__?.bulkActions)
22
+ ? window.__ADMIN_CONFIG__.bulkActions.filter(a => {
23
+ if (a.id === 'bulk-delete' || a.id === 'bulk-restore' || a.id === 'bulk-update') return false;
24
+ if (!a.models || a.models === '*') return true;
25
+ if (Array.isArray(a.models)) return a.models.includes(modelName);
26
+ return a.models === modelName;
27
+ })
28
+ : [];
29
+
30
+ const map = new Map();
31
+ for (const a of fromConfig) map.set(a.id, a);
32
+ for (const a of fromMeta) map.set(a.id, a);
33
+ return Array.from(map.values());
34
+ }
35
+
36
+ // Evaluate condition whether an action should be shown for a record
37
+ function shouldShowAction(action, record) {
38
+ if (!action || !action.showWhen) return true;
39
+ if (!record) return true;
40
+ if (typeof action.showWhen === 'function') {
41
+ try {
42
+ return !!action.showWhen(record);
43
+ } catch (e) {
44
+ return false;
45
+ }
46
+ }
47
+ if (typeof action.showWhen === 'object' && action.showWhen !== null) {
48
+ return Object.entries(action.showWhen).every(([k, v]) => record[k] === v);
49
+ }
50
+ return true;
51
+ }
52
+
53
+ // Render icon for actions (SVG string or common icon names)
54
+ function renderActionIcon(icon) {
55
+ if (!icon) return null;
56
+ if (typeof icon === 'string' && (icon.startsWith('<svg') || icon.includes('</svg>'))) {
57
+ return m.trust(icon);
58
+ }
59
+ const iconPaths = {
60
+ check: 'M5 13l4 4L19 7',
61
+ copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
62
+ duplicate: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
63
+ play: 'M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z',
64
+ refresh: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15',
65
+ eye: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z',
66
+ trash: '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',
67
+ archive: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4',
68
+ mail: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
69
+ star: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z',
70
+ };
71
+ const pathD = iconPaths[icon];
72
+ if (pathD) {
73
+ return m('svg.w-3.5.h-3.5', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
74
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: pathD })
75
+ );
76
+ }
77
+ return m('span.text-xs', icon);
78
+ }
79
+
80
+ function getActionColorClass(color) {
81
+ switch (color) {
82
+ case 'green':
83
+ return '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';
84
+ case 'red':
85
+ return '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';
86
+ case 'blue':
87
+ case 'indigo':
88
+ return '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';
89
+ case 'amber':
90
+ case 'yellow':
91
+ return 'text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-950/40';
92
+ case 'purple':
93
+ case 'violet':
94
+ return 'text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-300 hover:bg-purple-50 dark:hover:bg-purple-950/40';
95
+ default:
96
+ return 'text-gray-600 dark:text-slate-400 hover:text-gray-800 dark:hover:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700/60';
97
+ }
98
+ }
99
+
100
+ function getBulkActionColorClass(color) {
101
+ switch (color) {
102
+ case 'green':
103
+ return 'text-green-600 dark:text-green-400 border-green-200 dark:border-green-800 hover:bg-green-50 dark:hover:bg-green-950/40';
104
+ case 'red':
105
+ return 'text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-950/40';
106
+ case 'blue':
107
+ case 'indigo':
108
+ return 'text-indigo-600 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800 hover:bg-indigo-50 dark:hover:bg-indigo-950/40';
109
+ case 'amber':
110
+ case 'yellow':
111
+ return 'text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-800 hover:bg-amber-50 dark:hover:bg-amber-950/40';
112
+ case 'purple':
113
+ case 'violet':
114
+ return 'text-purple-600 dark:text-purple-400 border-purple-200 dark:border-purple-800 hover:bg-purple-50 dark:hover:bg-purple-950/40';
115
+ default:
116
+ return 'text-gray-700 dark:text-slate-300 border-gray-300 dark:border-slate-600 hover:bg-gray-50 dark:hover:bg-slate-700/40';
117
+ }
118
+ }
119
+
120
+ async function executeRecordAction(action, record, modelName, modelMeta, onSuccess) {
121
+ if (action.confirm) {
122
+ const msg = action.confirmMessage || ('Are you sure you want to perform "' + action.label + '"?');
123
+ const isConfirmed = await confirmAction({ title: action.label, message: msg, danger: action.color === 'red' });
124
+ if (!isConfirmed) return;
125
+ }
126
+ const primaryKey = modelMeta?.primaryKey || 'id';
127
+ const recId = record[primaryKey];
128
+ const busyKey = action.id + '-' + recId;
129
+ state.actionInProgress = busyKey;
130
+ m.redraw();
131
+ try {
132
+ const res = await api.post('/extensions/actions/' + action.id + '/' + modelName + '/' + recId);
133
+ const msg = res?.result?.message || res?.message;
134
+ if (msg) {
135
+ notifySuccess(msg);
136
+ }
137
+ if (typeof onSuccess === 'function') {
138
+ await onSuccess(res);
139
+ } else if (typeof loadRecords === 'function' && state.pagination) {
140
+ loadRecords(modelName, state.pagination.page);
141
+ }
142
+ } catch (err) {
143
+ const errText = 'Action failed: ' + (err.message || err);
144
+ notifyError(errText);
145
+ } finally {
146
+ state.actionInProgress = null;
147
+ m.redraw();
148
+ }
149
+ }
150
+
151
+ async function executeBulkAction(action, modelName, modelMeta) {
152
+ const count = state.selectAllMode ? state.pagination.total : state.selectedRecords.size;
153
+ if (action.confirm) {
154
+ const msg = action.confirmMessage || ('Perform "' + action.label + '" on ' + count + ' records?');
155
+ const isConfirmed = await confirmAction({ title: action.label, message: msg, danger: action.color === 'red' });
156
+ if (!isConfirmed) return;
157
+ }
158
+ state.bulkActionInProgress = true;
159
+ m.redraw();
160
+ try {
161
+ const payload = state.selectAllMode
162
+ ? { selectAll: true, filters: state.filters, trashed: state.trashedView }
163
+ : { ids: Array.from(state.selectedRecords), trashed: state.trashedView };
164
+ const res = await api.post('/extensions/bulk-actions/' + action.id + '/' + modelName, payload);
165
+ state.selectedRecords = new Set();
166
+ state.selectAllMode = false;
167
+ loadRecords(modelName, state.pagination.page);
168
+ if (res?.result?.message || res?.message) {
169
+ const msg = res.result?.message || res.message;
170
+ notifySuccess(msg);
171
+ }
172
+ } catch (err) {
173
+ const errText = 'Bulk action failed: ' + (err.message || err);
174
+ notifyError(errText);
175
+ } finally {
176
+ state.bulkActionInProgress = false;
177
+ m.redraw();
178
+ }
179
+ }
180
+
1
181
  // Record List Component - displays records with dynamic columns
2
182
  const RecordList = {
3
183
  oninit(vnode) {
@@ -25,8 +205,10 @@ const RecordList = {
25
205
  view: (vnode) => {
26
206
  const modelName = m.route.param('model');
27
207
  const modelMeta = state.currentModelMeta;
28
- const displayColumns = modelMeta ? getDisplayColumns(modelMeta.columns) : [];
208
+ const displayColumns = modelMeta ? getDisplayColumns(modelMeta.columns, modelMeta) : [];
29
209
  const primaryKey = modelMeta?.primaryKey || 'id';
210
+ const customActions = getModelActions(modelName, modelMeta);
211
+ const customBulkActions = getModelBulkActions(modelName, modelMeta);
30
212
 
31
213
  // Count active filters
32
214
  const activeFilterCount = Object.values(state.filters || {}).filter(filterPayloadIsActive).length;
@@ -101,7 +283,7 @@ const RecordList = {
101
283
  if (state.trashedView) payload.trashed = 'only';
102
284
  await downloadDataExchangeXlsx(modelName, payload);
103
285
  } catch (err) {
104
- alert('Error: ' + err.message);
286
+ notifyError('Error: ' + err.message);
105
287
  }
106
288
  },
107
289
  }, [
@@ -119,7 +301,12 @@ const RecordList = {
119
301
  e.target.value = '';
120
302
  if (!file) return;
121
303
  const adminPath = window.__ADMIN_PATH__ || '/_admin';
122
- const mode = window.confirm('OK = upsert by id, Cancel = insert only') ? 'upsert' : 'insert';
304
+ const mode = (await confirmAction({
305
+ title: 'Import Mode',
306
+ message: 'Confirm to upsert records matching existing IDs, or cancel to insert as new records only.',
307
+ confirmText: 'Upsert by ID',
308
+ cancelText: 'Insert only'
309
+ })) ? 'upsert' : 'insert';
123
310
  const upsertKey = 'id';
124
311
  const fd = new FormData();
125
312
  fd.append('file', file);
@@ -137,10 +324,10 @@ const RecordList = {
137
324
  if (body.errors && body.errors.length) {
138
325
  msg += 'First errors: ' + body.errors.slice(0, 3).map(function (x) { return 'row ' + x.row + ': ' + x.message; }).join('; ');
139
326
  }
140
- alert(msg);
327
+ notifySuccess(msg);
141
328
  loadRecords(modelName, state.pagination.page, state.filters);
142
329
  } catch (err) {
143
- alert('Error: ' + err.message);
330
+ notifyError('Error: ' + err.message);
144
331
  }
145
332
  },
146
333
  }) : null,
@@ -155,7 +342,7 @@ const RecordList = {
155
342
  ),
156
343
  'Import',
157
344
  ]) : 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', {
345
+ !state.trashedView && !modelMeta?.readOnly && modelMeta?.permissions?.create !== false ? 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
346
  onclick: () => {
160
347
  state.currentRecord = null;
161
348
  state.editing = true;
@@ -259,7 +446,7 @@ const RecordList = {
259
446
  ? 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
447
  disabled: state.bulkActionInProgress,
261
448
  onclick: async () => {
262
- if (!confirm('Restore the selected records?')) return;
449
+ if (!(await confirmAction({ title: 'Restore Records', message: 'Restore the selected records?', confirmText: 'Restore' }))) return;
263
450
  state.bulkActionInProgress = true;
264
451
  m.redraw();
265
452
  try {
@@ -269,9 +456,10 @@ const RecordList = {
269
456
  await api.post('/extensions/bulk-actions/bulk-restore/' + modelName, payload);
270
457
  state.selectedRecords = new Set();
271
458
  state.selectAllMode = false;
459
+ notifySuccess('Records restored successfully');
272
460
  loadRecords(modelName, 1);
273
461
  } catch (err) {
274
- alert('Error: ' + err.message);
462
+ notifyError('Error: ' + err.message);
275
463
  } finally {
276
464
  state.bulkActionInProgress = false;
277
465
  m.redraw();
@@ -283,11 +471,11 @@ const RecordList = {
283
471
  ),
284
472
  'Restore',
285
473
  ])
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', {
474
+ : (!modelMeta?.readOnly && modelMeta?.permissions?.delete !== false ? 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
475
  disabled: state.bulkActionInProgress,
288
476
  onclick: async () => {
289
477
  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;
478
+ if (!(await confirmAction({ title: 'Delete Records', message: 'Are you sure you want to delete ' + count + ' records? This action cannot be undone.', danger: true, confirmText: 'Delete' }))) return;
291
479
  state.bulkActionInProgress = true;
292
480
  m.redraw();
293
481
  try {
@@ -297,9 +485,10 @@ const RecordList = {
297
485
  await api.post('/extensions/bulk-actions/bulk-delete/' + modelName, payload);
298
486
  state.selectedRecords = new Set();
299
487
  state.selectAllMode = false;
488
+ notifySuccess('Records deleted successfully');
300
489
  loadRecords(modelName, 1);
301
490
  } catch (err) {
302
- alert('Error: ' + err.message);
491
+ notifyError('Error: ' + err.message);
303
492
  } finally {
304
493
  state.bulkActionInProgress = false;
305
494
  m.redraw();
@@ -310,7 +499,17 @@ const RecordList = {
310
499
  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
500
  ),
312
501
  'Delete',
313
- ]),
502
+ ]) : null),
503
+ // Custom bulk actions
504
+ !state.trashedView && customBulkActions.map(action =>
505
+ m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.bg-white.dark:bg-slate-800.border.rounded.transition-colors.' + getBulkActionColorClass(action.color), {
506
+ disabled: state.bulkActionInProgress,
507
+ onclick: () => executeBulkAction(action, modelName, modelMeta),
508
+ }, [
509
+ action.icon ? renderActionIcon(action.icon) : null,
510
+ action.label,
511
+ ])
512
+ ),
314
513
  !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
514
  disabled: state.bulkActionInProgress,
316
515
  onclick: async () => {
@@ -335,7 +534,7 @@ const RecordList = {
335
534
  URL.revokeObjectURL(url);
336
535
  }, 1000);
337
536
  } catch (err) {
338
- alert('Error: ' + err.message);
537
+ notifyError('Error: ' + err.message);
339
538
  } finally {
340
539
  state.bulkActionInProgress = false;
341
540
  m.redraw();
@@ -357,7 +556,7 @@ const RecordList = {
357
556
  ? { selectAll: true, filters: state.filters }
358
557
  : { ids: Array.from(state.selectedRecords) };
359
558
  if (!state.selectAllMode && (!payload.ids || payload.ids.length === 0)) {
360
- alert('Please select at least one record to export');
559
+ notifyWarning('Please select at least one record to export');
361
560
  return;
362
561
  }
363
562
  const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', payload);
@@ -375,7 +574,7 @@ const RecordList = {
375
574
  URL.revokeObjectURL(url);
376
575
  }, 1000);
377
576
  } catch (err) {
378
- alert('Error: ' + err.message);
577
+ notifyError('Error: ' + err.message);
379
578
  } finally {
380
579
  state.bulkActionInProgress = false;
381
580
  m.redraw();
@@ -397,13 +596,13 @@ const RecordList = {
397
596
  ? { selectAll: true, filters: state.filters }
398
597
  : { ids: Array.from(state.selectedRecords) };
399
598
  if (!state.selectAllMode && (!payload.ids || payload.ids.length === 0)) {
400
- alert('Please select at least one record to export');
599
+ notifyWarning('Please select at least one record to export');
401
600
  return;
402
601
  }
403
602
  if (state.trashedView) payload.trashed = 'only';
404
603
  await downloadDataExchangeXlsx(modelName, payload);
405
604
  } catch (err) {
406
- alert('Error: ' + err.message);
605
+ notifyError('Error: ' + err.message);
407
606
  } finally {
408
607
  state.bulkActionInProgress = false;
409
608
  m.redraw();
@@ -543,32 +742,53 @@ const RecordList = {
543
742
  onclick: async () => {
544
743
  try {
545
744
  await api.post('/models/' + modelName + '/records/' + record[primaryKey] + '/restore');
745
+ notifySuccess('Record restored successfully');
546
746
  loadRecords(modelName, state.pagination.page);
547
747
  } catch (err) {
548
- alert('Error: ' + err.message);
748
+ notifyError('Error: ' + err.message);
549
749
  }
550
750
  },
551
751
  }, 'Restore')
552
752
  : [
753
+ // Custom row actions
754
+ ...customActions
755
+ .filter(action => shouldShowAction(action, record))
756
+ .map(action =>
757
+ m('button.inline-flex.items-center.px-2.py-1.text-sm.rounded.mr-1.transition-colors.' + getActionColorClass(action.color), {
758
+ disabled: state.actionInProgress === (action.id + '-' + record[primaryKey]),
759
+ onclick: () => executeRecordAction(action, record, modelName, modelMeta),
760
+ }, [
761
+ action.icon ? m('span.mr-1', renderActionIcon(action.icon)) : null,
762
+ state.actionInProgress === (action.id + '-' + record[primaryKey]) ? '...' : action.label,
763
+ ])
764
+ ),
553
765
  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', {
554
766
  onclick: () => {
555
767
  state.currentRecord = record;
556
768
  state.editing = true;
557
769
  m.route.set('/models/' + modelName + '/edit/' + record[primaryKey]);
558
770
  },
559
- }, 'Edit'),
560
- 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', {
771
+ }, modelMeta?.readOnly || modelMeta?.permissions?.edit === false ? 'View' : 'Edit'),
772
+ !modelMeta?.readOnly && modelMeta?.permissions?.delete !== false ? 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', {
561
773
  onclick: async () => {
562
- if (confirm('Are you sure you want to delete this record?')) {
774
+ const isConfirmed = await confirmAction({
775
+ title: 'Delete Record',
776
+ message: 'Are you sure you want to delete this record?',
777
+ danger: true,
778
+ confirmText: 'Delete'
779
+ });
780
+ if (isConfirmed) {
563
781
  try {
564
782
  await api.delete('/models/' + modelName + '/records/' + record[primaryKey]);
783
+ notifySuccess('Record deleted successfully');
565
784
  loadRecords(modelName, state.pagination.page);
566
785
  } catch (err) {
567
- alert('Error: ' + err.message);
786
+ const errMsg = 'Error: ' + err.message;
787
+ notifyError(errMsg);
568
788
  }
569
789
  }
570
790
  },
571
- }, 'Delete'),
791
+ }, 'Delete') : null,
572
792
  ],
573
793
  ]),
574
794
  ])
@@ -144,6 +144,10 @@ const RecordForm = {
144
144
  api.get('/models/' + modelName)
145
145
  .then(modelMeta => {
146
146
  state.currentModelMeta = modelMeta;
147
+ if (typeof setAdminPageTitle === 'function') {
148
+ const label = modelMeta?.label || modelName;
149
+ setAdminPageTitle((id && id !== 'new' ? 'Edit ' : 'New ') + label);
150
+ }
147
151
 
148
152
  // Initialize form data with defaults
149
153
  modelMeta.columns.forEach(col => {
@@ -179,16 +183,26 @@ const RecordForm = {
179
183
  const id = m.route.param('id');
180
184
  const isNew = !id || id === 'new';
181
185
  const modelMeta = state.currentModelMeta;
186
+ const isFormReadOnly = modelMeta?.readOnly === true ||
187
+ (isNew && modelMeta?.permissions?.create === false) ||
188
+ (!isNew && modelMeta?.permissions?.edit === false);
182
189
 
190
+ const customActions = !isNew && state.currentRecord && typeof getModelActions === 'function'
191
+ ? getModelActions(modelName, modelMeta).filter(action => typeof shouldShowAction === 'function' ? shouldShowAction(action, state.currentRecord) : true)
192
+ : [];
193
+
183
194
  const breadcrumbs = [
184
195
  { label: modelMeta?.label || modelName, href: '/models/' + modelName },
185
- { label: isNew ? 'New' : 'Edit #' + id, href: '#' },
196
+ { label: isNew ? 'New' : (isFormReadOnly ? 'View #' + id : 'Edit #' + id), href: '#' },
186
197
  ];
187
198
 
188
199
  return m(Layout, { breadcrumbs }, [
189
200
  m('.flex.items-center.justify-between.mb-6', [
190
- m('h2.text-2xl.font-bold.text-gray-900.dark:text-slate-100', isNew ? 'New Record' : 'Edit Record'),
191
- modelMeta ? m('span.text-gray-500.dark:text-slate-400', modelMeta.label || modelMeta.name) : null,
201
+ m('h2.text-2xl.font-bold.text-gray-900.dark:text-slate-100', isNew ? 'New Record' : (isFormReadOnly ? 'View Record' : 'Edit Record')),
202
+ m('.flex.items-center.gap-2', [
203
+ isFormReadOnly ? m('span.px-2.py-0.5.text-xs.font-semibold.rounded.bg-amber-100.text-amber-800.dark:bg-amber-900/40.dark:text-amber-300', 'Read-Only') : null,
204
+ modelMeta ? m('span.text-gray-500.dark:text-slate-400', modelMeta.label || modelMeta.name) : null,
205
+ ]),
192
206
  ]),
193
207
 
194
208
  state.loading ? m('p.text-gray-600.dark:text-slate-400', 'Loading...') :
@@ -198,6 +212,7 @@ const RecordForm = {
198
212
  style: 'min-height: calc(100vh - 280px);',
199
213
  onsubmit: async (e) => {
200
214
  e.preventDefault();
215
+ if (isFormReadOnly) return;
201
216
  state.error = null;
202
217
  state.fieldErrors = {};
203
218
 
@@ -261,6 +276,9 @@ const RecordForm = {
261
276
  } else {
262
277
  await api.put('/models/' + modelName + '/records/' + id, payload);
263
278
  }
279
+ if (typeof window !== 'undefined' && window.Admin && typeof window.Admin.toast === 'function') {
280
+ window.Admin.toast.success(isNew ? 'Record created successfully' : 'Record updated successfully');
281
+ }
264
282
  m.route.set('/models/' + modelName);
265
283
  } catch (err) {
266
284
  state.error = err.message || 'Failed to save record';
@@ -287,12 +305,13 @@ const RecordForm = {
287
305
  // Hide hidden fields
288
306
  if (col.ui && col.ui.hidden) return null;
289
307
 
290
- const isReadonly = !!autoType || (col.ui && col.ui.readonly);
308
+ const isReadonly = isFormReadOnly || !!autoType || (col.ui && col.ui.readonly);
291
309
  const renderer = getFieldRenderer(col, modelMeta);
292
310
  const value = state.formData[col.name];
293
311
  const fieldError = state.fieldErrors ? state.fieldErrors[col.name] : null;
294
312
 
295
313
  const onChange = (newValue) => {
314
+ if (isFormReadOnly) return;
296
315
  state.formData[col.name] = newValue;
297
316
  if (state.fieldErrors && state.fieldErrors[col.name]) {
298
317
  delete state.fieldErrors[col.name];
@@ -304,14 +323,42 @@ const RecordForm = {
304
323
  ]),
305
324
 
306
325
  // Sticky footer buttons
307
- 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', [
308
- 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', {
309
- type: 'submit',
310
- disabled: state.loading,
311
- }, state.loading ? 'Saving...' : 'Save'),
312
- 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[type=button]', {
313
- onclick: () => m.route.set('/models/' + modelName),
314
- }, 'Cancel'),
326
+ m('.flex.items-center.justify-between.gap-4.p-4.border-t.border-gray-200.dark:border-slate-700.bg-gray-50.dark:bg-slate-900.sticky.bottom-0', [
327
+ m('.flex.items-center.gap-3', [
328
+ isFormReadOnly
329
+ ? m('button.bg-gray-600.dark:bg-slate-700.text-white.px-6.py-2.rounded-lg.hover:bg-gray-700.dark:hover:bg-slate-600[type=button]', {
330
+ onclick: () => m.route.set('/models/' + modelName),
331
+ }, 'Back')
332
+ : [
333
+ 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', {
334
+ type: 'submit',
335
+ disabled: state.loading,
336
+ }, state.loading ? 'Saving...' : 'Save'),
337
+ 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[type=button]', {
338
+ onclick: () => m.route.set('/models/' + modelName),
339
+ }, 'Cancel'),
340
+ ],
341
+ ]),
342
+ !isNew && state.currentRecord && customActions.length > 0 ? m('.flex.items-center.gap-2', customActions.map(action => {
343
+ const isBusy = state.actionInProgress === (action.id + '-' + id);
344
+ return m('button.px-3.py-2.text-xs.font-medium.rounded-lg.border.flex.items-center.gap-1.5.transition-colors[type=button]', {
345
+ class: typeof getBulkActionColorClass === 'function' ? getBulkActionColorClass(action.color) : 'text-gray-700 dark:text-slate-300 border-gray-300 dark:border-slate-600',
346
+ disabled: isBusy || state.loading,
347
+ title: action.label,
348
+ onclick: () => executeRecordAction(action, state.currentRecord, modelName, modelMeta, async () => {
349
+ const refreshed = await api.get('/models/' + modelName + '/records/' + id);
350
+ if (refreshed?.data) {
351
+ state.currentRecord = refreshed.data;
352
+ Object.keys(refreshed.data).forEach(key => {
353
+ state.formData[key] = refreshed.data[key];
354
+ });
355
+ }
356
+ }),
357
+ }, [
358
+ isBusy ? m('span.inline-block.animate-spin', '⟳') : (action.icon && typeof renderActionIcon === 'function' ? renderActionIcon(action.icon) : null),
359
+ m('span', action.label),
360
+ ]);
361
+ })) : null,
315
362
  ]),
316
363
  ]),
317
364
  ]);