Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
@@ -101,6 +101,37 @@ same person can be signed in at two sites at once without the system arguing abo
|
|||||||
An admin account can be limited to a single site. Those admins see only that site's visitors,
|
An admin account can be limited to a single site. Those admins see only that site's visitors,
|
||||||
staff list and log, and cannot touch the others.
|
staff list and log, and cannot touch the others.
|
||||||
|
|
||||||
|
## Branding the kiosk
|
||||||
|
|
||||||
|
Each site can carry its own banner and colours, set under **Sites → Edit**.
|
||||||
|
|
||||||
|
**Banner.** Upload a PNG, JPEG or WebP up to 2 MB. A PNG with a transparent background is the
|
||||||
|
one to use — it sits straight on the bar colour with nothing painted behind it, so it works
|
||||||
|
whatever colour you pick. The admin preview shows it on a checkerboard so you can see the
|
||||||
|
transparency. When a banner is set it replaces the site name in the kiosk header; set the
|
||||||
|
on-screen height in pixels to suit the shape of your logo. SVG is deliberately not accepted:
|
||||||
|
it can carry script, and this file is served to every kiosk.
|
||||||
|
|
||||||
|
**Colours.** Three are settable:
|
||||||
|
|
||||||
|
| Setting | Where it shows |
|
||||||
|
|---|---|
|
||||||
|
| Bar and buttons | The top bar, the Sign in door, primary buttons, the confirmation mark |
|
||||||
|
| Sign out | The Sign out door and the signed-out confirmation |
|
||||||
|
| Page background | Behind everything, with card and rule colours derived from it |
|
||||||
|
|
||||||
|
Everything else is worked out from those three. In particular the **text colour on a coloured
|
||||||
|
background is chosen by contrast**, not fixed — pick a pale yellow for the bar and the text on
|
||||||
|
it turns dark automatically, instead of staying white and becoming unreadable. Card, border and
|
||||||
|
body-text colours follow the page colour, so a dark background gives a usable dark theme rather
|
||||||
|
than white boxes.
|
||||||
|
|
||||||
|
Leave a colour box empty to fall back to the default. Anything that is not a six-digit hex value
|
||||||
|
is ignored rather than applied.
|
||||||
|
|
||||||
|
The admin console keeps its own neutral look, so it stays recognisable when you are switching
|
||||||
|
between sites. Printed badges stay black on white — a label printer has no colours to give.
|
||||||
|
|
||||||
## Badge printing
|
## Badge printing
|
||||||
|
|
||||||
Turn it on per site under **Sites → Edit**. After a visitor signs in, the kiosk loads the badge
|
Turn it on per site under **Sites → Edit**. After a visitor signs in, the kiosk loads the badge
|
||||||
|
|||||||
@@ -351,3 +351,52 @@ tr.row-bad td { background: #fdf0f2; }
|
|||||||
/* The confirmation for a destructive action should not look like a Save. */
|
/* The confirmation for a destructive action should not look like a Save. */
|
||||||
#modal.destructive { border-top-color: var(--alert); }
|
#modal.destructive { border-top-color: var(--alert); }
|
||||||
#modal.destructive .primary { border-color: var(--alert); background: var(--alert); }
|
#modal.destructive .primary { border-color: var(--alert); background: var(--alert); }
|
||||||
|
|
||||||
|
/* ----------------------------------------------------- branding editor */
|
||||||
|
|
||||||
|
.banner-frame {
|
||||||
|
/* A checkerboard, so a transparent PNG reads as transparent rather than white. */
|
||||||
|
background-color: #fff;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #e3e8ed 25%, transparent 25%, transparent 75%, #e3e8ed 75%),
|
||||||
|
linear-gradient(45deg, #e3e8ed 25%, transparent 25%, transparent 75%, #e3e8ed 75%);
|
||||||
|
background-size: 14px 14px;
|
||||||
|
background-position: 0 0, 7px 7px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
min-height: 76px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.banner-frame img { max-width: 100%; max-height: 90px; display: block; }
|
||||||
|
.banner-empty { margin: 0; color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
.colour-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.colour-row input[type="color"] {
|
||||||
|
width: 42px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.colour-row input[type="text"] {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.modal-row-3 { grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
vertical-align: -2px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|||||||
+19
-6
@@ -6,8 +6,10 @@
|
|||||||
--rule: #c9d3dc;
|
--rule: #c9d3dc;
|
||||||
--deep: #0b4f4a;
|
--deep: #0b4f4a;
|
||||||
--deep-dark: #083a36;
|
--deep-dark: #083a36;
|
||||||
|
--on-brand: #ffffff;
|
||||||
--exit: #2c4a6b;
|
--exit: #2c4a6b;
|
||||||
--exit-dark: #1f3650;
|
--exit-dark: #1f3650;
|
||||||
|
--on-exit: #ffffff;
|
||||||
--alert: #96162f;
|
--alert: #96162f;
|
||||||
--focus: #0b4f4a;
|
--focus: #0b4f4a;
|
||||||
}
|
}
|
||||||
@@ -45,7 +47,17 @@ body {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 14px 22px;
|
padding: 14px 22px;
|
||||||
background: var(--deep);
|
background: var(--deep);
|
||||||
color: #eef5f3;
|
color: var(--on-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An uploaded banner sits in the bar in place of the site name. Transparent PNGs
|
||||||
|
are the point, so nothing is painted behind it. */
|
||||||
|
.banner {
|
||||||
|
max-height: 64px;
|
||||||
|
max-width: min(70%, 460px);
|
||||||
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
|
.site { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
|
||||||
@@ -108,11 +120,11 @@ h2 { font-size: 25px; margin: 4px 0 20px; }
|
|||||||
border-left: 7px solid var(--deep-dark);
|
border-left: 7px solid var(--deep-dark);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: var(--deep);
|
background: var(--deep);
|
||||||
color: #fff;
|
color: var(--on-brand);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.door-out { background: var(--exit); border-left-color: var(--exit-dark); }
|
.door-out { background: var(--exit); border-left-color: var(--exit-dark); color: var(--on-exit); }
|
||||||
.door:active { transform: translateY(1px); }
|
.door:active { transform: translateY(1px); }
|
||||||
|
|
||||||
.door-title { display: block; font-size: 27px; font-weight: 650; letter-spacing: -0.01em; }
|
.door-title { display: block; font-size: 27px; font-weight: 650; letter-spacing: -0.01em; }
|
||||||
@@ -131,6 +143,7 @@ h2 { font-size: 25px; margin: 4px 0 20px; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.text-action:hover { background: var(--card); }
|
.text-action:hover { background: var(--card); }
|
||||||
|
.text-action, .field input, .picker, .choice, .host-option { color: var(--ink); }
|
||||||
|
|
||||||
/* ------------------------------------------------------- step rail */
|
/* ------------------------------------------------------- step rail */
|
||||||
|
|
||||||
@@ -280,11 +293,11 @@ a:focus-visible {
|
|||||||
padding: 7px 14px;
|
padding: 7px 14px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: var(--deep);
|
background: var(--deep);
|
||||||
color: #fff;
|
color: var(--on-brand);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
#screen-done-out .mark { background: var(--exit); }
|
#screen-done-out .mark { background: var(--exit); color: var(--on-exit); }
|
||||||
|
|
||||||
/* ------------------------------------------------------------ buttons */
|
/* ------------------------------------------------------------ buttons */
|
||||||
|
|
||||||
@@ -306,7 +319,7 @@ a:focus-visible {
|
|||||||
.nav .primary {
|
.nav .primary {
|
||||||
border: 1px solid var(--deep);
|
border: 1px solid var(--deep);
|
||||||
background: var(--deep);
|
background: var(--deep);
|
||||||
color: #fff;
|
color: var(--on-brand);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.nav .ghost {
|
.nav .ghost {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
|
<img class="banner" id="banner" alt="" hidden>
|
||||||
<p class="site" id="siteName">Visitor sign in</p>
|
<p class="site" id="siteName">Visitor sign in</p>
|
||||||
<p class="clock" id="clock"></p>
|
<p class="clock" id="clock"></p>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+113
-3
@@ -679,6 +679,13 @@ async function loadSites() {
|
|||||||
: 'Off'
|
: 'Off'
|
||||||
}</dd>
|
}</dd>
|
||||||
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</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>
|
</dl>
|
||||||
</article>`
|
</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>
|
<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
|
<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>
|
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) => {
|
async (form) => {
|
||||||
const data = Object.fromEntries(form.entries());
|
const data = Object.fromEntries(form.entries());
|
||||||
try {
|
try {
|
||||||
@@ -757,18 +789,96 @@ function openSiteModal(site) {
|
|||||||
accent: form.has('accent'),
|
accent: form.has('accent'),
|
||||||
note: data.note,
|
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();
|
loadSites();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
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() {
|
function wireBadgePreset() {
|
||||||
const width = $('#modal-form [name="widthMm"]');
|
const width = $('#modal-form [name="widthMm"]');
|
||||||
const height = $('#modal-form [name="heightMm"]');
|
const height = $('#modal-form [name="heightMm"]');
|
||||||
|
|||||||
@@ -435,11 +435,49 @@ async function chooseSite() {
|
|||||||
show('site', { push: false });
|
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() {
|
async function loadSiteContext() {
|
||||||
siteConfig = await api('/api/config');
|
siteConfig = await api('/api/config');
|
||||||
const name = siteConfig.site ? siteConfig.site.name : siteConfig.siteName;
|
const name = siteConfig.site ? siteConfig.site.name : siteConfig.siteName;
|
||||||
document.title = name;
|
document.title = name;
|
||||||
$('#siteName').textContent = name;
|
$('#siteName').textContent = name;
|
||||||
|
$('#banner').alt = name;
|
||||||
|
applyTheme(siteConfig.theme, siteConfig.banner);
|
||||||
|
|
||||||
const change = $('#change-site');
|
const change = $('#change-site');
|
||||||
change.hidden = !siteConfig.multiSite;
|
change.hidden = !siteConfig.multiSite;
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import config from './config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-site branding: an uploaded banner and a small set of colours.
|
||||||
|
*
|
||||||
|
* Only three colours are settable, and the rest of the palette is derived from
|
||||||
|
* them. Exposing every colour would let someone produce an unreadable kiosk, and
|
||||||
|
* the one that matters most — the text on a coloured bar — is chosen by contrast
|
||||||
|
* rather than left to chance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_THEME = {
|
||||||
|
brand: '#0b4f4a',
|
||||||
|
signout: '#2c4a6b',
|
||||||
|
page: '#e7ecf0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BANNER_DIR = path.join(config.dataDir, 'branding');
|
||||||
|
const MAX_BANNER_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
fs.mkdirSync(BANNER_DIR, { recursive: true });
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- colour */
|
||||||
|
|
||||||
|
export function isHexColour(value) {
|
||||||
|
return /^#[0-9a-f]{6}$/i.test(String(value || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normaliseColour(value, fallback) {
|
||||||
|
return isHexColour(value) ? String(value).trim().toLowerCase() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRgb(hex) {
|
||||||
|
return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relative luminance, per WCAG, used to pick readable text over a colour. */
|
||||||
|
function luminance(hex) {
|
||||||
|
const [r, g, b] = toRgb(hex).map((channel) => {
|
||||||
|
const c = channel / 255;
|
||||||
|
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||||
|
});
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** White or near-black, whichever is easier to read on the given background. */
|
||||||
|
export function readableOn(hex) {
|
||||||
|
return luminance(hex) > 0.45 ? '#16202b' : '#ffffff';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shifts a colour towards black (negative) or white (positive). */
|
||||||
|
export function shade(hex, amount) {
|
||||||
|
const channels = toRgb(hex).map((channel) => {
|
||||||
|
const target = amount < 0 ? 0 : 255;
|
||||||
|
const value = Math.round(channel + (target - channel) * Math.abs(amount));
|
||||||
|
return Math.max(0, Math.min(255, value));
|
||||||
|
});
|
||||||
|
return `#${channels.map((c) => c.toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full palette the kiosk needs, derived from what an admin actually set. */
|
||||||
|
export function themeFor(site) {
|
||||||
|
const brand = normaliseColour(site?.colour_brand, DEFAULT_THEME.brand);
|
||||||
|
const signout = normaliseColour(site?.colour_signout, DEFAULT_THEME.signout);
|
||||||
|
const page = normaliseColour(site?.colour_page, DEFAULT_THEME.page);
|
||||||
|
return {
|
||||||
|
brand,
|
||||||
|
brandDark: shade(brand, -0.25),
|
||||||
|
onBrand: readableOn(brand),
|
||||||
|
signout,
|
||||||
|
signoutDark: shade(signout, -0.25),
|
||||||
|
onSignout: readableOn(signout),
|
||||||
|
page,
|
||||||
|
// A card needs to lift off the page whether the page is light or dark.
|
||||||
|
card: luminance(page) > 0.5 ? '#ffffff' : shade(page, 0.12),
|
||||||
|
ink: readableOn(page) === '#ffffff' ? '#f2f5f7' : '#16202b',
|
||||||
|
rule: luminance(page) > 0.5 ? shade(page, -0.12) : shade(page, 0.2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- banner */
|
||||||
|
|
||||||
|
/** Accepts a data URL from the admin console and writes it to disk. */
|
||||||
|
export function saveBanner(siteId, dataUrl) {
|
||||||
|
const match = /^data:image\/(png|jpeg|jpg|webp);base64,([A-Za-z0-9+/=]+)$/.exec(
|
||||||
|
String(dataUrl || '').trim()
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
// SVG is deliberately not accepted: it can carry script, and this file is
|
||||||
|
// served to every kiosk.
|
||||||
|
throw new Error('Use a PNG, JPEG or WebP image. PNG keeps transparency.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(match[2], 'base64');
|
||||||
|
if (buffer.length > MAX_BANNER_BYTES) throw new Error('That image is over 2 MB. Use a smaller one.');
|
||||||
|
|
||||||
|
const ext = match[1] === 'jpg' ? 'jpeg' : match[1];
|
||||||
|
const name = `site-${siteId}-${crypto.randomBytes(4).toString('hex')}.${ext}`;
|
||||||
|
fs.mkdirSync(BANNER_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(BANNER_DIR, name), buffer);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bannerAbsolutePath(name) {
|
||||||
|
if (!name) return null;
|
||||||
|
const resolved = path.resolve(BANNER_DIR, name);
|
||||||
|
if (!resolved.startsWith(path.resolve(BANNER_DIR))) return null;
|
||||||
|
return fs.existsSync(resolved) ? resolved : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBanner(name) {
|
||||||
|
const abs = bannerAbsolutePath(name);
|
||||||
|
if (abs) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(abs);
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,11 @@ CREATE TABLE IF NOT EXISTS sites (
|
|||||||
badge_show_photo INTEGER NOT NULL DEFAULT 1,
|
badge_show_photo INTEGER NOT NULL DEFAULT 1,
|
||||||
badge_accent INTEGER NOT NULL DEFAULT 0,
|
badge_accent INTEGER NOT NULL DEFAULT 0,
|
||||||
badge_note TEXT,
|
badge_note TEXT,
|
||||||
|
banner_path TEXT,
|
||||||
|
banner_height INTEGER NOT NULL DEFAULT 64,
|
||||||
|
colour_brand TEXT,
|
||||||
|
colour_signout TEXT,
|
||||||
|
colour_page TEXT,
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
@@ -153,6 +158,12 @@ addColumn('frequent_visitors', 'photo_path', 'TEXT');
|
|||||||
addColumn('frequent_visitors', 'pin_lookup', 'TEXT');
|
addColumn('frequent_visitors', 'pin_lookup', 'TEXT');
|
||||||
// Two-colour printing, for rolls like the Brother DK-22251.
|
// Two-colour printing, for rolls like the Brother DK-22251.
|
||||||
addColumn('sites', 'badge_accent', 'INTEGER NOT NULL DEFAULT 0');
|
addColumn('sites', 'badge_accent', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
// Per-site branding on the kiosk.
|
||||||
|
addColumn('sites', 'banner_path', 'TEXT');
|
||||||
|
addColumn('sites', 'banner_height', 'INTEGER NOT NULL DEFAULT 64');
|
||||||
|
addColumn('sites', 'colour_brand', 'TEXT');
|
||||||
|
addColumn('sites', 'colour_signout', 'TEXT');
|
||||||
|
addColumn('sites', 'colour_page', 'TEXT');
|
||||||
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)');
|
||||||
|
|||||||
+59
-2
@@ -10,6 +10,13 @@ import * as sheets from '../sheets.js';
|
|||||||
import * as tls from '../tls.js';
|
import * as tls from '../tls.js';
|
||||||
import * as users from '../users.js';
|
import * as users from '../users.js';
|
||||||
import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js';
|
import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_THEME,
|
||||||
|
bannerAbsolutePath,
|
||||||
|
deleteBanner,
|
||||||
|
normaliseColour,
|
||||||
|
saveBanner,
|
||||||
|
} from '../branding.js';
|
||||||
import {
|
import {
|
||||||
consumeRecoveryCode,
|
consumeRecoveryCode,
|
||||||
generateRecoveryCodes,
|
generateRecoveryCodes,
|
||||||
@@ -376,9 +383,11 @@ router.patch('/sites/:id', (req, res) => {
|
|||||||
if (!site) return res.status(404).json({ error: 'Not found.' });
|
if (!site) return res.status(404).json({ error: 'Not found.' });
|
||||||
|
|
||||||
const badge = req.body?.badge || {};
|
const badge = req.body?.badge || {};
|
||||||
|
const branding = req.body?.branding || {};
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
||||||
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ? WHERE id = ?`
|
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
|
||||||
|
colour_brand = ?, colour_signout = ?, colour_page = ?, banner_height = ? WHERE id = ?`
|
||||||
).run(
|
).run(
|
||||||
clean(req.body?.name ?? site.name, 100) || site.name,
|
clean(req.body?.name ?? site.name, 100) || site.name,
|
||||||
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
||||||
@@ -388,12 +397,60 @@ router.patch('/sites/:id', (req, res) => {
|
|||||||
Math.min(200, Math.max(15, Number(badge.heightMm ?? site.badge_height_mm) || 54)),
|
Math.min(200, Math.max(15, Number(badge.heightMm ?? site.badge_height_mm) || 54)),
|
||||||
badge.showPhoto !== undefined ? (badge.showPhoto ? 1 : 0) : site.badge_show_photo,
|
badge.showPhoto !== undefined ? (badge.showPhoto ? 1 : 0) : site.badge_show_photo,
|
||||||
badge.accent !== undefined ? (badge.accent ? 1 : 0) : site.badge_accent,
|
badge.accent !== undefined ? (badge.accent ? 1 : 0) : site.badge_accent,
|
||||||
badge.note !== undefined ? clean(badge.note, 120) || null : site.badge_note
|
badge.note !== undefined ? clean(badge.note, 120) || null : site.badge_note,
|
||||||
|
// A blank colour means "use the default", so it is stored as NULL rather than
|
||||||
|
// being quietly frozen at whatever the default happens to be today.
|
||||||
|
branding.brand !== undefined
|
||||||
|
? normaliseColour(branding.brand, null)
|
||||||
|
: site.colour_brand,
|
||||||
|
branding.signout !== undefined
|
||||||
|
? normaliseColour(branding.signout, null)
|
||||||
|
: site.colour_signout,
|
||||||
|
branding.page !== undefined ? normaliseColour(branding.page, null) : site.colour_page,
|
||||||
|
branding.bannerHeight !== undefined
|
||||||
|
? Math.min(200, Math.max(24, Number(branding.bannerHeight) || 64))
|
||||||
|
: site.banner_height
|
||||||
, site.id);
|
, site.id);
|
||||||
|
|
||||||
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
|
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- branding */
|
||||||
|
|
||||||
|
router.post('/sites/:id/banner', (req, res) => {
|
||||||
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
||||||
|
if (!site) return res.status(404).json({ error: 'Not found.' });
|
||||||
|
try {
|
||||||
|
assertSiteAllowed(req, site.id);
|
||||||
|
const name = saveBanner(site.id, req.body?.image);
|
||||||
|
if (site.banner_path) deleteBanner(site.banner_path);
|
||||||
|
db.prepare('UPDATE sites SET banner_path = ? WHERE id = ?').run(name, site.id);
|
||||||
|
res.json({ ok: true, url: `/api/branding/${site.id}/banner?t=${Date.now()}` });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(err.status || 400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/sites/:id/banner', (req, res) => {
|
||||||
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
||||||
|
if (!site) return res.status(404).json({ error: 'Not found.' });
|
||||||
|
try {
|
||||||
|
assertSiteAllowed(req, site.id);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(403).json({ error: err.message });
|
||||||
|
}
|
||||||
|
if (site.banner_path) deleteBanner(site.banner_path);
|
||||||
|
db.prepare('UPDATE sites SET banner_path = NULL WHERE id = ?').run(site.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/sites/:id/banner', (req, res) => {
|
||||||
|
const site = db.prepare('SELECT banner_path FROM sites WHERE id = ?').get(req.params.id);
|
||||||
|
const abs = site && bannerAbsolutePath(site.banner_path);
|
||||||
|
if (!abs) return res.status(404).send('No banner set.');
|
||||||
|
res.sendFile(abs);
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/sites/:id/badge-preview', (req, res) => {
|
router.get('/sites/:id/badge-preview', (req, res) => {
|
||||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
||||||
if (!site) return res.status(404).send('Not found.');
|
if (!site) return res.status(404).send('Not found.');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { savePhoto, photoAbsolutePath, copyStoredPhoto } from '../photos.js';
|
|||||||
import { mirror } from '../sheets.js';
|
import { mirror } from '../sheets.js';
|
||||||
import { verifyPin } from '../pins.js';
|
import { verifyPin } from '../pins.js';
|
||||||
import { listSites, resolveSite, badgeHtml } from '../sites.js';
|
import { listSites, resolveSite, badgeHtml } from '../sites.js';
|
||||||
|
import { themeFor, bannerAbsolutePath } from '../branding.js';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import {
|
import {
|
||||||
clean,
|
clean,
|
||||||
@@ -47,9 +48,24 @@ router.get('/config', (req, res) => {
|
|||||||
: null,
|
: null,
|
||||||
siteName: site ? site.name : config.siteName,
|
siteName: site ? site.name : config.siteName,
|
||||||
requirePhoto: config.requirePhoto,
|
requirePhoto: config.requirePhoto,
|
||||||
|
// Branding for this kiosk: colours are applied as CSS variables and the
|
||||||
|
// banner replaces the site name in the top bar.
|
||||||
|
theme: themeFor(site),
|
||||||
|
banner: site?.banner_path
|
||||||
|
? { url: `/api/branding/${site.id}/banner`, height: site.banner_height || 64 }
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The site banner. Public, because the kiosk shows it before anyone signs in. */
|
||||||
|
router.get('/branding/:id/banner', (req, res) => {
|
||||||
|
const site = db.prepare('SELECT banner_path FROM sites WHERE id = ?').get(req.params.id);
|
||||||
|
const abs = site && bannerAbsolutePath(site.banner_path);
|
||||||
|
if (!abs) return res.status(404).send('No banner set.');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||||
|
res.sendFile(abs);
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/hosts', (req, res) => {
|
router.get('/hosts', (req, res) => {
|
||||||
const site = siteFrom(req);
|
const site = siteFrom(req);
|
||||||
if (!site) return res.json([]);
|
if (!site) return res.json([]);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import db from './db.js';
|
import db from './db.js';
|
||||||
import { clean, localStamp } from './util.js';
|
import { clean, localStamp } from './util.js';
|
||||||
|
import { themeFor } from './branding.js';
|
||||||
|
|
||||||
export function listSites({ activeOnly = false } = {}) {
|
export function listSites({ activeOnly = false } = {}) {
|
||||||
const sql = `SELECT * FROM sites ${activeOnly ? 'WHERE active = 1' : ''} ORDER BY name COLLATE NOCASE`;
|
const sql = `SELECT * FROM sites ${activeOnly ? 'WHERE active = 1' : ''} ORDER BY name COLLATE NOCASE`;
|
||||||
@@ -48,6 +49,14 @@ export function shapeSite(site) {
|
|||||||
name: site.name,
|
name: site.name,
|
||||||
slug: site.slug,
|
slug: site.slug,
|
||||||
active: Boolean(site.active),
|
active: Boolean(site.active),
|
||||||
|
branding: {
|
||||||
|
hasBanner: Boolean(site.banner_path),
|
||||||
|
bannerHeight: site.banner_height || 64,
|
||||||
|
brand: site.colour_brand,
|
||||||
|
signout: site.colour_signout,
|
||||||
|
page: site.colour_page,
|
||||||
|
theme: themeFor(site),
|
||||||
|
},
|
||||||
badge: {
|
badge: {
|
||||||
enabled: Boolean(site.badge_enabled),
|
enabled: Boolean(site.badge_enabled),
|
||||||
widthMm: site.badge_width_mm,
|
widthMm: site.badge_width_mm,
|
||||||
|
|||||||
Reference in New Issue
Block a user