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
|
**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
|
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
|
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
|
transparency. When a banner is set it replaces the site name in the kiosk header. Set the on-screen height in
|
||||||
on-screen height in pixels to suit the shape of your logo. SVG is deliberately not accepted:
|
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.
|
it can carry script, and this file is served to every kiosk.
|
||||||
|
|
||||||
**Colours.** Three are settable:
|
**Colours.** Three are settable:
|
||||||
|
|||||||
+11
-4
@@ -41,20 +41,27 @@ body {
|
|||||||
/* ------------------------------------------------------------- chrome */
|
/* ------------------------------------------------------------- chrome */
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
display: flex;
|
/* Three tracks, so the banner can sit centred on the page rather than centred
|
||||||
align-items: baseline;
|
in the space the clock happens to leave over. */
|
||||||
justify-content: space-between;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 14px 22px;
|
padding: 14px 22px;
|
||||||
background: var(--deep);
|
background: var(--deep);
|
||||||
color: var(--on-brand);
|
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
|
/* An uploaded banner sits in the bar in place of the site name. Transparent PNGs
|
||||||
are the point, so nothing is painted behind it. */
|
are the point, so nothing is painted behind it. */
|
||||||
.banner {
|
.banner {
|
||||||
max-height: 64px;
|
max-height: 64px;
|
||||||
max-width: min(70%, 460px);
|
max-width: min(60vw, 460px);
|
||||||
width: auto;
|
width: auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header class="bar">
|
<header class="bar align-left" id="bar">
|
||||||
<img class="banner" id="banner" alt="" hidden>
|
<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>
|
||||||
|
|||||||
+10
-2
@@ -681,7 +681,7 @@ async function loadSites() {
|
|||||||
${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>
|
<dt>Kiosk branding</dt>
|
||||||
<dd>
|
<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.brand)}"></span>
|
||||||
<span class="swatch" style="background:${esc(s.branding.theme.signout)}"></span>
|
<span class="swatch" style="background:${esc(s.branding.theme.signout)}"></span>
|
||||||
<span class="swatch" style="background:${esc(s.branding.theme.page)}"></span>
|
<span class="swatch" style="background:${esc(s.branding.theme.page)}"></span>
|
||||||
@@ -763,7 +763,14 @@ function openSiteModal(site) {
|
|||||||
</div>
|
</div>
|
||||||
<p class="hint">A PNG with a transparent background works best — it sits straight on the
|
<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>
|
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>
|
||||||
<div class="modal-row modal-row-3">
|
<div class="modal-row modal-row-3">
|
||||||
${colourField('Bar and buttons', 'brand', site.branding.brand, '#0b4f4a')}
|
${colourField('Bar and buttons', 'brand', site.branding.brand, '#0b4f4a')}
|
||||||
@@ -794,6 +801,7 @@ function openSiteModal(site) {
|
|||||||
signout: data.signout || null,
|
signout: data.signout || null,
|
||||||
page: data.page || null,
|
page: data.page || null,
|
||||||
bannerHeight: Number(data.bannerHeight) || 64,
|
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
|
* 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.
|
* dark logo colour cannot end up with dark text on it.
|
||||||
*/
|
*/
|
||||||
function applyTheme(theme, banner) {
|
function applyTheme(theme, banner, align = 'left') {
|
||||||
if (theme) {
|
if (theme) {
|
||||||
const root = document.documentElement.style;
|
const root = document.documentElement.style;
|
||||||
root.setProperty('--deep', theme.brand);
|
root.setProperty('--deep', theme.brand);
|
||||||
@@ -458,6 +458,10 @@ function applyTheme(theme, banner) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const img = $('#banner');
|
const img = $('#banner');
|
||||||
|
const bar = $('#bar');
|
||||||
|
bar.classList.toggle('align-center', align === 'center');
|
||||||
|
bar.classList.toggle('align-left', align !== 'center');
|
||||||
|
|
||||||
if (banner?.url) {
|
if (banner?.url) {
|
||||||
img.src = banner.url;
|
img.src = banner.url;
|
||||||
img.style.maxHeight = `${banner.height}px`;
|
img.style.maxHeight = `${banner.height}px`;
|
||||||
@@ -477,7 +481,7 @@ async function loadSiteContext() {
|
|||||||
document.title = name;
|
document.title = name;
|
||||||
$('#siteName').textContent = name;
|
$('#siteName').textContent = name;
|
||||||
$('#banner').alt = name;
|
$('#banner').alt = name;
|
||||||
applyTheme(siteConfig.theme, siteConfig.banner);
|
applyTheme(siteConfig.theme, siteConfig.banner, siteConfig.headerAlign);
|
||||||
|
|
||||||
const change = $('#change-site');
|
const change = $('#change-site');
|
||||||
change.hidden = !siteConfig.multiSite;
|
change.hidden = !siteConfig.multiSite;
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ export function themeFor(site) {
|
|||||||
|
|
||||||
/* -------------------------------------------------------------- banner */
|
/* -------------------------------------------------------------- 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. */
|
/** Accepts a data URL from the admin console and writes it to disk. */
|
||||||
export function saveBanner(siteId, dataUrl) {
|
export function saveBanner(siteId, dataUrl) {
|
||||||
const match = /^data:image\/(png|jpeg|jpg|webp);base64,([A-Za-z0-9+/=]+)$/.exec(
|
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,
|
badge_note TEXT,
|
||||||
banner_path TEXT,
|
banner_path TEXT,
|
||||||
banner_height INTEGER NOT NULL DEFAULT 64,
|
banner_height INTEGER NOT NULL DEFAULT 64,
|
||||||
|
banner_align TEXT NOT NULL DEFAULT 'left',
|
||||||
colour_brand TEXT,
|
colour_brand TEXT,
|
||||||
colour_signout TEXT,
|
colour_signout TEXT,
|
||||||
colour_page TEXT,
|
colour_page TEXT,
|
||||||
@@ -161,6 +162,7 @@ addColumn('sites', 'badge_accent', 'INTEGER NOT NULL DEFAULT 0');
|
|||||||
// Per-site branding on the kiosk.
|
// Per-site branding on the kiosk.
|
||||||
addColumn('sites', 'banner_path', 'TEXT');
|
addColumn('sites', 'banner_path', 'TEXT');
|
||||||
addColumn('sites', 'banner_height', 'INTEGER NOT NULL DEFAULT 64');
|
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_brand', 'TEXT');
|
||||||
addColumn('sites', 'colour_signout', 'TEXT');
|
addColumn('sites', 'colour_signout', 'TEXT');
|
||||||
addColumn('sites', 'colour_page', 'TEXT');
|
addColumn('sites', 'colour_page', 'TEXT');
|
||||||
|
|||||||
+7
-2
@@ -14,6 +14,7 @@ import {
|
|||||||
DEFAULT_THEME,
|
DEFAULT_THEME,
|
||||||
bannerAbsolutePath,
|
bannerAbsolutePath,
|
||||||
deleteBanner,
|
deleteBanner,
|
||||||
|
normaliseAlign,
|
||||||
normaliseColour,
|
normaliseColour,
|
||||||
saveBanner,
|
saveBanner,
|
||||||
} from '../branding.js';
|
} from '../branding.js';
|
||||||
@@ -387,7 +388,8 @@ router.patch('/sites/:id', (req, res) => {
|
|||||||
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 = ?,
|
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(
|
).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,
|
||||||
@@ -409,7 +411,10 @@ router.patch('/sites/:id', (req, res) => {
|
|||||||
branding.page !== undefined ? normaliseColour(branding.page, null) : site.colour_page,
|
branding.page !== undefined ? normaliseColour(branding.page, null) : site.colour_page,
|
||||||
branding.bannerHeight !== undefined
|
branding.bannerHeight !== undefined
|
||||||
? Math.min(200, Math.max(24, Number(branding.bannerHeight) || 64))
|
? 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);
|
, 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)));
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ router.get('/config', (req, res) => {
|
|||||||
banner: site?.banner_path
|
banner: site?.banner_path
|
||||||
? { url: `/api/branding/${site.id}/banner`, height: site.banner_height || 64 }
|
? { url: `/api/branding/${site.id}/banner`, height: site.banner_height || 64 }
|
||||||
: null,
|
: 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: {
|
branding: {
|
||||||
hasBanner: Boolean(site.banner_path),
|
hasBanner: Boolean(site.banner_path),
|
||||||
bannerHeight: site.banner_height || 64,
|
bannerHeight: site.banner_height || 64,
|
||||||
|
bannerAlign: site.banner_align || 'left',
|
||||||
brand: site.colour_brand,
|
brand: site.colour_brand,
|
||||||
signout: site.colour_signout,
|
signout: site.colour_signout,
|
||||||
page: site.colour_page,
|
page: site.colour_page,
|
||||||
|
|||||||
Reference in New Issue
Block a user