domma-cms 0.66.0 → 0.69.4

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 +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +94 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +310 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -0,0 +1,372 @@
1
+ /**
2
+ * What the Blog screens share: the settings panel, and the small pieces every
3
+ * view on the Tool upgrade path needs (help popovers, teardown, slideover
4
+ * cleanup, a ⋮ that opens the row's context menu).
5
+ *
6
+ * Loaded by the views at run time with the loader's cache-busting token, the
7
+ * way settings-cog.js is, so an update is never served a stale copy.
8
+ *
9
+ * @module blog/admin/views/kit
10
+ */
11
+
12
+ export const BASE = '/api/plugins/blog';
13
+
14
+ export function escapeHtml(s) {
15
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
16
+ }
17
+
18
+ /**
19
+ * The blog's public base path (`/blog` unless configured), for links to the
20
+ * live site. Read once per page load; a change needs a restart anyway.
21
+ *
22
+ * @returns {Promise<string>}
23
+ */
24
+ let basePathCache = null;
25
+ export async function sitePath() {
26
+ if (basePathCache) return basePathCache;
27
+ try {
28
+ const s = await H.get(`${BASE}/settings`);
29
+ basePathCache = String((s?.data ?? s)?.basePath || '/blog').replace(/\/+$/, '') || '/blog';
30
+ } catch {
31
+ return '/blog';
32
+ }
33
+ return basePathCache;
34
+ }
35
+
36
+ /**
37
+ * A shared admin helper, loaded at run time - a plugin must not import admin/
38
+ * statically, because an admin that predates the helper would take the whole
39
+ * view down with it. Null when it is not there.
40
+ *
41
+ * @param {string} file - e.g. 'help-popover.js'
42
+ * @returns {Promise<object|null>}
43
+ */
44
+ export async function adminLib(file) {
45
+ try { return await import(`/admin/js/lib/${file}`); } catch { return null; }
46
+ }
47
+
48
+ /**
49
+ * Give every `[data-help]` under `el` its (?) popover. Run it BEFORE
50
+ * M.applyBindings: a `data-if` lifts its element out of the document, and
51
+ * a marker scanned after that is never found.
52
+ *
53
+ * @param {HTMLElement} el
54
+ * @returns {Promise<() => void>} dispose
55
+ */
56
+ export async function scanHelp(el) {
57
+ const lib = await adminLib('help-popover.js');
58
+ return lib?.helpPopovers ? lib.helpPopovers(el, {position: 'bottom'}) : () => {};
59
+ }
60
+
61
+ /**
62
+ * The admin's shared Tool kit (admin/js/lib/tool-kit.js), loaded once at run
63
+ * time. Everything below prefers it, so the Blog behaves exactly like Notes,
64
+ * Todo and Analytics; the local copies are only for an admin that predates it.
65
+ */
66
+ const core = await adminLib('tool-kit.js');
67
+
68
+ /**
69
+ * Per-view teardown. Domma's router has no unmount hook, so a view's effects,
70
+ * bindings and listeners are dropped when the hash leaves its route - and on
71
+ * a remount, in case it never did.
72
+ *
73
+ * @param {string} name - a key per view
74
+ * @param {RegExp} route - the hashes this view owns
75
+ * @returns {(fn: Function) => void} add a disposer
76
+ */
77
+ const lifetimes = new Map();
78
+ export function viewScope(name, route) {
79
+ let life = lifetimes.get(name);
80
+ if (!life) {
81
+ life = core?.viewLifetime ? core.viewLifetime(route) : localLifetime(route);
82
+ lifetimes.set(name, life);
83
+ }
84
+ life.reset();
85
+ return (fn) => { if (typeof fn === 'function') life.add(fn); };
86
+ }
87
+
88
+ function localLifetime(pattern) {
89
+ let fns = [];
90
+ const run = () => { const list = fns; fns = []; list.forEach(fn => { try { fn(); } catch { /* gone */ } }); };
91
+ const onHash = () => { if (!pattern.test(location.hash)) run(); };
92
+ return {
93
+ add(fn) { fns.push(fn); },
94
+ reset() {
95
+ run();
96
+ window.addEventListener('hashchange', onHash);
97
+ fns.push(() => window.removeEventListener('hashchange', onHash));
98
+ }
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Run `cleanup` once when the slideover closes, then take it out of the page -
104
+ * Domma hides a closed slideover rather than removing it.
105
+ *
106
+ * @param {object} so
107
+ * @param {Function} [cleanup]
108
+ */
109
+ export function onceClosed(so, cleanup = () => {}) {
110
+ if (core?.onceClosed) return core.onceClosed(so, cleanup);
111
+ const close = so.close.bind(so);
112
+ let done = false;
113
+ so.close = (...args) => {
114
+ const result = close(...args);
115
+ if (!done) {
116
+ done = true;
117
+ cleanup();
118
+ setTimeout(() => {
119
+ try { so.destroy?.(); } catch { /* already gone */ }
120
+ so.element?.remove();
121
+ }, 400);
122
+ }
123
+ return result;
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Open the row's context menu from its ⋮, anchored under the button - the same
129
+ * menu a right-click opens, so there is one list of actions, not two.
130
+ *
131
+ * @param {HTMLElement} button
132
+ */
133
+ export function openMenuFrom(button) {
134
+ if (core?.openMenuAt) return core.openMenuAt(button);
135
+ const r = button.getBoundingClientRect();
136
+ button.dispatchEvent(new MouseEvent('contextmenu', {
137
+ bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window
138
+ }));
139
+ }
140
+
141
+ /**
142
+ * Drop hidden items, and the dividers that hiding leaves doubled or dangling.
143
+ *
144
+ * @param {object[]} items
145
+ * @returns {object[]}
146
+ */
147
+ export function tidy(items) {
148
+ const shown = items.filter(i => i.visible !== false);
149
+ return shown.filter((i, n) => i.type !== 'divider'
150
+ || (n > 0 && n < shown.length - 1 && shown[n - 1].type !== 'divider'));
151
+ }
152
+
153
+ /** Build a small DOM tree from an HTML string that holds NO user data. */
154
+ export function fragment(html) {
155
+ const t = document.createElement('template');
156
+ t.innerHTML = html.trim();
157
+ return t.content.firstElementChild;
158
+ }
159
+
160
+ /**
161
+ * A filter handed from one Blog screen to the next - "Show its posts" on a
162
+ * category lands on Posts already filtered. sessionStorage, because it is a
163
+ * one-shot hand-off rather than a preference.
164
+ */
165
+ export function handOff(key, value) {
166
+ try { sessionStorage.setItem(`blog.handoff.${key}`, value); } catch { /* storage blocked */ }
167
+ }
168
+
169
+ export function takeHandOff(key) {
170
+ try {
171
+ const v = sessionStorage.getItem(`blog.handoff.${key}`);
172
+ sessionStorage.removeItem(`blog.handoff.${key}`);
173
+ return v;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Settings
181
+ // ---------------------------------------------------------------------------
182
+
183
+ const SETTINGS_PANEL = `
184
+ <div class="blog-panel">
185
+ <div class="blog-field">
186
+ <label class="form-label" for="blog-set-base">Base path
187
+ <span data-help="The URL every blog page lives under - /blog gives /blog/my-post. Takes effect after a server restart."></span>
188
+ </label>
189
+ <input id="blog-set-base" type="text" class="form-input font-mono" placeholder="/blog" data-model="basePath.value">
190
+ </div>
191
+ <div class="blog-field">
192
+ <label class="form-label" for="blog-set-ppp">Posts per page
193
+ <span data-help="How many posts a listing page shows before paging. Takes effect after a server restart."></span>
194
+ </label>
195
+ <input id="blog-set-ppp" type="number" min="1" max="100" class="form-input" data-model="postsPerPage.value">
196
+ </div>
197
+ <div class="blog-field">
198
+ <label class="form-label" for="blog-set-feed">RSS feed length
199
+ <span data-help="The newest posts included in the RSS/Atom feed."></span>
200
+ </label>
201
+ <input id="blog-set-feed" type="number" min="1" max="200" class="form-input" data-model="feedLength.value">
202
+ </div>
203
+ <div class="blog-field blog-field-row">
204
+ <label class="form-label" for="blog-set-comments">Comments
205
+ <span data-help="Readers can leave a comment under each post. Off hides the form; existing comments stay in Comments."></span>
206
+ </label>
207
+ <input id="blog-set-comments" type="checkbox" class="form-switch" data-model="commentsEnabled.value">
208
+ </div>
209
+ <div class="blog-field blog-field-row">
210
+ <label class="form-label" for="blog-set-moderate">Hold comments for approval
211
+ <span data-help="New comments wait in Comments > Pending until approved. Off publishes them straight away."></span>
212
+ </label>
213
+ <input id="blog-set-moderate" type="checkbox" class="form-switch" data-model="commentModeration.value"
214
+ data-bind-disabled="!commentsEnabled.value">
215
+ </div>
216
+ <h4 class="blog-panel-heading">On the site
217
+ <span data-help-title="Layouts are blocks"
218
+ data-help="Every post layout and listing display is a block - blog-post-… for posts, blog-card-… for listings. Edit one under Blocks and every post using it changes. Copy one under a new name (say blog-post-long-read) and it appears here, and in the post editor, as a new choice."></span>
219
+ </h4>
220
+ <div class="blog-field">
221
+ <label class="form-label" for="blog-set-layout">Post layout
222
+ <span data-help="How a post looks unless it picks its own in the editor. Classic: title, image, story. Feature: a full-width hero. Minimal: just the words. Sidebar: contents list and details beside the story."></span>
223
+ </label>
224
+ <select id="blog-set-layout" class="form-select" data-model="postLayout.value"
225
+ data-options="layoutOptions.value" data-options-text="label" data-options-value="id"></select>
226
+ </div>
227
+ <div class="blog-field">
228
+ <label class="form-label" for="blog-set-display">Listing display
229
+ <span data-help="How the blog index and the category, tag and author pages show posts. Cards: a picture grid. List: one per row. Magazine: the newest large, the rest as cards. Compact: titles and dates."></span>
230
+ </label>
231
+ <select id="blog-set-display" class="form-select" data-model="listDisplay.value"
232
+ data-options="displayOptions.value" data-options-text="label" data-options-value="id"></select>
233
+ </div>
234
+ <div class="blog-field blog-field-row">
235
+ <label class="form-label" for="blog-set-switch">Readers can switch display
236
+ <span data-help="Shows the Cards / List / Magazine / Compact switch above listings. A reader's choice is remembered in their browser."></span>
237
+ </label>
238
+ <input id="blog-set-switch" type="checkbox" class="form-switch" data-model="displaySwitcher.value">
239
+ </div>
240
+ <div class="blog-field blog-field-row">
241
+ <label class="form-label" for="blog-set-live">Search and filter in the page
242
+ <span data-help="Listings get a search box, category pills, sorting and a right-click menu on each post (More in this category, More tagged…, More by this author), all without reloading. Off: plain pages of posts. Blogs with more than 300 posts always page on the server."></span>
243
+ </label>
244
+ <input id="blog-set-live" type="checkbox" class="form-switch" data-model="interactive.value">
245
+ </div>
246
+ <div class="blog-field">
247
+ <label class="form-label" for="blog-set-related">Related posts
248
+ <span data-help="How many posts sharing a category or tag are suggested under each post, in layouts that show them. 0 turns them off."></span>
249
+ </label>
250
+ <input id="blog-set-related" type="number" min="0" max="6" class="form-input" data-model="relatedCount.value">
251
+ </div>
252
+ <button type="button" class="btn btn-sm btn-ghost blog-panel-link" data-on-click="openBlocks">
253
+ <span data-icon="layout" data-icon-size="13"></span> Edit the layouts in Blocks
254
+ </button>
255
+ <p class="blog-panel-note" data-if="needsRestart.value">Base path and posts per page change after the server restarts.</p>
256
+ <div class="blog-panel-actions">
257
+ <span class="blog-panel-status" data-bind-text="status.value"></span>
258
+ <span class="blog-spacer"></span>
259
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel" data-if="closable">Cancel</button>
260
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="!dirty.value || saving.value">
261
+ <span data-icon="save" data-icon-size="13"></span> Save
262
+ </button>
263
+ </div>
264
+ </div>`;
265
+
266
+ /**
267
+ * The settings form as an element, bound to its own observables. Used by the
268
+ * banner cog's slideover and by the old #/plugins/blog/settings page, so the
269
+ * two can never drift apart.
270
+ *
271
+ * @param {{onClose?: Function}} [opts] - with onClose, a Cancel button closes the host
272
+ * @returns {Promise<{el: HTMLElement, dispose: Function}>}
273
+ */
274
+ export async function settingsPanel({onClose = null} = {}) {
275
+ let stored = {};
276
+ try {
277
+ const res = await H.get(`${BASE}/settings`);
278
+ stored = res?.data ?? res ?? {};
279
+ } catch {
280
+ E.toast('Failed to load blog settings.', {type: 'error'});
281
+ }
282
+ let layouts = {postLayouts: [], listDisplays: []};
283
+ try {
284
+ layouts = await H.get(`${BASE}/layouts`);
285
+ } catch { /* the selects fall back to the stored value alone */ }
286
+ const initial = {
287
+ basePath: stored.basePath ?? '/blog',
288
+ postsPerPage: Number(stored.postsPerPage ?? 10),
289
+ feedLength: Number(stored.feedLength ?? 20),
290
+ commentsEnabled: stored.commentsEnabled ?? false,
291
+ commentModeration: stored.commentModeration ?? true,
292
+ postLayout: stored.postLayout ?? 'blog-post-classic',
293
+ listDisplay: stored.listDisplay ?? 'cards',
294
+ displaySwitcher: stored.displaySwitcher ?? true,
295
+ interactive: stored.interactive ?? true,
296
+ relatedCount: Number(stored.relatedCount ?? 3)
297
+ };
298
+ // A stored choice whose block has since gone still shows, rather than a blank select.
299
+ const withCurrent = (list, id) => (list.some(o => o.id === id) ? list : [...list, {id, label: `${id} (missing)`}]);
300
+ const baseline = M.observable({...initial});
301
+
302
+ const f = Object.fromEntries(Object.entries(initial).map(([k, v]) => [k, M.observable(v)]));
303
+ const saving = M.observable(false);
304
+ const status = M.observable('');
305
+ const current = () => ({
306
+ basePath: String(f.basePath.value).trim() || '/blog',
307
+ postsPerPage: Number(f.postsPerPage.value) || 10,
308
+ feedLength: Number(f.feedLength.value) || 20,
309
+ commentsEnabled: Boolean(f.commentsEnabled.value),
310
+ commentModeration: Boolean(f.commentModeration.value),
311
+ postLayout: String(f.postLayout.value),
312
+ listDisplay: String(f.listDisplay.value),
313
+ displaySwitcher: Boolean(f.displaySwitcher.value),
314
+ interactive: Boolean(f.interactive.value),
315
+ relatedCount: Math.max(0, Math.min(6, Number(f.relatedCount.value) || 0))
316
+ });
317
+
318
+ const data = {
319
+ ...f, saving, status,
320
+ closable: Boolean(onClose),
321
+ layoutOptions: M.observable(withCurrent(layouts.postLayouts ?? [], initial.postLayout)),
322
+ displayOptions: M.observable(withCurrent(layouts.listDisplays ?? [], initial.listDisplay)),
323
+ dirty: M.computed(() => JSON.stringify(current()) !== JSON.stringify(baseline.value)),
324
+ needsRestart: M.computed(() => {
325
+ const b = baseline.value;
326
+ return String(f.basePath.value).trim() !== b.basePath || Number(f.postsPerPage.value) !== b.postsPerPage;
327
+ })
328
+ };
329
+
330
+ const el = fragment(SETTINGS_PANEL);
331
+ const disposeHelp = await scanHelp(el);
332
+ const handle = M.applyBindings(data, el, {
333
+ methods: {
334
+ cancel: () => onClose?.(),
335
+ openBlocks() {
336
+ onClose?.();
337
+ R.navigate('#/blocks');
338
+ },
339
+ async save() {
340
+ const body = current();
341
+ if (!body.basePath.startsWith('/')) {
342
+ E.toast('Base path must start with /.', {type: 'error'});
343
+ return;
344
+ }
345
+ saving.value = true;
346
+ try {
347
+ const res = await H.put(`${BASE}/settings`, body);
348
+ if (res?.error) throw new Error(res.error);
349
+ baseline.value = {...body};
350
+ basePathCache = null;
351
+ status.value = 'Saved.';
352
+ E.toast('Blog settings saved. Display changes are live now.', {type: 'success'});
353
+ } catch (err) {
354
+ E.toast(err?.message ?? 'Failed to save settings.', {type: 'error'});
355
+ } finally {
356
+ saving.value = false;
357
+ }
358
+ }
359
+ }
360
+ });
361
+ if (typeof I !== 'undefined' && I.scan) I.scan(el);
362
+ return {el, dispose: () => { handle.dispose(); disposeHelp(); }};
363
+ }
364
+
365
+ /** The banner cog's slideover. Resizable, like every admin slideover. */
366
+ export async function openSettings() {
367
+ const so = E.slideover({title: 'Blog settings', size: 'sm', position: 'right'});
368
+ const {el, dispose} = await settingsPanel({onClose: () => so.close()});
369
+ so.element.appendChild(el);
370
+ onceClosed(so, dispose);
371
+ so.open();
372
+ }