domma-cms 0.55.1 → 0.69.3

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 (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -14,12 +14,169 @@ function slugify(s) {
14
14
 
15
15
  const BASE = '/api/plugins/blog';
16
16
 
17
+ // The loader's cache-busting token, passed on so helpers are refetched with this view.
18
+ const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
19
+
20
+ /** One layout choice - the body of the picker's data-each (see mountLayoutCard). */
21
+ const LAYOUT_CHOICE = `
22
+ <button type="button" class="blog-layout {{cls}}" role="radio" aria-checked="{{checked}}" data-layout="{{id}}" title="{{description}}">
23
+ <span class="blog-thumb" data-thumb="{{thumb}}" aria-hidden="true"></span>
24
+ <span class="blog-layout-name">{{label}}</span>
25
+ </button>`;
26
+
27
+ const PREVIEW_PANEL = `
28
+ <div class="blog-preview">
29
+ <div class="blog-preview-bar">
30
+ <select class="form-select form-select-sm" aria-label="Layout" data-model="layout.value"
31
+ data-options="options.value" data-options-text="label" data-options-value="id"></select>
32
+ <div class="blog-seg" role="group" aria-label="Width">
33
+ <button type="button" data-on-click="desktop" data-bind-class="width.value === 'desktop' && 'is-on'" title="Desktop">
34
+ <span data-icon="monitor" data-icon-size="14"></span></button>
35
+ <button type="button" data-on-click="tablet" data-bind-class="width.value === 'tablet' && 'is-on'" title="Tablet">
36
+ <span data-icon="tablet" data-icon-size="14"></span></button>
37
+ <button type="button" data-on-click="phone" data-bind-class="width.value === 'phone' && 'is-on'" title="Phone">
38
+ <span data-icon="smartphone" data-icon-size="14"></span></button>
39
+ </div>
40
+ <span data-help-title="Preview"
41
+ data-help="The post as it will look on the site, from what is in the editor now - nothing is saved. Try another layout here; Use this layout keeps it for the post. Links are switched off inside the preview."></span>
42
+ <span class="blog-spacer"></span>
43
+ <span class="blog-preview-state" data-bind-text="state.value"></span>
44
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="use" data-bind-disabled="!changed.value">Use this layout</button>
45
+ </div>
46
+ <div class="blog-preview-stage" data-bind-class="'is-' + width.value">
47
+ <iframe title="Post preview"></iframe>
48
+ </div>
49
+ </div>`;
50
+
51
+ /**
52
+ * The Layout card: a picker over every post layout on offer (the shipped four
53
+ * and any blog-post-* block the site added), and a Preview panel that renders
54
+ * the UNSAVED post in any of them.
55
+ *
56
+ * domma-reactive, like the rest of the Tool upgrade path; the item template is
57
+ * inserted here because the router renders the page template through _.render,
58
+ * which eats {{…}}.
59
+ *
60
+ * @param {HTMLElement} card
61
+ * @param {{current: string, collect: () => object}} opts
62
+ * @returns {Promise<{value: () => string}>}
63
+ */
64
+ async function mountLayoutCard(card, {current, collect}) {
65
+ const BASE_URL = '/api/plugins/blog';
66
+ let catalogue = {postLayouts: [], postLayout: 'blog-post-classic'};
67
+ try { catalogue = await H.get(`${BASE_URL}/layouts`); } catch { /* the card still offers Site default */ }
68
+ const known = catalogue.postLayouts ?? [];
69
+ const defaultLabel = known.find(l => l.id === catalogue.postLayout)?.label ?? 'Classic';
70
+ const thumbOf = (id) => (['blog-post-classic', 'blog-post-feature', 'blog-post-minimal', 'blog-post-sidebar'].includes(id) ? id.slice(10) : 'custom');
71
+
72
+ const choices = [
73
+ {id: '', label: 'Site default', description: `Follows Blog settings - currently ${defaultLabel}.`, thumb: thumbOf(catalogue.postLayout)},
74
+ ...known.map(l => ({id: l.id, label: l.label, description: l.description, thumb: thumbOf(l.id)})),
75
+ ...(current && !known.some(l => l.id === current) ? [{id: current, label: `${current} (missing)`, description: 'This block no longer exists; the site default is used.', thumb: 'custom'}] : [])
76
+ ];
77
+
78
+ const layout = M.observable(current || '');
79
+ const layoutChoices = M.observable([]);
80
+ M.effect(() => {
81
+ layoutChoices.value = choices.map(c => ({...c, id: c.id || 'default', checked: String(c.id === layout.value), cls: c.id === layout.value ? 'is-on' : ''}));
82
+ });
83
+ const data = {
84
+ layoutChoices,
85
+ layoutDesc: M.computed(() => choices.find(c => c.id === layout.value)?.description ?? '')
86
+ };
87
+
88
+ card.querySelector('.blog-layouts').innerHTML = LAYOUT_CHOICE;
89
+ M.applyBindings(data, card.querySelector('.card-body'));
90
+ card.querySelector('.blog-layouts').addEventListener('click', (e) => {
91
+ const b = e.target.closest('[data-layout]');
92
+ if (b) layout.value = b.dataset.layout === 'default' ? '' : b.dataset.layout;
93
+ });
94
+ card.querySelector('#btn-preview').addEventListener('click', () => openPreview());
95
+
96
+ async function openPreview() {
97
+ const kit = await import('./kit.js?v=' + (new URL(import.meta.url).searchParams.get('v') ?? '1'));
98
+ const trial = M.observable(layout.peek() || catalogue.postLayout || 'blog-post-classic');
99
+ const width = M.observable('desktop');
100
+ const state = M.observable('');
101
+ const pdata = {
102
+ layout: trial, width, state,
103
+ options: M.observable(known.map(l => ({id: l.id, label: l.label}))),
104
+ changed: M.computed(() => trial.value !== (layout.value || catalogue.postLayout))
105
+ };
106
+ const so = E.slideover({title: 'Preview', size: 'xl', position: 'right'});
107
+ const el = kit.fragment(PREVIEW_PANEL);
108
+ so.element.appendChild(el);
109
+ const disposeHelp = await kit.scanHelp(el);
110
+ const frame = el.querySelector('iframe');
111
+ const handle = M.applyBindings(pdata, el, {
112
+ methods: {
113
+ desktop: () => { width.value = 'desktop'; },
114
+ tablet: () => { width.value = 'tablet'; },
115
+ phone: () => { width.value = 'phone'; },
116
+ use() {
117
+ layout.value = trial.peek() === catalogue.postLayout && !layout.peek() ? '' : trial.peek();
118
+ E.toast('Layout chosen - Save to keep it.', {type: 'success'});
119
+ }
120
+ }
121
+ });
122
+ if (typeof I !== 'undefined' && I.scan) I.scan(el);
123
+
124
+ let seq = 0;
125
+ const stop = M.effect(async () => {
126
+ const chosen = trial.value;
127
+ const mine = ++seq;
128
+ state.value = 'Rendering…';
129
+ try {
130
+ const res = await H.post(`${BASE_URL}/preview`, {...collect(), layout: chosen});
131
+ if (mine !== seq) return;
132
+ if (res?.error) throw new Error(res.error);
133
+ // The page's own addresses (/dist/…, /plugins/…) resolve against the site.
134
+ frame.srcdoc = String(res.html).replace(/<head>/i, `<head><base href="${location.origin}/">`);
135
+ state.value = '';
136
+ } catch (err) {
137
+ if (mine === seq) state.value = err?.message ?? 'Preview failed.';
138
+ }
139
+ });
140
+ // Links inside the preview would navigate the frame away from the post.
141
+ frame.addEventListener('load', () => {
142
+ try {
143
+ frame.contentDocument.addEventListener('click', (e) => {
144
+ if (e.target.closest('a[href]') && !e.target.closest('a[href^="#"]')) {
145
+ e.preventDefault();
146
+ E.toast('Links are switched off in the preview.', {type: 'info'});
147
+ }
148
+ }, true);
149
+ } catch { /* not ours to reach */ }
150
+ });
151
+ kit.onceClosed(so, () => { stop?.(); handle.dispose(); disposeHelp(); });
152
+ so.open();
153
+ }
154
+
155
+ return {value: () => layout.peek()};
156
+ }
157
+
17
158
  export const postEditorView = {
18
159
  templateUrl: '/plugins/blog/admin/templates/post-editor.html',
19
160
 
20
161
  async onMount($container) {
21
- const id = R.current?.params?.id !== 'new' ? R.current?.params?.id : null;
22
- const isEdit = Boolean(id);
162
+ // Help (?) popovers, loaded at run time - a plugin must not import admin/.
163
+ // Scanned now, before anything below reshapes the form.
164
+ import('/admin/js/lib/help-popover.js')
165
+ .then(m => m.helpPopovers?.($container, {position: 'bottom'}))
166
+ .catch(() => null);
167
+ import(`./settings-cog.js?v=${ASSET_VERSION}`)
168
+ .then(m => m.addSettingsCog($container, '.view-header-actions'))
169
+ .catch(() => { /* no cog is better than no screen */ });
170
+ // Domma's router hands onMount ONE argument and never the route params,
171
+ // and `R.current` is a METHOD - `R.current?.params` is undefined on the
172
+ // function object, so this view opened blank on every edit and Save
173
+ // created a duplicate. Read the slug off the hash, as every core editor
174
+ // view does.
175
+ const routeMatch = location.hash.match(/^#\/plugins\/blog\/posts\/([^?#]+)/);
176
+ const routeId = routeMatch ? decodeURIComponent(routeMatch[1]) : null;
177
+ // `let`: a new post becomes an existing one on its first Save.
178
+ let id = routeId && routeId !== 'new' ? routeId : null;
179
+ let isEdit = Boolean(id);
23
180
 
24
181
  let post = null;
25
182
  let categories = [];
@@ -40,7 +197,7 @@ export const postEditorView = {
40
197
  post = res.data ?? res;
41
198
  } catch {
42
199
  E.toast('Failed to load post.', { type: 'error' });
43
- R.navigate('#/plugins/blog');
200
+ R.navigate('#/plugins/blog/posts');
44
201
  return;
45
202
  }
46
203
  }
@@ -62,7 +219,7 @@ export const postEditorView = {
62
219
  const imagePreviewEl = $container.find('#featured-image-preview').get(0);
63
220
  const clearImageBtn = $container.find('#btn-clear-image').get(0);
64
221
 
65
- if (titleEl) titleEl.textContent = isEdit ? 'Edit Post' : 'New Post';
222
+ if (titleEl) titleEl.textContent = isEdit ? 'Editing post' : 'New post';
66
223
 
67
224
  if (isEdit && post) {
68
225
  if (postTitleEl) postTitleEl.value = post.title ?? '';
@@ -87,11 +244,11 @@ export const postEditorView = {
87
244
  const img = document.createElement('img');
88
245
  img.src = featuredImageUrl;
89
246
  img.alt = 'Featured image';
90
- img.style.cssText = 'max-width:100%;border-radius:.25rem;display:block;margin-bottom:.5rem;';
247
+ img.className = 'blog-image-preview';
91
248
  imagePreviewEl.appendChild(img);
92
- if (clearImageBtn) clearImageBtn.style.display = '';
249
+ if (clearImageBtn) clearImageBtn.classList.remove('blog-hidden');
93
250
  } else {
94
- if (clearImageBtn) clearImageBtn.style.display = 'none';
251
+ if (clearImageBtn) clearImageBtn.classList.add('blog-hidden');
95
252
  }
96
253
  }
97
254
 
@@ -99,14 +256,13 @@ export const postEditorView = {
99
256
  while (catsListEl.firstChild) catsListEl.removeChild(catsListEl.firstChild);
100
257
  if (categories.length === 0) {
101
258
  const p = document.createElement('p');
102
- p.className = 'text-muted small';
259
+ p.className = 'text-muted text-sm';
103
260
  p.textContent = 'No categories yet.';
104
261
  catsListEl.appendChild(p);
105
262
  } else {
106
263
  categories.forEach((cat) => {
107
264
  const label = document.createElement('label');
108
- label.className = 'd-flex align-items-center gap-2 mb-2';
109
- label.style.cursor = 'pointer';
265
+ label.className = 'flex items-center gap-2 mb-2 blog-clickable';
110
266
 
111
267
  const cb = document.createElement('input');
112
268
  cb.type = 'checkbox';
@@ -130,8 +286,14 @@ export const postEditorView = {
130
286
  }
131
287
  }
132
288
 
289
+ const layoutCard = await mountLayoutCard($container.find('#layout-card').get(0), {
290
+ current: post?.template ?? '',
291
+ collect: () => ({...collectValues(), id})
292
+ });
293
+
133
294
  function collectValues() {
134
295
  return {
296
+ template: layoutCard.value(),
135
297
  title: postTitleEl?.value?.trim() ?? '',
136
298
  slug: postSlugEl?.value?.trim() ?? '',
137
299
  excerpt: excerptEl?.value?.trim() ?? '',
@@ -150,6 +312,7 @@ export const postEditorView = {
150
312
  const body = { ...collectValues(), ...extra };
151
313
  if (!body.title) {
152
314
  E.toast('Title is required.', { type: 'error' });
315
+ postTitleEl?.focus();
153
316
  return null;
154
317
  }
155
318
  try {
@@ -157,13 +320,107 @@ export const postEditorView = {
157
320
  ? await H.put(`${BASE}/posts/${id}`, body)
158
321
  : await H.post(`${BASE}/posts`, body);
159
322
  if (res.error) throw new Error(res.error);
160
- return res.data ?? res;
323
+ const saved = res.data ?? res;
324
+ // A new post is an existing one from here on: later saves
325
+ // update it, and the address says which post this is (no
326
+ // hashchange, so the view is not re-mounted).
327
+ if (!isEdit && saved?.id) {
328
+ id = saved.id;
329
+ isEdit = true;
330
+ history.replaceState(history.state, '', `#/plugins/blog/posts/${encodeURIComponent(id)}`);
331
+ if (titleEl) titleEl.textContent = 'Editing post';
332
+ }
333
+ post = saved;
334
+ baseline = JSON.stringify(collectValues());
335
+ setStatus(saved.status ?? status);
336
+ showSaved();
337
+ return saved;
161
338
  } catch (err) {
162
339
  E.toast(err?.message ?? 'Failed to save post.', { type: 'error' });
163
340
  return null;
164
341
  }
165
342
  }
166
343
 
344
+ // ---- Status, and whether anything is unsaved ----
345
+ let status = post?.status ?? 'draft';
346
+ let baseline = JSON.stringify(collectValues());
347
+ let savedAt = null;
348
+ const saveStateEl = $container.find('#save-state').get(0);
349
+ const draftsBtn = $container.find('#btn-drafts').get(0);
350
+ const isDirty = () => JSON.stringify(collectValues()) !== baseline;
351
+
352
+ function showSaved() {
353
+ savedAt = new Date();
354
+ refreshState();
355
+ }
356
+ function refreshState() {
357
+ if (!saveStateEl) return;
358
+ const dirty = isDirty();
359
+ saveStateEl.classList.toggle('is-dirty', dirty);
360
+ saveStateEl.textContent = dirty
361
+ ? 'Unsaved changes'
362
+ : savedAt ? `Saved ${savedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : '';
363
+ }
364
+ function setStatus(next) {
365
+ status = next || 'draft';
366
+ if (statusBadge) {
367
+ statusBadge.textContent = status;
368
+ statusBadge.className = `badge ${({ published: 'badge-success', scheduled: 'badge-warning' })[status] ?? 'badge-secondary'}`;
369
+ }
370
+ const live = status === 'published';
371
+ $container.find('#btn-publish').get(0)?.classList.toggle('blog-hidden', live);
372
+ $container.find('#btn-schedule').get(0)?.classList.toggle('blog-hidden', live);
373
+ draftsBtn?.classList.toggle('blog-hidden', status === 'draft');
374
+ }
375
+ setStatus(status);
376
+
377
+ // Anything typed, ticked or picked may change what would be saved.
378
+ let queued = false;
379
+ const recheck = () => {
380
+ if (queued) return;
381
+ queued = true;
382
+ queueMicrotask(() => { queued = false; refreshState(); });
383
+ };
384
+ ['input', 'change', 'click'].forEach(ev => $container.get(0).addEventListener(ev, recheck));
385
+
386
+ async function save() {
387
+ if (await doSave()) E.toast(status === 'published' ? 'Saved - the live post is updated.' : 'Saved.', { type: 'success' });
388
+ }
389
+
390
+ async function goBack() {
391
+ if (isDirty() && !(await E.confirm('Leave without saving? Your changes to this post will be lost.'))) return;
392
+ baseline = JSON.stringify(collectValues()); // do not ask twice (beforeunload)
393
+ R.navigate('#/plugins/blog/posts');
394
+ }
395
+
396
+ $container.find('#btn-save').get(0)?.addEventListener('click', save);
397
+ $container.find('#btn-back').get(0)?.addEventListener('click', goBack);
398
+ draftsBtn?.addEventListener('click', async () => {
399
+ const saved = await doSave({ status: 'draft' });
400
+ if (saved) E.toast('Moved to drafts - it is no longer on the site.', { type: 'success' });
401
+ });
402
+
403
+ // Ctrl/Cmd+S saves; a closing tab with unsaved work asks first. Both
404
+ // come off when the hash leaves this editor (the router has no unmount).
405
+ const onKey = (e) => {
406
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
407
+ e.preventDefault();
408
+ save();
409
+ }
410
+ };
411
+ const onUnload = (e) => {
412
+ if (isDirty()) { e.preventDefault(); e.returnValue = ''; }
413
+ };
414
+ document.addEventListener('keydown', onKey);
415
+ window.addEventListener('beforeunload', onUnload);
416
+ const onHash = () => {
417
+ if (/^#\/plugins\/blog\/posts\/[^/]+$/.test(location.hash)) return;
418
+ document.removeEventListener('keydown', onKey);
419
+ window.removeEventListener('beforeunload', onUnload);
420
+ window.removeEventListener('hashchange', onHash);
421
+ };
422
+ window.addEventListener('hashchange', onHash);
423
+
167
424
  if (postTitleEl) {
168
425
  postTitleEl.addEventListener('input', () => {
169
426
  if (slugAutoEl?.checked && postSlugEl) {
@@ -175,9 +432,9 @@ export const postEditorView = {
175
432
  if (slugAutoEl) {
176
433
  slugAutoEl.addEventListener('change', () => {
177
434
  if (!slugAutoEl.checked && isEdit && post?.status === 'published') {
178
- if (slugWarnEl) slugWarnEl.style.display = '';
435
+ if (slugWarnEl) slugWarnEl.classList.remove('blog-hidden');
179
436
  } else {
180
- if (slugWarnEl) slugWarnEl.style.display = 'none';
437
+ if (slugWarnEl) slugWarnEl.classList.add('blog-hidden');
181
438
  }
182
439
  if (slugAutoEl.checked && postTitleEl && postSlugEl) {
183
440
  postSlugEl.value = slugify(postTitleEl.value);
@@ -189,18 +446,7 @@ export const postEditorView = {
189
446
  const seoPanel = $container.find('#seo-panel').get(0);
190
447
  if (seoToggle && seoPanel) {
191
448
  seoToggle.addEventListener('click', () => {
192
- seoPanel.style.display = seoPanel.style.display === 'none' ? '' : 'none';
193
- });
194
- }
195
-
196
- const saveDraftBtn = $container.find('#btn-save-draft').get(0);
197
- if (saveDraftBtn) {
198
- saveDraftBtn.addEventListener('click', async () => {
199
- const saved = await doSave({ status: 'draft' });
200
- if (saved) {
201
- E.toast('Draft saved.', { type: 'success' });
202
- R.navigate('#/plugins/blog/posts');
203
- }
449
+ seoPanel.classList.toggle('blog-hidden');
204
450
  });
205
451
  }
206
452
 
@@ -211,10 +457,11 @@ export const postEditorView = {
211
457
  if (!saved) return;
212
458
  const savedId = saved.id ?? id;
213
459
  try {
214
- const res = await H.post(`${BASE}/posts/${savedId}/publish`);
460
+ const res = await H.post(`${BASE}/posts/${savedId}/publish`, {});
215
461
  if (res.error) throw new Error(res.error);
216
- E.toast('Post published.', { type: 'success' });
217
- R.navigate('#/plugins/blog/posts');
462
+ post = res.data ?? res;
463
+ setStatus('published');
464
+ E.toast('Published - the post is live.', { type: 'success' });
218
465
  } catch (err) {
219
466
  E.toast(err?.message ?? 'Failed to publish post.', { type: 'error' });
220
467
  }
@@ -223,15 +470,15 @@ export const postEditorView = {
223
470
 
224
471
  const scheduleBtn = $container.find('#btn-schedule').get(0);
225
472
  if (scheduleBtn) {
226
- scheduleBtn.style.display = '';
227
473
  scheduleBtn.addEventListener('click', async () => {
228
- if (scheduleEl) scheduleEl.style.display = scheduleEl.style.display === 'none' ? '' : 'none';
229
- if (scheduleEl && scheduleEl.style.display !== 'none') return;
474
+ // First click reveals the picker; the second one schedules.
475
+ if (scheduleEl) scheduleEl.classList.toggle('blog-hidden');
476
+ if (scheduleEl && !scheduleEl.classList.contains('blog-hidden')) return;
230
477
 
231
478
  const publishedAt = schedAtEl?.value;
232
479
  if (!publishedAt) {
233
480
  E.toast('Please select a scheduled date/time.', { type: 'error' });
234
- if (scheduleEl) scheduleEl.style.display = '';
481
+ if (scheduleEl) scheduleEl.classList.remove('blog-hidden');
235
482
  return;
236
483
  }
237
484
 
@@ -241,8 +488,10 @@ export const postEditorView = {
241
488
  try {
242
489
  const res = await H.post(`${BASE}/posts/${savedId}/schedule`, { publishedAt });
243
490
  if (res.error) throw new Error(res.error);
244
- E.toast('Post scheduled.', { type: 'success' });
245
- R.navigate('#/plugins/blog/posts');
491
+ post = res.data ?? res;
492
+ setStatus('scheduled');
493
+ if (scheduleEl) scheduleEl.classList.add('blog-hidden');
494
+ E.toast(`Scheduled for ${new Date(publishedAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}.`, { type: 'success' });
246
495
  } catch (err) {
247
496
  E.toast(err?.message ?? 'Failed to schedule post.', { type: 'error' });
248
497
  }
@@ -271,7 +520,7 @@ export const postEditorView = {
271
520
  }
272
521
 
273
522
  const grid = document.createElement('div');
274
- grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill, minmax(120px, 1fr));gap:.75rem;padding:.5rem;';
523
+ grid.className = 'blog-media-grid';
275
524
 
276
525
  if (!media.length) {
277
526
  const p = document.createElement('p');
@@ -282,14 +531,14 @@ export const postEditorView = {
282
531
  media.forEach((m) => {
283
532
  const tile = document.createElement('button');
284
533
  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;';
534
+ tile.className = 'blog-media-tile';
286
535
  const img = document.createElement('img');
287
536
  img.src = m.url;
288
537
  img.alt = m.name;
289
- img.style.cssText = 'width:100%;height:90px;object-fit:cover;border-radius:.15rem;';
538
+
290
539
  const cap = document.createElement('span');
291
540
  cap.textContent = m.name;
292
- cap.style.cssText = 'font-size:.7rem;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;width:100%;';
541
+
293
542
  tile.appendChild(img);
294
543
  tile.appendChild(cap);
295
544
  tile.addEventListener('click', () => {
@@ -301,10 +550,28 @@ export const postEditorView = {
301
550
  });
302
551
  }
303
552
 
304
- const slideover = E.slideover({ title: 'Pick Featured Image', size: 'lg', position: 'right', content: grid });
553
+ // close() only hides the panel and destroy() tears the component down
554
+ // without detaching its element, so every trip to the media library
555
+ // used to leave another slideover in the document.
556
+ const slideover = E.slideover({
557
+ title: 'Pick Featured Image',
558
+ size: 'lg',
559
+ position: 'right',
560
+ content: grid,
561
+ onClose: () => setTimeout(() => {
562
+ slideover.destroy();
563
+ slideover.element?.remove();
564
+ }, 350)
565
+ });
305
566
  slideover.open();
306
567
  }
307
568
 
569
+ // Again at the end: the plugin chrome rebuilds the header after the
570
+ // scan at the top, so its (?) would otherwise never appear. Markers
571
+ // already converted are skipped.
572
+ import('/admin/js/lib/help-popover.js')
573
+ .then(m => m.helpPopovers?.($container, {position: 'bottom'}))
574
+ .catch(() => null);
308
575
  Domma.icons.scan();
309
576
  }
310
577
  };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The settings cog in the admin's plugin banner, to the left of the licence.
3
+ * It opens the settings in a (resizable) slideover rather than leaving the
4
+ * screen; #/plugins/blog/settings still works for anything that links there.
5
+ *
6
+ * Every plugin puts its settings in the same place, so it is not a sidebar item
7
+ * here any more. The banner belongs to the admin, and addBannerAction() is its
8
+ * door - imported at RUN time, because a static import would take the view
9
+ * down on a CMS whose admin predates it. There, the cog goes at the end of the
10
+ * view's own header row instead.
11
+ *
12
+ * @module blog/admin/views/settings-cog
13
+ */
14
+
15
+ // The loader's cache-busting token, passed on so the kit is refetched with this file.
16
+ const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
17
+
18
+ /** Settings open in a slideover over whichever Blog screen you are on. */
19
+ async function openSettings() {
20
+ try {
21
+ const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
22
+ await kit.openSettings();
23
+ } catch {
24
+ // The slideover is the better place, not the only one.
25
+ R.navigate('#/plugins/blog/settings');
26
+ }
27
+ }
28
+
29
+ /**
30
+ * @param {object} $container - the view container onMount was given
31
+ * @param {string} fallback - selector for the header row to use without a banner
32
+ */
33
+ export async function addSettingsCog($container, fallback) {
34
+ const onClick = () => openSettings();
35
+ try {
36
+ const chrome = await import('/admin/js/lib/plugin-chrome.js');
37
+ const placed = chrome.addBannerAction?.($container, {
38
+ id: 'blog-settings', icon: 'settings', title: 'Blog settings', onClick
39
+ });
40
+ if (placed) return;
41
+ } catch { /* older admin: fall through */ }
42
+
43
+ const row = $container.find(fallback).get(0);
44
+ if (!row) return;
45
+ const cog = document.createElement('button');
46
+ cog.type = 'button';
47
+ cog.className = 'btn btn-sm btn-ghost';
48
+ cog.title = 'Settings';
49
+ const glyph = document.createElement('span');
50
+ glyph.setAttribute('data-icon', 'settings');
51
+ glyph.setAttribute('data-icon-size', '16');
52
+ cog.appendChild(glyph);
53
+ cog.addEventListener('click', onClick);
54
+ row.appendChild(cog);
55
+ if (typeof I !== 'undefined' && I.scan) I.scan(cog);
56
+ }
@@ -1,100 +1,32 @@
1
1
  /**
2
- * Blog settings admin view.
2
+ * Blog settings as a page - the old #/plugins/blog/settings route.
3
+ *
4
+ * Settings live behind the banner cog now, in a slideover over whichever Blog
5
+ * screen you are on. This route stays so a bookmark or a link from elsewhere
6
+ * still lands somewhere useful: it renders the SAME panel (kit.settingsPanel),
7
+ * so the page and the slideover cannot drift apart.
3
8
  *
4
9
  * @module blog/admin/views/settings
5
10
  */
6
11
 
7
- const BASE = '/api/plugins/blog';
12
+ // The loader's cache-busting token, passed on so the kit is refetched with this view.
13
+ const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
8
14
 
9
15
  export const settingsView = {
10
16
  templateUrl: '/plugins/blog/admin/templates/settings.html',
11
17
 
12
18
  /**
13
- * Mount the blog settings view.
14
- *
15
19
  * @param {object} $container - Domma-wrapped container element
16
20
  * @returns {Promise<void>}
17
21
  */
18
22
  async onMount($container) {
19
- let settings = {};
20
-
21
- // ---------------------------------------------------------------
22
- // Load
23
- // ---------------------------------------------------------------
24
-
25
- try {
26
- const res = await H.get(`${BASE}/settings`);
27
- if (res.error) throw new Error(res.error);
28
- settings = res.data ?? res ?? {};
29
- } catch {
30
- E.toast('Failed to load settings.', { type: 'error' });
31
- }
32
-
33
- // ---------------------------------------------------------------
34
- // Render form using F.create
35
- // ---------------------------------------------------------------
36
-
37
- const formEl = $container.find('#settings-form').get(0);
38
- if (!formEl) return;
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).'
49
- },
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.'
57
- },
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.'
64
- },
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.'
71
- },
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' });
94
- }
95
- }
96
- });
97
-
98
- Domma.icons.scan();
23
+ const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
24
+ const add = kit.viewScope('settings', /^#\/plugins\/blog\/settings\b/);
25
+
26
+ const host = $container.find('#settings-form').get(0);
27
+ if (!host) return;
28
+ const {el, dispose} = await kit.settingsPanel();
29
+ host.appendChild(el);
30
+ add(dispose);
99
31
  }
100
32
  };