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
@@ -4,10 +4,6 @@
4
4
  * @module blog/admin/views/post-editor
5
5
  */
6
6
 
7
- function escapeHtml(s) {
8
- return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
9
- }
10
-
11
7
  function slugify(s) {
12
8
  return String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
13
9
  }
@@ -58,14 +54,15 @@ const PREVIEW_PANEL = `
58
54
  * inserted here because the router renders the page template through _.render,
59
55
  * which eats {{…}}.
60
56
  *
57
+ * Its effect, bindings and an open Preview go when the editor does (`add`);
58
+ * Preview opens one at a time and closes when the address changes.
59
+ *
61
60
  * @param {HTMLElement} card
62
- * @param {{current: string, collect: () => object}} opts
63
- * @returns {Promise<{value: () => string}>}
61
+ * @param {{current: string, collect: () => object, catalogue: object, kit: object, add: Function}} opts
62
+ * @returns {{value: () => string, set: (id: string) => void, layout: object}}
64
63
  */
65
- async function mountLayoutCard(card, {current, collect}) {
64
+ function mountLayoutCard(card, {current, collect, catalogue, kit, add}) {
66
65
  const BASE_URL = '/api/plugins/blog';
67
- let catalogue = {postLayouts: [], postLayout: 'blog-post-classic'};
68
- try { catalogue = await H.get(`${BASE_URL}/layouts`); } catch { /* the card still offers Site default */ }
69
66
  const known = catalogue.postLayouts ?? [];
70
67
  const defaultLabel = known.find(l => l.id === catalogue.postLayout)?.label ?? 'Classic';
71
68
  const thumbOf = (id) => (['blog-post-classic', 'blog-post-feature', 'blog-post-minimal', 'blog-post-sidebar', 'blog-post-split', 'blog-post-essay'].includes(id) ? id.slice(10) : 'custom');
@@ -78,16 +75,17 @@ async function mountLayoutCard(card, {current, collect}) {
78
75
 
79
76
  const layout = M.observable(current || '');
80
77
  const layoutChoices = M.observable([]);
81
- M.effect(() => {
78
+ add(M.effect(() => {
82
79
  layoutChoices.value = choices.map(c => ({...c, id: c.id || 'default', checked: String(c.id === layout.value), cls: c.id === layout.value ? 'is-on' : ''}));
83
- });
80
+ }));
84
81
  const data = {
85
82
  layoutChoices,
86
83
  layoutDesc: M.computed(() => choices.find(c => c.id === layout.value)?.description ?? '')
87
84
  };
88
85
 
89
86
  card.querySelector('.blog-layouts').innerHTML = LAYOUT_CHOICE;
90
- M.applyBindings(data, card.querySelector('.card-body'));
87
+ const bound = M.applyBindings(data, card.querySelector('.card-body'));
88
+ add(() => bound.dispose());
91
89
  card.querySelector('.blog-layouts').addEventListener('click', (e) => {
92
90
  const b = e.target.closest('[data-layout]');
93
91
  if (b) layout.value = b.dataset.layout === 'default' ? '' : b.dataset.layout;
@@ -95,7 +93,17 @@ async function mountLayoutCard(card, {current, collect}) {
95
93
  card.querySelector('#btn-preview').addEventListener('click', () => openPreview());
96
94
 
97
95
  async function openPreview() {
98
- const kit = await import('./kit.js?v=' + (new URL(import.meta.url).searchParams.get('v') ?? '1'));
96
+ const release = kit.claim('blog-preview'); // one at a time
97
+ if (!release) return;
98
+ try {
99
+ await preview(release);
100
+ } catch (err) {
101
+ release();
102
+ throw err;
103
+ }
104
+ }
105
+
106
+ async function preview(release) {
99
107
  const trial = M.observable(layout.peek() || catalogue.postLayout || 'blog-post-classic');
100
108
  const width = M.observable('desktop');
101
109
  const state = M.observable('');
@@ -105,11 +113,19 @@ async function mountLayoutCard(card, {current, collect}) {
105
113
  changed: M.computed(() => trial.value !== (layout.value || catalogue.postLayout))
106
114
  };
107
115
  const so = E.slideover({title: 'Preview', size: 'xl', position: 'right'});
116
+ let alive = true;
117
+ let handle = null;
118
+ let stop = null;
119
+ let disposeHelp = () => {};
120
+ // Leaving the editor closes it: a panel over the next screen catches its clicks.
121
+ const unhook = kit.closeOnLeave(so);
122
+ kit.onceClosed(so, () => { alive = false; release(); unhook(); stop?.(); handle?.dispose(); disposeHelp(); });
108
123
  const el = kit.fragment(PREVIEW_PANEL);
109
124
  so.element.appendChild(el);
110
- const disposeHelp = await kit.scanHelp(el);
125
+ disposeHelp = await kit.scanHelp(el);
126
+ if (!alive) return;
111
127
  const frame = el.querySelector('iframe');
112
- const handle = M.applyBindings(pdata, el, {
128
+ handle = M.applyBindings(pdata, el, {
113
129
  methods: {
114
130
  desktop: () => { width.value = 'desktop'; },
115
131
  tablet: () => { width.value = 'tablet'; },
@@ -123,19 +139,19 @@ async function mountLayoutCard(card, {current, collect}) {
123
139
  if (typeof I !== 'undefined' && I.scan) I.scan(el);
124
140
 
125
141
  let seq = 0;
126
- const stop = M.effect(async () => {
142
+ stop = M.effect(async () => {
127
143
  const chosen = trial.value;
128
144
  const mine = ++seq;
129
145
  state.value = 'Rendering…';
130
146
  try {
131
147
  const res = await H.post(`${BASE_URL}/preview`, {...collect(), layout: chosen});
132
- if (mine !== seq) return;
148
+ if (mine !== seq || !alive) return;
133
149
  if (res?.error) throw new Error(res.error);
134
150
  // The page's own addresses (/dist/…, /plugins/…) resolve against the site.
135
151
  frame.srcdoc = String(res.html).replace(/<head>/i, `<head><base href="${location.origin}/">`);
136
152
  state.value = '';
137
153
  } catch (err) {
138
- if (mine === seq) state.value = err?.message ?? 'Preview failed.';
154
+ if (mine === seq && alive) state.value = err?.message ?? 'Preview failed.';
139
155
  }
140
156
  });
141
157
  // Links inside the preview would navigate the frame away from the post.
@@ -149,11 +165,10 @@ async function mountLayoutCard(card, {current, collect}) {
149
165
  }, true);
150
166
  } catch { /* not ours to reach */ }
151
167
  });
152
- kit.onceClosed(so, () => { stop?.(); handle.dispose(); disposeHelp(); });
153
168
  so.open();
154
169
  }
155
170
 
156
- return {value: () => layout.peek()};
171
+ return {value: () => layout.peek(), set: (id) => { layout.value = id || ''; }, layout};
157
172
  }
158
173
 
159
174
  const STATUS_CLASS = {published: 'badge-success', scheduled: 'badge-warning'};
@@ -260,7 +275,8 @@ function mountPills(input, {data, value, creatable = false, placeholder, empty,
260
275
 
261
276
  const wrapper = input.nextElementSibling;
262
277
  field = wrapper?.querySelector('.dm-pillbox-input');
263
- if (!field) return {pending: () => '', destroy: () => box.destroy?.()};
278
+ const set = (v) => { box.setValue?.([...v]); selected.value = box.getValue(); };
279
+ if (!field) return {pending: () => '', set, destroy: () => box.destroy?.()};
264
280
 
265
281
  // Pillbox only opens once something is typed; a list you cannot see is a
266
282
  // list you cannot pick from. _filterOptions is Pillbox's own "narrow to
@@ -318,6 +334,8 @@ function mountPills(input, {data, value, creatable = false, placeholder, empty,
318
334
 
319
335
  return {
320
336
  pending: () => (creatable ? field.value.trim() : ''),
337
+ /** Put these values back (a picked-up draft). */
338
+ set,
321
339
  destroy: () => box.destroy?.()
322
340
  };
323
341
  }
@@ -326,11 +344,6 @@ export const postEditorView = {
326
344
  templateUrl: '/plugins/blog/admin/templates/post-editor.html',
327
345
 
328
346
  async onMount($container) {
329
- // Help (?) popovers, loaded at run time - a plugin must not import admin/.
330
- // Scanned now, before anything below reshapes the form.
331
- import('/admin/js/lib/help-popover.js')
332
- .then(m => m.helpPopovers?.($container, {position: 'bottom'}))
333
- .catch(() => null);
334
347
  import(`./settings-cog.js?v=${ASSET_VERSION}`)
335
348
  .then(m => m.addSettingsCog($container, '.view-header-actions'))
336
349
  .catch(() => { /* no cog is better than no screen */ });
@@ -339,34 +352,87 @@ export const postEditorView = {
339
352
  // function object, so this view opened blank on every edit and Save
340
353
  // created a duplicate. Read the slug off the hash, as every core editor
341
354
  // view does.
342
- const routeMatch = location.hash.match(/^#\/plugins\/blog\/posts\/([^?#]+)/);
355
+ const routeMatch = location.hash.match(/^#\/plugins\/blog\/posts\/([^?#/]+)/);
343
356
  const routeId = routeMatch ? decodeURIComponent(routeMatch[1]) : null;
344
357
  // `let`: a new post becomes an existing one on its first Save.
345
358
  let id = routeId && routeId !== 'new' ? routeId : null;
346
359
  let isEdit = Boolean(id);
347
360
 
361
+ const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
362
+ const {draftState, restoreQuestion} = kit.draftLib;
363
+ // Everything below is torn down when the hash leaves this editor (the
364
+ // router has no unmount) - or when another post's editor mounts.
365
+ const add = kit.viewScope('editor', /^#\/plugins\/blog\/posts\/[^/?#]+$/);
366
+ const here = add.here;
367
+ const root = $container.get(0);
368
+ const editorEl = root.querySelector('.blog-editor');
369
+ const slot = root.querySelector('.blog-loading');
370
+ const draftKey = (pid) => `post.${pid || 'new'}`;
371
+
372
+ // ---- Load: a skeleton until the post, the categories, tags and layouts are in ----
348
373
  let post = null;
349
374
  let categories = [];
350
- let featuredImageUrl = '';
351
-
375
+ let allPosts = [];
376
+ let catalogue = {postLayouts: [], postLayout: 'blog-post-classic'};
377
+ let missing = false;
352
378
  try {
353
- const catsRes = await H.get(`${BASE}/categories`).catch(() => ({ data: [] }));
354
- categories = catsRes.data ?? catsRes ?? [];
379
+ const [catsRes, postRes, postsRes, layouts] = await kit.withSkeleton(slot, Promise.all([
380
+ H.get(`${BASE}/categories`).catch(() => []),
381
+ isEdit ? H.get(`${BASE}/posts/${encodeURIComponent(id)}`).catch((err) => {
382
+ if (err?.status === 404 || /not found/i.test(err?.message ?? '')) { missing = true; return null; }
383
+ throw err;
384
+ }) : null,
385
+ H.get(`${BASE}/posts`).catch(() => []),
386
+ H.get(`${BASE}/layouts`).catch(() => null)
387
+ ]));
388
+ categories = catsRes?.data ?? (Array.isArray(catsRes) ? catsRes : []);
389
+ if (isEdit && !missing && /not found/i.test(String(postRes?.error ?? ''))) missing = true;
390
+ if (isEdit && !missing) {
391
+ if (!postRes || postRes.error) throw new Error(postRes?.error ?? 'no post');
392
+ post = postRes.data ?? postRes;
393
+ }
394
+ allPosts = postsRes?.data ?? (Array.isArray(postsRes) ? postsRes : []);
395
+ if (layouts?.postLayouts) catalogue = layouts;
355
396
  } catch {
356
- categories = [];
397
+ // No form is shown, so nothing can be saved over the post.
398
+ if (!here()) return;
399
+ E.toast('Failed to load the post.', {type: 'error'});
400
+ R.navigate('#/plugins/blog/posts');
401
+ return;
357
402
  }
403
+ if (!here()) return;
358
404
 
359
- if (isEdit) {
360
- try {
361
- const res = await H.get(`${BASE}/posts/${id}`);
362
- if (res.error) throw new Error(res.error);
363
- post = res.data ?? res;
364
- } catch {
365
- E.toast('Failed to load post.', { type: 'error' });
405
+ // Deleted since it was opened (or since a draft of it was kept).
406
+ if (missing) {
407
+ const draft = kit.drafts.read(draftKey(id));
408
+ if (!draft) {
409
+ E.toast('That post no longer exists.', {type: 'error'});
366
410
  R.navigate('#/plugins/blog/posts');
367
411
  return;
368
412
  }
413
+ const throwAway = await kit.askThrowAway(restoreQuestion({what: 'post', name: draft.values?.title || draft.name || '', state: 'deleted'}));
414
+ // Left while asked: it stays for next time.
415
+ if (!here()) return;
416
+ if (!throwAway && kit.drafts.read(draftKey(null))) {
417
+ // There is already an unsaved NEW post: starting this one again would replace it.
418
+ const replace = await E.confirm('You also have an unsaved new post. Starting this one again replaces it. Replace it?',
419
+ {confirmText: 'Replace it', cancelText: 'Keep both for now'});
420
+ if (!here()) return;
421
+ if (!replace) { R.navigate('#/plugins/blog/posts'); return; } // both drafts stay
422
+ }
423
+ kit.drafts.drop(draftKey(id));
424
+ if (!throwAway) {
425
+ // Started again as new: no age, and the new editor takes it without asking again.
426
+ kit.drafts.keep(draftKey(null), {id: null, name: '', values: draft.values, was: ''});
427
+ kit.handOff('restore-new', '1');
428
+ R.navigate('#/plugins/blog/posts/new');
429
+ } else {
430
+ R.navigate('#/plugins/blog/posts');
431
+ }
432
+ return;
369
433
  }
434
+ if (slot) slot.hidden = true;
435
+ if (editorEl) editorEl.hidden = false;
370
436
 
371
437
  const titleEl = $container.find('#editor-title').get(0);
372
438
  const postTitleEl = $container.find('#post-title').get(0);
@@ -384,6 +450,7 @@ export const postEditorView = {
384
450
  const imagePreviewEl = $container.find('#featured-image-preview').get(0);
385
451
  const clearImageBtn = $container.find('#btn-clear-image').get(0);
386
452
 
453
+ let featuredImageUrl = '';
387
454
  if (titleEl) titleEl.textContent = isEdit ? 'Editing post' : 'New post';
388
455
 
389
456
  if (isEdit && post) {
@@ -395,8 +462,8 @@ export const postEditorView = {
395
462
  if (seoDescEl) seoDescEl.value = post.seo?.description ?? '';
396
463
  featuredImageUrl = post.featuredImage ?? '';
397
464
  renderImagePreview();
398
- } else {
399
- if (slugAutoEl) slugAutoEl.checked = true;
465
+ } else if (slugAutoEl) {
466
+ slugAutoEl.checked = true;
400
467
  }
401
468
 
402
469
  function renderImagePreview() {
@@ -409,22 +476,21 @@ export const postEditorView = {
409
476
  img.className = 'blog-image-preview';
410
477
  imagePreviewEl.appendChild(img);
411
478
  if (clearImageBtn) clearImageBtn.classList.remove('blog-hidden');
412
- } else {
413
- if (clearImageBtn) clearImageBtn.classList.add('blog-hidden');
479
+ } else if (clearImageBtn) {
480
+ clearImageBtn.classList.add('blog-hidden');
414
481
  }
415
482
  }
416
483
 
417
484
  // ---- Status, categories and tags (domma-reactive) ----
418
- const disposers = [];
419
485
  const stamps = mountStatusCard($container.find('#status-card').get(0));
420
486
  stamps.set(post);
421
- disposers.push(() => stamps.dispose());
487
+ add(() => stamps.dispose());
422
488
 
423
489
  // A category deleted since the post was saved has no name to show; it
424
490
  // is kept as it was rather than silently dropped on the next save.
425
491
  const knownIds = new Set(categories.map(c => c.id));
426
492
  const postCats = Array.isArray(post?.categories) ? post.categories : [];
427
- const unknownCats = postCats.filter(cid => !knownIds.has(cid));
493
+ let unknownCats = postCats.filter(cid => !knownIds.has(cid));
428
494
  const selectedCats = M.observable([]);
429
495
  const cats = catsEl ? mountPills(catsEl, {
430
496
  data: categories.map(c => ({value: c.id, label: c.name ?? c.slug ?? c.id})),
@@ -434,20 +500,15 @@ export const postEditorView = {
434
500
  selected: selectedCats
435
501
  }) : null;
436
502
  if (!categories.length) $container.find('#categories-note').get(0)?.classList.remove('blog-hidden');
437
- disposers.push(() => cats?.destroy());
503
+ add(() => cats?.destroy());
438
504
 
439
505
  // Suggestions: every tag already on a post, most used first.
440
- let tagChoices = [];
441
- try {
442
- const res = await H.get(`${BASE}/posts`);
443
- const all = res?.data ?? (Array.isArray(res) ? res : []);
444
- const counts = new Map();
445
- for (const p of all) for (const t of (Array.isArray(p.tags) ? p.tags : [])) {
446
- const k = String(t).trim();
447
- if (k) counts.set(k, (counts.get(k) ?? 0) + 1);
448
- }
449
- tagChoices = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t]) => ({value: t, label: t}));
450
- } catch { /* free text still works */ }
506
+ const counts = new Map();
507
+ for (const p of allPosts) for (const t of (Array.isArray(p.tags) ? p.tags : [])) {
508
+ const k = String(t).trim();
509
+ if (k) counts.set(k, (counts.get(k) ?? 0) + 1);
510
+ }
511
+ const tagChoices = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t]) => ({value: t, label: t}));
451
512
  const postTags = Array.isArray(post?.tags) ? post.tags : String(post?.tags ?? '').split(',').map(t => t.trim()).filter(Boolean);
452
513
  const selectedTags = M.observable([]);
453
514
  const tags = tagsEl ? mountPills(tagsEl, {
@@ -458,11 +519,12 @@ export const postEditorView = {
458
519
  empty: 'New tag - press Enter to add it',
459
520
  selected: selectedTags
460
521
  }) : null;
461
- disposers.push(() => tags?.destroy());
522
+ add(() => tags?.destroy());
462
523
 
463
- const layoutCard = await mountLayoutCard($container.find('#layout-card').get(0), {
524
+ const layoutCard = mountLayoutCard($container.find('#layout-card').get(0), {
464
525
  current: post?.template ?? '',
465
- collect: () => ({...collectValues(), id})
526
+ collect: () => ({...collectValues(), id}),
527
+ catalogue, kit, add
466
528
  });
467
529
 
468
530
  function collectValues() {
@@ -483,44 +545,17 @@ export const postEditorView = {
483
545
  };
484
546
  }
485
547
 
486
- async function doSave(extra = {}) {
487
- const body = { ...collectValues(), ...extra };
488
- if (!body.title) {
489
- E.toast('Title is required.', { type: 'error' });
490
- postTitleEl?.focus();
491
- return null;
492
- }
493
- try {
494
- const res = isEdit
495
- ? await H.put(`${BASE}/posts/${id}`, body)
496
- : await H.post(`${BASE}/posts`, body);
497
- if (res.error) throw new Error(res.error);
498
- const saved = res.data ?? res;
499
- // A new post is an existing one from here on: later saves
500
- // update it, and the address says which post this is (no
501
- // hashchange, so the view is not re-mounted).
502
- if (!isEdit && saved?.id) {
503
- id = saved.id;
504
- isEdit = true;
505
- history.replaceState(history.state, '', `#/plugins/blog/posts/${encodeURIComponent(id)}`);
506
- if (titleEl) titleEl.textContent = 'Editing post';
507
- }
508
- post = saved;
509
- stamps.set(saved);
510
- baseline = JSON.stringify(collectValues());
511
- setStatus(saved.status ?? status);
512
- showSaved();
513
- return saved;
514
- } catch (err) {
515
- E.toast(err?.message ?? 'Failed to save post.', { type: 'error' });
516
- return null;
517
- }
518
- }
519
-
520
548
  // ---- Status, and whether anything is unsaved ----
521
549
  let status = post?.status ?? 'draft';
522
550
  let baseline = JSON.stringify(collectValues());
523
551
  let savedAt = null;
552
+ // The post as saved when editing began - a kept draft's age.
553
+ let was = post?.updatedAt ? `u:${post.updatedAt}` : '';
554
+ let discarded = false; // Back, "leave without saving": nothing to keep
555
+ // This editor's mark on the drafts it keeps: a new post's draft
556
+ // (post.new) is dropped or moved only while it is still this one's.
557
+ const owner = `${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
558
+ let saving = null; // the save in flight - a second Save waits for it
524
559
  const saveStateEl = $container.find('#save-state').get(0);
525
560
  const draftsBtn = $container.find('#btn-drafts').get(0);
526
561
  const isDirty = () => JSON.stringify(collectValues()) !== baseline;
@@ -534,7 +569,7 @@ export const postEditorView = {
534
569
  const dirty = isDirty();
535
570
  saveStateEl.classList.toggle('is-dirty', dirty);
536
571
  saveStateEl.textContent = dirty
537
- ? 'Unsaved changes'
572
+ ? (savedAt ? `Saved ${fmtTime(savedAt)} - changed since` : 'Unsaved changes')
538
573
  : savedAt ? `Saved ${fmtTime(savedAt)}` : '';
539
574
  }
540
575
  function setStatus(next) {
@@ -547,38 +582,127 @@ export const postEditorView = {
547
582
  }
548
583
  setStatus(status);
549
584
 
585
+ // ---- Drafts: kept when the editor is left with changes, offered back next time ----
586
+ function keepDraft() {
587
+ if (discarded || !isDirty()) return false;
588
+ if (!id) {
589
+ // Never over another editor's unsaved new post.
590
+ const other = kit.drafts.read(draftKey(null));
591
+ if (other && other.owner !== owner) return false;
592
+ }
593
+ kit.drafts.keep(draftKey(id), {
594
+ id, owner, name: postTitleEl?.value?.trim() || post?.title || '', was,
595
+ values: {...collectValues(), slugAuto: Boolean(slugAutoEl?.checked)}
596
+ });
597
+ return true;
598
+ }
599
+ add(() => {
600
+ if (discarded || !isDirty()) return;
601
+ const kept = 'Your unsaved changes to the post are kept - open it again to pick them up.';
602
+ if (saving && !isEdit) {
603
+ // A new post's first save is still running: its draft is kept under the
604
+ // post's real id once it exists (doSave), so a New post opened meanwhile
605
+ // is never offered these values - saving them would make it twice.
606
+ const run = saving;
607
+ run.catch(() => null).then(() => {
608
+ if (isEdit) return; // saved: doSave kept it under the id
609
+ if (!keepDraft()) E.toast('Your unsaved new post could not be kept - another unsaved new post is waiting.', {type: 'warning'});
610
+ });
611
+ E.toast(kept, {type: 'info'});
612
+ return;
613
+ }
614
+ if (keepDraft()) E.toast(kept, {type: 'info'});
615
+ else E.toast('Your unsaved new post could not be kept - another unsaved new post is waiting.', {type: 'warning'});
616
+ });
617
+
618
+ async function doSave(extra = {}) {
619
+ if (saving) await saving.catch(() => null);
620
+ const values = collectValues();
621
+ const body = {...values, ...extra};
622
+ if (!body.title) {
623
+ if (here()) {
624
+ E.toast('Title is required.', {type: 'error'});
625
+ postTitleEl?.focus();
626
+ }
627
+ return null;
628
+ }
629
+ // What is sent: anything typed while it saves stays unsaved (the page stays open).
630
+ const sent = JSON.stringify(values);
631
+ const wasNew = !isEdit;
632
+ const run = (async () => (isEdit
633
+ ? H.put(`${BASE}/posts/${encodeURIComponent(id)}`, body)
634
+ : H.post(`${BASE}/posts`, body)))();
635
+ saving = run;
636
+ try {
637
+ const res = await run;
638
+ if (res.error) throw new Error(res.error);
639
+ const saved = res.data ?? res;
640
+ kit.pokeSidebar();
641
+ // A new post is an existing one from here on: later saves
642
+ // update it, and the address says which post this is (no
643
+ // hashchange, so the view is not re-mounted).
644
+ if (wasNew && saved?.id) {
645
+ // Only this editor's draft of it: another New post may have kept its own since.
646
+ if (kit.drafts.read(draftKey(null))?.owner === owner) kit.drafts.drop(draftKey(null));
647
+ id = saved.id;
648
+ isEdit = true;
649
+ if (here()) history.replaceState(history.state, '', `#/plugins/blog/posts/${encodeURIComponent(id)}`);
650
+ if (titleEl) titleEl.textContent = 'Editing post';
651
+ }
652
+ post = saved;
653
+ if (saved?.updatedAt) was = `u:${saved.updatedAt}`;
654
+ baseline = sent;
655
+ if (!isDirty()) kit.drafts.drop(draftKey(id));
656
+ else if (!here()) keepDraft(); // left while it saved: later edits kept under its id, aged from this save
657
+ if (!here()) return saved;
658
+ stamps.set(saved);
659
+ setStatus(saved.status ?? status);
660
+ showSaved();
661
+ return saved;
662
+ } catch (err) {
663
+ if (here()) E.toast(err?.message ?? 'Failed to save post.', {type: 'error'});
664
+ return null;
665
+ } finally {
666
+ if (saving === run) saving = null;
667
+ }
668
+ }
669
+
550
670
  // Anything typed, ticked or picked may change what would be saved.
551
671
  let queued = false;
552
672
  const recheck = () => {
553
673
  if (queued) return;
554
674
  queued = true;
555
- queueMicrotask(() => { queued = false; refreshState(); });
675
+ queueMicrotask(() => { queued = false; if (here()) refreshState(); });
556
676
  };
557
- ['input', 'change', 'click'].forEach(ev => $container.get(0).addEventListener(ev, recheck));
558
- // A pill made with Enter fires none of those.
559
- const stopPillWatch = M.effect(() => { selectedCats.value; selectedTags.value; recheck(); });
560
- disposers.push(() => stopPillWatch?.());
677
+ ['input', 'change', 'click'].forEach(ev => root.addEventListener(ev, recheck));
678
+ // A pill made with Enter fires none of those; nor does picking a layout's binding.
679
+ add(M.effect(() => { selectedCats.value; selectedTags.value; layoutCard.layout.value; recheck(); }));
561
680
 
562
681
  async function save() {
563
- if (await doSave()) E.toast(status === 'published' ? 'Saved - the live post is updated.' : 'Saved.', { type: 'success' });
682
+ const saved = await doSave();
683
+ if (saved && here()) E.toast(status === 'published' ? 'Saved - the live post is updated.' : 'Saved.', {type: 'success'});
564
684
  }
565
685
 
566
686
  async function goBack() {
567
- if (isDirty() && !(await E.confirm('Leave without saving? Your changes to this post will be lost.'))) return;
568
- baseline = JSON.stringify(collectValues()); // do not ask twice (beforeunload)
687
+ if (isDirty()) {
688
+ const ok = await E.confirm('Leave without saving? Your changes to this post will be lost.',
689
+ {confirmText: 'Leave without saving', cancelText: 'Keep editing'});
690
+ if (!ok || !here()) return;
691
+ discarded = true; // asked and answered: no draft, no second question
692
+ kit.drafts.drop(draftKey(id));
693
+ }
569
694
  R.navigate('#/plugins/blog/posts');
570
695
  }
571
696
 
572
697
  $container.find('#btn-save').get(0)?.addEventListener('click', save);
573
698
  $container.find('#btn-back').get(0)?.addEventListener('click', goBack);
574
699
  draftsBtn?.addEventListener('click', async () => {
575
- const saved = await doSave({ status: 'draft' });
576
- if (saved) E.toast('Moved to drafts - it is no longer on the site.', { type: 'success' });
700
+ const saved = await doSave({status: 'draft'});
701
+ if (saved && here()) E.toast('Moved to drafts - it is no longer on the site.', {type: 'success'});
577
702
  });
578
703
 
579
- // Ctrl/Cmd+S saves; a closing tab with unsaved work asks first. Both
580
- // come off when the hash leaves this editor (the router has no unmount).
581
- // The admin's own Ctrl+S (app.js) clicks the view's primary Save and
704
+ // Ctrl/Cmd+S saves; a closing tab with unsaved work asks first (and keeps
705
+ // a draft). The admin's own Ctrl+S (app.js) clicks the view's primary Save and
582
706
  // marks the event handled; saving again here made two requests - and
583
707
  // on a new post, two posts. This one is for an admin without it.
584
708
  const onKey = (e) => {
@@ -589,18 +713,14 @@ export const postEditorView = {
589
713
  }
590
714
  };
591
715
  const onUnload = (e) => {
592
- if (isDirty()) { e.preventDefault(); e.returnValue = ''; }
716
+ if (isDirty() && !discarded) { keepDraft(); e.preventDefault(); e.returnValue = ''; }
593
717
  };
594
718
  document.addEventListener('keydown', onKey);
595
719
  window.addEventListener('beforeunload', onUnload);
596
- const onHash = () => {
597
- if (/^#\/plugins\/blog\/posts\/[^/]+$/.test(location.hash)) return;
720
+ add(() => {
598
721
  document.removeEventListener('keydown', onKey);
599
722
  window.removeEventListener('beforeunload', onUnload);
600
- window.removeEventListener('hashchange', onHash);
601
- disposers.splice(0).forEach(fn => { try { fn(); } catch { /* already gone */ } });
602
- };
603
- window.addEventListener('hashchange', onHash);
723
+ });
604
724
 
605
725
  if (postTitleEl) {
606
726
  postTitleEl.addEventListener('input', () => {
@@ -614,8 +734,8 @@ export const postEditorView = {
614
734
  slugAutoEl.addEventListener('change', () => {
615
735
  if (!slugAutoEl.checked && isEdit && post?.status === 'published') {
616
736
  if (slugWarnEl) slugWarnEl.classList.remove('blog-hidden');
617
- } else {
618
- if (slugWarnEl) slugWarnEl.classList.add('blog-hidden');
737
+ } else if (slugWarnEl) {
738
+ slugWarnEl.classList.add('blog-hidden');
619
739
  }
620
740
  if (slugAutoEl.checked && postTitleEl && postSlugEl) {
621
741
  postSlugEl.value = slugify(postTitleEl.value);
@@ -638,14 +758,17 @@ export const postEditorView = {
638
758
  if (!saved) return;
639
759
  const savedId = saved.id ?? id;
640
760
  try {
641
- const res = await H.post(`${BASE}/posts/${savedId}/publish`, {});
761
+ const res = await H.post(`${BASE}/posts/${encodeURIComponent(savedId)}/publish`, {});
642
762
  if (res.error) throw new Error(res.error);
643
763
  post = res.data ?? res;
764
+ if (post?.updatedAt) was = `u:${post.updatedAt}`;
765
+ kit.pokeSidebar();
766
+ if (!here()) return;
644
767
  stamps.set(post);
645
768
  setStatus('published');
646
- E.toast('Published - the post is live.', { type: 'success' });
769
+ E.toast('Published - the post is live.', {type: 'success'});
647
770
  } catch (err) {
648
- E.toast(err?.message ?? 'Failed to publish post.', { type: 'error' });
771
+ if (here()) E.toast(err?.message ?? 'Failed to publish post.', {type: 'error'});
649
772
  }
650
773
  });
651
774
  }
@@ -659,7 +782,7 @@ export const postEditorView = {
659
782
 
660
783
  const publishedAt = schedAtEl?.value;
661
784
  if (!publishedAt) {
662
- E.toast('Please select a scheduled date/time.', { type: 'error' });
785
+ E.toast('Please select a scheduled date/time.', {type: 'error'});
663
786
  if (scheduleEl) scheduleEl.classList.remove('blog-hidden');
664
787
  return;
665
788
  }
@@ -668,49 +791,69 @@ export const postEditorView = {
668
791
  if (!saved) return;
669
792
  const savedId = saved.id ?? id;
670
793
  try {
671
- const res = await H.post(`${BASE}/posts/${savedId}/schedule`, { publishedAt });
794
+ const res = await H.post(`${BASE}/posts/${encodeURIComponent(savedId)}/schedule`, {publishedAt});
672
795
  if (res.error) throw new Error(res.error);
673
796
  post = res.data ?? res;
797
+ if (post?.updatedAt) was = `u:${post.updatedAt}`;
798
+ kit.pokeSidebar();
799
+ if (!here()) return;
674
800
  stamps.set(post);
675
801
  setStatus('scheduled');
676
802
  if (scheduleEl) scheduleEl.classList.add('blog-hidden');
677
- E.toast(`Scheduled for ${fmtDateTime(publishedAt)}.`, { type: 'success' });
803
+ E.toast(`Scheduled for ${fmtDateTime(publishedAt)}.`, {type: 'success'});
678
804
  } catch (err) {
679
- E.toast(err?.message ?? 'Failed to schedule post.', { type: 'error' });
805
+ if (here()) E.toast(err?.message ?? 'Failed to schedule post.', {type: 'error'});
680
806
  }
681
807
  });
682
808
  }
683
809
 
684
- const pickImageBtn = $container.find('#btn-pick-image').get(0);
685
- if (pickImageBtn) {
686
- pickImageBtn.addEventListener('click', () => openMediaPicker());
687
- }
688
- if (clearImageBtn) {
689
- clearImageBtn.addEventListener('click', () => {
690
- featuredImageUrl = '';
691
- renderImagePreview();
692
- });
693
- }
810
+ $container.find('#btn-pick-image').get(0)?.addEventListener('click', () => openMediaPicker());
811
+ clearImageBtn?.addEventListener('click', () => {
812
+ featuredImageUrl = '';
813
+ renderImagePreview();
814
+ recheck();
815
+ });
694
816
 
817
+ /**
818
+ * The Featured Image picker: one at a time, a skeleton while the media
819
+ * library loads, closed (choosing nothing) when the editor is left.
820
+ */
695
821
  async function openMediaPicker() {
696
- let media = [];
822
+ const release = kit.claim('blog-media');
823
+ if (!release) return;
824
+ let so = null;
697
825
  try {
698
- const res = await H.get('/api/media');
699
- media = (res.data ?? res ?? []).filter((m) => /\.(png|jpe?g|gif|webp|svg)$/i.test(m.name || m.url || ''));
700
- } catch {
701
- E.toast('Could not load media library.', { type: 'error' });
702
- return;
703
- }
704
-
705
- const grid = document.createElement('div');
706
- grid.className = 'blog-media-grid';
707
-
708
- if (!media.length) {
709
- const p = document.createElement('p');
710
- p.className = 'text-muted p-3';
711
- p.textContent = 'No images uploaded yet. Use the Media admin to upload one first.';
712
- grid.appendChild(p);
713
- } else {
826
+ const grid = document.createElement('div');
827
+ grid.className = 'blog-media-grid';
828
+ const wait = document.createElement('div');
829
+ wait.className = 'blog-loading';
830
+ wait.textContent = 'Loading the media library…';
831
+ const box = document.createElement('div');
832
+ box.append(wait, grid);
833
+ so = E.slideover({title: 'Pick Featured Image', size: 'lg', position: 'right'});
834
+ so.element.appendChild(box);
835
+ let open = true;
836
+ const unhook = kit.closeOnLeave(so);
837
+ kit.onceClosed(so, () => { open = false; unhook(); release(); });
838
+ so.open();
839
+
840
+ let media = [];
841
+ try {
842
+ const res = await kit.withSkeleton(wait, H.get('/api/media'), 'tiles');
843
+ media = (res?.data ?? res ?? []).filter((m) => /\.(png|jpe?g|gif|webp|svg)$/i.test(m.name || m.url || ''));
844
+ } catch {
845
+ if (open) { E.toast('Could not load media library.', {type: 'error'}); so.close(); }
846
+ return;
847
+ }
848
+ if (!open || !here()) return;
849
+ wait.remove();
850
+ if (!media.length) {
851
+ const p = document.createElement('p');
852
+ p.className = 'text-muted p-3';
853
+ p.textContent = 'No images uploaded yet. Use the Media admin to upload one first.';
854
+ grid.appendChild(p);
855
+ return;
856
+ }
714
857
  media.forEach((m) => {
715
858
  const tile = document.createElement('button');
716
859
  tile.type = 'button';
@@ -718,43 +861,70 @@ export const postEditorView = {
718
861
  const img = document.createElement('img');
719
862
  img.src = m.url;
720
863
  img.alt = m.name;
721
-
722
864
  const cap = document.createElement('span');
723
865
  cap.textContent = m.name;
724
-
725
- tile.appendChild(img);
726
- tile.appendChild(cap);
866
+ tile.append(img, cap);
727
867
  tile.addEventListener('click', () => {
868
+ if (!here()) return;
728
869
  featuredImageUrl = m.url;
729
870
  renderImagePreview();
730
- slideover.close();
871
+ recheck();
872
+ so.close();
731
873
  });
732
874
  grid.appendChild(tile);
733
875
  });
876
+ } catch (err) {
877
+ if (!so) release();
878
+ throw err;
734
879
  }
735
-
736
- // close() only hides the panel and destroy() tears the component down
737
- // without detaching its element, so every trip to the media library
738
- // used to leave another slideover in the document.
739
- const slideover = E.slideover({
740
- title: 'Pick Featured Image',
741
- size: 'lg',
742
- position: 'right',
743
- content: grid,
744
- onClose: () => setTimeout(() => {
745
- slideover.destroy();
746
- slideover.element?.remove();
747
- }, 350)
748
- });
749
- slideover.open();
750
880
  }
751
881
 
752
- // Again at the end: the plugin chrome rebuilds the header after the
753
- // scan at the top, so its (?) would otherwise never appear. Markers
754
- // already converted are skipped.
755
- import('/admin/js/lib/help-popover.js')
756
- .then(m => m.helpPopovers?.($container, {position: 'bottom'}))
757
- .catch(() => null);
882
+ // Help (?) popovers last: the plugin chrome rebuilds the header after the
883
+ // view's template is in, and markers already converted are skipped.
884
+ // Overtaken while it loaded: register nothing - the lifetime is the new mount's now.
885
+ const offHelp = await kit.scanHelp(root);
886
+ if (!here()) { offHelp(); return; }
887
+ add(offHelp);
758
888
  Domma.icons.scan();
889
+
890
+ /** Put a kept draft's values back into the form. */
891
+ function restore(v) {
892
+ if (postTitleEl) postTitleEl.value = v.title ?? '';
893
+ if (postSlugEl) postSlugEl.value = v.slug ?? '';
894
+ if (slugAutoEl && 'slugAuto' in v) slugAutoEl.checked = Boolean(v.slugAuto);
895
+ if (excerptEl) excerptEl.value = v.excerpt ?? '';
896
+ if (contentEl) contentEl.value = v.content ?? '';
897
+ if (seoTitleEl) seoTitleEl.value = v.seo?.title ?? '';
898
+ if (seoDescEl) seoDescEl.value = v.seo?.description ?? '';
899
+ featuredImageUrl = v.featuredImage ?? '';
900
+ renderImagePreview();
901
+ layoutCard.set(v.template ?? '');
902
+ const vc = Array.isArray(v.categories) ? v.categories : [];
903
+ cats?.set(vc.filter(c => knownIds.has(c)));
904
+ unknownCats = vc.filter(c => !knownIds.has(c));
905
+ tags?.set(Array.isArray(v.tags) ? v.tags : []);
906
+ refreshState();
907
+ }
908
+
909
+ // A draft from last time, for this same post: "Throw away" drops it;
910
+ // Keep (and Escape) put it back. Removed only once answered - leaving
911
+ // while asked keeps it.
912
+ const draft = kit.drafts.read(draftKey(id));
913
+ if (draft) {
914
+ const handedOver = !isEdit && kit.takeHandOff('restore-new') === '1';
915
+ let throwAway = false;
916
+ if (!handedOver) {
917
+ const state = draftState({...draft, id}, isEdit ? post : null);
918
+ throwAway = await kit.askThrowAway(restoreQuestion({what: 'post', name: isEdit ? (post?.title || draft.name || '') : '', state, isNew: !isEdit}));
919
+ if (!here()) return;
920
+ }
921
+ kit.drafts.drop(draftKey(id));
922
+ if (!throwAway) {
923
+ restore(draft.values ?? {});
924
+ // A picked-up draft keeps its age: its values are still the older edits.
925
+ if (draft.was) was = draft.was;
926
+ }
927
+ }
928
+ if (here() && !isEdit) postTitleEl?.focus();
759
929
  }
760
930
  };