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
@@ -1,83 +1,231 @@
1
1
  // Field Renderers - render appropriate input based on column type
2
2
 
3
- // BelongsTo relation dropdown select component
3
+ // BelongsTo relation searchable combobox dropdown component
4
4
  const BelongsToField = {
5
5
  oninit: (vnode) => {
6
- const { col } = vnode.attrs;
7
- const customField = col.customField || {};
8
- const relationName = customField.relation || col.name.replace(/_id$/, '');
9
- const modelName = m.route.param('model');
10
6
  vnode.state.loading = true;
7
+ vnode.state.loadingMore = false;
11
8
  vnode.state.options = [];
12
-
13
- api.get('/models/' + modelName + '/relations/' + relationName)
14
- .then(result => {
15
- vnode.state.options = result.data || [];
16
- })
17
- .catch(err => {
18
- console.error('Failed to load relation data:', err);
19
- })
20
- .finally(() => {
21
- vnode.state.loading = false;
22
- m.redraw();
23
- });
9
+ vnode.state.isOpen = false;
10
+ vnode.state.search = '';
11
+ vnode.state.page = 1;
12
+ vnode.state.hasMore = false;
13
+ vnode.state.total = 0;
14
+ vnode.state.displayField = null;
15
+ vnode.state.primaryKey = 'id';
16
+ vnode.state._searchTimer = null;
17
+
18
+ vnode.state.fetchOptions = (vn, opts = {}) => {
19
+ const page = opts.page || 1;
20
+ const append = opts.append === true;
21
+ const search = opts.search !== undefined ? opts.search : (vn.state.search || '');
22
+ const { col, value } = vn.attrs;
23
+ const customField = col.customField || {};
24
+ const relationName = customField.relation || col.name.replace(/_id$/, '');
25
+ const modelName = m.route.param('model');
26
+
27
+ let url = '/models/' + modelName + '/relations/' + relationName + '?page=' + page + '&limit=50';
28
+ if (search) url += '&search=' + encodeURIComponent(search);
29
+ if (value !== null && value !== undefined && value !== '') {
30
+ url += '&selected=' + encodeURIComponent(value);
31
+ }
32
+
33
+ if (append) {
34
+ vn.state.loadingMore = true;
35
+ } else {
36
+ vn.state.loading = true;
37
+ }
38
+ m.redraw();
39
+
40
+ return api.get(url)
41
+ .then(result => {
42
+ const incoming = result.data || [];
43
+ const pk = result.primaryKey || vn.state.primaryKey || 'id';
44
+ if (append) {
45
+ const existingIds = new Set(vn.state.options.map(o => String(o[pk])));
46
+ const newItems = incoming.filter(o => !existingIds.has(String(o[pk])));
47
+ vn.state.options = vn.state.options.concat(newItems);
48
+ } else {
49
+ vn.state.options = incoming;
50
+ }
51
+ if (result.displayField) vn.state.displayField = result.displayField;
52
+ if (result.primaryKey) vn.state.primaryKey = result.primaryKey;
53
+ if (result.pagination) {
54
+ vn.state.page = result.pagination.page;
55
+ vn.state.hasMore = result.pagination.hasMore;
56
+ vn.state.total = result.pagination.total;
57
+ } else {
58
+ vn.state.hasMore = false;
59
+ }
60
+ })
61
+ .catch(err => {
62
+ console.error('Failed to load relation data:', err);
63
+ })
64
+ .finally(() => {
65
+ vn.state.loading = false;
66
+ vn.state.loadingMore = false;
67
+ m.redraw();
68
+ });
69
+ };
70
+
71
+ vnode.state.fetchOptions(vnode);
72
+ },
73
+ onremove: (vnode) => {
74
+ if (vnode.state._searchTimer) {
75
+ clearTimeout(vnode.state._searchTimer);
76
+ vnode.state._searchTimer = null;
77
+ }
24
78
  },
25
79
  view: (vnode) => {
26
80
  const { col, value, onChange, readonly, error } = vnode.attrs;
27
81
  const customField = col.customField || {};
28
82
  const label = col.ui?.label || formatColumnLabel(col.name);
29
83
  const hint = col.ui?.hint || '';
30
- const valueKey = customField.valueKey || 'id';
31
- const displayKey = customField.displayKey ||
84
+ const valueKey = customField.valueKey || vnode.state.primaryKey || 'id';
85
+ const displayKey = customField.displayKey || vnode.state.displayField ||
32
86
  (vnode.state.options.length > 0 ? ['name', 'title', 'label', 'username', 'email'].find(k => k in vnode.state.options[0]) : null) ||
33
87
  'id';
34
-
35
- if (vnode.state.loading) {
36
- return m('.mb-4', [
37
- m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label),
38
- m('.text-xs.text-gray-500.dark:text-slate-400', 'Loading options...')
39
- ]);
40
- }
88
+
89
+ const selectedItem = (value !== null && value !== undefined && value !== '')
90
+ ? vnode.state.options.find(item => String(item[valueKey]) === String(value))
91
+ : null;
92
+ const selectedDisplay = selectedItem
93
+ ? (selectedItem[displayKey] || String(selectedItem[valueKey]))
94
+ : (value !== null && value !== undefined && value !== '' ? String(value) : '-- Select --');
95
+ const hasSelection = value !== null && value !== undefined && value !== '';
96
+
97
+ const selectItem = (item) => {
98
+ const selectedValue = item ? item[valueKey] : null;
99
+ const typedValue = (col.type === 'integer' || col.type === 'bigint' || col.type === 'float' || col.type === 'decimal') && selectedValue !== null
100
+ ? Number(selectedValue)
101
+ : selectedValue;
102
+ onChange(typedValue);
103
+ vnode.state.isOpen = false;
104
+ vnode.state.search = '';
105
+ };
106
+
107
+ const clearSelection = (e) => {
108
+ e.stopPropagation();
109
+ onChange(null);
110
+ vnode.state.search = '';
111
+ };
41
112
 
42
113
  const borderClass = error
43
114
  ? 'border-red-500 dark:border-red-500 focus:ring-red-500'
44
115
  : 'border-gray-300 dark:border-slate-600 focus:ring-blue-500 dark:focus:ring-blue-400';
45
-
116
+
46
117
  return m('.mb-4', [
47
118
  m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', { for: col.name }, [
48
119
  label,
49
- !col.nullable && !readonly ? m('span.text-red-500', ' *') : null
120
+ !col.nullable && !readonly ? m('span.text-red-500', ' *') : null,
50
121
  ]),
51
- m('select.w-full.px-3.py-2.border.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.focus:outline-none.focus:ring-2', {
52
- id: col.name,
53
- name: col.name,
54
- value: value !== null && value !== undefined ? String(value) : '',
55
- disabled: readonly,
56
- class: (readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed ' : '') + borderClass,
57
- onchange: (e) => {
58
- const selectedValue = e.target.value === '' ? null : e.target.value;
59
- const typedValue = (col.type === 'integer' || col.type === 'bigint' || col.type === 'float' || col.type === 'decimal') && selectedValue !== null
60
- ? Number(selectedValue)
61
- : selectedValue;
62
- onChange(typedValue);
63
- },
64
- }, [
65
- col.nullable ? m('option', { value: '' }, '-- Select --') : m('option', { value: '' }, '-- Select --'),
66
- vnode.state.options.map(item => {
67
- const itemValue = item[valueKey];
68
- const itemDisplay = item[displayKey] || String(itemValue);
69
- return m('option', {
70
- value: String(itemValue),
71
- selected: value !== null && value !== undefined && String(value) === String(itemValue)
72
- }, itemDisplay);
73
- }),
122
+ m('.relative', [
123
+ // Backdrop to close popover when clicked outside
124
+ vnode.state.isOpen ? m('.fixed.inset-0.z-30', {
125
+ onclick: () => { vnode.state.isOpen = false; },
126
+ }) : null,
127
+
128
+ // Trigger Button / Combobox Display
129
+ m('button.w-full.flex.items-center.justify-between.px-3.py-2.border.rounded-md.bg-white.dark:bg-slate-900/70.text-left.text-sm.transition-colors.relative.z-30', {
130
+ id: col.name,
131
+ type: 'button',
132
+ disabled: readonly,
133
+ class: (readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed text-gray-500 ' : 'hover:border-gray-400 dark:hover:border-slate-500 ') + borderClass,
134
+ onclick: () => {
135
+ if (!readonly) {
136
+ vnode.state.isOpen = !vnode.state.isOpen;
137
+ }
138
+ },
139
+ }, [
140
+ m('span.truncate', {
141
+ class: hasSelection ? 'text-gray-900 dark:text-slate-100 font-medium' : 'text-gray-400 dark:text-slate-500',
142
+ }, selectedDisplay),
143
+ m('.flex.items-center.gap-1.ml-2', [
144
+ hasSelection && !readonly ? m('span.text-gray-400.hover:text-red-500.cursor-pointer.px-1', {
145
+ title: 'Clear',
146
+ onclick: clearSelection,
147
+ }, '✕') : null,
148
+ m('svg.w-4.h-4.text-gray-400.transition-transform', {
149
+ class: vnode.state.isOpen ? 'rotate-180' : '',
150
+ fill: 'none',
151
+ stroke: 'currentColor',
152
+ viewBox: '0 0 24 24',
153
+ }, m('path', { strokeLinecap: 'round', strokeLinejoin: 'round', strokeWidth: 2, d: 'M19 9l-7 7-7-7' })),
154
+ ]),
155
+ ]),
156
+
157
+ // Dropdown Popover
158
+ vnode.state.isOpen ? m('.absolute.left-0.right-0.top-full.mt-1.bg-white.dark:bg-slate-900.border.border-gray-200.dark:border-slate-700.rounded-md.shadow-lg.z-40.overflow-hidden', [
159
+ // Search input bar
160
+ m('.p-2.border-b.border-gray-100.dark:border-slate-800.bg-gray-50/50.dark:bg-slate-900/50', [
161
+ m('input.w-full.px-2.5.py-1.5.text-xs.border.border-gray-300.dark:border-slate-700.rounded.bg-white.dark:bg-slate-800.text-gray-900.dark:text-slate-100.focus:outline-none.focus:ring-1.focus:ring-blue-500', {
162
+ type: 'text',
163
+ placeholder: 'Search ' + label.toLowerCase() + '...',
164
+ value: vnode.state.search,
165
+ oncreate: (v) => {
166
+ if (v.dom && typeof v.dom.focus === 'function') v.dom.focus();
167
+ },
168
+ oninput: (e) => {
169
+ const term = e.target.value;
170
+ vnode.state.search = term;
171
+ clearTimeout(vnode.state._searchTimer);
172
+ vnode.state._searchTimer = setTimeout(() => {
173
+ vnode.state.fetchOptions(vnode, { page: 1, append: false, search: term });
174
+ }, 250);
175
+ },
176
+ }),
177
+ ]),
178
+
179
+ // Options list
180
+ m('.max-h-60.overflow-y-auto.py-1', [
181
+ vnode.state.loading ? m('.py-4.text-center.text-xs.text-gray-400', 'Loading...') : null,
182
+
183
+ // Clear / None option
184
+ col.nullable ? m('.px-3.py-2.text-xs.text-gray-500.hover:bg-gray-100.dark:hover:bg-slate-800.cursor-pointer', {
185
+ onclick: () => selectItem(null),
186
+ }, '-- None --') : null,
187
+
188
+ // Item options
189
+ !vnode.state.loading && vnode.state.options.length === 0 ? m('.py-4.text-center.text-xs.text-gray-400', 'No results found') : null,
190
+
191
+ vnode.state.options.map(item => {
192
+ const itemValue = item[valueKey];
193
+ const itemDisplay = item[displayKey] || String(itemValue);
194
+ const isSelected = String(value) === String(itemValue);
195
+ return m('.px-3.py-2.text-xs.flex.items-center.justify-between.cursor-pointer.transition-colors', {
196
+ class: isSelected
197
+ ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-300 font-medium'
198
+ : 'text-gray-800 dark:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-800',
199
+ onclick: () => selectItem(item),
200
+ }, [
201
+ m('span.truncate', itemDisplay),
202
+ isSelected ? m('span.text-blue-600.dark:text-blue-400.font-bold.ml-2', '✓') : null,
203
+ ]);
204
+ }),
205
+ ]),
206
+
207
+ // Pagination Load More
208
+ vnode.state.hasMore ? m('.p-2.border-t.border-gray-100.dark:border-slate-800.text-center.bg-gray-50/50.dark:bg-slate-900/50', [
209
+ m('button.text-xs.text-blue-600.dark:text-blue-400.hover:underline.font-medium.py-1.px-2', {
210
+ type: 'button',
211
+ disabled: vnode.state.loadingMore,
212
+ onclick: () => {
213
+ vnode.state.fetchOptions(vnode, {
214
+ page: vnode.state.page + 1,
215
+ append: true,
216
+ search: vnode.state.search,
217
+ });
218
+ },
219
+ }, vnode.state.loadingMore ? 'Loading more...' : 'Load more (' + (vnode.state.total - vnode.state.options.length) + ' remaining)'),
220
+ ]) : null,
221
+ ]) : null,
74
222
  ]),
75
223
  error ? m('p.text-xs.text-red-600.dark:text-red-400.mt-1.font-medium', error) : (hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null),
76
224
  ]);
77
225
  }
78
226
  };
79
227
 
80
- // HasMany relation multi-select checkbox component
228
+ // HasMany relation multi-select checkbox component with filter & select-all
81
229
  const HasManyField = {
82
230
  oninit: (vnode) => {
83
231
  const { col } = vnode.attrs;
@@ -86,10 +234,13 @@ const HasManyField = {
86
234
  const modelName = m.route.param('model');
87
235
  vnode.state.loading = true;
88
236
  vnode.state.options = [];
237
+ vnode.state.filter = '';
89
238
 
90
- api.get('/models/' + modelName + '/relations/' + relationName)
239
+ api.get('/models/' + modelName + '/relations/' + relationName + '?all=true')
91
240
  .then(result => {
92
241
  vnode.state.options = result.data || [];
242
+ if (result.displayField) vnode.state.displayField = result.displayField;
243
+ if (result.primaryKey) vnode.state.primaryKey = result.primaryKey;
93
244
  })
94
245
  .catch(err => {
95
246
  console.error('Failed to load relation data:', err);
@@ -104,12 +255,38 @@ const HasManyField = {
104
255
  const customField = col.customField || {};
105
256
  const label = col.ui?.label || formatColumnLabel(col.name);
106
257
  const hint = col.ui?.hint || '';
107
- const valueKey = customField.valueKey || 'id';
108
- const displayKey = customField.displayKey ||
258
+ const valueKey = customField.valueKey || vnode.state.primaryKey || 'id';
259
+ const displayKey = customField.displayKey || vnode.state.displayField ||
109
260
  (vnode.state.options.length > 0 ? ['name', 'title', 'label', 'username', 'email'].find(k => k in vnode.state.options[0]) : null) ||
110
261
  'id';
111
262
  const selectedIds = Array.isArray(value) ? value.map(v => String(v)) : [];
112
-
263
+
264
+ const filterTerm = (vnode.state.filter || '').trim().toLowerCase();
265
+ const visibleOptions = vnode.state.options.filter(item => {
266
+ if (!filterTerm) return true;
267
+ const text = String(item[displayKey] || item[valueKey] || '').toLowerCase();
268
+ return text.includes(filterTerm);
269
+ });
270
+
271
+ const updateSelection = (newSelected) => {
272
+ const typedSelected = col.type === 'array' || (Array.isArray(value) && typeof value[0] === 'string')
273
+ ? newSelected
274
+ : newSelected.map(Number);
275
+ onChange(typedSelected);
276
+ };
277
+
278
+ const selectAll = () => {
279
+ const visibleIds = visibleOptions.map(item => String(item[valueKey]));
280
+ const merged = Array.from(new Set([...selectedIds, ...visibleIds]));
281
+ updateSelection(merged);
282
+ };
283
+
284
+ const deselectAll = () => {
285
+ const visibleIds = new Set(visibleOptions.map(item => String(item[valueKey])));
286
+ const filtered = selectedIds.filter(id => !visibleIds.has(id));
287
+ updateSelection(filtered);
288
+ };
289
+
113
290
  if (vnode.state.loading) {
114
291
  return m('.mb-4', [
115
292
  m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label),
@@ -120,14 +297,39 @@ const HasManyField = {
120
297
  const borderClass = error
121
298
  ? 'border-red-500 dark:border-red-500'
122
299
  : 'border-gray-300 dark:border-slate-600';
123
-
300
+
124
301
  return m('.mb-4', [
125
- m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', [
126
- label,
127
- !col.nullable && !readonly ? m('span.text-red-500', ' *') : null
302
+ m('.flex.items-center.justify-between.mb-1', [
303
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300', [
304
+ label,
305
+ !col.nullable && !readonly ? m('span.text-red-500', ' *') : null,
306
+ ]),
307
+ m('.text-xs.text-gray-500.dark:text-slate-400', selectedIds.length + ' of ' + vnode.state.options.length + ' selected'),
128
308
  ]),
309
+
310
+ // Search & select all controls
311
+ vnode.state.options.length > 5 ? m('.flex.items-center.gap-2.mb-2', [
312
+ m('input.flex-1.px-2.5.py-1.text-xs.border.border-gray-300.dark:border-slate-700.rounded.bg-white.dark:bg-slate-800.text-gray-900.dark:text-slate-100.focus:outline-none.focus:ring-1.focus:ring-blue-500', {
313
+ type: 'text',
314
+ placeholder: 'Filter ' + label.toLowerCase() + '...',
315
+ value: vnode.state.filter,
316
+ oninput: (e) => { vnode.state.filter = e.target.value; },
317
+ }),
318
+ !readonly ? m('.flex.items-center.gap-1', [
319
+ m('button.px-2.py-1.text-xs.border.border-gray-300.dark:border-slate-700.rounded.hover:bg-gray-100.dark:hover:bg-slate-800.text-gray-700.dark:text-slate-300', {
320
+ type: 'button',
321
+ onclick: selectAll,
322
+ }, 'All'),
323
+ m('button.px-2.py-1.text-xs.border.border-gray-300.dark:border-slate-700.rounded.hover:bg-gray-100.dark:hover:bg-slate-800.text-gray-700.dark:text-slate-300', {
324
+ type: 'button',
325
+ onclick: deselectAll,
326
+ }, 'None'),
327
+ ]) : null,
328
+ ]) : null,
329
+
129
330
  m('.border.rounded-md.p-3.max-h-48.overflow-y-auto.bg-white.dark:bg-slate-900/70', { class: borderClass }, [
130
- vnode.state.options.map(item => {
331
+ visibleOptions.length === 0 ? m('.py-2.text-center.text-xs.text-gray-400', 'No matching items') : null,
332
+ visibleOptions.map(item => {
131
333
  const itemValue = String(item[valueKey]);
132
334
  const itemDisplay = item[displayKey] || itemValue;
133
335
  const isSelected = selectedIds.includes(itemValue);
@@ -146,12 +348,7 @@ const HasManyField = {
146
348
  } else {
147
349
  newSelected = newSelected.filter(id => id !== itemValue);
148
350
  }
149
-
150
- // Convert type if needed
151
- const typedSelected = col.type === 'array' || typeof value[0] === 'string'
152
- ? newSelected
153
- : newSelected.map(Number);
154
- onChange(typedSelected);
351
+ updateSelection(newSelected);
155
352
  }
156
353
  }),
157
354
  m('span', itemDisplay),
@@ -343,12 +343,12 @@ async function handleAdminFileUpload(file, onChange, meta, state) {
343
343
  uploadUrl = (cfg && cfg.settings && cfg.settings.uploadUrl) ? String(cfg.settings.uploadUrl) : '';
344
344
  } catch (e) {}
345
345
  if (!uploadUrl) {
346
- alert('Upload URL is not configured.');
346
+ notifyError('Upload URL is not configured.');
347
347
  return;
348
348
  }
349
349
  var maxSize = meta.maxSize || meta.maxBytes || (10 * 1024 * 1024);
350
350
  if (file.size > maxSize) {
351
- alert('File too large (max ' + Math.round(maxSize / 1024 / 1024) + ' MB).');
351
+ notifyWarning('File too large (max ' + Math.round(maxSize / 1024 / 1024) + ' MB).');
352
352
  return;
353
353
  }
354
354
  if (state && file.type && file.type.indexOf('image/') === 0) {
@@ -365,7 +365,7 @@ async function handleAdminFileUpload(file, onChange, meta, state) {
365
365
  if (!res.ok) {
366
366
  revokeLocalPreview(state);
367
367
  m.redraw();
368
- alert(data.message || data.error || ('Upload failed (' + res.status + ')'));
368
+ notifyError(data.message || data.error || ('Upload failed (' + res.status + ')'));
369
369
  return;
370
370
  }
371
371
  revokeLocalPreview(state);
@@ -375,7 +375,7 @@ async function handleAdminFileUpload(file, onChange, meta, state) {
375
375
  } catch (err) {
376
376
  revokeLocalPreview(state);
377
377
  m.redraw();
378
- alert(err.message || 'Upload failed');
378
+ notifyError(err.message || 'Upload failed');
379
379
  }
380
380
  }
381
381
 
@@ -459,7 +459,12 @@ function getFieldRenderer(col, modelMeta) {
459
459
  });
460
460
  };
461
461
  }
462
-
462
+
463
+ // Auto-detect belongsTo relation for foreign key columns (ends with _id or has references)
464
+ if (!col.primary && !col.autoIncrement && ((col.name && col.name.endsWith('_id')) || col.references)) {
465
+ return FieldRenderers.belongsTo;
466
+ }
467
+
463
468
  // Fallback to standard type renderers
464
469
  const typeMap = {
465
470
  string: 'string',
@@ -1,8 +1,28 @@
1
1
  // Login Form Component
2
2
  const LoginForm = {
3
+ oninit: () => {
4
+ state.loading = false;
5
+ state.error = null;
6
+ state.notice = null;
7
+ var reason = null;
8
+ try {
9
+ if (typeof m !== 'undefined' && m.route && typeof m.route.param === 'function') {
10
+ reason = m.route.param('reason');
11
+ }
12
+ if (!reason && typeof window !== 'undefined' && window.location && window.location.search) {
13
+ var match = window.location.search.match(/[?&]reason=([^&]+)/);
14
+ if (match && match[1]) reason = decodeURIComponent(match[1]);
15
+ }
16
+ } catch (e) {}
17
+ if (reason === 'expired') {
18
+ state.notice = 'Your session has expired. Please sign in again.';
19
+ }
20
+ },
3
21
  view: () => m('.min-h-screen.flex.items-center.justify-center.p-4.sm:p-6.bg-gradient-to-br.from-blue-600.via-indigo-600.to-purple-700', [
22
+ typeof ToastContainer !== 'undefined' ? m(ToastContainer) : null,
23
+ typeof ConfirmModalContainer !== 'undefined' ? m(ConfirmModalContainer) : null,
4
24
  m('.w-full.max-w-md', [
5
- m('.bg-white dark:bg-slate-800.rounded-2xl.shadow-2xl.p-6.sm:p-8', [
25
+ m('.bg-white.dark:bg-slate-800.rounded-2xl.shadow-2xl.p-6.sm:p-8', [
6
26
  m('div.text-center.mb-6', [
7
27
  m('h1.text-2xl.sm:text-3xl.font-bold.text-gray-900.dark:text-slate-50', 'Admin Login'),
8
28
  m('p.text-gray-500.dark:text-slate-400.text-sm.mt-1', 'Sign in to your account'),
@@ -12,22 +32,41 @@ const LoginForm = {
12
32
  e.preventDefault();
13
33
  state.loading = true;
14
34
  state.error = null;
35
+ m.redraw();
15
36
  try {
16
37
  const data = new FormData(e.target);
38
+ const email = (data.get('email') || '').trim();
39
+ const password = data.get('password') || '';
40
+ if (!email || !password) {
41
+ state.error = 'Please enter both email and password';
42
+ state.loading = false;
43
+ m.redraw();
44
+ return;
45
+ }
17
46
  const result = await api.post('/auth/login', {
18
- email: data.get('email'),
19
- password: data.get('password'),
47
+ email,
48
+ password,
20
49
  });
21
50
  state.user = result.user;
51
+ state.error = null;
52
+ state.notice = null;
22
53
  m.route.set(consumeIntendedRoute('/'));
23
54
  } catch (err) {
24
- state.error = err.message;
55
+ state.error = err.message || 'Invalid email or password';
25
56
  } finally {
26
57
  state.loading = false;
58
+ m.redraw();
27
59
  }
28
60
  }
29
61
  }, [
30
- state.error ? m('.bg-red-50.border.border-red-200.text-red-700.dark:bg-red-950/50.dark:border-red-800/80.dark:text-red-200.px-4.py-3.rounded-lg.mb-4.text-sm', state.error) : null,
62
+ state.notice ? m('.bg-amber-50.border.border-amber-200.text-amber-800.dark:bg-amber-950/50.dark:border-amber-800/80.dark:text-amber-200.px-4.py-3.rounded-lg.mb-4.text-sm.flex.items-center.gap-2', [
63
+ m('span.text-base.select-none', 'ℹ️'),
64
+ m('span.flex-1', state.notice),
65
+ ]) : null,
66
+ state.error ? m('.bg-red-50.border.border-red-200.text-red-700.dark:bg-red-950/50.dark:border-red-800/80.dark:text-red-200.px-4.py-3.rounded-lg.mb-4.text-sm.flex.items-center.gap-2', [
67
+ m('span.text-base.select-none', '⚠️'),
68
+ m('span.flex-1', state.error),
69
+ ]) : null,
31
70
  m('.mb-4', [
32
71
  m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-2', { for: 'email' }, 'Email'),
33
72
  m('input#email.w-full.px-3.py-2.5.bg-white.text-gray-900.border.border-gray-300.rounded-lg.placeholder-gray-400.focus:ring-2.focus:ring-blue-500.focus:border-blue-500.transition-colors.dark:bg-slate-900/80.dark:border-slate-500.dark:text-slate-100.dark:placeholder-slate-500', {
@@ -35,6 +74,12 @@ const LoginForm = {
35
74
  name: 'email',
36
75
  required: true,
37
76
  placeholder: 'admin@example.com',
77
+ oninput: () => {
78
+ if (state.error) {
79
+ state.error = null;
80
+ m.redraw();
81
+ }
82
+ },
38
83
  }),
39
84
  ]),
40
85
  m('.mb-6', [
@@ -43,6 +88,12 @@ const LoginForm = {
43
88
  type: 'password',
44
89
  name: 'password',
45
90
  required: true,
91
+ oninput: () => {
92
+ if (state.error) {
93
+ state.error = null;
94
+ m.redraw();
95
+ }
96
+ },
46
97
  }),
47
98
  ]),
48
99
  m('button.w-full.bg-blue-600.text-white.py-2.5.px-4.rounded-lg.font-medium.hover:bg-blue-700.focus:ring-2.focus:ring-blue-500.focus:ring-offset-2.dark:focus:ring-offset-slate-800.disabled:opacity-50.transition-colors', {
@@ -57,42 +108,70 @@ const LoginForm = {
57
108
 
58
109
  // Setup Form Component
59
110
  const SetupForm = {
111
+ oninit: () => {
112
+ state.loading = false;
113
+ state.error = null;
114
+ state.notice = null;
115
+ },
60
116
  view: () => m('.min-h-screen.flex.items-center.justify-center.p-4.sm:p-6.bg-gradient-to-br.from-blue-600.via-indigo-600.to-purple-700', [
117
+ typeof ToastContainer !== 'undefined' ? m(ToastContainer) : null,
118
+ typeof ConfirmModalContainer !== 'undefined' ? m(ConfirmModalContainer) : null,
61
119
  m('.w-full.max-w-md', [
62
- m('.bg-white dark:bg-slate-800.rounded-2xl.shadow-2xl.p-6.sm:p-8', [
120
+ m('.bg-white.dark:bg-slate-800.rounded-2xl.shadow-2xl.p-6.sm:p-8', [
63
121
  m('div.text-center.mb-6', [
64
122
  m('h1.text-2xl.sm:text-3xl.font-bold.text-gray-900.dark:text-slate-50', 'Setup Admin Account'),
65
123
  m('p.text-gray-500.dark:text-slate-400.text-sm.mt-1', 'Create the first admin user account.'),
66
124
  ]),
67
125
  m('form', {
68
- onsubmit: async (e) => {
69
- e.preventDefault();
70
- state.loading = true;
71
- state.error = null;
72
- try {
73
- const data = new FormData(e.target);
74
- const result = await api.post('/auth/setup', {
75
- email: data.get('email'),
76
- password: data.get('password'),
77
- name: data.get('name'),
78
- });
79
- state.user = result.user;
80
- state.needsSetup = false;
81
- m.route.set(consumeIntendedRoute('/'));
82
- } catch (err) {
83
- state.error = err.message;
84
- } finally {
85
- state.loading = false;
86
- }
87
- }
88
- }, [
89
- state.error ? m('.bg-red-50.border.border-red-200.text-red-700.dark:bg-red-950/50.dark:border-red-800/80.dark:text-red-200.px-4.py-3.rounded-lg.mb-4.text-sm', state.error) : null,
126
+ onsubmit: async (e) => {
127
+ e.preventDefault();
128
+ state.loading = true;
129
+ state.error = null;
130
+ m.redraw();
131
+ try {
132
+ const data = new FormData(e.target);
133
+ const name = (data.get('name') || '').trim();
134
+ const email = (data.get('email') || '').trim();
135
+ const password = data.get('password') || '';
136
+ if (!name || !email || !password) {
137
+ state.error = 'Please fill in all fields';
138
+ state.loading = false;
139
+ m.redraw();
140
+ return;
141
+ }
142
+ const result = await api.post('/auth/setup', {
143
+ email,
144
+ password,
145
+ name,
146
+ });
147
+ state.user = result.user;
148
+ state.needsSetup = false;
149
+ state.error = null;
150
+ m.route.set(consumeIntendedRoute('/'));
151
+ } catch (err) {
152
+ state.error = err.message || 'Failed to setup admin account';
153
+ } finally {
154
+ state.loading = false;
155
+ m.redraw();
156
+ }
157
+ }
158
+ }, [
159
+ state.error ? m('.bg-red-50.border.border-red-200.text-red-700.dark:bg-red-950/50.dark:border-red-800/80.dark:text-red-200.px-4.py-3.rounded-lg.mb-4.text-sm.flex.items-center.gap-2', [
160
+ m('span.text-base.select-none', '⚠️'),
161
+ m('span.flex-1', state.error),
162
+ ]) : null,
90
163
  m('.mb-4', [
91
164
  m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-2', { for: 'name' }, 'Name'),
92
165
  m('input#name.w-full.px-3.py-2.5.bg-white.text-gray-900.border.border-gray-300.rounded-lg.focus:ring-2.focus:ring-blue-500.focus:border-blue-500.transition-colors.dark:bg-slate-900/80.dark:border-slate-500.dark:text-slate-100', {
93
166
  type: 'text',
94
167
  name: 'name',
95
168
  required: true,
169
+ oninput: () => {
170
+ if (state.error) {
171
+ state.error = null;
172
+ m.redraw();
173
+ }
174
+ },
96
175
  }),
97
176
  ]),
98
177
  m('.mb-4', [
@@ -102,6 +181,12 @@ const SetupForm = {
102
181
  name: 'email',
103
182
  required: true,
104
183
  placeholder: 'admin@example.com',
184
+ oninput: () => {
185
+ if (state.error) {
186
+ state.error = null;
187
+ m.redraw();
188
+ }
189
+ },
105
190
  }),
106
191
  ]),
107
192
  m('.mb-6', [
@@ -110,6 +195,12 @@ const SetupForm = {
110
195
  type: 'password',
111
196
  name: 'password',
112
197
  required: true,
198
+ oninput: () => {
199
+ if (state.error) {
200
+ state.error = null;
201
+ m.redraw();
202
+ }
203
+ },
113
204
  }),
114
205
  ]),
115
206
  m('button.w-full.bg-blue-600.text-white.py-2.5.px-4.rounded-lg.font-medium.hover:bg-blue-700.focus:ring-2.focus:ring-blue-500.focus:ring-offset-2.dark:focus:ring-offset-slate-800.disabled:opacity-50.transition-colors', {