domma-cms 0.10.0 → 0.13.0

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 (121) hide show
  1. package/CLAUDE.md +248 -159
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +7 -3
  5. package/admin/js/config/sidebar-config.js +1 -1
  6. package/admin/js/http-interceptor.js +1 -0
  7. package/admin/js/lib/safe-html.js +1 -0
  8. package/admin/js/templates/documentation.html +611 -2
  9. package/admin/js/templates/layouts.html +5 -4
  10. package/admin/js/templates/notifications.html +14 -0
  11. package/admin/js/templates/plugin-marketplace.html +16 -0
  12. package/admin/js/templates/plugins.html +17 -5
  13. package/admin/js/views/index.js +1 -1
  14. package/admin/js/views/layouts.js +1 -16
  15. package/admin/js/views/notifications.js +1 -0
  16. package/admin/js/views/plugin-marketplace.js +1 -0
  17. package/admin/js/views/plugins.js +16 -16
  18. package/config/navigation.json +5 -72
  19. package/config/plugins.json +10 -14
  20. package/config/presets.json +50 -13
  21. package/config/site.json +11 -63
  22. package/package.json +2 -1
  23. package/plugins/_template/admin/templates/index.html +17 -0
  24. package/plugins/_template/admin/views/index.js +19 -0
  25. package/plugins/_template/config.js +8 -0
  26. package/plugins/_template/plugin.js +23 -0
  27. package/plugins/_template/plugin.json +34 -0
  28. package/plugins/analytics/plugin.json +41 -31
  29. package/plugins/blog/admin/templates/blog.html +22 -0
  30. package/plugins/blog/admin/templates/categories.html +7 -0
  31. package/plugins/blog/admin/templates/comments.html +11 -0
  32. package/plugins/blog/admin/templates/post-editor.html +97 -0
  33. package/plugins/blog/admin/templates/settings.html +11 -0
  34. package/plugins/blog/admin/views/blog.js +183 -0
  35. package/plugins/blog/admin/views/categories.js +235 -0
  36. package/plugins/blog/admin/views/comments.js +187 -0
  37. package/plugins/blog/admin/views/post-editor.js +291 -0
  38. package/plugins/blog/admin/views/settings.js +100 -0
  39. package/plugins/blog/collections/categories/schema.json +12 -0
  40. package/plugins/blog/collections/comments/schema.json +16 -0
  41. package/plugins/blog/collections/posts/schema.json +19 -0
  42. package/plugins/blog/config.js +8 -0
  43. package/plugins/blog/plugin.js +352 -0
  44. package/plugins/blog/plugin.json +96 -0
  45. package/plugins/blog/roles/blog-author.json +10 -0
  46. package/plugins/blog/roles/blog-editor.json +12 -0
  47. package/plugins/blog/templates/author.html +9 -0
  48. package/plugins/blog/templates/category.html +9 -0
  49. package/plugins/blog/templates/index.html +9 -0
  50. package/plugins/blog/templates/post.html +17 -0
  51. package/plugins/blog/templates/tag.html +9 -0
  52. package/plugins/contacts/collections/user-contact-groups/schema.json +1 -1
  53. package/plugins/contacts/collections/user-contacts/schema.json +1 -1
  54. package/plugins/contacts/plugin.js +4 -10
  55. package/plugins/contacts/plugin.json +13 -3
  56. package/plugins/notes/collections/user-notes/schema.json +1 -1
  57. package/plugins/notes/plugin.js +3 -9
  58. package/plugins/notes/plugin.json +13 -3
  59. package/plugins/site-search/plugin.json +5 -2
  60. package/plugins/theme-switcher/plugin.json +1 -1
  61. package/plugins/todo/collections/todos/schema.json +1 -1
  62. package/plugins/todo/plugin.js +3 -9
  63. package/plugins/todo/plugin.json +13 -3
  64. package/public/css/site.css +1 -1
  65. package/scripts/build.js +48 -0
  66. package/scripts/create-plugin.js +113 -0
  67. package/scripts/fresh.js +6 -7
  68. package/scripts/gen-instance-secret.js +46 -0
  69. package/scripts/reset.js +3 -3
  70. package/scripts/setup.js +31 -13
  71. package/server/middleware/auth.js +48 -0
  72. package/server/middleware/managerAuth.js +36 -0
  73. package/server/routes/api/actions.js +1 -1
  74. package/server/routes/api/auth.js +4 -3
  75. package/server/routes/api/layouts.js +173 -49
  76. package/server/routes/api/notifications.js +155 -0
  77. package/server/routes/api/plugin-marketplace.js +75 -0
  78. package/server/routes/api/users.js +1 -1
  79. package/server/routes/api/views.js +1 -1
  80. package/server/routes/public.js +4 -9
  81. package/server/server.js +32 -3
  82. package/server/services/actions.js +1 -1
  83. package/server/services/managerClient.js +182 -0
  84. package/server/services/markdown.js +52 -14
  85. package/server/services/permissionRegistry.js +245 -173
  86. package/server/services/pluginInstaller.js +301 -0
  87. package/server/services/plugins.js +117 -10
  88. package/server/services/presetCollections.js +66 -251
  89. package/server/services/renderer.js +99 -0
  90. package/server/services/roles.js +191 -39
  91. package/server/services/users.js +1 -1
  92. package/server/services/views.js +1 -1
  93. package/server/templates/page.html +2 -2
  94. package/plugins/docs/admin/templates/docs.html +0 -69
  95. package/plugins/docs/admin/views/docs.js +0 -276
  96. package/plugins/docs/config.js +0 -8
  97. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +0 -11
  98. package/plugins/docs/data/folders.json +0 -9
  99. package/plugins/docs/data/templates.json +0 -1
  100. package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +0 -5
  101. package/plugins/docs/plugin.js +0 -375
  102. package/plugins/docs/plugin.json +0 -23
  103. package/plugins/form-builder/data/forms/contacts.json +0 -66
  104. package/plugins/form-builder/data/forms/enquiries.json +0 -103
  105. package/plugins/form-builder/data/forms/feedback.json +0 -131
  106. package/plugins/form-builder/data/forms/notes.json +0 -79
  107. package/plugins/form-builder/data/forms/to-do.json +0 -100
  108. package/plugins/form-builder/data/submissions/contacts.json +0 -1
  109. package/plugins/form-builder/data/submissions/enquiries.json +0 -1
  110. package/plugins/form-builder/data/submissions/feedback.json +0 -1
  111. package/plugins/form-builder/data/submissions/notes.json +0 -1
  112. package/plugins/form-builder/data/submissions/to-do.json +0 -1
  113. package/plugins/garage/admin/templates/garage.html +0 -111
  114. package/plugins/garage/admin/views/garage.js +0 -622
  115. package/plugins/garage/collections/garage-vehicles/schema.json +0 -101
  116. package/plugins/garage/config.js +0 -18
  117. package/plugins/garage/data/vehicles.json +0 -70
  118. package/plugins/garage/plugin.js +0 -398
  119. package/plugins/garage/plugin.json +0 -33
  120. package/scripts/seed.js +0 -1996
  121. package/server/services/userTypes.js +0 -227
@@ -1,622 +0,0 @@
1
- /**
2
- * Garage Plugin — Admin View
3
- *
4
- * Provides the Garage admin UI: UK vehicle lookup via DVLA API, saved vehicle
5
- * management ("My Garage"), and full lookup history.
6
- *
7
- * Pattern: follows contacts plugin conventions.
8
- * - api() — fetch wrapper with CMS Bearer token
9
- * - buildVehicleCard() — DOM-built result card (preserves interactive buttons)
10
- * - statusBadge() — colour-coded MOT/tax status badge
11
- * - loadSaved() — populates My Garage tab via T.create
12
- * - loadHistory() — populates History tab via T.create with D().fromNow()
13
- */
14
-
15
- const BASE = '/api/plugins/garage';
16
-
17
- /**
18
- * Authenticated fetch helper.
19
- *
20
- * @param {string} url
21
- * @param {string} [method]
22
- * @param {object} [body]
23
- * @returns {Promise<any>}
24
- */
25
- async function api(url, method = 'GET', body) {
26
- const opts = {method, headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}};
27
- if (body !== undefined) {
28
- opts.headers['Content-Type'] = 'application/json';
29
- opts.body = JSON.stringify(body);
30
- }
31
- const res = await fetch(url, opts);
32
- if (!res.ok) {
33
- const err = await res.json().catch(() => ({error: res.statusText}));
34
- throw new Error(err.error || res.statusText);
35
- }
36
- const text = await res.text();
37
- return text ? JSON.parse(text) : {};
38
- }
39
-
40
- /**
41
- * Escape HTML special characters to prevent XSS in T.create render functions.
42
- *
43
- * @param {*} str
44
- * @returns {string}
45
- */
46
- function esc(str) {
47
- return String(str ?? '')
48
- .replace(/&/g, '&amp;')
49
- .replace(/</g, '&lt;')
50
- .replace(/>/g, '&gt;')
51
- .replace(/"/g, '&quot;')
52
- .replace(/'/g, '&#39;');
53
- }
54
-
55
- /**
56
- * Build a colour-coded status badge element.
57
- *
58
- * @param {string} label - e.g. "MOT" or "Tax"
59
- * @param {string} value - e.g. "Valid", "SORN", "Not valid"
60
- * @returns {HTMLElement}
61
- */
62
- function statusBadge(label, value) {
63
- const span = document.createElement('span');
64
- const v = (value || '').toLowerCase();
65
- let cls = 'badge-secondary';
66
- if (v === 'valid' || v === 'taxed') cls = 'badge-success';
67
- else if (v === 'sorn') cls = 'badge-warning';
68
- else if (v.includes('not valid') || v === 'untaxed') cls = 'badge-danger';
69
- span.className = `badge ${cls}`;
70
- span.textContent = `${label}: ${value || 'Unknown'}`;
71
- return span;
72
- }
73
-
74
- /**
75
- * Format a date string using D() (Moment-style), falling back gracefully.
76
- *
77
- * @param {string|null} dateStr
78
- * @returns {string}
79
- */
80
- function formatDate(dateStr) {
81
- if (!dateStr) return '—';
82
- try {
83
- return D(dateStr).format('D MMM YYYY');
84
- } catch {
85
- return dateStr;
86
- }
87
- }
88
-
89
- /**
90
- * Build a badge HTML string for T.create render functions (values are escaped).
91
- *
92
- * @param {string} value
93
- * @param {'success'|'warning'|'danger'|'secondary'} cls
94
- * @returns {string}
95
- */
96
- function badgeHtml(value, cls) {
97
- return `<span class="badge badge-${cls}">${esc(value || '—')}</span>`;
98
- }
99
-
100
- /**
101
- * Resolve a badge class from a MOT status string.
102
- *
103
- * @param {string} v
104
- * @returns {string}
105
- */
106
- function motBadgeCls(v) {
107
- if ((v || '').toLowerCase() === 'valid') return 'success';
108
- if (v) return 'danger';
109
- return 'secondary';
110
- }
111
-
112
- /**
113
- * Resolve a badge class from a tax status string.
114
- *
115
- * @param {string} v
116
- * @returns {string}
117
- */
118
- function taxBadgeCls(v) {
119
- const lv = (v || '').toLowerCase();
120
- if (lv === 'taxed') return 'success';
121
- if (lv === 'sorn') return 'warning';
122
- if (v) return 'danger';
123
- return 'secondary';
124
- }
125
-
126
- /** Plate-style HTML span for use inside T.create render functions. */
127
- function plateHtml(reg) {
128
- return `<span style="background:#FFD900;color:#000;font-family:'Arial Black',Arial,sans-serif;font-size:0.85rem;font-weight:900;letter-spacing:2px;padding:0.1rem 0.4rem;border-radius:3px;border:2px solid #000;">${esc(reg)}</span>`;
129
- }
130
-
131
- export const garageView = {
132
- templateUrl: '/plugins/garage/admin/templates/garage.html',
133
-
134
- /**
135
- * Mount the Garage admin view.
136
- *
137
- * @param {object} $container - Domma-wrapped container element
138
- */
139
- async onMount($container) {
140
-
141
- // ---------------------------------------------------------------
142
- // State
143
- // ---------------------------------------------------------------
144
- let savedVehicles = [];
145
- let historyVehicles = [];
146
-
147
- // ---------------------------------------------------------------
148
- // DOM refs
149
- // ---------------------------------------------------------------
150
- const regInput = $container.find('#reg-input').get(0);
151
- const lookupBtn = $container.find('#lookup-btn').get(0);
152
- const rateLimitInfo = $container.find('#rate-limit-info').get(0);
153
- const lookupError = $container.find('#lookup-error').get(0);
154
- const vehicleResult = $container.find('#vehicle-result').get(0);
155
- const resultEmpty = $container.find('#result-empty').get(0);
156
- const garageCount = $container.find('#garage-count').get(0);
157
- const garageTableEl = $container.find('#garage-table').get(0);
158
- const garageEmpty = $container.find('#garage-empty').get(0);
159
- const historyCount = $container.find('#history-count').get(0);
160
- const historyTableEl = $container.find('#history-table').get(0);
161
- const historyEmpty = $container.find('#history-empty').get(0);
162
- const clearHistoryBtn = $container.find('#clear-history-btn').get(0);
163
- const tabBtnGarage = $container.find('#tab-btn-garage').get(0);
164
- const tabBtnHistory = $container.find('#tab-btn-history').get(0);
165
- const settingsApiKeyInput = $container.find('#settings-api-key').get(0);
166
- const settingsSaveBtn = $container.find('#settings-save-btn').get(0);
167
- const settingsKeyStatus = $container.find('#settings-key-status').get(0);
168
-
169
- // ---------------------------------------------------------------
170
- // Tabs
171
- // ---------------------------------------------------------------
172
- E.tabs($container.find('#garage-tabs').get(0));
173
-
174
- // ---------------------------------------------------------------
175
- // Build vehicle detail card (DOM construction — preserves buttons)
176
- // ---------------------------------------------------------------
177
-
178
- /**
179
- * Builds a full vehicle detail card as a DOM node.
180
- * Uses createElement throughout so interactive buttons are preserved
181
- * (Domma's .html() strips buttons via DOMPurify).
182
- *
183
- * @param {object} vehicle
184
- * @param {Function} [onSaveToggle] - optional callback after save toggle
185
- * @returns {HTMLElement}
186
- */
187
- function buildVehicleCard(vehicle, onSaveToggle) {
188
- const card = document.createElement('div');
189
- card.className = 'card';
190
- card.style.cssText = 'max-width:560px;';
191
-
192
- // Card header — UK plate style registration + make/year
193
- const header = document.createElement('div');
194
- header.className = 'card-header';
195
- header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:1rem;';
196
-
197
- const plate = document.createElement('span');
198
- plate.style.cssText = 'background:#FFD900;color:#000;font-family:\'Arial Black\',Arial,sans-serif;font-weight:900;font-size:1.1rem;letter-spacing:3px;padding:0.2rem 0.6rem;border-radius:4px;border:2px solid #000;';
199
- plate.textContent = vehicle.registrationNumber || '—';
200
-
201
- const makeYear = document.createElement('span');
202
- makeYear.style.cssText = 'font-size:0.9rem;color:var(--dm-text-muted,#888);';
203
- makeYear.textContent = [vehicle.make, vehicle.yearOfManufacture].filter(Boolean).join(' · ');
204
-
205
- header.appendChild(plate);
206
- header.appendChild(makeYear);
207
- card.appendChild(header);
208
-
209
- // Card body
210
- const body = document.createElement('div');
211
- body.className = 'card-body';
212
-
213
- // Detail grid
214
- const grid = document.createElement('div');
215
- grid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 1rem;font-size:0.88rem;margin-bottom:0.75rem;';
216
-
217
- [
218
- ['Colour', vehicle.colour],
219
- ['Fuel', vehicle.fuelType],
220
- ['Engine', vehicle.engineCapacity ? `${vehicle.engineCapacity}cc` : null],
221
- ['CO₂', vehicle.co2Emissions ? `${vehicle.co2Emissions}g/km` : null],
222
- ].forEach(([label, value]) => {
223
- if (!value) return;
224
- const item = document.createElement('div');
225
- const lbl = document.createElement('span');
226
- lbl.style.color = 'var(--dm-text-muted,#888)';
227
- lbl.textContent = label + ': ';
228
- const val = document.createElement('strong');
229
- val.textContent = value;
230
- item.appendChild(lbl);
231
- item.appendChild(val);
232
- grid.appendChild(item);
233
- });
234
-
235
- body.appendChild(grid);
236
-
237
- // Status badges
238
- const badges = document.createElement('div');
239
- badges.style.cssText = 'display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:0.75rem;';
240
- badges.appendChild(statusBadge('MOT', vehicle.motStatus));
241
- if (vehicle.motExpiryDate) {
242
- const expiry = document.createElement('span');
243
- expiry.style.cssText = 'font-size:0.8rem;color:var(--dm-text-muted,#888);';
244
- expiry.textContent = `Expires ${formatDate(vehicle.motExpiryDate)}`;
245
- badges.appendChild(expiry);
246
- }
247
- badges.appendChild(statusBadge('Tax', vehicle.taxStatus));
248
- if (vehicle.taxDueDate) {
249
- const due = document.createElement('span');
250
- due.style.cssText = 'font-size:0.8rem;color:var(--dm-text-muted,#888);';
251
- due.textContent = `Due ${formatDate(vehicle.taxDueDate)}`;
252
- badges.appendChild(due);
253
- }
254
- body.appendChild(badges);
255
-
256
- // Save / Unsave button
257
- const saveBtn = document.createElement('button');
258
- saveBtn.className = vehicle.isSaved ? 'btn btn-sm btn-warning' : 'btn btn-sm btn-success';
259
- const saveIconEl = document.createElement('span');
260
- saveIconEl.setAttribute('data-icon', vehicle.isSaved ? 'bookmark-minus' : 'bookmark-plus');
261
- const saveTxt = document.createTextNode(' ' + (vehicle.isSaved ? 'Remove from Garage' : 'Save to Garage'));
262
- saveBtn.appendChild(saveIconEl);
263
- saveBtn.appendChild(saveTxt);
264
-
265
- saveBtn.addEventListener('click', async () => {
266
- try {
267
- const updated = await api(`${BASE}/vehicles/${vehicle.id}/save`, 'PATCH');
268
- vehicle.isSaved = updated.isSaved;
269
- saveBtn.className = updated.isSaved ? 'btn btn-sm btn-warning' : 'btn btn-sm btn-success';
270
- saveIconEl.setAttribute('data-icon', updated.isSaved ? 'bookmark-minus' : 'bookmark-plus');
271
- saveTxt.textContent = ' ' + (updated.isSaved ? 'Remove from Garage' : 'Save to Garage');
272
- E.toast(updated.isSaved ? 'Saved to Garage' : 'Removed from Garage', {type: 'success'});
273
- Domma.icons.scan();
274
- if (onSaveToggle) onSaveToggle(updated);
275
- } catch (err) {
276
- E.toast(err.message, {type: 'error'});
277
- }
278
- });
279
-
280
- body.appendChild(saveBtn);
281
- card.appendChild(body);
282
- return card;
283
- }
284
-
285
- // ---------------------------------------------------------------
286
- // Lookup
287
- // ---------------------------------------------------------------
288
-
289
- function setLookupError(msg) {
290
- if (msg) {
291
- lookupError.textContent = msg;
292
- lookupError.style.display = '';
293
- } else {
294
- lookupError.style.display = 'none';
295
- }
296
- }
297
-
298
- async function doLookup() {
299
- const reg = (regInput.value || '').trim();
300
- if (!reg) {
301
- regInput.focus();
302
- return;
303
- }
304
-
305
- setLookupError(null);
306
- rateLimitInfo.textContent = '';
307
- lookupBtn.disabled = true;
308
- lookupBtn.textContent = 'Looking up…';
309
-
310
- try {
311
- const vehicle = await api(`${BASE}/lookup`, 'POST', {registrationNumber: reg});
312
-
313
- // Show vehicle card in Results tab
314
- while (vehicleResult.firstChild) vehicleResult.removeChild(vehicleResult.firstChild);
315
- vehicleResult.appendChild(buildVehicleCard(vehicle, async () => {
316
- await loadSaved();
317
- await loadHistory();
318
- }));
319
- vehicleResult.style.display = '';
320
- resultEmpty.style.display = 'none';
321
- Domma.icons.scan();
322
-
323
- loadHistory().catch(() => {
324
- });
325
-
326
- } catch (err) {
327
- if (err.message && err.message.toLowerCase().includes('rate limit')) {
328
- rateLimitInfo.textContent = err.message;
329
- } else {
330
- setLookupError(err.message);
331
- }
332
- } finally {
333
- lookupBtn.disabled = false;
334
- const icon = document.createElement('span');
335
- icon.setAttribute('data-icon', 'search');
336
- lookupBtn.textContent = '';
337
- lookupBtn.appendChild(icon);
338
- lookupBtn.appendChild(document.createTextNode(' Look Up'));
339
- Domma.icons.scan();
340
- }
341
- }
342
-
343
- lookupBtn.addEventListener('click', doLookup);
344
- regInput.addEventListener('keydown', (e) => {
345
- if (e.key === 'Enter') doLookup();
346
- });
347
- regInput.addEventListener('input', () => {
348
- const pos = regInput.selectionStart;
349
- regInput.value = regInput.value.toUpperCase();
350
- regInput.setSelectionRange(pos, pos);
351
- });
352
-
353
- // ---------------------------------------------------------------
354
- // My Garage (saved vehicles)
355
- // ---------------------------------------------------------------
356
-
357
- async function loadSaved() {
358
- try {
359
- savedVehicles = await api(`${BASE}/vehicles`);
360
- } catch (err) {
361
- E.toast('Could not load saved vehicles: ' + err.message, {type: 'error'});
362
- savedVehicles = [];
363
- }
364
-
365
- try {
366
- const empty = savedVehicles.length === 0;
367
- garageEmpty.style.display = empty ? '' : 'none';
368
- garageTableEl.style.display = empty ? 'none' : '';
369
- garageCount.textContent = savedVehicles.length
370
- ? `${savedVehicles.length} vehicle${savedVehicles.length === 1 ? '' : 's'}`
371
- : '';
372
- if (tabBtnGarage) tabBtnGarage.innerHTML = savedVehicles.length
373
- ? `My Garage <span class="badge badge-primary" style="margin-left:0.35rem;">${savedVehicles.length}</span>`
374
- : 'My Garage';
375
-
376
- if (!empty) {
377
- T.create('#garage-table', {
378
- data: savedVehicles,
379
- columns: [
380
- {key: 'registrationNumber', title: 'Registration', render: (v) => plateHtml(v)},
381
- {key: 'make', title: 'Make', render: (v) => esc(v)},
382
- {key: 'colour', title: 'Colour', render: (v) => esc(v)},
383
- {key: 'yearOfManufacture', title: 'Year', render: (v) => esc(v)},
384
- {
385
- key: 'motStatus', title: 'MOT',
386
- render: (v, row) => badgeHtml(v, motBadgeCls(v)) +
387
- (row && row.motExpiryDate ? `<br><span style="font-size:0.78rem;color:var(--dm-text-muted,#888);">Exp ${formatDate(row.motExpiryDate)}</span>` : '')
388
- },
389
- {
390
- key: 'taxStatus', title: 'Road Tax',
391
- render: (v, row) => badgeHtml(v, taxBadgeCls(v)) +
392
- (row && row.taxDueDate ? `<br><span style="font-size:0.78rem;color:var(--dm-text-muted,#888);">Due ${formatDate(row.taxDueDate)}</span>` : '')
393
- },
394
- {
395
- key: 'id', title: 'Actions',
396
- render: (id) => `<button class="btn btn-xs btn-outline garage-unsave-btn" data-id="${esc(id)}" title="Remove from Garage"><span data-icon="bookmark-minus"></span> Remove</button>`
397
- }
398
- ]
399
- });
400
- Domma.icons.scan();
401
- }
402
- } catch (err) {
403
- console.error('[garage] loadSaved render error:', err);
404
- }
405
- }
406
-
407
- garageTableEl.addEventListener('click', async (e) => {
408
- const unsaveBtn = e.target.closest('.garage-unsave-btn');
409
- if (!unsaveBtn) return;
410
- const id = unsaveBtn.dataset.id;
411
- try {
412
- await api(`${BASE}/vehicles/${id}/save`, 'PATCH');
413
- E.toast('Removed from Garage', {type: 'success'});
414
- await loadSaved();
415
- await loadHistory();
416
- } catch (err) {
417
- E.toast(err.message, {type: 'error'});
418
- }
419
- });
420
-
421
- // ---------------------------------------------------------------
422
- // History
423
- // ---------------------------------------------------------------
424
-
425
- async function loadHistory() {
426
- try {
427
- historyVehicles = await api(`${BASE}/history`);
428
- } catch (err) {
429
- E.toast('Could not load history: ' + err.message, {type: 'error'});
430
- historyVehicles = [];
431
- }
432
-
433
- try {
434
- const empty = historyVehicles.length === 0;
435
- historyEmpty.style.display = empty ? '' : 'none';
436
- historyTableEl.style.display = empty ? 'none' : '';
437
- historyCount.textContent = historyVehicles.length
438
- ? `${historyVehicles.length} lookup${historyVehicles.length === 1 ? '' : 's'}`
439
- : '';
440
- if (tabBtnHistory) tabBtnHistory.innerHTML = historyVehicles.length
441
- ? `History <span class="badge badge-secondary" style="margin-left:0.35rem;">${historyVehicles.length}</span>`
442
- : 'History';
443
-
444
- if (!empty) {
445
- T.create('#history-table', {
446
- data: historyVehicles,
447
- columns: [
448
- {key: 'registrationNumber', title: 'Registration', render: (v) => plateHtml(v)},
449
- {key: 'make', title: 'Make', render: (v) => esc(v)},
450
- {key: 'yearOfManufacture', title: 'Year', render: (v) => esc(v)},
451
- {
452
- key: 'lookupDate', title: 'Last Checked',
453
- render: (v) => {
454
- if (!v) return '—';
455
- try {
456
- return esc(D(v).fromNow());
457
- } catch {
458
- return esc(v);
459
- }
460
- }
461
- },
462
- {
463
- key: 'motStatus', title: 'MOT',
464
- render: (v, row) => badgeHtml(v, motBadgeCls(v)) +
465
- (row && row.motExpiryDate ? `<br><span style="font-size:0.78rem;color:var(--dm-text-muted,#888);">Exp ${formatDate(row.motExpiryDate)}</span>` : '')
466
- },
467
- {
468
- key: 'taxStatus', title: 'Road Tax',
469
- render: (v, row) => badgeHtml(v, taxBadgeCls(v)) +
470
- (row && row.taxDueDate ? `<br><span style="font-size:0.78rem;color:var(--dm-text-muted,#888);">Due ${formatDate(row.taxDueDate)}</span>` : '')
471
- },
472
- {
473
- key: 'isSaved', title: 'In Garage',
474
- render: (v) => v
475
- ? '<span class="badge badge-success">Saved</span>'
476
- : '<span class="badge badge-secondary">No</span>'
477
- },
478
- {
479
- key: 'id', title: 'Actions',
480
- render: (id, row) => {
481
- const saved = row && row.isSaved;
482
- return `<div style="display:flex;gap:0.4rem;">
483
- <button class="btn btn-xs ${saved ? 'btn-warning' : 'btn-outline'} history-save-btn" data-id="${esc(id)}" title="${saved ? 'Remove from Garage' : 'Save to Garage'}">
484
- <span data-icon="${saved ? 'bookmark-minus' : 'bookmark-plus'}"></span>
485
- ${saved ? 'Remove' : 'Save'}
486
- </button>
487
- <button class="btn btn-xs btn-danger history-delete-btn" data-id="${esc(id)}" title="Delete">
488
- <span data-icon="trash"></span>
489
- </button>
490
- </div>`;
491
- }
492
- }
493
- ]
494
- });
495
- Domma.icons.scan();
496
- }
497
- } catch (err) {
498
- console.error('[garage] loadHistory render error:', err);
499
- }
500
- }
501
-
502
- historyTableEl.addEventListener('click', async (e) => {
503
- const saveBtn = e.target.closest('.history-save-btn');
504
- const deleteBtn = e.target.closest('.history-delete-btn');
505
- if (!saveBtn && !deleteBtn) return;
506
- const id = (saveBtn || deleteBtn).dataset.id;
507
- if (saveBtn) {
508
- try {
509
- const updated = await api(`${BASE}/vehicles/${id}/save`, 'PATCH');
510
- E.toast(updated.isSaved ? 'Saved to Garage' : 'Removed from Garage', {type: 'success'});
511
- await loadSaved();
512
- await loadHistory();
513
- } catch (err) {
514
- E.toast(err.message, {type: 'error'});
515
- }
516
- }
517
- if (deleteBtn) {
518
- const confirmed = await E.confirm('Delete this lookup from your history?');
519
- if (!confirmed) return;
520
- try {
521
- await api(`${BASE}/vehicles/${id}`, 'DELETE');
522
- E.toast('Deleted', {type: 'success'});
523
- await loadSaved();
524
- await loadHistory();
525
- } catch (err) {
526
- E.toast(err.message, {type: 'error'});
527
- }
528
- }
529
- });
530
-
531
- // ---------------------------------------------------------------
532
- // Clear History
533
- // ---------------------------------------------------------------
534
-
535
- clearHistoryBtn.addEventListener('click', async () => {
536
- const unsaved = historyVehicles.filter(v => !v.isSaved);
537
- if (unsaved.length === 0) {
538
- E.toast('No unsaved history to clear — saved vehicles are kept.', {type: 'info'});
539
- return;
540
- }
541
- const confirmed = await E.confirm(
542
- `Delete ${unsaved.length} unsaved lookup${unsaved.length === 1 ? '' : 's'} from history? Saved vehicles will not be affected.`
543
- );
544
- if (!confirmed) return;
545
-
546
- let deleted = 0;
547
- for (const v of unsaved) {
548
- try {
549
- await api(`${BASE}/vehicles/${v.id}`, 'DELETE');
550
- deleted++;
551
- } catch { /* continue */
552
- }
553
- }
554
- E.toast(`Cleared ${deleted} history item${deleted === 1 ? '' : 's'}.`, {type: 'success'});
555
- await loadHistory();
556
- });
557
-
558
- // ---------------------------------------------------------------
559
- // Settings
560
- // ---------------------------------------------------------------
561
-
562
- let currentSettings = {};
563
-
564
- async function loadSettings() {
565
- try {
566
- const plugins = await api('/api/plugins');
567
- const garage = plugins.find(p => p.name === 'garage');
568
- currentSettings = garage?.settings || {};
569
- const hasKey = !!(currentSettings.dvlaApiKey);
570
- const badge = document.createElement('span');
571
- badge.className = hasKey ? 'badge badge-success' : 'badge badge-warning';
572
- badge.textContent = hasKey
573
- ? 'API key configured'
574
- : 'No API key — lookups will fail until one is saved';
575
- settingsKeyStatus.textContent = '';
576
- settingsKeyStatus.appendChild(badge);
577
- } catch (err) {
578
- settingsKeyStatus.textContent = 'Could not load settings.';
579
- }
580
- }
581
-
582
- settingsSaveBtn.addEventListener('click', async () => {
583
- const key = (settingsApiKeyInput.value || '').trim();
584
- if (!key) {
585
- E.toast('Please enter an API key.', {type: 'warning'});
586
- return;
587
- }
588
-
589
- settingsSaveBtn.disabled = true;
590
- try {
591
- await api('/api/plugins/garage', 'PUT', {
592
- enabled: true,
593
- settings: {...currentSettings, dvlaApiKey: key}
594
- });
595
- currentSettings.dvlaApiKey = key;
596
- settingsApiKeyInput.value = '';
597
- const badge = document.createElement('span');
598
- badge.className = 'badge badge-success';
599
- badge.textContent = 'API key configured';
600
- settingsKeyStatus.textContent = '';
601
- settingsKeyStatus.appendChild(badge);
602
- E.toast('Settings saved.', {type: 'success'});
603
- } catch (err) {
604
- E.toast('Failed to save: ' + err.message, {type: 'error'});
605
- } finally {
606
- settingsSaveBtn.disabled = false;
607
- const icon = document.createElement('span');
608
- icon.setAttribute('data-icon', 'save');
609
- settingsSaveBtn.textContent = '';
610
- settingsSaveBtn.appendChild(icon);
611
- settingsSaveBtn.appendChild(document.createTextNode(' Save'));
612
- Domma.icons.scan();
613
- }
614
- });
615
-
616
- // ---------------------------------------------------------------
617
- // Initial load
618
- // ---------------------------------------------------------------
619
- await Promise.all([loadSaved(), loadHistory(), loadSettings()]);
620
- Domma.icons.scan();
621
- }
622
- };