domma-cms 1.6.37 → 1.6.39
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.
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
- package/plugins/_lib/admin/mail/reader-view.js +126 -13
- package/plugins/_lib/mail/accounts.js +548 -225
- package/plugins/_lib/mail/imapPool.js +63 -13
- package/plugins/_lib/mail/oauth.js +453 -0
- package/plugins/_lib/mail/oauthRoutes.js +294 -0
- package/plugins/_lib/mail/readRoutes.js +23 -2
- package/plugins/_lib/mail/scheduler.js +30 -6
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/lib/cache.js +43 -0
- package/plugins/blog/lib/page.js +28 -9
- package/plugins/blog/plugin.js +14 -13
- package/plugins/blog/plugin.json +1 -1
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/cache.test.js +65 -0
- package/plugins/booking/CLAUDE.md +14 -2
- package/plugins/booking/admin/lib/diary-layout.js +18 -0
- package/plugins/booking/admin/views/diary.js +153 -152
- package/plugins/booking/admin/views/people.js +64 -66
- package/plugins/booking/admin/views/settings.js +31 -32
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/tests/admin-diary.test.js +9 -1
- package/plugins/feedback/CLAUDE.md +62 -0
- package/plugins/feedback/admin/css/index.css +16 -1
- package/plugins/feedback/admin/lib/kit.js +106 -0
- package/plugins/feedback/admin/lib/settings.js +107 -0
- package/plugins/feedback/admin/views/feedback.js +4 -1
- package/plugins/feedback/admin/views/inbox.js +11 -2
- package/plugins/feedback/admin/views/send.js +74 -7
- package/plugins/feedback/docs/guide.md +9 -9
- package/plugins/feedback/lib/attachments.js +133 -0
- package/plugins/feedback/lib/cache.js +43 -0
- package/plugins/feedback/lib/receiver.js +36 -6
- package/plugins/feedback/lib/sender.js +89 -6
- package/plugins/feedback/lib/shape.js +111 -6
- package/plugins/feedback/plugin.js +173 -17
- package/plugins/feedback/plugin.json +2 -2
- package/plugins/feedback/tests/api.test.js +125 -3
- package/plugins/feedback/tests/settings.test.js +59 -0
- package/plugins/feedback/tests/shape.test.js +87 -1
- package/plugins/free-tier.lock.json +74 -62
- package/plugins/mail-reader/CLAUDE.md +15 -4
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/shopping-cart/CLAUDE.md +35 -2
- package/plugins/shopping-cart/admin/css/index.css +3 -0
- package/plugins/shopping-cart/admin/views/orders.js +75 -23
- package/plugins/shopping-cart/admin/views/shop.js +1 -1
- package/plugins/shopping-cart/docs/guide.md +7 -3
- package/plugins/shopping-cart/lib/payments.js +15 -2
- package/plugins/shopping-cart/lib/refunds.js +51 -0
- package/plugins/shopping-cart/lib/sales.js +15 -8
- package/plugins/shopping-cart/lib/shop.js +38 -3
- package/plugins/shopping-cart/plugin.js +47 -19
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/tests/api.test.js +52 -0
- package/plugins/shopping-cart/tests/commission.test.js +1 -1
- package/plugins/shopping-cart/tests/pure.test.js +16 -0
- package/plugins/shopping-cart/tests/refunds.test.js +42 -0
- package/plugins/support/CLAUDE.md +58 -0
- package/plugins/support/admin/css/index.css +16 -1
- package/plugins/support/admin/lib/kit.js +192 -8
- package/plugins/support/admin/lib/settings.js +123 -0
- package/plugins/support/admin/views/desk.js +55 -12
- package/plugins/support/admin/views/inbox.js +17 -4
- package/plugins/support/admin/views/support.js +4 -1
- package/plugins/support/docs/guide.md +7 -9
- package/plugins/support/lib/attachments.js +133 -0
- package/plugins/support/lib/cache.js +43 -0
- package/plugins/support/lib/receiver.js +45 -10
- package/plugins/support/lib/sender.js +96 -10
- package/plugins/support/lib/shape.js +153 -12
- package/plugins/support/plugin.js +159 -29
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/api.test.js +125 -3
- package/plugins/support/tests/settings.test.js +68 -0
- package/plugins/support/tests/shape.test.js +76 -2
- package/server/routes/outreach-public.js +2 -2
- package/server/services/request-log.js +3 -1
|
@@ -22,6 +22,31 @@ const ROW = `
|
|
|
22
22
|
<button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
23
23
|
</div>`;
|
|
24
24
|
|
|
25
|
+
// One weekday in the editor: its windows (a keyed list of their own) or "Closed".
|
|
26
|
+
const DAY_ROW = `
|
|
27
|
+
<div class="bk-day">
|
|
28
|
+
<span class="bk-day-name" data-bind-text="name"></span>
|
|
29
|
+
<div class="bk-windows">
|
|
30
|
+
<span class="bk-muted" data-bind-hidden="windows.value.length">Closed</span>
|
|
31
|
+
{{#each windows.value key=id}}<span class="bk-window">
|
|
32
|
+
<input type="time" class="form-input" step="300" data-model="from.value" data-bind-aria-label="$parent.name + ' from'">
|
|
33
|
+
<span>-</span>
|
|
34
|
+
<input type="time" class="form-input" step="300" data-model="to.value" data-bind-aria-label="$parent.name + ' to'">
|
|
35
|
+
<button type="button" class="btn btn-xs btn-ghost bk-x" title="Remove these hours" aria-label="Remove these hours" data-on-click="$root.dropWindow($parent, $data)"><span data-icon="x" data-icon-size="12"></span></button></span>{{/each}}
|
|
36
|
+
</div>
|
|
37
|
+
<button type="button" class="btn btn-xs btn-ghost" data-bind-title="'Add hours on ' + name" data-on-click="$parent.addWindow($data)"><span data-icon="plus" data-icon-size="12"></span> Hours</button>
|
|
38
|
+
</div>`;
|
|
39
|
+
|
|
40
|
+
// One spell of days off.
|
|
41
|
+
const OFF_ROW = `
|
|
42
|
+
<div class="bk-off">
|
|
43
|
+
<input type="date" class="form-input" data-model="from.value" aria-label="From" data-on-change="$parent.offFrom($data)">
|
|
44
|
+
<span>to</span>
|
|
45
|
+
<input type="date" class="form-input" data-model="to.value" aria-label="To">
|
|
46
|
+
<input class="form-input" data-model="label.value" placeholder="Holiday, training…" maxlength="80" aria-label="Why">
|
|
47
|
+
<button type="button" class="btn btn-xs btn-ghost bk-x" title="Remove" aria-label="Remove" data-on-click="$parent.dropOff($data)"><span data-icon="x" data-icon-size="12"></span></button>
|
|
48
|
+
</div>`;
|
|
49
|
+
|
|
25
50
|
/** "Mon-Fri 09:00-17:00" style summary of weekly hours. */
|
|
26
51
|
export function hoursSummary(hours) {
|
|
27
52
|
const txt = (d) => (hours?.[d] || []).map(w => `${w.from}-${w.to}`).join(', ');
|
|
@@ -123,43 +148,43 @@ export async function mount(root, ctx) {
|
|
|
123
148
|
const f = {};
|
|
124
149
|
for (const k of ['name', 'kind', 'description', 'userId', 'email', 'colour']) f[k] = M.observable(start[k] ?? '');
|
|
125
150
|
const bools = {calendarBusy: M.observable(start.calendarBusy !== false), active: M.observable(start.active !== false)};
|
|
126
|
-
|
|
127
|
-
|
|
151
|
+
// Hours and days off are keyed lists (see DAY_ROW / OFF_ROW): a day's
|
|
152
|
+
// windows live in an observable so adding or removing one redraws only
|
|
153
|
+
// that day; each field is an observable the inputs write through.
|
|
154
|
+
let seq = 0;
|
|
155
|
+
const windowRow = (w) => ({id: `w${++seq}`, from: M.observable(w.from || ''), to: M.observable(w.to || '')});
|
|
156
|
+
const offRow = (o) => ({id: `o${++seq}`, from: M.observable(o.from || ''), to: M.observable(o.to || ''), label: M.observable(o.label || '')});
|
|
157
|
+
const days = M.observable(WEEK.map(d => ({id: d, name: DAY_NAMES[d], windows: M.observable((start.hours?.[d] || []).map(windowRow))})));
|
|
158
|
+
const daysOff = M.observable((start.daysOff || []).map(offRow));
|
|
159
|
+
const hoursOut = () => Object.fromEntries(days.peek().map(d => [d.id, d.windows.peek().map(w => ({from: w.from.peek(), to: w.to.peek()}))]));
|
|
160
|
+
const daysOffOut = () => daysOff.peek().map(o => ({from: o.from.peek(), to: o.to.peek(), label: o.label.peek()}));
|
|
128
161
|
const isPerson = M.computed(() => f.kind.value === 'person');
|
|
129
162
|
const saving = M.observable(false);
|
|
130
|
-
const snapshot = () => JSON.stringify([Object.values(f).map(o => o.peek()), Object.values(bools).map(o => o.peek()),
|
|
163
|
+
const snapshot = () => JSON.stringify([Object.values(f).map(o => o.peek()), Object.values(bools).map(o => o.peek()), hoursOut(), daysOffOut()]);
|
|
131
164
|
const opened = snapshot();
|
|
132
165
|
let panel = null;
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
const box = el.querySelector('[data-part="hours"]');
|
|
136
|
-
box.innerHTML = WEEK.map(d => `<div class="bk-day" data-day="${d}">
|
|
137
|
-
<span class="bk-day-name">${DAY_NAMES[d]}</span>
|
|
138
|
-
<div class="bk-windows">${hours[d].length ? hours[d].map((w, i) => `<span class="bk-window">
|
|
139
|
-
<input type="time" class="form-input" step="300" value="${esc(w.from)}" data-i="${i}" data-k="from" aria-label="${DAY_NAMES[d]} from">
|
|
140
|
-
<span>-</span>
|
|
141
|
-
<input type="time" class="form-input" step="300" value="${esc(w.to)}" data-i="${i}" data-k="to" aria-label="${DAY_NAMES[d]} to">
|
|
142
|
-
<button type="button" class="btn btn-xs btn-ghost bk-x" data-drop="${i}" title="Remove these hours" aria-label="Remove these hours"><span data-icon="x" data-icon-size="12"></span></button></span>`).join('') : '<span class="bk-muted">Closed</span>'}</div>
|
|
143
|
-
<button type="button" class="btn btn-xs btn-ghost" data-add title="Add hours on ${DAY_NAMES[d]}"><span data-icon="plus" data-icon-size="12"></span> Hours</button>
|
|
144
|
-
</div>`).join('');
|
|
145
|
-
I.scan(box);
|
|
146
|
-
};
|
|
147
|
-
const drawDaysOff = (el) => {
|
|
148
|
-
const box = el.querySelector('[data-part="daysoff"]');
|
|
149
|
-
box.innerHTML = daysOff.length ? daysOff.map((o, i) => `<div class="bk-off" data-i="${i}">
|
|
150
|
-
<input type="date" class="form-input" value="${esc(o.from)}" data-k="from" aria-label="From">
|
|
151
|
-
<span>to</span>
|
|
152
|
-
<input type="date" class="form-input" value="${esc(o.to)}" data-k="to" aria-label="To">
|
|
153
|
-
<input class="form-input" value="${esc(o.label)}" data-k="label" placeholder="Holiday, training…" maxlength="80" aria-label="Why">
|
|
154
|
-
<button type="button" class="btn btn-xs btn-ghost bk-x" data-drop title="Remove" aria-label="Remove"><span data-icon="x" data-icon-size="12"></span></button></div>`).join('')
|
|
155
|
-
: '<p class="bk-note">None. Add holidays and other days they cannot be booked.</p>';
|
|
156
|
-
I.scan(box);
|
|
157
|
-
};
|
|
166
|
+
// New rows carry icons; they are drawn once the list has rendered.
|
|
167
|
+
const rescan = () => { M.flushSync?.(); if (panel) { I.scan(panel.panel); requestAnimationFrame(() => panel?.panel.isConnected && I.scan(panel.panel)); } };
|
|
158
168
|
|
|
159
169
|
panel = await openPanel({
|
|
160
170
|
title: b ? `Edit ${b.name}` : 'Add a person or room',
|
|
161
171
|
size: 'lg',
|
|
162
|
-
model: {
|
|
172
|
+
model: {
|
|
173
|
+
...f, saving, isPerson, days, daysOff,
|
|
174
|
+
addWindow(day) {
|
|
175
|
+
const list = day.windows.peek();
|
|
176
|
+
const last = list[list.length - 1];
|
|
177
|
+
day.windows.value = [...list, windowRow(last ? {from: last.to.peek(), to: last.to.peek() < '17:00' ? '17:00' : last.to.peek()} : {from: '09:00', to: '17:00'})];
|
|
178
|
+
rescan();
|
|
179
|
+
},
|
|
180
|
+
dropWindow(day, w) { day.windows.value = day.windows.peek().filter(x => x !== w); },
|
|
181
|
+
dropOff(o) { daysOff.value = daysOff.peek().filter(x => x !== o); },
|
|
182
|
+
// An end before the start is never meant: it follows the start.
|
|
183
|
+
offFrom(o, e) {
|
|
184
|
+
const v = e?.target?.value ?? o.from.peek();
|
|
185
|
+
if (!o.to.peek() || o.to.peek() < v) o.to.value = v;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
163
188
|
dirty: () => snapshot() !== opened,
|
|
164
189
|
html: `<div class="bk-form">
|
|
165
190
|
<div class="bk-grid">
|
|
@@ -182,10 +207,11 @@ export async function mount(root, ctx) {
|
|
|
182
207
|
</div>
|
|
183
208
|
</div>
|
|
184
209
|
<h4 class="bk-sub">Opening hours <span data-help-title="Opening hours" data-help="When they can be booked, in ${esc(ctx.tz)}. Add several windows for a day with a break (09:00-12:30, 13:30-17:00). A day with no hours is closed."></span></h4>
|
|
185
|
-
<div class="bk-hours" data-
|
|
210
|
+
<div class="bk-hours" data-each="days.value key=id">${DAY_ROW}</div>
|
|
186
211
|
<div class="bk-actions bk-actions-start"><button type="button" class="btn btn-xs btn-outline" data-on-click="copyMonday"><span data-icon="copy" data-icon-size="12"></span> Copy Monday to Tuesday-Friday</button></div>
|
|
187
212
|
<h4 class="bk-sub">Days off <span data-help-title="Days off" data-help="Holidays and other days they cannot be booked. For days the whole business is closed, use Closed days in Booking settings."></span></h4>
|
|
188
|
-
<
|
|
213
|
+
<p class="bk-note" data-bind-hidden="daysOff.value.length">None. Add holidays and other days they cannot be booked.</p>
|
|
214
|
+
<div data-each="daysOff.value key=id">${OFF_ROW}</div>
|
|
189
215
|
<div class="bk-actions bk-actions-start"><button type="button" class="btn btn-xs btn-outline" data-on-click="addOff"><span data-icon="plus" data-icon-size="12"></span> Add days off</button></div>
|
|
190
216
|
<div class="bk-actions"><span class="bk-spacer"></span>
|
|
191
217
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
@@ -196,52 +222,24 @@ export async function mount(root, ctx) {
|
|
|
196
222
|
box.checked = bools[box.dataset.bool].peek();
|
|
197
223
|
box.addEventListener('change', () => { bools[box.dataset.bool].value = box.checked; });
|
|
198
224
|
}
|
|
199
|
-
drawHours(el);
|
|
200
|
-
drawDaysOff(el);
|
|
201
|
-
const hoursBox = el.querySelector('[data-part="hours"]');
|
|
202
|
-
hoursBox.addEventListener('input', (e) => {
|
|
203
|
-
const day = e.target.closest('[data-day]')?.dataset.day;
|
|
204
|
-
if (!day || e.target.dataset.k === undefined) return;
|
|
205
|
-
hours[day][Number(e.target.dataset.i)][e.target.dataset.k] = e.target.value;
|
|
206
|
-
});
|
|
207
|
-
hoursBox.addEventListener('click', (e) => {
|
|
208
|
-
const day = e.target.closest('[data-day]')?.dataset.day;
|
|
209
|
-
if (!day) return;
|
|
210
|
-
const drop = e.target.closest('[data-drop]');
|
|
211
|
-
if (drop) { hours[day].splice(Number(drop.dataset.drop), 1); drawHours(el); return; }
|
|
212
|
-
if (e.target.closest('[data-add]')) {
|
|
213
|
-
const last = hours[day][hours[day].length - 1];
|
|
214
|
-
hours[day].push(last ? {from: last.to, to: last.to < '17:00' ? '17:00' : last.to} : {from: '09:00', to: '17:00'});
|
|
215
|
-
drawHours(el);
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
const offBox = el.querySelector('[data-part="daysoff"]');
|
|
219
|
-
offBox.addEventListener('input', (e) => {
|
|
220
|
-
const row = e.target.closest('[data-i]');
|
|
221
|
-
if (!row) return;
|
|
222
|
-
const o = daysOff[Number(row.dataset.i)];
|
|
223
|
-
o[e.target.dataset.k] = e.target.value;
|
|
224
|
-
if (e.target.dataset.k === 'from' && (!o.to || o.to < o.from)) { o.to = o.from; row.querySelector('[data-k="to"]').value = o.from; }
|
|
225
|
-
});
|
|
226
|
-
offBox.addEventListener('click', (e) => {
|
|
227
|
-
const row = e.target.closest('[data-i]');
|
|
228
|
-
if (row && e.target.closest('[data-drop]')) { daysOff.splice(Number(row.dataset.i), 1); drawDaysOff(el); }
|
|
229
|
-
});
|
|
230
225
|
},
|
|
231
226
|
after: (el) => setTimeout(() => el.querySelector('input')?.focus(), 60),
|
|
232
227
|
methods: {
|
|
233
228
|
cancel: () => panel?.close(),
|
|
234
229
|
copyMonday() {
|
|
235
|
-
|
|
236
|
-
|
|
230
|
+
const byId = Object.fromEntries(days.peek().map(d => [d.id, d]));
|
|
231
|
+
for (const d of ['tue', 'wed', 'thu', 'fri']) byId[d].windows.value = byId.mon.windows.peek().map(w => windowRow({from: w.from.peek(), to: w.to.peek()}));
|
|
232
|
+
rescan();
|
|
237
233
|
},
|
|
238
234
|
addOff() {
|
|
239
|
-
daysOff = [...daysOff, {from: boot.today, to: boot.today, label: ''}];
|
|
240
|
-
|
|
235
|
+
daysOff.value = [...daysOff.peek(), offRow({from: boot.today, to: boot.today, label: ''})];
|
|
236
|
+
rescan();
|
|
241
237
|
},
|
|
242
238
|
async save() {
|
|
243
239
|
if (saving.peek()) return;
|
|
244
240
|
// Say which hours are wrong rather than let the server drop them quietly.
|
|
241
|
+
const hours = hoursOut();
|
|
242
|
+
const daysOff = daysOffOut();
|
|
245
243
|
for (const d of DAY_KEYS) {
|
|
246
244
|
for (const w of hours[d]) {
|
|
247
245
|
if (!(toMinutes(w.to) > toMinutes(w.from))) { E.toast(`${DAY_NAMES[d]}: ${w.from || '?'}-${w.to || '?'} - the end must be after the start.`, {type: 'warning'}); return; }
|
|
@@ -15,6 +15,15 @@ const SECTIONS = [
|
|
|
15
15
|
{key: 'tools', label: 'Other Tools', icon: 'plug'},
|
|
16
16
|
{key: 'closed', label: 'Closed days', icon: 'ban'}
|
|
17
17
|
];
|
|
18
|
+
// One spell of closed days.
|
|
19
|
+
const CLOSURE_ROW = `
|
|
20
|
+
<div class="bk-off">
|
|
21
|
+
<input type="date" class="form-input" data-model="from.value" aria-label="From" data-on-change="$parent.closureFrom($data)"><span>to</span>
|
|
22
|
+
<input type="date" class="form-input" data-model="to.value" aria-label="To">
|
|
23
|
+
<input class="form-input" data-model="label.value" placeholder="Bank holiday…" maxlength="80" aria-label="Why">
|
|
24
|
+
<button type="button" class="btn btn-xs btn-ghost bk-x" title="Remove" aria-label="Remove" data-on-click="$parent.dropClosure($data)"><span data-icon="x" data-icon-size="12"></span></button>
|
|
25
|
+
</div>`;
|
|
26
|
+
|
|
18
27
|
const BOOL_KEYS = ['askPhone', 'phoneRequired', 'allowCancel', 'allowMove', 'sendConfirmation', 'addToContacts', 'calendarBusy'];
|
|
19
28
|
const FIELD_SECTION = {pageTitle: 'page', bookingPath: 'page', timeZone: 'page', termsUrl: 'page', notifyAddress: 'emails'};
|
|
20
29
|
|
|
@@ -32,13 +41,26 @@ export async function openSettings(ctx, section = 'page') {
|
|
|
32
41
|
const s = info.settings;
|
|
33
42
|
const f = {};
|
|
34
43
|
for (const [k, v] of Object.entries(s)) if (k !== 'closures') f[k] = M.observable(v);
|
|
35
|
-
|
|
44
|
+
// Closed days are a keyed list (CLOSURE_ROW); each field is an observable the inputs write through.
|
|
45
|
+
let seq = 0;
|
|
46
|
+
const closureRow = (c) => ({id: `c${++seq}`, from: M.observable(c.from || ''), to: M.observable(c.to || ''), label: M.observable(c.label || '')});
|
|
47
|
+
const closures = M.observable((s.closures || []).map(closureRow));
|
|
48
|
+
const closuresOut = () => closures.peek().map(o => ({from: o.from.peek(), to: o.to.peek(), label: o.label.peek()}));
|
|
36
49
|
const tab = M.observable(SECTIONS.some(x => x.key === section) ? section : 'page');
|
|
37
50
|
const saving = M.observable(false);
|
|
38
|
-
const snapshot = () => JSON.stringify([Object.entries(f).map(([k, o]) => [k, o.peek()]),
|
|
51
|
+
const snapshot = () => JSON.stringify([Object.entries(f).map(([k, o]) => [k, o.peek()]), closuresOut()]);
|
|
39
52
|
const opened = snapshot();
|
|
53
|
+
let panel = null;
|
|
54
|
+
// New rows carry icons; they are drawn once the list has rendered.
|
|
55
|
+
const rescan = () => { M.flushSync?.(); if (panel) { I.scan(panel.panel); requestAnimationFrame(() => panel?.panel.isConnected && I.scan(panel.panel)); } };
|
|
40
56
|
const model = {
|
|
41
|
-
...f, tab, saving,
|
|
57
|
+
...f, tab, saving, closures,
|
|
58
|
+
dropClosure(o) { closures.value = closures.peek().filter(x => x !== o); },
|
|
59
|
+
// An end before the start is never meant: it follows the start.
|
|
60
|
+
closureFrom(o, e) {
|
|
61
|
+
const v = e?.target?.value ?? o.from.peek();
|
|
62
|
+
if (!o.to.peek() || o.to.peek() < v) o.to.value = v;
|
|
63
|
+
},
|
|
42
64
|
pageUrl: M.computed(() => `${location.origin}${f.bookingPath.value || '/booking'}`),
|
|
43
65
|
noPhone: M.computed(() => !f.askPhone.value),
|
|
44
66
|
remindText: M.computed(() => (Number(f.reminderHours.value) > 0 ? `${f.reminderHours.value} hours before` : 'off'))
|
|
@@ -110,7 +132,8 @@ export async function openSettings(ctx, section = 'page') {
|
|
|
110
132
|
|
|
111
133
|
<section data-bind-hidden="!is_closed.value">
|
|
112
134
|
<p class="bk-note">Days the whole business is closed - bank holidays, a closure. Nobody can be booked. (For one person's holiday, use their days off.)</p>
|
|
113
|
-
<
|
|
135
|
+
<p class="bk-note" data-bind-hidden="closures.value.length">None yet.</p>
|
|
136
|
+
<div data-each="closures.value key=id">${CLOSURE_ROW}</div>
|
|
114
137
|
<div class="bk-actions bk-actions-start"><button type="button" class="btn btn-xs btn-outline" data-on-click="addClosure"><span data-icon="plus" data-icon-size="12"></span> Add closed days</button></div>
|
|
115
138
|
</section>
|
|
116
139
|
|
|
@@ -121,18 +144,6 @@ export async function openSettings(ctx, section = 'page') {
|
|
|
121
144
|
</div>
|
|
122
145
|
</div>`;
|
|
123
146
|
|
|
124
|
-
const drawClosures = (el) => {
|
|
125
|
-
const box = el.querySelector('[data-part="closures"]');
|
|
126
|
-
box.innerHTML = closures.length ? closures.map((o, i) => `<div class="bk-off" data-i="${i}">
|
|
127
|
-
<input type="date" class="form-input" value="${esc(o.from)}" data-k="from" aria-label="From"><span>to</span>
|
|
128
|
-
<input type="date" class="form-input" value="${esc(o.to)}" data-k="to" aria-label="To">
|
|
129
|
-
<input class="form-input" value="${esc(o.label)}" data-k="label" placeholder="Bank holiday…" maxlength="80" aria-label="Why">
|
|
130
|
-
<button type="button" class="btn btn-xs btn-ghost bk-x" data-drop title="Remove" aria-label="Remove"><span data-icon="x" data-icon-size="12"></span></button></div>`).join('')
|
|
131
|
-
: '<p class="bk-note">None yet.</p>';
|
|
132
|
-
I.scan(box);
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
let panel = null;
|
|
136
147
|
panel = await openPanel({
|
|
137
148
|
title: 'Booking settings',
|
|
138
149
|
size: 'md',
|
|
@@ -148,27 +159,15 @@ export async function openSettings(ctx, section = 'page') {
|
|
|
148
159
|
const b = e.target.closest('[data-tab]');
|
|
149
160
|
if (b) tab.value = b.dataset.tab;
|
|
150
161
|
});
|
|
151
|
-
drawClosures(el);
|
|
152
|
-
const box = el.querySelector('[data-part="closures"]');
|
|
153
|
-
box.addEventListener('input', (e) => {
|
|
154
|
-
const row = e.target.closest('[data-i]');
|
|
155
|
-
if (!row) return;
|
|
156
|
-
const o = closures[Number(row.dataset.i)];
|
|
157
|
-
o[e.target.dataset.k] = e.target.value;
|
|
158
|
-
if (e.target.dataset.k === 'from' && (!o.to || o.to < o.from)) { o.to = o.from; row.querySelector('[data-k="to"]').value = o.from; }
|
|
159
|
-
});
|
|
160
|
-
box.addEventListener('click', (e) => {
|
|
161
|
-
const row = e.target.closest('[data-i]');
|
|
162
|
-
if (row && e.target.closest('[data-drop]')) { closures.splice(Number(row.dataset.i), 1); drawClosures(el); }
|
|
163
|
-
});
|
|
164
162
|
},
|
|
165
163
|
methods: {
|
|
166
164
|
cancel: () => panel?.close(),
|
|
167
165
|
copyUrl: () => copy(model.pageUrl.peek(), 'Booking page address copied'),
|
|
168
|
-
addClosure() { closures = [...closures, {from: ctx.boot.today, to: ctx.boot.today, label: ''}];
|
|
166
|
+
addClosure() { closures.value = [...closures.peek(), closureRow({from: ctx.boot.today, to: ctx.boot.today, label: ''})]; rescan(); },
|
|
169
167
|
async save() {
|
|
170
168
|
if (saving.peek()) return;
|
|
171
|
-
|
|
169
|
+
const closed = closuresOut();
|
|
170
|
+
for (const o of closed) {
|
|
172
171
|
if (!isDate(o.from) || !isDate(o.to) || o.to < o.from) { tab.value = 'closed'; E.toast('Closed days: each needs a start date, and an end on or after it.', {type: 'warning'}); return; }
|
|
173
172
|
}
|
|
174
173
|
const out = {};
|
|
@@ -176,7 +175,7 @@ export async function openSettings(ctx, section = 'page') {
|
|
|
176
175
|
for (const k of BOOL_KEYS) out[k] = Boolean(out[k]);
|
|
177
176
|
out.changeNoticeHours = Number(out.changeNoticeHours) || 0;
|
|
178
177
|
out.reminderHours = Number(out.reminderHours) || 0;
|
|
179
|
-
out.closures =
|
|
178
|
+
out.closures = closed;
|
|
180
179
|
saving.value = true;
|
|
181
180
|
const el = panel.panel;
|
|
182
181
|
try {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "booking",
|
|
3
3
|
"displayName": "Booking",
|
|
4
|
-
"version": "1.6.
|
|
4
|
+
"version": "1.6.4",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Let people book appointments with you or your team, and rooms or equipment, from a booking page on your site. Opening hours, days off and buffers; confirmation and reminder emails with a link to change or cancel; a diary in the admin. Book straight from a contact or an email.",
|
|
7
7
|
"author": "Domma CMS",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import {test} from 'node:test';
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
6
|
-
import {datesFor, gridHours, layoutColumn, minuteAt, placeOf, weekStart, dayLabel} from '../admin/lib/diary-layout.js';
|
|
6
|
+
import {datesFor, gridHours, layoutColumn, mergeWindows, minuteAt, placeOf, weekStart, dayLabel} from '../admin/lib/diary-layout.js';
|
|
7
7
|
|
|
8
8
|
test('weeks start on Monday', () => {
|
|
9
9
|
assert.equal(weekStart('2031-07-15'), '2031-07-14');
|
|
@@ -71,3 +71,11 @@ test('booking details: who, where from, answers, places', () => {
|
|
|
71
71
|
assert.equal(placesLabel(1), '1 place left');
|
|
72
72
|
assert.equal(placesLabel(3), '3 places left');
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
test('week bands: windows merged where they touch or overlap, sorted, inputs untouched', () => {
|
|
76
|
+
const input = [{from: 780, to: 1020}, {from: 540, to: 750}, {from: 600, to: 780}, {from: 1080, to: 1140}];
|
|
77
|
+
assert.deepEqual(mergeWindows(input), [{from: 540, to: 1020}, {from: 1080, to: 1140}]);
|
|
78
|
+
assert.deepEqual(input[0], {from: 780, to: 1020}, 'no input is changed');
|
|
79
|
+
assert.deepEqual(mergeWindows([]), []);
|
|
80
|
+
assert.deepEqual(mergeWindows(undefined), []);
|
|
81
|
+
});
|
|
@@ -118,3 +118,65 @@ Replying in the inbox to a `_manager` report you filed yourself marks it seen fo
|
|
|
118
118
|
|
|
119
119
|
- Cache-busters follow the manifest: `feedback.js?v=1.1.4` in plugin.json and `V = '1.1.4'` for the sub-views - bump
|
|
120
120
|
both with the version. Fleet-token note above corrected (tokens are on disk since 2026-10-04).
|
|
121
|
+
|
|
122
|
+
## 1.1.5 (2026-10-13 Tool sweep - caching)
|
|
123
|
+
|
|
124
|
+
- **`allReports()` is cached 30 s** (`lib/cache.js`; promise kept, concurrent reads share one). It served the
|
|
125
|
+
manager badge (every minute per `manage` tab), every site's `/fleet/mine` and `receive()`'s dedupe. Every write
|
|
126
|
+
in lib/receiver.js (`receive`, `applyPatch` - and so `issueMoved` - `removeReport`) calls `forget()` after the
|
|
127
|
+
write, so the inbox and badge show it on the next read. A test that writes the collection directly must call
|
|
128
|
+
`forget()` itself. Test: `tests/api.test.js` "the inbox memory".
|
|
129
|
+
|
|
130
|
+
## 1.1.6 (2026-10-13) - attachments
|
|
131
|
+
|
|
132
|
+
Up to 3 files per report, 2 MB each: PNG, JPEG, GIF, WebP, PDF, plain text (`ATTACHMENTS` / `ATTACHMENT_TYPES` in
|
|
133
|
+
lib/shape.js; no SVG or HTML - they can carry script). Chosen with "Attach a file" or pasted into the description.
|
|
134
|
+
|
|
135
|
+
- **Not the CMS media API, on purpose.** `/api/media` saves into the PUBLIC media folder (anyone with the URL reads
|
|
136
|
+
it) and needs `media.create`, which a `feedback.send` user may lack. These are screenshots of admin screens, so the
|
|
137
|
+
plugin has its own upload route that mirrors media.js's checks (mime whitelist, `request.file()` from the engine's
|
|
138
|
+
@fastify/multipart, size cap, sanitised name) and keeps files in its own `data/attachments/` (0600 files, 0700
|
|
139
|
+
folders, `lib/attachments.js createStore`). Every path is built from a cleaned attachment (`cleanAttachment`: id
|
|
140
|
+
`[A-Za-z0-9-]{8,64}`, made by `randomUUID()` on upload) - nothing from a request reaches `path.join` raw.
|
|
141
|
+
- **Flow.** Site: `POST /attachments` (multipart) -> `{id, name, type, size}` saved with a `<id>.json` sidecar.
|
|
142
|
+
`POST /send {..., attachments: [{id}]}` takes each file's description from the SIDECAR, never the form, and the
|
|
143
|
+
payload announces them. `flush()` (now single-flight, like Support's) submits the report, then posts each file RAW to
|
|
144
|
+
`POST /fleet/attach/:reportId/:attId` (`application/octet-stream`; a plugin-scoped parser, bodyLimit 2 MB + 1 KB;
|
|
145
|
+
its own throttle, perSiteMax x 3). The manager takes only a file the report announced, at the announced size, on a
|
|
146
|
+
report of the token's site (else 404), saves it under `data/attachments/<report id>/` and sets `stored: true`
|
|
147
|
+
(`rx.markStored`). Outbox items record `filesSent` / `filesRefused` (a 4xx other than 401/408/429, or a file this
|
|
148
|
+
site no longer has), so a file is retried until it lands or is refused for good.
|
|
149
|
+
- **Reading them.** Inbox: `GET /reports/:id/attachments/:att` (`manage`). Site: `GET /attachments/:id` - its own
|
|
150
|
+
copy, so a file shows before Domma's copy lands. Served with the stored type, `nosniff`, inline, private cache.
|
|
151
|
+
The admin fetches with the session token into a `blob:` URL (`kit.blobUrl`) for thumbnails and "open in a new tab".
|
|
152
|
+
- The level-0 role's own feedback: uploads stage in `data/attachments/_staging/`, `POST /send` moves them into the
|
|
153
|
+
report's folder and marks them stored; `GET /attachments/:id` finds staged or filed ones.
|
|
154
|
+
- Deleting a report deletes its folder. A site drops files never sent (no outbox item names them) after a day, on the
|
|
155
|
+
sync timer; the manager prunes `_staging` on each upload.
|
|
156
|
+
- UI: `kit.FILES` (data-each row template, bound on its own host AFTER the panel's bindings - the panel model has no
|
|
157
|
+
`files`; no icons in rows because they land after the icon scan) and `kit.showFiles()` for detail panels.
|
|
158
|
+
- Also: `GET /meta` asks for `manage` on the manager (a manage-only role used to 403 and the screen failed), and
|
|
159
|
+
answers the attachment caps; `text()` strips all control characters (as Support's).
|
|
160
|
+
- Tests: shape.test.js (cleanAttachment, caps, store perms and prune), api.test.js (upload -> travel -> inbox
|
|
161
|
+
download, refusals, offline wait then both go). Not covered by tests: the level-0 role's own attachments.
|
|
162
|
+
|
|
163
|
+
## 1.1.7 (2026-10-13) - settings cog
|
|
164
|
+
|
|
165
|
+
- Settings are read when used, per side (`cleanSettings(raw, current, side)`, `SIDE_SETTINGS` in lib/shape.js):
|
|
166
|
+
the site's `syncMinutes`, the manager's `perSiteMax` / `perSiteMinutes`. `GET/PUT /settings` answers this side
|
|
167
|
+
only - a site's for `requireAdmin` (from `options.auth`; `send` on an engine without it), the manager's for
|
|
168
|
+
`manage`; the save merges over the stored object (the other side's keys and `storage` survive). Tests inject
|
|
169
|
+
`options.readSettings` / `options.writeSettings` (tests/api.test.js passes the test's limits that way).
|
|
170
|
+
- The sync timer is a self-re-arming `setTimeout` that asks for `syncMinutes` before each wait, and a save
|
|
171
|
+
re-arms it. The two throttles (`createThrottle()` now takes `{max, windowMs}` per call) get the live limits on
|
|
172
|
+
each report or file. Before, all three were read at boot and needed a restart.
|
|
173
|
+
- `admin/lib/settings.js`: the banner cog, added by `feedback.js` once the mode is known, only when the silent
|
|
174
|
+
`GET /settings` answers; a slideover through kit's `openPanel` with M bindings, data-help per field, discard
|
|
175
|
+
guard, Ctrl+Enter. Tests: tests/settings.test.js, and "the per-site throttle" in tests/api.test.js (live limit).
|
|
176
|
+
|
|
177
|
+
## 1.1.8 (2026-10-13) - a report deleted on the manager leaves the site too
|
|
178
|
+
Deleting a report in the inbox hid it from the site, but the site's outbox kept the sent item - its text and, through
|
|
179
|
+
`pruneFiles`' keep list, its screenshots - for good. `sync()` now calls `outbox.forgetDropped(remote)` whenever the
|
|
180
|
+
manager answered `/fleet/mine` (its list for the site is complete): a SENT item the manager no longer has is dropped
|
|
181
|
+
(`droppedByManager`, PURE, tested); waiting/refused items and an unreachable manager keep everything. The files go
|
|
182
|
+
at the next prune (older than a day, no longer named). Found on the dcm click-through.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* Feedback - admin styles. Theme tokens only (every one below is defined by every Domma theme). */
|
|
2
2
|
|
|
3
3
|
/* data-bind-hidden sets [hidden]; any display rule below would otherwise beat it. */
|
|
4
|
-
.fb [hidden], .fb-detail [hidden], .fb-form [hidden] { display: none !important; }
|
|
4
|
+
.fb [hidden], .fb-detail [hidden], .fb-form [hidden], .fb-files-host [hidden] { display: none !important; }
|
|
5
5
|
|
|
6
6
|
.fb { max-width: 60rem; }
|
|
7
7
|
.fb-card { background: var(--dm-surface); border: 1px solid var(--dm-border); border-radius: var(--dm-radius-lg); padding: 1rem 1.1rem; }
|
|
@@ -74,6 +74,21 @@
|
|
|
74
74
|
.fb-history li { display: flex; justify-content: space-between; gap: 1rem; padding: .3rem 0; border-top: 1px solid var(--dm-border); }
|
|
75
75
|
.fb-history small { color: var(--dm-text-secondary); }
|
|
76
76
|
|
|
77
|
+
/* Attached files: a thumbnail or the file's kind, its name and size */
|
|
78
|
+
.fb-attach { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
|
79
|
+
.fb-files { list-style: none; margin: .3rem 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: .4rem; }
|
|
80
|
+
.fb-file { display: flex; align-items: center; gap: .55rem; padding: .35rem .5rem; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md);
|
|
81
|
+
background: var(--dm-background); cursor: pointer; min-width: 0; }
|
|
82
|
+
.fb-file:hover, .fb-file:focus-visible { border-color: var(--dm-primary); outline: none; }
|
|
83
|
+
.fb-thumb { width: 2.6rem; height: 2.6rem; object-fit: cover; border-radius: var(--dm-radius-md); flex: none; }
|
|
84
|
+
.fb-file-ext { width: 2.6rem; height: 2.6rem; display: inline-flex; align-items: center; justify-content: center; flex: none; font-size: var(--dm-font-size-xs);
|
|
85
|
+
font-weight: 700; color: var(--dm-text-secondary); background: var(--dm-hover-bg); border-radius: var(--dm-radius-md); }
|
|
86
|
+
.fb-file-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
87
|
+
.fb-file-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--dm-font-size-sm); }
|
|
88
|
+
.fb-file-main small { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
|
|
89
|
+
.fb-file-x { border: 0; background: none; color: var(--dm-text-secondary); cursor: pointer; font-size: 1.1rem; line-height: 1; padding: .1rem .35rem; border-radius: var(--dm-radius-md); }
|
|
90
|
+
.fb-file-x:hover { background: var(--dm-hover-bg); color: var(--dm-danger); }
|
|
91
|
+
|
|
77
92
|
@media (max-width: 640px) {
|
|
78
93
|
.fb-grid3 { grid-template-columns: 1fr; }
|
|
79
94
|
.fb-row { flex-wrap: wrap; }
|
|
@@ -46,9 +46,115 @@ export const api = {
|
|
|
46
46
|
update: (id, d) => H.put(`${BASE}/reports/${seg(id)}`, d),
|
|
47
47
|
remove: (id) => H.delete(`${BASE}/reports/${seg(id)}`),
|
|
48
48
|
waypoint: () => H.get(`${BASE}/waypoint`),
|
|
49
|
+
/** Where a file is read from: this site's (or the manager's own) copy, or a report's in the inbox. */
|
|
50
|
+
ownFileUrl: (id) => `${BASE}/attachments/${seg(id)}`,
|
|
51
|
+
reportFileUrl: (id, att) => `${BASE}/reports/${seg(id)}/attachments/${seg(att)}`,
|
|
49
52
|
promote: (id, d) => H.post(`${BASE}/reports/${seg(id)}/promote`, d)
|
|
50
53
|
};
|
|
51
54
|
|
|
55
|
+
// ---------------------------------------------------------------------------------
|
|
56
|
+
// Attachments
|
|
57
|
+
// ---------------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
const authHeaders = () => { const t = (() => { try { return S.get('auth_token'); } catch { return ''; } })(); return t ? {Authorization: `Bearer ${t}`} : {}; };
|
|
60
|
+
|
|
61
|
+
/** Upload one file for the report being written. H has no multipart helper, so this is the admin's own upload pattern. */
|
|
62
|
+
export async function uploadFile(file) {
|
|
63
|
+
const fd = new FormData();
|
|
64
|
+
fd.append('file', file, file.name);
|
|
65
|
+
const res = await fetch(`${BASE}/attachments`, {method: 'POST', headers: authHeaders(), body: fd});
|
|
66
|
+
const body = await res.json().catch(() => ({}));
|
|
67
|
+
if (!res.ok) throw new Error(body.error || `Upload failed (${res.status}).`);
|
|
68
|
+
return body;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A file behind the session as a blob: URL (an <img src> or a new tab cannot send the token). Revoke it when done. */
|
|
72
|
+
export async function blobUrl(url) {
|
|
73
|
+
const res = await fetch(url, {headers: authHeaders()});
|
|
74
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `Could not open the file (${res.status}).`);
|
|
75
|
+
return URL.createObjectURL(await res.blob());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,application/pdf,text/plain';
|
|
79
|
+
export const isImage = (type) => /^image\//.test(String(type || ''));
|
|
80
|
+
const EXT = {'image/png': 'PNG', 'image/jpeg': 'JPG', 'image/gif': 'GIF', 'image/webp': 'WEBP', 'application/pdf': 'PDF', 'text/plain': 'TXT'};
|
|
81
|
+
|
|
82
|
+
/** 812 B, 14 KB, 1.2 MB. */
|
|
83
|
+
export function fileSize(n) {
|
|
84
|
+
const b = Number(n) || 0;
|
|
85
|
+
if (b < 1024) return `${b} B`;
|
|
86
|
+
if (b < 1024 * 1024) return `${Math.round(b / 1024)} KB`;
|
|
87
|
+
return `${(b / 1024 / 1024).toFixed(1)} MB`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A list of files, bound to a `files` observable of fileRow()s. Text bindings only;
|
|
92
|
+
* the thumbnail is a blob: URL or nothing. No icons in the row - rows arrive after
|
|
93
|
+
* the panel's icon scan.
|
|
94
|
+
*/
|
|
95
|
+
export const FILES = `
|
|
96
|
+
<ul class="fb-files" data-bind-hidden="!files.value.length" data-each="files.value key=key"><li class="fb-file" data-key="{{key}}" tabindex="0" role="button" data-bind-title="title">
|
|
97
|
+
<img class="fb-thumb" alt="" data-bind-src="thumb || false" data-bind-hidden="!thumb">
|
|
98
|
+
<span class="fb-file-ext" data-bind-hidden="thumb" data-bind-text="ext"></span>
|
|
99
|
+
<span class="fb-file-main"><strong data-bind-text="name"></strong><small data-bind-text="meta"></small></span>
|
|
100
|
+
<button type="button" class="fb-file-x" data-bind-hidden="!removable" aria-label="Remove this file" title="Remove">×</button>
|
|
101
|
+
</li></ul>`;
|
|
102
|
+
|
|
103
|
+
/** One row for FILES. */
|
|
104
|
+
export function fileRow(a, extra = {}) {
|
|
105
|
+
return {key: a.id || a.key, id: a.id || '', name: a.name, type: a.type, ext: EXT[a.type] || 'FILE', thumb: '',
|
|
106
|
+
meta: fileSize(a.size), title: `Open ${a.name}`, removable: false, ...extra};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Show a report's attached files in a panel: thumbnails for the images that are
|
|
111
|
+
* there, and click (or Enter) to open one in a new tab.
|
|
112
|
+
*
|
|
113
|
+
* @param {HTMLElement} host - an element holding FILES
|
|
114
|
+
* @param {object[]} list - attachments {id, name, type, size, stored?}
|
|
115
|
+
* @param {(a) => string} urlOf - where the file is fetched from
|
|
116
|
+
* @param {string} [missing] - said of a file that has not arrived yet
|
|
117
|
+
* @returns {{files, dispose: () => void}}
|
|
118
|
+
*/
|
|
119
|
+
export function showFiles(host, list, urlOf, missing = 'not arrived yet') {
|
|
120
|
+
const urls = [];
|
|
121
|
+
const files = M.observable((list || []).map(a => fileRow(a, {meta: [fileSize(a.size), a.stored === false ? missing : ''].filter(Boolean).join(' · ')})));
|
|
122
|
+
const handle = M.applyBindings({files}, host);
|
|
123
|
+
let live = true;
|
|
124
|
+
for (const a of list || []) {
|
|
125
|
+
if (!isImage(a.type) || a.stored === false) continue;
|
|
126
|
+
blobUrl(urlOf(a)).then((u) => {
|
|
127
|
+
if (!live) { URL.revokeObjectURL(u); return; }
|
|
128
|
+
urls.push(u);
|
|
129
|
+
files.value = files.peek().map(r => (r.key === a.id ? {...r, thumb: u} : r));
|
|
130
|
+
}).catch(() => { /* no thumbnail; the row still opens */ });
|
|
131
|
+
}
|
|
132
|
+
const openOne = async (key) => {
|
|
133
|
+
const a = (list || []).find(x => x.id === key);
|
|
134
|
+
if (!a) return;
|
|
135
|
+
if (a.stored === false) { E.toast(`${a.name} has not arrived yet - it follows its report.`, {type: 'info'}); return; }
|
|
136
|
+
try {
|
|
137
|
+
const u = await blobUrl(urlOf(a));
|
|
138
|
+
urls.push(u);
|
|
139
|
+
window.open(u, '_blank', 'noopener');
|
|
140
|
+
} catch (err) { E.toast(err.message || String(err), {type: 'error'}); }
|
|
141
|
+
};
|
|
142
|
+
const onClick = (e) => { const li = e.target.closest('.fb-file'); if (li) openOne(li.dataset.key); };
|
|
143
|
+
const onKey = (e) => { const li = e.target.closest('.fb-file'); if (li && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); openOne(li.dataset.key); } };
|
|
144
|
+
host.addEventListener('click', onClick);
|
|
145
|
+
host.addEventListener('keydown', onKey);
|
|
146
|
+
return {
|
|
147
|
+
files,
|
|
148
|
+
dispose() {
|
|
149
|
+
live = false;
|
|
150
|
+
host.removeEventListener('click', onClick);
|
|
151
|
+
host.removeEventListener('keydown', onKey);
|
|
152
|
+
handle.dispose();
|
|
153
|
+
for (const u of urls.splice(0)) URL.revokeObjectURL(u);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
52
158
|
/** E.confirm and slideover titles render HTML: user text goes in escaped. */
|
|
53
159
|
export const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
54
160
|
|