webspresso 0.0.77 → 0.0.79
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 +5 -0
- package/bin/commands/db-scaffold.js +81 -0
- package/bin/utils/model-migrations.js +211 -0
- package/bin/webspresso.js +2 -0
- package/core/content/cache.js +64 -0
- package/core/content/field-types.js +180 -0
- package/core/content/index.js +30 -0
- package/core/content/renderer.js +84 -0
- package/core/content/schema.js +75 -0
- package/core/content/service.js +400 -0
- package/core/content/types.js +59 -0
- package/index.d.ts +17 -0
- package/index.js +7 -0
- package/package.json +1 -1
- package/plugins/admin-panel/app.js +7 -7
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +41 -0
- package/plugins/admin-panel/client/parts/04-field-renderers.js +166 -0
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +32 -1
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +2 -2
- package/plugins/admin-panel/client/parts/07-model-list.js +11 -1
- package/plugins/admin-panel/client/parts/08-record-list.js +1 -1
- package/plugins/admin-panel/index.js +46 -18
- package/plugins/admin-panel/modules/menu.js +1 -0
- package/plugins/content/admin/content-entries-component.js +291 -0
- package/plugins/content/admin/content-types-component.js +250 -0
- package/plugins/content/api-handlers.js +157 -0
- package/plugins/content/client/inline-edit.css +296 -0
- package/plugins/content/client/inline-edit.js +366 -0
- package/plugins/content/helpers.js +77 -0
- package/plugins/content/index.js +231 -0
- package/plugins/content/migration-template.js +54 -0
- package/plugins/content/models/content-entry.js +45 -0
- package/plugins/content/models/content-type.js +36 -0
- package/plugins/index.js +2 -0
- package/src/file-router.js +21 -1
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -1
- package/templates/skills/webspresso-usage/SKILL.md +5 -0
|
@@ -1,4 +1,160 @@
|
|
|
1
1
|
// Field Renderers - render appropriate input based on column type
|
|
2
|
+
|
|
3
|
+
// BelongsTo relation dropdown select component
|
|
4
|
+
const BelongsToField = {
|
|
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
|
+
vnode.state.loading = true;
|
|
11
|
+
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
|
+
});
|
|
24
|
+
},
|
|
25
|
+
view: (vnode) => {
|
|
26
|
+
const { col, value, onChange, readonly } = vnode.attrs;
|
|
27
|
+
const customField = col.customField || {};
|
|
28
|
+
const label = col.ui?.label || formatColumnLabel(col.name);
|
|
29
|
+
const hint = col.ui?.hint || '';
|
|
30
|
+
const valueKey = customField.valueKey || 'id';
|
|
31
|
+
const displayKey = customField.displayKey ||
|
|
32
|
+
(vnode.state.options.length > 0 ? ['name', 'title', 'label', 'username', 'email'].find(k => k in vnode.state.options[0]) : null) ||
|
|
33
|
+
'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
|
+
}
|
|
41
|
+
|
|
42
|
+
return m('.mb-4', [
|
|
43
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', { for: col.name }, [
|
|
44
|
+
label,
|
|
45
|
+
!col.nullable && !readonly ? m('span.text-red-500', ' *') : null
|
|
46
|
+
]),
|
|
47
|
+
m('select.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-md.bg-white.dark:bg-slate-900/70.text-gray-900.dark:text-slate-100.focus:outline-none.focus:ring-2.focus:ring-blue-500.dark:focus:ring-blue-400', {
|
|
48
|
+
id: col.name,
|
|
49
|
+
name: col.name,
|
|
50
|
+
value: value !== null && value !== undefined ? String(value) : '',
|
|
51
|
+
disabled: readonly,
|
|
52
|
+
class: readonly ? 'bg-gray-100 dark:bg-slate-800 cursor-not-allowed' : '',
|
|
53
|
+
onchange: (e) => {
|
|
54
|
+
const selectedValue = e.target.value === '' ? null : e.target.value;
|
|
55
|
+
const typedValue = (col.type === 'integer' || col.type === 'bigint' || col.type === 'float' || col.type === 'decimal') && selectedValue !== null
|
|
56
|
+
? Number(selectedValue)
|
|
57
|
+
: selectedValue;
|
|
58
|
+
onChange(typedValue);
|
|
59
|
+
},
|
|
60
|
+
}, [
|
|
61
|
+
col.nullable ? m('option', { value: '' }, '-- Select --') : m('option', { value: '' }, '-- Select --'),
|
|
62
|
+
vnode.state.options.map(item => {
|
|
63
|
+
const itemValue = item[valueKey];
|
|
64
|
+
const itemDisplay = item[displayKey] || String(itemValue);
|
|
65
|
+
return m('option', {
|
|
66
|
+
value: String(itemValue),
|
|
67
|
+
selected: value !== null && value !== undefined && String(value) === String(itemValue)
|
|
68
|
+
}, itemDisplay);
|
|
69
|
+
}),
|
|
70
|
+
]),
|
|
71
|
+
hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// HasMany relation multi-select checkbox component
|
|
77
|
+
const HasManyField = {
|
|
78
|
+
oninit: (vnode) => {
|
|
79
|
+
const { col } = vnode.attrs;
|
|
80
|
+
const customField = col.customField || {};
|
|
81
|
+
const relationName = customField.relation || col.name.replace(/_ids$/, '').replace(/_id$/, '');
|
|
82
|
+
const modelName = m.route.param('model');
|
|
83
|
+
vnode.state.loading = true;
|
|
84
|
+
vnode.state.options = [];
|
|
85
|
+
|
|
86
|
+
api.get('/models/' + modelName + '/relations/' + relationName)
|
|
87
|
+
.then(result => {
|
|
88
|
+
vnode.state.options = result.data || [];
|
|
89
|
+
})
|
|
90
|
+
.catch(err => {
|
|
91
|
+
console.error('Failed to load relation data:', err);
|
|
92
|
+
})
|
|
93
|
+
.finally(() => {
|
|
94
|
+
vnode.state.loading = false;
|
|
95
|
+
m.redraw();
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
view: (vnode) => {
|
|
99
|
+
const { col, value = [], onChange, readonly } = vnode.attrs;
|
|
100
|
+
const customField = col.customField || {};
|
|
101
|
+
const label = col.ui?.label || formatColumnLabel(col.name);
|
|
102
|
+
const hint = col.ui?.hint || '';
|
|
103
|
+
const valueKey = customField.valueKey || 'id';
|
|
104
|
+
const displayKey = customField.displayKey ||
|
|
105
|
+
(vnode.state.options.length > 0 ? ['name', 'title', 'label', 'username', 'email'].find(k => k in vnode.state.options[0]) : null) ||
|
|
106
|
+
'id';
|
|
107
|
+
const selectedIds = Array.isArray(value) ? value.map(v => String(v)) : [];
|
|
108
|
+
|
|
109
|
+
if (vnode.state.loading) {
|
|
110
|
+
return m('.mb-4', [
|
|
111
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label),
|
|
112
|
+
m('.text-xs.text-gray-500.dark:text-slate-400', 'Loading options...')
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return m('.mb-4', [
|
|
117
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', [
|
|
118
|
+
label,
|
|
119
|
+
!col.nullable && !readonly ? m('span.text-red-500', ' *') : null
|
|
120
|
+
]),
|
|
121
|
+
m('.border.border-gray-300.dark:border-slate-600.rounded-md.p-3.max-h-48.overflow-y-auto.bg-white.dark:bg-slate-900/70', [
|
|
122
|
+
vnode.state.options.map(item => {
|
|
123
|
+
const itemValue = String(item[valueKey]);
|
|
124
|
+
const itemDisplay = item[displayKey] || itemValue;
|
|
125
|
+
const isSelected = selectedIds.includes(itemValue);
|
|
126
|
+
|
|
127
|
+
return m('label.flex.items-center.mb-2.cursor-pointer.text-sm.text-gray-700.dark:text-slate-300', [
|
|
128
|
+
m('input.mr-2.rounded.border-gray-300.dark:border-slate-600.text-indigo-600.focus:ring-indigo-500', {
|
|
129
|
+
type: 'checkbox',
|
|
130
|
+
checked: isSelected,
|
|
131
|
+
disabled: readonly,
|
|
132
|
+
onchange: (e) => {
|
|
133
|
+
let newSelected = [...selectedIds];
|
|
134
|
+
if (e.target.checked) {
|
|
135
|
+
if (!newSelected.includes(itemValue)) {
|
|
136
|
+
newSelected.push(itemValue);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
newSelected = newSelected.filter(id => id !== itemValue);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Convert type if needed
|
|
143
|
+
const typedSelected = col.type === 'array' || typeof value[0] === 'string'
|
|
144
|
+
? newSelected
|
|
145
|
+
: newSelected.map(Number);
|
|
146
|
+
onChange(typedSelected);
|
|
147
|
+
}
|
|
148
|
+
}),
|
|
149
|
+
m('span', itemDisplay),
|
|
150
|
+
]);
|
|
151
|
+
}),
|
|
152
|
+
]),
|
|
153
|
+
hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
|
|
154
|
+
]);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
2
158
|
const FieldRenderers = {
|
|
3
159
|
// Text input (string)
|
|
4
160
|
string: (col, value, onChange, readonly) => {
|
|
@@ -284,4 +440,14 @@ const FieldRenderers = {
|
|
|
284
440
|
hint ? m('p.text-xs.text-gray-500 dark:text-slate-400.mt-1', hint) : null,
|
|
285
441
|
]);
|
|
286
442
|
},
|
|
443
|
+
|
|
444
|
+
// belongsTo relation dropdown
|
|
445
|
+
belongsTo: (col, value, onChange, readonly) => {
|
|
446
|
+
return m(BelongsToField, { col, value, onChange, readonly });
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
// hasMany relation checklist
|
|
450
|
+
hasMany: (col, value, onChange, readonly) => {
|
|
451
|
+
return m(HasManyField, { col, value, onChange, readonly });
|
|
452
|
+
},
|
|
287
453
|
};
|
|
@@ -372,7 +372,36 @@ function getFieldRenderer(col, modelMeta) {
|
|
|
372
372
|
});
|
|
373
373
|
};
|
|
374
374
|
}
|
|
375
|
-
|
|
375
|
+
if (col.customField.type === 'belongsTo') {
|
|
376
|
+
return FieldRenderers.belongsTo;
|
|
377
|
+
}
|
|
378
|
+
if (col.customField.type === 'hasMany') {
|
|
379
|
+
return FieldRenderers.hasMany;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Check registered custom field renderers in browser
|
|
383
|
+
const custom = window.__customFieldRenderers && window.__customFieldRenderers[col.customField.type];
|
|
384
|
+
if (custom) {
|
|
385
|
+
return (col, value, onChange, readonly) => {
|
|
386
|
+
if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
|
|
387
|
+
return m(custom.edit, { col, value, onChange, readonly });
|
|
388
|
+
} else if (typeof custom.edit === 'function') {
|
|
389
|
+
return custom.edit(value, onChange, col);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Check schema type custom field renderers
|
|
396
|
+
if (col.type && window.__customFieldRenderers && window.__customFieldRenderers[col.type]) {
|
|
397
|
+
const custom = window.__customFieldRenderers[col.type];
|
|
398
|
+
return (col, value, onChange, readonly) => {
|
|
399
|
+
if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
|
|
400
|
+
return m(custom.edit, { col, value, onChange, readonly });
|
|
401
|
+
} else if (typeof custom.edit === 'function') {
|
|
402
|
+
return custom.edit(value, onChange, col);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
376
405
|
}
|
|
377
406
|
|
|
378
407
|
if (col.type === 'file') {
|
|
@@ -403,6 +432,8 @@ function getFieldRenderer(col, modelMeta) {
|
|
|
403
432
|
array: 'array',
|
|
404
433
|
uuid: 'string',
|
|
405
434
|
nanoid: 'string',
|
|
435
|
+
belongsTo: 'belongsTo',
|
|
436
|
+
hasMany: 'hasMany',
|
|
406
437
|
};
|
|
407
438
|
return FieldRenderers[typeMap[col.type] || 'string'];
|
|
408
439
|
}
|
|
@@ -19,7 +19,7 @@ const LoginForm = {
|
|
|
19
19
|
password: data.get('password'),
|
|
20
20
|
});
|
|
21
21
|
state.user = result.user;
|
|
22
|
-
m.route.set('/');
|
|
22
|
+
m.route.set(consumeIntendedRoute('/'));
|
|
23
23
|
} catch (err) {
|
|
24
24
|
state.error = err.message;
|
|
25
25
|
} finally {
|
|
@@ -78,7 +78,7 @@ const SetupForm = {
|
|
|
78
78
|
});
|
|
79
79
|
state.user = result.user;
|
|
80
80
|
state.needsSetup = false;
|
|
81
|
-
m.route.set('/');
|
|
81
|
+
m.route.set(consumeIntendedRoute('/'));
|
|
82
82
|
} catch (err) {
|
|
83
83
|
state.error = err.message;
|
|
84
84
|
} finally {
|
|
@@ -65,10 +65,20 @@ const ModelList = {
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
// Format cell value based on column type
|
|
68
|
-
function formatCellValue(value, col) {
|
|
68
|
+
function formatCellValue(value, col, record = null) {
|
|
69
69
|
if (value === null || value === undefined) {
|
|
70
70
|
return m('span.text-gray-400 dark:text-slate-500.italic', 'null');
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
const customRendererType = (col && col.customField && col.customField.type) || (col && col.type);
|
|
74
|
+
const custom = window.__customFieldRenderers && window.__customFieldRenderers[customRendererType];
|
|
75
|
+
if (custom && custom.display) {
|
|
76
|
+
if (typeof custom.display === 'object' && custom.display.view) {
|
|
77
|
+
return m(custom.display, { value, col, record });
|
|
78
|
+
} else if (typeof custom.display === 'function') {
|
|
79
|
+
return custom.display(value, record);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
72
82
|
|
|
73
83
|
switch (col?.type) {
|
|
74
84
|
case 'boolean':
|
|
@@ -478,7 +478,7 @@ const RecordList = {
|
|
|
478
478
|
...displayColumns.map((col, i) =>
|
|
479
479
|
m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700 dark:text-slate-300.bg-white dark:bg-slate-800',
|
|
480
480
|
i === 0 ? { style: 'position: sticky; left: 40px; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
|
|
481
|
-
formatCellValue(record[col.name], col)
|
|
481
|
+
formatCellValue(record[col.name], col, record)
|
|
482
482
|
)
|
|
483
483
|
),
|
|
484
484
|
// Sticky actions cell (sticky right, box-shadow on left)
|
|
@@ -119,6 +119,22 @@ function adminPanelPlugin(options = {}) {
|
|
|
119
119
|
if (typeof configure === 'function') {
|
|
120
120
|
configure(registry);
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
// Session must be registered before file routes so SSR pages can read adminUser.
|
|
124
|
+
if (enabled && ctx.app && !ctx.app._webspressoSessionInitialized) {
|
|
125
|
+
const secret = sessionSecret || process.env.SESSION_SECRET || 'webspresso-admin-secret-change-in-production';
|
|
126
|
+
ctx.app.use(session({
|
|
127
|
+
secret,
|
|
128
|
+
resave: false,
|
|
129
|
+
saveUninitialized: false,
|
|
130
|
+
cookie: {
|
|
131
|
+
secure: process.env.NODE_ENV === 'production',
|
|
132
|
+
httpOnly: true,
|
|
133
|
+
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
|
134
|
+
},
|
|
135
|
+
}));
|
|
136
|
+
ctx.app._webspressoSessionInitialized = true;
|
|
137
|
+
}
|
|
122
138
|
},
|
|
123
139
|
|
|
124
140
|
/**
|
|
@@ -164,24 +180,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
164
180
|
db.registerModel(AdminUser);
|
|
165
181
|
}
|
|
166
182
|
|
|
167
|
-
//
|
|
168
|
-
if (!app._webspressoSessionInitialized) {
|
|
169
|
-
const secret = sessionSecret || process.env.SESSION_SECRET || 'webspresso-admin-secret-change-in-production';
|
|
170
|
-
|
|
171
|
-
app.use(session({
|
|
172
|
-
secret,
|
|
173
|
-
resave: false,
|
|
174
|
-
saveUninitialized: false,
|
|
175
|
-
cookie: {
|
|
176
|
-
secure: process.env.NODE_ENV === 'production',
|
|
177
|
-
httpOnly: true,
|
|
178
|
-
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
|
179
|
-
},
|
|
180
|
-
}));
|
|
181
|
-
|
|
182
|
-
app._webspressoSessionInitialized = true;
|
|
183
|
-
}
|
|
184
|
-
|
|
183
|
+
// Session middleware is initialized in register() (before file routes).
|
|
185
184
|
// Register default modules
|
|
186
185
|
registerSystemMenuItems({ registry });
|
|
187
186
|
registerModelMenuItems({ registry, db });
|
|
@@ -330,6 +329,25 @@ function adminPanelPlugin(options = {}) {
|
|
|
330
329
|
};
|
|
331
330
|
}
|
|
332
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Helper to serialize arbitrary JS structures, including functions
|
|
334
|
+
*/
|
|
335
|
+
function serializeJs(obj) {
|
|
336
|
+
if (typeof obj === 'function') {
|
|
337
|
+
return obj.toString();
|
|
338
|
+
}
|
|
339
|
+
if (Array.isArray(obj)) {
|
|
340
|
+
return '[' + obj.map(serializeJs).join(', ') + ']';
|
|
341
|
+
}
|
|
342
|
+
if (obj && typeof obj === 'object') {
|
|
343
|
+
const entries = Object.entries(obj).map(([key, val]) => {
|
|
344
|
+
return `${JSON.stringify(key)}: ${serializeJs(val)}`;
|
|
345
|
+
});
|
|
346
|
+
return '{' + entries.join(',\n') + '}';
|
|
347
|
+
}
|
|
348
|
+
return JSON.stringify(obj);
|
|
349
|
+
}
|
|
350
|
+
|
|
333
351
|
/**
|
|
334
352
|
* Generate admin panel HTML
|
|
335
353
|
* @param {string} adminPath - Admin panel path
|
|
@@ -344,6 +362,12 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
344
362
|
const customPageComponent = generateCustomPageComponent();
|
|
345
363
|
|
|
346
364
|
const settings = registry.settings;
|
|
365
|
+
const fieldRenderersCode = Array.from(registry.fieldRenderers.entries()).map(([type, r]) => {
|
|
366
|
+
return `window.__customFieldRenderers['${type}'] = {
|
|
367
|
+
display: ${r.display ? serializeJs(r.display) : 'null'},
|
|
368
|
+
edit: ${r.edit ? serializeJs(r.edit) : 'null'}
|
|
369
|
+
};`;
|
|
370
|
+
}).join('\n');
|
|
347
371
|
|
|
348
372
|
return `<!DOCTYPE html>
|
|
349
373
|
<html lang="en">
|
|
@@ -419,6 +443,10 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
419
443
|
// Custom page components container
|
|
420
444
|
window.__customPages = window.__customPages || {};
|
|
421
445
|
${registry.getClientComponents()}
|
|
446
|
+
|
|
447
|
+
// Custom field renderers container
|
|
448
|
+
window.__customFieldRenderers = window.__customFieldRenderers || {};
|
|
449
|
+
${fieldRenderersCode}
|
|
422
450
|
</script>
|
|
423
451
|
<script>${appScript}</script>
|
|
424
452
|
</body>
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content entries admin pages — Mithril component source.
|
|
3
|
+
* @module plugins/content/admin/content-entries-component
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {Object} [options]
|
|
8
|
+
* @param {string} [options.apiPrefix='/content']
|
|
9
|
+
*/
|
|
10
|
+
function generateContentEntriesComponent(options = {}) {
|
|
11
|
+
const apiPrefix = options.apiPrefix || '/content';
|
|
12
|
+
|
|
13
|
+
return `
|
|
14
|
+
(function() {
|
|
15
|
+
var API = '${apiPrefix}';
|
|
16
|
+
|
|
17
|
+
function ContentEntriesListPage() {
|
|
18
|
+
var typeSlug = m.route.param('typeSlug');
|
|
19
|
+
var entries = [];
|
|
20
|
+
var typeMeta = null;
|
|
21
|
+
var loading = true;
|
|
22
|
+
var error = null;
|
|
23
|
+
|
|
24
|
+
function load() {
|
|
25
|
+
loading = true;
|
|
26
|
+
return Promise.all([
|
|
27
|
+
api.get(API + '/types/' + encodeURIComponent(typeSlug) + '/schema').catch(function() { return null; }),
|
|
28
|
+
api.get(API + '/types/' + encodeURIComponent(typeSlug) + '/entries'),
|
|
29
|
+
]).then(function(results) {
|
|
30
|
+
if (results[0]) typeMeta = results[0].data;
|
|
31
|
+
entries = (results[1] && results[1].data) || [];
|
|
32
|
+
loading = false;
|
|
33
|
+
}).catch(function(e) {
|
|
34
|
+
error = e.message;
|
|
35
|
+
loading = false;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
oninit: load,
|
|
41
|
+
view: function() {
|
|
42
|
+
var title = typeMeta ? typeMeta.name : typeSlug;
|
|
43
|
+
return m(Layout, { breadcrumbs: [
|
|
44
|
+
{ label: 'Content Types', href: '/content/types' },
|
|
45
|
+
{ label: title, href: '/content/types/' + encodeURIComponent(typeSlug) + '/entries' },
|
|
46
|
+
]}, [
|
|
47
|
+
m('.flex.items-center.justify-between.mb-6', [
|
|
48
|
+
m('h2.text-2xl.font-bold', title + ' — Entries'),
|
|
49
|
+
m('a.bg-blue-600.text-white.px-4.py-2.rounded.text-sm', {
|
|
50
|
+
href: '/content/types/' + encodeURIComponent(typeSlug) + '/entries/new',
|
|
51
|
+
}, 'New Entry'),
|
|
52
|
+
]),
|
|
53
|
+
loading ? m('p.text-gray-500', 'Loading…') : null,
|
|
54
|
+
error ? m('.bg-red-50.text-red-700.p-4.rounded', error) : null,
|
|
55
|
+
!loading && !error ? m('.bg-white.dark:bg-slate-800.rounded-lg.shadow.overflow-hidden', [
|
|
56
|
+
m('table.w-full.text-sm', [
|
|
57
|
+
m('thead.bg-gray-50', m('tr', [
|
|
58
|
+
m('th.text-left.p-3', 'Title'),
|
|
59
|
+
m('th.text-left.p-3', 'Slug'),
|
|
60
|
+
m('th.text-left.p-3', 'Status'),
|
|
61
|
+
m('th.text-right.p-3', 'Actions'),
|
|
62
|
+
])),
|
|
63
|
+
m('tbody', entries.map(function(e) {
|
|
64
|
+
return m('tr.border-t', [
|
|
65
|
+
m('td.p-3', e.title || '—'),
|
|
66
|
+
m('td.p-3.text-gray-500', e.slug),
|
|
67
|
+
m('td.p-3', m('span.px-2.py-0.5.rounded.text-xs', {
|
|
68
|
+
class: e.status === 'published' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800',
|
|
69
|
+
}, e.status)),
|
|
70
|
+
m('td.p-3.text-right', m('a.text-blue-600', {
|
|
71
|
+
href: '/content/types/' + encodeURIComponent(typeSlug) + '/entries/edit/' + e.id,
|
|
72
|
+
}, 'Edit')),
|
|
73
|
+
]);
|
|
74
|
+
})),
|
|
75
|
+
]),
|
|
76
|
+
entries.length === 0 ? m('p.p-6.text-gray-500', 'No entries yet.') : null,
|
|
77
|
+
]) : null,
|
|
78
|
+
]);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function fieldInput(field, formData, uploadUrl) {
|
|
84
|
+
var val = formData[field.name];
|
|
85
|
+
var label = field.label || field.name;
|
|
86
|
+
|
|
87
|
+
if (field.type === 'boolean') {
|
|
88
|
+
return m('label.flex.items-center.gap-2', [
|
|
89
|
+
m('input', { type: 'checkbox', checked: !!val, onchange: function(e) { formData[field.name] = e.target.checked; } }),
|
|
90
|
+
label,
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
if (field.type === 'textarea' || field.type === 'rich-text') {
|
|
94
|
+
return m('div', [
|
|
95
|
+
m('label.block.text-sm.font-medium.mb-1', label),
|
|
96
|
+
m('textarea.w-full.border.rounded.px-3.py-2.min-h-24', {
|
|
97
|
+
value: val || '',
|
|
98
|
+
oninput: function(e) { formData[field.name] = e.target.value; },
|
|
99
|
+
}),
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
if (field.type === 'select') {
|
|
103
|
+
return m('div', [
|
|
104
|
+
m('label.block.text-sm.font-medium.mb-1', label),
|
|
105
|
+
m('select.w-full.border.rounded.px-3.py-2', {
|
|
106
|
+
value: val || '',
|
|
107
|
+
onchange: function(e) { formData[field.name] = e.target.value; },
|
|
108
|
+
}, (field.options || []).map(function(o) { return m('option', { value: o }, o); })),
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
if (field.type === 'image' && uploadUrl) {
|
|
112
|
+
return m('div', [
|
|
113
|
+
m('label.block.text-sm.font-medium.mb-1', label),
|
|
114
|
+
m('input.w-full.border.rounded.px-3.py-2.mb-2', {
|
|
115
|
+
value: val || '',
|
|
116
|
+
placeholder: 'Image URL',
|
|
117
|
+
oninput: function(e) { formData[field.name] = e.target.value; },
|
|
118
|
+
}),
|
|
119
|
+
m('input', {
|
|
120
|
+
type: 'file',
|
|
121
|
+
accept: 'image/*',
|
|
122
|
+
onchange: function(e) {
|
|
123
|
+
var file = e.target.files && e.target.files[0];
|
|
124
|
+
if (!file) return;
|
|
125
|
+
var fd = new FormData();
|
|
126
|
+
fd.append('file', file);
|
|
127
|
+
fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' })
|
|
128
|
+
.then(function(r) { return r.json(); })
|
|
129
|
+
.then(function(res) {
|
|
130
|
+
if (res.url) { formData[field.name] = res.url; m.redraw(); }
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
}),
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
if (field.type === 'repeater') {
|
|
137
|
+
var items = Array.isArray(val) ? val : [];
|
|
138
|
+
return m('div.space-y-3', [
|
|
139
|
+
m('label.block.text-sm.font-medium', label),
|
|
140
|
+
items.map(function(item, idx) {
|
|
141
|
+
return m('.border.rounded.p-3.space-y-2', [
|
|
142
|
+
m('.text-xs.text-gray-500', 'Item ' + (idx + 1)),
|
|
143
|
+
(field.fields || []).map(function(nf) {
|
|
144
|
+
return fieldInput(nf, item, uploadUrl);
|
|
145
|
+
}),
|
|
146
|
+
m('button.text-xs.text-red-600', {
|
|
147
|
+
type: 'button',
|
|
148
|
+
onclick: function() { items.splice(idx, 1); formData[field.name] = items; },
|
|
149
|
+
}, 'Remove'),
|
|
150
|
+
]);
|
|
151
|
+
}),
|
|
152
|
+
m('button.text-sm.text-blue-600', {
|
|
153
|
+
type: 'button',
|
|
154
|
+
onclick: function() {
|
|
155
|
+
var row = {};
|
|
156
|
+
(field.fields || []).forEach(function(nf) { row[nf.name] = ''; });
|
|
157
|
+
items.push(row);
|
|
158
|
+
formData[field.name] = items;
|
|
159
|
+
},
|
|
160
|
+
}, '+ Add item'),
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
var inputType = field.type === 'number' ? 'number' : (field.type === 'date' ? 'date' : 'text');
|
|
164
|
+
return m('div', [
|
|
165
|
+
m('label.block.text-sm.font-medium.mb-1', label),
|
|
166
|
+
m('input.w-full.border.rounded.px-3.py-2', {
|
|
167
|
+
type: inputType,
|
|
168
|
+
value: val != null ? val : '',
|
|
169
|
+
oninput: function(e) {
|
|
170
|
+
formData[field.name] = field.type === 'number' ? Number(e.target.value) : e.target.value;
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function ContentEntryFormPage() {
|
|
177
|
+
var typeSlug = m.route.param('typeSlug');
|
|
178
|
+
var id = m.route.param('id');
|
|
179
|
+
var isNew = !id || id === 'new';
|
|
180
|
+
var schema = { fields: [] };
|
|
181
|
+
var form = { slug: '', title: '', status: 'published', data: {} };
|
|
182
|
+
var loading = true;
|
|
183
|
+
var saving = false;
|
|
184
|
+
var error = null;
|
|
185
|
+
var uploadUrl = (window.__ADMIN_CONFIG__ && window.__ADMIN_CONFIG__.uploadUrl) || null;
|
|
186
|
+
|
|
187
|
+
function load() {
|
|
188
|
+
return api.get(API + '/types/' + encodeURIComponent(typeSlug) + '/schema').then(function(res) {
|
|
189
|
+
schema = res.data.schema || { fields: [] };
|
|
190
|
+
if (!isNew) {
|
|
191
|
+
return api.get(API + '/entries/' + id).then(function(entryRes) {
|
|
192
|
+
var e = entryRes.data;
|
|
193
|
+
form.slug = e.slug;
|
|
194
|
+
form.title = e.title || '';
|
|
195
|
+
form.status = e.status || 'published';
|
|
196
|
+
form.data = e.data || {};
|
|
197
|
+
loading = false;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
loading = false;
|
|
201
|
+
}).catch(function(e) {
|
|
202
|
+
error = e.message;
|
|
203
|
+
loading = false;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
oninit: load,
|
|
209
|
+
view: function() {
|
|
210
|
+
if (loading) return m(Layout, m('p.p-8', 'Loading…'));
|
|
211
|
+
return m(Layout, { breadcrumbs: [
|
|
212
|
+
{ label: 'Content Types', href: '/content/types' },
|
|
213
|
+
{ label: typeSlug, href: '/content/types/' + encodeURIComponent(typeSlug) + '/entries' },
|
|
214
|
+
{ label: isNew ? 'New' : 'Edit', href: '#' },
|
|
215
|
+
]}, [
|
|
216
|
+
m('h2.text-2xl.font-bold.mb-6', isNew ? 'New Entry' : 'Edit Entry'),
|
|
217
|
+
error ? m('.bg-red-50.text-red-700.p-4.rounded.mb-4', error) : null,
|
|
218
|
+
m('form.bg-white.dark:bg-slate-800.rounded-lg.shadow.p-6.space-y-4', {
|
|
219
|
+
onsubmit: function(e) {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
saving = true;
|
|
222
|
+
var payload = {
|
|
223
|
+
slug: form.slug,
|
|
224
|
+
title: form.title || null,
|
|
225
|
+
status: form.status,
|
|
226
|
+
data: form.data,
|
|
227
|
+
};
|
|
228
|
+
var req = isNew
|
|
229
|
+
? api.post(API + '/types/' + encodeURIComponent(typeSlug) + '/entries', payload)
|
|
230
|
+
: api.put(API + '/entries/' + id, payload);
|
|
231
|
+
req.then(function() {
|
|
232
|
+
m.route.set('/content/types/' + encodeURIComponent(typeSlug) + '/entries');
|
|
233
|
+
}).catch(function(err) {
|
|
234
|
+
error = err.message;
|
|
235
|
+
saving = false;
|
|
236
|
+
m.redraw();
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
}, [
|
|
240
|
+
m('.grid.grid-cols-2.gap-4', [
|
|
241
|
+
m('div', [
|
|
242
|
+
m('label.block.text-sm.font-medium.mb-1', 'Slug'),
|
|
243
|
+
m('input.w-full.border.rounded.px-3.py-2', {
|
|
244
|
+
value: form.slug,
|
|
245
|
+
disabled: !isNew,
|
|
246
|
+
oninput: function(e) { form.slug = e.target.value; },
|
|
247
|
+
}),
|
|
248
|
+
]),
|
|
249
|
+
m('div', [
|
|
250
|
+
m('label.block.text-sm.font-medium.mb-1', 'Title'),
|
|
251
|
+
m('input.w-full.border.rounded.px-3.py-2', {
|
|
252
|
+
value: form.title,
|
|
253
|
+
oninput: function(e) { form.title = e.target.value; },
|
|
254
|
+
}),
|
|
255
|
+
]),
|
|
256
|
+
]),
|
|
257
|
+
m('div', [
|
|
258
|
+
m('label.block.text-sm.font-medium.mb-1', 'Status'),
|
|
259
|
+
m('select.w-full.border.rounded.px-3.py-2', {
|
|
260
|
+
value: form.status,
|
|
261
|
+
onchange: function(e) { form.status = e.target.value; },
|
|
262
|
+
}, [
|
|
263
|
+
m('option', { value: 'published' }, 'Published'),
|
|
264
|
+
m('option', { value: 'draft' }, 'Draft'),
|
|
265
|
+
]),
|
|
266
|
+
]),
|
|
267
|
+
m('hr'),
|
|
268
|
+
schema.fields.map(function(f) {
|
|
269
|
+
return fieldInput(f, form.data, uploadUrl);
|
|
270
|
+
}),
|
|
271
|
+
m('.flex.justify-end.gap-3', [
|
|
272
|
+
m('a.px-4.py-2.border.rounded', {
|
|
273
|
+
href: '/content/types/' + encodeURIComponent(typeSlug) + '/entries',
|
|
274
|
+
}, 'Cancel'),
|
|
275
|
+
m('button.px-4.py-2.bg-blue-600.text-white.rounded', { disabled: saving }, saving ? 'Saving…' : 'Save'),
|
|
276
|
+
]),
|
|
277
|
+
]),
|
|
278
|
+
]);
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
window.__customPages = window.__customPages || {};
|
|
284
|
+
window.__customPages['content-entries'] = ContentEntriesListPage;
|
|
285
|
+
window.__customPages['content-entries-new'] = ContentEntryFormPage;
|
|
286
|
+
window.__customPages['content-entries-edit'] = ContentEntryFormPage;
|
|
287
|
+
})();
|
|
288
|
+
`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = { generateContentEntriesComponent };
|