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,6 +14,13 @@ export const indexView = {
14
14
  // Confirm: if (!await E.confirm('Are you sure?')) return;
15
15
  // Create table: T.create(el, { data, columns: [...] });
16
16
  // Create form: F.create({ fields }, defaults, { showSubmitButton: false }).renderTo(el);
17
+ //
18
+ // Route params: onMount is handed ONE argument and never the route
19
+ // params, and R.current is a METHOD - R.current.params
20
+ // is always undefined. An editor view reads its id off
21
+ // the hash:
22
+ // const m = location.hash.match(/^#\/plugins\/PLUGIN_SLUG\/edit\/([^?#]+)/);
23
+ // const id = m ? decodeURIComponent(m[1]) : null;
17
24
  I.scan();
18
25
  }
19
26
  };
@@ -0,0 +1,237 @@
1
+ # blog - AI Assistant Guide
2
+
3
+ ## Purpose
4
+
5
+ Public-facing blog for the CMS. Supports posts with categories, tags, and optional moderated comments. Authors can draft posts; only blog-editors (and admins) can publish or schedule them. Public SSR routes serve an archive index, category/tag/author archives, individual post pages, and an RSS feed. All collection I/O goes through `server/services/collections.js` (FileAdapter by default).
6
+
7
+ ## Files
8
+
9
+ - `plugin.json`, `plugin.js`, `config.js`, `plugin.public.js`
10
+ - `collections/posts/schema.json` - post entries
11
+ - `collections/categories/schema.json` - category entries
12
+ - `collections/comments/schema.json` - comment entries
13
+ - `templates/index.html` - every listing (index, page n, category, tag, author)
14
+ - `templates/post.html` - a post page (also the admin preview)
15
+ - `lib/layouts.js` - PURE: layouts on offer, interpolation, contents list, card values, settings whitelist
16
+ - `lib/render.js` - blocks I/O: seed, load (site copy, else shipped), render cards
17
+ - `lib/page.js` - a post page through its layout; shared by the public route and `POST /preview`
18
+ - `lib/samples.js` - the sample posts and categories
19
+ - `blocks/*.html|css` - the shipped layouts, copied into content/blocks/ once
20
+ - `public/blog.js`, `public/blog.css` - the browser half (listing, right-click menu, post extras)
21
+ - `public/samples/*.svg` - sample covers
22
+ - `tests/layouts.test.js`, `tests/public.test.js`
23
+ - `admin/views/blog.js` - post list (KPIs + status/category/search filters)
24
+ - `admin/views/post-editor.js` - create / edit a post
25
+ - `admin/views/categories.js`, `admin/views/comments.js`, `admin/views/settings.js`
26
+ - `admin/templates/*.html`
27
+ - `admin/css/index.css` - admin styles (declared in `plugin.json` `admin.css`)
28
+
29
+ ## API Routes
30
+
31
+ All routes are under `/api/plugins/blog`. All require authentication unless noted.
32
+
33
+ | Method | Path | Purpose | Auth |
34
+ |---|---|---|---|
35
+ | GET | `/posts` | List posts; blog-authors see only their own | authenticate |
36
+ | POST | `/posts` | Create a new post (draft by default) | authenticate |
37
+ | GET | `/posts/:id` | Get a single post by ID | authenticate |
38
+ | PUT | `/posts/:id` | Update post; blog-authors may only edit their own | authenticate |
39
+ | DELETE | `/posts/:id` | Delete post; blog-editor / admin only | authenticate |
40
+ | POST | `/posts/:id/publish` | Set status to `published`; blog-editor / admin only | authenticate |
41
+ | POST | `/posts/:id/schedule` | Set status to `scheduled` + `publishedAt`; blog-editor / admin only | authenticate |
42
+ | GET | `/categories` | List all categories | authenticate |
43
+ | POST | `/categories` | Create category; blog-editor / admin only | authenticate |
44
+ | PUT | `/categories/:id` | Update category; blog-editor / admin only | authenticate |
45
+ | DELETE | `/categories/:id` | Delete category; blog-editor / admin only | authenticate |
46
+ | GET | `/comments` | List comments; supports `?status=` and `?postId=` filters | authenticate |
47
+ | POST | `/comments` | Submit a comment; **no authentication required** (public route) | - |
48
+ | PUT | `/comments/:id` | Moderate comment (update status field) | authenticate |
49
+ | DELETE | `/comments/:id` | Delete comment | authenticate |
50
+ | GET | `/layouts` | Post layouts and listing displays on offer, plus the defaults | authenticate |
51
+ | POST | `/preview` | A post page from UNSAVED fields, in any layout (`{html, layout}`) | authenticate |
52
+ | POST | `/samples` | Add the sample posts (idempotent by slug) | authenticate + requireAdmin |
53
+ | DELETE | `/samples` | Remove `sample: true` posts, and sample categories nothing uses | authenticate + requireAdmin |
54
+ | GET | `/settings` | Read blog plugin settings | authenticate |
55
+ | PUT | `/settings` | Save blog plugin settings | authenticate + requireAdmin |
56
+
57
+ ## Public Routes
58
+
59
+ Registered at the Fastify root (no `/api/plugins/blog` prefix). Base path defaults to `/blog` (overridable via `basePath` config key).
60
+
61
+ | Method | Path | Purpose |
62
+ |---|---|---|
63
+ | GET | `{base}/feed.xml` | RSS 2.0 feed (published + scheduled-due posts) |
64
+ | GET | `{base}/page/:n` | Paginated post archive |
65
+ | GET | `{base}/category/:slug` | Posts filtered by category slug |
66
+ | GET | `{base}/tag/:tag` | Posts filtered by tag |
67
+ | GET | `{base}/author/:id` | Posts filtered by author user ID |
68
+ | GET | `{base}` | Blog index (page 1) |
69
+ | GET | `{base}/:slug` | Single post page - registered LAST |
70
+
71
+ ## Data Model
72
+
73
+ ### posts
74
+ | Field | Type | Notes |
75
+ |---|---|---|
76
+ | `title` | string | Required |
77
+ | `slug` | string | URL slug; auto-derived from title if omitted |
78
+ | `content` | string | Raw Markdown or HTML body |
79
+ | `excerpt` | string | Short summary |
80
+ | `featuredImage` | string\|null | Media path or URL |
81
+ | `categories` | string[] | Array of category entry IDs |
82
+ | `tags` | string[] | Array of tag strings |
83
+ | `status` | string | `draft` \| `published` \| `scheduled` |
84
+ | `authorId` | string | User UUID of the author |
85
+ | `publishedAt` | ISO string | Set on publish or schedule |
86
+ | `seo.title` | string | SEO title override |
87
+ | `seo.description` | string | SEO description override |
88
+ | `template` | string | Post layout block (`blog-post-*`); `''` = the site default |
89
+ | `sample` | boolean | Added by "Add sample posts"; removed by "Remove" |
90
+
91
+ ### categories
92
+ | Field | Type | Notes |
93
+ |---|---|---|
94
+ | `name` | string | Display name |
95
+ | `slug` | string | URL slug |
96
+ | `description` | string | Optional |
97
+
98
+ ### comments
99
+ | Field | Type | Notes |
100
+ |---|---|---|
101
+ | `postId` | string | ID of the parent post entry |
102
+ | `authorName` | string | Commenter display name |
103
+ | `authorEmail` | string | Commenter email (not publicly displayed) |
104
+ | `authorUrl` | string | Optional website URL |
105
+ | `body` | string | Comment text |
106
+ | `status` | string | `pending` \| `approved` \| `rejected` \| `spam` |
107
+ | `createdAt` | ISO string | Set at submission time |
108
+
109
+ ## Config Keys (`config.js`)
110
+
111
+ | Key | Default | Purpose |
112
+ |---|---|---|
113
+ | `basePath` | `'/blog'` | URL prefix for all public blog routes |
114
+ | `postsPerPage` | `10` | Archive pagination page size |
115
+ | `commentsEnabled` | `false` | Show comment list and form on post pages |
116
+ | `commentModeration` | `true` | New comments start as `pending`; `false` = auto-approve |
117
+ | `feedLength` | `20` | Maximum items in RSS feed |
118
+ | `postLayout` | `'blog-post-classic'` | Default post layout (read live) |
119
+ | `listDisplay` | `'cards'` | `cards` \| `list` \| `magazine` \| `compact` \| a `blog-card-*` block (read live) |
120
+ | `displaySwitcher` | `true` | Readers can switch display above listings |
121
+ | `interactive` | `true` | Listings search/filter/sort/right-click in the browser (≤300 posts) |
122
+ | `relatedCount` | `3` | Related posts under a post; 0 = none |
123
+ | `storage.adapter` | `'file'` | Storage adapter; `'file'` works on free installs |
124
+
125
+ ## Roles
126
+
127
+ Two custom roles are seeded for this plugin:
128
+
129
+ - **`blog-author`** - can create posts and edit their own; cannot publish, schedule, or delete
130
+ - **`blog-editor`** - can do everything including publish, schedule, delete, and manage categories/comments
131
+
132
+ Both roles are at **level 2** in the role hierarchy (same as `editor`). The publish gate is therefore a **named-role check**, not a level comparison.
133
+
134
+ ## Embed Shortcodes
135
+
136
+ Registered via `options.hooks.registerShortcode` so users can drop blog content into any page:
137
+
138
+ | Shortcode | Attributes | Purpose |
139
+ |---|---|---|
140
+ | `[blog-list count="5" category="news" /]` | `count`, `limit`, `category` (slug) | Vertical list of recent posts (title + date) |
141
+ | `[blog-grid count="6" cols="3" category="..." /]` | `count`, `limit`, `cols` (1-4), `category` | Card grid with featured images |
142
+ | `[blog-featured slug="..." /]` | `slug` (omit for newest) | Single hero card for one post |
143
+ | `[blog-categories show-counts="true" /]` | `show-counts` | Linked category list with optional post counts |
144
+
145
+ Shortcode handlers are async (markdown.js was upgraded to await them). They reuse `loadPosts()` / `loadCategories()` helpers that always return arrays regardless of adapter shape.
146
+
147
+ ## Layouts are blocks
148
+
149
+ A post page and a listing card are BLOCKS. `blog-post-<name>` is a post layout, `blog-card-<name>` a
150
+ card; any block with those prefixes is offered automatically (`lib/layouts.js`). The shipped ones are
151
+ copied into content/blocks/ on start, once each (remembered as `seededBlocks` in config/plugins.json),
152
+ so a site edits them in the Blocks tool; a deleted one falls back to `blocks/` here. Placeholders are
153
+ documented in each block's header comment: `{{x}}` escaped, `{{x:D MMM YYYY}}` a date, `{{{x}}}` raw
154
+ for fragments this plugin builds and escapes (content, image, categories, tags, toc, share, prevNext,
155
+ related, comments). Block CSS is scoped under `[data-block="name"]` like every other block.
156
+
157
+ The listing works without JS; `public/blog.js` then reads each card's `data-post` (base64 JSON of
158
+ plain values - never markup) into domma-reactive observables. Display switching fetches the same URL
159
+ with `?display=` and swaps the cards, since each display is different block markup.
160
+
161
+ ## Gotchas
162
+
163
+ - **Public handlers must RETURN the reply.** `reply.type(...).send(html)` without `return` in an
164
+ async handler loses the body under @fastify/compress - every browser got a 200 with nothing in it,
165
+ while curl (no Accept-Encoding) saw the page. `tests/public.test.js` drives gzip and brotli.
166
+
167
+ - **The theme styles every `.page-body a` with `!important`** (public/css/site.css, specificity
168
+ ~(0,8,4)). The blog's interface links opt out by id (`#dm-blog` on the templates' `<main>`); links
169
+ inside `.blog-content` keep the theme's look. Theme inputs style `input:not([class])`, so the
170
+ listing's search box carries a class.
171
+
172
+ - **The theme wrapper has `overflow: hidden`**, which disables `position: sticky` below it. The
173
+ Sidebar layout's contents list is kept in view by `keepInView()` in public/blog.js instead.
174
+
175
+ - **Status is not a field a PUT can set.** `postFields()` whitelists what a body may change; only
176
+ `draft` is accepted from anyone. Publishing goes through `/publish` and `/schedule`, which check the
177
+ role. It used to merge the body wholesale, so a blog-author could publish with `{"status": "published"}`.
178
+
179
+ - **Admin styling is Domma's, not Bootstrap's**: `form-input` / `form-textarea`
180
+ (not `form-control`), `flex` / `items-center` / `justify-between` (not
181
+ `d-flex` / `align-items-center` / `justify-content-between`), `text-sm`,
182
+ `font-mono`, `font-bold`, `form-input-lg`, `btn-outline`. The Bootstrap
183
+ spellings resolve to nothing in this admin, which left every input in the
184
+ plugin unstyled. KPI tiles use the admin's own `.stat-card` / `.stat-icon` /
185
+ `.stat-value` / `.stat-label`, and everything else lives in
186
+ `admin/css/index.css` - the templates carry no inline styles.
187
+
188
+ - **`GET /posts` sends a BARE ARRAY**, so `postsRes.data` is undefined. The list
189
+ view read exactly that and showed an empty table with four zero KPIs while the
190
+ API was returning posts perfectly well. Read it as
191
+ `res.data ?? (Array.isArray(res) ? res : [])`, the way `categories.js` and
192
+ `comments.js` already do.
193
+
194
+ - **Collection schemas follow the CMS contract**: a collection is named by
195
+ `title` (not `label`) and a field by `name` (not `key`). They were `label` and
196
+ `key`, so `createCollection` threw on all three, `setupPlugin`'s bare
197
+ `catch {}` swallowed it, and a fresh install got three empty directories and
198
+ `Collection "blog-posts" not found` from every route. Existing sites were
199
+ unaffected only because the collections were already on disk.
200
+
201
+ - **The post editor reads its id off the HASH**: Domma's router hands `onMount`
202
+ one argument and never the params, and `R.current` is a method, so
203
+ `R.current?.params?.id` was always undefined - the editor opened blank on
204
+ every edit and Save created a duplicate.
205
+
206
+ - **`/plugins/blog` is a real route**, mapped to the post list. The sidebar's
207
+ parent item points at it and `post-editor.js` navigates to it on a failed
208
+ load; without the route both went nowhere.
209
+
210
+ - **Settings**: `getConfig`/`saveConfig` in `server/config.js` take a FILE NAME.
211
+ These routes called `getConfig()` with no argument (reading
212
+ `config/undefined.json`, so GET answered 500 every time) and
213
+ `saveConfig(cfg)`. Plugin settings belong in `config/plugins.json` via
214
+ `getPluginStates()` / `savePluginState('blog', {settings})`. The PUT also
215
+ guarded with `requireAdmin` alone, which reads `request.user` and therefore
216
+ 401s everyone - it needs `[authenticate, requireAdmin]`.
217
+
218
+ - **`POST /posts` answers 400 on an empty title.** The schema marks title, slug,
219
+ authorId and status required, so without the guard an empty title reached
220
+ `createEntry` and came back 500 where the categories and comments routes both
221
+ answer 400.
222
+
223
+ - **Named-role publish gate**: `canPublish()` uses `['blog-editor', 'admin', 'super-admin'].includes(user.role)` - NOT a level comparison. Do not refactor this to `getRoleLevel() <= N`; `blog-author` and `blog-editor` are both level 2, so a level check would give blog-authors publish access.
224
+
225
+ - **Route registration order in plugin.public.js**: Static path segments (`/feed.xml`, `/page/:n`, `/category/:slug`, `/tag/:tag`, `/author/:id`, base index) are all registered **before** `/:slug`. Fastify matches routes in registration order for same-prefix paths, so `/:slug` must be last to avoid swallowing archive routes.
226
+
227
+ - **Lazy scheduled post check**: `plugin.public.js` calls `isVisible(post)` on every `GET /:slug` request. A post transitions from `scheduled` to visible the moment `new Date(post.publishedAt) <= new Date()` - no background job needed, no cron required.
228
+
229
+ - **POST /comments has no authenticate preHandler**: It is intentionally a public route so anonymous visitors can submit comments. The honeypot field (`website`) silently discards bot submissions. Do not add `authenticate` to this route.
230
+
231
+ - **Comments disabled by default**: `commentsEnabled: false` in `config.js`. The comment list and form are only rendered in `plugin.public.js` when `settings.commentsEnabled` is truthy. Enable in plugin settings via the admin panel.
232
+
233
+ - **plugin.public.js registered at Fastify root**: This file is registered without the `/api/plugins/blog` prefix. Routes use the full absolute path starting from `base` (default `/blog`). The server registers it separately from `plugin.js`.
234
+
235
+ - **`storage.adapter: 'file'` default**: Unlike the messages plugin (which defaults to `mongodb`), blog defaults to `file` so it works on free installs without MongoDB. Do not change this default.
236
+
237
+ - **`listEntries` called with bare slug strings**: In `plugin.public.js`, `listEntries('posts')`, `listEntries('categories')`, and `listEntries('comments')` are called with plain string slugs (not constants). This is consistent with the rest of the public file but differs from `plugin.js`, which uses `POSTS_SLUG` / `CATEGORIES_SLUG` / `COMMENTS_SLUG` constants.
@@ -0,0 +1,178 @@
1
+ /* Blog plugin - admin styling.
2
+ The templates carry no inline styles and no Bootstrap class names; Domma's
3
+ spellings are form-input / form-textarea, flex, items-center, justify-between,
4
+ text-sm and font-mono. */
5
+
6
+ .blog-hidden { display: none !important; }
7
+ .blog-clickable { cursor: pointer; }
8
+ .blog-nowrap { white-space: nowrap; }
9
+
10
+ /* Post editor: body on the left, status / categories / tags rail on the right. */
11
+ .blog-editor { align-items: flex-start; }
12
+ .blog-editor-main { flex: 1; min-width: 0; }
13
+ .blog-editor-side { width: 280px; flex-shrink: 0; }
14
+
15
+ @media (max-width: 900px) {
16
+ .blog-editor { flex-wrap: wrap; }
17
+ .blog-editor-side { width: 100%; }
18
+ }
19
+
20
+ /* Media picker (the Featured Image slideover) */
21
+ .blog-media-grid {
22
+ display: grid;
23
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
24
+ gap: 0.75rem;
25
+ padding: 0.5rem;
26
+ }
27
+
28
+ .blog-media-tile {
29
+ border: 1px solid var(--dm-border, #333);
30
+ background: transparent;
31
+ border-radius: 0.25rem;
32
+ padding: 0.25rem;
33
+ cursor: pointer;
34
+ display: flex;
35
+ flex-direction: column;
36
+ align-items: center;
37
+ gap: 0.25rem;
38
+ }
39
+
40
+ .blog-media-tile img {
41
+ width: 100%;
42
+ height: 90px;
43
+ object-fit: cover;
44
+ border-radius: 0.15rem;
45
+ }
46
+
47
+ .blog-media-tile span {
48
+ font-size: var(--dm-text-xs);
49
+ text-overflow: ellipsis;
50
+ overflow: hidden;
51
+ white-space: nowrap;
52
+ width: 100%;
53
+ }
54
+
55
+ .blog-image-preview {
56
+ max-width: 100%;
57
+ border-radius: 0.25rem;
58
+ display: block;
59
+ margin-bottom: 0.5rem;
60
+ }
61
+
62
+ /* ---- The list screens (Posts, Categories, Comments) ----
63
+ One compact bar of filters, then rows. Every row action lives on its
64
+ context menu, reached by right-click, Shift+F10 or the ⋮. */
65
+ .blog-screen { display: flex; flex-direction: column; gap: .75rem; }
66
+ .blog-bar { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
67
+ .blog-bar-end { display: flex; align-items: center; gap: .5rem; margin-left: auto; }
68
+ .blog-search { width: 240px; max-width: 100%; height: 2rem; }
69
+ .blog-select { width: auto; height: 2rem; padding-top: 0; padding-bottom: 0; }
70
+ .blog-count { font-size: var(--dm-text-sm); color: var(--dm-text-muted); }
71
+ .blog-state { margin: 1rem 0; text-align: center; color: var(--dm-text-muted); }
72
+ .blog-note { margin: 0; display: flex; align-items: center; gap: .5rem; font-size: var(--dm-text-sm);
73
+ padding: .5rem .75rem; border-radius: var(--dm-radius-md);
74
+ background: color-mix(in srgb, var(--dm-warning, #d97706) 12%, transparent); }
75
+ .blog-note--quiet { background: none; padding: 0; color: var(--dm-text-muted); }
76
+
77
+ /* Status chips: a count and a filter in one. */
78
+ .blog-chips { display: inline-flex; flex-wrap: wrap; gap: .25rem; }
79
+ .blog-chip {
80
+ border: 1px solid var(--dm-border); background: none; color: var(--dm-text); cursor: pointer;
81
+ font: inherit; font-size: var(--dm-text-xs); padding: .15rem .6rem; border-radius: 999px;
82
+ }
83
+ .blog-chip b { font-weight: 600; margin-left: .15rem; color: var(--dm-text-muted); }
84
+ .blog-chip:hover { border-color: var(--dm-primary); }
85
+ .blog-chip.is-active { background: var(--dm-primary); border-color: var(--dm-primary); color: var(--dm-text-inverse, #fff); }
86
+ .blog-chip.is-active b { color: inherit; }
87
+
88
+ .blog-rows:not(:empty) { border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); overflow: hidden; }
89
+ .blog-row {
90
+ display: flex; align-items: center; gap: .75rem; padding: .5rem .5rem .5rem .75rem; cursor: pointer;
91
+ border-bottom: 1px solid var(--dm-border); font-size: var(--dm-text-sm);
92
+ }
93
+ .blog-row:last-child { border-bottom: 0; }
94
+ .blog-row:hover, .blog-row:focus-visible { background: var(--dm-surface-hover, rgba(127,127,127,.08)); outline: none; }
95
+ .blog-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
96
+ .blog-row-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
97
+ .blog-row-sub { color: var(--dm-text-muted); font-size: var(--dm-text-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
98
+ .blog-row-sub:empty { display: none; }
99
+ .blog-row-sub-inline { font-weight: 400; color: var(--dm-text-muted); margin-left: .35rem; }
100
+ .blog-row-cats { flex: 0 1 12rem; min-width: 0; font-size: var(--dm-text-xs); color: var(--dm-text-muted);
101
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
102
+ .blog-row-slug { flex: 0 1 12rem; font-size: var(--dm-text-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
103
+ .blog-row-count { flex: 0 0 5rem; text-align: right; font-size: var(--dm-text-xs); color: var(--dm-text-muted); }
104
+ .blog-row-date { flex: 0 0 6.5rem; text-align: right; font-size: var(--dm-text-xs); color: var(--dm-text-muted); white-space: nowrap; }
105
+ .blog-kebab {
106
+ flex: 0 0 auto; border: 0; background: none; cursor: pointer; padding: 0 .3rem;
107
+ font-size: var(--dm-text-lg); line-height: 1.2; color: var(--dm-text-muted); border-radius: var(--dm-radius-sm);
108
+ }
109
+ .blog-kebab:hover { color: var(--dm-primary); background: var(--dm-surface-hover, rgba(127,127,127,.15)); }
110
+
111
+ /* A comment reads as one line until clicked open. */
112
+ .blog-comment-text { font-size: var(--dm-text-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
113
+ .blog-comment.is-open .blog-comment-text { white-space: pre-wrap; overflow-wrap: anywhere; padding: .25rem 0; }
114
+
115
+ @media (max-width: 720px) {
116
+ .blog-row-cats, .blog-row-slug, .blog-row-count { display: none; }
117
+ .blog-bar-end { margin-left: 0; }
118
+ }
119
+
120
+ /* ---- Slideover panels (settings, category editor) ---- */
121
+ .blog-panel { display: flex; flex-direction: column; gap: 1rem; padding: .25rem 0 .5rem; }
122
+ .blog-field { display: flex; flex-direction: column; gap: .3rem; }
123
+ .blog-field .form-label { display: flex; align-items: center; margin: 0; font-weight: 500; }
124
+ .blog-field-row { flex-direction: row; align-items: center; justify-content: space-between; gap: 1rem; }
125
+ .blog-panel-heading { display: flex; align-items: center; margin: .5rem 0 -.25rem; padding-top: .9rem; border-top: 1px solid var(--dm-border);
126
+ font-size: var(--dm-text-xs); font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--dm-text-muted); }
127
+ .blog-panel-link { align-self: flex-start; }
128
+ .blog-panel-note { margin: 0; font-size: var(--dm-text-xs); color: var(--dm-warning, #d97706); }
129
+ .blog-panel-actions { display: flex; align-items: center; gap: .5rem; padding-top: .75rem; border-top: 1px solid var(--dm-border); }
130
+ .blog-panel-status { font-size: var(--dm-text-xs); color: var(--dm-text-muted); }
131
+ .blog-spacer { flex: 1; }
132
+
133
+ /* The old settings page renders the same panel in a card. */
134
+ .blog-settings-card { max-width: 600px; }
135
+
136
+ /* ── Post editor: layout picker ─────────────────────────────────────── */
137
+ .blog-card-head { display: flex; align-items: center; gap: .25rem; }
138
+ .blog-preview-btn { margin-left: auto; }
139
+ .blog-layouts { display: grid; grid-template-columns: repeat(auto-fill, minmax(5.6rem, 1fr)); gap: .5rem; }
140
+ .blog-layout { display: flex; flex-direction: column; align-items: center; gap: .35rem; padding: .5rem .35rem .45rem; cursor: pointer;
141
+ border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); background: none; color: var(--dm-text-muted); font: inherit; font-size: var(--dm-text-xs); }
142
+ .blog-layout:hover { border-color: var(--dm-primary); color: var(--dm-text); }
143
+ .blog-layout.is-on { border-color: var(--dm-primary); color: var(--dm-primary);
144
+ background: color-mix(in srgb, var(--dm-primary) 10%, transparent); box-shadow: 0 0 0 1px var(--dm-primary) inset; }
145
+ .blog-layout-name { line-height: 1.2; text-align: center; overflow-wrap: anywhere; }
146
+ /* Schematic thumbnails, drawn as masks so they take the theme's colour. */
147
+ .blog-thumb { width: 3.6rem; height: 2.4rem; background: currentColor; -webkit-mask: var(--thumb) center / contain no-repeat; mask: var(--thumb) center / contain no-repeat; }
148
+ .blog-thumb[data-thumb="classic"] { --thumb: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2060%2040'%3E%3Crect%20x='18'%20y='4'%20width='24'%20height='3'%20rx='1'%20opacity='1'/%3E%3Crect%20x='22'%20y='9'%20width='16'%20height='2'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='12'%20y='13'%20width='36'%20height='11'%20rx='1'%20opacity='0.45'/%3E%3Crect%20x='14'%20y='27'%20width='32'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='14'%20y='30'%20width='32'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='14'%20y='33'%20width='32'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='14'%20y='36'%20width='32'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3C/svg%3E"); }
149
+ .blog-thumb[data-thumb="feature"] { --thumb: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2060%2040'%3E%3Crect%20x='0'%20y='0'%20width='60'%20height='20'%20rx='1'%20opacity='0.45'/%3E%3Crect%20x='6'%20y='11'%20width='30'%20height='3.2'%20rx='1'%20opacity='1'/%3E%3Crect%20x='6'%20y='16'%20width='20'%20height='1.8'%20rx='1'%20opacity='0.8'/%3E%3Crect%20x='14'%20y='24.0'%20width='32'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='14'%20y='27.2'%20width='32'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='14'%20y='30.4'%20width='32'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='14'%20y='33.6'%20width='32'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='14'%20y='36.8'%20width='32'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3C/svg%3E"); }
150
+ .blog-thumb[data-thumb="minimal"] { --thumb: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2060%2040'%3E%3Crect%20x='20'%20y='5'%20width='20'%20height='1.5'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='20'%20y='9'%20width='22'%20height='3'%20rx='1'%20opacity='1'/%3E%3Crect%20x='20'%20y='16.0'%20width='20'%20height='1.5'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='20'%20y='19.2'%20width='20'%20height='1.5'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='20'%20y='22.4'%20width='20'%20height='1.5'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='20'%20y='25.6'%20width='20'%20height='1.5'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='20'%20y='28.8'%20width='20'%20height='1.5'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='20'%20y='32.0'%20width='20'%20height='1.5'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='20'%20y='35.2'%20width='20'%20height='1.5'%20rx='1'%20opacity='0.6'/%3E%3C/svg%3E"); }
151
+ .blog-thumb[data-thumb="sidebar"] { --thumb: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2060%2040'%3E%3Crect%20x='4'%20y='4'%20width='30'%20height='3'%20rx='1'%20opacity='1'/%3E%3Crect%20x='4'%20y='10'%20width='34'%20height='9'%20rx='1'%20opacity='0.45'/%3E%3Crect%20x='4'%20y='22.0'%20width='34'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='4'%20y='25.2'%20width='34'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='4'%20y='28.4'%20width='34'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='4'%20y='31.6'%20width='34'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='4'%20y='34.8'%20width='34'%20height='1.6'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='42'%20y='10'%20width='14'%20height='10'%20rx='1'%20opacity='0.35'/%3E%3Crect%20x='43'%20y='23'%20width='10'%20height='1.4'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='43'%20y='26'%20width='10'%20height='1.4'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='43'%20y='29'%20width='10'%20height='1.4'%20rx='1'%20opacity='0.6'/%3E%3Crect%20x='43'%20y='32'%20width='10'%20height='1.4'%20rx='1'%20opacity='0.6'/%3E%3C/svg%3E"); }
152
+ .blog-thumb[data-thumb="custom"] { --thumb: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2060%2040'%3E%3Crect%20x='6'%20y='5'%20width='48'%20height='30'%20rx='3'%20fill='none'%20stroke='%23000'%20stroke-width='1.6'%20stroke-dasharray='3%202.4'/%3E%3Crect%20x='28.8'%20y='14'%20width='2.4'%20height='12'%20rx='1'%20opacity='1'/%3E%3Crect%20x='24'%20y='18.8'%20width='12'%20height='2.4'%20rx='1'%20opacity='1'/%3E%3C/svg%3E"); }
153
+ .blog-layout-desc { margin: .6rem 0 0; font-size: var(--dm-text-xs); color: var(--dm-text-muted); min-height: 2.2em; }
154
+
155
+ /* ── Post editor: preview slideover ──────────────────────────────────── */
156
+ .blog-preview { display: flex; flex-direction: column; gap: .6rem; height: 100%; min-height: 70vh; }
157
+ .blog-preview-bar { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
158
+ .blog-preview-bar .form-select { width: auto; }
159
+ .blog-preview-state { font-size: var(--dm-text-xs); color: var(--dm-text-muted); }
160
+ .blog-seg { display: inline-flex; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); overflow: hidden; }
161
+ .blog-seg button { border: 0; background: none; padding: .3rem .55rem; cursor: pointer; color: var(--dm-text-muted); display: inline-flex; }
162
+ .blog-seg button + button { border-left: 1px solid var(--dm-border); }
163
+ .blog-seg button.is-on { background: color-mix(in srgb, var(--dm-primary) 14%, transparent); color: var(--dm-primary); }
164
+ .blog-preview-stage { flex: 1; display: flex; justify-content: center; overflow: auto; padding: .5rem;
165
+ border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); background: repeating-linear-gradient(45deg, transparent 0 8px, rgba(127,127,127,.06) 8px 16px); }
166
+ .blog-preview-stage iframe { width: 100%; height: 100%; min-height: 65vh; border: 0; border-radius: var(--dm-radius-sm); background: #fff;
167
+ box-shadow: 0 4px 18px rgba(0,0,0,.18); transition: width .2s ease; }
168
+ .blog-preview-stage.is-tablet iframe { width: 820px; max-width: 100%; }
169
+ .blog-preview-stage.is-phone iframe { width: 390px; max-width: 100%; }
170
+
171
+ /* ── Post editor header: Back, title, save state, actions ──────────────── */
172
+ .blog-editor-bar { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
173
+ .blog-editor-bar .view-header-actions { margin-left: auto; display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
174
+ .blog-editor-title { font-size: var(--dm-text-sm); font-weight: 600; color: var(--dm-text-muted); }
175
+ .blog-save-state { font-size: var(--dm-text-xs); color: var(--dm-text-muted); }
176
+ .blog-save-state.is-dirty { color: var(--dm-warning, #d97706); }
177
+ .blog-save-state.is-dirty::before { content: '●'; margin-right: .3rem; font-size: .7em; vertical-align: .1em; }
178
+ .blog-editor-bar .btn { display: inline-flex; align-items: center; gap: .35rem; }
@@ -1,45 +1,39 @@
1
- <div class="view-header d-flex align-items-center justify-content-between mb-4">
2
- <h1 class="h3 mb-0"><span data-icon="file-text"></span> Blog Posts</h1>
3
- <button id="btn-new-post" class="btn btn-primary">
4
- <span data-icon="plus"></span> New Post
5
- </button>
6
- </div>
1
+ <!-- No heading: the banner above is the title. Bound with M.applyBindings - see
2
+ admin/views/blog.js, which also inserts the row and chip templates (the router
3
+ renders this file through _.render first, which would eat any {{…}}). -->
4
+ <div class="blog-screen">
5
+ <div class="blog-bar">
6
+ <div class="blog-chips" data-each="chips.value key=key"></div>
7
+ <span data-help="Published posts are live. Drafts are only visible here. Scheduled posts go live on their date. Click a count to show only those."></span>
8
+ <span class="blog-bar-end">
9
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="create">
10
+ <span data-icon="plus" data-icon-size="14"></span> New post
11
+ </button>
12
+ <button type="button" class="btn btn-sm btn-ghost blog-more" aria-label="More" title="View the blog, sample posts, layouts">⋮</button>
13
+ <span data-help-title="Sample posts and layouts"
14
+ data-help="⋮ adds a set of sample posts - spread over every layout, with categories and pictures - so there is something to look at before you have written anything. Remove them from the same menu; only the samples go. Each post's look is a block you can edit under Blocks (blog-post-… and blog-card-…)."></span>
15
+ </span>
16
+ </div>
7
17
 
8
- <div class="grid grid-cols-4 gap-3 mb-4">
9
- <div class="card"><div class="card-body">
10
- <div class="text-muted small">Total</div>
11
- <div class="h3 mb-0" id="kpi-total">-</div>
12
- </div></div>
13
- <div class="card"><div class="card-body">
14
- <div class="text-muted small">Published</div>
15
- <div class="h3 mb-0" id="kpi-published">-</div>
16
- </div></div>
17
- <div class="card"><div class="card-body">
18
- <div class="text-muted small">Drafts</div>
19
- <div class="h3 mb-0" id="kpi-drafts">-</div>
20
- </div></div>
21
- <div class="card"><div class="card-body">
22
- <div class="text-muted small">Scheduled</div>
23
- <div class="h3 mb-0" id="kpi-scheduled">-</div>
24
- </div></div>
25
- </div>
18
+ <div class="blog-bar">
19
+ <input type="search" class="form-input blog-search" placeholder="Search posts…" aria-label="Search posts"
20
+ data-model="query.value">
21
+ <select class="form-select blog-select" aria-label="Category"
22
+ data-options="categoryOptions.value" data-options-text="name" data-options-value="id"
23
+ data-options-caption="'All categories'" data-model="category.value"></select>
24
+ <select class="form-select blog-select" aria-label="Sort" data-model="sort.value">
25
+ <option value="newest">Newest first</option>
26
+ <option value="oldest">Oldest first</option>
27
+ <option value="title">Title, A to Z</option>
28
+ </select>
29
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="reset" data-if="canReset.value">Clear filters</button>
30
+ <span data-help="Search matches titles, excerpts and slugs. Right-click a post, or use its ⋮, to publish, unpublish, duplicate or delete it."></span>
31
+ </div>
32
+
33
+ <p class="blog-state" data-if="loading.value">Loading posts…</p>
34
+ <p class="blog-state" data-if="isEmpty.value" data-bind-text="emptyText.value"></p>
26
35
 
27
- <div class="card mb-3">
28
- <div class="card-body">
29
- <div class="d-flex gap-2 flex-wrap">
30
- <select id="filter-status" class="form-select" style="width:auto;">
31
- <option value="">All statuses</option>
32
- <option value="draft">Draft</option>
33
- <option value="scheduled">Scheduled</option>
34
- <option value="published">Published</option>
35
- </select>
36
- <select id="filter-category" class="form-select" style="width:auto;">
37
- <option value="">All categories</option>
38
- </select>
39
- <input id="filter-search" type="text" class="form-control" style="width:240px;" placeholder="Search title or excerpt…">
40
- <button id="filter-reset" class="btn btn-ghost btn-sm">Reset</button>
41
- </div>
36
+ <div class="blog-list">
37
+ <div class="blog-rows" data-each="rows.value key=id"></div>
42
38
  </div>
43
39
  </div>
44
-
45
- <div id="posts-table"></div>
@@ -1,7 +1,20 @@
1
- <div class="view-header d-flex align-items-center justify-content-between mb-4">
2
- <h1 class="h3 mb-0">Blog Categories</h1>
3
- <button id="btn-new-category" class="btn btn-primary">
4
- <span data-icon="plus"></span> New Category
5
- </button>
1
+ <!-- No heading: the banner above is the title. Bound with M.applyBindings - see
2
+ admin/views/categories.js, which also inserts the row template. -->
3
+ <div class="blog-screen">
4
+ <div class="blog-bar">
5
+ <span class="blog-count" data-bind-text="countText.value"></span>
6
+ <span data-help="Categories group posts on the site, each with its own page at /blog/category/<slug>. Right-click a category, or use its ⋮, to edit it, see its posts or delete it."></span>
7
+ <span class="blog-bar-end">
8
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="create">
9
+ <span data-icon="plus" data-icon-size="14"></span> New category
10
+ </button>
11
+ </span>
12
+ </div>
13
+
14
+ <p class="blog-state" data-if="loading.value">Loading categories…</p>
15
+ <p class="blog-state" data-if="isEmpty.value">No categories yet - New category adds one.</p>
16
+
17
+ <div class="blog-list">
18
+ <div class="blog-rows" data-each="rows.value key=id"></div>
19
+ </div>
6
20
  </div>
7
- <div id="categories-table"></div>
@@ -1,11 +1,28 @@
1
- <div class="view-header mb-4">
2
- <h1 class="h3 mb-0">Blog Comments</h1>
3
- </div>
1
+ <!-- No heading: the banner above is the title. Bound with M.applyBindings - see
2
+ admin/views/comments.js, which also inserts the row and chip templates. -->
3
+ <div class="blog-screen">
4
+ <div class="blog-bar">
5
+ <div class="blog-chips" data-each="chips.value key=key"></div>
6
+ <span data-help="Pending comments wait for you before they appear on the site; Approved ones are live; Rejected and Spam never show. Right-click a comment, or use its ⋮, to moderate it. Click it to read all of it."></span>
7
+ <span class="blog-bar-end">
8
+ <input type="search" class="form-input blog-search" placeholder="Search comments…" aria-label="Search comments"
9
+ data-model="query.value">
10
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="approveAll" data-bind-disabled="!pendingCount.value" title="Approve every pending comment">
11
+ <span data-icon="check-circle" data-icon-size="14"></span> Approve all pending
12
+ </button>
13
+ </span>
14
+ </div>
4
15
 
5
- <div class="tab-list mb-3">
6
- <button class="tab-item active" data-tab="pending">Pending</button>
7
- <button class="tab-item" data-tab="approved">Approved</button>
8
- <button class="tab-item" data-tab="spam">Spam</button>
9
- </div>
16
+ <p class="blog-note" data-if="commentsOff.value">
17
+ Comments are switched off, so readers cannot leave new ones.
18
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="settings">Blog settings</button>
19
+ </p>
20
+ <p class="blog-note blog-note--quiet" data-if="!commentsOff.value" data-bind-text="moderationText.value"></p>
10
21
 
11
- <div id="comments-table"></div>
22
+ <p class="blog-state" data-if="loading.value">Loading comments…</p>
23
+ <p class="blog-state" data-if="isEmpty.value" data-bind-text="emptyText.value"></p>
24
+
25
+ <div class="blog-list">
26
+ <div class="blog-rows" data-each="rows.value key=id"></div>
27
+ </div>
28
+ </div>