webspresso 0.0.97 → 0.0.99

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 (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -1,3 +1,188 @@
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
+ const redirectUrl = res?.result?.redirect || res?.redirect;
138
+ if (redirectUrl) {
139
+ m.route.set(redirectUrl);
140
+ return;
141
+ }
142
+ if (typeof onSuccess === 'function') {
143
+ await onSuccess(res);
144
+ } else if (typeof loadRecords === 'function' && state.pagination) {
145
+ loadRecords(modelName, state.pagination.page);
146
+ }
147
+ } catch (err) {
148
+ const errText = 'Action failed: ' + (err.message || err);
149
+ notifyError(errText);
150
+ } finally {
151
+ state.actionInProgress = null;
152
+ m.redraw();
153
+ }
154
+ }
155
+
156
+ async function executeBulkAction(action, modelName, modelMeta) {
157
+ const count = state.selectAllMode ? state.pagination.total : state.selectedRecords.size;
158
+ if (action.confirm) {
159
+ const msg = action.confirmMessage || ('Perform "' + action.label + '" on ' + count + ' records?');
160
+ const isConfirmed = await confirmAction({ title: action.label, message: msg, danger: action.color === 'red' });
161
+ if (!isConfirmed) return;
162
+ }
163
+ state.bulkActionInProgress = true;
164
+ m.redraw();
165
+ try {
166
+ const payload = state.selectAllMode
167
+ ? { selectAll: true, filters: state.filters, trashed: state.trashedView }
168
+ : { ids: Array.from(state.selectedRecords), trashed: state.trashedView };
169
+ const res = await api.post('/extensions/bulk-actions/' + action.id + '/' + modelName, payload);
170
+ state.selectedRecords = new Set();
171
+ state.selectAllMode = false;
172
+ loadRecords(modelName, state.pagination.page);
173
+ if (res?.result?.message || res?.message) {
174
+ const msg = res.result?.message || res.message;
175
+ notifySuccess(msg);
176
+ }
177
+ } catch (err) {
178
+ const errText = 'Bulk action failed: ' + (err.message || err);
179
+ notifyError(errText);
180
+ } finally {
181
+ state.bulkActionInProgress = false;
182
+ m.redraw();
183
+ }
184
+ }
185
+
1
186
  // Record List Component - displays records with dynamic columns
2
187
  const RecordList = {
3
188
  oninit(vnode) {
@@ -25,8 +210,10 @@ const RecordList = {
25
210
  view: (vnode) => {
26
211
  const modelName = m.route.param('model');
27
212
  const modelMeta = state.currentModelMeta;
28
- const displayColumns = modelMeta ? getDisplayColumns(modelMeta.columns) : [];
213
+ const displayColumns = modelMeta ? getDisplayColumns(modelMeta.columns, modelMeta) : [];
29
214
  const primaryKey = modelMeta?.primaryKey || 'id';
215
+ const customActions = getModelActions(modelName, modelMeta);
216
+ const customBulkActions = getModelBulkActions(modelName, modelMeta);
30
217
 
31
218
  // Count active filters
32
219
  const activeFilterCount = Object.values(state.filters || {}).filter(filterPayloadIsActive).length;
@@ -101,7 +288,7 @@ const RecordList = {
101
288
  if (state.trashedView) payload.trashed = 'only';
102
289
  await downloadDataExchangeXlsx(modelName, payload);
103
290
  } catch (err) {
104
- alert('Error: ' + err.message);
291
+ notifyError('Error: ' + err.message);
105
292
  }
106
293
  },
107
294
  }, [
@@ -119,7 +306,12 @@ const RecordList = {
119
306
  e.target.value = '';
120
307
  if (!file) return;
121
308
  const adminPath = window.__ADMIN_PATH__ || '/_admin';
122
- const mode = window.confirm('OK = upsert by id, Cancel = insert only') ? 'upsert' : 'insert';
309
+ const mode = (await confirmAction({
310
+ title: 'Import Mode',
311
+ message: 'Confirm to upsert records matching existing IDs, or cancel to insert as new records only.',
312
+ confirmText: 'Upsert by ID',
313
+ cancelText: 'Insert only'
314
+ })) ? 'upsert' : 'insert';
123
315
  const upsertKey = 'id';
124
316
  const fd = new FormData();
125
317
  fd.append('file', file);
@@ -137,10 +329,10 @@ const RecordList = {
137
329
  if (body.errors && body.errors.length) {
138
330
  msg += 'First errors: ' + body.errors.slice(0, 3).map(function (x) { return 'row ' + x.row + ': ' + x.message; }).join('; ');
139
331
  }
140
- alert(msg);
332
+ notifySuccess(msg);
141
333
  loadRecords(modelName, state.pagination.page, state.filters);
142
334
  } catch (err) {
143
- alert('Error: ' + err.message);
335
+ notifyError('Error: ' + err.message);
144
336
  }
145
337
  },
146
338
  }) : null,
@@ -155,7 +347,7 @@ const RecordList = {
155
347
  ),
156
348
  'Import',
157
349
  ]) : 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', {
350
+ !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
351
  onclick: () => {
160
352
  state.currentRecord = null;
161
353
  state.editing = true;
@@ -259,7 +451,7 @@ const RecordList = {
259
451
  ? 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
452
  disabled: state.bulkActionInProgress,
261
453
  onclick: async () => {
262
- if (!confirm('Restore the selected records?')) return;
454
+ if (!(await confirmAction({ title: 'Restore Records', message: 'Restore the selected records?', confirmText: 'Restore' }))) return;
263
455
  state.bulkActionInProgress = true;
264
456
  m.redraw();
265
457
  try {
@@ -269,9 +461,10 @@ const RecordList = {
269
461
  await api.post('/extensions/bulk-actions/bulk-restore/' + modelName, payload);
270
462
  state.selectedRecords = new Set();
271
463
  state.selectAllMode = false;
464
+ notifySuccess('Records restored successfully');
272
465
  loadRecords(modelName, 1);
273
466
  } catch (err) {
274
- alert('Error: ' + err.message);
467
+ notifyError('Error: ' + err.message);
275
468
  } finally {
276
469
  state.bulkActionInProgress = false;
277
470
  m.redraw();
@@ -283,11 +476,11 @@ const RecordList = {
283
476
  ),
284
477
  'Restore',
285
478
  ])
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', {
479
+ : (!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
480
  disabled: state.bulkActionInProgress,
288
481
  onclick: async () => {
289
482
  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;
483
+ 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
484
  state.bulkActionInProgress = true;
292
485
  m.redraw();
293
486
  try {
@@ -297,9 +490,10 @@ const RecordList = {
297
490
  await api.post('/extensions/bulk-actions/bulk-delete/' + modelName, payload);
298
491
  state.selectedRecords = new Set();
299
492
  state.selectAllMode = false;
493
+ notifySuccess('Records deleted successfully');
300
494
  loadRecords(modelName, 1);
301
495
  } catch (err) {
302
- alert('Error: ' + err.message);
496
+ notifyError('Error: ' + err.message);
303
497
  } finally {
304
498
  state.bulkActionInProgress = false;
305
499
  m.redraw();
@@ -310,7 +504,17 @@ const RecordList = {
310
504
  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
505
  ),
312
506
  'Delete',
313
- ]),
507
+ ]) : null),
508
+ // Custom bulk actions
509
+ !state.trashedView && customBulkActions.map(action =>
510
+ 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), {
511
+ disabled: state.bulkActionInProgress,
512
+ onclick: () => executeBulkAction(action, modelName, modelMeta),
513
+ }, [
514
+ action.icon ? renderActionIcon(action.icon) : null,
515
+ action.label,
516
+ ])
517
+ ),
314
518
  !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
519
  disabled: state.bulkActionInProgress,
316
520
  onclick: async () => {
@@ -335,7 +539,7 @@ const RecordList = {
335
539
  URL.revokeObjectURL(url);
336
540
  }, 1000);
337
541
  } catch (err) {
338
- alert('Error: ' + err.message);
542
+ notifyError('Error: ' + err.message);
339
543
  } finally {
340
544
  state.bulkActionInProgress = false;
341
545
  m.redraw();
@@ -357,7 +561,7 @@ const RecordList = {
357
561
  ? { selectAll: true, filters: state.filters }
358
562
  : { ids: Array.from(state.selectedRecords) };
359
563
  if (!state.selectAllMode && (!payload.ids || payload.ids.length === 0)) {
360
- alert('Please select at least one record to export');
564
+ notifyWarning('Please select at least one record to export');
361
565
  return;
362
566
  }
363
567
  const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', payload);
@@ -375,7 +579,7 @@ const RecordList = {
375
579
  URL.revokeObjectURL(url);
376
580
  }, 1000);
377
581
  } catch (err) {
378
- alert('Error: ' + err.message);
582
+ notifyError('Error: ' + err.message);
379
583
  } finally {
380
584
  state.bulkActionInProgress = false;
381
585
  m.redraw();
@@ -397,13 +601,13 @@ const RecordList = {
397
601
  ? { selectAll: true, filters: state.filters }
398
602
  : { ids: Array.from(state.selectedRecords) };
399
603
  if (!state.selectAllMode && (!payload.ids || payload.ids.length === 0)) {
400
- alert('Please select at least one record to export');
604
+ notifyWarning('Please select at least one record to export');
401
605
  return;
402
606
  }
403
607
  if (state.trashedView) payload.trashed = 'only';
404
608
  await downloadDataExchangeXlsx(modelName, payload);
405
609
  } catch (err) {
406
- alert('Error: ' + err.message);
610
+ notifyError('Error: ' + err.message);
407
611
  } finally {
408
612
  state.bulkActionInProgress = false;
409
613
  m.redraw();
@@ -543,32 +747,53 @@ const RecordList = {
543
747
  onclick: async () => {
544
748
  try {
545
749
  await api.post('/models/' + modelName + '/records/' + record[primaryKey] + '/restore');
750
+ notifySuccess('Record restored successfully');
546
751
  loadRecords(modelName, state.pagination.page);
547
752
  } catch (err) {
548
- alert('Error: ' + err.message);
753
+ notifyError('Error: ' + err.message);
549
754
  }
550
755
  },
551
756
  }, 'Restore')
552
757
  : [
758
+ // Custom row actions
759
+ ...customActions
760
+ .filter(action => shouldShowAction(action, record))
761
+ .map(action =>
762
+ m('button.inline-flex.items-center.px-2.py-1.text-sm.rounded.mr-1.transition-colors.' + getActionColorClass(action.color), {
763
+ disabled: state.actionInProgress === (action.id + '-' + record[primaryKey]),
764
+ onclick: () => executeRecordAction(action, record, modelName, modelMeta),
765
+ }, [
766
+ action.icon ? m('span.mr-1', renderActionIcon(action.icon)) : null,
767
+ state.actionInProgress === (action.id + '-' + record[primaryKey]) ? '...' : action.label,
768
+ ])
769
+ ),
553
770
  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
771
  onclick: () => {
555
772
  state.currentRecord = record;
556
773
  state.editing = true;
557
774
  m.route.set('/models/' + modelName + '/edit/' + record[primaryKey]);
558
775
  },
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', {
776
+ }, modelMeta?.readOnly || modelMeta?.permissions?.edit === false ? 'View' : 'Edit'),
777
+ !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
778
  onclick: async () => {
562
- if (confirm('Are you sure you want to delete this record?')) {
779
+ const isConfirmed = await confirmAction({
780
+ title: 'Delete Record',
781
+ message: 'Are you sure you want to delete this record?',
782
+ danger: true,
783
+ confirmText: 'Delete'
784
+ });
785
+ if (isConfirmed) {
563
786
  try {
564
787
  await api.delete('/models/' + modelName + '/records/' + record[primaryKey]);
788
+ notifySuccess('Record deleted successfully');
565
789
  loadRecords(modelName, state.pagination.page);
566
790
  } catch (err) {
567
- alert('Error: ' + err.message);
791
+ const errMsg = 'Error: ' + err.message;
792
+ notifyError(errMsg);
568
793
  }
569
794
  }
570
795
  },
571
- }, 'Delete'),
796
+ }, 'Delete') : null,
572
797
  ],
573
798
  ]),
574
799
  ])
@@ -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
  ]);
@@ -8,4 +8,27 @@ window.__ADMIN_COMPONENTS__ = {
8
8
  RecordForm,
9
9
  api,
10
10
  state,
11
+ notify: typeof window !== 'undefined' ? window.notify : undefined,
12
+ confirmModal: typeof window !== 'undefined' ? window.confirmModal : undefined,
13
+ toastState: typeof window !== 'undefined' ? window.toastState : undefined,
14
+ confirmState: typeof window !== 'undefined' ? window.confirmState : undefined,
15
+ AdminUI: typeof window !== 'undefined' ? window.AdminUI : undefined,
16
+ Admin: typeof window !== 'undefined' ? window.Admin : undefined,
17
+ ToastContainer: typeof window !== 'undefined' ? window.ToastContainer : undefined,
18
+ ConfirmModalContainer: typeof window !== 'undefined' ? window.ConfirmModalContainer : undefined,
11
19
  };
20
+
21
+ if (typeof window !== 'undefined') {
22
+ window.Admin = window.Admin || {};
23
+ window.Admin.api = api;
24
+ window.Admin.state = state;
25
+ if (typeof window.AdminUI !== 'undefined') {
26
+ window.Admin.ui = window.AdminUI;
27
+ }
28
+ if (typeof window.notify !== 'undefined') {
29
+ window.Admin.toast = window.notify;
30
+ }
31
+ if (typeof window.confirmModal !== 'undefined') {
32
+ window.Admin.confirm = window.confirmModal;
33
+ }
34
+ }
@@ -9,11 +9,13 @@
9
9
  const path = require('path');
10
10
  const { buildComponentsBody } = require(path.join(__dirname, 'load-parts.js'));
11
11
 
12
+ const vm = require('vm');
12
13
  const body = buildComponentsBody();
13
14
  const checks = [
14
15
  ['has api helper', () => body.includes('const api =')],
15
16
  ['has Mithril globals', () => body.includes('window.__ADMIN_COMPONENTS__')],
16
17
  ['has reasonable size', () => body.length > 50_000],
18
+ ['valid JS syntax', () => { vm.compileFunction(body); return true; }],
17
19
  ];
18
20
 
19
21
  let failed = false;
@@ -193,19 +193,22 @@ function registerModule(config, deps) {
193
193
  menuList = menuList.concat(dirMenuItems);
194
194
  }
195
195
 
196
+ const moduleTitle = config.title;
197
+
196
198
  for (const p of pagesList) {
197
199
  if (p.menu && !menuList.some(m => m.id === p.id)) {
200
+ const pageLabel = p.title || moduleTitle || p.id;
198
201
  if (p.menu === true) {
199
202
  menuList.push({
200
203
  id: p.id,
201
- label: p.title,
204
+ label: pageLabel,
202
205
  path: p.path,
203
206
  icon: p.icon,
204
207
  });
205
208
  } else if (typeof p.menu === 'object') {
206
209
  menuList.push({
207
210
  id: p.id,
208
- label: p.title,
211
+ label: pageLabel,
209
212
  path: p.path,
210
213
  icon: p.icon,
211
214
  ...p.menu,
@@ -215,7 +218,7 @@ function registerModule(config, deps) {
215
218
  }
216
219
 
217
220
  if (pagesList.length > 0) {
218
- registerPages(config.id, pagesList, { registry, adminPath, ctx, optionalAuth, serveAdminPanel });
221
+ registerPages(config.id, pagesList, { registry, adminPath, ctx, optionalAuth, serveAdminPanel }, moduleTitle);
219
222
  }
220
223
 
221
224
  if (menuList.length > 0) {
@@ -246,7 +249,7 @@ function registerPageDir(dirPath, deps) {
246
249
  }
247
250
  }
248
251
 
249
- function registerPages(moduleId, pages, deps) {
252
+ function registerPages(moduleId, pages, deps, moduleTitle) {
250
253
  if (!Array.isArray(pages)) {
251
254
  throw new Error(`Module "${moduleId}": pages must be an array`);
252
255
  }
@@ -257,8 +260,9 @@ function registerPages(moduleId, pages, deps) {
257
260
  const pageId = page.id || moduleId;
258
261
 
259
262
  let htmlContent = page.html || null;
263
+ let resolvedHtmlPath = null;
260
264
  if (page.htmlFile) {
261
- const resolvedHtmlPath = path.resolve(page.htmlFile);
265
+ resolvedHtmlPath = path.resolve(page.htmlFile);
262
266
  if (!fs.existsSync(resolvedHtmlPath)) {
263
267
  throw new Error(`Module "${moduleId}", page "${pageId}": htmlFile not found at ${page.htmlFile}`);
264
268
  }
@@ -266,7 +270,8 @@ function registerPages(moduleId, pages, deps) {
266
270
  }
267
271
 
268
272
  registry.registerPage(pageId, {
269
- title: page.title,
273
+ title: page.title || moduleTitle,
274
+ documentTitle: page.documentTitle,
270
275
  path: page.path,
271
276
  icon: page.icon,
272
277
  description: page.description,
@@ -275,6 +280,7 @@ function registerPages(moduleId, pages, deps) {
275
280
  iframe: page.iframe,
276
281
  url: page.url || page.iframeUrl || undefined,
277
282
  html: htmlContent || undefined,
283
+ htmlFile: resolvedHtmlPath || (page.htmlFile ? path.resolve(page.htmlFile) : undefined),
278
284
  });
279
285
 
280
286
  if (page.componentFile) {
@@ -284,7 +290,7 @@ function registerPages(moduleId, pages, deps) {
284
290
  }
285
291
  const rawCode = fs.readFileSync(resolvedPath, 'utf8');
286
292
  const formattedCode = formatClientComponent(pageId, rawCode);
287
- registry.registerClientComponent(pageId, formattedCode);
293
+ registry.registerClientComponent(pageId, formattedCode, resolvedPath);
288
294
  } else if (page.component) {
289
295
  const formattedCode = formatClientComponent(pageId, page.component);
290
296
  registry.registerClientComponent(pageId, formattedCode);