Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA

This commit is contained in:
2026-09-02 16:45:48 +10:00
parent ff9f51eaf9
commit 8c97c314e0
11 changed files with 211 additions and 247 deletions
+64 -30
View File
@@ -777,8 +777,11 @@ function openSiteModal(site) {
${colourField('Sign out', 'signout', site.branding.signout, '#2c4a6b')}
${colourField('Page background', 'page', site.branding.page, '#e7ecf0')}
</div>
<p class="hint">Text colours are worked out from these, so a pale brand colour gets dark
text rather than white. Clear a box to go back to the default.</p>`,
${colourField('Body text', 'text', site.branding.text, site.branding.theme.ink)}
<p class="hint" id="contrast-note"></p>
<p class="hint">Text on the bar and on buttons is chosen automatically for contrast, so a
pale brand colour gets dark text rather than white. Labels and hints are a softened version
of the body text, kept readable against the background. Clear a box for the default.</p>`,
async (form) => {
const data = Object.fromEntries(form.entries());
try {
@@ -800,6 +803,7 @@ function openSiteModal(site) {
brand: data.brand || null,
signout: data.signout || null,
page: data.page || null,
text: data.text || null,
bannerHeight: Number(data.bannerHeight) || 64,
bannerAlign: data.bannerAlign,
},
@@ -844,6 +848,18 @@ function colourField(label, name, value, fallback) {
</label>`;
}
/** WCAG contrast ratio, mirroring the server so the console can warn as you type. */
function contrastRatio(a, b) {
const lum = (hex) => {
const [r, g, bl] = [1, 3, 5]
.map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
};
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
const bannerEditor = { dataUrl: null, remove: false };
function wireBannerEditor() {
@@ -861,6 +877,36 @@ function wireBannerEditor() {
});
});
// Live contrast readout, because a colour that looks fine in a swatch can be
// unreadable as body text.
const pageInput = $('#modal-form [name="page"]');
const textInput = $('#modal-form [name="text"]');
const note = $('#contrast-note');
const showContrast = () => {
const page = pageInput.value.trim() || pageInput.placeholder;
const text = textInput.value.trim() || textInput.placeholder;
if (!/^#[0-9a-fA-F]{6}$/.test(page) || !/^#[0-9a-fA-F]{6}$/.test(text)) {
note.hidden = true;
return;
}
const ratio = contrastRatio(text, page);
note.hidden = false;
if (ratio >= 7) {
note.className = 'hint';
note.textContent = `Contrast ${ratio.toFixed(1)}:1 — comfortable at arm's length.`;
} else if (ratio >= 4.5) {
note.className = 'hint';
note.textContent = `Contrast ${ratio.toFixed(1)}:1 — readable, but aim for 7:1 on a kiosk people read standing up.`;
} else {
note.className = 'hint warn';
note.textContent = `Contrast only ${ratio.toFixed(1)}:1. This will be hard to read — pick a darker or lighter body text.`;
}
};
[pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast));
showContrast();
$('#banner-file').addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
@@ -1100,20 +1146,22 @@ async function loadSystem() {
<dt>On site now</dt><dd>${s.onSite}</dd>
<dt>Google Sheet</dt><dd>${
s.sheets.enabled
? `Connected. ${s.sheets.queued} row(s) waiting to send.${s.sheets.lastError ? ` Last error: ${esc(s.sheets.lastError)}` : ''}`
? `Mirroring who is on site to the "${esc(s.sheets.tab)}" tab${
s.sheets.stale ? ' <span class="pill warn">waiting to retry</span>' : ''
}${s.sheets.lastError ? `<br><span class="pill bad">${esc(s.sheets.lastError)}</span>` : ''}`
: 'Turned off in the environment file.'
}</dd>
<dt>History tab</dt><dd>${esc(s.sheets.logTab)} — last written ${stamp(s.sheets.lastOk)}</dd>
<dt>Live tab</dt><dd>${esc(s.sheets.onSiteTab)}${
s.sheets.onSiteCount === null ? 'not synced yet' : `${s.sheets.onSiteCount} on site`
}, last synced ${stamp(s.sheets.lastOnSiteSync)}${
s.sheets.onSiteError ? ` <span class="pill bad">${esc(s.sheets.onSiteError)}</span>` : ''
}</dd>
${
s.sheets.enabled
? `<dt>On the sheet</dt><dd>${
s.sheets.onSiteCount === null ? 'Not written yet' : `${s.sheets.onSiteCount} on site`
}, last written ${stamp(s.sheets.lastOk)}</dd>`
: ''
}
</dl>
<div class="sys-actions">
<button class="ghost" id="sheet-test">Test the sheet connection</button>
<button class="ghost" id="sheet-flush">Send queued rows now</button>
<button class="ghost" id="sheet-resync">Rebuild the live list</button>
<button class="ghost" id="sheet-resync">Rebuild the sheet now</button>
<button class="ghost danger" id="photo-purge">Purge photos past retention</button>
</div>
<h3 class="section-gap">Certificate</h3>
@@ -1128,28 +1176,14 @@ async function loadSystem() {
toast(err.message, true);
}
});
$('#sheet-resync').addEventListener('click', async () => {
try {
const r = await api('/sheets/sync', { method: 'POST' });
toast(r.skipped ? 'Sheet mirroring is off.' : `Live tab rewritten with ${r.rows} on site.`);
loadSystem();
} catch (err) {
toast(err.message, true);
}
});
$('#sheet-flush').addEventListener('click', async () => {
try {
const r = await api('/sheets/flush', { method: 'POST' });
toast(`${r.sent} sent, ${r.remaining} still queued.`);
loadSystem();
} catch (err) {
toast(err.message, true);
}
});
$('#sheet-resync').addEventListener('click', async () => {
try {
const r = await api('/sheets/resync', { method: 'POST' });
toast(`Live list rebuilt with ${r.rows} ${r.rows === 1 ? 'person' : 'people'}.`);
toast(
r.skipped
? 'Sheet mirroring is off.'
: `Sheet rewritten with ${r.rows} ${r.rows === 1 ? 'person' : 'people'} on site.`
);
loadSystem();
} catch (err) {
toast(err.message, true);
+1
View File
@@ -452,6 +452,7 @@ function applyTheme(theme, banner, align = 'left') {
root.setProperty('--paper', theme.page);
root.setProperty('--card', theme.card);
root.setProperty('--ink', theme.ink);
root.setProperty('--muted', theme.muted);
root.setProperty('--rule', theme.rule);
root.setProperty('--focus', theme.brand);
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme.brand);