Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
+64
-30
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user