Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
@@ -108,8 +108,11 @@ 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:
|
||||
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, and choose whether it sits **left** or **centred** — the
|
||||
header is a three-column layout, so a centred logo is centred on the page rather than centred in
|
||||
whatever space the clock leaves over. The setting applies to the site name too, when no banner is
|
||||
uploaded. SVG is deliberately not accepted:
|
||||
it can carry script, and this file is served to every kiosk.
|
||||
|
||||
**Colours.** Three are settable:
|
||||
|
||||
+11
-4
@@ -41,20 +41,27 @@ body {
|
||||
/* ------------------------------------------------------------- chrome */
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
/* Three tracks, so the banner can sit centred on the page rather than centred
|
||||
in the space the clock happens to leave over. */
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 22px;
|
||||
background: var(--deep);
|
||||
color: var(--on-brand);
|
||||
}
|
||||
.bar .clock { grid-column: 3; justify-self: end; }
|
||||
.bar.align-left .banner,
|
||||
.bar.align-left .site { grid-column: 1; justify-self: start; }
|
||||
.bar.align-center .banner,
|
||||
.bar.align-center .site { grid-column: 2; justify-self: center; }
|
||||
|
||||
/* 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);
|
||||
max-width: min(60vw, 460px);
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="bar">
|
||||
<header class="bar align-left" id="bar">
|
||||
<img class="banner" id="banner" alt="" hidden>
|
||||
<p class="site" id="siteName">Visitor sign in</p>
|
||||
<p class="clock" id="clock"></p>
|
||||
|
||||
+10
-2
@@ -681,7 +681,7 @@ async function loadSites() {
|
||||
${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'} ·
|
||||
${s.branding.hasBanner ? `Banner set, ${s.branding.bannerAlign === 'center' ? 'centred' : 'left'}` : '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>
|
||||
@@ -763,7 +763,14 @@ function openSiteModal(site) {
|
||||
</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 class="modal-row">
|
||||
${field('Height on screen (px)', 'bannerHeight', site.branding.bannerHeight, 'number')}
|
||||
<label class="modal-field"><span>Position in the bar</span>
|
||||
<select name="bannerAlign">
|
||||
<option value="left" ${site.branding.bannerAlign !== 'center' ? 'selected' : ''}>Left</option>
|
||||
<option value="center" ${site.branding.bannerAlign === 'center' ? 'selected' : ''}>Centred</option>
|
||||
</select></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-row modal-row-3">
|
||||
${colourField('Bar and buttons', 'brand', site.branding.brand, '#0b4f4a')}
|
||||
@@ -794,6 +801,7 @@ function openSiteModal(site) {
|
||||
signout: data.signout || null,
|
||||
page: data.page || null,
|
||||
bannerHeight: Number(data.bannerHeight) || 64,
|
||||
bannerAlign: data.bannerAlign,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+6
-2
@@ -440,7 +440,7 @@ async function chooseSite() {
|
||||
* 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) {
|
||||
function applyTheme(theme, banner, align = 'left') {
|
||||
if (theme) {
|
||||
const root = document.documentElement.style;
|
||||
root.setProperty('--deep', theme.brand);
|
||||
@@ -458,6 +458,10 @@ function applyTheme(theme, banner) {
|
||||
}
|
||||
|
||||
const img = $('#banner');
|
||||
const bar = $('#bar');
|
||||
bar.classList.toggle('align-center', align === 'center');
|
||||
bar.classList.toggle('align-left', align !== 'center');
|
||||
|
||||
if (banner?.url) {
|
||||
img.src = banner.url;
|
||||
img.style.maxHeight = `${banner.height}px`;
|
||||
@@ -477,7 +481,7 @@ async function loadSiteContext() {
|
||||
document.title = name;
|
||||
$('#siteName').textContent = name;
|
||||
$('#banner').alt = name;
|
||||
applyTheme(siteConfig.theme, siteConfig.banner);
|
||||
applyTheme(siteConfig.theme, siteConfig.banner, siteConfig.headerAlign);
|
||||
|
||||
const change = $('#change-site');
|
||||
change.hidden = !siteConfig.multiSite;
|
||||
|
||||
@@ -83,6 +83,13 @@ export function themeFor(site) {
|
||||
|
||||
/* -------------------------------------------------------------- banner */
|
||||
|
||||
const ALIGNMENTS = new Set(['left', 'center']);
|
||||
|
||||
export function normaliseAlign(value, fallback = 'left') {
|
||||
const clean = String(value || '').trim().toLowerCase();
|
||||
return ALIGNMENTS.has(clean) ? clean : fallback;
|
||||
}
|
||||
|
||||
/** 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(
|
||||
|
||||
@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS sites (
|
||||
badge_note TEXT,
|
||||
banner_path TEXT,
|
||||
banner_height INTEGER NOT NULL DEFAULT 64,
|
||||
banner_align TEXT NOT NULL DEFAULT 'left',
|
||||
colour_brand TEXT,
|
||||
colour_signout TEXT,
|
||||
colour_page TEXT,
|
||||
@@ -161,6 +162,7 @@ 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', 'banner_align', "TEXT NOT NULL DEFAULT 'left'");
|
||||
addColumn('sites', 'colour_brand', 'TEXT');
|
||||
addColumn('sites', 'colour_signout', 'TEXT');
|
||||
addColumn('sites', 'colour_page', 'TEXT');
|
||||
|
||||
+7
-2
@@ -14,6 +14,7 @@ import {
|
||||
DEFAULT_THEME,
|
||||
bannerAbsolutePath,
|
||||
deleteBanner,
|
||||
normaliseAlign,
|
||||
normaliseColour,
|
||||
saveBanner,
|
||||
} from '../branding.js';
|
||||
@@ -387,7 +388,8 @@ router.patch('/sites/:id', (req, res) => {
|
||||
db.prepare(
|
||||
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
||||
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
|
||||
colour_brand = ?, colour_signout = ?, colour_page = ?, banner_height = ? WHERE id = ?`
|
||||
colour_brand = ?, colour_signout = ?, colour_page = ?, banner_height = ?,
|
||||
banner_align = ? WHERE id = ?`
|
||||
).run(
|
||||
clean(req.body?.name ?? site.name, 100) || site.name,
|
||||
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
||||
@@ -409,7 +411,10 @@ router.patch('/sites/:id', (req, res) => {
|
||||
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.banner_height,
|
||||
branding.bannerAlign !== undefined
|
||||
? normaliseAlign(branding.bannerAlign, site.banner_align)
|
||||
: site.banner_align
|
||||
, site.id);
|
||||
|
||||
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
|
||||
|
||||
@@ -54,6 +54,8 @@ router.get('/config', (req, res) => {
|
||||
banner: site?.banner_path
|
||||
? { url: `/api/branding/${site.id}/banner`, height: site.banner_height || 64 }
|
||||
: null,
|
||||
// Applies to the site name too, so the header looks the same either way.
|
||||
headerAlign: site?.banner_align || 'left',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ export function shapeSite(site) {
|
||||
branding: {
|
||||
hasBanner: Boolean(site.banner_path),
|
||||
bannerHeight: site.banner_height || 64,
|
||||
bannerAlign: site.banner_align || 'left',
|
||||
brand: site.colour_brand,
|
||||
signout: site.colour_signout,
|
||||
page: site.colour_page,
|
||||
|
||||
Reference in New Issue
Block a user