webspresso 0.0.96 → 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.
- package/.agents/ADMIN.md +153 -5
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/CLI.md +195 -1
- package/.agents/PLUGINS.md +153 -9
- package/.agents/QUEUE.md +43 -0
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
- package/.agents/skills/webspresso-framework/SKILL.md +56 -0
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
- package/.agents/skills/webspresso-services/SKILL.md +101 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/admin-password.js +2 -1
- package/bin/commands/admin-setup.js +1 -1
- package/bin/commands/agents-init.js +51 -11
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/audit-prune.js +1 -1
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/favicon-generate.js +1 -1
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/orm-map.js +5 -4
- package/bin/commands/page.js +6 -3
- package/bin/commands/polar-migrate.js +1 -1
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/seed.js +1 -1
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +31 -17
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/index.js +12 -7
- package/core/orm/model.js +49 -7
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/manager.js +123 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +660 -10
- package/index.js +44 -96
- package/package.json +32 -12
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +124 -31
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +34 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/audit-log/admin-component.js +11 -0
- package/plugins/audit-log/api-handlers.js +34 -0
- package/plugins/audit-log/index.js +129 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/basic-auth/index.js +3 -1
- package/plugins/content/client/inline-edit.js +12 -12
- package/plugins/cors.js +6 -7
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/core.js +1 -1
- package/plugins/file-manager/security.js +33 -23
- package/plugins/index.js +49 -87
- package/plugins/mcp/transports/sse.js +4 -2
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +11 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +30 -8
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +3 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/file-router.js +213 -63
- package/src/helpers.js +958 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/server.js +629 -63
- package/src/services/discovery.js +6 -1
- package/src/services/index.js +6 -13
|
@@ -1,83 +1,231 @@
|
|
|
1
1
|
// Field Renderers - render appropriate input based on column type
|
|
2
2
|
|
|
3
|
-
// BelongsTo relation dropdown
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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('
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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('
|
|
126
|
-
label,
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
19
|
-
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.
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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', {
|