domma-cms 1.8.32 → 1.8.34

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 (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -0,0 +1,581 @@
1
+ /**
2
+ * Garage - admin screen.
3
+ *
4
+ * Look up a UK vehicle with the DVLA (the plate box at the top), save it to My
5
+ * Garage, and see its MOT and road tax at a glance - the ones coming due or run
6
+ * out first, and on the sidebar badge. History keeps every lookup.
7
+ *
8
+ * Built on the Tool upgrade path: domma-reactive state bound with
9
+ * M.applyBindings; rows with a context menu (right-click, ⋮, Shift+F10) and a
10
+ * click for details; settings (the DVLA API key) in a slideover from the
11
+ * banner's cog; help popovers; a sidebar badge (countUrl /count); viewLifetime
12
+ * teardown. The admin's helpers are imported at run time - a plugin must not
13
+ * import admin/ statically, since the updater replaces it wholesale.
14
+ *
15
+ * The API key is a secret: the settings form asks before a typed key is thrown
16
+ * away, but is never kept as a draft.
17
+ */
18
+ import {arrangeVehicles, displayReg, isoDay, normaliseReg, settingsWithKey, shortDay, vehicleAlert, vehicleRow} from '../lib/garage.js';
19
+ import {fmtDateTime} from '../../../_lib/admin/ui/dates.js';
20
+
21
+ const API = '/plugins/garage';
22
+ const ROUTE = /^#\/plugins\/garage\b/;
23
+ const SIDEBAR_URL = '#/plugins/garage';
24
+ const DVLA_PORTAL = 'https://developer-portal.driver-vehicle-licensing.api.gov.uk';
25
+
26
+ const ROW = `
27
+ <div class="gx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
28
+ <span class="gx-plate">{{reg}}</span>
29
+ <span class="gx-main"><strong>{{title}}</strong><small data-bind-text="meta"></small></span>
30
+ <span class="gx-chips">
31
+ <span class="gx-chip" data-bind-class="motTone" data-bind-text="motText"></span>
32
+ <span class="gx-chip" data-bind-class="taxTone" data-bind-text="taxText"></span>
33
+ <span class="gx-chip is-saved" data-bind-hidden="!savedChip">In My Garage</span>
34
+ </span>
35
+ <button type="button" class="gx-kebab" aria-label="Actions for {{reg}}" title="Actions">⋮</button>
36
+ </div>`;
37
+
38
+ /** Filled by loadKit() on first mount. */
39
+ let apiRequest, addBannerAction, helpPopovers, onceClosed, openMenuAt, viewLifetime, sidebarCount, showLoading, SHAPES;
40
+ let lifetime;
41
+
42
+ async function loadKit() {
43
+ if (lifetime) return;
44
+ const [api, chrome, help, kit, loading] = await Promise.all([
45
+ import('/admin/js/api.js'),
46
+ import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
47
+ import('/admin/js/lib/help-popover.js').catch(() => ({})),
48
+ import('/admin/js/lib/tool-kit.js'),
49
+ import('/admin/js/lib/loading.js').catch(() => ({}))
50
+ ]);
51
+ ({apiRequest} = api);
52
+ addBannerAction = chrome.addBannerAction || null;
53
+ helpPopovers = help.helpPopovers || (() => () => {});
54
+ ({onceClosed, openMenuAt, viewLifetime} = kit);
55
+ sidebarCount = kit.sidebarCount || ((url) => {
56
+ try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url}})); } catch { /* no sidebar */ }
57
+ });
58
+ // An older core without loading.js: the slots show their "Loading…" text.
59
+ showLoading = loading.showLoading || (() => ({done: () => Promise.resolve()}));
60
+ SHAPES = loading.SHAPES || {};
61
+ lifetime = viewLifetime(ROUTE);
62
+ }
63
+
64
+ /** E.confirm / E.prompt / a slideover title render HTML: user text goes in escaped. */
65
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
66
+
67
+ const refreshBadge = () => sidebarCount(SIDEBAR_URL);
68
+
69
+ function fragment(html) {
70
+ const t = document.createElement('template');
71
+ t.innerHTML = html.trim();
72
+ return t.content.firstElementChild;
73
+ }
74
+
75
+ const fmtDay = (d) => (d ? shortDay(d) : '-');
76
+
77
+ export const garageView = {
78
+ templateUrl: '/plugins/garage/admin/templates/garage.html',
79
+
80
+ async onMount($container) {
81
+ await loadKit();
82
+ if (!ROUTE.test(location.hash)) return;
83
+ lifetime.reset();
84
+ const root = $container.get(0).querySelector('.gx') || $container.get(0);
85
+ const here = () => ROUTE.test(location.hash) && root.isConnected;
86
+ const scope = (d) => { lifetime.add(d); return d; };
87
+
88
+ // ---- State ----
89
+ const today = M.observable(isoDay());
90
+ const vehicles = M.observable([]); // the whole history, newest first (saved ones included)
91
+ const loading = M.observable(true);
92
+ const loadError = M.observable('');
93
+ const reg = M.observable('');
94
+ const looking = M.observable(false);
95
+ const lookupError = M.observable('');
96
+ const rateNote = M.observable('');
97
+ const resultId = M.observable('');
98
+ const filter = M.observable('all');
99
+ const garageQuery = M.observable('');
100
+ const historyQuery = M.observable('');
101
+ const settingsLoaded = M.observable(false);
102
+ const hasKey = M.observable(false);
103
+ // Can this person change the site's key? Reading the plugin settings needs
104
+ // plugins.read and saving them plugins.update; a user with only the Garage
105
+ // permission gets a settings panel that says so rather than one that fails.
106
+ const canConfigure = M.observable(true);
107
+ // One save request per vehicle at a time. Observable, so every way in (the
108
+ // result's button, the details panel, the menu) greys out while it runs.
109
+ const toggling = M.observable([]);
110
+
111
+ const saved = M.computed(() => vehicles.value.filter(v => v.isSaved));
112
+ const history = vehicles;
113
+ const byId = (id) => vehicles.peek().find(v => v.id === id);
114
+ const result = M.computed(() => vehicles.value.find(v => v.id === resultId.value) || null);
115
+ const blank = {reg: '', title: '', meta: '', motText: '', motTone: '', taxText: '', taxTone: '', saved: false};
116
+ const resultRow = M.computed(() => (result.value ? vehicleRow(result.value, today.value) : blank));
117
+
118
+ const garageRows = M.observable([]);
119
+ const historyRows = M.observable([]);
120
+ scope(M.effect(() => {
121
+ const t = today.value;
122
+ garageRows.value = arrangeVehicles(saved.value, {today: t, filter: filter.value, query: garageQuery.value})
123
+ .map(v => ({...vehicleRow(v, t), savedChip: false}));
124
+ historyRows.value = arrangeVehicles(vehicles.value, {today: t, query: historyQuery.value, order: 'recent'})
125
+ .map(v => {
126
+ const r = vehicleRow(v, t);
127
+ return {...r, savedChip: r.saved, meta: [`checked ${v.lookupDate ? D(v.lookupDate).fromNow() : 'earlier'}`, r.meta].filter(Boolean).join(' · ')};
128
+ });
129
+ }));
130
+
131
+ const data = {
132
+ vehicles, saved, history, loading, loadError, reg, looking, lookupError, rateNote, result, resultRow,
133
+ filter, garageQuery, historyQuery, settingsLoaded, hasKey, garageRows, historyRows,
134
+ hasBanner: false,
135
+ attentionCount: M.computed(() => saved.value.filter(v => vehicleAlert(v, today.value).needsAttention).length),
136
+ unsavedCount: M.computed(() => vehicles.value.filter(v => !v.isSaved).length),
137
+ resultSaving: M.computed(() => Boolean(resultId.value) && toggling.value.includes(resultId.value)),
138
+ cannotLookUp: M.computed(() => looking.value || !normaliseReg(reg.value)),
139
+ lookupText: M.computed(() => (looking.value ? 'Looking up…' : 'Look up')),
140
+ garageEmpty: M.computed(() => (saved.value.length
141
+ ? (filter.value === 'attention' ? 'Nothing due - every saved vehicle is taxed and has a valid MOT.' : 'No vehicles match.')
142
+ : 'No saved vehicles yet - look one up and save it to My Garage.')),
143
+ historyEmpty: M.computed(() => (vehicles.value.length ? 'No lookups match.' : 'No lookups yet.'))
144
+ };
145
+
146
+ // A day boundary passes while the screen is open: "due tomorrow" becomes "due today".
147
+ const tick = setInterval(() => { const d = isoDay(); if (d !== today.peek()) today.value = d; }, 60_000);
148
+ scope(() => clearInterval(tick));
149
+
150
+ // ---- Server ----
151
+ /**
152
+ * The whole list. The first load draws a skeleton in each list's slot (the
153
+ * one on screen - History is a closed tab at first); a reload keeps the rows
154
+ * on screen and swaps them when the answer is in.
155
+ */
156
+ async function load({first = false} = {}) {
157
+ const sk = first ? showLoading([...root.querySelectorAll('.gx-loading')], SHAPES.rows) : null;
158
+ try {
159
+ const list = await apiRequest(`${API}/history`, {method: 'GET'});
160
+ await sk?.done();
161
+ if (!here()) return;
162
+ vehicles.value = Array.isArray(list) ? list : [];
163
+ loadError.value = '';
164
+ } catch (err) {
165
+ await sk?.done();
166
+ if (!here()) return;
167
+ loadError.value = `Could not load your vehicles: ${err.message || err}`;
168
+ } finally {
169
+ if (here()) loading.value = false;
170
+ }
171
+ }
172
+
173
+ /** The stored settings for this plugin (secrets come back masked), or throws. */
174
+ async function readSettings() {
175
+ const plugins = await apiRequest('/plugins', {method: 'GET'});
176
+ const mine = (Array.isArray(plugins) ? plugins : []).find(p => p.name === 'garage');
177
+ if (!mine) throw new Error('Garage is not in the plugin list');
178
+ return mine.settings || {};
179
+ }
180
+
181
+ async function loadSettings() {
182
+ try {
183
+ const stored = await readSettings();
184
+ if (!here()) return;
185
+ hasKey.value = Boolean(stored.dvlaApiKey);
186
+ canConfigure.value = true;
187
+ } catch (err) {
188
+ if (!here()) return;
189
+ // No right to read plugin settings: say nothing rather than claim there is no key.
190
+ hasKey.value = true;
191
+ if (err?.status === 403) canConfigure.value = false;
192
+ } finally {
193
+ if (here()) settingsLoaded.value = true;
194
+ }
195
+ }
196
+
197
+ /** Put one vehicle (as the server returned it) in place, or add it at the top. */
198
+ function upsert(v) {
199
+ const list = vehicles.peek();
200
+ const i = list.findIndex(x => x.id === v.id);
201
+ vehicles.value = i >= 0 ? list.map(x => (x.id === v.id ? v : x)) : [v, ...list];
202
+ }
203
+
204
+ async function lookup(regNumber = reg.peek()) {
205
+ const r = normaliseReg(regNumber);
206
+ if (!r || looking.peek()) return;
207
+ looking.value = true;
208
+ lookupError.value = '';
209
+ rateNote.value = '';
210
+ try {
211
+ const v = await apiRequest(`${API}/lookup`, {method: 'POST', body: JSON.stringify({registrationNumber: r})});
212
+ if (!here() || !v) return null;
213
+ upsert(v);
214
+ resultId.value = v.id;
215
+ refreshBadge();
216
+ return v;
217
+ } catch (err) {
218
+ if (!here()) return null;
219
+ const msg = err.message || String(err);
220
+ if (/rate limit/i.test(msg)) rateNote.value = msg;
221
+ else lookupError.value = msg;
222
+ return null;
223
+ } finally {
224
+ if (here()) looking.value = false;
225
+ }
226
+ }
227
+
228
+ const isToggling = (id) => toggling.peek().includes(id);
229
+ async function toggleSaved(v) {
230
+ if (isToggling(v.id)) return null;
231
+ toggling.value = [...toggling.peek(), v.id];
232
+ try {
233
+ // Sent as the state wanted, not a toggle: if another tab changed it meanwhile, this still does what was asked.
234
+ const updated = await apiRequest(`${API}/vehicles/${encodeURIComponent(v.id)}/save`, {method: 'PATCH', body: JSON.stringify({isSaved: !v.isSaved})});
235
+ refreshBadge();
236
+ if (!here() || !updated) return null;
237
+ upsert(updated);
238
+ E.toast(updated.isSaved ? `${displayReg(updated.registrationNumber)} saved to My Garage.` : `${displayReg(updated.registrationNumber)} taken out of My Garage.`, {type: 'success'});
239
+ return updated;
240
+ } catch (err) {
241
+ if (here()) E.toast(`Not changed: ${err.message || err}`, {type: 'error'});
242
+ return null;
243
+ } finally {
244
+ toggling.value = toggling.peek().filter(x => x !== v.id);
245
+ }
246
+ }
247
+
248
+ async function remove(v) {
249
+ const what = v.isSaved ? 'from My Garage and your history' : 'from your history';
250
+ if (!await E.confirm(`Delete ${esc(displayReg(v.registrationNumber))} ${what}? Looking it up again brings it back.`)) return false;
251
+ // Left while asked: leaving is not a yes.
252
+ if (!here()) return false;
253
+ try {
254
+ await apiRequest(`${API}/vehicles/${encodeURIComponent(v.id)}`, {method: 'DELETE'});
255
+ refreshBadge();
256
+ if (!here()) return false;
257
+ vehicles.value = vehicles.peek().filter(x => x.id !== v.id);
258
+ if (resultId.peek() === v.id) resultId.value = '';
259
+ E.toast(`Deleted ${displayReg(v.registrationNumber)}.`, {type: 'success'});
260
+ return true;
261
+ } catch (err) {
262
+ if (here()) E.toast(`Not deleted: ${err.message || err}`, {type: 'error'});
263
+ return false;
264
+ }
265
+ }
266
+
267
+ async function clearUnsaved() {
268
+ const unsaved = vehicles.peek().filter(v => !v.isSaved);
269
+ if (!unsaved.length) return;
270
+ if (!await E.confirm(`Delete ${unsaved.length} lookup${unsaved.length === 1 ? '' : 's'} you have not saved? Vehicles in My Garage are kept.`)) return;
271
+ if (!here()) return;
272
+ let done = 0;
273
+ for (const v of unsaved) {
274
+ try { await apiRequest(`${API}/vehicles/${encodeURIComponent(v.id)}`, {method: 'DELETE'}); done++; } catch { /* keep going */ }
275
+ }
276
+ if (!here()) return;
277
+ await load();
278
+ if (!here()) return;
279
+ E.toast(`Cleared ${done} lookup${done === 1 ? '' : 's'}.`, {type: done === unsaved.length ? 'success' : 'warning'});
280
+ }
281
+
282
+ async function copyReg(v) {
283
+ const text = normaliseReg(v.registrationNumber);
284
+ try { await navigator.clipboard.writeText(text); E.toast(`Copied ${text}.`, {type: 'success'}); } catch { E.toast(text, {type: 'info'}); }
285
+ }
286
+
287
+ // ---- Slideovers ----
288
+ const panels = new Set();
289
+ const claims = new Map(); // once-only claims: 'settings' / 'details:<id>' -> its slideover
290
+ let dirtyForm = null; // the settings form's dirty() while it is open
291
+
292
+ /**
293
+ * A slideover. `claim` makes it once-only: asking again while it is open
294
+ * brings nothing new up. The claim is released when it closes - or at once
295
+ * if building it throws, or it could never open again until a reload.
296
+ * `busy()` true (a save on its way) refuses a close outright.
297
+ */
298
+ function panel(title, build, {size = 'md', claim = '', dirty = null, busy = null, cleanup = () => {}} = {}) {
299
+ if (claim && claims.has(claim)) return null;
300
+ if (claim) claims.set(claim, true);
301
+ let so;
302
+ try {
303
+ so = E.slideover({title, size, position: 'right'});
304
+ const body = build(so);
305
+ so.element.appendChild(body);
306
+ const disposeHelp = helpPopovers(body);
307
+ onceClosed(so, () => { try { cleanup(); } finally { disposeHelp(); panels.delete(so); if (claim) claims.delete(claim); } });
308
+ // Asks once before throwing typed work away; onceClosed's later destroy()
309
+ // calls close() again, argument-less, and must go straight through.
310
+ // Wrapped AFTER onceClosed, so a "no" never reaches its cleanup.
311
+ const close = so.close.bind(so);
312
+ let closed = false;
313
+ let asking = false;
314
+ so.close = async (force) => {
315
+ if (!closed && force !== true) {
316
+ if (busy?.()) return undefined;
317
+ if (dirty?.()) {
318
+ if (asking) return undefined;
319
+ asking = true;
320
+ const ok = await E.confirm('Discard what you have typed?');
321
+ asking = false;
322
+ if (!ok) return undefined;
323
+ }
324
+ }
325
+ closed = true;
326
+ return close();
327
+ };
328
+ panels.add(so);
329
+ if (claim) claims.set(claim, so);
330
+ I.scan(body);
331
+ so.open();
332
+ return so;
333
+ } catch (err) {
334
+ if (claim) claims.delete(claim);
335
+ // What the build started (a details panel's effect) stops with it.
336
+ try { cleanup(); } catch { /* half-built */ }
337
+ try { so?.destroy?.(); so?.element?.remove(); } catch { /* half-built */ }
338
+ throw err;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * A vehicle's full DVLA record. Built with textContent - nothing here is
344
+ * markup - and kept in step with the list: saving it from the menu, a fresh
345
+ * lookup or a delete elsewhere shows here at once (a deleted one closes).
346
+ */
347
+ function details(id) {
348
+ if (!byId(id)) return;
349
+ let stop = null;
350
+ let so = null;
351
+ so = panel(esc(displayReg(byId(id).registrationNumber)), () => {
352
+ const body = fragment(`
353
+ <div class="gx-panel">
354
+ <div class="gx-result-head"><span class="gx-plate is-big"></span><strong class="gx-d-title"></strong></div>
355
+ <div class="gx-chips gx-chips-start"><span class="gx-chip gx-d-mot"></span><span class="gx-chip gx-d-tax"></span><span class="gx-chip is-saved gx-d-saved">In My Garage</span></div>
356
+ <dl class="gx-facts"></dl>
357
+ <small class="gx-hint">As the DVLA had it when last looked up. Look it up again for fresh details.</small>
358
+ <div class="gx-panel-actions">
359
+ <button type="button" class="btn btn-sm btn-ghost text-danger gx-d-del"><span data-icon="trash" data-icon-size="13"></span> Delete</button>
360
+ <span class="gx-spacer"></span>
361
+ <button type="button" class="btn btn-sm btn-outline gx-d-again"><span data-icon="refresh-cw" data-icon-size="13"></span> <span class="gx-d-again-text">Look up again</span></button>
362
+ <button type="button" class="btn btn-sm gx-d-save"></button>
363
+ </div>
364
+ </div>`);
365
+ const set = (sel, text) => { body.querySelector(sel).textContent = text; };
366
+ const fill = (v, t) => {
367
+ const a = vehicleAlert(v, t);
368
+ set('.gx-plate', displayReg(v.registrationNumber));
369
+ set('.gx-d-title', [v.make, v.yearOfManufacture].filter(Boolean).join(' · '));
370
+ const mot = body.querySelector('.gx-d-mot'); mot.textContent = a.mot.text; mot.className = `gx-chip gx-d-mot is-${a.mot.tone}`;
371
+ const tax = body.querySelector('.gx-d-tax'); tax.textContent = a.tax.text; tax.className = `gx-chip gx-d-tax is-${a.tax.tone}`;
372
+ body.querySelector('.gx-d-saved').hidden = !v.isSaved;
373
+ const dl = body.querySelector('.gx-facts');
374
+ dl.replaceChildren();
375
+ for (const [k, val] of [
376
+ ['Make', v.make], ['Year', v.yearOfManufacture], ['Colour', v.colour], ['Fuel', v.fuelType],
377
+ ['Engine', v.engineCapacity ? `${v.engineCapacity} cc` : ''], ['CO₂', v.co2Emissions ? `${v.co2Emissions} g/km` : ''],
378
+ ['MOT', v.motStatus], ['MOT expires', v.motExpiryDate ? fmtDay(v.motExpiryDate) : ''],
379
+ ['Road tax', v.taxStatus], ['Tax due', v.taxDueDate ? fmtDay(v.taxDueDate) : ''],
380
+ ['Last looked up', v.lookupDate ? fmtDateTime(v.lookupDate) : '']
381
+ ]) {
382
+ if (val === null || val === undefined || val === '') continue;
383
+ // One <div> per pair (valid in a <dl>) so each label stays with its value in the grid.
384
+ const pair = document.createElement('div');
385
+ const dt = document.createElement('dt'); dt.textContent = k;
386
+ const dd = document.createElement('dd'); dd.textContent = String(val);
387
+ pair.append(dt, dd);
388
+ dl.appendChild(pair);
389
+ }
390
+ const saveBtn = body.querySelector('.gx-d-save');
391
+ saveBtn.className = `btn btn-sm ${v.isSaved ? 'btn-outline' : 'btn-success'} gx-d-save`;
392
+ saveBtn.textContent = v.isSaved ? 'Remove from My Garage' : 'Save to My Garage';
393
+ };
394
+ let first = true;
395
+ stop = M.effect(() => {
396
+ const v = vehicles.value.find(x => x.id === id);
397
+ const t = today.value;
398
+ const busy = looking.value;
399
+ const saving = toggling.value.includes(id);
400
+ if (!v) { if (!first) queueMicrotask(() => so?.close(true)); return; }
401
+ fill(v, t);
402
+ body.querySelector('.gx-d-again').disabled = busy;
403
+ body.querySelector('.gx-d-save').disabled = saving;
404
+ body.querySelector('.gx-d-again-text').textContent = busy ? 'Looking up…' : 'Look up again';
405
+ first = false;
406
+ });
407
+ // Each button acts on the vehicle as it is NOW, not as it was when the panel opened.
408
+ body.querySelector('.gx-d-save').addEventListener('click', () => { const v = byId(id); if (v) toggleSaved(v); });
409
+ body.querySelector('.gx-d-again').addEventListener('click', () => { const v = byId(id); if (v) lookup(v.registrationNumber); });
410
+ body.querySelector('.gx-d-del').addEventListener('click', async () => {
411
+ const v = byId(id);
412
+ if (v && await remove(v)) so?.close(true);
413
+ });
414
+ return body;
415
+ }, {claim: `details:${id}`, cleanup: () => stop?.()});
416
+ if (!so) claims.get(`details:${id}`)?.element?.querySelector('.gx-d-save')?.focus();
417
+ }
418
+
419
+ function openSettings() {
420
+ if (claims.has('settings')) return;
421
+ const m = {key: M.observable(''), saving: M.observable(false), hasKey, canConfigure, settingsLoaded};
422
+ m.cannot = M.computed(() => !m.key.value.trim() || m.saving.value || !m.canConfigure.value);
423
+ m.saveText = M.computed(() => (m.saving.value ? 'Saving…' : 'Save key'));
424
+ const dirty = () => Boolean(m.key.peek().trim());
425
+ let handle = null;
426
+ let body = null;
427
+ let so = null;
428
+ async function save() {
429
+ if (m.cannot.peek()) return;
430
+ const key = m.key.peek().trim();
431
+ m.saving.value = true;
432
+ // The panel closes after saving, so nothing may be typed while it saves
433
+ // (it would be thrown away with it): the form is locked until the answer.
434
+ body.inert = true;
435
+ try {
436
+ // Merged onto the settings as stored NOW - not as they were when the
437
+ // screen opened, or a failed or slow first read would PUT a bare
438
+ // {dvlaApiKey} over everything else saved for the plugin.
439
+ const stored = await readSettings();
440
+ await apiRequest('/plugins/garage', {method: 'PUT', body: JSON.stringify({settings: settingsWithKey(stored, key)})});
441
+ if (!here()) return;
442
+ hasKey.value = true;
443
+ m.key.value = '';
444
+ E.toast('DVLA API key saved.', {type: 'success'});
445
+ m.saving.value = false;
446
+ so.close(true);
447
+ } catch (err) {
448
+ if (!here()) return;
449
+ if (err?.status === 403) canConfigure.value = false;
450
+ E.toast(`Not saved: ${err.message || err}`, {type: 'error'});
451
+ } finally {
452
+ m.saving.value = false;
453
+ if (body) body.inert = false;
454
+ }
455
+ }
456
+ so = panel('Garage settings', () => {
457
+ body = fragment(`
458
+ <div class="gx-panel">
459
+ <p class="gx-note">Lookups need a key for the DVLA's Vehicle Enquiry Service. Get one from the
460
+ <a href="${DVLA_PORTAL}" target="_blank" rel="noopener">DVLA developer portal</a>.</p>
461
+ <div class="gx-chips gx-chips-start">
462
+ <span class="gx-chip" data-bind-hidden="settingsLoaded.value">Checking…</span>
463
+ <span class="gx-chip is-ok" data-bind-hidden="!settingsLoaded.value || !hasKey.value || !canConfigure.value">A key is saved</span>
464
+ <span class="gx-chip is-warn" data-bind-hidden="!settingsLoaded.value || hasKey.value">No key yet - lookups fail</span>
465
+ </div>
466
+ <p class="gx-alert" data-bind-hidden="canConfigure.value">
467
+ <span data-icon="lock" data-icon-size="16"></span>
468
+ Only someone who can manage plugins can change the site's DVLA key.</p>
469
+ <div>
470
+ <label class="form-label" for="gx-key">DVLA API key
471
+ <span data-help-title="DVLA API key" data-help="Saved in this site's plugin settings (config/plugins.json) and sent only to the DVLA. It is never shown again here - type a new one to replace it. The DVLA_API_KEY environment variable, when set, is used instead. Being a secret, it is never kept as a draft: leave the screen and a typed key is gone."></span></label>
472
+ <input id="gx-key" type="password" class="form-input" autocomplete="off" placeholder="Paste your DVLA API key"
473
+ data-model="key.value" data-bind-disabled="!canConfigure.value">
474
+ </div>
475
+ <div class="gx-panel-actions">
476
+ <small class="gx-hint">Enter saves.</small>
477
+ <span class="gx-spacer"></span>
478
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
479
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
480
+ <span data-icon="save" data-icon-size="13"></span> <span data-bind-text="saveText.value">Save key</span></button>
481
+ </div>
482
+ </div>`);
483
+ handle = M.applyBindings(m, body, {methods: {save, cancel: () => so?.close()}});
484
+ body.querySelector('#gx-key').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); save(); } });
485
+ return body;
486
+ }, {size: 'sm', claim: 'settings', dirty, busy: () => m.saving.peek(),
487
+ cleanup: () => { handle?.dispose(); if (dirtyForm === dirty) dirtyForm = null; }});
488
+ if (!so) return;
489
+ dirtyForm = dirty;
490
+ body.querySelector('#gx-key').focus();
491
+ }
492
+
493
+ // ---- Context menu ----
494
+ function items(id) {
495
+ const v = byId(id);
496
+ if (!v) return [];
497
+ return [
498
+ {header: displayReg(v.registrationNumber)},
499
+ {label: 'Details…', icon: 'eye', action: () => details(id)},
500
+ {label: 'Look up again', icon: 'refresh-cw', disabled: looking.peek(), action: () => lookup(v.registrationNumber)},
501
+ v.isSaved
502
+ ? {label: 'Remove from My Garage', icon: 'minus-circle', disabled: isToggling(id), action: () => toggleSaved(v)}
503
+ : {label: 'Save to My Garage', icon: 'plus-circle', disabled: isToggling(id), action: () => toggleSaved(v)},
504
+ {label: 'Copy registration', icon: 'copy', action: () => copyReg(v)},
505
+ {type: 'divider'},
506
+ {label: 'Delete…', icon: 'trash', danger: true, action: () => remove(v)}
507
+ ];
508
+ }
509
+
510
+ function wireList(list) {
511
+ const menu = E.contextMenu(list, {cursor: 'glyph', match: '.gx-row', inherit: false, density: 'compact',
512
+ items: (row) => items(row.dataset.id)});
513
+ scope(() => menu?.destroy?.());
514
+ const onClick = (e) => {
515
+ const row = e.target.closest('.gx-row');
516
+ if (!row) return;
517
+ const kebab = e.target.closest('.gx-kebab');
518
+ if (kebab) { openMenuAt(kebab); return; }
519
+ details(row.dataset.id);
520
+ };
521
+ const onKey = (e) => {
522
+ const row = e.target.closest('.gx-row');
523
+ if (!row) return;
524
+ if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); openMenuAt(row); }
525
+ else if (e.key === 'Enter' && e.target === row) details(row.dataset.id);
526
+ else if (e.key === 'Delete' && e.target === row) { const v = byId(row.dataset.id); if (v) remove(v); }
527
+ };
528
+ list.addEventListener('click', onClick);
529
+ list.addEventListener('keydown', onKey);
530
+ scope(() => { list.removeEventListener('click', onClick); list.removeEventListener('keydown', onKey); });
531
+ }
532
+
533
+ // ---- Bindings ----
534
+ root.querySelector('.gx-garage').innerHTML = ROW;
535
+ root.querySelector('.gx-history').innerHTML = ROW;
536
+ const hasBanner = typeof addBannerAction === 'function';
537
+ data.hasBanner = hasBanner;
538
+ E.tabs(root.querySelector('.gx-tabs'), {tabSelector: '.tab-item', panelSelector: '.tab-panel'});
539
+ const bindings = M.applyBindings(data, root, {
540
+ methods: {
541
+ lookup: () => lookup(),
542
+ toggleResultSaved: () => { const v = result.peek(); if (v) toggleSaved(v); },
543
+ resultDetails: () => { if (resultId.peek()) details(resultId.peek()); },
544
+ showAll: () => { filter.value = 'all'; },
545
+ showAttention: () => { filter.value = 'attention'; },
546
+ clearUnsaved,
547
+ openSettings
548
+ }
549
+ });
550
+ scope(() => bindings.dispose());
551
+ scope(helpPopovers(root, {position: 'bottom'}));
552
+ wireList(root.querySelector('.gx-garage'));
553
+ wireList(root.querySelector('.gx-history'));
554
+
555
+ // The plate box: capitals as you type, Enter looks it up.
556
+ const plateInput = root.querySelector('.gx-plate-input');
557
+ const onPlateKey = (e) => { if (e.key === 'Enter') { e.preventDefault(); lookup(); } };
558
+ const onPlateInput = () => {
559
+ const pos = plateInput.selectionStart;
560
+ const up = plateInput.value.toUpperCase();
561
+ if (up !== plateInput.value) { plateInput.value = up; plateInput.setSelectionRange(pos, pos); reg.value = up; }
562
+ };
563
+ plateInput.addEventListener('keydown', onPlateKey);
564
+ plateInput.addEventListener('input', onPlateInput);
565
+ scope(() => { plateInput.removeEventListener('keydown', onPlateKey); plateInput.removeEventListener('input', onPlateInput); });
566
+
567
+ if (hasBanner) addBannerAction($container, {id: 'garage-settings', icon: 'settings', title: 'Garage settings', onClick: openSettings});
568
+
569
+ // ---- Leaving ----
570
+ const onUnload = (e) => { if (dirtyForm?.()) { e.preventDefault(); e.returnValue = ''; } };
571
+ window.addEventListener('beforeunload', onUnload);
572
+ scope(() => window.removeEventListener('beforeunload', onUnload));
573
+ // Panels close with the screen. The settings key is a secret: it is not kept as a draft.
574
+ scope(() => [...panels].forEach(so => { try { so.close(true); } catch { /* gone */ } }));
575
+
576
+ I.scan(root);
577
+ await Promise.all([load({first: true}), loadSettings()]);
578
+ if (!here()) return;
579
+ plateInput.focus();
580
+ }
581
+ };