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
@@ -23,10 +23,24 @@ export const settingsView = {
23
23
  const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
24
24
  const add = kit.viewScope('settings', /^#\/plugins\/blog\/settings\b/);
25
25
 
26
+ const here = add.here;
26
27
  const host = $container.find('#settings-form').get(0);
27
28
  if (!host) return;
28
- const {el, dispose} = await kit.settingsPanel();
29
- host.appendChild(el);
30
- add(dispose);
29
+ const panel = await kit.settingsPanel();
30
+ if (!here()) { panel.dispose(); return; }
31
+ host.appendChild(panel.el);
32
+ // Left with unsaved changes: kept as a draft, offered back next time
33
+ // (here or in the cog's slideover - it is one form).
34
+ // Keeping it twice (a reload the person then cancelled, then leaving) is harmless.
35
+ const onUnload = (e) => { if (panel.isDirty()) { kit.keepSettingsDraft(panel); e.preventDefault(); e.returnValue = ''; } };
36
+ window.addEventListener('beforeunload', onUnload);
37
+ add(() => {
38
+ window.removeEventListener('beforeunload', onUnload);
39
+ if (kit.keepSettingsDraft(panel)) {
40
+ E.toast('Your unsaved settings are kept - open Blog settings again to pick them up.', {type: 'info'});
41
+ }
42
+ });
43
+ add(panel.dispose);
44
+ await kit.offerSettingsDraft(panel, here);
31
45
  }
32
46
  };
@@ -393,7 +393,7 @@ export default async function blogPlugin(fastify, options) {
393
393
  });
394
394
  const html = await renderBlogPage(
395
395
  fileURLToPath(new URL('./templates/post.html', import.meta.url)),
396
- { post: page.html, assetV: '1.9.7' },
396
+ { post: page.html, assetV: '1.10.0' },
397
397
  { title: post.title || 'Preview', description: post.excerpt || '', ogImage: safeImageUrl(post.featuredImage),
398
398
  usedComponents: page.usedComponents, urlPath: `${basePath}/${post.slug}`, noindex: true }
399
399
  );
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "blog",
3
3
  "displayName": "Blog",
4
- "version": "1.9.7",
4
+ "version": "1.10.0",
5
5
  "tier": "free",
6
6
  "description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-10-13",
8
+ "date": "2026-10-15",
9
9
  "icon": "file-text",
10
10
  "admin": {
11
11
  "css": [
@@ -79,23 +79,23 @@
79
79
  ],
80
80
  "views": {
81
81
  "plugin-blog-posts": {
82
- "entry": "blog/admin/views/blog.js?v=10",
82
+ "entry": "blog/admin/views/blog.js?v=11",
83
83
  "exportName": "blogView"
84
84
  },
85
85
  "plugin-blog-post-editor": {
86
- "entry": "blog/admin/views/post-editor.js?v=10",
86
+ "entry": "blog/admin/views/post-editor.js?v=11",
87
87
  "exportName": "postEditorView"
88
88
  },
89
89
  "plugin-blog-categories": {
90
- "entry": "blog/admin/views/categories.js?v=9",
90
+ "entry": "blog/admin/views/categories.js?v=10",
91
91
  "exportName": "categoriesView"
92
92
  },
93
93
  "plugin-blog-comments": {
94
- "entry": "blog/admin/views/comments.js?v=10",
94
+ "entry": "blog/admin/views/comments.js?v=11",
95
95
  "exportName": "commentsView"
96
96
  },
97
97
  "plugin-blog-settings": {
98
- "entry": "blog/admin/views/settings.js?v=9",
98
+ "entry": "blog/admin/views/settings.js?v=10",
99
99
  "exportName": "settingsView"
100
100
  }
101
101
  }
@@ -24,7 +24,7 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
24
24
  const TEMPLATES_DIR = path.join(__dirname, 'templates');
25
25
 
26
26
  /** Bumped with the plugin, so a release is never served a stale script. */
27
- const ASSET_V = '1.9.7';
27
+ const ASSET_V = '1.10.0';
28
28
 
29
29
  /**
30
30
  * Above this many posts a listing pages on the server instead: the browser
@@ -0,0 +1,50 @@
1
+ /** Blog - kept drafts: versions, how a draft stands, the restore question (admin/lib/drafts.js). */
2
+ import {test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {draftState, fingerprint, restoreQuestion, RESTORE_ANSWERS, stableJson, versionOf} from '../admin/lib/drafts.js';
5
+
6
+ test('stable JSON ignores key order; a fingerprint follows the value', () => {
7
+ assert.equal(stableJson({b: 1, a: [1, {d: 2, c: 3}]}), stableJson({a: [1, {c: 3, d: 2}], b: 1}));
8
+ assert.equal(fingerprint({a: 1, b: 2}), fingerprint({b: 2, a: 1}));
9
+ assert.notEqual(fingerprint({a: 1}), fingerprint({a: 2}));
10
+ });
11
+
12
+ test('a version is the updatedAt, else a fingerprint of the fields that matter', () => {
13
+ assert.equal(versionOf({updatedAt: '2026-10-15T10:00:00Z', title: 'x'}), 'u:2026-10-15T10:00:00Z');
14
+ const cat = {name: 'News', slug: 'news', description: '', posts: 3};
15
+ const keys = ['name', 'slug', 'description'];
16
+ assert.equal(versionOf(cat, keys), versionOf({...cat, posts: 9}, keys));
17
+ assert.notEqual(versionOf(cat, keys), versionOf({...cat, name: 'Updates'}, keys));
18
+ assert.equal(versionOf(null), '');
19
+ });
20
+
21
+ test('a draft stands same, changed or deleted against the record now', () => {
22
+ const rec = {updatedAt: '2026-10-15T10:00:00Z'};
23
+ assert.equal(draftState(null, rec), 'none');
24
+ assert.equal(draftState({id: 'p1'}, rec), 'none');
25
+ assert.equal(draftState({id: null, values: {}}, null), 'same');
26
+ assert.equal(draftState({id: 'p1', values: {}, was: 'u:2026-10-15T10:00:00Z'}, rec), 'same');
27
+ assert.equal(draftState({id: 'p1', values: {}, was: 'u:2026-10-14T10:00:00Z'}, rec), 'changed');
28
+ assert.equal(draftState({id: 'p1', values: {}, was: 'u:x'}, null), 'deleted');
29
+ const c = {name: 'A'};
30
+ assert.equal(draftState({id: 'c', values: {}, was: versionOf(c, ['name'])}, {name: 'A', n: 4}, ['name']), 'same');
31
+ assert.equal(draftState({id: 'c', values: {}, was: versionOf(c, ['name'])}, {name: 'B'}, ['name']), 'changed');
32
+ });
33
+
34
+ test('a draft kept before `was` falls back to its time', () => {
35
+ const rec = {updatedAt: '2026-10-15T10:00:00Z'};
36
+ assert.equal(draftState({id: 'p', values: {}, at: '2026-10-14T00:00:00Z'}, rec), 'changed');
37
+ assert.equal(draftState({id: 'p', values: {}, at: '2026-10-16T00:00:00Z'}, rec), 'same');
38
+ assert.equal(draftState({id: 'p', values: {}}, rec), 'same');
39
+ });
40
+
41
+ test('the restore question makes OK the destructive answer and says what happened since', () => {
42
+ assert.equal(restoreQuestion({what: 'post', isNew: true, state: 'same'}),
43
+ 'Throw away the changes you left unsaved to a new post?');
44
+ assert.match(restoreQuestion({what: 'post', name: 'Hello', state: 'changed'}),
45
+ /^Throw away the changes you left unsaved to the post "Hello"\? It has been saved since/);
46
+ assert.match(restoreQuestion({what: 'category', name: 'News', state: 'deleted'}),
47
+ /deleted since - keeping yours starts it again as a new category\.$/);
48
+ assert.equal(restoreQuestion({what: 'settings', state: 'same'}), 'Throw away the changes you left unsaved to the settings?');
49
+ assert.deepEqual({...RESTORE_ANSWERS}, {confirmText: 'Throw away', cancelText: 'Keep them'});
50
+ });
@@ -180,3 +180,16 @@ only to roles that hold it.
180
180
  turn them back into plain data for the dirty snapshot and the save. A "from" after the "to" still pulls "to" along.
181
181
  - Icons in rows added after binding are drawn by `I.scan` after a flush (`rescan`), because new rows arrive after
182
182
  the panel's first scan.
183
+
184
+ ## 1.7.0 (2026-10-15 Tool Upgrade, second pass)
185
+
186
+ - Slideovers: `openPanel({ctx, release, draft})` - the panel goes with the screen (`ctx.hold`), `claim(key)` stops it
187
+ opening twice (released if building the panel throws - it is built inside try/catch), and `methods.save` is locked
188
+ while it runs. `draft: {key, label, kind, values, apply(values, panel), was, prefilled, initial}` keeps typed work
189
+ per user (`booking.drafts.<user>`, 14 days, rules in `admin/lib/drafts.js`, tests/admin-drafts.test.js); `was` is
190
+ `updatedAt`, or `fingerprint()` for settings; a draft taken "as new" from a deleted record starts with `was: ''` and
191
+ waits while another new-record draft exists. `ask()` is an E.confirm that answers 'no' when the screen goes.
192
+ - Restore questions put Discard on the CONFIRM button: Escape, the backdrop or leaving keep the draft (Domma's confirm
193
+ cannot tell Escape from Cancel). Booking Pro's sections are not drafted - the section contract cannot put values back.
194
+ - First loads use core skeletons through `kit.whileLoading` (`.bk-loading-slot`) on the host, Bookings and the Diary.
195
+ - No ContextMenu-key handler in wireRows: Windows Chrome opens its own menu on that key too. Shift+F10 only.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Booking admin - drafts, the pure part (no DOM, no storage; tested in
3
+ * tests/admin-drafts.test.js). kit.js `openPanel({draft})` does the rest.
4
+ *
5
+ * A draft is what a slideover form held when it was left (the screen changed,
6
+ * the tab closed): `{values, at, was, label}`. `was` is the record as the form
7
+ * began - its `updatedAt`, or a `fingerprint()` of it when it has none (the
8
+ * settings) - so the next opening can say the record changed since. All of a
9
+ * person's drafts live in one stored map, keyed `service:<id|new>`,
10
+ * `bookable:<id|new>`, `new-booking`, `note:<booking id>`, `settings`.
11
+ *
12
+ * @module booking/admin/lib/drafts
13
+ */
14
+
15
+ /** A draft older than this is not offered back. */
16
+ export const DRAFT_MAX_DAYS = 14;
17
+
18
+ /** JSON with object keys sorted, so equal data is equal text. */
19
+ export function stableStringify(value) {
20
+ if (Array.isArray(value)) return `[${value.map(v => stableStringify(v === undefined ? null : v)).join(',')}]`;
21
+ if (value && typeof value === 'object') {
22
+ return `{${Object.keys(value).sort().filter(k => value[k] !== undefined)
23
+ .map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
24
+ }
25
+ return JSON.stringify(value ?? null);
26
+ }
27
+
28
+ /** A short, stable fingerprint of some data (FNV-1a over its stable JSON). */
29
+ export function fingerprint(value) {
30
+ const s = stableStringify(value);
31
+ let h = 0x811c9dc5;
32
+ for (let i = 0; i < s.length; i++) {
33
+ h ^= s.charCodeAt(i);
34
+ h = Math.imul(h, 0x01000193) >>> 0;
35
+ }
36
+ return `f${h.toString(36)}${s.length.toString(36)}`;
37
+ }
38
+
39
+ /**
40
+ * A stored draft, if it is one a form can use: `values` a plain object, not
41
+ * older than `maxAgeDays`. Anything else (an older build's, a hand-edited
42
+ * store) is not trusted.
43
+ *
44
+ * @returns {{values: object, at: string, was: string, label: string}|null}
45
+ */
46
+ export function usableDraft(raw, {now = Date.now(), maxAgeDays = DRAFT_MAX_DAYS} = {}) {
47
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
48
+ const v = raw.values;
49
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
50
+ const at = Date.parse(raw.at);
51
+ if (!Number.isFinite(at) || now - at > maxAgeDays * 86_400_000) return null;
52
+ return {values: v, at: String(raw.at), was: typeof raw.was === 'string' ? raw.was : '', label: typeof raw.label === 'string' ? raw.label : ''};
53
+ }
54
+
55
+ /**
56
+ * What to do with a stored draft when a form opens.
57
+ *
58
+ * 'none' no draft, or it holds just what the form opened with
59
+ * 'auto' put it back and say so - the record is as it was
60
+ * 'changed' ask: the record was saved by someone (or somewhere) since
61
+ * 'prefilled' ask: the form was opened for something specific (a contact,
62
+ * a time clicked in the Diary), which the draft would replace
63
+ *
64
+ * @param {{draft: object|null, was?: string, opening?: object, prefilled?: boolean}} o
65
+ * `was` - the record as the form opens now ('' for a new one); `opening` -
66
+ * the values the form opened with.
67
+ */
68
+ export function restorePlan({draft, was = '', opening, prefilled = false}) {
69
+ if (!draft) return 'none';
70
+ if (opening !== undefined && stableStringify(draft.values) === stableStringify(opening)) return 'none';
71
+ if ((draft.was || '') !== (was || '')) return 'changed';
72
+ return prefilled ? 'prefilled' : 'auto';
73
+ }
74
+
75
+ /** "3 min ago", "yesterday", "2 days ago". */
76
+ export function agoText(at, now = Date.now()) {
77
+ const t = Date.parse(at);
78
+ if (!Number.isFinite(t)) return 'earlier';
79
+ const s = Math.max(0, (now - t) / 1000);
80
+ if (s < 60) return 'just now';
81
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
82
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
83
+ const d = Math.round(s / 86400);
84
+ return d === 1 ? 'yesterday' : `${d} days ago`;
85
+ }
86
+
87
+ /**
88
+ * The question (plain text - the caller escapes it) for a plan that asks, or
89
+ * the toast for 'auto'.
90
+ *
91
+ * @param {'auto'|'changed'|'prefilled'|'deleted'} plan
92
+ * @param {{label?: string, at?: string, now?: number, kind?: string}} o - `kind` names the form for 'prefilled' ("a new booking")
93
+ */
94
+ export function restoreText(plan, {label = '', at = '', now = Date.now(), kind = 'something'} = {}) {
95
+ const to = label ? ` to “${label}”` : '';
96
+ const ago = agoText(at, now);
97
+ if (plan === 'auto') return `Unsaved changes from ${ago} put back${label ? ` (${label})` : ''}.`;
98
+ // The questions put DISCARD on the confirm button: Escape (and leaving) is Cancel, which keeps the work.
99
+ if (plan === 'changed') return `You left unsaved changes${to} ${ago}, and it has been saved since - by someone else, or in another tab. Discard your changes, or keep them over it (check them before saving)?`;
100
+ if (plan === 'deleted') return `You left unsaved changes${to} ${ago}, but it has been deleted since. Discard them, or start a new one from them?`;
101
+ if (plan === 'prefilled') return `You started ${kind}${label ? ` (${label})` : ''} ${ago} and did not finish it. Discard it and start this one, or carry on with it?`;
102
+ return '';
103
+ }
104
+
105
+ /** The map with `key` set to `draft` (a new object - stored maps are not changed in place). */
106
+ export function draftsWith(map, key, draft) {
107
+ return {...(map && typeof map === 'object' && !Array.isArray(map) ? map : {}), [key]: draft};
108
+ }
109
+
110
+ /** The map without `key`. */
111
+ export function draftsWithout(map, key) {
112
+ const out = {...(map && typeof map === 'object' && !Array.isArray(map) ? map : {})};
113
+ delete out[key];
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * Drafts under `prefix` (e.g. 'service:') whose record is gone - not 'new' and
119
+ * not one of `liveIds`. Unusable drafts are skipped.
120
+ *
121
+ * @returns {{key: string, id: string, draft: object}[]}
122
+ */
123
+ export function orphanDrafts(map, prefix, liveIds, opts) {
124
+ const live = new Set(liveIds);
125
+ const out = [];
126
+ for (const [key, raw] of Object.entries(map && typeof map === 'object' ? map : {})) {
127
+ if (!key.startsWith(prefix)) continue;
128
+ const id = key.slice(prefix.length);
129
+ if (!id || id === 'new' || live.has(id)) continue;
130
+ const draft = usableDraft(raw, opts);
131
+ if (draft) out.push({key, id, draft});
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /** Stored drafts past their age, to drop. */
137
+ export function staleDraftKeys(map, opts) {
138
+ return Object.entries(map && typeof map === 'object' ? map : {}).filter(([, raw]) => !usableDraft(raw, opts)).map(([k]) => k);
139
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import {fmtDate, fmtDateTime} from '/plugins/_lib/admin/ui/dates.js';
11
+ import {draftsWith, draftsWithout, restorePlan, restoreText, staleDraftKeys, usableDraft} from './drafts.js';
11
12
 
12
13
  export {fmtDate, fmtDateTime};
13
14
 
@@ -17,14 +18,15 @@ export const V = '1.4.0';
17
18
 
18
19
  let kit = null;
19
20
 
20
- /** tool-kit, plugin-chrome and help-popover, once, with stand-ins for an older admin. */
21
+ /** tool-kit, plugin-chrome, help-popover and loading, once, with stand-ins for an older admin. */
21
22
  export function loadKit() {
22
23
  if (!kit) {
23
24
  kit = Promise.all([
24
25
  import('/admin/js/lib/tool-kit.js').catch(() => ({})),
25
26
  import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
26
- import('/admin/js/lib/help-popover.js').catch(() => ({}))
27
- ]).then(([tk, chrome, help]) => ({
27
+ import('/admin/js/lib/help-popover.js').catch(() => ({})),
28
+ import('/admin/js/lib/loading.js').catch(() => ({}))
29
+ ]).then(([tk, chrome, help, loading]) => ({
28
30
  onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
29
31
  openMenuAt: tk.openMenuAt || ((el) => {
30
32
  const r = el.getBoundingClientRect();
@@ -32,7 +34,11 @@ export function loadKit() {
32
34
  }),
33
35
  viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
34
36
  addBannerAction: chrome.addBannerAction || (() => null),
35
- helpPopovers: help.helpPopovers || (() => () => {})
37
+ helpPopovers: help.helpPopovers || (() => () => {}),
38
+ // A skeleton in a loading slot while the first load runs (core 1.x); without it the slot's own
39
+ // "Loading…" text shows, as before.
40
+ whileLoading: loading.whileLoading || ((slot, p) => (typeof p === 'function' ? p() : p)),
41
+ SHAPES: loading.SHAPES || {}
36
42
  }));
37
43
  }
38
44
  return kit;
@@ -151,42 +157,255 @@ export function when(iso) {
151
157
 
152
158
  // ---- Slideovers ---------------------------------------------------------------------
153
159
 
160
+ /** Panels open (or opening) now, by key - so a double click opens one, not two. */
161
+ const claimed = new Set();
162
+
163
+ /**
164
+ * Take `key` for a panel about to open. Null when it is already open or on its
165
+ * way (the caller does nothing); else the release, which openPanel's `release`
166
+ * option calls when the panel closes - call it yourself if the panel never opens.
167
+ *
168
+ * @returns {(() => void)|null}
169
+ */
170
+ export function claim(key) {
171
+ if (claimed.has(key)) return null;
172
+ claimed.add(key);
173
+ let done = false;
174
+ return () => { if (!done) { done = true; claimed.delete(key); } };
175
+ }
176
+
177
+ /**
178
+ * E.confirm that goes when the screen does: leaving while it is open answers
179
+ * it "no" (its Cancel is pressed) instead of leaving it over another screen.
180
+ * Callers check the screen (or panel) is still there before acting on a "no".
181
+ */
182
+ export function ask(message, options = {}, ctx = null) {
183
+ const answer = Promise.resolve(E.confirm(message, options));
184
+ const overlay = [...document.querySelectorAll('.dm-dialog-overlay')].at(-1);
185
+ const unhold = ctx?.hold?.(() => { try { overlay?.querySelector('.dm-dialog-cancel')?.click(); } catch { /* gone */ } });
186
+ answer.finally(() => unhold?.()).catch(() => {});
187
+ return answer;
188
+ }
189
+
190
+ // ---- Drafts (the stored side; the decisions are lib/drafts.js) -----------------------
191
+
192
+ /** One stored map per signed-in person: a shared browser never offers one person's work to another. */
193
+ function draftsKey() {
194
+ let user = '';
195
+ try { user = S.get('auth_user')?.id || ''; } catch { /* storage blocked */ }
196
+ return `booking.drafts.${user || 'anyone'}`;
197
+ }
198
+ function readDrafts() {
199
+ const map = store.get(draftsKey());
200
+ return map && typeof map === 'object' && !Array.isArray(map) ? map : {};
201
+ }
202
+ export function getDraft(key) { return usableDraft(readDrafts()[key]); }
203
+ export function allDrafts() { return readDrafts(); }
204
+ export function putDraft(key, draft) {
205
+ let map = readDrafts();
206
+ for (const k of staleDraftKeys(map)) map = draftsWithout(map, k);
207
+ store.set(draftsKey(), draftsWith(map, key, draft));
208
+ }
209
+ export function dropDraft(key) {
210
+ const map = readDrafts();
211
+ if (key in map) store.set(draftsKey(), draftsWithout(map, key));
212
+ }
213
+
154
214
  /**
155
215
  * Open a slideover holding `html`, bound to `model`. Closing with something
156
216
  * changed asks first (once). Everything is disposed and removed on close.
157
217
  *
158
- * @returns {Promise<{so, panel, close: (force?: boolean) => void}>}
218
+ * - `ctx` (the screen's): the panel goes when the screen does (`ctx.hold`),
219
+ * keeping its draft, and it does not open at all if the screen went while
220
+ * it was getting ready (`ctx.here`).
221
+ * - `release` (from claim()) is called when it closes.
222
+ * - `methods.save` is wrapped: the form is locked (inert) while it runs, so
223
+ * nothing typed during a save that then closes the panel is lost; it is
224
+ * unlocked - focus back where it was - when the panel stays open.
225
+ * - `draft: {key, label, kind, values(), apply(values, panel), was, prefilled, initial}`
226
+ * keeps what was typed (on every change, and when the tab closes) while the
227
+ * panel is dirty, and offers it back the next time this form opens - put
228
+ * back with a toast when the record is as it was, asked about when it was
229
+ * saved since (`was` differs) or the form was opened for something specific
230
+ * (`prefilled`). A draft is removed after a save, a discard, a "no" to the
231
+ * question - never while the question is open, and never written again
232
+ * once the panel has closed. A restored draft keeps its own `was`.
233
+ * `label` (text, or a function) names it in the question; `kind` ("a new booking") is for 'prefilled'.
234
+ * `initial`: a draft to put back straight away (one of a deleted record,
235
+ * started again as new - its old key is the caller's to drop).
236
+ *
237
+ * @returns {Promise<{so, panel, close: (force?: boolean) => void}|null>} null when the screen went first
159
238
  */
160
- export async function openPanel({title, size = 'md', html, model = {}, methods = {}, dirty, cleanup = () => {}, before, after}) {
239
+ export async function openPanel({title, size = 'md', html, model = {}, methods = {}, dirty, cleanup = () => {}, before, after,
240
+ ctx = null, release = () => {}, draft = null}) {
161
241
  const {onceClosed, helpPopovers} = await loadKit();
162
- const so = E.slideover({title: esc(title), size, position: 'right'});
163
- const panel = fragment(html);
164
- so.element.appendChild(panel);
165
- before?.(panel);
166
- const disposeHelp = helpPopovers(panel);
167
- const handle = M.applyBindings(model, panel, {methods});
168
- after?.(panel);
169
- onceClosed(so, () => { cleanup(); handle.dispose(); disposeHelp(); });
170
- const close = so.close.bind(so);
242
+ if (ctx?.here && !ctx.here()) { release(); return null; }
243
+
244
+ let so = null;
245
+ let panel = null;
246
+ let handle = null;
247
+ let disposeHelp = null;
248
+ let up = false; // onceClosed is wired: closing tidies (and releases) from here on
171
249
  let closed = false;
250
+ let saving = false;
251
+ // Drafts are written only once any stored one has been dealt with (restored, or answered).
252
+ let drafting = false;
253
+ let draftWas = draft?.was || '';
254
+ const keepDraft = () => {
255
+ if (!draft || !drafting || closed) return;
256
+ if (dirty?.()) {
257
+ const label = typeof draft.label === 'function' ? draft.label() : draft.label;
258
+ putDraft(draft.key, {values: draft.values(), at: new Date().toISOString(), was: draftWas, label: label || ''});
259
+ } else dropDraft(draft.key);
260
+ };
261
+ const queueKeep = () => queueMicrotask(keepDraft); // data-model handles the same event first
262
+ const onUnload = () => keepDraft();
263
+ let unhold = null;
264
+
265
+ const wrapped = {...methods};
266
+ if (methods.save) {
267
+ wrapped.save = async (...args) => {
268
+ if (saving || closed) return undefined;
269
+ saving = true;
270
+ const focus = document.activeElement;
271
+ panel.inert = true;
272
+ try {
273
+ return await methods.save(...args);
274
+ } finally {
275
+ saving = false;
276
+ if (!closed) {
277
+ panel.inert = false;
278
+ if (focus && panel.contains(focus)) { try { focus.focus(); } catch { /* gone */ } }
279
+ queueKeep(); // saved and still open (the staff note): the draft goes with the changes
280
+ }
281
+ }
282
+ };
283
+ }
284
+
285
+ // Anything that throws while the panel is built must not keep the claim, or leave a half-built
286
+ // slideover: it is undone and released, and the error goes on to the caller.
287
+ try {
288
+ so = E.slideover({title: esc(title), size, position: 'right'});
289
+ panel = fragment(html);
290
+ so.element.appendChild(panel);
291
+ before?.(panel);
292
+ disposeHelp = helpPopovers(panel);
293
+ handle = M.applyBindings(model, panel, {methods: wrapped});
294
+ after?.(panel);
295
+ onceClosed(so, () => {
296
+ cleanup(); handle.dispose(); disposeHelp(); release();
297
+ window.removeEventListener('beforeunload', onUnload);
298
+ unhold?.();
299
+ });
300
+ up = true;
301
+ } catch (err) {
302
+ closed = true;
303
+ try { handle?.dispose(); } catch { /* half built */ }
304
+ try { disposeHelp?.(); } catch { /* half built */ }
305
+ try { cleanup(); } catch { /* half built */ }
306
+ try { so?.destroy?.(); } catch { /* gone */ }
307
+ try { so?.element?.remove(); } catch { /* gone */ }
308
+ release();
309
+ throw err;
310
+ }
311
+
312
+ const close = so.close.bind(so);
172
313
  let asking = false;
173
314
  so.close = async (force) => {
174
- if (!closed && force !== true && dirty?.()) {
315
+ if (closed) return close();
316
+ if (force !== true && saving) return undefined;
317
+ if (force !== true && dirty?.()) {
175
318
  if (asking) return undefined;
176
319
  asking = true;
177
- const ok = await E.confirm('Discard your changes?');
320
+ const ok = await ask('Discard your changes?', {}, ctx);
178
321
  asking = false;
179
- if (!ok) return undefined;
322
+ if (!ok || closed) return undefined;
180
323
  }
324
+ if (draft && drafting) dropDraft(draft.key);
181
325
  closed = true;
182
326
  return close();
183
327
  };
184
- panel.addEventListener('keydown', (e) => {
185
- if ((e.ctrlKey || e.metaKey) && (e.key === 'Enter' || e.key === 's') && methods.save) { e.preventDefault(); e.stopPropagation(); methods.save(); }
186
- });
187
- I.scan(panel);
188
- so.open();
189
- return {so, panel, close: (force) => so.close(force)};
328
+ try {
329
+ // The screen going (another tab, another Tool, a remount): keep the draft, take the panel away.
330
+ if (ctx?.hold) {
331
+ unhold = ctx.hold(() => {
332
+ if (closed) return;
333
+ keepDraft();
334
+ closed = true;
335
+ close();
336
+ });
337
+ }
338
+ panel.addEventListener('keydown', (e) => {
339
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'Enter' || e.key === 's') && wrapped.save) { e.preventDefault(); e.stopPropagation(); wrapped.save(); }
340
+ });
341
+ if (draft) {
342
+ panel.addEventListener('input', queueKeep);
343
+ panel.addEventListener('change', queueKeep);
344
+ panel.addEventListener('click', queueKeep); // rows added or removed, slot buttons
345
+ window.addEventListener('beforeunload', onUnload);
346
+ }
347
+ I.scan(panel);
348
+ so.open();
349
+ if (draft) offerDraft();
350
+ } catch (err) {
351
+ // Up, but failing: close it (onceClosed tidies and releases) - any stored draft is left alone.
352
+ if (up && !closed) { closed = true; try { close(); } catch { /* gone */ } }
353
+ throw err;
354
+ }
355
+ return {
356
+ so, panel,
357
+ close(force) {
358
+ // A save that was running when the screen went has now succeeded (saves close with force):
359
+ // the draft kept on leaving is saved work, so it goes.
360
+ if (closed && force === true && saving && draft) { dropDraft(draft.key); return undefined; }
361
+ return so.close(force);
362
+ },
363
+ /** The record was saved from this panel and it stays open (the staff note): drafts now start from `was`. */
364
+ rebase(was) { draftWas = was || ''; }
365
+ };
366
+
367
+ function offerDraft() {
368
+ // `asNew`: a deleted record's draft started again as a new one - nothing to compare it with now.
369
+ const restore = (d, asNew = false) => {
370
+ try { draft.apply(d.values, panel); } catch (err) { console.warn('[booking] draft not restored:', err); }
371
+ draftWas = asNew ? (draft.was || '') : (d.was || '');
372
+ // Rows the draft added (hours, closed days) carry icons: drawn once they have rendered.
373
+ M.flushSync?.();
374
+ I.scan(panel);
375
+ requestAnimationFrame(() => { if (panel.isConnected) I.scan(panel); });
376
+ };
377
+ if (draft.initial) {
378
+ restore(draft.initial, true);
379
+ drafting = true;
380
+ keepDraft();
381
+ E.toast(restoreText('auto', {label: draft.initial.label, at: draft.initial.at, kind: draft.kind}), {type: 'info'});
382
+ return;
383
+ }
384
+ const stored = getDraft(draft.key);
385
+ const plan = restorePlan({draft: stored, was: draft.was || '', opening: draft.values(), prefilled: draft.prefilled});
386
+ if (plan === 'none') {
387
+ if (stored) dropDraft(draft.key);
388
+ drafting = true;
389
+ return;
390
+ }
391
+ if (plan === 'auto') {
392
+ restore(stored);
393
+ drafting = true;
394
+ keepDraft();
395
+ E.toast(restoreText('auto', {label: stored.label, at: stored.at, kind: draft.kind}), {type: 'info'});
396
+ return;
397
+ }
398
+ // Discard is the confirm button; Cancel - which Escape and leaving also press - keeps the work.
399
+ ask(esc(restoreText(plan, {label: stored.label, at: stored.at, kind: draft.kind})),
400
+ {title: 'Unsaved changes', confirmText: plan === 'prefilled' ? 'Discard it' : 'Discard them', cancelText: plan === 'prefilled' ? 'Carry on with it' : 'Keep them'}, ctx)
401
+ .then((discard) => {
402
+ // Gone while asked (the screen changed): the draft stays for next time.
403
+ if (closed) return;
404
+ if (discard) dropDraft(draft.key); else restore(stored);
405
+ drafting = true;
406
+ keepDraft();
407
+ });
408
+ }
190
409
  }
191
410
 
192
411
  /**
@@ -207,7 +426,9 @@ export function wireRows(list, {items, main, openMenuAt, scope, match = '.bk-row
207
426
  const onKey = (e) => {
208
427
  const row = e.target.closest(match);
209
428
  if (!row) return;
210
- if ((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu') { e.preventDefault(); openMenuAt(row); }
429
+ // Not the ContextMenu key: Chrome on Windows opens its own menu on that key's key-up, so the menu
430
+ // appeared twice. That key's own contextmenu event reaches E.contextMenu anyway.
431
+ if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); openMenuAt(row); }
211
432
  else if ((e.key === 'Enter' || e.key === ' ') && e.target === row) { e.preventDefault(); main(row.dataset.id, row); }
212
433
  else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target === row) {
213
434
  e.preventDefault();