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

This commit is contained in:
2026-09-02 16:07:52 +10:00
parent c21123885d
commit 74792b2764
11 changed files with 469 additions and 11 deletions
+113 -3
View File
@@ -679,6 +679,13 @@ async function loadSites() {
: 'Off'
}</dd>
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''}
<dt>Kiosk branding</dt>
<dd>
${s.branding.hasBanner ? 'Banner set' : 'No banner'} ·
<span class="swatch" style="background:${esc(s.branding.theme.brand)}"></span>
<span class="swatch" style="background:${esc(s.branding.theme.signout)}"></span>
<span class="swatch" style="background:${esc(s.branding.theme.page)}"></span>
</dd>
</dl>
</article>`
)
@@ -739,7 +746,32 @@ function openSiteModal(site) {
<label class="inline"><input type="checkbox" name="accent" id="badge-accent" ${site.badge.accent ? 'checked' : ''}> Print the heading and the no-check warning in red</label>
<p class="hint">Red needs a two-colour roll such as the Brother DK-22251. On any other
roll it prints as grey. Two-colour printing is also much slower than black alone.</p>
${field('Line printed at the bottom', 'note', site.badge.note)}`,
${field('Line printed at the bottom', 'note', site.badge.note)}
<h4 class="modal-section">Kiosk branding</h4>
<div class="banner-editor">
<div class="banner-frame" id="banner-frame">
<img id="banner-preview" alt="Banner"${
site.branding.hasBanner ? ` src="/admin/api/sites/${site.id}/banner?t=${Date.now()}"` : ' hidden'
}>
<p class="banner-empty" id="banner-empty"${site.branding.hasBanner ? ' hidden' : ''}>No banner</p>
</div>
<div class="photo-actions">
<label class="ghost photo-upload">Upload an image
<input type="file" id="banner-file" accept="image/png,image/jpeg,image/webp" hidden>
</label>
<button type="button" class="ghost danger" id="banner-clear"${site.branding.hasBanner ? '' : ' hidden'}>Remove</button>
</div>
<p class="hint">A PNG with a transparent background works best — it sits straight on the
bar colour with nothing painted behind it. Up to 2 MB.</p>
${field('Banner height on screen (px)', 'bannerHeight', site.branding.bannerHeight, 'number')}
</div>
<div class="modal-row modal-row-3">
${colourField('Bar and buttons', 'brand', site.branding.brand, '#0b4f4a')}
${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>`,
async (form) => {
const data = Object.fromEntries(form.entries());
try {
@@ -757,18 +789,96 @@ function openSiteModal(site) {
accent: form.has('accent'),
note: data.note,
},
branding: {
brand: data.brand || null,
signout: data.signout || null,
page: data.page || null,
bannerHeight: Number(data.bannerHeight) || 64,
},
},
});
toast('Site saved. Preview the badge before committing a roll to it.');
if (bannerEditor.dataUrl) {
await api(`/sites/${site.id}/banner`, {
method: 'POST',
body: { image: bannerEditor.dataUrl },
});
} else if (bannerEditor.remove) {
await api(`/sites/${site.id}/banner`, { method: 'DELETE' });
}
toast('Site saved. Reload the kiosk to see the change.');
loadSites();
} catch (err) {
toast(err.message, true);
}
},
{ onOpen: wireBadgePreset }
{
onOpen: () => {
wireBadgePreset();
wireBannerEditor();
},
}
);
}
/**
* A colour box paired with a text field, so a colour can be picked by eye or
* pasted from a brand guide, and cleared entirely to fall back to the default.
*/
function colourField(label, name, value, fallback) {
const current = value || '';
return `<label class="modal-field colour-field">
<span>${esc(label)}</span>
<span class="colour-row">
<input type="color" data-colour-for="${name}" value="${esc(current || fallback)}">
<input type="text" name="${name}" value="${esc(current)}" placeholder="${esc(fallback)}"
maxlength="7" spellcheck="false">
</span>
</label>`;
}
const bannerEditor = { dataUrl: null, remove: false };
function wireBannerEditor() {
bannerEditor.dataUrl = null;
bannerEditor.remove = false;
// Keep the swatch and the hex box in step, in both directions.
$$('#modal-form [data-colour-for]').forEach((swatch) => {
const text = $(`#modal-form [name="${swatch.dataset.colourFor}"]`);
swatch.addEventListener('input', () => {
text.value = swatch.value;
});
text.addEventListener('input', () => {
if (/^#[0-9a-fA-F]{6}$/.test(text.value.trim())) swatch.value = text.value.trim();
});
});
$('#banner-file').addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) return toast('That image is over 2 MB. Use a smaller one.', true);
const reader = new FileReader();
reader.onload = () => {
bannerEditor.dataUrl = reader.result;
bannerEditor.remove = false;
$('#banner-preview').src = reader.result;
$('#banner-preview').hidden = false;
$('#banner-empty').hidden = true;
$('#banner-clear').hidden = false;
};
reader.readAsDataURL(file);
});
$('#banner-clear').addEventListener('click', () => {
bannerEditor.dataUrl = null;
bannerEditor.remove = true;
$('#banner-preview').hidden = true;
$('#banner-preview').removeAttribute('src');
$('#banner-empty').hidden = false;
$('#banner-clear').hidden = true;
});
}
function wireBadgePreset() {
const width = $('#modal-form [name="widthMm"]');
const height = $('#modal-form [name="heightMm"]');
+38
View File
@@ -435,11 +435,49 @@ async function chooseSite() {
show('site', { push: false });
}
/**
* Applies the site's colours as CSS variables. Only three are chosen by an admin;
* the shades and the text colours that sit on them are derived server-side so a
* dark logo colour cannot end up with dark text on it.
*/
function applyTheme(theme, banner) {
if (theme) {
const root = document.documentElement.style;
root.setProperty('--deep', theme.brand);
root.setProperty('--deep-dark', theme.brandDark);
root.setProperty('--on-brand', theme.onBrand);
root.setProperty('--exit', theme.signout);
root.setProperty('--exit-dark', theme.signoutDark);
root.setProperty('--on-exit', theme.onSignout);
root.setProperty('--paper', theme.page);
root.setProperty('--card', theme.card);
root.setProperty('--ink', theme.ink);
root.setProperty('--rule', theme.rule);
root.setProperty('--focus', theme.brand);
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme.brand);
}
const img = $('#banner');
if (banner?.url) {
img.src = banner.url;
img.style.maxHeight = `${banner.height}px`;
img.hidden = false;
// The banner carries the branding, so the name beside it would just repeat it.
$('#siteName').hidden = true;
} else {
img.hidden = true;
img.removeAttribute('src');
$('#siteName').hidden = false;
}
}
async function loadSiteContext() {
siteConfig = await api('/api/config');
const name = siteConfig.site ? siteConfig.site.name : siteConfig.siteName;
document.title = name;
$('#siteName').textContent = name;
$('#banner').alt = name;
applyTheme(siteConfig.theme, siteConfig.banner);
const change = $('#change-site');
change.hidden = !siteConfig.multiSite;