Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
+195
-10
@@ -254,7 +254,7 @@ async function loadRecurring() {
|
||||
: '';
|
||||
|
||||
$('#recurring-table').innerHTML = table(
|
||||
['Visitor', 'Mobile', 'Site', 'Check', 'Expiry', 'Status', ''],
|
||||
['', 'Visitor', 'Mobile', 'Site', 'Check', 'Expiry', 'Status', ''],
|
||||
rows.map(
|
||||
(p) => `<tr class="${p.expiry.status === 'expired' ? 'row-bad' : p.expiry.status === 'expiring' ? 'row-warn' : ''}">
|
||||
<td><strong>${esc(p.firstName)} ${esc(p.lastName)}</strong>
|
||||
@@ -316,7 +316,7 @@ function siteOptions(selectedId, { anyLabel = 'Any site' } = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = false } = {}) {
|
||||
function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = false, onOpen, onClose } = {}) {
|
||||
$('#modal-title').textContent = title;
|
||||
$('#modal-body').innerHTML = bodyHtml;
|
||||
$('#modal-save').textContent = saveLabel;
|
||||
@@ -324,13 +324,124 @@ function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = f
|
||||
const modal = $('#modal');
|
||||
modal.returnValue = '';
|
||||
modal.showModal();
|
||||
onOpen?.();
|
||||
modal.onclose = () => {
|
||||
$('#modal-cancel').hidden = false;
|
||||
$('#modal-save').textContent = 'Save';
|
||||
onClose?.();
|
||||
if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form')));
|
||||
};
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- visitor photo editor */
|
||||
// A recurring visitor can have a photo kept on file, so the kiosk never asks them
|
||||
// to pose again. It can come from this machine's camera or from a file.
|
||||
|
||||
const photoEditor = { dataUrl: null, remove: false, stream: null };
|
||||
|
||||
function photoEditorMarkup(person) {
|
||||
const existing = person?.hasPhoto ? `/admin/api/frequent/${person.id}/photo?t=${Date.now()}` : null;
|
||||
return `
|
||||
<div class="photo-editor">
|
||||
<div class="photo-frame">
|
||||
<img id="photo-preview" alt="Photo on file"${existing ? ` src="${existing}"` : ' hidden'}>
|
||||
<video id="photo-video" playsinline muted autoplay hidden></video>
|
||||
<p class="photo-empty" id="photo-empty"${existing ? ' hidden' : ''}>No photo on file</p>
|
||||
</div>
|
||||
<div class="photo-actions">
|
||||
<button type="button" class="ghost" id="photo-camera">Use the camera</button>
|
||||
<button type="button" class="ghost" id="photo-shoot" hidden>Take it</button>
|
||||
<label class="ghost photo-upload">Upload a file
|
||||
<input type="file" id="photo-file" accept="image/*" hidden>
|
||||
</label>
|
||||
<button type="button" class="ghost danger" id="photo-clear"${existing ? '' : ' hidden'}>Remove</button>
|
||||
</div>
|
||||
<p class="hint">With a photo saved here, this visitor signs in with their PIN and their
|
||||
pass prints straight away — the kiosk does not ask them to pose.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function wirePhotoEditor() {
|
||||
photoEditor.dataUrl = null;
|
||||
photoEditor.remove = false;
|
||||
|
||||
const preview = $('#photo-preview');
|
||||
const video = $('#photo-video');
|
||||
const empty = $('#photo-empty');
|
||||
|
||||
const showImage = (src) => {
|
||||
preview.src = src;
|
||||
preview.hidden = false;
|
||||
video.hidden = true;
|
||||
empty.hidden = true;
|
||||
$('#photo-shoot').hidden = true;
|
||||
$('#photo-camera').textContent = 'Retake';
|
||||
$('#photo-clear').hidden = false;
|
||||
};
|
||||
|
||||
$('#photo-camera').addEventListener('click', async () => {
|
||||
try {
|
||||
photoEditor.stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'user', width: { ideal: 960 } },
|
||||
audio: false,
|
||||
});
|
||||
video.srcObject = photoEditor.stream;
|
||||
video.hidden = false;
|
||||
preview.hidden = true;
|
||||
empty.hidden = true;
|
||||
$('#photo-shoot').hidden = false;
|
||||
} catch {
|
||||
toast('No camera available on this machine. Upload a file instead.', true);
|
||||
}
|
||||
});
|
||||
|
||||
$('#photo-shoot').addEventListener('click', () => {
|
||||
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);
|
||||
photoEditor.dataUrl = canvas.toDataURL('image/jpeg', 0.72);
|
||||
photoEditor.remove = false;
|
||||
stopPhotoCamera();
|
||||
showImage(photoEditor.dataUrl);
|
||||
});
|
||||
|
||||
$('#photo-file').addEventListener('change', async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
if (file.size > 4 * 1024 * 1024) return toast('That image is over 4 MB. Use a smaller one.', true);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
photoEditor.dataUrl = reader.result;
|
||||
photoEditor.remove = false;
|
||||
stopPhotoCamera();
|
||||
showImage(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
$('#photo-clear').addEventListener('click', () => {
|
||||
photoEditor.dataUrl = null;
|
||||
photoEditor.remove = true;
|
||||
stopPhotoCamera();
|
||||
preview.hidden = true;
|
||||
video.hidden = true;
|
||||
empty.hidden = false;
|
||||
$('#photo-shoot').hidden = true;
|
||||
$('#photo-clear').hidden = true;
|
||||
$('#photo-camera').textContent = 'Use the camera';
|
||||
});
|
||||
}
|
||||
|
||||
function stopPhotoCamera() {
|
||||
if (!photoEditor.stream) return;
|
||||
photoEditor.stream.getTracks().forEach((t) => t.stop());
|
||||
photoEditor.stream = null;
|
||||
const video = $('#photo-video');
|
||||
if (video) video.srcObject = null;
|
||||
}
|
||||
|
||||
function openRecurringModal(person = null) {
|
||||
const editing = Boolean(person);
|
||||
openModal(
|
||||
@@ -354,12 +465,16 @@ function openRecurringModal(person = null) {
|
||||
<select name="defaultHostId">${hostOptions(person?.defaultHostId)}</select></label>
|
||||
${field('PIN (leave blank to generate one)', 'pin', '')}
|
||||
${field('Notes', 'notes', person?.notes)}
|
||||
<h4 class="modal-section">Photo on file</h4>
|
||||
${photoEditorMarkup(person)}
|
||||
${editing ? `<label class="inline"><input type="checkbox" name="active" ${person.active ? 'checked' : ''}> Active</label>` : ''}
|
||||
`,
|
||||
async (form) => {
|
||||
const payload = Object.fromEntries(form.entries());
|
||||
payload.active = editing ? form.has('active') : true;
|
||||
if (!payload.pin) delete payload.pin;
|
||||
if (photoEditor.dataUrl) payload.photo = photoEditor.dataUrl;
|
||||
if (photoEditor.remove) payload.removePhoto = true;
|
||||
try {
|
||||
const saved = editing
|
||||
? await api(`/frequent/${person.id}`, { method: 'PATCH', body: payload })
|
||||
@@ -370,7 +485,8 @@ function openRecurringModal(person = null) {
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ onOpen: wirePhotoEditor, onClose: stopPhotoCamera }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -501,7 +617,7 @@ async function loadSites() {
|
||||
<dt>Badge printing</dt>
|
||||
<dd>${
|
||||
s.badge.enabled
|
||||
? `On — ${s.badge.widthMm} × ${s.badge.heightMm} mm${s.badge.showPhoto ? ', with photo' : ''}`
|
||||
? `On — ${s.badge.widthMm} × ${s.badge.heightMm} mm${s.badge.showPhoto ? ', with photo' : ''}${s.badge.accent ? ', two-colour' : ''}`
|
||||
: 'Off'
|
||||
}</dd>
|
||||
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''}
|
||||
@@ -522,6 +638,22 @@ async function loadSites() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label stock, so nobody has to measure a roll. The Brother QL-820NWB takes
|
||||
* 12–62 mm wide media and prints up to 60.96 mm across, so anything wider than
|
||||
* 62 mm is for a different printer.
|
||||
*/
|
||||
const LABEL_PRESETS = [
|
||||
{ id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true },
|
||||
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true },
|
||||
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, accent: true },
|
||||
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false },
|
||||
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false },
|
||||
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false },
|
||||
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true },
|
||||
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true },
|
||||
];
|
||||
|
||||
function openSiteModal(site) {
|
||||
openModal(
|
||||
`Edit ${site.name}`,
|
||||
@@ -530,14 +662,26 @@ function openSiteModal(site) {
|
||||
<label class="inline"><input type="checkbox" name="active" ${site.active ? 'checked' : ''}> Active</label>
|
||||
<h4 class="modal-section">Badge printing</h4>
|
||||
<label class="inline"><input type="checkbox" name="badgeEnabled" ${site.badge.enabled ? 'checked' : ''}> Print a badge after each sign in</label>
|
||||
<label class="modal-field"><span>Label stock</span>
|
||||
<select name="preset" id="badge-preset">
|
||||
<option value="">Custom size</option>
|
||||
${LABEL_PRESETS.map(
|
||||
(p) =>
|
||||
`<option value="${p.id}" ${
|
||||
Number(site.badge.widthMm) === p.w && Number(site.badge.heightMm) === p.h ? 'selected' : ''
|
||||
}>${esc(p.label)}</option>`
|
||||
).join('')}
|
||||
</select></label>
|
||||
<div class="modal-row">
|
||||
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
|
||||
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
|
||||
</div>
|
||||
<label class="inline"><input type="checkbox" name="showPhoto" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
|
||||
${field('Line printed at the bottom', 'note', site.badge.note)}
|
||||
<p class="hint">Common label sizes: 86 × 54 mm (card), 100 × 62 mm and 62 × 29 mm (Brother),
|
||||
101 × 54 mm (Dymo). Preview before you commit a roll to it.</p>`,
|
||||
<p class="hint" id="badge-warning" hidden></p>
|
||||
<label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
|
||||
<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)}`,
|
||||
async (form) => {
|
||||
const data = Object.fromEntries(form.entries());
|
||||
try {
|
||||
@@ -552,19 +696,60 @@ function openSiteModal(site) {
|
||||
widthMm: Number(data.widthMm),
|
||||
heightMm: Number(data.heightMm),
|
||||
showPhoto: form.has('showPhoto'),
|
||||
accent: form.has('accent'),
|
||||
note: data.note,
|
||||
},
|
||||
},
|
||||
});
|
||||
toast('Site saved.');
|
||||
toast('Site saved. Preview the badge before committing a roll to it.');
|
||||
loadSites();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ onOpen: wireBadgePreset }
|
||||
);
|
||||
}
|
||||
|
||||
function wireBadgePreset() {
|
||||
const width = $('#modal-form [name="widthMm"]');
|
||||
const height = $('#modal-form [name="heightMm"]');
|
||||
const warning = $('#badge-warning');
|
||||
|
||||
const check = () => {
|
||||
const w = Number(width.value);
|
||||
const h = Number(height.value);
|
||||
if (w > 62) {
|
||||
warning.hidden = false;
|
||||
warning.className = 'hint warn';
|
||||
warning.textContent = `${w} mm is wider than a QL-820NWB can take — it handles 12 to 62 mm media, printing up to 60.96 mm across. Fine for a different printer.`;
|
||||
} else if (h < w * 1.2 && $('#badge-photo').checked && w <= 40) {
|
||||
warning.hidden = false;
|
||||
warning.className = 'hint warn';
|
||||
warning.textContent = 'A photo on a label this narrow leaves very little room for the name. Consider turning the photo off.';
|
||||
} else {
|
||||
warning.hidden = true;
|
||||
}
|
||||
};
|
||||
|
||||
$('#badge-preset').addEventListener('change', (event) => {
|
||||
const preset = LABEL_PRESETS.find((p) => p.id === event.target.value);
|
||||
if (!preset) return;
|
||||
width.value = preset.w;
|
||||
height.value = preset.h;
|
||||
$('#badge-photo').checked = preset.photo;
|
||||
$('#badge-accent').checked = Boolean(preset.accent);
|
||||
check();
|
||||
});
|
||||
|
||||
[width, height].forEach((el) => el.addEventListener('input', () => {
|
||||
$('#badge-preset').value = '';
|
||||
check();
|
||||
}));
|
||||
$('#badge-photo').addEventListener('change', check);
|
||||
check();
|
||||
}
|
||||
|
||||
$('#new-site').addEventListener('click', () => {
|
||||
openModal('Add a site', field('Site name', 'name', ''), async (form) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user