domma-cms 0.54.2 → 0.66.0

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 (221) hide show
  1. package/CLAUDE.md +151 -77
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/menu-editor.html +19 -25
  19. package/admin/js/templates/plugin-code.html +1 -1
  20. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  21. package/admin/js/templates/settings.html +27 -97
  22. package/admin/js/templates/theme.html +173 -0
  23. package/admin/js/views/context-menu-editor.js +55 -0
  24. package/admin/js/views/context-menus.js +5 -0
  25. package/admin/js/views/form-editor.js +7 -7
  26. package/admin/js/views/index.js +1 -1
  27. package/admin/js/views/menu-editor.js +13 -13
  28. package/admin/js/views/plugin-marketplace.js +1 -1
  29. package/admin/js/views/plugins.js +25 -23
  30. package/admin/js/views/search.js +1 -0
  31. package/admin/js/views/settings.js +3 -3
  32. package/admin/js/views/theme.js +1 -0
  33. package/bin/cli.js +11 -2
  34. package/bin/lib/smtp-defaults.js +53 -0
  35. package/bin/update.js +13 -2
  36. package/config/menus/admin-sidebar.json +129 -23
  37. package/config/plugins.json +5 -5
  38. package/config/search.json +13 -0
  39. package/config/theme.json +18 -0
  40. package/package.json +12 -4
  41. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  42. package/plugins/_lib/admin/mail/contacts.js +301 -0
  43. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  44. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  45. package/plugins/_lib/admin/mail/identity.js +480 -0
  46. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  47. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  48. package/plugins/_lib/admin/mail/mail.css +1 -0
  49. package/plugins/_lib/admin/mail/mail.html +71 -0
  50. package/plugins/_lib/admin/mail/panes.js +253 -0
  51. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  52. package/plugins/_lib/admin/mail/resizable.js +26 -0
  53. package/plugins/_lib/admin/mail/rules.js +343 -0
  54. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  55. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  56. package/plugins/_lib/admin/mail/templates.js +238 -0
  57. package/plugins/_lib/admin/mail/threads.js +200 -0
  58. package/plugins/_lib/admin/mail/vacation.js +269 -0
  59. package/plugins/_lib/admin/ui/help.css +1 -0
  60. package/plugins/_lib/admin/ui/help.js +174 -0
  61. package/plugins/_lib/admin/ui/resizable.js +151 -0
  62. package/plugins/_lib/dataStore.js +117 -0
  63. package/plugins/_lib/mail/accounts.js +919 -0
  64. package/plugins/_lib/mail/bodyTokens.js +101 -0
  65. package/plugins/_lib/mail/compose.js +256 -0
  66. package/plugins/_lib/mail/defaults.js +57 -0
  67. package/plugins/_lib/mail/diagnostics.js +154 -0
  68. package/plugins/_lib/mail/envelope.js +161 -0
  69. package/plugins/_lib/mail/folders.js +192 -0
  70. package/plugins/_lib/mail/handoff.js +274 -0
  71. package/plugins/_lib/mail/imapPool.js +293 -0
  72. package/plugins/_lib/mail/mbox.js +74 -0
  73. package/plugins/_lib/mail/pollSchedule.js +79 -0
  74. package/plugins/_lib/mail/poller.js +135 -0
  75. package/plugins/_lib/mail/priority.js +138 -0
  76. package/plugins/_lib/mail/readRoutes.js +680 -0
  77. package/plugins/_lib/mail/render.js +291 -0
  78. package/plugins/_lib/mail/ruleRunner.js +152 -0
  79. package/plugins/_lib/mail/scheduler.js +254 -0
  80. package/plugins/_lib/mail/secretbox.js +229 -0
  81. package/plugins/_lib/mail/send.js +396 -0
  82. package/plugins/_lib/mail/store.js +1002 -0
  83. package/plugins/_lib/mail/sync.js +292 -0
  84. package/plugins/_lib/mail/syncPlan.js +126 -0
  85. package/plugins/_lib/mail/syncSelection.js +82 -0
  86. package/plugins/_lib/mail/unsubscribe.js +183 -0
  87. package/plugins/_lib/mail/vacationRunner.js +114 -0
  88. package/plugins/_lib/mail/write.js +473 -0
  89. package/plugins/_lib/schemaSync.js +83 -0
  90. package/plugins/_template/admin/css/index.css +0 -0
  91. package/plugins/_template/admin/templates/index.html +4 -4
  92. package/plugins/_template/admin/views/index.js +7 -0
  93. package/plugins/analytics/admin/css/index.css +1 -0
  94. package/plugins/analytics/admin/templates/analytics.html +22 -13
  95. package/plugins/analytics/plugin.json +3 -0
  96. package/plugins/blog/admin/css/index.css +1 -0
  97. package/plugins/blog/admin/templates/blog.html +30 -18
  98. package/plugins/blog/admin/templates/categories.html +2 -2
  99. package/plugins/blog/admin/templates/comments.html +2 -2
  100. package/plugins/blog/admin/templates/post-editor.html +34 -34
  101. package/plugins/blog/admin/templates/settings.html +6 -3
  102. package/plugins/blog/admin/views/blog.js +8 -5
  103. package/plugins/blog/admin/views/categories.js +5 -10
  104. package/plugins/blog/admin/views/comments.js +5 -5
  105. package/plugins/blog/admin/views/post-editor.js +39 -20
  106. package/plugins/blog/admin/views/settings.js +52 -50
  107. package/plugins/blog/collections/categories/schema.json +7 -6
  108. package/plugins/blog/collections/comments/schema.json +11 -10
  109. package/plugins/blog/collections/posts/schema.json +14 -13
  110. package/plugins/blog/plugin.js +36 -13
  111. package/plugins/blog/plugin.json +13 -5
  112. package/plugins/blog/plugin.public.js +312 -0
  113. package/plugins/contacts/admin/css/index.css +1 -0
  114. package/plugins/contacts/admin/templates/contacts.html +128 -0
  115. package/plugins/contacts/admin/views/contacts.js +237 -4
  116. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  117. package/plugins/contacts/plugin.js +214 -27
  118. package/plugins/contacts/plugin.json +4 -1
  119. package/plugins/invoice/admin/css/index.css +1 -0
  120. package/plugins/invoice/admin/templates/editor.html +140 -49
  121. package/plugins/invoice/admin/templates/index.html +153 -23
  122. package/plugins/invoice/admin/templates/issuers.html +2 -5
  123. package/plugins/invoice/admin/templates/receivers.html +2 -5
  124. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  125. package/plugins/invoice/admin/views/editor.js +366 -199
  126. package/plugins/invoice/admin/views/export.js +199 -0
  127. package/plugins/invoice/admin/views/help-content.js +61 -0
  128. package/plugins/invoice/admin/views/index.js +582 -94
  129. package/plugins/invoice/admin/views/issuers.js +24 -17
  130. package/plugins/invoice/admin/views/media.js +172 -0
  131. package/plugins/invoice/admin/views/party-view.js +305 -67
  132. package/plugins/invoice/admin/views/payments.js +127 -0
  133. package/plugins/invoice/admin/views/print.js +130 -0
  134. package/plugins/invoice/admin/views/receivers.js +49 -16
  135. package/plugins/invoice/admin/views/send.js +212 -0
  136. package/plugins/invoice/admin/views/settings.js +594 -0
  137. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  138. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  139. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  140. package/plugins/invoice/collections/invoices/schema.json +19 -13
  141. package/plugins/invoice/config.js +27 -6
  142. package/plugins/invoice/pdf.js +164 -0
  143. package/plugins/invoice/plugin.js +1217 -44
  144. package/plugins/invoice/plugin.json +10 -9
  145. package/plugins/invoice/templates/_base.css +1 -0
  146. package/plugins/invoice/templates/classic-nologo.html +100 -0
  147. package/plugins/invoice/templates/classic.html +91 -0
  148. package/plugins/invoice/templates/invoice-print.html +24 -0
  149. package/plugins/invoice/templates/minimal.html +99 -0
  150. package/plugins/invoice/templates/modern-nologo.html +114 -0
  151. package/plugins/invoice/templates/modern.html +113 -0
  152. package/plugins/invoice/templates/templates.json +11 -0
  153. package/plugins/mail-reader/admin/views/mail.js +19 -0
  154. package/plugins/mail-reader/config.js +7 -0
  155. package/plugins/mail-reader/plugin.js +48 -0
  156. package/plugins/mail-reader/plugin.json +33 -0
  157. package/plugins/notes/admin/views/notes.js +1 -1
  158. package/plugins/notes/plugin.json +2 -2
  159. package/plugins/surveys/lib/audience.js +37 -0
  160. package/plugins/surveys/lib/campaigns.js +43 -0
  161. package/plugins/surveys/lib/ledger.js +110 -0
  162. package/plugins/surveys/lib/sending.js +106 -0
  163. package/plugins/surveys/lib/stats.js +62 -0
  164. package/plugins/surveys/lib/submit.js +95 -0
  165. package/plugins/surveys/lib/tokens.js +28 -0
  166. package/plugins/surveys/plugin.public.js +149 -0
  167. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  168. package/public/css/forms.css +1 -1
  169. package/public/css/menu-highlight.css +1 -1
  170. package/public/css/search.css +1 -0
  171. package/public/css/site.css +1 -1
  172. package/public/js/collection-context.js +2 -2
  173. package/public/js/context-menus.js +1 -0
  174. package/public/js/form-logic-engine.js +1 -1
  175. package/public/js/forms.js +2 -2
  176. package/public/js/menu-decor.mjs +1 -1
  177. package/public/js/search.js +1 -0
  178. package/public/js/site.js +1 -1
  179. package/scripts/build.js +37 -3
  180. package/scripts/copy-domma.js +48 -0
  181. package/scripts/seed.js +1996 -0
  182. package/scripts/setup.js +8 -0
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/context-menus.js +104 -0
  185. package/server/routes/api/forms.js +42 -3
  186. package/server/routes/api/notifications.js +69 -19
  187. package/server/routes/api/plugins.js +50 -6
  188. package/server/routes/api/search.js +43 -0
  189. package/server/routes/api/theme.js +69 -0
  190. package/server/routes/public.js +42 -7
  191. package/server/server.js +74 -0
  192. package/server/services/adapters/FileAdapter.js +6 -1
  193. package/server/services/content.js +26 -0
  194. package/server/services/contextMenus.js +477 -0
  195. package/server/services/email.js +29 -3
  196. package/server/services/health.js +23 -2
  197. package/server/services/markdown.js +70 -9
  198. package/server/services/menuRender.js +28 -4
  199. package/server/services/menus.js +25 -1
  200. package/server/services/permissionRegistry.js +24 -0
  201. package/server/services/pluginFiles.js +52 -11
  202. package/server/services/plugins.js +229 -6
  203. package/server/services/renderer.js +148 -22
  204. package/server/services/roles.js +1 -1
  205. package/server/services/search-migration.js +82 -0
  206. package/server/services/search.js +413 -0
  207. package/server/services/sidebar-migration.js +1 -0
  208. package/server/services/themeSettings.js +541 -0
  209. package/server/services/users.js +8 -0
  210. package/server/templates/page.html +4 -2
  211. package/plugins/contacts/data/contacts.json +0 -20
  212. package/plugins/notes/data/notes.json +0 -1
  213. package/plugins/site-search/admin/views/site-search.js +0 -116
  214. package/plugins/site-search/config.js +0 -15
  215. package/plugins/site-search/plugin.js +0 -188
  216. package/plugins/site-search/plugin.json +0 -40
  217. package/plugins/site-search/public/inject-body.html +0 -17
  218. package/plugins/site-search/public/inject-head.html +0 -1
  219. package/plugins/site-search/public/search.css +0 -1
  220. package/plugins/site-search/public/search.js +0 -1
  221. package/plugins/todo/data/todos.json +0 -1
@@ -18,7 +18,14 @@ export const postEditorView = {
18
18
  templateUrl: '/plugins/blog/admin/templates/post-editor.html',
19
19
 
20
20
  async onMount($container) {
21
- const id = R.current?.params?.id !== 'new' ? R.current?.params?.id : null;
21
+ // Domma's router hands onMount ONE argument and never the route params,
22
+ // and `R.current` is a METHOD - `R.current?.params` is undefined on the
23
+ // function object, so this view opened blank on every edit and Save
24
+ // created a duplicate. Read the slug off the hash, as every core editor
25
+ // view does.
26
+ const routeMatch = location.hash.match(/^#\/plugins\/blog\/posts\/([^?#]+)/);
27
+ const routeId = routeMatch ? decodeURIComponent(routeMatch[1]) : null;
28
+ const id = routeId && routeId !== 'new' ? routeId : null;
22
29
  const isEdit = Boolean(id);
23
30
 
24
31
  let post = null;
@@ -40,7 +47,7 @@ export const postEditorView = {
40
47
  post = res.data ?? res;
41
48
  } catch {
42
49
  E.toast('Failed to load post.', { type: 'error' });
43
- R.navigate('#/plugins/blog');
50
+ R.navigate('#/plugins/blog/posts');
44
51
  return;
45
52
  }
46
53
  }
@@ -87,11 +94,11 @@ export const postEditorView = {
87
94
  const img = document.createElement('img');
88
95
  img.src = featuredImageUrl;
89
96
  img.alt = 'Featured image';
90
- img.style.cssText = 'max-width:100%;border-radius:.25rem;display:block;margin-bottom:.5rem;';
97
+ img.className = 'blog-image-preview';
91
98
  imagePreviewEl.appendChild(img);
92
- if (clearImageBtn) clearImageBtn.style.display = '';
99
+ if (clearImageBtn) clearImageBtn.classList.remove('blog-hidden');
93
100
  } else {
94
- if (clearImageBtn) clearImageBtn.style.display = 'none';
101
+ if (clearImageBtn) clearImageBtn.classList.add('blog-hidden');
95
102
  }
96
103
  }
97
104
 
@@ -99,14 +106,13 @@ export const postEditorView = {
99
106
  while (catsListEl.firstChild) catsListEl.removeChild(catsListEl.firstChild);
100
107
  if (categories.length === 0) {
101
108
  const p = document.createElement('p');
102
- p.className = 'text-muted small';
109
+ p.className = 'text-muted text-sm';
103
110
  p.textContent = 'No categories yet.';
104
111
  catsListEl.appendChild(p);
105
112
  } else {
106
113
  categories.forEach((cat) => {
107
114
  const label = document.createElement('label');
108
- label.className = 'd-flex align-items-center gap-2 mb-2';
109
- label.style.cursor = 'pointer';
115
+ label.className = 'flex items-center gap-2 mb-2 blog-clickable';
110
116
 
111
117
  const cb = document.createElement('input');
112
118
  cb.type = 'checkbox';
@@ -175,9 +181,9 @@ export const postEditorView = {
175
181
  if (slugAutoEl) {
176
182
  slugAutoEl.addEventListener('change', () => {
177
183
  if (!slugAutoEl.checked && isEdit && post?.status === 'published') {
178
- if (slugWarnEl) slugWarnEl.style.display = '';
184
+ if (slugWarnEl) slugWarnEl.classList.remove('blog-hidden');
179
185
  } else {
180
- if (slugWarnEl) slugWarnEl.style.display = 'none';
186
+ if (slugWarnEl) slugWarnEl.classList.add('blog-hidden');
181
187
  }
182
188
  if (slugAutoEl.checked && postTitleEl && postSlugEl) {
183
189
  postSlugEl.value = slugify(postTitleEl.value);
@@ -189,7 +195,7 @@ export const postEditorView = {
189
195
  const seoPanel = $container.find('#seo-panel').get(0);
190
196
  if (seoToggle && seoPanel) {
191
197
  seoToggle.addEventListener('click', () => {
192
- seoPanel.style.display = seoPanel.style.display === 'none' ? '' : 'none';
198
+ seoPanel.classList.toggle('blog-hidden');
193
199
  });
194
200
  }
195
201
 
@@ -223,15 +229,16 @@ export const postEditorView = {
223
229
 
224
230
  const scheduleBtn = $container.find('#btn-schedule').get(0);
225
231
  if (scheduleBtn) {
226
- scheduleBtn.style.display = '';
232
+ scheduleBtn.classList.remove('blog-hidden');
227
233
  scheduleBtn.addEventListener('click', async () => {
228
- if (scheduleEl) scheduleEl.style.display = scheduleEl.style.display === 'none' ? '' : 'none';
229
- if (scheduleEl && scheduleEl.style.display !== 'none') return;
234
+ // First click reveals the picker; the second one schedules.
235
+ if (scheduleEl) scheduleEl.classList.toggle('blog-hidden');
236
+ if (scheduleEl && !scheduleEl.classList.contains('blog-hidden')) return;
230
237
 
231
238
  const publishedAt = schedAtEl?.value;
232
239
  if (!publishedAt) {
233
240
  E.toast('Please select a scheduled date/time.', { type: 'error' });
234
- if (scheduleEl) scheduleEl.style.display = '';
241
+ if (scheduleEl) scheduleEl.classList.remove('blog-hidden');
235
242
  return;
236
243
  }
237
244
 
@@ -271,7 +278,7 @@ export const postEditorView = {
271
278
  }
272
279
 
273
280
  const grid = document.createElement('div');
274
- grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill, minmax(120px, 1fr));gap:.75rem;padding:.5rem;';
281
+ grid.className = 'blog-media-grid';
275
282
 
276
283
  if (!media.length) {
277
284
  const p = document.createElement('p');
@@ -282,14 +289,14 @@ export const postEditorView = {
282
289
  media.forEach((m) => {
283
290
  const tile = document.createElement('button');
284
291
  tile.type = 'button';
285
- tile.style.cssText = 'border:1px solid var(--dm-border,#333);background:transparent;border-radius:.25rem;padding:.25rem;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.25rem;';
292
+ tile.className = 'blog-media-tile';
286
293
  const img = document.createElement('img');
287
294
  img.src = m.url;
288
295
  img.alt = m.name;
289
- img.style.cssText = 'width:100%;height:90px;object-fit:cover;border-radius:.15rem;';
296
+
290
297
  const cap = document.createElement('span');
291
298
  cap.textContent = m.name;
292
- cap.style.cssText = 'font-size:.7rem;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;width:100%;';
299
+
293
300
  tile.appendChild(img);
294
301
  tile.appendChild(cap);
295
302
  tile.addEventListener('click', () => {
@@ -301,7 +308,19 @@ export const postEditorView = {
301
308
  });
302
309
  }
303
310
 
304
- const slideover = E.slideover({ title: 'Pick Featured Image', size: 'lg', position: 'right', content: grid });
311
+ // close() only hides the panel and destroy() tears the component down
312
+ // without detaching its element, so every trip to the media library
313
+ // used to leave another slideover in the document.
314
+ const slideover = E.slideover({
315
+ title: 'Pick Featured Image',
316
+ size: 'lg',
317
+ position: 'right',
318
+ content: grid,
319
+ onClose: () => setTimeout(() => {
320
+ slideover.destroy();
321
+ slideover.element?.remove();
322
+ }, 350)
323
+ });
305
324
  slideover.open();
306
325
  }
307
326
 
@@ -37,61 +37,63 @@ export const settingsView = {
37
37
  const formEl = $container.find('#settings-form').get(0);
38
38
  if (!formEl) return;
39
39
 
40
- F.create(formEl, {
41
- blueprint: [
42
- {
43
- key: 'basePath',
44
- label: 'Base path',
45
- type: 'text',
46
- placeholder: '/blog',
47
- value: settings.basePath ?? '/blog',
48
- hint: 'URL prefix for all blog pages (e.g. /blog).'
40
+ // F.create takes a SCHEMA, initial data and options, and renders with
41
+ // .renderTo() - it does not take a container. Passing the element as the
42
+ // first argument made it the schema, so this form rendered nothing at
43
+ // all. See admin/js/views/plugins.js for the house pattern.
44
+ const form = F.create(
45
+ {
46
+ basePath: {
47
+ type: 'string', label: 'Base path', default: '/blog',
48
+ // placeholder and hint live under formConfig - Forma reads
49
+ // fieldDef.formConfig, and a top-level `placeholder` or
50
+ // `help` is silently ignored.
51
+ formConfig: {placeholder: '/blog', hint: 'URL prefix for all blog pages (e.g. /blog).'}
49
52
  },
50
- {
51
- key: 'postsPerPage',
52
- label: 'Posts per page',
53
- type: 'number',
54
- placeholder: '10',
55
- value: settings.postsPerPage ?? 10,
56
- hint: 'Number of posts shown per listing page.'
53
+ postsPerPage: {
54
+ type: 'number', label: 'Posts per page', default: 10,
55
+ formConfig: {placeholder: '10', hint: 'Number of posts shown per listing page.'}
57
56
  },
58
- {
59
- key: 'commentsEnabled',
60
- label: 'Enable comments',
61
- type: 'toggle',
62
- value: settings.commentsEnabled ?? true,
63
- hint: 'Allow readers to submit comments on posts.'
57
+ commentsEnabled: {
58
+ type: 'boolean', label: 'Enable comments',
59
+ formConfig: {hint: 'Allow readers to submit comments on posts.'}
64
60
  },
65
- {
66
- key: 'commentModeration',
67
- label: 'Moderate comments',
68
- type: 'toggle',
69
- value: settings.commentModeration ?? true,
70
- hint: 'Hold new comments for approval before they appear.'
61
+ commentModeration: {
62
+ type: 'boolean', label: 'Moderate comments',
63
+ formConfig: {hint: 'Hold new comments for approval before they appear.'}
71
64
  },
72
- {
73
- key: 'feedLength',
74
- label: 'RSS feed length',
75
- type: 'number',
76
- placeholder: '20',
77
- value: settings.feedLength ?? 20,
78
- hint: 'Maximum number of items in the RSS/Atom feed.'
79
- }
80
- ],
81
- onSubmit: async (values) => {
82
- try {
83
- const res = await H.put(`${BASE}/settings`, {
84
- basePath: values.basePath,
85
- postsPerPage: Number(values.postsPerPage) || 10,
86
- commentsEnabled: Boolean(values.commentsEnabled),
87
- commentModeration: Boolean(values.commentModeration),
88
- feedLength: Number(values.feedLength) || 20
89
- });
90
- if (res.error) throw new Error(res.error);
91
- E.toast('Settings saved.', { type: 'success' });
92
- } catch (err) {
93
- E.toast(err?.message ?? 'Failed to save settings.', { type: 'error' });
65
+ feedLength: {
66
+ type: 'number', label: 'RSS feed length', default: 20,
67
+ formConfig: {placeholder: '20', hint: 'Maximum number of items in the RSS/Atom feed.'}
94
68
  }
69
+ },
70
+ {
71
+ basePath: settings.basePath ?? '/blog',
72
+ postsPerPage: settings.postsPerPage ?? 10,
73
+ commentsEnabled: settings.commentsEnabled ?? true,
74
+ commentModeration: settings.commentModeration ?? true,
75
+ feedLength: settings.feedLength ?? 20
76
+ },
77
+ { showSubmitButton: false }
78
+ );
79
+ form.renderTo(formEl);
80
+
81
+ const saveBtn = $container.find('#btn-save-settings').get(0);
82
+ saveBtn?.addEventListener('click', async () => {
83
+ if (!form.validate()) return;
84
+ const values = form.getData();
85
+ try {
86
+ const res = await H.put(`${BASE}/settings`, {
87
+ basePath: values.basePath,
88
+ postsPerPage: Number(values.postsPerPage) || 10,
89
+ commentsEnabled: Boolean(values.commentsEnabled),
90
+ commentModeration: Boolean(values.commentModeration),
91
+ feedLength: Number(values.feedLength) || 20
92
+ });
93
+ if (res.error) throw new Error(res.error);
94
+ E.toast('Settings saved.', { type: 'success' });
95
+ } catch (err) {
96
+ E.toast(err?.message ?? 'Failed to save settings.', { type: 'error' });
95
97
  }
96
98
  });
97
99
 
@@ -1,12 +1,13 @@
1
1
  {
2
- "name": "blog-categories",
3
- "label": "Blog Categories",
4
2
  "slug": "blog-categories",
3
+ "title": "Blog Categories",
4
+ "description": "Post categories managed by the Blog plugin.",
5
+ "plugin": "blog",
5
6
  "storage": { "adapter": "file" },
6
7
  "fields": [
7
- { "key": "name", "label": "Name", "type": "text", "required": true },
8
- { "key": "slug", "label": "Slug", "type": "text", "required": true },
9
- { "key": "description", "label": "Description", "type": "textarea", "required": false },
10
- { "key": "parentId", "label": "Parent", "type": "text", "required": false }
8
+ { "name": "name", "label": "Name", "type": "text", "required": true },
9
+ { "name": "slug", "label": "Slug", "type": "text", "required": true },
10
+ { "name": "description", "label": "Description", "type": "textarea", "required": false },
11
+ { "name": "parentId", "label": "Parent", "type": "text", "required": false }
11
12
  ]
12
13
  }
@@ -1,16 +1,17 @@
1
1
  {
2
- "name": "blog-comments",
3
- "label": "Blog Comments",
4
2
  "slug": "blog-comments",
3
+ "title": "Blog Comments",
4
+ "description": "Post comments managed by the Blog plugin.",
5
+ "plugin": "blog",
5
6
  "storage": { "adapter": "file" },
6
7
  "fields": [
7
- { "key": "postId", "label": "Post", "type": "text", "required": true },
8
- { "key": "parentId", "label": "Parent", "type": "text", "required": false },
9
- { "key": "authorName", "label": "Author Name", "type": "text", "required": true },
10
- { "key": "authorEmail", "label": "Author Email", "type": "text", "required": true },
11
- { "key": "authorUrl", "label": "Author URL", "type": "text", "required": false },
12
- { "key": "body", "label": "Body", "type": "textarea", "required": true },
13
- { "key": "status", "label": "Status", "type": "select", "required": true, "options": ["pending", "approved", "rejected", "spam"] },
14
- { "key": "createdAt", "label": "Created At", "type": "datetime", "required": false }
8
+ { "name": "postId", "label": "Post", "type": "text", "required": true },
9
+ { "name": "parentId", "label": "Parent", "type": "text", "required": false },
10
+ { "name": "authorName", "label": "Author Name", "type": "text", "required": true },
11
+ { "name": "authorEmail", "label": "Author Email", "type": "text", "required": true },
12
+ { "name": "authorUrl", "label": "Author URL", "type": "text", "required": false },
13
+ { "name": "body", "label": "Body", "type": "textarea", "required": true },
14
+ { "name": "status", "label": "Status", "type": "select", "required": true, "options": ["pending", "approved", "rejected", "spam"] },
15
+ { "name": "createdAt", "label": "Created At", "type": "datetime", "required": false }
15
16
  ]
16
17
  }
@@ -1,19 +1,20 @@
1
1
  {
2
- "name": "blog-posts",
3
- "label": "Blog Posts",
4
2
  "slug": "blog-posts",
3
+ "title": "Blog Posts",
4
+ "description": "Posts managed by the Blog plugin.",
5
+ "plugin": "blog",
5
6
  "storage": { "adapter": "file" },
6
7
  "fields": [
7
- { "key": "title", "label": "Title", "type": "text", "required": true },
8
- { "key": "slug", "label": "Slug", "type": "text", "required": true },
9
- { "key": "excerpt", "label": "Excerpt", "type": "textarea", "required": false },
10
- { "key": "content", "label": "Content", "type": "markdown", "required": false },
11
- { "key": "featuredImage", "label": "Featured Image", "type": "text", "required": false },
12
- { "key": "categories", "label": "Categories", "type": "array", "required": false },
13
- { "key": "tags", "label": "Tags", "type": "array", "required": false },
14
- { "key": "authorId", "label": "Author", "type": "text", "required": true },
15
- { "key": "status", "label": "Status", "type": "select", "required": true, "options": ["draft", "scheduled", "published"] },
16
- { "key": "publishedAt", "label": "Published At", "type": "datetime", "required": false },
17
- { "key": "seo", "label": "SEO", "type": "object", "required": false }
8
+ { "name": "title", "label": "Title", "type": "text", "required": true },
9
+ { "name": "slug", "label": "Slug", "type": "text", "required": true },
10
+ { "name": "excerpt", "label": "Excerpt", "type": "textarea", "required": false },
11
+ { "name": "content", "label": "Content", "type": "markdown", "required": false },
12
+ { "name": "featuredImage", "label": "Featured Image", "type": "text", "required": false },
13
+ { "name": "categories", "label": "Categories", "type": "array", "required": false },
14
+ { "name": "tags", "label": "Tags", "type": "array", "required": false },
15
+ { "name": "authorId", "label": "Author", "type": "text", "required": true },
16
+ { "name": "status", "label": "Status", "type": "select", "required": true, "options": ["draft", "scheduled", "published"] },
17
+ { "name": "publishedAt", "label": "Published At", "type": "datetime", "required": false },
18
+ { "name": "seo", "label": "SEO", "type": "object", "required": false }
18
19
  ]
19
20
  }
@@ -1,6 +1,6 @@
1
1
  import defaultConfig from './config.js';
2
2
  import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
3
- import {getConfig, saveConfig} from '../../server/config.js';
3
+ import {getPluginStates, savePluginState} from '../../server/services/plugins.js';
4
4
 
5
5
  const POSTS_SLUG = 'blog-posts';
6
6
  const CATEGORIES_SLUG = 'blog-categories';
@@ -217,12 +217,19 @@ export default async function blogPlugin(fastify, options) {
217
217
  categories, tags, status, slug
218
218
  } = body;
219
219
 
220
+ // The schema marks title, slug, authorId and status required, so an
221
+ // empty title reaches createEntry and throws - a 500 where the
222
+ // categories and comments routes both answer 400. Say so here instead.
223
+ if (!title || typeof title !== 'string' || !title.trim()) {
224
+ return reply.code(400).send({ error: 'title is required' });
225
+ }
226
+
220
227
  const derivedSlug = slug
221
228
  ? String(slug).trim()
222
- : slugify(title ?? '');
229
+ : slugify(title);
223
230
 
224
231
  const entry = await createEntry(POSTS_SLUG, {
225
- title: typeof title === 'string' ? title.trim() : '',
232
+ title: title.trim(),
226
233
  slug: derivedSlug,
227
234
  content: typeof content === 'string' ? content : '',
228
235
  excerpt: typeof excerpt === 'string' ? excerpt.trim() : '',
@@ -465,21 +472,37 @@ export default async function blogPlugin(fastify, options) {
465
472
  // Settings
466
473
  // -------------------------------------------------------------------------
467
474
 
475
+ // Plugin settings live in config/plugins.json under blog.settings, and the
476
+ // way to reach them is getPluginStates() / savePluginState(). These used to
477
+ // call getConfig() and saveConfig() from server/config.js, which take a FILE
478
+ // NAME: getConfig() with no argument read config/undefined.json and threw,
479
+ // so this endpoint answered 500 every time, and saveConfig(cfg) passed the
480
+ // whole config object as the name.
481
+
468
482
  /** GET /api/plugins/blog/settings */
469
483
  fastify.get('/settings', { preHandler: [authenticate] }, async (request, reply) => {
470
- const cfg = getConfig();
471
- const overrides = cfg.plugins?.blog?.settings ?? {};
472
- return reply.send({ ...defaultConfig, ...overrides });
484
+ const stored = getPluginStates()?.blog?.settings ?? {};
485
+ return reply.send({ ...defaultConfig, ...stored });
473
486
  });
474
487
 
475
488
  /** PUT /api/plugins/blog/settings */
476
- fastify.put('/settings', { preHandler: [requireAdmin] }, async (request, reply) => {
489
+ // requireAdmin reads request.user, which only `authenticate` populates -
490
+ // on its own it answers 401 to everyone, admin included.
491
+ fastify.put('/settings', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
477
492
  const body = request.body ?? {};
478
- const cfg = getConfig();
479
- cfg.plugins ??= {};
480
- cfg.plugins.blog ??= {};
481
- cfg.plugins.blog.settings = body;
482
- await saveConfig(cfg);
483
- return reply.send({ ok: true });
493
+
494
+ // Whitelist rather than merge: every key here is read back by the
495
+ // public routes, and storing whatever was posted invites surprises.
496
+ const merged = {
497
+ ...defaultConfig,
498
+ ...(body.basePath !== undefined && { basePath: String(body.basePath) }),
499
+ ...(body.postsPerPage !== undefined && { postsPerPage: Number(body.postsPerPage) || defaultConfig.postsPerPage }),
500
+ ...(body.commentsEnabled !== undefined && { commentsEnabled: Boolean(body.commentsEnabled) }),
501
+ ...(body.commentModeration !== undefined && { commentModeration: Boolean(body.commentModeration) }),
502
+ ...(body.feedLength !== undefined && { feedLength: Number(body.feedLength) || defaultConfig.feedLength })
503
+ };
504
+
505
+ savePluginState('blog', { settings: merged });
506
+ return reply.send({ ok: true, settings: merged });
484
507
  });
485
508
  }
@@ -7,6 +7,9 @@
7
7
  "date": "2026-04-07",
8
8
  "icon": "file-text",
9
9
  "admin": {
10
+ "css": [
11
+ "admin/css/index.css"
12
+ ],
10
13
  "sidebar": [
11
14
  {
12
15
  "id": "blog",
@@ -39,6 +42,11 @@
39
42
  }
40
43
  ],
41
44
  "routes": [
45
+ {
46
+ "path": "/plugins/blog",
47
+ "view": "plugin-blog-posts",
48
+ "title": "Blog - Domma CMS"
49
+ },
42
50
  {
43
51
  "path": "/plugins/blog/posts",
44
52
  "view": "plugin-blog-posts",
@@ -72,23 +80,23 @@
72
80
  ],
73
81
  "views": {
74
82
  "plugin-blog-posts": {
75
- "entry": "blog/admin/views/blog.js?v=da5c1a46",
83
+ "entry": "blog/admin/views/blog.js?v=01f8b54e",
76
84
  "exportName": "blogView"
77
85
  },
78
86
  "plugin-blog-post-editor": {
79
- "entry": "blog/admin/views/post-editor.js?v=d4d7a514",
87
+ "entry": "blog/admin/views/post-editor.js?v=58c616ec",
80
88
  "exportName": "postEditorView"
81
89
  },
82
90
  "plugin-blog-categories": {
83
- "entry": "blog/admin/views/categories.js?v=7e3946ca",
91
+ "entry": "blog/admin/views/categories.js?v=17f4038c",
84
92
  "exportName": "categoriesView"
85
93
  },
86
94
  "plugin-blog-comments": {
87
- "entry": "blog/admin/views/comments.js?v=ee2146e9",
95
+ "entry": "blog/admin/views/comments.js?v=55dd8ad9",
88
96
  "exportName": "commentsView"
89
97
  },
90
98
  "plugin-blog-settings": {
91
- "entry": "blog/admin/views/settings.js?v=3fab4235",
99
+ "entry": "blog/admin/views/settings.js?v=bd2310f3",
92
100
  "exportName": "settingsView"
93
101
  }
94
102
  }