webspresso 0.0.77 → 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 (33) hide show
  1. package/README.md +5 -0
  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/06-login-setup-forms.js +2 -2
  18. package/plugins/admin-panel/index.js +17 -18
  19. package/plugins/admin-panel/modules/menu.js +1 -0
  20. package/plugins/content/admin/content-entries-component.js +291 -0
  21. package/plugins/content/admin/content-types-component.js +250 -0
  22. package/plugins/content/api-handlers.js +157 -0
  23. package/plugins/content/client/inline-edit.css +296 -0
  24. package/plugins/content/client/inline-edit.js +366 -0
  25. package/plugins/content/helpers.js +77 -0
  26. package/plugins/content/index.js +231 -0
  27. package/plugins/content/migration-template.js +54 -0
  28. package/plugins/content/models/content-entry.js +45 -0
  29. package/plugins/content/models/content-type.js +36 -0
  30. package/plugins/index.js +2 -0
  31. package/src/file-router.js +21 -1
  32. package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -1
  33. package/templates/skills/webspresso-usage/SKILL.md +5 -0
@@ -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 {
@@ -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
- // Setup session middleware (only once)
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 });
@@ -351,6 +351,7 @@ const Sidebar = {
351
351
  await api.post('/auth/logout');
352
352
  state.user = null;
353
353
  sidebarOpen = false;
354
+ clearIntendedRoute();
354
355
  m.route.set('/login');
355
356
  },
356
357
  }, m(Icon, { name: 'logout', class: 'w-5 h-5' })),
@@ -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 };