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
@@ -1,199 +1,328 @@
1
1
  /**
2
- * Blog posts overview - KPI cards + filterable, sortable post table.
2
+ * Blog posts - the list, on the Tool upgrade path.
3
+ *
4
+ * State is domma-reactive observables bound with M.applyBindings; there is no
5
+ * render function. The four status counts are chips that also filter, which is
6
+ * what the KPI cards were taking a row of screen for. Everything you can do to
7
+ * a post is on its context menu (right-click, Shift+F10, or ⋮); a click on the
8
+ * row opens it.
3
9
  *
4
10
  * @module blog/admin/views/blog
5
11
  */
6
12
 
7
- function escapeHtml(s) {
8
- return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
9
- }
13
+ // The loader's cache-busting token, passed on so helpers are refetched with this view.
14
+ const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
10
15
 
11
- const BASE = '/api/plugins/blog';
16
+ const STATUSES = [
17
+ {key: '', label: 'All'},
18
+ {key: 'published', label: 'Published'},
19
+ {key: 'draft', label: 'Drafts'},
20
+ {key: 'scheduled', label: 'Scheduled'}
21
+ ];
12
22
 
13
- const STATUS_BADGE = {
14
- draft: 'badge-secondary',
15
- scheduled: 'badge-warning',
16
- published: 'badge-success'
17
- };
23
+ /**
24
+ * One post row - the body of the rows `data-each`. Here, not in blog.html,
25
+ * because the router renders a templateUrl through `_.render` first, which
26
+ * would replace every `{{…}}` with nothing. Mustache escapes its values.
27
+ */
28
+ const ROW_TEMPLATE = `
29
+ <div class="blog-row" data-id="{{id}}" tabindex="0">
30
+ <span class="blog-row-main">
31
+ <strong class="blog-row-title">{{title}}</strong>
32
+ <small class="blog-row-sub">{{sub}}</small>
33
+ </span>
34
+ <span class="badge {{badge}}">{{status}}</span>
35
+ <span class="blog-row-cats">{{cats}}</span>
36
+ <time class="blog-row-date" title="{{dateTitle}}">{{date}}</time>
37
+ <button type="button" class="blog-kebab" aria-label="Actions" title="Actions">⋮</button>
38
+ </div>`;
39
+
40
+ const CHIP_TEMPLATE = `<button type="button" class="blog-chip" data-status="{{key}}">{{label}} <b>{{count}}</b></button>`;
41
+
42
+ const BADGE = {draft: 'badge-secondary', scheduled: 'badge-warning', published: 'badge-success'};
18
43
 
19
44
  export const blogView = {
20
45
  templateUrl: '/plugins/blog/admin/templates/blog.html',
21
46
 
22
47
  async onMount($container) {
23
- let allPosts = [];
24
- let categories = [];
25
- let postsTable = null;
26
-
27
- function buildTable(data) {
28
- const tableEl = $container.find('#posts-table').get(0);
29
- if (!tableEl) return;
30
-
31
- if (postsTable) {
32
- postsTable.setData(data);
33
- Domma.icons.scan(tableEl);
34
- return;
35
- }
48
+ // Not awaited: the cog is chrome, and the screen should not wait on it.
49
+ import(`./settings-cog.js?v=${ASSET_VERSION}`)
50
+ .then(m => m.addSettingsCog($container, '.blog-bar'))
51
+ .catch(() => { /* no cog is better than no screen */ });
36
52
 
37
- const catNameById = (id) => categories.find((c) => c.id === id)?.name ?? '';
38
-
39
- postsTable = T.create(tableEl, {
40
- data,
41
- columns: [
42
- {
43
- key: 'title',
44
- title: 'Title',
45
- sortable: true,
46
- render: (v, row) =>
47
- `<a href="#/plugins/blog/posts/${escapeHtml(row.id)}" style="font-weight:600;">${escapeHtml(v || 'Untitled')}</a>`
48
- },
49
- {
50
- key: 'status',
51
- title: 'Status',
52
- sortable: true,
53
- render: (v) => {
54
- const cls = STATUS_BADGE[v] || 'badge-secondary';
55
- return `<span class="badge ${cls}">${escapeHtml(v ?? 'draft')}</span>`;
56
- }
57
- },
58
- {
59
- key: 'categories',
60
- title: 'Categories',
61
- sortable: false,
62
- render: (val) => Array.isArray(val) && val.length
63
- ? val.map((id) => `<span class="badge badge-secondary mr-1">${escapeHtml(catNameById(id) || id)}</span>`).join('')
64
- : '<span style="opacity:0.4;">-</span>'
65
- },
66
- {
67
- key: 'publishedAt',
68
- title: 'Published',
69
- sortable: true,
70
- render: (v) => v
71
- ? `<span title="${escapeHtml(D(v).format('DD MMM YYYY HH:mm'))}">${escapeHtml(D(v).format('DD MMM YYYY'))}</span>`
72
- : '<span style="opacity:0.4;">-</span>'
73
- },
74
- {
75
- key: 'id',
76
- title: 'Actions',
77
- sortable: false,
78
- render: (id, row) => {
79
- const slug = escapeHtml(row.slug || '');
80
- const viewLink = row.status === 'published' && slug
81
- ? `<a href="/blog/${slug}" target="_blank" class="btn btn-sm btn-outline" title="View on site"><span data-icon="external-link" data-icon-size="14"></span></a>`
82
- : '';
83
- return `<div style="display:flex;gap:.4rem;">` +
84
- `<button class="btn btn-sm btn-outline post-edit-btn" data-id="${escapeHtml(id)}"><span data-icon="edit" data-icon-size="14"></span></button>` +
85
- viewLink +
86
- `<button class="btn btn-sm btn-danger post-delete-btn" data-id="${escapeHtml(id)}"><span data-icon="trash" data-icon-size="14"></span></button>` +
87
- `</div>`;
88
- }
89
- }
90
- ],
91
- emptyMessage: 'No posts found.'
92
- });
53
+ const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
54
+ const {BASE} = kit;
55
+ const add = kit.viewScope('posts', /^#\/plugins\/blog(\/posts)?\/?$/);
56
+ const root = $container.get(0);
93
57
 
94
- tableEl.addEventListener('click', async (e) => {
95
- const editBtn = e.target.closest('.post-edit-btn');
96
- const deleteBtn = e.target.closest('.post-delete-btn');
58
+ // ---- State ----
59
+ const posts = M.observable([]);
60
+ const categories = M.observable([]);
61
+ const status = M.observable('');
62
+ const category = M.observable(kit.takeHandOff('category') || '');
63
+ const query = M.observable('');
64
+ const sort = M.observable('newest');
65
+ const loading = M.observable(true);
66
+ let site = '/blog';
97
67
 
98
- if (editBtn) {
99
- R.navigate('#/plugins/blog/posts/' + editBtn.dataset.id);
100
- }
68
+ const catName = (id) => categories.peek().find(c => c.id === id)?.name ?? id;
69
+ const when = (p) => p.publishedAt || p.updatedAt || p.createdAt || '';
101
70
 
102
- if (deleteBtn) {
103
- const id = deleteBtn.dataset.id;
104
- const post = allPosts.find((p) => p.id === id);
105
- const ok = await E.confirm(`Delete "${post?.title ?? 'this post'}"?`);
106
- if (!ok) return;
107
- try {
108
- await H.delete(`${BASE}/posts/${id}`);
109
- E.toast('Post deleted.', { type: 'success' });
110
- await reload();
111
- } catch {
112
- E.toast('Failed to delete post.', { type: 'error' });
113
- }
114
- }
71
+ const categoryOptions = M.computed(() => categories.value.map(c => ({id: c.id, name: c.name ?? c.slug ?? c.id})));
72
+
73
+ // Plain observables fed by effects: the admin's Domma never re-renders
74
+ // a data-each whose source is a computed.
75
+ const rows = M.observable([]);
76
+ const chips = M.observable([]);
77
+ add(M.effect(() => {
78
+ const term = query.value.trim().toLowerCase();
79
+ const list = posts.value.filter(p =>
80
+ (!status.value || p.status === status.value)
81
+ && (!category.value || (Array.isArray(p.categories) && p.categories.includes(category.value)))
82
+ && (!term || [p.title, p.excerpt, p.slug].some(v => String(v ?? '').toLowerCase().includes(term))));
83
+ const order = {
84
+ newest: (a, b) => String(when(b)).localeCompare(String(when(a))),
85
+ oldest: (a, b) => String(when(a)).localeCompare(String(when(b))),
86
+ title: (a, b) => String(a.title ?? '').localeCompare(String(b.title ?? ''), undefined, {sensitivity: 'base'})
87
+ }[sort.value];
88
+ categories.value; // re-label when categories arrive
89
+ rows.value = [...list].sort(order).map(p => {
90
+ const at = when(p);
91
+ const label = p.status === 'published' ? 'Published' : p.status === 'scheduled' ? 'Scheduled for' : 'Updated';
92
+ return {
93
+ id: p.id,
94
+ title: p.title || 'Untitled',
95
+ sub: `${p.sample ? 'Sample · ' : ''}/${p.slug || ''}${p.excerpt ? ' · ' + String(p.excerpt).slice(0, 90) : ''}`,
96
+ status: p.status ?? 'draft',
97
+ badge: BADGE[p.status] || 'badge-secondary',
98
+ cats: (p.categories || []).map(catName).join(', '),
99
+ date: at ? D(at).format('DD MMM YYYY') : '',
100
+ dateTitle: at ? `${label} ${D(at).format('DD MMM YYYY HH:mm')}` : ''
101
+ };
115
102
  });
103
+ }));
104
+ add(M.effect(() => {
105
+ const all = posts.value;
106
+ chips.value = STATUSES.map(s => ({
107
+ key: s.key || 'all', label: s.label,
108
+ count: s.key ? all.filter(p => (p.status ?? 'draft') === s.key).length : all.length
109
+ }));
110
+ }));
111
+ // The active chip, marked without re-rendering the list.
112
+ add(M.effect(() => {
113
+ const active = status.value || 'all';
114
+ chips.value;
115
+ queueMicrotask(() => root.querySelectorAll('.blog-chip').forEach(c =>
116
+ c.classList.toggle('is-active', c.dataset.status === active)));
117
+ }));
118
+
119
+ const filtered = M.computed(() => Boolean(status.value || category.value || query.value.trim()));
120
+ const data = {
121
+ rows, chips, query, category, categoryOptions, sort, loading,
122
+ hasRows: M.computed(() => rows.value.length > 0),
123
+ isEmpty: M.computed(() => !loading.value && rows.value.length === 0),
124
+ emptyText: M.computed(() => filtered.value ? 'No posts match these filters.' : 'No posts yet - New post starts one, or ⋮ adds some samples to look at.'),
125
+ canReset: filtered
126
+ };
127
+
128
+ // ---- Server ----
129
+ const byId = (id) => posts.peek().find(p => p.id === id);
116
130
 
117
- Domma.icons.scan(tableEl);
131
+ async function load() {
132
+ loading.value = true;
133
+ try {
134
+ const [postsRes, catsRes] = await Promise.all([
135
+ H.get(`${BASE}/posts`),
136
+ H.get(`${BASE}/categories`).catch(() => [])
137
+ ]);
138
+ site = await kit.sitePath();
139
+ // GET /posts sends a BARE ARRAY; `.data` is only for a wrapped reply.
140
+ posts.value = postsRes?.data ?? (Array.isArray(postsRes) ? postsRes : []);
141
+ categories.value = catsRes?.data ?? (Array.isArray(catsRes) ? catsRes : []);
142
+ } catch {
143
+ E.toast('Failed to load posts.', {type: 'error'});
144
+ } finally {
145
+ loading.value = false;
146
+ }
118
147
  }
119
148
 
120
- function refreshKpis() {
121
- const counts = { total: allPosts.length, published: 0, draft: 0, scheduled: 0 };
122
- for (const p of allPosts) {
123
- if (p.status === 'published') counts.published++;
124
- else if (p.status === 'scheduled') counts.scheduled++;
125
- else counts.draft++;
149
+ /** Replace one post in place, as a NEW array, from a route's reply. */
150
+ const upsert = (post) => {
151
+ const list = posts.peek();
152
+ posts.value = list.some(p => p.id === post.id) ? list.map(p => (p.id === post.id ? post : p)) : [post, ...list];
153
+ };
154
+
155
+ async function publish(id) {
156
+ try {
157
+ upsert(await H.post(`${BASE}/posts/${id}/publish`, {}));
158
+ E.toast('Post published.', {type: 'success'});
159
+ } catch (err) {
160
+ E.toast(err?.message ?? 'Failed to publish.', {type: 'error'});
126
161
  }
127
- $container.find('#kpi-total').text(counts.total);
128
- $container.find('#kpi-published').text(counts.published);
129
- $container.find('#kpi-drafts').text(counts.draft);
130
- $container.find('#kpi-scheduled').text(counts.scheduled);
131
162
  }
132
163
 
133
- function applyFilters() {
134
- const status = $container.find('#filter-status').val() || '';
135
- const cat = $container.find('#filter-category').val() || '';
136
- const query = ($container.find('#filter-search').val() || '').toString().toLowerCase().trim();
137
-
138
- let filtered = allPosts;
139
- if (status) filtered = filtered.filter((p) => p.status === status);
140
- if (cat) filtered = filtered.filter((p) => Array.isArray(p.categories) && p.categories.includes(cat));
141
- if (query) {
142
- filtered = filtered.filter((p) =>
143
- (p.title ?? '').toLowerCase().includes(query) ||
144
- (p.excerpt ?? '').toLowerCase().includes(query)
145
- );
164
+ async function unpublish(id) {
165
+ try {
166
+ upsert(await H.put(`${BASE}/posts/${id}`, {status: 'draft'}));
167
+ E.toast('Post moved back to drafts.', {type: 'success'});
168
+ } catch (err) {
169
+ E.toast(err?.message ?? 'Failed to unpublish.', {type: 'error'});
146
170
  }
147
- buildTable(filtered);
148
171
  }
149
172
 
150
- function populateCategoryFilter() {
151
- const select = $container.find('#filter-category').get(0);
152
- if (!select) return;
153
- for (const cat of categories) {
154
- const opt = document.createElement('option');
155
- opt.value = cat.id;
156
- opt.textContent = cat.name ?? cat.slug ?? cat.id;
157
- select.appendChild(opt);
173
+ async function duplicate(id) {
174
+ const p = byId(id);
175
+ if (!p) return;
176
+ try {
177
+ const copy = await H.post(`${BASE}/posts`, {
178
+ title: `${p.title || 'Untitled'} (copy)`,
179
+ slug: `${p.slug || 'post'}-copy-${Date.now().toString(36)}`,
180
+ content: p.content ?? '', excerpt: p.excerpt ?? '', featuredImage: p.featuredImage ?? null,
181
+ categories: p.categories ?? [], tags: p.tags ?? [], template: p.template ?? '', status: 'draft'
182
+ });
183
+ upsert(copy);
184
+ E.toast('Copied as a draft.', {type: 'success'});
185
+ } catch (err) {
186
+ E.toast(err?.message ?? 'Failed to duplicate.', {type: 'error'});
158
187
  }
159
188
  }
160
189
 
161
- async function reload() {
190
+ async function remove(id) {
191
+ const p = byId(id);
192
+ // No title in the prompt: E.confirm is not promised to escape it.
193
+ const ok = await E.confirm(p?.status === 'published'
194
+ ? 'Delete this published post? Its page goes offline and links to it will break.'
195
+ : 'Delete this post? This cannot be undone.');
196
+ if (!ok) return;
162
197
  try {
163
- const [postsRes, catsRes] = await Promise.all([
164
- H.get(`${BASE}/posts`),
165
- H.get(`${BASE}/categories`).catch(() => ({ data: [] }))
166
- ]);
167
- if (postsRes.error) throw new Error(postsRes.error);
168
- allPosts = postsRes.data ?? [];
169
- categories = catsRes.data ?? catsRes ?? [];
198
+ await H.delete(`${BASE}/posts/${id}`);
199
+ posts.value = posts.peek().filter(x => x.id !== id);
200
+ E.toast('Post deleted.', {type: 'success'});
170
201
  } catch {
171
- allPosts = [];
172
- categories = [];
173
- E.toast('Failed to load posts.', { type: 'error' });
202
+ E.toast('Failed to delete post.', {type: 'error'});
174
203
  }
175
- refreshKpis();
176
- populateCategoryFilter();
177
- applyFilters();
178
204
  }
179
205
 
180
- $container.find('#btn-new-post').on('click', () => R.navigate('#/plugins/blog/posts/new'));
181
- $container.find('#filter-status').on('change', applyFilters);
182
- $container.find('#filter-category').on('change', applyFilters);
183
- $container.find('#filter-reset').on('click', () => {
184
- $container.find('#filter-status').val('');
185
- $container.find('#filter-category').val('');
186
- $container.find('#filter-search').val('');
187
- applyFilters();
206
+ async function copyLink(p) {
207
+ try {
208
+ await navigator.clipboard.writeText(`${location.origin}${site}/${p.slug}`);
209
+ E.toast('Link copied.', {type: 'success'});
210
+ } catch {
211
+ E.toast('The browser would not allow copying.', {type: 'error'});
212
+ }
213
+ }
214
+
215
+ // ---- Bindings ----
216
+ // Refs and help BEFORE binding: data-if lifts elements out of the document.
217
+ const list = root.querySelector('.blog-list');
218
+ root.querySelector('.blog-rows').innerHTML = ROW_TEMPLATE;
219
+ root.querySelector('.blog-chips').innerHTML = CHIP_TEMPLATE;
220
+ add(await kit.scanHelp(root));
221
+ const handle = M.applyBindings(data, root, {
222
+ methods: {
223
+ create: () => R.navigate('#/plugins/blog/posts/new'),
224
+ reset() {
225
+ status.value = '';
226
+ category.value = '';
227
+ query.value = '';
228
+ }
229
+ }
230
+ });
231
+ add(() => handle.dispose());
232
+
233
+ root.querySelector('.blog-chips').addEventListener('click', (e) => {
234
+ const chip = e.target.closest('.blog-chip');
235
+ if (chip) status.value = chip.dataset.status === 'all' ? '' : chip.dataset.status;
236
+ });
237
+
238
+ // ---- Context menu: every action on a post ----
239
+ const menu = E.contextMenu(list, {
240
+ match: '.blog-row',
241
+ inherit: false,
242
+ density: 'compact',
243
+ items: (row) => {
244
+ const p = byId(row.dataset.id);
245
+ if (!p) return [];
246
+ const live = p.status === 'published';
247
+ return kit.tidy([
248
+ {label: 'Edit', icon: 'edit', action: () => R.navigate(`#/plugins/blog/posts/${p.id}`)},
249
+ {label: 'View on site', icon: 'external-link', visible: live && Boolean(p.slug),
250
+ action: () => window.open(`${site}/${encodeURIComponent(p.slug)}`, '_blank', 'noopener')},
251
+ {label: 'Copy link', icon: 'link', visible: live && Boolean(p.slug), action: () => copyLink(p)},
252
+ {type: 'divider'},
253
+ {label: 'Publish now', icon: 'check-circle', visible: !live, action: () => publish(p.id)},
254
+ {label: 'Unpublish', icon: 'eye-off', visible: p.status !== 'draft', action: () => unpublish(p.id)},
255
+ {label: 'Duplicate as draft', icon: 'copy', action: () => duplicate(p.id)},
256
+ {type: 'divider'},
257
+ {label: 'Delete', icon: 'trash', danger: true, action: () => remove(p.id)}
258
+ ]);
259
+ }
188
260
  });
261
+ add(() => menu?.destroy?.());
189
262
 
190
- let searchTimer = null;
191
- $container.find('#filter-search').on('input', () => {
192
- clearTimeout(searchTimer);
193
- searchTimer = setTimeout(applyFilters, 250);
263
+ // ---- The bar's ⋮: the blog as a whole ----
264
+ // Adding and removing samples is admin-only on the server; ask it who
265
+ // this is rather than trusting whatever the browser cached at login.
266
+ let isAdmin = false;
267
+ H.get('/api/auth/me')
268
+ .then(me => { isAdmin = ['admin', 'super-admin'].includes((me?.data ?? me)?.role); })
269
+ .catch(() => { /* not shown, then */ });
270
+ const samples = () => posts.peek().filter(p => p.sample === true).length;
271
+ const barMenu = E.contextMenu(root.querySelector('.blog-bar-end'), {
272
+ match: '.blog-more',
273
+ inherit: false,
274
+ density: 'compact',
275
+ items: () => kit.tidy([
276
+ {label: 'View the blog', icon: 'external-link', action: () => window.open(site, '_blank', 'noopener')},
277
+ {label: 'Edit layouts in Blocks', icon: 'layout', action: () => R.navigate('#/blocks')},
278
+ {type: 'divider'},
279
+ {label: 'Add sample posts', icon: 'file-plus', visible: isAdmin && !samples(), action: addSamples},
280
+ {label: `Remove ${samples()} sample post${samples() === 1 ? '' : 's'}…`, icon: 'trash', danger: true,
281
+ visible: isAdmin && samples() > 0, action: removeSamples}
282
+ ])
283
+ });
284
+ add(() => barMenu?.destroy?.());
285
+ root.querySelector('.blog-bar-end').addEventListener('click', (e) => {
286
+ const more = e.target.closest('.blog-more');
287
+ if (more) kit.openMenuFrom(more);
288
+ });
289
+
290
+ async function addSamples() {
291
+ try {
292
+ const res = await H.post(`${BASE}/samples`, {});
293
+ if (res?.error) throw new Error(res.error);
294
+ E.toast(res.added ? `Added ${res.added} sample posts. Remove them from ⋮ whenever you like.` : 'The sample posts are already here.', {type: 'success'});
295
+ await load();
296
+ } catch (err) {
297
+ E.toast(err?.message ?? 'Could not add the sample posts.', {type: 'error'});
298
+ }
299
+ }
300
+
301
+ async function removeSamples() {
302
+ if (!(await E.confirm('Remove the sample posts, and any sample category nothing else uses? Your own posts are not touched.'))) return;
303
+ try {
304
+ const res = await H.delete(`${BASE}/samples`);
305
+ if (res?.error) throw new Error(res.error);
306
+ E.toast(`Removed ${res.posts} sample posts.`, {type: 'success'});
307
+ await load();
308
+ } catch (err) {
309
+ E.toast(err?.message ?? 'Could not remove the sample posts.', {type: 'error'});
310
+ }
311
+ }
312
+
313
+ list.addEventListener('click', (e) => {
314
+ const row = e.target.closest('.blog-row');
315
+ if (!row) return;
316
+ const kebab = e.target.closest('.blog-kebab');
317
+ if (kebab) return kit.openMenuFrom(kebab);
318
+ R.navigate(`#/plugins/blog/posts/${row.dataset.id}`);
319
+ });
320
+ list.addEventListener('keydown', (e) => {
321
+ const row = e.target.closest?.('.blog-row');
322
+ if (row && e.key === 'Enter' && e.target === row) R.navigate(`#/plugins/blog/posts/${row.dataset.id}`);
194
323
  });
195
324
 
196
- await reload();
197
- Domma.icons.scan();
325
+ if (typeof I !== 'undefined' && I.scan) I.scan(root);
326
+ await load();
198
327
  }
199
328
  };