domma-cms 1.6.55 → 1.6.57

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 (113) hide show
  1. package/admin/css/calculator.css +1 -1
  2. package/admin/js/lib/analytics-shape.js +1 -1
  3. package/admin/js/lib/calculator-engine.js +1 -1
  4. package/admin/js/lib/calculator.js +22 -10
  5. package/admin/js/lib/form-editor-arrange.js +1 -1
  6. package/admin/js/lib/forms-arrange.js +1 -1
  7. package/admin/js/lib/notes-arrange.js +1 -1
  8. package/admin/js/lib/search-settings-arrange.js +1 -1
  9. package/admin/js/lib/seo-arrange.js +1 -0
  10. package/admin/js/lib/todo-arrange.js +1 -1
  11. package/admin/js/lib/tool-kit.js +1 -1
  12. package/admin/js/lib/tool-recovery.js +4 -4
  13. package/admin/js/templates/analytics.html +26 -14
  14. package/admin/js/templates/contacts.html +26 -5
  15. package/admin/js/templates/form-editor.html +2 -0
  16. package/admin/js/templates/notes.html +11 -8
  17. package/admin/js/templates/search.html +9 -3
  18. package/admin/js/templates/seo.html +23 -17
  19. package/admin/js/templates/todo.html +18 -14
  20. package/admin/js/views/analytics.js +10 -10
  21. package/admin/js/views/contacts.js +47 -47
  22. package/admin/js/views/form-editor.js +7 -7
  23. package/admin/js/views/form-submissions.js +2 -2
  24. package/admin/js/views/forms.js +5 -5
  25. package/admin/js/views/notes.js +6 -6
  26. package/admin/js/views/search.js +2 -2
  27. package/admin/js/views/seo.js +43 -43
  28. package/admin/js/views/todo.js +8 -8
  29. package/package.json +1 -1
  30. package/plugins/_lib/admin/mail/compose-window.js +4 -1
  31. package/plugins/_lib/admin/mail/mail.css +1 -1
  32. package/plugins/_lib/admin/mail/reader-kit.js +364 -0
  33. package/plugins/_lib/admin/mail/reader-view.js +1009 -161
  34. package/plugins/_lib/admin/mail/when-closed.js +33 -3
  35. package/plugins/_lib/mail/readRoutes.js +48 -10
  36. package/plugins/_lib/mail/unreadBadge.js +4 -1
  37. package/plugins/blog/CLAUDE.md +14 -0
  38. package/plugins/blog/admin/css/index.css +5 -0
  39. package/plugins/blog/admin/lib/drafts.js +95 -0
  40. package/plugins/blog/admin/templates/blog.html +1 -1
  41. package/plugins/blog/admin/templates/categories.html +1 -1
  42. package/plugins/blog/admin/templates/comments.html +1 -1
  43. package/plugins/blog/admin/templates/post-editor.html +5 -1
  44. package/plugins/blog/admin/views/blog.js +44 -19
  45. package/plugins/blog/admin/views/categories.js +138 -20
  46. package/plugins/blog/admin/views/comments.js +32 -23
  47. package/plugins/blog/admin/views/kit.js +232 -22
  48. package/plugins/blog/admin/views/post-editor.js +348 -178
  49. package/plugins/blog/admin/views/settings.js +17 -3
  50. package/plugins/blog/plugin.js +1 -1
  51. package/plugins/blog/plugin.json +7 -7
  52. package/plugins/blog/plugin.public.js +1 -1
  53. package/plugins/blog/tests/drafts.test.js +50 -0
  54. package/plugins/booking/CLAUDE.md +13 -0
  55. package/plugins/booking/admin/lib/drafts.js +139 -0
  56. package/plugins/booking/admin/lib/kit.js +246 -25
  57. package/plugins/booking/admin/views/booking.js +11 -2
  58. package/plugins/booking/admin/views/bookings.js +7 -2
  59. package/plugins/booking/admin/views/diary.js +13 -6
  60. package/plugins/booking/admin/views/panels.js +85 -12
  61. package/plugins/booking/admin/views/people.js +41 -2
  62. package/plugins/booking/admin/views/services.js +45 -3
  63. package/plugins/booking/admin/views/settings.js +22 -2
  64. package/plugins/booking/plugin.json +2 -2
  65. package/plugins/booking/tests/admin-drafts.test.js +67 -0
  66. package/plugins/feedback/CLAUDE.md +12 -0
  67. package/plugins/feedback/admin/css/index.css +5 -0
  68. package/plugins/feedback/admin/lib/drafts.js +145 -0
  69. package/plugins/feedback/admin/lib/kit.js +161 -5
  70. package/plugins/feedback/admin/lib/settings.js +46 -28
  71. package/plugins/feedback/admin/views/feedback.js +1 -1
  72. package/plugins/feedback/admin/views/inbox.js +110 -46
  73. package/plugins/feedback/admin/views/send.js +174 -83
  74. package/plugins/feedback/plugin.json +3 -3
  75. package/plugins/feedback/tests/drafts.test.js +93 -0
  76. package/plugins/free-tier.lock.json +75 -63
  77. package/plugins/security/CLAUDE.md +9 -0
  78. package/plugins/security/admin/lib/screen-state.js +120 -0
  79. package/plugins/security/admin/templates/security.html +15 -10
  80. package/plugins/security/admin/views/security.js +270 -132
  81. package/plugins/security/plugin.json +2 -2
  82. package/plugins/security/tests/screen-state.test.js +69 -0
  83. package/plugins/shopping-cart/CLAUDE.md +13 -0
  84. package/plugins/shopping-cart/admin/css/index.css +21 -5
  85. package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
  86. package/plugins/shopping-cart/admin/lib/kit.js +136 -28
  87. package/plugins/shopping-cart/admin/views/orders.js +117 -36
  88. package/plugins/shopping-cart/admin/views/overview.js +38 -8
  89. package/plugins/shopping-cart/admin/views/products.js +317 -68
  90. package/plugins/shopping-cart/admin/views/sales.js +21 -3
  91. package/plugins/shopping-cart/admin/views/settings.js +62 -3
  92. package/plugins/shopping-cart/admin/views/shop.js +18 -3
  93. package/plugins/shopping-cart/plugin.json +3 -3
  94. package/plugins/shopping-cart/tests/accounts.test.js +11 -0
  95. package/plugins/shopping-cart/tests/drafts.test.js +118 -0
  96. package/plugins/support/CLAUDE.md +14 -0
  97. package/plugins/support/admin/css/index.css +10 -0
  98. package/plugins/support/admin/lib/drafts.js +162 -0
  99. package/plugins/support/admin/lib/kit.js +156 -16
  100. package/plugins/support/admin/lib/settings.js +52 -29
  101. package/plugins/support/admin/views/desk.js +114 -33
  102. package/plugins/support/admin/views/inbox.js +85 -26
  103. package/plugins/support/admin/views/support.js +1 -1
  104. package/plugins/support/plugin.json +3 -3
  105. package/plugins/support/tests/drafts.test.js +92 -0
  106. package/public/js/site.js +1 -1
  107. package/server/routes/api/analytics.js +5 -1
  108. package/server/routes/api/contacts.js +42 -0
  109. package/server/routes/api/forms.js +8 -0
  110. package/server/routes/api/seo.js +4 -1
  111. package/server/routes/api/todo.js +50 -10
  112. package/server/server.js +1 -1
  113. package/server/templates/page.html +1 -1
@@ -69,7 +69,11 @@ export const categoriesView = {
69
69
  const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
70
70
  const {BASE} = kit;
71
71
  const add = kit.viewScope('categories', /^#\/plugins\/blog\/categories\b/);
72
+ const here = add.here;
72
73
  const root = $container.get(0);
74
+ const slot = root.querySelector('.blog-loading');
75
+ const {draftState, restoreQuestion, versionOf} = kit.draftLib;
76
+ const CAT_KEYS = ['name', 'slug', 'description'];
73
77
 
74
78
  // ---- State ----
75
79
  const categories = M.observable([]);
@@ -104,20 +108,27 @@ export const categoriesView = {
104
108
  // ---- Server ----
105
109
  const byId = (id) => categories.peek().find(c => c.id === id);
106
110
 
111
+ // The first load draws a skeleton; a reload keeps the rows on screen.
112
+ let loaded = false;
107
113
  async function load() {
108
- loading.value = true;
114
+ const work = Promise.all([
115
+ H.get(`${BASE}/categories`),
116
+ H.get(`${BASE}/posts`).catch(() => []),
117
+ kit.sitePath()
118
+ ]);
109
119
  try {
110
- const [catsRes, postsRes] = await Promise.all([
111
- H.get(`${BASE}/categories`),
112
- H.get(`${BASE}/posts`).catch(() => [])
113
- ]);
114
- site = await kit.sitePath();
120
+ const [catsRes, postsRes, base] = loaded ? await work : await kit.withSkeleton(slot, work);
121
+ if (!here()) return false;
122
+ site = base;
123
+ loaded = true;
115
124
  categories.value = catsRes?.data ?? (Array.isArray(catsRes) ? catsRes : []);
116
125
  posts.value = postsRes?.data ?? (Array.isArray(postsRes) ? postsRes : []);
126
+ return true;
117
127
  } catch {
118
- E.toast('Failed to load categories.', {type: 'error'});
128
+ if (here()) E.toast('Failed to load categories.', {type: 'error'});
129
+ return false;
119
130
  } finally {
120
- loading.value = false;
131
+ if (here()) loading.value = false;
121
132
  }
122
133
  }
123
134
 
@@ -126,13 +137,14 @@ export const categoriesView = {
126
137
  const ok = await E.confirm(n
127
138
  ? `Delete this category? ${n} post${n === 1 ? ' uses' : 's use'} it and will simply lose it.`
128
139
  : 'Delete this category?');
129
- if (!ok) return;
140
+ if (!ok || !here()) return;
130
141
  try {
131
142
  await H.delete(`${BASE}/categories/${id}`);
143
+ if (!here()) return;
132
144
  categories.value = categories.peek().filter(c => c.id !== id);
133
145
  E.toast('Category deleted.', {type: 'success'});
134
146
  } catch {
135
- E.toast('Failed to delete category.', {type: 'error'});
147
+ if (here()) E.toast('Failed to delete category.', {type: 'error'});
136
148
  }
137
149
  }
138
150
 
@@ -142,13 +154,55 @@ export const categoriesView = {
142
154
  }
143
155
 
144
156
  // ---- Editor: a slideover ----
145
- async function openEditor(cat = null) {
157
+ // One at a time. Closing it with changes asks; leaving the screen keeps
158
+ // them as a draft (per category, or one for a new category), offered
159
+ // back the next time that editor opens. It closes after a save, so it
160
+ // is locked while saving - typing then would go unsent.
161
+ const draftKey = (cid) => `category.${cid || 'new'}`;
162
+
163
+ async function openEditor(cat = null, {startFrom = null} = {}) {
164
+ const release = kit.claim('category-editor');
165
+ if (!release) return;
166
+ try {
167
+ await editor(cat, startFrom, release);
168
+ } catch (err) {
169
+ release();
170
+ throw err;
171
+ }
172
+ }
173
+
174
+ async function editor(cat, startFrom, release) {
175
+ // A draft from last time for this same category: asked about BEFORE
176
+ // the panel opens, so the Escape that keeps it cannot also close the
177
+ // panel. Removed only once answered - leaving while asked keeps it.
178
+ let picked = startFrom;
179
+ let pickedWas = '';
180
+ if (!picked) {
181
+ const draft = kit.drafts.read(draftKey(cat?.id));
182
+ if (draft) {
183
+ const state = draftState({...draft, id: cat?.id ?? null}, cat, CAT_KEYS);
184
+ const throwAway = await kit.askThrowAway(restoreQuestion({what: 'category', name: cat?.name ?? '', state, isNew: !cat}));
185
+ if (!here()) { release(); return; }
186
+ kit.drafts.drop(draftKey(cat?.id));
187
+ if (!throwAway) {
188
+ picked = draft.values ?? {};
189
+ // A picked-up draft keeps its age: its values are still the older edits.
190
+ pickedWas = draft.was ?? '';
191
+ }
192
+ }
193
+ }
146
194
  const so = E.slideover({title: cat ? 'Edit category' : 'New category', size: 'sm', position: 'right'});
147
- const name = M.observable(cat?.name ?? '');
148
- const slug = M.observable(cat?.slug ?? '');
149
- const description = M.observable(cat?.description ?? '');
195
+ const original = {name: cat?.name ?? '', slug: cat?.slug ?? '', description: cat?.description ?? ''};
196
+ const name = M.observable(original.name);
197
+ const slug = M.observable(original.slug);
198
+ const description = M.observable(original.description);
150
199
  const saving = M.observable(false);
151
200
  const focusName = M.observable(false);
201
+ let open = true;
202
+ let was = cat ? versionOf(cat, CAT_KEYS) : '';
203
+ let baseline = JSON.stringify(original);
204
+ const values = () => ({name: name.peek(), slug: slug.peek(), description: description.peek()});
205
+ const isDirty = () => JSON.stringify(values()) !== baseline;
152
206
 
153
207
  // A new category's slug follows its name until someone edits the slug.
154
208
  let slugTouched = Boolean(cat);
@@ -164,24 +218,29 @@ export const categoriesView = {
164
218
  methods: {
165
219
  cancel: () => so.close(),
166
220
  async save() {
221
+ if (saving.peek()) return;
167
222
  const body = {name: name.peek().trim(), slug: slug.peek().trim(), description: description.peek().trim()};
168
223
  if (!body.name) {
169
224
  E.toast('Name is required.', {type: 'error'});
170
225
  return;
171
226
  }
172
227
  saving.value = true;
228
+ const unlock = kit.lockWhileSaving(el);
173
229
  try {
174
230
  const res = cat
175
231
  ? await H.put(`${BASE}/categories/${cat.id}`, body)
176
232
  : await H.post(`${BASE}/categories`, body);
177
233
  if (res?.error) throw new Error(res.error);
234
+ kit.drafts.drop(draftKey(cat?.id));
235
+ baseline = JSON.stringify(values());
178
236
  E.toast(cat ? 'Category updated.' : 'Category created.', {type: 'success'});
179
- so.close();
180
- await load();
237
+ guard.close();
238
+ if (here()) await load();
181
239
  } catch (err) {
182
240
  E.toast(err?.message ?? 'Failed to save category.', {type: 'error'});
183
241
  } finally {
184
242
  saving.value = false;
243
+ unlock();
185
244
  }
186
245
  }
187
246
  }
@@ -190,16 +249,74 @@ export const categoriesView = {
190
249
  if (e.key === 'Enter' && e.target.tagName === 'INPUT') { e.preventDefault(); el.querySelector('.btn-primary').click(); }
191
250
  });
192
251
  so.element.appendChild(el);
193
- kit.onceClosed(so, () => { handle.dispose(); disposeHelp(); stopFollow(); });
252
+ let unhook = () => {};
253
+ kit.onceClosed(so, () => { open = false; unhook(); handle.dispose(); disposeHelp(); stopFollow(); release(); });
254
+ const guard = kit.guardSlideover(so, {dirty: () => !saving.peek() && isDirty()});
255
+ const keep = () => {
256
+ if (!isDirty()) return false;
257
+ kit.drafts.keep(draftKey(cat?.id), {id: cat?.id ?? null, name: original.name, values: values(), was});
258
+ return true;
259
+ };
260
+ unhook = kit.closeOnLeave(so, () => {
261
+ if (keep()) E.toast('Your unsaved category is kept - open it again to pick it up.', {type: 'info'});
262
+ guard.close();
263
+ });
264
+ const onUnload = (e) => { if (open && isDirty()) { keep(); e.preventDefault(); e.returnValue = ''; } };
265
+ window.addEventListener('beforeunload', onUnload);
266
+ const unhookLeave = unhook;
267
+ unhook = () => { unhookLeave(); window.removeEventListener('beforeunload', onUnload); };
268
+
269
+ const put = (v) => {
270
+ slugTouched = true;
271
+ name.value = v.name ?? '';
272
+ slug.value = v.slug ?? '';
273
+ description.value = v.description ?? '';
274
+ };
194
275
  if (typeof I !== 'undefined' && I.scan) I.scan(el);
195
276
  so.open();
196
- setTimeout(() => { focusName.value = true; }, 60);
277
+
278
+ // A kept draft's changes - or a deleted category's, started again as a new one.
279
+ if (picked) {
280
+ put(picked);
281
+ if (pickedWas) was = pickedWas;
282
+ }
283
+ if (open) setTimeout(() => { focusName.value = true; }, 60);
284
+ }
285
+
286
+ /**
287
+ * Drafts kept for categories deleted since: offered once the list is in.
288
+ * "Keep them" starts the category again as a new one (asking first if
289
+ * that would replace another unsaved new category).
290
+ */
291
+ async function offerOrphans() {
292
+ let keys = [];
293
+ try { keys = Object.keys(localStorage).filter(k => k.includes('blog.draft.category.') && !k.endsWith('.new')); } catch { return; }
294
+ for (const k of keys) {
295
+ const cid = k.slice(k.indexOf('blog.draft.category.') + 'blog.draft.category.'.length);
296
+ if (byId(cid)) continue;
297
+ const draft = kit.drafts.read(draftKey(cid));
298
+ if (!draft) continue;
299
+ const throwAway = await kit.askThrowAway(restoreQuestion({what: 'category', name: draft.name || draft.values?.name || '', state: 'deleted'}));
300
+ if (!here()) return;
301
+ if (!throwAway && kit.drafts.read(draftKey(null))) {
302
+ const replace = await E.confirm('You also have an unsaved new category. Starting this one again replaces it. Replace it?',
303
+ {confirmText: 'Replace it', cancelText: 'Keep both for now'});
304
+ if (!here()) return;
305
+ if (!replace) continue; // both drafts stay
306
+ kit.drafts.drop(draftKey(null));
307
+ }
308
+ kit.drafts.drop(draftKey(cid));
309
+ if (!throwAway) { await openEditor(null, {startFrom: draft.values ?? {}}); return; }
310
+ }
197
311
  }
198
312
 
199
313
  // ---- Bindings ----
200
314
  const list = root.querySelector('.blog-list');
201
315
  root.querySelector('.blog-rows').innerHTML = ROW_TEMPLATE;
202
- add(await kit.scanHelp(root));
316
+ // Overtaken while it loaded: register nothing - the lifetime is the new mount's now.
317
+ const offHelp = await kit.scanHelp(root);
318
+ if (!here()) { offHelp(); return; }
319
+ add(offHelp);
203
320
  const handle = M.applyBindings(data, root, {methods: {create: () => openEditor(null)}});
204
321
  add(() => handle.dispose());
205
322
 
@@ -235,8 +352,9 @@ export const categoriesView = {
235
352
  const row = e.target.closest?.('.blog-row');
236
353
  if (row && e.key === 'Enter' && e.target === row) openEditor(byId(row.dataset.id));
237
354
  });
355
+ add(kit.menuKeys(list, '.blog-row'));
238
356
 
239
357
  if (typeof I !== 'undefined' && I.scan) I.scan(root);
240
- await load();
358
+ if (await load() && here()) await offerOrphans();
241
359
  }
242
360
  };
@@ -55,7 +55,9 @@ export const commentsView = {
55
55
  const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
56
56
  const {BASE} = kit;
57
57
  const add = kit.viewScope('comments', /^#\/plugins\/blog\/comments\b/);
58
+ const here = add.here;
58
59
  const root = $container.get(0);
60
+ const slot = root.querySelector('.blog-loading');
59
61
 
60
62
  // ---- State ----
61
63
  const comments = M.observable([]);
@@ -67,16 +69,6 @@ export const commentsView = {
67
69
 
68
70
  const postTitle = (id) => posts.peek().find(p => p.id === id)?.title || 'a deleted post';
69
71
 
70
- // Moderating moves the "waiting for approval" badge on Blog in the sidebar. An
71
- // event rather than the admin's sidebarCount(): a plugin must not
72
- // import admin/ statically.
73
- add(M.effect(() => {
74
- comments.value;
75
- if (!loading.peek()) {
76
- window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: '#/plugins/blog'}}));
77
- }
78
- }));
79
-
80
72
  const rows = M.observable([]);
81
73
  const chips = M.observable([]);
82
74
  add(M.effect(() => {
@@ -126,44 +118,51 @@ export const commentsView = {
126
118
  const byId = (id) => comments.peek().find(c => c.id === id);
127
119
 
128
120
  async function load() {
129
- loading.value = true;
130
121
  try {
131
- const [commentsRes, postsRes, settingsRes] = await Promise.all([
122
+ const [commentsRes, postsRes, settingsRes] = await kit.withSkeleton(slot, Promise.all([
132
123
  H.get(`${BASE}/comments`),
133
124
  H.get(`${BASE}/posts`).catch(() => []),
134
125
  H.get(`${BASE}/settings`).catch(() => ({}))
135
- ]);
126
+ ]));
127
+ if (!here()) return;
136
128
  comments.value = commentsRes?.data ?? (Array.isArray(commentsRes) ? commentsRes : []);
137
129
  posts.value = postsRes?.data ?? (Array.isArray(postsRes) ? postsRes : []);
138
130
  settings.value = settingsRes?.data ?? settingsRes ?? {};
139
131
  } catch {
140
- E.toast('Failed to load comments.', {type: 'error'});
132
+ if (here()) E.toast('Failed to load comments.', {type: 'error'});
141
133
  } finally {
142
- loading.value = false;
134
+ if (here()) loading.value = false;
143
135
  }
144
136
  }
145
137
 
146
- async function setStatus(id, next) {
138
+ async function setStatus(id, next, {quiet = false} = {}) {
147
139
  try {
148
140
  const res = await H.put(`${BASE}/comments/${id}`, {status: next});
149
141
  if (res?.error) throw new Error(res.error);
142
+ // Moderating moves the "waiting for approval" badge on Blog in the sidebar.
143
+ kit.pokeSidebar();
144
+ if (!here()) return true;
150
145
  const updated = res?.data ?? res;
151
146
  comments.value = comments.peek().map(c => (c.id === id ? {...c, ...(updated?.id ? updated : {status: next})} : c));
152
- E.toast(`Comment marked ${next}.`, {type: 'success'});
147
+ if (!quiet) E.toast(`Comment marked ${next}.`, {type: 'success'});
148
+ return true;
153
149
  } catch {
154
- E.toast('Failed to update comment.', {type: 'error'});
150
+ if (here()) E.toast('Failed to update comment.', {type: 'error'});
151
+ return false;
155
152
  }
156
153
  }
157
154
 
158
155
  async function remove(id) {
159
156
  const ok = await E.confirm('Delete this comment? This cannot be undone.');
160
- if (!ok) return;
157
+ if (!ok || !here()) return;
161
158
  try {
162
159
  await H.delete(`${BASE}/comments/${id}`);
160
+ kit.pokeSidebar();
161
+ if (!here()) return;
163
162
  comments.value = comments.peek().filter(c => c.id !== id);
164
163
  E.toast('Comment deleted.', {type: 'success'});
165
164
  } catch {
166
- E.toast('Failed to delete comment.', {type: 'error'});
165
+ if (here()) E.toast('Failed to delete comment.', {type: 'error'});
167
166
  }
168
167
  }
169
168
 
@@ -171,7 +170,10 @@ export const commentsView = {
171
170
  const list = root.querySelector('.blog-list');
172
171
  root.querySelector('.blog-rows').innerHTML = ROW_TEMPLATE;
173
172
  root.querySelector('.blog-chips').innerHTML = CHIP_TEMPLATE;
174
- add(await kit.scanHelp(root));
173
+ // Overtaken while it loaded: register nothing - the lifetime is the new mount's now.
174
+ const offHelp = await kit.scanHelp(root);
175
+ if (!here()) { offHelp(); return; }
176
+ add(offHelp);
175
177
  const handle = M.applyBindings(data, root, {
176
178
  methods: {
177
179
  settings: () => kit.openSettings().then(() => {}),
@@ -179,8 +181,13 @@ export const commentsView = {
179
181
  const pending = comments.peek().filter(c => c.status === 'pending');
180
182
  if (!pending.length) return;
181
183
  const ok = await E.confirm(`Approve all ${pending.length} pending comment${pending.length === 1 ? '' : 's'}?`);
182
- if (!ok) return;
183
- for (const c of pending) await setStatus(c.id, 'approved');
184
+ if (!ok || !here()) return;
185
+ let done = 0;
186
+ for (const c of pending) {
187
+ if (await setStatus(c.id, 'approved', {quiet: true})) done++;
188
+ if (!here()) return;
189
+ }
190
+ E.toast(`Approved ${done} comment${done === 1 ? '' : 's'}.`, {type: done === pending.length ? 'success' : 'warning'});
184
191
  }
185
192
  }
186
193
  });
@@ -229,9 +236,11 @@ export const commentsView = {
229
236
  const row = e.target.closest?.('.blog-row');
230
237
  if (row && e.key === 'Enter' && e.target === row) row.classList.toggle('is-open');
231
238
  });
239
+ add(kit.menuKeys(list, '.blog-row'));
232
240
 
233
241
  if (typeof I !== 'undefined' && I.scan) I.scan(root);
234
242
  await load();
243
+ if (!here()) return;
235
244
  // Landing with nothing pending shows everything, rather than an empty screen.
236
245
  if (!comments.peek().some(c => c.status === 'pending') && comments.peek().length) status.value = 'all';
237
246
  }