domma-cms 0.66.0 → 0.69.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +92 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +307 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -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 class="blog-empty-cell">-</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 class="blog-comment-body">${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 class="blog-empty-cell">-</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 class="blog-empty-cell">-</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 class="blog-moderation-actions">${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
  };