domma-cms 1.6.56 → 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 (111) 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/server/routes/api/analytics.js +5 -1
  107. package/server/routes/api/contacts.js +42 -0
  108. package/server/routes/api/forms.js +8 -0
  109. package/server/routes/api/seo.js +4 -1
  110. package/server/routes/api/todo.js +50 -10
  111. package/server/server.js +1 -1
@@ -10,14 +10,19 @@
10
10
  * the same screen files straight into the inbox: nothing waits, nothing travels.
11
11
  */
12
12
 
13
- import {FILES, FILE_TYPES, api, esc, fileRow, fileSize, isImage, openPanel, refreshBadge, showFiles, stamp, uploadFile, when} from '../lib/kit.js';
13
+ import {
14
+ FILES, FILE_TYPES, api, copyText, esc, fileRow, fileSize, guardPanel, isImage, offerDraft, openPanel, panelSet, refreshBadge, rowKeys, showFiles, stamp,
15
+ uploadFile, when
16
+ } from '../lib/kit.js';
17
+ import {DRAFT_KEYS, draftValues, newDraftQuestion} from '../lib/drafts.js';
14
18
 
15
19
  const ROW = `
16
- <div class="fb-row" data-id="{{key}}" tabindex="0" role="button">
20
+ <div class="fb-row" data-id="{{key}}" tabindex="0" role="button" aria-haspopup="menu">
17
21
  <span class="fb-type" data-bind-title="typeLabel"><span data-icon="{{icon}}" data-icon-size="16"></span></span>
18
22
  <span class="fb-main"><strong data-bind-text="title"></strong><small data-bind-text="meta"></small></span>
19
23
  <span class="fb-dot" data-bind-hidden="!unread" title="New reply from Domma"></span>
20
24
  <span class="fb-chip" data-bind-class="tone" data-bind-text="statusLabel"></span>
25
+ <button type="button" class="fb-kebab" aria-label="Actions" title="Actions">&#8942;</button>
21
26
  </div>`;
22
27
 
23
28
  const TONE = {new: 'info', acknowledged: 'muted', planned: 'info', 'in-progress': 'warning', done: 'success', declined: 'muted',
@@ -51,6 +56,8 @@ export async function mount(root, ctx) {
51
56
  const connected = M.observable(Boolean(ctx.mode.connected));
52
57
  const reports = M.observable([]);
53
58
  const loading = M.observable(true);
59
+ const panels = panelSet();
60
+ scope(() => panels.leave());
54
61
  const reachable = M.observable(true);
55
62
  const error = M.observable('');
56
63
  const rows = M.observable([]);
@@ -94,7 +101,7 @@ export async function mount(root, ctx) {
94
101
  <button type="button" class="btn btn-ghost btn-xs" data-on-click="reload">Try now</button>
95
102
  </p>
96
103
  <p class="fb-note is-warn" data-bind-hidden="reachable.value || loading.value">Domma could not be reached just now, so the statuses below may be out of date.</p>
97
- <p class="fb-note" data-bind-hidden="!loading.value">Loading...</p>
104
+ <div class="fb-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading...</div>
98
105
  <p class="fb-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
99
106
  <p class="fb-note" data-bind-hidden="loading.value || rows.value.length">Nothing sent yet. Found something odd, or wish the CMS did something it does not? Send feedback.</p>
100
107
  <div class="fb-list" data-each="rows.value key=key"></div>
@@ -112,29 +119,52 @@ export async function mount(root, ctx) {
112
119
  Promise.resolve().then(() => { M.flush?.(); I.scan(listEl); });
113
120
  }));
114
121
 
122
+ // The first load shows a skeleton in the slot; a reload keeps the list on screen.
123
+ let loaded = false;
115
124
  async function load() {
116
- loading.value = true;
125
+ const first = !loaded;
117
126
  try {
118
- const res = await api.sent();
127
+ const asked = api.sent();
128
+ const res = first ? await kit.whileLoading(view.querySelector('.fb-loading'), asked, kit.SHAPES.iconRows) : await asked;
119
129
  if (!here()) return;
120
130
  connected.value = Boolean(res.connected);
121
131
  reachable.value = Boolean(res.reachable) || !res.connected;
122
132
  reports.value = Array.isArray(res.reports) ? res.reports : [];
123
133
  error.value = '';
134
+ loaded = true;
124
135
  } catch (err) {
125
- error.value = `Could not load your feedback: ${err.message || err}`;
136
+ if (here()) error.value = `Could not load your feedback: ${err.message || err}`;
126
137
  } finally {
127
- loading.value = false;
128
- I.scan(view);
138
+ if (here()) { loading.value = false; I.scan(view); }
129
139
  }
130
140
  }
131
141
 
132
142
  const byKey = (k) => reports.peek().find(r => (r.id || r.ref) === k);
133
143
 
134
- function compose() {
144
+ const FORM = {type: 'bug', title: '', description: '', where: '', severity: 'minor'};
145
+ const formOf = (f) => ({type: f.type.value, title: f.title.value, description: f.description.value, where: f.where.value, severity: f.severity.value});
146
+
147
+ async function compose() {
148
+ if (!panels.claim('compose')) return;
149
+ let p = null;
150
+ try {
151
+ // Something left unsent last time is offered back first; it goes only on "Throw away".
152
+ const offer = await offerDraft(DRAFT_KEYS.compose, {here, question: (d) => newDraftQuestion(d, 'the feedback')});
153
+ if (offer.state === 'gone') { panels.release('compose'); return; }
154
+ const start = offer.state === 'keep' ? draftValues(offer.draft, FORM) : {...FORM};
155
+ p = await openCompose(start);
156
+ } catch (err) {
157
+ panels.release('compose');
158
+ E.toast(err.message || String(err), {type: 'error'});
159
+ return;
160
+ }
161
+ if (!p) panels.release('compose');
162
+ }
163
+
164
+ async function openCompose(start) {
135
165
  const f = {
136
- type: M.observable('bug'), title: M.observable(''), description: M.observable(''),
137
- where: M.observable(''), severity: M.observable('minor'), busy: M.observable(false)
166
+ type: M.observable(start.type), title: M.observable(start.title), description: M.observable(start.description),
167
+ where: M.observable(start.where), severity: M.observable(start.severity), busy: M.observable(false)
138
168
  };
139
169
  const isBug = M.computed(() => f.type.value === 'bug');
140
170
  // The files picked so far: uploaded as soon as they are chosen, sent by id with the report.
@@ -143,12 +173,18 @@ export async function mount(root, ctx) {
143
173
  const uploading = M.computed(() => picked.value.some(x => x.busy));
144
174
  const localUrls = [];
145
175
  let filesHandle = null;
146
- let panel = null;
147
- openPanel({
176
+ let guard = null;
177
+ const offs = [];
178
+ const p = await openPanel({
148
179
  title: COPY.title,
149
180
  size: 'md',
150
181
  model: {...f, isBug, room, uploading},
151
- cleanup: () => { filesHandle?.dispose(); for (const u of localUrls.splice(0)) URL.revokeObjectURL(u); },
182
+ cleanup: () => {
183
+ filesHandle?.dispose();
184
+ for (const off of offs.splice(0)) off();
185
+ for (const u of localUrls.splice(0)) URL.revokeObjectURL(u);
186
+ [isBug, room, uploading].forEach(c => c.dispose?.());
187
+ },
152
188
  html: `<div class="fb-form">
153
189
  <label class="form-label">What is it?</label>
154
190
  <div class="fb-types" role="radiogroup" aria-label="Type">
@@ -166,7 +202,7 @@ export async function mount(root, ctx) {
166
202
  </div>
167
203
  <label class="form-label" for="fb-where">Where <span data-help="The screen or page it is about, if there is one - e.g. Pages, the Blog editor, /contact. Optional."></span></label>
168
204
  <input id="fb-where" class="form-input" maxlength="300" data-model="where.value" placeholder="Optional">
169
- <label class="form-label">Screenshots and files <span data-help="${esc(`Up to ${caps.max} files of ${fileSize(caps.maxBytes)} each: PNG, JPEG, GIF or WebP images, a PDF or a text file. A screenshot of what went wrong helps most. They are seen only by the Domma team.`)}"></span></label>
205
+ <label class="form-label">Screenshots and files <span data-help="${esc(`Up to ${caps.max} files of ${fileSize(caps.maxBytes)} each: PNG, JPEG, GIF or WebP images, a PDF or a text file. A screenshot of what went wrong helps most. They are seen only by the Domma team. What you type is kept if you leave before sending; files are not - attach them again.`)}"></span></label>
170
206
  <div class="fb-files-host" data-files></div>
171
207
  <div class="fb-attach" data-bind-hidden="!room.value">
172
208
  <button type="button" class="btn btn-sm btn-outline" data-on-click="pickFiles"><span data-icon="paperclip" data-icon-size="13"></span> Attach a file</button>
@@ -180,78 +216,106 @@ export async function mount(root, ctx) {
180
216
  </div>
181
217
  </div>`,
182
218
  methods: {
183
- cancel: () => panel?.close(),
184
- pickFiles: () => panel?.panel.querySelector('.fb-file-input')?.click(),
219
+ cancel: () => guard?.close(),
220
+ pickFiles: () => p?.panel.querySelector('.fb-file-input')?.click(),
185
221
  async send() {
186
222
  if (f.busy.peek() || uploading.peek()) return;
187
223
  f.busy.value = true;
224
+ // The panel closes once sent: locked meanwhile, so nothing typed during the request is lost.
225
+ guard?.lock(true);
188
226
  try {
189
227
  const r = await api.send({type: f.type.peek(), title: f.title.peek(), description: f.description.peek(),
190
228
  where: f.where.peek(), severity: f.type.peek() === 'bug' ? f.severity.peek() : '',
191
229
  attachments: picked.peek().filter(x => x.id && !x.busy).map(x => ({id: x.id, name: x.name, type: x.type, size: x.size}))});
192
230
  E.toast(r.message || 'Sent.', {type: r.state === 'sent' ? 'success' : 'warning'});
193
- panel?.close();
231
+ guard?.settle();
232
+ guard?.close(true);
194
233
  refreshBadge();
195
- await load();
234
+ if (here()) await load();
196
235
  } catch (err) {
236
+ guard?.lock(false);
197
237
  E.toast(err.message || String(err), {type: 'error'});
198
238
  } finally {
199
239
  f.busy.value = false;
200
240
  }
201
241
  }
202
242
  }
203
- }).then(p => {
204
- panel = p;
205
- p.panel.querySelector('.fb-types').addEventListener('click', (e) => {
206
- const b = e.target.closest('[data-type]');
207
- if (b) f.type.value = b.dataset.type;
208
- });
209
- // The file list is bound on its own, after the panel: the panel's model has no `files`.
210
- const host = p.panel.querySelector('[data-files]');
211
- host.innerHTML = FILES;
212
- const files = M.computed(() => picked.value.map(x => fileRow(x, {key: x.key, thumb: x.thumb, removable: !x.busy,
213
- meta: x.busy ? 'Uploading...' : fileSize(x.size), title: x.name})));
214
- const bound = M.applyBindings({files}, host);
215
- filesHandle = {dispose: () => { bound.dispose(); files.dispose?.(); }};
216
- host.addEventListener('click', (e) => {
217
- const x = e.target.closest('.fb-file-x');
218
- if (x) picked.value = picked.peek().filter(r => r.key !== x.closest('.fb-file').dataset.key);
219
- });
220
- const add = async (list) => {
221
- for (const file of list) {
222
- if (picked.peek().length >= caps.max) { E.toast(`Up to ${caps.max} files.`, {type: 'warning'}); break; }
223
- if (file.size > caps.maxBytes) { E.toast(`${esc(file.name)} is too big - up to ${fileSize(caps.maxBytes)} each.`, {type: 'warning'}); continue; }
224
- if (!FILE_TYPES.split(',').includes(file.type)) { E.toast(`${esc(file.name)} cannot be attached - images, PDF or text only.`, {type: 'warning'}); continue; }
225
- const key = `k${Date.now()}${Math.random().toString(16).slice(2)}`;
226
- let thumb = '';
227
- if (isImage(file.type)) { thumb = URL.createObjectURL(file); localUrls.push(thumb); }
228
- picked.value = [...picked.peek(), {key, name: file.name || 'screenshot.png', type: file.type, size: file.size, thumb, busy: true}];
229
- try {
230
- const a = await uploadFile(file);
231
- picked.value = picked.peek().map(r => (r.key === key ? {...r, ...a, key, busy: false} : r));
232
- } catch (err) {
233
- picked.value = picked.peek().filter(r => r.key !== key);
234
- E.toast(esc(err.message || String(err)), {type: 'error'});
235
- }
236
- }
237
- };
238
- const input = p.panel.querySelector('.fb-file-input');
239
- input.addEventListener('change', () => { const list = [...input.files]; input.value = ''; add(list); });
240
- // A screenshot pasted into the description is the quickest way there is.
241
- p.panel.querySelector('#fb-desc')?.addEventListener('paste', (e) => {
242
- const list = [...(e.clipboardData?.files || [])];
243
- if (!list.length) return;
244
- e.preventDefault();
245
- add(list.map(file => (file.name && file.name !== 'image.png' ? file
246
- : new File([file], `screenshot-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`, {type: file.type}))));
247
- });
248
- p.panel.querySelector('#fb-title')?.focus();
249
243
  });
244
+ guard = guardPanel(p, {
245
+ key: DRAFT_KEYS.compose,
246
+ values: () => formOf(f),
247
+ baseline: {...FORM},
248
+ extraDirty: () => picked.peek().length > 0,
249
+ onClosed: () => panels.release('compose')
250
+ });
251
+ panels.set('compose', {close: (how) => guard.close(how)});
252
+ const typesEl = p.panel.querySelector('.fb-types');
253
+ const onType = (e) => { const b = e.target.closest('[data-type]'); if (b) f.type.value = b.dataset.type; };
254
+ typesEl.addEventListener('click', onType);
255
+ offs.push(() => typesEl.removeEventListener('click', onType));
256
+ // The file list is bound on its own, after the panel: the panel's model has no `files`.
257
+ const host = p.panel.querySelector('[data-files]');
258
+ host.innerHTML = FILES;
259
+ const files = M.computed(() => picked.value.map(x => fileRow(x, {key: x.key, thumb: x.thumb, removable: !x.busy,
260
+ meta: x.busy ? 'Uploading...' : fileSize(x.size), title: x.name})));
261
+ const bound = M.applyBindings({files}, host);
262
+ filesHandle = {dispose: () => { bound.dispose(); files.dispose?.(); }};
263
+ const onRemove = (e) => {
264
+ const x = e.target.closest('.fb-file-x');
265
+ if (x) picked.value = picked.peek().filter(r => r.key !== x.closest('.fb-file').dataset.key);
266
+ };
267
+ host.addEventListener('click', onRemove);
268
+ offs.push(() => host.removeEventListener('click', onRemove));
269
+ let live = true;
270
+ offs.push(() => { live = false; });
271
+ const add = async (list) => {
272
+ for (const file of list) {
273
+ if (!live) return;
274
+ if (picked.peek().length >= caps.max) { E.toast(`Up to ${caps.max} files.`, {type: 'warning'}); break; }
275
+ if (file.size > caps.maxBytes) { E.toast(`${file.name} is too big - up to ${fileSize(caps.maxBytes)} each.`, {type: 'warning'}); continue; }
276
+ if (!FILE_TYPES.split(',').includes(file.type)) { E.toast(`${file.name} cannot be attached - images, PDF or text only.`, {type: 'warning'}); continue; }
277
+ const key = `k${Date.now()}${Math.random().toString(16).slice(2)}`;
278
+ let thumb = '';
279
+ if (isImage(file.type)) { thumb = URL.createObjectURL(file); localUrls.push(thumb); }
280
+ picked.value = [...picked.peek(), {key, name: file.name || 'screenshot.png', type: file.type, size: file.size, thumb, busy: true}];
281
+ try {
282
+ const a = await uploadFile(file);
283
+ if (!live) return;
284
+ picked.value = picked.peek().map(r => (r.key === key ? {...r, ...a, key, busy: false} : r));
285
+ } catch (err) {
286
+ if (!live) return;
287
+ picked.value = picked.peek().filter(r => r.key !== key);
288
+ E.toast(err.message || String(err), {type: 'error'});
289
+ }
290
+ }
291
+ };
292
+ const input = p.panel.querySelector('.fb-file-input');
293
+ const onChange = () => { const list = [...input.files]; input.value = ''; add(list); };
294
+ input.addEventListener('change', onChange);
295
+ offs.push(() => input.removeEventListener('change', onChange));
296
+ // A screenshot pasted into the description is the quickest way there is.
297
+ const desc = p.panel.querySelector('#fb-desc');
298
+ const onPaste = (e) => {
299
+ const list = [...(e.clipboardData?.files || [])];
300
+ if (!list.length) return;
301
+ e.preventDefault();
302
+ add(list.map(file => (file.name && file.name !== 'image.png' ? file
303
+ : new File([file], `screenshot-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`, {type: file.type}))));
304
+ };
305
+ desc?.addEventListener('paste', onPaste);
306
+ offs.push(() => desc?.removeEventListener('paste', onPaste));
307
+ p.panel.querySelector('#fb-title')?.focus();
308
+ return p;
250
309
  }
251
310
 
311
+ /** One report open at a time: opening another closes the one showing. */
252
312
  async function open(k) {
253
313
  const r = byKey(k);
254
314
  if (!r) return;
315
+ const showing = panels.get('detail');
316
+ if (showing?.key === k) return;
317
+ if (showing) { await showing.close?.(); if (panels.get('detail')) return; }
318
+ if (!here() || !panels.claim('detail')) return;
255
319
  const lines = [
256
320
  ['Type', labelOf(r.type)],
257
321
  ['Status', r.statusLabel],
@@ -262,18 +326,26 @@ export async function mount(root, ctx) {
262
326
  ...(r.lastError ? [['Last try', r.lastError]] : [])
263
327
  ];
264
328
  let filesView = null;
265
- const p = await openPanel({
266
- title: r.title,
267
- size: 'md',
268
- cleanup: () => filesView?.dispose(),
269
- html: `<div class="fb-detail">
329
+ let p;
330
+ try {
331
+ p = await openPanel({
332
+ title: r.title,
333
+ size: 'md',
334
+ cleanup: () => { filesView?.dispose(); panels.release('detail'); },
335
+ html: `<div class="fb-detail">
270
336
  <dl class="fb-facts">${lines.map(([a, b]) => `<div><dt>${esc(a)}</dt><dd>${esc(b)}</dd></div>`).join('')}</dl>
271
337
  <h4>What was sent</h4>
272
338
  <div class="fb-text">${esc(r.description)}</div>
273
339
  ${(r.attachments || []).length ? '<h4>Files</h4><div class="fb-files-host" data-files></div>' : ''}
274
340
  ${r.reply ? `<h4><span data-icon="corner-up-left" data-icon-size="14"></span> ${esc(COPY.replied)} <small>${esc(stamp(r.replyAt))}</small></h4><div class="fb-text fb-reply">${esc(r.reply)}</div>` : `<p class="fb-note">${esc(COPY.noReply)}</p>`}
275
341
  </div>`
276
- });
342
+ });
343
+ } catch (err) {
344
+ panels.release('detail');
345
+ E.toast(err.message || String(err), {type: 'error'});
346
+ return;
347
+ }
348
+ panels.set('detail', {key: k, close: (how) => p.so.close(how)});
277
349
  const host = p.panel.querySelector('[data-files]');
278
350
  if (host) {
279
351
  host.innerHTML = FILES;
@@ -281,13 +353,30 @@ export async function mount(root, ctx) {
281
353
  // Domma's copy is still on its way. The manager's own reports read theirs in place.
282
354
  filesView = showFiles(host, (r.attachments || []).map(a => ({...a, stored: true})), (a) => api.ownFileUrl(a.id));
283
355
  }
284
- if (r.unread && r.id) {
285
- try {
286
- await api.seen(r.id, r.replyAt);
287
- reports.value = reports.peek().map(x => (x === r ? {...x, unread: false} : x));
288
- refreshBadge();
289
- } catch { /* the badge catches up next time */ }
290
- }
356
+ await markRead(r);
357
+ }
358
+
359
+ async function markRead(r) {
360
+ if (!r.unread || !r.id) return;
361
+ try {
362
+ await api.seen(r.id, r.replyAt);
363
+ if (!here()) return;
364
+ reports.value = reports.peek().map(x => ((x.id || x.ref) === (r.id || r.ref) ? {...x, unread: false} : x));
365
+ refreshBadge();
366
+ } catch { /* the badge catches up next time */ }
367
+ }
368
+
369
+ /** The row menu: right-click, the ⋮ button and Shift+F10 all open this one definition. */
370
+ function menuItems(r) {
371
+ const k = r.id || r.ref;
372
+ return [
373
+ {label: 'Open', icon: 'eye', action: () => open(k)},
374
+ ...(r.unread && r.id ? [{label: 'Mark reply as read', icon: 'check', action: () => markRead(r)}] : []),
375
+ ...(r.status === 'waiting' ? [{label: 'Try sending now', icon: 'send', action: () => load()}] : []),
376
+ {type: 'divider'},
377
+ {label: 'Copy what was sent', icon: 'copy', action: () => copyText(`${r.title}\n\n${r.description || ''}`, 'Copied')},
378
+ ...(r.reply ? [{label: local ? 'Copy the reply' : 'Copy Domma\'s reply', icon: 'copy', action: () => copyText(r.reply, 'Reply copied')}] : [])
379
+ ];
291
380
  }
292
381
 
293
382
  const bindings = M.applyBindings({connected, loading, reachable, error, rows, waitingCount}, view, {
@@ -295,12 +384,14 @@ export async function mount(root, ctx) {
295
384
  });
296
385
  scope(() => bindings.dispose());
297
386
 
387
+ // Rows: click or Enter opens; right-click, the ⋮ or Shift+F10 = the menu.
298
388
  const list = view.querySelector('.fb-list');
299
- const onClick = (e) => { const row = e.target.closest('.fb-row'); if (row) open(row.dataset.id); };
300
- const onKey = (e) => { const row = e.target.closest('.fb-row'); if (row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); open(row.dataset.id); } };
301
- list.addEventListener('click', onClick);
302
- list.addEventListener('keydown', onKey);
303
- scope(() => { list.removeEventListener('click', onClick); list.removeEventListener('keydown', onKey); });
389
+ const menu = E.contextMenu(list, {
390
+ cursor: 'glyph', match: '.fb-row', inherit: false, density: 'compact',
391
+ items: (row) => { const r = byKey(row.dataset.id); return r ? menuItems(r) : []; }
392
+ });
393
+ scope(() => menu?.destroy?.());
394
+ scope(rowKeys(list, {rowSel: '.fb-row', kebabSel: '.fb-kebab', openMenuAt: kit.openMenuAt, primary: open}));
304
395
 
305
396
  I.scan(view);
306
397
  if (connected.peek()) {
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "feedback",
3
3
  "displayName": "Feedback",
4
- "version": "1.1.8",
4
+ "version": "1.2.0",
5
5
  "tier": "free",
6
6
  "description": "Tell Domma what is broken, missing or good - straight from your admin - and see what became of it: seen, planned, in progress, done, with Domma's reply.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-10-13",
8
+ "date": "2026-10-15",
9
9
  "icon": "message-square",
10
10
  "permissions": [
11
11
  {
@@ -40,7 +40,7 @@
40
40
  ],
41
41
  "views": {
42
42
  "plugin-feedback": {
43
- "entry": "feedback/admin/views/feedback.js?v=1.1.8",
43
+ "entry": "feedback/admin/views/feedback.js?v=1.2.0",
44
44
  "exportName": "feedbackView"
45
45
  }
46
46
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Kept drafts (admin/lib/drafts.js): what a panel keeps of unsaved typing, and
3
+ * the question it is offered back with - OK always throws away, so Cancel,
4
+ * Escape and a backdrop click (all `false` from E.confirm) keep.
5
+ */
6
+ import {test} from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import {
9
+ DRAFT_KEYS, RESTORE_CONFIRM, changedFields, differs, draftFate, draftQuestion, draftText, draftValues, isDraft, makeDraft, newDraftQuestion,
10
+ reportForm, reportPrint, settingsPrint, shortWhen, stableJson
11
+ } from '../admin/lib/drafts.js';
12
+
13
+ test('stableJson ignores key order; differs compares by it', () => {
14
+ assert.equal(stableJson({b: 1, a: [1, {d: 2, c: 3}]}), stableJson({a: [1, {c: 3, d: 2}], b: 1}));
15
+ assert.equal(differs({a: '1'}, {a: '1'}), false);
16
+ assert.equal(differs({a: '1'}, {a: '2'}), true);
17
+ });
18
+
19
+ test('keys are one per panel and record - no shared map', () => {
20
+ assert.equal(DRAFT_KEYS.report('r1'), 'feedback.draft.report.r1');
21
+ assert.notEqual(DRAFT_KEYS.report('r1'), DRAFT_KEYS.report('r2'));
22
+ assert.notEqual(DRAFT_KEYS.compose, DRAFT_KEYS.settings);
23
+ });
24
+
25
+ test('makeDraft / isDraft / draftValues', () => {
26
+ const d = makeDraft({title: 'x', extra: 'y', n: 5}, 'fp', '2026-10-15T10:00:00.000Z');
27
+ assert.deepEqual(d, {data: {title: 'x', extra: 'y', n: 5}, was: 'fp', at: '2026-10-15T10:00:00.000Z'});
28
+ assert.ok(isDraft(d));
29
+ for (const bad of [null, 'x', {}, {data: []}, {data: 'x'}]) assert.equal(isDraft(bad), false);
30
+ // Only the form's own keys, as strings; missing ones keep the defaults.
31
+ assert.deepEqual(draftValues(d, {title: '', where: 'here', n: ''}), {title: 'x', where: 'here', n: '5'});
32
+ assert.deepEqual(draftValues(null, {title: 'a'}), {title: 'a'});
33
+ });
34
+
35
+ test('reportPrint: new counts as acknowledged (opening a report acknowledges it)', () => {
36
+ const r = {status: 'new', reply: '', notes: 'n', title: 'ignored'};
37
+ assert.equal(reportPrint(r), reportPrint({...r, status: 'acknowledged'}));
38
+ assert.notEqual(reportPrint(r), reportPrint({...r, status: 'planned'}));
39
+ assert.notEqual(reportPrint(r), reportPrint({...r, reply: 'Thanks'}));
40
+ assert.equal(reportPrint(r), reportPrint({...r, title: 'other', updatedAt: 'later'}), 'only what the panel edits counts');
41
+ assert.deepEqual(reportForm({status: 'done'}), {status: 'done', reply: '', notes: ''});
42
+ assert.equal(reportPrint(null), '');
43
+ });
44
+
45
+ test('draftFate: deleted, changed since, or neither', () => {
46
+ const d = {was: 'A'};
47
+ assert.equal(draftFate(d, null), 'deleted');
48
+ assert.equal(draftFate(d, 'B'), 'changed');
49
+ assert.equal(draftFate(d, 'A'), '');
50
+ assert.equal(draftFate({was: ''}, 'B'), '', 'no was (something new) - nothing to compare');
51
+ });
52
+
53
+ test('draftQuestion: OK throws away; the text says what happened since', () => {
54
+ const d = makeDraft({reply: 'Hi'}, 'A', '2026-10-15T10:00:00.000Z');
55
+ const plain = draftQuestion(d, 'A', {what: 'the report “X”'});
56
+ assert.match(plain.text, /^Throw away the changes you left unsaved on the report “X” \(15 Oct, \d\d:00\)\?/);
57
+ assert.match(plain.text, /puts them back in the form/);
58
+ assert.deepEqual(plain.options, RESTORE_CONFIRM);
59
+ assert.equal(plain.options.confirmText, 'Throw away', 'the destructive answer is OK');
60
+ assert.equal(plain.options.cancelText, 'Keep them', 'Cancel / Escape / backdrop keep');
61
+
62
+ const changed = draftQuestion(d, 'B', {what: 'the report “X”', changedAt: '2026-10-15T11:30:00.000Z'});
63
+ assert.equal(changed.fate, 'changed');
64
+ assert.match(changed.text, /changed since \(15 Oct, \d\d:30\) - keeping them puts your changes over that/);
65
+
66
+ const gone = draftQuestion(d, null, {what: 'a report'});
67
+ assert.equal(gone.fate, 'deleted');
68
+ assert.match(gone.text, /deleted since/);
69
+ assert.equal(gone.options.confirmText, 'Throw away');
70
+ assert.equal(gone.options.cancelText, 'Copy them');
71
+ });
72
+
73
+ test('newDraftQuestion and settingsPrint', () => {
74
+ const q = newDraftQuestion(makeDraft({title: 't'}, '', '2026-10-15T09:05:00.000Z'));
75
+ assert.match(q.text, /^Throw away the feedback you left unsent \(15 Oct, \d\d:05\)\? Keeping it puts it back in the form\.$/);
76
+ assert.equal(q.options.confirmText, 'Throw away');
77
+ assert.equal(settingsPrint({b: '2', a: '1'}), settingsPrint({a: '1', b: '2'}));
78
+ });
79
+
80
+ test('draftText keeps the words, for copying when the record has gone', () => {
81
+ assert.equal(draftText(makeDraft({status: 'planned', reply: 'Thanks', notes: ' '})), 'status:\nplanned\n\nreply:\nThanks');
82
+ assert.equal(draftText(null), '');
83
+ assert.equal(shortWhen('nope'), '');
84
+ });
85
+
86
+ test('changedFields keeps only what was edited, so a picked-up draft leaves the rest as the record has it now', () => {
87
+ const saved = {status: 'acknowledged', reply: '', notes: ''};
88
+ const kept = changedFields({status: 'acknowledged', reply: 'Thanks', notes: ''}, saved);
89
+ assert.deepEqual(kept, {reply: 'Thanks'});
90
+ // Later, someone else set the status and wrote notes: the draft puts back only the reply.
91
+ assert.deepEqual(draftValues(makeDraft(kept), {status: 'planned', reply: '', notes: 'theirs'}), {status: 'planned', reply: 'Thanks', notes: 'theirs'});
92
+ assert.deepEqual(changedFields({a: '1'}, {a: '1'}), {});
93
+ });