domma-cms 0.66.0 → 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 (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 +92 -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 +307 -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
@@ -14,10 +14,159 @@ 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) {
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 */ });
21
170
  // Domma's router hands onMount ONE argument and never the route params,
22
171
  // and `R.current` is a METHOD - `R.current?.params` is undefined on the
23
172
  // function object, so this view opened blank on every edit and Save
@@ -25,8 +174,9 @@ export const postEditorView = {
25
174
  // view does.
26
175
  const routeMatch = location.hash.match(/^#\/plugins\/blog\/posts\/([^?#]+)/);
27
176
  const routeId = routeMatch ? decodeURIComponent(routeMatch[1]) : null;
28
- const id = routeId && routeId !== 'new' ? routeId : null;
29
- const isEdit = Boolean(id);
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);
30
180
 
31
181
  let post = null;
32
182
  let categories = [];
@@ -69,7 +219,7 @@ export const postEditorView = {
69
219
  const imagePreviewEl = $container.find('#featured-image-preview').get(0);
70
220
  const clearImageBtn = $container.find('#btn-clear-image').get(0);
71
221
 
72
- if (titleEl) titleEl.textContent = isEdit ? 'Edit Post' : 'New Post';
222
+ if (titleEl) titleEl.textContent = isEdit ? 'Editing post' : 'New post';
73
223
 
74
224
  if (isEdit && post) {
75
225
  if (postTitleEl) postTitleEl.value = post.title ?? '';
@@ -136,8 +286,14 @@ export const postEditorView = {
136
286
  }
137
287
  }
138
288
 
289
+ const layoutCard = await mountLayoutCard($container.find('#layout-card').get(0), {
290
+ current: post?.template ?? '',
291
+ collect: () => ({...collectValues(), id})
292
+ });
293
+
139
294
  function collectValues() {
140
295
  return {
296
+ template: layoutCard.value(),
141
297
  title: postTitleEl?.value?.trim() ?? '',
142
298
  slug: postSlugEl?.value?.trim() ?? '',
143
299
  excerpt: excerptEl?.value?.trim() ?? '',
@@ -156,6 +312,7 @@ export const postEditorView = {
156
312
  const body = { ...collectValues(), ...extra };
157
313
  if (!body.title) {
158
314
  E.toast('Title is required.', { type: 'error' });
315
+ postTitleEl?.focus();
159
316
  return null;
160
317
  }
161
318
  try {
@@ -163,13 +320,107 @@ export const postEditorView = {
163
320
  ? await H.put(`${BASE}/posts/${id}`, body)
164
321
  : await H.post(`${BASE}/posts`, body);
165
322
  if (res.error) throw new Error(res.error);
166
- 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;
167
338
  } catch (err) {
168
339
  E.toast(err?.message ?? 'Failed to save post.', { type: 'error' });
169
340
  return null;
170
341
  }
171
342
  }
172
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
+
173
424
  if (postTitleEl) {
174
425
  postTitleEl.addEventListener('input', () => {
175
426
  if (slugAutoEl?.checked && postSlugEl) {
@@ -199,17 +450,6 @@ export const postEditorView = {
199
450
  });
200
451
  }
201
452
 
202
- const saveDraftBtn = $container.find('#btn-save-draft').get(0);
203
- if (saveDraftBtn) {
204
- saveDraftBtn.addEventListener('click', async () => {
205
- const saved = await doSave({ status: 'draft' });
206
- if (saved) {
207
- E.toast('Draft saved.', { type: 'success' });
208
- R.navigate('#/plugins/blog/posts');
209
- }
210
- });
211
- }
212
-
213
453
  const publishBtn = $container.find('#btn-publish').get(0);
214
454
  if (publishBtn) {
215
455
  publishBtn.addEventListener('click', async () => {
@@ -217,10 +457,11 @@ export const postEditorView = {
217
457
  if (!saved) return;
218
458
  const savedId = saved.id ?? id;
219
459
  try {
220
- const res = await H.post(`${BASE}/posts/${savedId}/publish`);
460
+ const res = await H.post(`${BASE}/posts/${savedId}/publish`, {});
221
461
  if (res.error) throw new Error(res.error);
222
- E.toast('Post published.', { type: 'success' });
223
- R.navigate('#/plugins/blog/posts');
462
+ post = res.data ?? res;
463
+ setStatus('published');
464
+ E.toast('Published - the post is live.', { type: 'success' });
224
465
  } catch (err) {
225
466
  E.toast(err?.message ?? 'Failed to publish post.', { type: 'error' });
226
467
  }
@@ -229,7 +470,6 @@ export const postEditorView = {
229
470
 
230
471
  const scheduleBtn = $container.find('#btn-schedule').get(0);
231
472
  if (scheduleBtn) {
232
- scheduleBtn.classList.remove('blog-hidden');
233
473
  scheduleBtn.addEventListener('click', async () => {
234
474
  // First click reveals the picker; the second one schedules.
235
475
  if (scheduleEl) scheduleEl.classList.toggle('blog-hidden');
@@ -248,8 +488,10 @@ export const postEditorView = {
248
488
  try {
249
489
  const res = await H.post(`${BASE}/posts/${savedId}/schedule`, { publishedAt });
250
490
  if (res.error) throw new Error(res.error);
251
- E.toast('Post scheduled.', { type: 'success' });
252
- 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' });
253
495
  } catch (err) {
254
496
  E.toast(err?.message ?? 'Failed to schedule post.', { type: 'error' });
255
497
  }
@@ -324,6 +566,12 @@ export const postEditorView = {
324
566
  slideover.open();
325
567
  }
326
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);
327
575
  Domma.icons.scan();
328
576
  }
329
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,102 +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 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).'}
52
- },
53
- postsPerPage: {
54
- type: 'number', label: 'Posts per page', default: 10,
55
- formConfig: {placeholder: '10', hint: 'Number of posts shown per listing page.'}
56
- },
57
- commentsEnabled: {
58
- type: 'boolean', label: 'Enable comments',
59
- formConfig: {hint: 'Allow readers to submit comments on posts.'}
60
- },
61
- commentModeration: {
62
- type: 'boolean', label: 'Moderate comments',
63
- formConfig: {hint: 'Hold new comments for approval before they appear.'}
64
- },
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.'}
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' });
97
- }
98
- });
99
-
100
- 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);
101
31
  }
102
32
  };
@@ -0,0 +1,17 @@
1
+ display: grid;
2
+ grid-template-columns: 7.5rem 1fr auto auto;
3
+ align-items: baseline;
4
+ gap: 1rem;
5
+ padding: .6rem .25rem;
6
+ border-bottom: 1px solid var(--dm-border, rgba(127,127,127,.2));
7
+ &:hover { background: rgba(127,127,127,.06); }
8
+ .bk-date { font-variant-numeric: tabular-nums; color: var(--dm-text-muted); font-size: .85rem; }
9
+ .bk-title { font-weight: 600; color: inherit; text-decoration: none; min-width: 0; }
10
+ .bk-title:hover { color: var(--dm-primary); }
11
+ .bk-cats { display: inline-flex; gap: .3rem; }
12
+ .bk-time { font-size: .8rem; color: var(--dm-text-muted); white-space: nowrap; }
13
+ @media (max-width: 640px) {
14
+ grid-template-columns: 1fr auto;
15
+ .bk-date { grid-column: 1 / -1; }
16
+ .bk-cats { display: none; }
17
+ }
@@ -0,0 +1,8 @@
1
+ <!--
2
+ blog-card-compact - a title and a date on one line (the Compact display).
3
+ Same placeholders as blog-card.
4
+ -->
5
+ <time class="bk-date" datetime="{{publishedAt}}">{{publishedAt:DD MMM YYYY}}</time>
6
+ <a class="bk-title" href="{{url}}">{{title}}</a>
7
+ <span class="bk-cats">{{{categories}}}</span>
8
+ <span class="bk-time">{{readingTime}}</span>
@@ -0,0 +1,22 @@
1
+ display: grid;
2
+ grid-template-columns: minmax(0, 15rem) 1fr;
3
+ gap: 1.25rem;
4
+ align-items: start;
5
+ padding: 1.1rem 0;
6
+ border-bottom: 1px solid var(--dm-border, rgba(127,127,127,.25));
7
+ &:has(.br-media.no-image) { grid-template-columns: 1fr; }
8
+ .br-media { display: block; aspect-ratio: 4 / 3; border-radius: var(--dm-radius-md, 8px); overflow: hidden; }
9
+ .br-media img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .35s ease; }
10
+ &:hover .br-media img { transform: scale(1.04); }
11
+ .br-media.no-image { display: none; }
12
+ .br-body { display: flex; flex-direction: column; gap: .4rem; min-width: 0; }
13
+ .br-meta { margin: 0; font-size: .8rem; color: var(--dm-text-muted); text-transform: uppercase; letter-spacing: .04em; }
14
+ .br-title { margin: 0; font-size: 1.35rem; line-height: 1.25; }
15
+ .br-title a { color: inherit; text-decoration: none; }
16
+ .br-title a:hover { color: var(--dm-primary); }
17
+ .br-excerpt { margin: 0; color: var(--dm-text-muted); line-height: 1.6; }
18
+ .br-excerpt:empty { display: none; }
19
+ .br-foot { display: flex; align-items: center; justify-content: space-between; gap: .75rem; flex-wrap: wrap; font-size: .85rem; }
20
+ .br-cats { display: inline-flex; gap: .35rem; flex-wrap: wrap; }
21
+ .br-author { color: var(--dm-text-muted); }
22
+ @media (max-width: 640px) { grid-template-columns: 1fr; }
@@ -0,0 +1,11 @@
1
+ <!--
2
+ blog-card-row - one post per row, picture beside the text (the List display).
3
+ Same placeholders as blog-card.
4
+ -->
5
+ <a class="br-media {{imageClass}}" href="{{url}}" tabindex="-1" aria-hidden="true">{{{image}}}</a>
6
+ <div class="br-body">
7
+ <p class="br-meta"><time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
8
+ <h2 class="br-title"><a href="{{url}}">{{title}}</a></h2>
9
+ <p class="br-excerpt">{{excerpt}}</p>
10
+ <div class="br-foot"><span class="br-cats">{{{categories}}}</span><a class="br-author" href="{{authorUrl}}">{{authorName}}</a></div>
11
+ </div>
@@ -0,0 +1,35 @@
1
+ display: flex;
2
+ flex-direction: column;
3
+ border: 1px solid var(--dm-border, rgba(127,127,127,.25));
4
+ border-radius: var(--dm-radius-lg, 12px);
5
+ overflow: hidden;
6
+ background: var(--dm-surface, transparent);
7
+ transition: transform .15s ease, box-shadow .15s ease;
8
+ &:hover { transform: translateY(-2px); box-shadow: var(--dm-shadow-md, 0 10px 28px rgba(0,0,0,.12)); }
9
+ .bc-media { display: block; aspect-ratio: 16 / 9; overflow: hidden; background: rgba(127,127,127,.08); }
10
+ .bc-media img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .35s ease; }
11
+ &:hover .bc-media img { transform: scale(1.04); }
12
+ .bc-media.no-image { display: none; }
13
+ .bc-body { flex: 1; display: flex; flex-direction: column; gap: .45rem; padding: 1rem 1.1rem 1.1rem; }
14
+ .bc-kicker { display: flex; flex-wrap: wrap; gap: .35rem; }
15
+ .bc-kicker:empty { display: none; }
16
+ .bc-title { margin: 0; font-size: 1.15rem; line-height: 1.3; }
17
+ .bc-title a { color: inherit; text-decoration: none; }
18
+ .bc-title a:hover { color: var(--dm-primary); }
19
+ .bc-excerpt { margin: 0; color: var(--dm-text-muted); font-size: .93rem; line-height: 1.55;
20
+ display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
21
+ .bc-excerpt:empty { display: none; }
22
+ .bc-meta { margin: auto 0 0; padding-top: .35rem; font-size: .8rem; color: var(--dm-text-muted); }
23
+ .bc-meta a { color: inherit; }
24
+ /* Magazine: the first post leads, across the whole grid. */
25
+ &.is-lead { grid-column: 1 / -1; flex-direction: row; }
26
+ &.is-lead .bc-media { flex: 0 0 58%; aspect-ratio: auto; min-height: 320px; }
27
+ &.is-lead .bc-body { justify-content: center; padding: 1.75rem; }
28
+ &.is-lead .bc-title { font-size: 1.85rem; }
29
+ &.is-lead .bc-excerpt { font-size: 1.05rem; -webkit-line-clamp: 5; }
30
+ &.is-lead .bc-meta { margin-top: .5rem; }
31
+ @media (max-width: 760px) {
32
+ &.is-lead { flex-direction: column; }
33
+ &.is-lead .bc-media { min-height: 0; aspect-ratio: 16 / 9; }
34
+ &.is-lead .bc-title { font-size: 1.4rem; }
35
+ }