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.
- package/admin/css/calculator.css +1 -1
- package/admin/js/lib/analytics-shape.js +1 -1
- package/admin/js/lib/calculator-engine.js +1 -1
- package/admin/js/lib/calculator.js +22 -10
- package/admin/js/lib/form-editor-arrange.js +1 -1
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/notes-arrange.js +1 -1
- package/admin/js/lib/search-settings-arrange.js +1 -1
- package/admin/js/lib/seo-arrange.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -1
- package/admin/js/lib/tool-kit.js +1 -1
- package/admin/js/lib/tool-recovery.js +4 -4
- package/admin/js/templates/analytics.html +26 -14
- package/admin/js/templates/contacts.html +26 -5
- package/admin/js/templates/form-editor.html +2 -0
- package/admin/js/templates/notes.html +11 -8
- package/admin/js/templates/search.html +9 -3
- package/admin/js/templates/seo.html +23 -17
- package/admin/js/templates/todo.html +18 -14
- package/admin/js/views/analytics.js +10 -10
- package/admin/js/views/contacts.js +47 -47
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/forms.js +5 -5
- package/admin/js/views/notes.js +6 -6
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +43 -43
- package/admin/js/views/todo.js +8 -8
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -1
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-kit.js +364 -0
- package/plugins/_lib/admin/mail/reader-view.js +1009 -161
- package/plugins/_lib/admin/mail/when-closed.js +33 -3
- package/plugins/_lib/mail/readRoutes.js +48 -10
- package/plugins/_lib/mail/unreadBadge.js +4 -1
- package/plugins/blog/CLAUDE.md +14 -0
- package/plugins/blog/admin/css/index.css +5 -0
- package/plugins/blog/admin/lib/drafts.js +95 -0
- package/plugins/blog/admin/templates/blog.html +1 -1
- package/plugins/blog/admin/templates/categories.html +1 -1
- package/plugins/blog/admin/templates/comments.html +1 -1
- package/plugins/blog/admin/templates/post-editor.html +5 -1
- package/plugins/blog/admin/views/blog.js +44 -19
- package/plugins/blog/admin/views/categories.js +138 -20
- package/plugins/blog/admin/views/comments.js +32 -23
- package/plugins/blog/admin/views/kit.js +232 -22
- package/plugins/blog/admin/views/post-editor.js +348 -178
- package/plugins/blog/admin/views/settings.js +17 -3
- package/plugins/blog/plugin.js +1 -1
- package/plugins/blog/plugin.json +7 -7
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/drafts.test.js +50 -0
- package/plugins/booking/CLAUDE.md +13 -0
- package/plugins/booking/admin/lib/drafts.js +139 -0
- package/plugins/booking/admin/lib/kit.js +246 -25
- package/plugins/booking/admin/views/booking.js +11 -2
- package/plugins/booking/admin/views/bookings.js +7 -2
- package/plugins/booking/admin/views/diary.js +13 -6
- package/plugins/booking/admin/views/panels.js +85 -12
- package/plugins/booking/admin/views/people.js +41 -2
- package/plugins/booking/admin/views/services.js +45 -3
- package/plugins/booking/admin/views/settings.js +22 -2
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/admin-drafts.test.js +67 -0
- package/plugins/feedback/CLAUDE.md +12 -0
- package/plugins/feedback/admin/css/index.css +5 -0
- package/plugins/feedback/admin/lib/drafts.js +145 -0
- package/plugins/feedback/admin/lib/kit.js +161 -5
- package/plugins/feedback/admin/lib/settings.js +46 -28
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/admin/views/inbox.js +110 -46
- package/plugins/feedback/admin/views/send.js +174 -83
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/feedback/tests/drafts.test.js +93 -0
- package/plugins/free-tier.lock.json +75 -63
- package/plugins/security/CLAUDE.md +9 -0
- package/plugins/security/admin/lib/screen-state.js +120 -0
- package/plugins/security/admin/templates/security.html +15 -10
- package/plugins/security/admin/views/security.js +270 -132
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/screen-state.test.js +69 -0
- package/plugins/shopping-cart/CLAUDE.md +13 -0
- package/plugins/shopping-cart/admin/css/index.css +21 -5
- package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
- package/plugins/shopping-cart/admin/lib/kit.js +136 -28
- package/plugins/shopping-cart/admin/views/orders.js +117 -36
- package/plugins/shopping-cart/admin/views/overview.js +38 -8
- package/plugins/shopping-cart/admin/views/products.js +317 -68
- package/plugins/shopping-cart/admin/views/sales.js +21 -3
- package/plugins/shopping-cart/admin/views/settings.js +62 -3
- package/plugins/shopping-cart/admin/views/shop.js +18 -3
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/tests/accounts.test.js +11 -0
- package/plugins/shopping-cart/tests/drafts.test.js +118 -0
- package/plugins/support/CLAUDE.md +14 -0
- package/plugins/support/admin/css/index.css +10 -0
- package/plugins/support/admin/lib/drafts.js +162 -0
- package/plugins/support/admin/lib/kit.js +156 -16
- package/plugins/support/admin/lib/settings.js +52 -29
- package/plugins/support/admin/views/desk.js +114 -33
- package/plugins/support/admin/views/inbox.js +85 -26
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/plugins/support/tests/drafts.test.js +92 -0
- package/server/routes/api/analytics.js +5 -1
- package/server/routes/api/contacts.js +42 -0
- package/server/routes/api/forms.js +8 -0
- package/server/routes/api/seo.js +4 -1
- package/server/routes/api/todo.js +50 -10
- package/server/server.js +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
|
|
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
|
-
|
|
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(`${
|
|
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(
|
|
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="-
|
|
215
|
-
<div class="sp-msg-head"><strong data-bind-text="who"></strong><small data-bind-text="when"></small
|
|
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">⋮</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
|
-
*
|
|
248
|
-
*
|
|
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 (
|
|
264
|
-
else if ((e.key === 'Enter' || e.key === ' ') && e.target === row) {
|
|
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(`${
|
|
313
|
-
if (!FILE_TYPES.split(',').includes(file.type)) { E.toast(`${
|
|
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(
|
|
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
|
-
/**
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
90
|
-
let closed = false;
|
|
91
|
-
let asking = false;
|
|
112
|
+
let guard = null;
|
|
92
113
|
const methods = {
|
|
93
|
-
cancel: () =>
|
|
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
|
-
|
|
124
|
+
guard?.close(true);
|
|
101
125
|
} catch (err) {
|
|
102
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
}
|