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
@@ -82,7 +82,14 @@ export function viewScope(name, route) {
82
82
  lifetimes.set(name, life);
83
83
  }
84
84
  life.reset();
85
- return (fn) => { if (typeof fn === 'function') life.add(fn); };
85
+ // This mount's own flag: the next mount's reset (or leaving) turns it off,
86
+ // so work finishing after an await can ask whether its screen is still there.
87
+ let alive = true;
88
+ life.add(() => { alive = false; });
89
+ const add = (fn) => { if (typeof fn === 'function') life.add(fn); };
90
+ /** Is this mount still the one on screen? Check it after EVERY await. */
91
+ add.here = () => alive;
92
+ return add;
86
93
  }
87
94
 
88
95
  function localLifetime(pattern) {
@@ -176,6 +183,141 @@ export function takeHandOff(key) {
176
183
  }
177
184
  }
178
185
 
186
+ /** Tell the sidebar the Blog badge may have changed (it re-asks the countUrl). */
187
+ export function pokeSidebar() {
188
+ const url = '#/plugins/blog';
189
+ if (core?.sidebarCount) return core.sidebarCount(url);
190
+ try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url}})); } catch { /* no sidebar */ }
191
+ }
192
+
193
+ /**
194
+ * Shift+F10 on a focused row opens its context menu, as a right-click would.
195
+ * (The ContextMenu key is left alone: Chrome on Windows opens its own menu on
196
+ * key-up as well.)
197
+ *
198
+ * @param {HTMLElement} list
199
+ * @param {string} rowSel
200
+ * @returns {() => void} unhook
201
+ */
202
+ export function menuKeys(list, rowSel) {
203
+ const onKey = (e) => {
204
+ const row = e.target.closest?.(rowSel);
205
+ if (!row || e.target !== row || !(e.shiftKey && e.key === 'F10')) return;
206
+ e.preventDefault();
207
+ openMenuFrom(row);
208
+ };
209
+ list.addEventListener('keydown', onKey);
210
+ return () => list.removeEventListener('keydown', onKey);
211
+ }
212
+
213
+ /**
214
+ * A skeleton in `slot` while `promise` runs; the data back once it has gone
215
+ * (rethrows). No slot, or an admin without loading.js: just the promise. The
216
+ * slot must be a BLOCK element.
217
+ */
218
+ const loadingLib = await adminLib('loading.js');
219
+ export function withSkeleton(slot, promise, shape = 'rows') {
220
+ if (!slot || !loadingLib?.whileLoading) return promise;
221
+ return loadingLib.whileLoading(slot, promise, loadingLib.SHAPES?.[shape] ?? loadingLib.SHAPES?.rows);
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Unsaved work: kept drafts, the questions, saving without losing typing
226
+ // ---------------------------------------------------------------------------
227
+
228
+ /** The pure half (versions, how a draft stands, the wording) - admin/lib/drafts.js. */
229
+ const KIT_V = new URL(import.meta.url).searchParams.get('v') ?? '1';
230
+ export const draftLib = await import(`../lib/drafts.js?v=${KIT_V}`);
231
+
232
+ /**
233
+ * One kept draft per key - never a map of them, so a late writer cannot put
234
+ * back a stale copy of the others. Never holds a secret (Blog has none).
235
+ */
236
+ export const drafts = {
237
+ read(key) { try { const d = S.get(`blog.draft.${key}`); return d && d.values ? d : null; } catch { return null; } },
238
+ keep(key, draft) { try { S.set(`blog.draft.${key}`, {...draft, at: new Date().toISOString()}); } catch { /* storage blocked */ } },
239
+ drop(key) { try { if (S.remove) S.remove(`blog.draft.${key}`); else S.set(`blog.draft.${key}`, null); } catch { /* storage blocked */ } }
240
+ };
241
+
242
+ /**
243
+ * Ask whether to throw away a kept draft. OK ("Throw away") discards;
244
+ * Cancel, Escape and the backdrop all KEEP - Domma's confirm cannot tell
245
+ * them apart, so the safe answer has to be the one dismissing gives.
246
+ * `message` is plain text (escaped here).
247
+ *
248
+ * @param {string} message
249
+ * @param {{confirmText?: string, cancelText?: string}} [labels]
250
+ * @returns {Promise<boolean>} true = throw away
251
+ */
252
+ export async function askThrowAway(message, labels = draftLib.RESTORE_ANSWERS) {
253
+ return Boolean(await E.confirm(escapeHtml(message), {...draftLib.RESTORE_ANSWERS, ...labels}));
254
+ }
255
+
256
+ /**
257
+ * Ask before a slideover with unsaved changes closes - Cancel, ×, Escape and
258
+ * the backdrop all go through close(). Wrap AFTER onceClosed. `close()` on
259
+ * the handle closes without asking (after a save, or when the screen is left
260
+ * and a draft has been kept).
261
+ *
262
+ * @param {object} so
263
+ * @param {{dirty: () => boolean, question?: string}} opts
264
+ * @returns {{close: () => void}}
265
+ */
266
+ export function guardSlideover(so, {dirty, question = 'Discard your changes? They have not been saved.'}) {
267
+ const inner = so.close;
268
+ let asking = false;
269
+ let free = false;
270
+ so.close = (...args) => {
271
+ if (free || !dirty()) return inner(...args);
272
+ if (asking) return undefined;
273
+ asking = true;
274
+ Promise.resolve(E.confirm(question, {confirmText: 'Discard', cancelText: 'Keep editing'})).then((ok) => {
275
+ asking = false;
276
+ if (ok) { free = true; inner(...args); }
277
+ });
278
+ return undefined;
279
+ };
280
+ return {close() { free = true; try { inner(); } catch { /* already gone */ } }};
281
+ }
282
+
283
+ /**
284
+ * Lock a panel while it saves when it closes afterwards - anything typed
285
+ * meanwhile would go unsent. Returns the unlock (focus back where it was).
286
+ */
287
+ export function lockWhileSaving(panel) {
288
+ const had = document.activeElement;
289
+ panel.inert = true;
290
+ return () => {
291
+ panel.inert = false;
292
+ if (had && panel.contains(had) && had.isConnected) had.focus();
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Close a slideover the moment the address changes - a panel over the next
298
+ * screen catches its clicks. `close` is how (a guarded panel passes its
299
+ * guard's close, which does not ask). Returns the unhook.
300
+ */
301
+ export function closeOnLeave(so, close = () => so.close()) {
302
+ const at = location.hash;
303
+ const onHash = () => { if (location.hash !== at) { try { close(); } catch { /* gone */ } } };
304
+ window.addEventListener('hashchange', onHash);
305
+ return () => window.removeEventListener('hashchange', onHash);
306
+ }
307
+
308
+ /**
309
+ * One panel of a kind at a time. `claim(name)` is false while it is open;
310
+ * the release must run if opening throws, or it could never open again
311
+ * until a reload.
312
+ */
313
+ const openPanels = new Set();
314
+ export function claim(name) {
315
+ if (openPanels.has(name)) return null;
316
+ openPanels.add(name);
317
+ let done = false;
318
+ return () => { if (!done) { done = true; openPanels.delete(name); } };
319
+ }
320
+
179
321
  // ---------------------------------------------------------------------------
180
322
  // Settings
181
323
  // ---------------------------------------------------------------------------
@@ -262,26 +404,33 @@ const SETTINGS_PANEL = `
262
404
  </div>
263
405
  </div>`;
264
406
 
407
+ /** The settings fields a draft is aged against. */
408
+ const SETTING_KEYS = ['basePath', 'postsPerPage', 'feedLength', 'commentsEnabled', 'commentModeration',
409
+ 'postLayout', 'listDisplay', 'displaySwitcher', 'interactive', 'relatedCount'];
410
+
265
411
  /**
266
412
  * The settings form as an element, bound to its own observables. Used by the
267
413
  * banner cog's slideover and by the old #/plugins/blog/settings page, so the
268
414
  * two can never drift apart.
269
415
  *
416
+ * A failed load leaves the form unable to save: saving defaults over settings
417
+ * that could not be read would wipe them.
418
+ *
270
419
  * @param {{onClose?: Function}} [opts] - with onClose, a Cancel button closes the host
271
- * @returns {Promise<{el: HTMLElement, dispose: Function}>}
420
+ * @returns {Promise<{el: HTMLElement, dispose: Function, isDirty: () => boolean,
421
+ * values: () => object, restore: (v: object) => void, was: string, failed: boolean}>}
272
422
  */
273
423
  export async function settingsPanel({onClose = null} = {}) {
274
424
  let stored = {};
275
- try {
276
- const res = await H.get(`${BASE}/settings`);
277
- stored = res?.data ?? res ?? {};
278
- } catch {
279
- E.toast('Failed to load blog settings.', {type: 'error'});
425
+ let failed = false;
426
+ const [settingsRes, layoutsRes] = await Promise.allSettled([H.get(`${BASE}/settings`), H.get(`${BASE}/layouts`)]);
427
+ if (settingsRes.status === 'fulfilled' && settingsRes.value && !settingsRes.value.error) {
428
+ stored = settingsRes.value?.data ?? settingsRes.value ?? {};
429
+ } else {
430
+ failed = true;
280
431
  }
281
- let layouts = {postLayouts: [], listDisplays: []};
282
- try {
283
- layouts = await H.get(`${BASE}/layouts`);
284
- } catch { /* the selects fall back to the stored value alone */ }
432
+ // The selects fall back to the stored value alone.
433
+ const layouts = layoutsRes.status === 'fulfilled' && layoutsRes.value ? layoutsRes.value : {postLayouts: [], listDisplays: []};
285
434
  const initial = {
286
435
  basePath: stored.basePath ?? '/blog',
287
436
  postsPerPage: Number(stored.postsPerPage ?? 10),
@@ -300,7 +449,7 @@ export async function settingsPanel({onClose = null} = {}) {
300
449
 
301
450
  const f = Object.fromEntries(Object.entries(initial).map(([k, v]) => [k, M.observable(v)]));
302
451
  const saving = M.observable(false);
303
- const status = M.observable('');
452
+ const status = M.observable(failed ? 'The settings could not be loaded - close this and try again.' : '');
304
453
  const current = () => ({
305
454
  basePath: String(f.basePath.value).trim() || '/blog',
306
455
  postsPerPage: Number(f.postsPerPage.value) || 10,
@@ -313,13 +462,15 @@ export async function settingsPanel({onClose = null} = {}) {
313
462
  interactive: Boolean(f.interactive.value),
314
463
  relatedCount: Math.max(0, Math.min(6, Number(f.relatedCount.value) || 0))
315
464
  });
465
+ const isDirty = () => !failed && JSON.stringify(current()) !== JSON.stringify(baseline.peek());
316
466
 
317
467
  const data = {
318
468
  ...f, saving, status,
319
469
  closable: Boolean(onClose),
470
+ failed,
320
471
  layoutOptions: M.observable(withCurrent(layouts.postLayouts ?? [], initial.postLayout)),
321
472
  displayOptions: M.observable(withCurrent(layouts.listDisplays ?? [], initial.listDisplay)),
322
- dirty: M.computed(() => JSON.stringify(current()) !== JSON.stringify(baseline.value))
473
+ dirty: M.computed(() => !failed && JSON.stringify(current()) !== JSON.stringify(baseline.value))
323
474
  };
324
475
 
325
476
  const el = fragment(SETTINGS_PANEL);
@@ -332,6 +483,7 @@ export async function settingsPanel({onClose = null} = {}) {
332
483
  R.navigate('#/blocks');
333
484
  },
334
485
  async save() {
486
+ if (failed) return;
335
487
  const body = current();
336
488
  if (!body.basePath.startsWith('/')) {
337
489
  E.toast('Base path must start with /.', {type: 'error'});
@@ -341,9 +493,12 @@ export async function settingsPanel({onClose = null} = {}) {
341
493
  try {
342
494
  const res = await H.put(`${BASE}/settings`, body);
343
495
  if (res?.error) throw new Error(res.error);
344
- // The server tidies the path ("/news/" is saved as "/news"); show what it kept.
496
+ // The server tidies the path ("/news/" is saved as "/news"); show what it
497
+ // kept - unless it was typed over while saving.
345
498
  const savedBase = (res?.settings ?? res?.data?.settings)?.basePath;
346
- if (savedBase) f.basePath.value = body.basePath = savedBase;
499
+ if (savedBase && String(f.basePath.peek()).trim() === body.basePath) f.basePath.value = savedBase;
500
+ if (savedBase) body.basePath = savedBase;
501
+ // What was SENT is the new baseline: anything typed meanwhile stays unsaved.
347
502
  baseline.value = {...body};
348
503
  basePathCache = null;
349
504
  status.value = 'Saved.';
@@ -357,14 +512,69 @@ export async function settingsPanel({onClose = null} = {}) {
357
512
  }
358
513
  });
359
514
  if (typeof I !== 'undefined' && I.scan) I.scan(el);
360
- return {el, dispose: () => { handle.dispose(); disposeHelp(); }};
515
+ return {
516
+ el,
517
+ failed,
518
+ was: failed ? '' : draftLib.versionOf(initial, SETTING_KEYS),
519
+ isDirty,
520
+ values: current,
521
+ restore(v) {
522
+ for (const k of SETTING_KEYS) if (k in (v ?? {})) f[k].value = v[k];
523
+ },
524
+ dispose: () => { handle.dispose(); disposeHelp(); }
525
+ };
361
526
  }
362
527
 
363
- /** The banner cog's slideover. Resizable, like every admin slideover. */
528
+ /**
529
+ * A kept settings draft, offered back when the settings open: "Throw away"
530
+ * drops it; Cancel/Escape put the changes back in the form. Left alone when
531
+ * `stillHere()` turns false while asking - it is there for next time.
532
+ */
533
+ export async function offerSettingsDraft(panel, stillHere) {
534
+ const draft = drafts.read('settings');
535
+ if (!draft || panel.failed) return;
536
+ const state = draft.was && draft.was !== panel.was ? 'changed' : 'same';
537
+ const throwAway = await askThrowAway(draftLib.restoreQuestion({what: 'settings', state}));
538
+ if (!stillHere()) return;
539
+ drafts.drop('settings');
540
+ if (!throwAway) panel.restore(draft.values);
541
+ }
542
+
543
+ /** Keep the settings form's unsaved changes for next time. */
544
+ export function keepSettingsDraft(panel) {
545
+ if (!panel.isDirty()) return false;
546
+ drafts.keep('settings', {id: 'settings', name: '', values: panel.values(), was: panel.was});
547
+ return true;
548
+ }
549
+
550
+ /**
551
+ * The banner cog's slideover. Resizable, like every admin slideover. One at a
552
+ * time; closing with unsaved changes asks; leaving the screen keeps them as a
553
+ * draft, offered back next time.
554
+ */
364
555
  export async function openSettings() {
365
- const so = E.slideover({title: 'Blog settings', size: 'sm', position: 'right'});
366
- const {el, dispose} = await settingsPanel({onClose: () => so.close()});
367
- so.element.appendChild(el);
368
- onceClosed(so, dispose);
369
- so.open();
556
+ const release = claim('settings');
557
+ if (!release) return;
558
+ let so = null;
559
+ try {
560
+ const panel = await settingsPanel({onClose: () => so?.close()});
561
+ // A kept draft is asked about BEFORE the panel opens, so the Escape
562
+ // that keeps it cannot also close the panel. Left while asked: kept.
563
+ const at = location.hash;
564
+ await offerSettingsDraft(panel, () => location.hash === at);
565
+ if (location.hash !== at) { panel.dispose(); release(); return; }
566
+ so = E.slideover({title: 'Blog settings', size: 'sm', position: 'right'});
567
+ so.element.appendChild(panel.el);
568
+ let unhook = () => {};
569
+ onceClosed(so, () => { unhook(); panel.dispose(); release(); });
570
+ const guard = guardSlideover(so, {dirty: panel.isDirty});
571
+ unhook = closeOnLeave(so, () => {
572
+ if (keepSettingsDraft(panel)) E.toast('Your unsaved settings are kept - open Blog settings again to pick them up.', {type: 'info'});
573
+ guard.close();
574
+ });
575
+ so.open();
576
+ } catch (err) {
577
+ release();
578
+ throw err;
579
+ }
370
580
  }