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
@@ -7,20 +7,22 @@
7
7
  */
8
8
 
9
9
  import {fmtDate, fmtDateTime} from '/plugins/_lib/admin/ui/dates.js';
10
+ import {DISCARD_CONFIRM, changedFields, differs, isDraft, makeDraft} from './drafts.js';
10
11
 
11
12
  export const SIDEBAR_URL = '#/plugins/support';
12
13
  export const BASE = '/api/plugins/support';
13
14
 
14
15
  let kit = null;
15
16
 
16
- /** tool-kit, help-popover and plugin-chrome, once, with stand-ins for an older admin. */
17
+ /** tool-kit, help-popover, plugin-chrome and loading, once, with stand-ins for an older admin. */
17
18
  export function loadKit() {
18
19
  if (!kit) {
19
20
  kit = Promise.all([
20
21
  import('/admin/js/lib/tool-kit.js').catch(() => ({})),
21
22
  import('/admin/js/lib/help-popover.js').catch(() => ({})),
22
- import('/admin/js/lib/plugin-chrome.js').catch(() => ({}))
23
- ]).then(([tk, help, chrome]) => ({
23
+ import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
24
+ import('/admin/js/lib/loading.js').catch(() => ({}))
25
+ ]).then(([tk, help, chrome, loading]) => ({
24
26
  onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
25
27
  openMenuAt: tk.openMenuAt || ((el) => {
26
28
  const r = el.getBoundingClientRect();
@@ -29,7 +31,10 @@ export function loadKit() {
29
31
  viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
30
32
  helpPopovers: help.helpPopovers || (() => () => {}),
31
33
  // The banner's door (the settings cog, 1.1.5); an older admin has none and gets no cog.
32
- addBannerAction: chrome.addBannerAction || (() => null)
34
+ addBannerAction: chrome.addBannerAction || (() => null),
35
+ // A skeleton in a list's loading slot for its first load (an older admin: the slot's own text).
36
+ whileLoading: loading.whileLoading || ((slot, promise) => promise),
37
+ SHAPES: loading.SHAPES || {}
33
38
  }));
34
39
  }
35
40
  return kit;
@@ -136,12 +141,12 @@ export function threadFiles(urlOf, available = () => true) {
136
141
  /** Open one in a new tab. */
137
142
  async open(a) {
138
143
  if (!a) return;
139
- if (!available(a)) { E.toast(`${esc(a.name)} has not arrived yet - it follows its message.`, {type: 'info'}); return; }
144
+ if (!available(a)) { E.toast(`${a.name} has not arrived yet - it follows its message.`, {type: 'info'}); return; }
140
145
  try {
141
146
  const u = await blobUrl(urlOf(a));
142
147
  urls.push(u);
143
148
  window.open(u, '_blank', 'noopener');
144
- } catch (err) { E.toast(esc(err.message || String(err)), {type: 'error'}); }
149
+ } catch (err) { E.toast(err.message || String(err), {type: 'error'}); }
145
150
  },
146
151
  dispose() { live = false; for (const u of urls.splice(0)) URL.revokeObjectURL(u); }
147
152
  };
@@ -162,7 +167,8 @@ export function fragment(html) {
162
167
 
163
168
  export const store = {
164
169
  get(k) { try { return S.get(k); } catch { return null; } },
165
- set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } }
170
+ set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } },
171
+ remove(k) { try { if (S.remove) S.remove(k); else S.set(k, null); } catch { /* storage blocked */ } }
166
172
  };
167
173
 
168
174
  /** The sidebar re-asks the Support badge. */
@@ -211,8 +217,9 @@ export const findFile = (messages = [], id) => messages.flatMap(m => m.attachmen
211
217
  /** The thread, bound to a `thread` observable of threadRows(). Text bindings only: nothing is parsed as HTML. */
212
218
  export const THREAD = `
213
219
  <p class="sp-note" data-bind-hidden="thread.value.length">Nothing here yet.</p>
214
- <ol class="sp-thread" data-each="thread.value key=key"><li class="sp-msg" data-bind-class="cls" data-key="{{key}}" tabindex="-1">
215
- <div class="sp-msg-head"><strong data-bind-text="who"></strong><small data-bind-text="when"></small></div>
220
+ <ol class="sp-thread" data-each="thread.value key=key"><li class="sp-msg" data-bind-class="cls" data-key="{{key}}" tabindex="0" aria-haspopup="menu">
221
+ <div class="sp-msg-head"><strong data-bind-text="who"></strong><small data-bind-text="when"></small>
222
+ <button type="button" class="sp-kebab sp-msg-kebab" aria-label="Message actions" title="Actions">&#8942;</button></div>
216
223
  <div class="sp-msg-body" data-bind-text="body"></div>
217
224
  <ul class="sp-files" data-bind-hidden="!hasFiles">{{#each files key=id}}<li class="sp-file" data-att="{{id}}" tabindex="0" role="button" data-bind-title="name">
218
225
  <img class="sp-thumb" alt="" data-bind-src="thumb || false" data-bind-hidden="!thumb">
@@ -238,14 +245,33 @@ export async function copyText(text, what = 'Copied') {
238
245
  try {
239
246
  await navigator.clipboard.writeText(String(text ?? ''));
240
247
  E.toast(`${what}.`, {type: 'success'});
248
+ return true;
241
249
  } catch {
242
250
  E.toast('Your browser would not let us copy that.', {type: 'warning'});
251
+ return false;
243
252
  }
244
253
  }
245
254
 
246
255
  /**
247
- * The one way into a list's row menu besides right-click: the row's ⋮ button
248
- * and Shift+F10. Click (or Enter) on the row itself does `primary`.
256
+ * A thread's messages: the ⋮ on a message, or Shift+F10 on a focused one, opens
257
+ * the message menu (the same one a right-click opens).
258
+ * @returns {Function} removes the listeners
259
+ */
260
+ export function msgKeys(box, openMenuAt) {
261
+ const onClick = (e) => { const k = e.target.closest('.sp-msg-kebab'); if (k) { e.stopPropagation(); openMenuAt(k); } };
262
+ const onKey = (e) => {
263
+ const li = e.target.closest('.sp-msg');
264
+ if (li && e.key === 'F10' && e.shiftKey) { e.preventDefault(); openMenuAt(e.target.closest('.sp-msg-kebab') || li); }
265
+ };
266
+ box.addEventListener('click', onClick);
267
+ box.addEventListener('keydown', onKey);
268
+ return () => { box.removeEventListener('click', onClick); box.removeEventListener('keydown', onKey); };
269
+ }
270
+
271
+ /**
272
+ * The ways into a list's row menu besides right-click: the row's ⋮ button and
273
+ * Shift+F10 (never the ContextMenu key - Chrome on Windows opens its own menu on
274
+ * its key-up). Click (or Enter) on the row itself does `primary`.
249
275
  *
250
276
  * @returns {Function} removes the listeners
251
277
  */
@@ -257,11 +283,18 @@ export function rowKeys(listEl, {rowSel, kebabSel, openMenuAt, primary}) {
257
283
  if (kebab) { e.stopPropagation(); openMenuAt(kebab); return; }
258
284
  primary(row.dataset.id);
259
285
  };
286
+ // Enter must not reach a dialog the main action opens: Domma's confirm takes Enter as OK, and its
287
+ // listener goes on while this key is still bubbling - a restore question would be answered unseen.
260
288
  const onKey = (e) => {
261
289
  const row = e.target.closest(rowSel);
262
290
  if (!row) return;
263
- if ((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu') { e.preventDefault(); openMenuAt(row); }
264
- else if ((e.key === 'Enter' || e.key === ' ') && e.target === row) { e.preventDefault(); primary(row.dataset.id); }
291
+ if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); openMenuAt(row); }
292
+ else if ((e.key === 'Enter' || e.key === ' ') && e.target === row) {
293
+ e.preventDefault();
294
+ e.stopPropagation();
295
+ const id = row.dataset.id;
296
+ setTimeout(() => primary(id), 0);
297
+ }
265
298
  };
266
299
  listEl.addEventListener('click', onClick);
267
300
  listEl.addEventListener('keydown', onKey);
@@ -309,8 +342,8 @@ export function filePicker({host, input, paste, caps}) {
309
342
  async function add(list) {
310
343
  for (const file of list) {
311
344
  if (picked.peek().length >= caps.max) { E.toast(`Up to ${caps.max} files a message.`, {type: 'warning'}); break; }
312
- if (file.size > caps.maxBytes) { E.toast(`${esc(file.name)} is too big - up to ${fileSize(caps.maxBytes)} each.`, {type: 'warning'}); continue; }
313
- if (!FILE_TYPES.split(',').includes(file.type)) { E.toast(`${esc(file.name)} cannot be attached - images, PDF or text only.`, {type: 'warning'}); continue; }
345
+ if (file.size > caps.maxBytes) { E.toast(`${file.name} is too big - up to ${fileSize(caps.maxBytes)} each.`, {type: 'warning'}); continue; }
346
+ if (!FILE_TYPES.split(',').includes(file.type)) { E.toast(`${file.name} cannot be attached - images, PDF or text only.`, {type: 'warning'}); continue; }
314
347
  const key = `k${Date.now()}${Math.random().toString(16).slice(2)}`;
315
348
  let thumb = '';
316
349
  if (isImage(file.type)) { thumb = URL.createObjectURL(file); localUrls.push(thumb); }
@@ -320,7 +353,7 @@ export function filePicker({host, input, paste, caps}) {
320
353
  picked.value = picked.peek().map(r => (r.key === key ? {...r, ...a, key, busy: false} : r));
321
354
  } catch (err) {
322
355
  picked.value = picked.peek().filter(r => r.key !== key);
323
- E.toast(esc(err.message || String(err)), {type: 'error'});
356
+ E.toast(err.message || String(err), {type: 'error'});
324
357
  }
325
358
  }
326
359
  }
@@ -340,6 +373,8 @@ export function filePicker({host, input, paste, caps}) {
340
373
  /** What Send names: the uploaded ones. */
341
374
  attachments: () => picked.peek().filter(x => x.id && !x.busy).map(x => ({id: x.id, name: x.name, type: x.type, size: x.size})),
342
375
  clear() { picked.value = []; },
376
+ /** Take out the files that went with a message (by uploaded id); any picked since stay. */
377
+ drop(ids = []) { const gone = new Set(ids); picked.value = picked.peek().filter(x => !x.id || !gone.has(x.id)); },
343
378
  dispose() {
344
379
  host.removeEventListener('click', onRemove);
345
380
  input.removeEventListener('change', onChange);
@@ -350,3 +385,108 @@ export function filePicker({host, input, paste, caps}) {
350
385
  }
351
386
  };
352
387
  }
388
+
389
+ // ---------------------------------------------------------------------------------
390
+ // Panels: one of a kind at a time, closed when the screen goes, and unsaved work kept
391
+ // ---------------------------------------------------------------------------------
392
+
393
+ /**
394
+ * The panels one mount has open, by kind ('compose', 'detail', ...). `claim()`
395
+ * before opening (false: one is already open or opening - no double-open), `set()`
396
+ * once it is open, `release()` when it closes or fails to open (or it could never
397
+ * open again until a reload), `leave()` from the view's teardown.
398
+ */
399
+ export function panelSet() {
400
+ const open = new Map();
401
+ return {
402
+ claim(kind) { if (open.has(kind)) return false; open.set(kind, {}); return true; },
403
+ set(kind, entry) { if (open.has(kind)) open.set(kind, entry); },
404
+ get: (kind) => open.get(kind),
405
+ release(kind) { open.delete(kind); },
406
+ /** The screen has gone: close every panel, keeping any drafts. */
407
+ leave() {
408
+ const all = [...open.values()];
409
+ open.clear();
410
+ for (const e of all) { try { e.close?.('leave'); } catch { /* already gone */ } }
411
+ }
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Offer a kept draft back before its panel opens. The draft is removed only
417
+ * once the person has answered "Throw away"; leaving while asked keeps it.
418
+ *
419
+ * @param {string} key
420
+ * @param {{question: (draft) => {text: string, options: object}, here: () => boolean}} o
421
+ * @returns {Promise<{state: 'none'|'discard'|'keep'|'gone', draft?: object, fate?: string}>}
422
+ */
423
+ export async function offerDraft(key, {question, here}) {
424
+ const draft = store.get(key);
425
+ if (!isDraft(draft)) return {state: 'none'};
426
+ const q = question(draft);
427
+ // E.confirm renders HTML: the question carries titles people typed.
428
+ const ok = await E.confirm(esc(q.text), q.options);
429
+ if (!here()) return {state: 'gone'};
430
+ if (ok) { store.remove(key); return {state: 'discard'}; }
431
+ return {state: 'keep', draft, fate: q.fate || ''};
432
+ }
433
+
434
+ /**
435
+ * Unsaved work in an open panel. Call it AFTER openPanel: its close guard wraps
436
+ * over the one kit.onceClosed put there.
437
+ *
438
+ * - while the form differs from `baseline` a draft is kept under `key`
439
+ * ({data: the changed fields only, was, at}); when it matches again the draft goes;
440
+ * - closing with changes asks first; Cancel (or Escape) keeps the panel open;
441
+ * - close('leave') - the screen has gone - closes without asking and KEEPS the draft;
442
+ * - close(true) - after a save - closes without asking.
443
+ *
444
+ * @param {{so, panel}} p - from openPanel
445
+ * @param {object} o
446
+ * @param {string} o.key
447
+ * @param {() => object} o.values - the form now (read observables with .value: it is tracked)
448
+ * @param {object} o.baseline - what is saved
449
+ * @param {string} [o.was] - the record's fingerprint when the edits began (a picked-up draft's own)
450
+ * @param {() => boolean} [o.extraDirty] - unsaved work no draft can keep (files picked)
451
+ * @param {(how) => void} [o.onClosed]
452
+ * @returns {{dirty: () => boolean, rebase: (baseline: object, was?: string) => void, settle: () => void, lock: (on: boolean) => void, close: (how?) => Promise}}
453
+ */
454
+ export function guardPanel(p, {key, values, baseline, was = '', extraDirty = () => false, onClosed = () => {}}) {
455
+ const base = M.observable(baseline);
456
+ let wasNow = was;
457
+ let closed = false;
458
+ let asking = false;
459
+ const keep = (v, b, settled = false) => {
460
+ // Once closed only a settle writes: leaving mid-save kept the form, and the request's answer decides what stays.
461
+ if (closed && !settled) return;
462
+ if (v && differs(v, b)) store.set(key, makeDraft(changedFields(v, b), wasNow));
463
+ else store.remove(key);
464
+ };
465
+ const stop = M.effect(() => keep(values(), base.value));
466
+ const dirty = () => differs(values(), base.peek()) || extraDirty();
467
+ const close = p.so.close.bind(p.so);
468
+ const finish = (how) => { closed = true; stop(); if (how !== 'leave') store.remove(key); onClosed(how); return close(); };
469
+ p.so.close = async (how) => {
470
+ if (closed) return undefined;
471
+ if (how !== 'leave' && how !== true && dirty()) {
472
+ if (asking) return undefined;
473
+ asking = true;
474
+ const ok = await E.confirm('Discard your changes?', DISCARD_CONFIRM);
475
+ asking = false;
476
+ // Left (or saved) while asked: that close has already happened.
477
+ if (!ok || closed) return undefined;
478
+ }
479
+ return finish(how);
480
+ };
481
+ return {
482
+ dirty,
483
+ // Written here as well as by the effect (an observable may not announce an equal value) - and
484
+ // after the panel has closed too: a reply box left while sending keeps only what was not sent.
485
+ rebase(next, nextWas) { if (nextWas !== undefined) wasNow = nextWas; base.value = next; keep(values(), next, true); },
486
+ /** A save succeeded: nothing is left unsaved, whether or not the panel was left meanwhile. */
487
+ settle() { keep(null, base.peek(), true); },
488
+ /** A panel that closes after saving is locked while it saves, so nothing typed meanwhile is lost. */
489
+ lock(on) { p.panel.inert = Boolean(on); p.panel.classList.toggle('is-saving', Boolean(on)); },
490
+ close: (how) => p.so.close(how)
491
+ };
492
+ }
@@ -11,7 +11,8 @@
11
11
  *
12
12
  * @module support/admin/lib/settings
13
13
  */
14
- import {BASE, esc, openPanel} from './kit.js';
14
+ import {BASE, esc, guardPanel, offerDraft, openPanel, panelSet} from './kit.js';
15
+ import {DRAFT_KEYS, draftQuestion, draftValues, settingsPrint} from './drafts.js';
15
16
 
16
17
  /** As lib/shape.js checks them; the server checks again. */
17
18
  const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
@@ -49,21 +50,43 @@ const problem = (kind, v, key) => {
49
50
  return '';
50
51
  };
51
52
 
52
- /** Add the cog when this user may change this side's settings (asked once, silently). */
53
- export async function addSettingsCog($container, kit) {
53
+ /**
54
+ * Add the cog when this user may change this side's settings (asked once, silently).
55
+ *
56
+ * @param {object} $container
57
+ * @param {object} kit - loadKit()
58
+ * @param {{scope: Function, here: () => boolean}} view - the mount's teardown and "still on screen"
59
+ */
60
+ export async function addSettingsCog($container, kit, {scope, here}) {
54
61
  let got = null;
55
62
  try { got = await H.request('GET', `${BASE}/settings`, null, {silent: true}); } catch { return; }
56
- if (!got?.side) return;
57
- kit.addBannerAction($container, {id: 'support-settings', icon: 'settings', title: 'Support settings', onClick: () => openSettings()});
63
+ if (!got?.side || !here()) return;
64
+ // The panel belongs to this mount: leaving the screen closes it, keeping what was typed.
65
+ const panels = panelSet();
66
+ scope(() => panels.leave());
67
+ kit.addBannerAction($container, {id: 'support-settings', icon: 'settings', title: 'Support settings', onClick: () => openSettings({panels, here})});
58
68
  }
59
69
 
60
- export async function openSettings() {
70
+ export async function openSettings({panels = panelSet(), here = () => true} = {}) {
71
+ if (!panels.claim('settings')) return;
72
+ let opened = false;
73
+ try { opened = await showSettings(panels, here); } catch (err) { E.toast(`Settings not opened: ${err?.message || err}`, {type: 'error'}); }
74
+ if (!opened) panels.release('settings');
75
+ }
76
+
77
+ async function showSettings(panels, here) {
61
78
  let got;
62
- try { got = await H.get(`${BASE}/settings`); } catch (err) { E.toast(`Settings not loaded: ${esc(err?.message || err)}`, {type: 'error'}); return; }
79
+ // A failed load opens nothing - there is no form that could save over the server's values.
80
+ try { got = await H.get(`${BASE}/settings`); } catch (err) { E.toast(`Settings not loaded: ${err?.message || err}`, {type: 'error'}); return false; }
81
+ if (!here()) return false;
63
82
  const fields = FIELDS[got.side] || [];
64
- const f = Object.fromEntries(fields.map(x => [x.key, M.observable(String(got.settings?.[x.key] ?? ''))]));
83
+ const loaded = Object.fromEntries(fields.map(x => [x.key, String(got.settings?.[x.key] ?? '')]));
84
+ const offer = await offerDraft(DRAFT_KEYS.settings, {here,
85
+ question: (d) => draftQuestion(d, settingsPrint(loaded), {what: 'the Support settings'})});
86
+ if (offer.state === 'gone') return false;
87
+ const start = offer.state === 'keep' ? draftValues(offer.draft, loaded) : loaded;
88
+ const f = Object.fromEntries(fields.map(x => [x.key, M.observable(start[x.key])]));
65
89
  const values = () => Object.fromEntries(fields.map(x => [x.key, x.kind === 'number' ? Number(f[x.key].peek()) : f[x.key].peek().trim()]));
66
- const opened = JSON.stringify(values());
67
90
  const saving = M.observable(false);
68
91
  const errs = Object.fromEntries(fields.map(x => [`${x.key}Err`, M.computed(() => problem(x.kind, f[x.key].value, x.key))]));
69
92
  const model = {...f, ...errs, saving, cannot: M.computed(() => saving.value || Object.values(errs).some(e => e.value))};
@@ -86,38 +109,38 @@ export async function openSettings() {
86
109
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">Save</button>
87
110
  </div>
88
111
  </div>`;
89
- let panel = null;
90
- let closed = false;
91
- let asking = false;
112
+ let guard = null;
92
113
  const methods = {
93
- cancel: () => panel?.so.close(),
114
+ cancel: () => guard?.close(),
94
115
  async save() {
95
116
  if (model.cannot.peek()) return;
96
117
  saving.value = true;
118
+ // The panel closes once saved: locked meanwhile, so nothing typed during the request is lost.
119
+ guard?.lock(true);
97
120
  try {
98
121
  await H.put(`${BASE}/settings`, values());
122
+ guard?.settle();
99
123
  E.toast('Support settings saved.', {type: 'success'});
100
- panel.so.close(true);
124
+ guard?.close(true);
101
125
  } catch (err) {
102
- E.toast(`Not saved: ${esc(err?.message || err)}`, {type: 'error'});
126
+ guard?.lock(false);
127
+ E.toast(`Not saved: ${err?.message || err}`, {type: 'error'});
103
128
  } finally {
104
129
  saving.value = false;
105
130
  }
106
131
  }
107
132
  };
108
- panel = await openPanel({title: 'Support settings', size: 'md', html, model, methods});
109
- // Closing with a change asks first - once (onceClosed calls close() again, argument-less).
110
- const close = panel.so.close.bind(panel.so);
111
- panel.so.close = async (force) => {
112
- if (!closed && force !== true && JSON.stringify(values()) !== opened) {
113
- if (asking) return undefined;
114
- asking = true;
115
- const ok = await E.confirm('Discard what you have entered?');
116
- asking = false;
117
- if (!ok) return undefined;
118
- }
119
- closed = true;
120
- return close();
121
- };
133
+ const panel = await openPanel({title: 'Support settings', size: 'md', html, model, methods,
134
+ cleanup: () => [...Object.values(errs), model.cannot].forEach(c => c.dispose?.())});
135
+ // Closing with a change asks first; leaving the screen keeps the change as a draft.
136
+ guard = guardPanel(panel, {
137
+ key: DRAFT_KEYS.settings,
138
+ values: () => Object.fromEntries(fields.map(x => [x.key, String(f[x.key].value)])),
139
+ baseline: loaded,
140
+ was: offer.state === 'keep' ? offer.draft.was : settingsPrint(loaded),
141
+ onClosed: () => panels.release('settings')
142
+ });
143
+ panels.set('settings', {close: (how) => guard.close(how)});
122
144
  panel.panel.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); methods.save(); } });
145
+ return true;
123
146
  }