webspresso 0.0.76 → 0.0.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +6 -1
  2. package/bin/commands/db-scaffold.js +81 -0
  3. package/bin/utils/model-migrations.js +211 -0
  4. package/bin/webspresso.js +2 -0
  5. package/core/content/cache.js +64 -0
  6. package/core/content/field-types.js +180 -0
  7. package/core/content/index.js +30 -0
  8. package/core/content/renderer.js +84 -0
  9. package/core/content/schema.js +75 -0
  10. package/core/content/service.js +400 -0
  11. package/core/content/types.js +59 -0
  12. package/index.d.ts +17 -0
  13. package/index.js +7 -0
  14. package/package.json +1 -1
  15. package/plugins/admin-panel/app.js +7 -7
  16. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +41 -0
  17. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +99 -15
  18. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +2 -2
  19. package/plugins/admin-panel/field-renderers/file-upload.js +108 -27
  20. package/plugins/admin-panel/index.js +17 -18
  21. package/plugins/admin-panel/modules/menu.js +1 -0
  22. package/plugins/content/admin/content-entries-component.js +291 -0
  23. package/plugins/content/admin/content-types-component.js +250 -0
  24. package/plugins/content/api-handlers.js +157 -0
  25. package/plugins/content/client/inline-edit.css +296 -0
  26. package/plugins/content/client/inline-edit.js +366 -0
  27. package/plugins/content/helpers.js +77 -0
  28. package/plugins/content/index.js +231 -0
  29. package/plugins/content/migration-template.js +54 -0
  30. package/plugins/content/models/content-entry.js +45 -0
  31. package/plugins/content/models/content-type.js +36 -0
  32. package/plugins/index.js +2 -0
  33. package/src/file-router.js +21 -1
  34. package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -1
  35. package/templates/skills/webspresso-usage/SKILL.md +5 -0
@@ -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 };
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Content types admin page — Mithril component source.
3
+ * @module plugins/content/admin/content-types-component
4
+ */
5
+
6
+ /**
7
+ * @param {Object} [options]
8
+ * @param {string} [options.apiPrefix='/content']
9
+ */
10
+ function generateContentTypesComponent(options = {}) {
11
+ const apiPrefix = options.apiPrefix || '/content';
12
+
13
+ return `
14
+ (function() {
15
+ var API = '${apiPrefix}';
16
+ var FIELD_TYPES = ['text','textarea','rich-text','number','boolean','image','url','date','select','repeater'];
17
+
18
+ function emptyField() {
19
+ return { name: '', type: 'text', label: '', required: false, options: [], fields: [] };
20
+ }
21
+
22
+ function ContentTypesListPage() {
23
+ var types = [];
24
+ var loading = true;
25
+ var error = null;
26
+
27
+ function load() {
28
+ loading = true;
29
+ error = null;
30
+ return api.get(API + '/types').then(function(res) {
31
+ types = res.data || [];
32
+ loading = false;
33
+ }).catch(function(e) {
34
+ error = e.message || String(e);
35
+ loading = false;
36
+ });
37
+ }
38
+
39
+ return {
40
+ oninit: load,
41
+ view: function() {
42
+ return m(Layout, { breadcrumbs: [{ label: 'Content Types', href: '/content/types' }] }, [
43
+ m('.flex.items-center.justify-between.mb-6', [
44
+ m('h2.text-2xl.font-bold.text-gray-900.dark:text-slate-100', 'Content Types'),
45
+ m('a.bg-blue-600.text-white.px-4.py-2.rounded-lg.text-sm', { href: '/content/types/new' }, 'New Type'),
46
+ ]),
47
+ loading ? m('p.text-gray-500', 'Loading…') : null,
48
+ error ? m('.bg-red-50.text-red-700.p-4.rounded', error) : null,
49
+ !loading && !error ? m('.bg-white.dark:bg-slate-800.rounded-lg.shadow.overflow-hidden', [
50
+ m('table.w-full.text-sm', [
51
+ m('thead.bg-gray-50.dark:bg-slate-900', m('tr', [
52
+ m('th.text-left.p-3', 'Name'),
53
+ m('th.text-left.p-3', 'Slug'),
54
+ m('th.text-left.p-3', 'Fields'),
55
+ m('th.text-right.p-3', 'Actions'),
56
+ ])),
57
+ m('tbody', types.map(function(t) {
58
+ return m('tr.border-t.border-gray-100.dark:border-slate-700', [
59
+ m('td.p-3.font-medium', t.name),
60
+ m('td.p-3.text-gray-500', t.slug),
61
+ m('td.p-3.text-gray-500', (t.schema && t.schema.fields ? t.schema.fields.length : 0)),
62
+ m('td.p-3.text-right.space-x-2', [
63
+ m('a.text-blue-600', { href: '/content/types/' + encodeURIComponent(t.slug) + '/entries' }, 'Entries'),
64
+ m('a.text-gray-600', { href: '/content/types/edit/' + t.id }, 'Edit'),
65
+ ]),
66
+ ]);
67
+ })),
68
+ ]),
69
+ types.length === 0 ? m('p.p-6.text-gray-500', 'No content types yet.') : null,
70
+ ]) : null,
71
+ ]);
72
+ },
73
+ };
74
+ }
75
+
76
+ function ContentTypeFormPage() {
77
+ var id = m.route.param('id');
78
+ var isNew = !id || id === 'new';
79
+ var form = { slug: '', name: '', description: '', schema: { fields: [emptyField()] } };
80
+ var loading = !isNew;
81
+ var saving = false;
82
+ var error = null;
83
+
84
+ function load() {
85
+ if (isNew) return Promise.resolve();
86
+ return api.get(API + '/types/' + id).then(function(res) {
87
+ var d = res.data;
88
+ form.slug = d.slug;
89
+ form.name = d.name;
90
+ form.description = d.description || '';
91
+ form.schema = d.schema || { fields: [] };
92
+ if (!form.schema.fields || form.schema.fields.length === 0) {
93
+ form.schema.fields = [emptyField()];
94
+ }
95
+ loading = false;
96
+ }).catch(function(e) {
97
+ error = e.message;
98
+ loading = false;
99
+ });
100
+ }
101
+
102
+ function renderFieldEditor(field, path, onRemove) {
103
+ return m('.border.border-gray-200.dark:border-slate-600.rounded.p-4.space-y-3', [
104
+ m('.grid.grid-cols-2.gap-3', [
105
+ m('div', [
106
+ m('label.block.text-xs.text-gray-500.mb-1', 'Name'),
107
+ m('input.w-full.border.rounded.px-2.py-1', {
108
+ value: field.name,
109
+ oninput: function(e) { field.name = e.target.value; },
110
+ }),
111
+ ]),
112
+ m('div', [
113
+ m('label.block.text-xs.text-gray-500.mb-1', 'Type'),
114
+ m('select.w-full.border.rounded.px-2.py-1', {
115
+ value: field.type,
116
+ onchange: function(e) { field.type = e.target.value; },
117
+ }, FIELD_TYPES.map(function(t) { return m('option', { value: t }, t); })),
118
+ ]),
119
+ m('div', [
120
+ m('label.block.text-xs.text-gray-500.mb-1', 'Label'),
121
+ m('input.w-full.border.rounded.px-2.py-1', {
122
+ value: field.label || '',
123
+ oninput: function(e) { field.label = e.target.value; },
124
+ }),
125
+ ]),
126
+ m('div.flex.items-end', [
127
+ m('label.flex.items-center.gap-2.text-sm', [
128
+ m('input', { type: 'checkbox', checked: !!field.required, onchange: function(e) { field.required = e.target.checked; } }),
129
+ 'Required',
130
+ ]),
131
+ ]),
132
+ ]),
133
+ field.type === 'select' ? m('div', [
134
+ m('label.block.text-xs.text-gray-500.mb-1', 'Options (comma-separated)'),
135
+ m('input.w-full.border.rounded.px-2.py-1', {
136
+ value: (field.options || []).join(', '),
137
+ oninput: function(e) {
138
+ field.options = e.target.value.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
139
+ },
140
+ }),
141
+ ]) : null,
142
+ field.type === 'repeater' ? m('.pl-4.border-l-2.border-blue-200.space-y-2', [
143
+ m('p.text-xs.text-gray-500', 'Nested fields'),
144
+ (field.fields || []).map(function(nf, idx) {
145
+ return renderFieldEditor(nf, path + '.fields.' + idx, function() {
146
+ field.fields.splice(idx, 1);
147
+ });
148
+ }),
149
+ m('button.text-sm.text-blue-600', {
150
+ type: 'button',
151
+ onclick: function() {
152
+ field.fields = field.fields || [];
153
+ field.fields.push(emptyField());
154
+ },
155
+ }, '+ Add nested field'),
156
+ ]) : null,
157
+ onRemove ? m('button.text-sm.text-red-600', { type: 'button', onclick: onRemove }, 'Remove field') : null,
158
+ ]);
159
+ }
160
+
161
+ return {
162
+ oninit: load,
163
+ view: function() {
164
+ if (loading) return m(Layout, m('p.p-8.text-gray-500', 'Loading…'));
165
+ return m(Layout, { breadcrumbs: [
166
+ { label: 'Content Types', href: '/content/types' },
167
+ { label: isNew ? 'New' : 'Edit', href: '#' },
168
+ ]}, [
169
+ m('h2.text-2xl.font-bold.mb-6', isNew ? 'New Content Type' : 'Edit Content Type'),
170
+ error ? m('.bg-red-50.text-red-700.p-4.rounded.mb-4', error) : null,
171
+ m('form.bg-white.dark:bg-slate-800.rounded-lg.shadow.p-6.space-y-4', {
172
+ onsubmit: function(e) {
173
+ e.preventDefault();
174
+ saving = true;
175
+ error = null;
176
+ var payload = {
177
+ slug: form.slug,
178
+ name: form.name,
179
+ description: form.description || null,
180
+ schema: form.schema,
181
+ };
182
+ var req = isNew
183
+ ? api.post(API + '/types', payload)
184
+ : api.put(API + '/types/' + id, payload);
185
+ req.then(function() {
186
+ m.route.set('/content/types');
187
+ }).catch(function(err) {
188
+ error = err.message;
189
+ saving = false;
190
+ m.redraw();
191
+ });
192
+ },
193
+ }, [
194
+ m('.grid.grid-cols-2.gap-4', [
195
+ m('div', [
196
+ m('label.block.text-sm.font-medium.mb-1', 'Slug'),
197
+ m('input.w-full.border.rounded.px-3.py-2', {
198
+ value: form.slug,
199
+ disabled: !isNew,
200
+ oninput: function(e) { form.slug = e.target.value; },
201
+ }),
202
+ ]),
203
+ m('div', [
204
+ m('label.block.text-sm.font-medium.mb-1', 'Name'),
205
+ m('input.w-full.border.rounded.px-3.py-2', {
206
+ value: form.name,
207
+ oninput: function(e) { form.name = e.target.value; },
208
+ }),
209
+ ]),
210
+ ]),
211
+ m('div', [
212
+ m('label.block.text-sm.font-medium.mb-1', 'Description'),
213
+ m('textarea.w-full.border.rounded.px-3.py-2', {
214
+ value: form.description,
215
+ oninput: function(e) { form.description = e.target.value; },
216
+ }),
217
+ ]),
218
+ m('div', [
219
+ m('.flex.items-center.justify-between.mb-2', [
220
+ m('h3.font-semibold', 'Fields'),
221
+ m('button.text-sm.text-blue-600', {
222
+ type: 'button',
223
+ onclick: function() { form.schema.fields.push(emptyField()); },
224
+ }, '+ Add field'),
225
+ ]),
226
+ form.schema.fields.map(function(f, i) {
227
+ return renderFieldEditor(f, String(i), function() {
228
+ form.schema.fields.splice(i, 1);
229
+ });
230
+ }),
231
+ ]),
232
+ m('.flex.justify-end.gap-3.pt-4', [
233
+ m('a.px-4.py-2.border.rounded', { href: '/content/types' }, 'Cancel'),
234
+ m('button.px-4.py-2.bg-blue-600.text-white.rounded', { disabled: saving }, saving ? 'Saving…' : 'Save'),
235
+ ]),
236
+ ]),
237
+ ]);
238
+ },
239
+ };
240
+ }
241
+
242
+ window.__customPages = window.__customPages || {};
243
+ window.__customPages['content-types'] = ContentTypesListPage;
244
+ window.__customPages['content-types-new'] = ContentTypeFormPage;
245
+ window.__customPages['content-types-edit'] = ContentTypeFormPage;
246
+ })();
247
+ `;
248
+ }
249
+
250
+ module.exports = { generateContentTypesComponent };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Content plugin API handlers
3
+ * @module plugins/content/api-handlers
4
+ */
5
+
6
+ /**
7
+ * @param {Object} deps
8
+ * @param {ReturnType<import('../../core/content/service').createContentService>} deps.contentService
9
+ */
10
+ function createContentApiHandlers({ contentService }) {
11
+ function sendError(res, err, status = 400) {
12
+ const message = err?.message || 'Request failed';
13
+ if (message.includes('not found')) {
14
+ return res.status(404).json({ error: message });
15
+ }
16
+ return res.status(status).json({ error: message });
17
+ }
18
+
19
+ async function listTypesHandler(req, res) {
20
+ try {
21
+ const data = await contentService.listTypes();
22
+ res.json({ data });
23
+ } catch (err) {
24
+ sendError(res, err);
25
+ }
26
+ }
27
+
28
+ async function createTypeHandler(req, res) {
29
+ try {
30
+ const row = await contentService.createType(req.body);
31
+ res.status(201).json({ data: row });
32
+ } catch (err) {
33
+ sendError(res, err);
34
+ }
35
+ }
36
+
37
+ async function getTypeHandler(req, res) {
38
+ try {
39
+ const row = await contentService.getTypeById(req.params.id);
40
+ if (!row) return res.status(404).json({ error: 'Content type not found' });
41
+ res.json({ data: row });
42
+ } catch (err) {
43
+ sendError(res, err);
44
+ }
45
+ }
46
+
47
+ async function updateTypeHandler(req, res) {
48
+ try {
49
+ const row = await contentService.updateType(req.params.id, req.body);
50
+ res.json({ data: row });
51
+ } catch (err) {
52
+ sendError(res, err);
53
+ }
54
+ }
55
+
56
+ async function deleteTypeHandler(req, res) {
57
+ try {
58
+ await contentService.deleteType(req.params.id);
59
+ res.json({ success: true });
60
+ } catch (err) {
61
+ sendError(res, err);
62
+ }
63
+ }
64
+
65
+ async function getTypeSchemaHandler(req, res) {
66
+ try {
67
+ const row = await contentService.getTypeBySlug(req.params.typeSlug);
68
+ if (!row) return res.status(404).json({ error: 'Content type not found' });
69
+ res.json({ data: { slug: row.slug, name: row.name, schema: row.schema } });
70
+ } catch (err) {
71
+ sendError(res, err);
72
+ }
73
+ }
74
+
75
+ async function listEntriesHandler(req, res) {
76
+ try {
77
+ const data = await contentService.listEntries(req.params.typeSlug, {
78
+ status: req.query.status,
79
+ locale: req.query.locale !== undefined ? req.query.locale : undefined,
80
+ });
81
+ res.json({ data });
82
+ } catch (err) {
83
+ sendError(res, err);
84
+ }
85
+ }
86
+
87
+ async function createEntryHandler(req, res) {
88
+ try {
89
+ const row = await contentService.createEntry(req.params.typeSlug, req.body);
90
+ res.status(201).json({ data: row });
91
+ } catch (err) {
92
+ sendError(res, err);
93
+ }
94
+ }
95
+
96
+ async function getEntryHandler(req, res) {
97
+ try {
98
+ const bundle = await contentService.getEntryById(req.params.id);
99
+ if (!bundle) return res.status(404).json({ error: 'Content entry not found' });
100
+ res.json({ data: bundle.entry, type: bundle.type });
101
+ } catch (err) {
102
+ sendError(res, err);
103
+ }
104
+ }
105
+
106
+ async function updateEntryHandler(req, res) {
107
+ try {
108
+ const row = await contentService.updateEntry(req.params.id, req.body);
109
+ res.json({ data: row });
110
+ } catch (err) {
111
+ sendError(res, err);
112
+ }
113
+ }
114
+
115
+ async function deleteEntryHandler(req, res) {
116
+ try {
117
+ await contentService.deleteEntry(req.params.id);
118
+ res.json({ success: true });
119
+ } catch (err) {
120
+ sendError(res, err);
121
+ }
122
+ }
123
+
124
+ async function publicGetEntryHandler(req, res) {
125
+ try {
126
+ const result = await contentService.getEntry(req.params.typeSlug, req.params.entrySlug, {
127
+ locale: req.query.locale ?? null,
128
+ });
129
+ if (!result) return res.status(404).json({ error: 'Content not found' });
130
+ res.json({
131
+ type: result.meta.typeSlug,
132
+ slug: result.meta.slug,
133
+ data: result.data,
134
+ meta: result.meta,
135
+ });
136
+ } catch (err) {
137
+ sendError(res, err);
138
+ }
139
+ }
140
+
141
+ return {
142
+ listTypesHandler,
143
+ createTypeHandler,
144
+ getTypeHandler,
145
+ updateTypeHandler,
146
+ deleteTypeHandler,
147
+ getTypeSchemaHandler,
148
+ listEntriesHandler,
149
+ createEntryHandler,
150
+ getEntryHandler,
151
+ updateEntryHandler,
152
+ deleteEntryHandler,
153
+ publicGetEntryHandler,
154
+ };
155
+ }
156
+
157
+ module.exports = { createContentApiHandlers };