Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+19
-6
@@ -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 {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<body>
|
||||
|
||||
<header class="bar">
|
||||
<img class="banner" id="banner" alt="" hidden>
|
||||
<p class="site" id="siteName">Visitor sign in</p>
|
||||
<p class="clock" id="clock"></p>
|
||||
</header>
|
||||
|
||||
+113
-3
@@ -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"]');
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user