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.
Files changed (37) 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/04-field-renderers.js +166 -0
  18. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +32 -1
  19. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +2 -2
  20. package/plugins/admin-panel/client/parts/07-model-list.js +11 -1
  21. package/plugins/admin-panel/client/parts/08-record-list.js +1 -1
  22. package/plugins/admin-panel/index.js +46 -18
  23. package/plugins/admin-panel/modules/menu.js +1 -0
  24. package/plugins/content/admin/content-entries-component.js +291 -0
  25. package/plugins/content/admin/content-types-component.js +250 -0
  26. package/plugins/content/api-handlers.js +157 -0
  27. package/plugins/content/client/inline-edit.css +296 -0
  28. package/plugins/content/client/inline-edit.js +366 -0
  29. package/plugins/content/helpers.js +77 -0
  30. package/plugins/content/index.js +231 -0
  31. package/plugins/content/migration-template.js +54 -0
  32. package/plugins/content/models/content-entry.js +45 -0
  33. package/plugins/content/models/content-type.js +36 -0
  34. package/plugins/index.js +2 -0
  35. package/src/file-router.js +21 -1
  36. package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -1
  37. 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
- // Add other custom field types here if needed
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
- // 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 });
@@ -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>
@@ -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 };