From a7564b9033aef6f3c5e6e2fe7c1f8a576707aec2 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 1 Sep 2026 15:24:03 +1000 Subject: [PATCH] Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA --- public/css/admin.css | 11 ++++++++--- public/css/kiosk.css | 24 ++++++++++++++++++------ public/css/login.css | 4 ++++ public/index.html | 1 - public/js/admin.js | 12 ++++++++---- public/js/kiosk.js | 21 +++++++++++++++------ src/sites.js | 6 ++++-- 7 files changed, 57 insertions(+), 22 deletions(-) diff --git a/public/css/admin.css b/public/css/admin.css index 814bf3f..aa7365e 100644 --- a/public/css/admin.css +++ b/public/css/admin.css @@ -11,6 +11,10 @@ * { box-sizing: border-box; } +/* Author display rules beat the browser's [hidden] { display: none }, and several + elements here are toggled with that attribute. */ +[hidden] { display: none !important; } + body { margin: 0; background: var(--paper); @@ -301,8 +305,9 @@ tr.row-bad td { background: #fdf0f2; } .photo-frame { position: relative; - width: 148px; - aspect-ratio: 4 / 3; + width: 152px; + /* Square, to match the kiosk camera and the frame printed on the badge. */ + aspect-ratio: 1 / 1; margin-bottom: 12px; background: #eef1f4; border: 1px solid var(--rule); @@ -317,8 +322,8 @@ tr.row-bad td { background: #fdf0f2; } height: 100%; object-fit: cover; display: block; - transform: scaleX(-1); } +.photo-frame video { transform: scaleX(-1); } .photo-empty { margin: 0; padding: 0 10px; color: var(--muted); font-size: 13px; text-align: center; } .photo-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; } diff --git a/public/css/kiosk.css b/public/css/kiosk.css index 5475b58..ca6a2c3 100644 --- a/public/css/kiosk.css +++ b/public/css/kiosk.css @@ -14,6 +14,11 @@ * { box-sizing: border-box; } +/* The rules below set display on elements that are toggled with the hidden + attribute, and an author rule beats the browser's [hidden] { display: none }. + Without this the captured still renders underneath the live camera feed. */ +[hidden] { display: none !important; } + html, body { margin: 0; min-height: 100%; @@ -48,8 +53,9 @@ body { .foot { display: flex; - justify-content: space-between; + justify-content: flex-end; gap: 16px; + min-height: 20px; padding: 14px 22px 20px; font-size: 12.5px; color: var(--muted); @@ -215,19 +221,25 @@ a:focus-visible { .camera { position: relative; - aspect-ratio: 4 / 3; + /* Square, matching the crop that is saved and the frame printed on the badge, + so the visitor sees exactly what ends up on their pass. */ + aspect-ratio: 1 / 1; + width: 100%; + max-width: 380px; + margin: 0 auto 18px; background: #0f1720; border-radius: 3px; overflow: hidden; - margin-bottom: 18px; } .camera video, .camera img { width: 100%; height: 100%; object-fit: cover; display: block; - transform: scaleX(-1); } +/* The live feed is mirrored because that is how people expect to see themselves. + The captured still is not: it shows what will actually print. */ +.camera video { transform: scaleX(-1); } .camera-error { margin: -8px 0 18px; padding: 14px; @@ -250,7 +262,7 @@ a:focus-visible { } .review dt { color: var(--muted); } .review dd { margin: 0; } -.review img { width: 78px; border-radius: 3px; display: block; } +.review img { width: 78px; aspect-ratio: 1 / 1; object-fit: cover; border-radius: 3px; display: block; } /* ------------------------------------------------------------- done */ @@ -325,7 +337,7 @@ a:focus-visible { .foot-link { border: none; background: none; - padding: 0 12px 0 0; + padding: 0; color: var(--muted); font: inherit; font-size: 12.5px; diff --git a/public/css/login.css b/public/css/login.css index c2a4ad6..ed17666 100644 --- a/public/css/login.css +++ b/public/css/login.css @@ -10,6 +10,10 @@ * { box-sizing: border-box; } +/* Author display rules beat the browser's [hidden] { display: none }, and the + sign in screens are toggled with that attribute. */ +[hidden] { display: none !important; } + body { margin: 0; min-height: 100vh; diff --git a/public/index.html b/public/index.html index 5c22e9d..cf67ed3 100644 --- a/public/index.html +++ b/public/index.html @@ -223,7 +223,6 @@ diff --git a/public/js/admin.js b/public/js/admin.js index bcb9b78..1d685e8 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -396,11 +396,15 @@ function wirePhotoEditor() { }); $('#photo-shoot').addEventListener('click', () => { + // Square, cropped from the centre, to match the kiosk camera and the badge. + const side = Math.min(video.videoWidth, video.videoHeight); + if (!side) return toast('The camera is not ready yet. Try again in a moment.', true); const canvas = document.createElement('canvas'); - const width = 720; - canvas.width = width; - canvas.height = Math.round((video.videoHeight / video.videoWidth) * width) || 540; - canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height); + canvas.width = 640; + canvas.height = 640; + canvas + .getContext('2d') + .drawImage(video, (video.videoWidth - side) / 2, (video.videoHeight - side) / 2, side, side, 0, 0, 640, 640); photoEditor.dataUrl = canvas.toDataURL('image/jpeg', 0.72); photoEditor.remove = false; stopPhotoCamera(); diff --git a/public/js/kiosk.js b/public/js/kiosk.js index 258b571..1c1b08f 100644 --- a/public/js/kiosk.js +++ b/public/js/kiosk.js @@ -258,15 +258,24 @@ function stopCamera() { $('#cam-video').srcObject = null; } +const PHOTO_SIZE = 640; + +/** + * Takes a square photo, cropped from the centre of whatever shape the camera + * gives us. The preview frame, the saved file and the space on the badge are all + * square, so nothing is stretched and what the visitor sees is what prints. + */ function capture() { const video = $('#cam-video'); const canvas = $('#cam-canvas'); - const width = 720; - const height = Math.round((video.videoHeight / video.videoWidth) * width) || 540; - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d'); - ctx.drawImage(video, 0, 0, width, height); + const side = Math.min(video.videoWidth, video.videoHeight); + if (!side) return say('The camera is not ready yet. Try again in a moment.'); + + const sx = (video.videoWidth - side) / 2; + const sy = (video.videoHeight - side) / 2; + canvas.width = PHOTO_SIZE; + canvas.height = PHOTO_SIZE; + canvas.getContext('2d').drawImage(video, sx, sy, side, side, 0, 0, PHOTO_SIZE, PHOTO_SIZE); state.photo = canvas.toDataURL('image/jpeg', 0.72); const shot = $('#cam-shot'); diff --git a/src/sites.js b/src/sites.js index b7872a6..54010c5 100644 --- a/src/sites.js +++ b/src/sites.js @@ -86,7 +86,9 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = { const pad = unit * 0.07; const nameSize = Math.max(3.2, unit * (portrait ? 0.105 : 0.115)); const bodySize = Math.max(2.0, unit * (portrait ? 0.055 : 0.062)); - const photoWidth = portrait ? unit * 0.52 : unit * 0.42; + // Square, matching the crop taken at the kiosk. Slightly smaller on a portrait + // badge than the old 4:3 frame, so a square photo still leaves room for the name. + const photoWidth = portrait ? unit * 0.46 : unit * 0.42; // Red only appears on a two-colour roll (DK-22251 on the QL-820NWB). Anywhere // else it prints as grey, so it is off unless the site opts in. @@ -133,7 +135,7 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = { } .photo { width: ${photoWidth}mm; - ${portrait ? `height: ${photoWidth * 0.78}mm;` : ''} + height: ${photoWidth}mm; flex: 0 0 auto; object-fit: cover; border: 0.3mm solid #000;