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