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
@@ -5,9 +5,19 @@
5
5
  * `options[event]` when that happens to be a function, so it silently does
6
6
  * nothing - every "on close" cleanup in the mail client written that way
7
7
  * (editor teardown, resize handles, address suggesters, settling a dialog's
8
- * promise when it was closed by its X) never ran. `options.onClose` is what
9
- * close() itself reads, at the moment it closes - so it also fires only when a
10
- * close really happens, after any "discard changes?" guard has let it through.
8
+ * promise when it was closed by its X) never ran.
9
+ *
10
+ * Two kinds of component, two hooks:
11
+ *
12
+ * - A slideover is a class whose close() reads `options.onClose` at the moment
13
+ * it closes - so chaining that fires only when a close really happens, after
14
+ * any "discard changes?" guard has let it through.
15
+ * - E.modal is a `<domma-modal>` web component. Its handle's `options` is the
16
+ * element's `_options` - usually undefined, so the old code returned without
17
+ * hooking anything - and close() never reads it: it dispatches a `close` DOM
18
+ * event instead - for the X, the backdrop, Escape and close() alike. Chaining
19
+ * `onClose` there did nothing at all, which is how a modal shut with Escape
20
+ * left its promise unsettled (Email Pro saw it). The event is what fires.
11
21
  *
12
22
  * @module _lib/admin/mail/when-closed
13
23
  *
@@ -17,6 +27,26 @@
17
27
  */
18
28
  export function whenClosed(component, fn) {
19
29
  const opts = component?.options;
30
+ const el = component?.element;
31
+ // The web component (`<domma-modal>` - a custom element's tag has a
32
+ // hyphen). Its handle's `options` is the element's `_options`, which is
33
+ // often not even set, so it cannot be what decides.
34
+ const custom = typeof el?.tagName === 'string' && el.tagName.includes('-');
35
+ if (el && typeof el.addEventListener === 'function' && (custom || !opts || el._options === opts)) {
36
+ // Once per close: the X sits in the modal's shadow root, so its click
37
+ // also reaches the host's backdrop handler and close() emits twice.
38
+ // Re-armed when the modal opens again.
39
+ let fired = false;
40
+ el.addEventListener('open', (event) => { if (event.target === el) fired = false; });
41
+ el.addEventListener('close', (event) => {
42
+ // `close` bubbles and is composed: a modal opened INSIDE this one
43
+ // closing must not count as this one closing.
44
+ if (event.target !== el || fired) return;
45
+ fired = true;
46
+ try { fn(component); } catch { /* one cleanup failing must not stop the next */ }
47
+ });
48
+ return;
49
+ }
20
50
  if (!opts) return;
21
51
  const previous = opts.onClose;
22
52
  opts.onClose = (instance) => {
@@ -151,7 +151,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
151
151
  }
152
152
 
153
153
  /**
154
- * Run an IMAP operation, turning transport failures into tidy 502s.
154
+ * Run an IMAP operation, turning transport failures into tidy 502s
155
+ * (resolving to undefined after replying).
155
156
  *
156
157
  * @param {object} reply
157
158
  * @param {() => Promise<any>} fn
@@ -163,7 +164,11 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
163
164
  } catch (err) {
164
165
  fastify.log.error({err}, `[${pluginName}] IMAP operation failed`);
165
166
  reply.code(502).send({error: describeError(err)});
166
- return null;
167
+ // undefined, NOT null: an async handler that resolves to anything
168
+ // but undefined has it sent as well, and Fastify logs "Reply was
169
+ // already sent" over the 502 just sent (every IMAP failure did it,
170
+ // /folders?counts=1 most visibly).
171
+ return undefined;
167
172
  }
168
173
  }
169
174
 
@@ -177,19 +182,40 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
177
182
  * STATUS (no select, so nothing can be marked read), and kept a minute
178
183
  * per user - the sidebar asks every minute from every open admin tab.
179
184
  */
185
+ // The badge's minute-long cache, per user. Forgotten whenever that user's
186
+ // mailboxes change or a read goes to the server anyway (a Refresh, the
187
+ // folder counts), so the screen's `dm:sidebar-count` after it gets the
188
+ // new number rather than the one from before.
189
+ //
190
+ // Each forget also bumps the user's generation: a badge request that was
191
+ // already on its way (asking IMAP) when the forget happened carries the
192
+ // old one, and must not write its now-stale count back into the cache.
193
+ const badgeCache = new Map();
194
+ const badgeGen = new Map();
195
+ const forgetBadge = (uid) => {
196
+ if (!uid) return;
197
+ badgeCache.delete(uid);
198
+ badgeGen.set(uid, (badgeGen.get(uid) ?? 0) + 1);
199
+ };
200
+ // A mailbox that failed (a changed password, a server down) is not asked
201
+ // again every minute: that is a failed IMAP login per minute per open tab,
202
+ // enough to trip fail2ban or a provider lockout. It backs off 5 min, doubling
203
+ // to an hour; saving the account (a new updatedAt) retries straight away, and
204
+ // so does the mailbox answering anywhere else (a Refresh, a folder listing) -
205
+ // or a working Refresh would leave the badge red for up to an hour.
206
+ const failures = new Map();
207
+ const failKey = (uid, account) => `${uid}:${account.id}:${account.updatedAt || account.createdAt || ''}`;
208
+ const clearFailures = (uid, accountId) => {
209
+ const prefix = `${uid}:${accountId}:`;
210
+ for (const key of failures.keys()) if (key.startsWith(prefix)) failures.delete(key);
211
+ };
180
212
  if (withUnreadBadge) {
181
- const badgeCache = new Map();
182
- // A mailbox that failed (a changed password, a server down) is not asked
183
- // again every minute: that is a failed IMAP login per minute per open tab,
184
- // enough to trip fail2ban or a provider lockout. It backs off 5 min, doubling
185
- // to an hour; saving the account (a new updatedAt) retries straight away.
186
- const failures = new Map();
187
- const failKey = (uid, account) => `${uid}:${account.id}:${account.updatedAt || account.createdAt || ''}`;
188
213
  fastify.get('/unread', {preHandler: [authenticate]}, async (request, reply) => {
189
214
  const uid = userId(request);
190
215
  if (!uid) return reply.code(401).send({error: 'Authentication required.'});
191
216
  const hit = badgeCache.get(uid);
192
217
  if (hit && Date.now() - hit.at < 60_000) return hit.value;
218
+ const gen = badgeGen.get(uid) ?? 0;
193
219
  const boxes = [];
194
220
  for (const account of await accounts.listAccounts(uid)) {
195
221
  const key = failKey(uid, account);
@@ -207,7 +233,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
207
233
  }
208
234
  }
209
235
  const value = unreadBadge(boxes);
210
- badgeCache.set(uid, {at: Date.now(), value});
236
+ // Only if nothing changed while IMAP was being asked.
237
+ if ((badgeGen.get(uid) ?? 0) === gen) badgeCache.set(uid, {at: Date.now(), value});
211
238
  return value;
212
239
  });
213
240
  }
@@ -235,6 +262,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
235
262
  if (!where.ok) return reply.code(400).send({error: where.error});
236
263
 
237
264
  const saved = await accounts.saveAccount(uid, null, result.value);
265
+ forgetBadge(uid);
238
266
  return {account: accounts.toPublic(saved)};
239
267
  });
240
268
 
@@ -255,6 +283,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
255
283
  const saved = await accounts.saveAccount(uid, request.params.id, result.value);
256
284
  // The pooled connection was opened with the old details.
257
285
  pool.release(`${uid}:${request.params.id}`);
286
+ forgetBadge(uid);
258
287
  return {account: accounts.toPublic(saved)};
259
288
  });
260
289
 
@@ -277,6 +306,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
277
306
  const existing = await accounts.getAccount(uid, request.params.id);
278
307
  const deleted = await accounts.deleteAccount(uid, request.params.id);
279
308
  if (!deleted) return reply.code(404).send({error: 'That mailbox does not exist.'});
309
+ forgetBadge(uid);
280
310
  // A signed-in mailbox hands its sign-in back where the provider allows
281
311
  // it (Google). Best-effort and after the delete: forgetting it here
282
312
  // must not wait on, or fail because of, the provider.
@@ -366,7 +396,10 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
366
396
  // folder listing pays.
367
397
  if (request.query.counts === '1') {
368
398
  await withUnreadCounts(ctx, folders);
399
+ forgetBadge(ctx.uid);
369
400
  }
401
+ // The mailbox answered: it is not failing any more.
402
+ clearFailures(ctx.uid, ctx.account.id);
370
403
 
371
404
  return {accountId: ctx.account.id, folders};
372
405
  });
@@ -429,6 +462,11 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
429
462
  // answered from the store - instant, and no connection needed. It
430
463
  // returns null for anything not mirrored, which falls through to the
431
464
  // wire exactly as the free edition always does.
465
+ if (request.query.fresh === '1') {
466
+ // A Refresh: ask the server again for the badge, back-off or not.
467
+ forgetBadge(ctx.uid);
468
+ clearFailures(ctx.uid, ctx.account.id);
469
+ }
432
470
  if (cachedList && request.query.fresh !== '1') {
433
471
  try {
434
472
  const cached = await cachedList({
@@ -27,7 +27,10 @@ export function unreadBadge(boxes = []) {
27
27
  label: boxes.length
28
28
  ? `${plural(count, 'unread message')} in the inbox${boxes.length > 1 ? 'es' : ''}${failed ? ` (${plural(failed, 'mailbox', 'mailboxes')} could not be checked)` : ''}`
29
29
  : 'No mailbox set up',
30
- tone: 'info',
30
+ // A mailbox that cannot be reached is the thing worth seeing: red, and
31
+ // shown even at 0 - otherwise a broken password reads as "all read".
32
+ tone: failed ? 'danger' : 'info',
33
+ ...(failed ? {showZero: true} : {}),
31
34
  details: {
32
35
  title: 'Mail',
33
36
  rows: boxes.map(b => ({
@@ -339,3 +339,17 @@ Declared in plugin.json `permissions` (granted once to `admin`), checked on EVER
339
339
  read. **Every non-GET route of this plugin forgets it in an `onSend` hook** - before the writer's response leaves -
340
340
  so the admin's own next read, and the public POST /comments, are fresh. The TTL only covers writes made outside
341
341
  the plugin (Collections tool, Blog Pro). Callers share the cached array: filter/sort copies, never mutate it.
342
+
343
+ ## 1.10.0 (2026-10-15 Tool Upgrade, second pass)
344
+
345
+ - **Drafts**: pure rules in `admin/lib/drafts.js` (tests/drafts.test.js); storage via `drafts.read/keep/drop` in
346
+ views/kit.js, one key per draft (`blog.draft.<key>`), never a map. A kept draft carries `was`; the restore question
347
+ puts "Throw away" on OK, so Cancel/Escape keep it, and a draft is removed only after an answer.
348
+ - **`post.new` is owned**: each editor mount stamps an `owner` token. A new post's first save drops `post.new` only
349
+ when it is this editor's; leaving during that save keeps the draft under the post's real id (back to `post.new`
350
+ if the save fails); `keepDraft()` never overwrites another editor's `post.new` (warning toast instead).
351
+ - `viewScope()` returns `add` with `add.here()` - check it after EVERY await, including right after `scanHelp`
352
+ (an overtaken mount disposes its popovers and returns before binding).
353
+ - `menuKeys(list, rowSel)`: Shift+F10 on a focused row opens its menu. `pokeSidebar()` after any change that moves
354
+ the badge. `withSkeleton(slot, promise, shape)` wraps core's loading.js (slot must be a block element).
355
+ - Settings: leaving always keeps the draft (the old `unloading` flag is gone).
@@ -204,3 +204,8 @@
204
204
  .blog-save-state.is-dirty { color: var(--dm-warning, #d97706); }
205
205
  .blog-save-state.is-dirty::before { content: '●'; margin-right: .3rem; font-size: .7em; vertical-align: .1em; }
206
206
  .blog-editor-bar .btn { display: inline-flex; align-items: center; gap: .35rem; }
207
+
208
+ /* The first-load skeleton's slot (admin/js/lib/loading.js draws into it). No
209
+ display rule here: one would beat the [hidden] the binding sets. */
210
+ .blog-loading { color: var(--dm-text-muted); font-size: var(--dm-text-sm); }
211
+ .blog-editor[hidden] { display: none !important; }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Kept drafts - what an editor left unsaved when its screen (or panel) was
3
+ * left, and how to tell, when it is offered back, whether the record moved on
4
+ * meanwhile.
5
+ *
6
+ * PURE: no DOM, no storage - the views keep each draft under its own key (one
7
+ * per editor, never a map of drafts) and tests/drafts.test.js covers this.
8
+ *
9
+ * A draft is `{id, name, values, was, at}`: `id` the record (null for a new
10
+ * one), `was` the record's version when editing BEGAN (its updatedAt, or a
11
+ * fingerprint of the fields that matter when it has none - a category, the
12
+ * settings), `at` when it was kept.
13
+ *
14
+ * @module blog/admin/lib/drafts
15
+ */
16
+
17
+ /** JSON with sorted keys, so {a, b} and {b, a} are one value. */
18
+ export function stableJson(value) {
19
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
20
+ if (value && typeof value === 'object') {
21
+ return `{${Object.keys(value).sort().filter(k => value[k] !== undefined)
22
+ .map(k => `${JSON.stringify(k)}:${stableJson(value[k])}`).join(',')}}`;
23
+ }
24
+ return JSON.stringify(value ?? null);
25
+ }
26
+
27
+ /** A short fingerprint of a value (FNV-1a over its stable JSON). */
28
+ export function fingerprint(value) {
29
+ const s = stableJson(value);
30
+ let h = 0x811c9dc5;
31
+ for (let i = 0; i < s.length; i++) {
32
+ h ^= s.charCodeAt(i);
33
+ h = Math.imul(h, 0x01000193) >>> 0;
34
+ }
35
+ return `f:${h.toString(36)}`;
36
+ }
37
+
38
+ /**
39
+ * A record's version: its updatedAt when it has one, else a fingerprint of
40
+ * `keys` (or of the whole record). '' for no record.
41
+ *
42
+ * @param {object|null} record
43
+ * @param {string[]} [keys] - the fields that matter
44
+ * @returns {string}
45
+ */
46
+ export function versionOf(record, keys = null) {
47
+ if (!record || typeof record !== 'object') return '';
48
+ if (record.updatedAt) return `u:${record.updatedAt}`;
49
+ const picked = keys ? Object.fromEntries(keys.map(k => [k, record[k] ?? null])) : record;
50
+ return fingerprint(picked);
51
+ }
52
+
53
+ /**
54
+ * How a kept draft stands against the record as it is now.
55
+ *
56
+ * @param {{id?: string|null, values?: object, was?: string, at?: string}|null} draft
57
+ * @param {object|null} record - null when it no longer exists
58
+ * @param {string[]} [keys] - as versionOf
59
+ * @returns {'none'|'same'|'changed'|'deleted'}
60
+ */
61
+ export function draftState(draft, record, keys = null) {
62
+ if (!draft || !draft.values) return 'none';
63
+ if (!draft.id) return 'same';
64
+ if (!record) return 'deleted';
65
+ if (draft.was) return draft.was === versionOf(record, keys) ? 'same' : 'changed';
66
+ // A draft kept before `was` existed: by the clock, when the record says when it was saved.
67
+ return draft.at && record.updatedAt && String(record.updatedAt) > String(draft.at) ? 'changed' : 'same';
68
+ }
69
+
70
+ /**
71
+ * The question asked when a kept draft is found, as plain text (the caller
72
+ * escapes it). Asked so that OK is the DESTRUCTIVE answer - "Throw away" -
73
+ * because Domma's E.confirm resolves false for Cancel, Escape and a backdrop
74
+ * click alike, and dismissing must never lose someone's work: Cancel keeps
75
+ * the changes (puts them back in the form).
76
+ *
77
+ * @param {object} o
78
+ * @param {string} o.what - 'post', 'category', 'settings'…
79
+ * @param {string} [o.name] - the record's name, when it has one
80
+ * @param {'same'|'changed'|'deleted'} o.state
81
+ * @param {boolean} [o.isNew] - the draft is of a record not yet saved
82
+ * @returns {string}
83
+ */
84
+ export function restoreQuestion({what, name = '', state, isNew = false}) {
85
+ const which = isNew ? `a new ${what}` : name ? `the ${what} "${name}"` : `the ${what}`;
86
+ const note = state === 'changed'
87
+ ? ` It has been saved since you left them - keeping yours puts your older changes over it once you save.`
88
+ : state === 'deleted'
89
+ ? ` It has been deleted since - keeping yours starts it again as a new ${what}.`
90
+ : '';
91
+ return `Throw away the changes you left unsaved to ${which}?${note}`;
92
+ }
93
+
94
+ /** The answers that go with restoreQuestion: OK throws away, Cancel (and Escape) keeps. */
95
+ export const RESTORE_ANSWERS = Object.freeze({confirmText: 'Throw away', cancelText: 'Keep them'});
@@ -30,7 +30,7 @@
30
30
  <span data-help="Search matches titles, excerpts and slugs. Right-click a post, or use its ⋮, to publish, unpublish, duplicate or delete it."></span>
31
31
  </div>
32
32
 
33
- <p class="blog-state" data-if="loading.value">Loading posts…</p>
33
+ <div class="blog-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading posts…</div>
34
34
  <p class="blog-state" data-if="isEmpty.value" data-bind-text="emptyText.value"></p>
35
35
 
36
36
  <div class="blog-list">
@@ -11,7 +11,7 @@
11
11
  </span>
12
12
  </div>
13
13
 
14
- <p class="blog-state" data-if="loading.value">Loading categories…</p>
14
+ <div class="blog-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading categories…</div>
15
15
  <p class="blog-state" data-if="isEmpty.value">No categories yet - New category adds one.</p>
16
16
 
17
17
  <div class="blog-list">
@@ -19,7 +19,7 @@
19
19
  </p>
20
20
  <p class="blog-note blog-note--quiet" data-if="!commentsOff.value" data-bind-text="moderationText.value"></p>
21
21
 
22
- <p class="blog-state" data-if="loading.value">Loading comments…</p>
22
+ <div class="blog-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading comments…</div>
23
23
  <p class="blog-state" data-if="isEmpty.value" data-bind-text="emptyText.value"></p>
24
24
 
25
25
  <div class="blog-list">
@@ -16,7 +16,11 @@
16
16
  </div>
17
17
  </div>
18
18
 
19
- <div class="flex gap-4 blog-editor">
19
+ <!-- The skeleton's slot while the post loads; the form stays hidden until it is in
20
+ (typed into before the post arrived, it was overwritten). -->
21
+ <div class="blog-loading dm-loading-slot">Loading the post…</div>
22
+
23
+ <div class="flex gap-4 blog-editor" hidden>
20
24
  <!-- Main content area -->
21
25
  <div class="blog-editor-main">
22
26
  <div class="card mb-3">
@@ -54,7 +54,9 @@ export const blogView = {
54
54
  const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
55
55
  const {BASE} = kit;
56
56
  const add = kit.viewScope('posts', /^#\/plugins\/blog(\/posts)?\/?$/);
57
+ const here = add.here;
57
58
  const root = $container.get(0);
59
+ const slot = root.querySelector('.blog-loading');
58
60
 
59
61
  // ---- State ----
60
62
  const posts = M.observable([]);
@@ -129,21 +131,26 @@ export const blogView = {
129
131
  // ---- Server ----
130
132
  const byId = (id) => posts.peek().find(p => p.id === id);
131
133
 
134
+ // The first load draws a skeleton; a reload keeps the rows on screen.
135
+ let loaded = false;
132
136
  async function load() {
133
- loading.value = true;
137
+ const work = Promise.all([
138
+ H.get(`${BASE}/posts`),
139
+ H.get(`${BASE}/categories`).catch(() => []),
140
+ kit.sitePath()
141
+ ]);
134
142
  try {
135
- const [postsRes, catsRes] = await Promise.all([
136
- H.get(`${BASE}/posts`),
137
- H.get(`${BASE}/categories`).catch(() => [])
138
- ]);
139
- site = await kit.sitePath();
143
+ const [postsRes, catsRes, base] = loaded ? await work : await kit.withSkeleton(slot, work);
144
+ if (!here()) return;
145
+ site = base;
140
146
  // GET /posts sends a BARE ARRAY; `.data` is only for a wrapped reply.
141
147
  posts.value = postsRes?.data ?? (Array.isArray(postsRes) ? postsRes : []);
142
148
  categories.value = catsRes?.data ?? (Array.isArray(catsRes) ? catsRes : []);
149
+ loaded = true;
143
150
  } catch {
144
- E.toast('Failed to load posts.', {type: 'error'});
151
+ if (here()) E.toast('Failed to load posts.', {type: 'error'});
145
152
  } finally {
146
- loading.value = false;
153
+ if (here()) loading.value = false;
147
154
  }
148
155
  }
149
156
 
@@ -155,19 +162,25 @@ export const blogView = {
155
162
 
156
163
  async function publish(id) {
157
164
  try {
158
- upsert(await H.post(`${BASE}/posts/${id}/publish`, {}));
165
+ const res = await H.post(`${BASE}/posts/${id}/publish`, {});
166
+ kit.pokeSidebar();
167
+ if (!here()) return;
168
+ upsert(res);
159
169
  E.toast('Post published.', {type: 'success'});
160
170
  } catch (err) {
161
- E.toast(err?.message ?? 'Failed to publish.', {type: 'error'});
171
+ if (here()) E.toast(err?.message ?? 'Failed to publish.', {type: 'error'});
162
172
  }
163
173
  }
164
174
 
165
175
  async function unpublish(id) {
166
176
  try {
167
- upsert(await H.put(`${BASE}/posts/${id}`, {status: 'draft'}));
177
+ const res = await H.put(`${BASE}/posts/${id}`, {status: 'draft'});
178
+ kit.pokeSidebar();
179
+ if (!here()) return;
180
+ upsert(res);
168
181
  E.toast('Post moved back to drafts.', {type: 'success'});
169
182
  } catch (err) {
170
- E.toast(err?.message ?? 'Failed to unpublish.', {type: 'error'});
183
+ if (here()) E.toast(err?.message ?? 'Failed to unpublish.', {type: 'error'});
171
184
  }
172
185
  }
173
186
 
@@ -181,10 +194,12 @@ export const blogView = {
181
194
  content: p.content ?? '', excerpt: p.excerpt ?? '', featuredImage: p.featuredImage ?? null,
182
195
  categories: p.categories ?? [], tags: p.tags ?? [], template: p.template ?? '', status: 'draft'
183
196
  });
197
+ kit.pokeSidebar();
198
+ if (!here()) return;
184
199
  upsert(copy);
185
200
  E.toast('Copied as a draft.', {type: 'success'});
186
201
  } catch (err) {
187
- E.toast(err?.message ?? 'Failed to duplicate.', {type: 'error'});
202
+ if (here()) E.toast(err?.message ?? 'Failed to duplicate.', {type: 'error'});
188
203
  }
189
204
  }
190
205
 
@@ -194,13 +209,15 @@ export const blogView = {
194
209
  const ok = await E.confirm(p?.status === 'published'
195
210
  ? 'Delete this published post? Its page goes offline and links to it will break.'
196
211
  : 'Delete this post? This cannot be undone.');
197
- if (!ok) return;
212
+ if (!ok || !here()) return;
198
213
  try {
199
214
  await H.delete(`${BASE}/posts/${id}`);
215
+ kit.pokeSidebar();
216
+ if (!here()) return;
200
217
  posts.value = posts.peek().filter(x => x.id !== id);
201
218
  E.toast('Post deleted.', {type: 'success'});
202
219
  } catch {
203
- E.toast('Failed to delete post.', {type: 'error'});
220
+ if (here()) E.toast('Failed to delete post.', {type: 'error'});
204
221
  }
205
222
  }
206
223
 
@@ -218,7 +235,10 @@ export const blogView = {
218
235
  const list = root.querySelector('.blog-list');
219
236
  root.querySelector('.blog-rows').innerHTML = ROW_TEMPLATE;
220
237
  root.querySelector('.blog-chips').innerHTML = CHIP_TEMPLATE;
221
- add(await kit.scanHelp(root));
238
+ // Overtaken while it loaded: register nothing - the lifetime is the new mount's now.
239
+ const offHelp = await kit.scanHelp(root);
240
+ if (!here()) { offHelp(); return; }
241
+ add(offHelp);
222
242
  const handle = M.applyBindings(data, root, {
223
243
  methods: {
224
244
  create: () => R.navigate('#/plugins/blog/posts/new'),
@@ -294,22 +314,26 @@ export const blogView = {
294
314
  try {
295
315
  const res = await H.post(`${BASE}/samples`, {});
296
316
  if (res?.error) throw new Error(res.error);
317
+ kit.pokeSidebar();
318
+ if (!here()) return;
297
319
  E.toast(res.added ? `Added ${res.added} sample posts. Remove them from ⋮ whenever you like.` : 'The sample posts are already here.', {type: 'success'});
298
320
  await load();
299
321
  } catch (err) {
300
- E.toast(err?.message ?? 'Could not add the sample posts.', {type: 'error'});
322
+ if (here()) E.toast(err?.message ?? 'Could not add the sample posts.', {type: 'error'});
301
323
  }
302
324
  }
303
325
 
304
326
  async function removeSamples() {
305
- if (!(await E.confirm('Remove the sample posts, and any sample category nothing else uses? Your own posts are not touched.'))) return;
327
+ if (!(await E.confirm('Remove the sample posts, and any sample category nothing else uses? Your own posts are not touched.')) || !here()) return;
306
328
  try {
307
329
  const res = await H.delete(`${BASE}/samples`);
308
330
  if (res?.error) throw new Error(res.error);
331
+ kit.pokeSidebar();
332
+ if (!here()) return;
309
333
  E.toast(`Removed ${res.posts} sample posts.`, {type: 'success'});
310
334
  await load();
311
335
  } catch (err) {
312
- E.toast(err?.message ?? 'Could not remove the sample posts.', {type: 'error'});
336
+ if (here()) E.toast(err?.message ?? 'Could not remove the sample posts.', {type: 'error'});
313
337
  }
314
338
  }
315
339
 
@@ -324,6 +348,7 @@ export const blogView = {
324
348
  const row = e.target.closest?.('.blog-row');
325
349
  if (row && e.key === 'Enter' && e.target === row) R.navigate(`#/plugins/blog/posts/${row.dataset.id}`);
326
350
  });
351
+ add(kit.menuKeys(list, '.blog-row'));
327
352
 
328
353
  if (typeof I !== 'undefined' && I.scan) I.scan(root);
329
354
  await load();