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,187 +1,236 @@
1
1
  /**
2
- * Blog comments admin view.
2
+ * Blog comments, on the Tool upgrade path.
3
+ *
4
+ * Every comment is loaded once and filtered in the browser, so each status
5
+ * carries its count - "Pending 3" is the whole point of a moderation screen,
6
+ * and the old tabs could not say it (nor reach Rejected at all). Rows show the
7
+ * post's title rather than its id; a click expands the full comment in place;
8
+ * moderation is on the row's context menu (right-click, Shift+F10, or ⋮).
3
9
  *
4
10
  * @module blog/admin/views/comments
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
- }
10
-
11
- const BASE = '/api/plugins/blog';
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';
15
+
16
+ const STATUSES = [
17
+ {key: 'pending', label: 'Pending'},
18
+ {key: 'approved', label: 'Approved'},
19
+ {key: 'rejected', label: 'Rejected'},
20
+ {key: 'spam', label: 'Spam'},
21
+ {key: 'all', label: 'All'}
22
+ ];
23
+
24
+ /** One row. In JS, not the template: the router's _.render would eat {{…}}. */
25
+ const ROW_TEMPLATE = `
26
+ <div class="blog-row blog-comment" data-id="{{id}}" tabindex="0">
27
+ <span class="blog-row-main">
28
+ <strong class="blog-row-title">{{author}} <small class="blog-row-sub-inline">{{email}}</small></strong>
29
+ <span class="blog-comment-text">{{body}}</span>
30
+ <small class="blog-row-sub">on {{post}}</small>
31
+ </span>
32
+ <span class="badge {{badge}}">{{status}}</span>
33
+ <time class="blog-row-date" title="{{dateTitle}}">{{date}}</time>
34
+ <button type="button" class="blog-kebab" aria-label="Actions" title="Actions">⋮</button>
35
+ </div>`;
36
+
37
+ const CHIP_TEMPLATE = `<button type="button" class="blog-chip" data-status="{{key}}">{{label}} <b>{{count}}</b></button>`;
38
+
39
+ const BADGE = {pending: 'badge-warning', approved: 'badge-success', rejected: 'badge-secondary', spam: 'badge-danger'};
12
40
 
13
41
  export const commentsView = {
14
42
  templateUrl: '/plugins/blog/admin/templates/comments.html',
15
43
 
16
44
  /**
17
- * Mount the blog comments view.
18
- *
19
45
  * @param {object} $container - Domma-wrapped container element
20
46
  * @returns {Promise<void>}
21
47
  */
22
48
  async onMount($container) {
23
- let currentTab = 'pending';
24
- let comments = [];
25
- let commentsTable = null;
26
-
27
- // ---------------------------------------------------------------
28
- // Load + render
29
- // ---------------------------------------------------------------
30
-
31
- async function loadComments() {
49
+ // Not awaited: the cog is chrome, and the screen should not wait on it.
50
+ import(`./settings-cog.js?v=${ASSET_VERSION}`)
51
+ .then(m => m.addSettingsCog($container, '.blog-bar'))
52
+ .catch(() => { /* no cog is better than no screen */ });
53
+
54
+ const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
55
+ const {BASE} = kit;
56
+ const add = kit.viewScope('comments', /^#\/plugins\/blog\/comments\b/);
57
+ const root = $container.get(0);
58
+
59
+ // ---- State ----
60
+ const comments = M.observable([]);
61
+ const posts = M.observable([]);
62
+ const status = M.observable('pending');
63
+ const query = M.observable('');
64
+ const loading = M.observable(true);
65
+ const settings = M.observable({});
66
+
67
+ const postTitle = (id) => posts.peek().find(p => p.id === id)?.title || 'a deleted post';
68
+
69
+ // Moderating moves the "waiting for approval" badge on Blog in the sidebar. An
70
+ // event rather than the admin's sidebarCount(): a plugin must not
71
+ // import admin/ statically.
72
+ add(M.effect(() => {
73
+ comments.value;
74
+ if (!loading.peek()) {
75
+ window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: '#/plugins/blog'}}));
76
+ }
77
+ }));
78
+
79
+ const rows = M.observable([]);
80
+ const chips = M.observable([]);
81
+ add(M.effect(() => {
82
+ const term = query.value.trim().toLowerCase();
83
+ posts.value;
84
+ rows.value = comments.value
85
+ .filter(c => (status.value === 'all' || c.status === status.value)
86
+ && (!term || [c.authorName, c.authorEmail, c.body].some(v => String(v ?? '').toLowerCase().includes(term))))
87
+ .map(c => ({
88
+ id: c.id,
89
+ author: c.authorName || 'Anonymous',
90
+ email: c.authorEmail || '',
91
+ body: c.body || '',
92
+ post: postTitle(c.postId),
93
+ status: c.status || 'pending',
94
+ badge: BADGE[c.status] || 'badge-secondary',
95
+ date: c.createdAt ? D(c.createdAt).fromNow() : '',
96
+ dateTitle: c.createdAt ? D(c.createdAt).format('DD MMM YYYY HH:mm') : ''
97
+ }));
98
+ }));
99
+ add(M.effect(() => {
100
+ const all = comments.value;
101
+ chips.value = STATUSES.map(s => ({
102
+ ...s, count: s.key === 'all' ? all.length : all.filter(c => c.status === s.key).length
103
+ }));
104
+ }));
105
+ add(M.effect(() => {
106
+ const active = status.value;
107
+ chips.value;
108
+ queueMicrotask(() => root.querySelectorAll('.blog-chip').forEach(c =>
109
+ c.classList.toggle('is-active', c.dataset.status === active)));
110
+ }));
111
+
112
+ const data = {
113
+ rows, chips, query, loading,
114
+ pendingCount: M.computed(() => comments.value.filter(c => c.status === 'pending').length),
115
+ isEmpty: M.computed(() => !loading.value && rows.value.length === 0),
116
+ emptyText: M.computed(() => query.value.trim()
117
+ ? 'No comments match the search.'
118
+ : status.value === 'pending' ? 'Nothing waiting for moderation.' : `No ${status.value === 'all' ? '' : status.value + ' '}comments.`),
119
+ commentsOff: M.computed(() => settings.value.commentsEnabled === false),
120
+ moderationText: M.computed(() => settings.value.commentModeration === false
121
+ ? 'New comments appear straight away.' : 'New comments wait here for approval.')
122
+ };
123
+
124
+ // ---- Server ----
125
+ const byId = (id) => comments.peek().find(c => c.id === id);
126
+
127
+ async function load() {
128
+ loading.value = true;
32
129
  try {
33
- const res = await H.get(`${BASE}/comments?status=${encodeURIComponent(currentTab)}`);
34
- if (res.error) throw new Error(res.error);
35
- comments = res.data ?? (Array.isArray(res) ? res : []);
130
+ const [commentsRes, postsRes, settingsRes] = await Promise.all([
131
+ H.get(`${BASE}/comments`),
132
+ H.get(`${BASE}/posts`).catch(() => []),
133
+ H.get(`${BASE}/settings`).catch(() => ({}))
134
+ ]);
135
+ comments.value = commentsRes?.data ?? (Array.isArray(commentsRes) ? commentsRes : []);
136
+ posts.value = postsRes?.data ?? (Array.isArray(postsRes) ? postsRes : []);
137
+ settings.value = settingsRes?.data ?? settingsRes ?? {};
36
138
  } catch {
37
- comments = [];
38
- E.toast('Failed to load comments.', { type: 'error' });
139
+ E.toast('Failed to load comments.', {type: 'error'});
140
+ } finally {
141
+ loading.value = false;
39
142
  }
40
- buildTable();
41
143
  }
42
144
 
43
- function buildTable() {
44
- const tableEl = $container.find('#comments-table').get(0);
45
- if (!tableEl) return;
46
-
47
- if (commentsTable) {
48
- commentsTable.setData(comments);
49
- Domma.icons.scan();
50
- return;
51
- }
52
-
53
- commentsTable = T.create(tableEl, {
54
- data: comments,
55
- columns: [
56
- {
57
- key: 'authorName',
58
- title: 'Author',
59
- sortable: true,
60
- render: (v) => `<strong>${escapeHtml(v)}</strong>`
61
- },
62
- {
63
- key: 'authorEmail',
64
- title: 'Email',
65
- sortable: false,
66
- render: (v) => v
67
- ? `<a href="mailto:${escapeHtml(v)}">${escapeHtml(v)}</a>`
68
- : '<span style="opacity:0.4;">-</span>'
69
- },
70
- {
71
- key: 'body',
72
- title: 'Comment',
73
- sortable: false,
74
- render: (v) => {
75
- const preview = String(v ?? '').slice(0, 80);
76
- const ellipsis = String(v ?? '').length > 80 ? '\u2026' : '';
77
- return `<span style="font-size:0.87rem;">${escapeHtml(preview)}${ellipsis}</span>`;
78
- }
79
- },
80
- {
81
- key: 'postId',
82
- title: 'Post',
83
- sortable: false,
84
- render: (v) => v
85
- ? `<a href="#/plugins/blog/posts/${escapeHtml(v)}">${escapeHtml(v)}</a>`
86
- : '<span style="opacity:0.4;">-</span>'
87
- },
88
- {
89
- key: 'createdAt',
90
- title: 'Date',
91
- sortable: true,
92
- render: (v) => v
93
- ? `<span title="${escapeHtml(D(v).format('DD MMM YYYY HH:mm'))}">${escapeHtml(D(v).fromNow())}</span>`
94
- : '<span style="opacity:0.4;">-</span>'
95
- },
96
- {
97
- key: 'id',
98
- title: 'Actions',
99
- sortable: false,
100
- render: (id) => {
101
- const btns = [];
102
- if (currentTab !== 'approved') {
103
- btns.push(`<button class="btn btn-sm btn-success comment-approve-btn" data-id="${escapeHtml(id)}">Approve</button>`);
104
- }
105
- if (currentTab !== 'spam') {
106
- btns.push(`<button class="btn btn-sm btn-warning comment-spam-btn" data-id="${escapeHtml(id)}">Spam</button>`);
107
- }
108
- if (currentTab !== 'rejected') {
109
- btns.push(`<button class="btn btn-sm btn-outline comment-reject-btn" data-id="${escapeHtml(id)}">Reject</button>`);
110
- }
111
- btns.push(`<button class="btn btn-sm btn-danger comment-delete-btn" data-id="${escapeHtml(id)}">` +
112
- `<span data-icon="trash" data-icon-size="14"></span></button>`);
113
- return `<div style="display:flex;gap:.3rem;flex-wrap:wrap;">${btns.join('')}</div>`;
114
- }
115
- }
116
- ],
117
- emptyMessage: `No ${currentTab} comments.`
118
- });
119
-
120
- // Event delegation - attach once
121
- if (!tableEl.dataset.listenerAttached) {
122
- tableEl.dataset.listenerAttached = '1';
123
- tableEl.addEventListener('click', async (e) => {
124
- const approveBtn = e.target.closest('.comment-approve-btn');
125
- const rejectBtn = e.target.closest('.comment-reject-btn');
126
- const spamBtn = e.target.closest('.comment-spam-btn');
127
- const deleteBtn = e.target.closest('.comment-delete-btn');
128
-
129
- if (approveBtn) await updateStatus(approveBtn.dataset.id, 'approved');
130
- if (rejectBtn) await updateStatus(rejectBtn.dataset.id, 'rejected');
131
- if (spamBtn) await updateStatus(spamBtn.dataset.id, 'spam');
132
-
133
- if (deleteBtn) {
134
- const ok = await E.confirm('Delete this comment?');
135
- if (!ok) return;
136
- try {
137
- await H.delete(`${BASE}/comments/${deleteBtn.dataset.id}`);
138
- E.toast('Comment deleted.', { type: 'success' });
139
- await loadComments();
140
- } catch {
141
- E.toast('Failed to delete comment.', { type: 'error' });
142
- }
143
- }
144
- });
145
+ async function setStatus(id, next) {
146
+ try {
147
+ const res = await H.put(`${BASE}/comments/${id}`, {status: next});
148
+ if (res?.error) throw new Error(res.error);
149
+ const updated = res?.data ?? res;
150
+ comments.value = comments.peek().map(c => (c.id === id ? {...c, ...(updated?.id ? updated : {status: next})} : c));
151
+ E.toast(`Comment marked ${next}.`, {type: 'success'});
152
+ } catch {
153
+ E.toast('Failed to update comment.', {type: 'error'});
145
154
  }
146
-
147
- Domma.icons.scan();
148
155
  }
149
156
 
150
- async function updateStatus(commentId, status) {
157
+ async function remove(id) {
158
+ const ok = await E.confirm('Delete this comment? This cannot be undone.');
159
+ if (!ok) return;
151
160
  try {
152
- const res = await H.put(`${BASE}/comments/${commentId}`, { status });
153
- if (res.error) throw new Error(res.error);
154
- E.toast(`Comment marked as ${status}.`, { type: 'success' });
155
- await loadComments();
161
+ await H.delete(`${BASE}/comments/${id}`);
162
+ comments.value = comments.peek().filter(c => c.id !== id);
163
+ E.toast('Comment deleted.', {type: 'success'});
156
164
  } catch {
157
- E.toast('Failed to update comment.', { type: 'error' });
165
+ E.toast('Failed to delete comment.', {type: 'error'});
158
166
  }
159
167
  }
160
168
 
161
- // ---------------------------------------------------------------
162
- // Tab switching
163
- // ---------------------------------------------------------------
164
-
165
- const tabList = $container.find('.tab-list').get(0);
166
- if (tabList) {
167
- tabList.addEventListener('click', async (e) => {
168
- const tab = e.target.closest('.tab-item');
169
- if (!tab) return;
170
- currentTab = tab.dataset.tab;
171
-
172
- // Update active class
173
- tabList.querySelectorAll('.tab-item').forEach((t) => {
174
- t.classList.toggle('active', t === tab);
175
- });
176
-
177
- await loadComments();
178
- });
179
- }
180
-
181
- // ---------------------------------------------------------------
182
- // Initial load
183
- // ---------------------------------------------------------------
184
- await loadComments();
185
- Domma.icons.scan();
169
+ // ---- Bindings ----
170
+ const list = root.querySelector('.blog-list');
171
+ root.querySelector('.blog-rows').innerHTML = ROW_TEMPLATE;
172
+ root.querySelector('.blog-chips').innerHTML = CHIP_TEMPLATE;
173
+ add(await kit.scanHelp(root));
174
+ const handle = M.applyBindings(data, root, {
175
+ methods: {
176
+ settings: () => kit.openSettings().then(() => {}),
177
+ async approveAll() {
178
+ const pending = comments.peek().filter(c => c.status === 'pending');
179
+ if (!pending.length) return;
180
+ const ok = await E.confirm(`Approve all ${pending.length} pending comment${pending.length === 1 ? '' : 's'}?`);
181
+ if (!ok) return;
182
+ for (const c of pending) await setStatus(c.id, 'approved');
183
+ }
184
+ }
185
+ });
186
+ add(() => handle.dispose());
187
+
188
+ root.querySelector('.blog-chips').addEventListener('click', (e) => {
189
+ const chip = e.target.closest('.blog-chip');
190
+ if (chip) status.value = chip.dataset.status;
191
+ });
192
+
193
+ const menu = E.contextMenu(list, {
194
+ match: '.blog-row',
195
+ inherit: false,
196
+ density: 'compact',
197
+ items: (row) => {
198
+ const c = byId(row.dataset.id);
199
+ if (!c) return [];
200
+ const s = c.status;
201
+ return kit.tidy([
202
+ {label: 'Approve', icon: 'check-circle', visible: s !== 'approved', action: () => setStatus(c.id, 'approved')},
203
+ {label: 'Back to pending', icon: 'clock', visible: s !== 'pending', action: () => setStatus(c.id, 'pending')},
204
+ {label: 'Reject', icon: 'x-circle', visible: s !== 'rejected', action: () => setStatus(c.id, 'rejected')},
205
+ {label: 'Mark as spam', icon: 'alert-triangle', visible: s !== 'spam', action: () => setStatus(c.id, 'spam')},
206
+ {type: 'divider'},
207
+ {label: 'Open the post', icon: 'edit', visible: Boolean(c.postId) && posts.peek().some(p => p.id === c.postId),
208
+ action: () => R.navigate(`#/plugins/blog/posts/${c.postId}`)},
209
+ {label: 'Reply by email', icon: 'mail', visible: Boolean(c.authorEmail),
210
+ action: () => { location.href = `mailto:${encodeURIComponent(c.authorEmail)}`; }},
211
+ {type: 'divider'},
212
+ {label: 'Delete', icon: 'trash', danger: true, action: () => remove(c.id)}
213
+ ]);
214
+ }
215
+ });
216
+ add(() => menu?.destroy?.());
217
+
218
+ // A click expands the comment in place; ⋮ opens the menu.
219
+ list.addEventListener('click', (e) => {
220
+ const row = e.target.closest('.blog-row');
221
+ if (!row) return;
222
+ const kebab = e.target.closest('.blog-kebab');
223
+ if (kebab) return kit.openMenuFrom(kebab);
224
+ row.classList.toggle('is-open');
225
+ });
226
+ list.addEventListener('keydown', (e) => {
227
+ const row = e.target.closest?.('.blog-row');
228
+ if (row && e.key === 'Enter' && e.target === row) row.classList.toggle('is-open');
229
+ });
230
+
231
+ if (typeof I !== 'undefined' && I.scan) I.scan(root);
232
+ await load();
233
+ // Landing with nothing pending shows everything, rather than an empty screen.
234
+ if (!comments.peek().some(c => c.status === 'pending') && comments.peek().length) status.value = 'all';
186
235
  }
187
236
  };