From 74792b276479e75ed9c12676da378279c1b156dc Mon Sep 17 00:00:00 2001 From: jessikitty Date: Wed, 2 Sep 2026 16:07:52 +1000 Subject: [PATCH] Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA --- README.md | 31 +++++++++++ public/css/admin.css | 49 +++++++++++++++++ public/css/kiosk.css | 25 ++++++--- public/index.html | 1 + public/js/admin.js | 116 ++++++++++++++++++++++++++++++++++++++-- public/js/kiosk.js | 38 +++++++++++++ src/branding.js | 123 +++++++++++++++++++++++++++++++++++++++++++ src/db.js | 11 ++++ src/routes/admin.js | 61 ++++++++++++++++++++- src/routes/kiosk.js | 16 ++++++ src/sites.js | 9 ++++ 11 files changed, 469 insertions(+), 11 deletions(-) create mode 100644 src/branding.js diff --git a/README.md b/README.md index a6d2367..29b09cf 100644 --- a/README.md +++ b/README.md @@ -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, 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 Turn it on per site under **Sites → Edit**. After a visitor signs in, the kiosk loads the badge diff --git a/public/css/admin.css b/public/css/admin.css index 00d5424..1cbdab1 100644 --- a/public/css/admin.css +++ b/public/css/admin.css @@ -351,3 +351,52 @@ tr.row-bad td { background: #fdf0f2; } /* The confirmation for a destructive action should not look like a Save. */ #modal.destructive { border-top-color: 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; +} diff --git a/public/css/kiosk.css b/public/css/kiosk.css index 982a600..f14dcfd 100644 --- a/public/css/kiosk.css +++ b/public/css/kiosk.css @@ -6,8 +6,10 @@ --rule: #c9d3dc; --deep: #0b4f4a; --deep-dark: #083a36; + --on-brand: #ffffff; --exit: #2c4a6b; --exit-dark: #1f3650; + --on-exit: #ffffff; --alert: #96162f; --focus: #0b4f4a; } @@ -45,7 +47,17 @@ body { gap: 16px; padding: 14px 22px; 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; } @@ -108,11 +120,11 @@ h2 { font-size: 25px; margin: 4px 0 20px; } border-left: 7px solid var(--deep-dark); border-radius: 3px; background: var(--deep); - color: #fff; + color: var(--on-brand); font: inherit; 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-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; } .text-action:hover { background: var(--card); } +.text-action, .field input, .picker, .choice, .host-option { color: var(--ink); } /* ------------------------------------------------------- step rail */ @@ -280,11 +293,11 @@ a:focus-visible { padding: 7px 14px; border-radius: 2px; background: var(--deep); - color: #fff; + color: var(--on-brand); font-size: 14px; font-weight: 600; } -#screen-done-out .mark { background: var(--exit); } +#screen-done-out .mark { background: var(--exit); color: var(--on-exit); } /* ------------------------------------------------------------ buttons */ @@ -306,7 +319,7 @@ a:focus-visible { .nav .primary { border: 1px solid var(--deep); background: var(--deep); - color: #fff; + color: var(--on-brand); font-weight: 600; } .nav .ghost { diff --git a/public/index.html b/public/index.html index 39a1934..72c138d 100644 --- a/public/index.html +++ b/public/index.html @@ -11,6 +11,7 @@
+

Visitor sign in

diff --git a/public/js/admin.js b/public/js/admin.js index 9772f95..176bda0 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -679,6 +679,13 @@ async function loadSites() { : 'Off' } ${s.badge.note ? `
Badge note
${esc(s.badge.note)}
` : ''} +
Kiosk branding
+
+ ${s.branding.hasBanner ? 'Banner set' : 'No banner'} · + + + +
` ) @@ -739,7 +746,32 @@ function openSiteModal(site) {

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.

- ${field('Line printed at the bottom', 'note', site.badge.note)}`, + ${field('Line printed at the bottom', 'note', site.badge.note)} + + + +

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.

`, 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 ``; +} + +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"]'); diff --git a/public/js/kiosk.js b/public/js/kiosk.js index c50c2d3..79f5c6d 100644 --- a/public/js/kiosk.js +++ b/public/js/kiosk.js @@ -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; diff --git a/src/branding.js b/src/branding.js new file mode 100644 index 0000000..86b7934 --- /dev/null +++ b/src/branding.js @@ -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 */ + } + } +} diff --git a/src/db.js b/src/db.js index 60c2119..309f93c 100644 --- a/src/db.js +++ b/src/db.js @@ -22,6 +22,11 @@ CREATE TABLE IF NOT EXISTS sites ( badge_show_photo INTEGER NOT NULL DEFAULT 1, badge_accent INTEGER NOT NULL DEFAULT 0, 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, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -153,6 +158,12 @@ addColumn('frequent_visitors', 'photo_path', 'TEXT'); addColumn('frequent_visitors', 'pin_lookup', 'TEXT'); // Two-colour printing, for rolls like the Brother DK-22251. 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_hosts_site ON hosts(site_id, active)'); diff --git a/src/routes/admin.js b/src/routes/admin.js index b88f229..4380ac5 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -10,6 +10,13 @@ import * as sheets from '../sheets.js'; import * as tls from '../tls.js'; import * as users from '../users.js'; import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js'; +import { + DEFAULT_THEME, + bannerAbsolutePath, + deleteBanner, + normaliseColour, + saveBanner, +} from '../branding.js'; import { consumeRecoveryCode, generateRecoveryCodes, @@ -376,9 +383,11 @@ router.patch('/sites/:id', (req, res) => { if (!site) return res.status(404).json({ error: 'Not found.' }); const badge = req.body?.badge || {}; + const branding = req.body?.branding || {}; db.prepare( `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( clean(req.body?.name ?? site.name, 100) || site.name, 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)), badge.showPhoto !== undefined ? (badge.showPhoto ? 1 : 0) : site.badge_show_photo, 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); 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) => { const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); if (!site) return res.status(404).send('Not found.'); diff --git a/src/routes/kiosk.js b/src/routes/kiosk.js index 330885c..3ff2017 100644 --- a/src/routes/kiosk.js +++ b/src/routes/kiosk.js @@ -6,6 +6,7 @@ import { savePhoto, photoAbsolutePath, copyStoredPhoto } from '../photos.js'; import { mirror } from '../sheets.js'; import { verifyPin } from '../pins.js'; import { listSites, resolveSite, badgeHtml } from '../sites.js'; +import { themeFor, bannerAbsolutePath } from '../branding.js'; import fs from 'node:fs'; import { clean, @@ -47,9 +48,24 @@ router.get('/config', (req, res) => { : null, siteName: site ? site.name : config.siteName, 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) => { const site = siteFrom(req); if (!site) return res.json([]); diff --git a/src/sites.js b/src/sites.js index 58e98b0..3eb0fd6 100644 --- a/src/sites.js +++ b/src/sites.js @@ -1,5 +1,6 @@ import db from './db.js'; import { clean, localStamp } from './util.js'; +import { themeFor } from './branding.js'; export function listSites({ activeOnly = false } = {}) { 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, slug: site.slug, 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: { enabled: Boolean(site.badge_enabled), widthMm: site.badge_width_mm,