Public Access
205 lines
6.6 KiB
JavaScript
205 lines
6.6 KiB
JavaScript
import db from './db.js';
|
|
import { clean, localStamp } from './util.js';
|
|
|
|
export function listSites({ activeOnly = false } = {}) {
|
|
const sql = `SELECT * FROM sites ${activeOnly ? 'WHERE active = 1' : ''} ORDER BY name COLLATE NOCASE`;
|
|
return db.prepare(sql).all();
|
|
}
|
|
|
|
export function getSite(idOrSlug) {
|
|
if (idOrSlug === undefined || idOrSlug === null || idOrSlug === '') return null;
|
|
const asNumber = Number(idOrSlug);
|
|
if (Number.isInteger(asNumber) && String(asNumber) === String(idOrSlug)) {
|
|
return db.prepare('SELECT * FROM sites WHERE id = ?').get(asNumber) || null;
|
|
}
|
|
return db.prepare('SELECT * FROM sites WHERE slug = ?').get(String(idOrSlug).toLowerCase()) || null;
|
|
}
|
|
|
|
/** Falls back to the only active site, which keeps single-site installs simple. */
|
|
export function resolveSite(idOrSlug) {
|
|
const found = getSite(idOrSlug);
|
|
if (found && found.active) return found;
|
|
const active = listSites({ activeOnly: true });
|
|
return active.length === 1 ? active[0] : found || null;
|
|
}
|
|
|
|
export function slugify(value) {
|
|
return clean(value, 60)
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
.slice(0, 40);
|
|
}
|
|
|
|
export function uniqueSlug(base, excludeId = null) {
|
|
let slug = slugify(base) || 'site';
|
|
let n = 2;
|
|
while (true) {
|
|
const clash = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
|
|
if (!clash || clash.id === excludeId) return slug;
|
|
slug = `${slugify(base)}-${n}`;
|
|
n += 1;
|
|
}
|
|
}
|
|
|
|
export function shapeSite(site) {
|
|
return {
|
|
id: site.id,
|
|
name: site.name,
|
|
slug: site.slug,
|
|
active: Boolean(site.active),
|
|
badge: {
|
|
enabled: Boolean(site.badge_enabled),
|
|
widthMm: site.badge_width_mm,
|
|
heightMm: site.badge_height_mm,
|
|
showPhoto: Boolean(site.badge_show_photo),
|
|
accent: Boolean(site.badge_accent),
|
|
note: site.badge_note,
|
|
},
|
|
};
|
|
}
|
|
|
|
/* --------------------------------------------------------------- badge */
|
|
|
|
const esc = (value) =>
|
|
String(value ?? '').replace(/[&<>"']/g, (c) =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
|
);
|
|
|
|
/**
|
|
* A self-contained print page sized to the site's label stock. It calls print()
|
|
* on load so a kiosk can drop it into a hidden iframe and get one badge out.
|
|
*/
|
|
export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {}) {
|
|
const width = Number(site.badge_width_mm) || 62;
|
|
const height = Number(site.badge_height_mm) || 100;
|
|
const showPhoto = Boolean(site.badge_show_photo) && Boolean(photoUrl);
|
|
|
|
// A label noticeably taller than it is wide gets a stacked layout. That is the
|
|
// normal case on a 62mm roll printer like the Brother QL-820NWB, where the roll
|
|
// fixes the width and the length runs down the badge.
|
|
const portrait = height >= width * 1.2;
|
|
|
|
// Type scales with the dimension that constrains it: the width on a portrait
|
|
// badge, the shorter side on a wide one. Keeps small stock legible.
|
|
const unit = portrait ? width : Math.min(width, height);
|
|
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));
|
|
// 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.
|
|
const accent = site.badge_accent ? '#d00019' : '#000';
|
|
const timeIn = new Date(visit.signed_in_at);
|
|
const noCheck = visit.check_type === 'NONE';
|
|
|
|
const photo = showPhoto ? `<img class="photo" src="${esc(photoUrl)}" alt="">` : '';
|
|
const details = `
|
|
<div class="rows">
|
|
<div>Visiting <b>${esc(visit.host_name)}</b></div>
|
|
<div>In at <b>${esc(
|
|
timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })
|
|
)}</b> on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}</div>
|
|
<div>${
|
|
noCheck
|
|
? '<span class="flag">No WWCC / VIT</span>'
|
|
: `${esc(visit.check_type)} ${esc(visit.check_number || '')}`
|
|
}</div>
|
|
${site.badge_note ? `<div class="note">${esc(site.badge_note)}</div>` : ''}
|
|
</div>`;
|
|
|
|
return `<!doctype html>
|
|
<html lang="en-AU">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Badge — ${esc(visit.first_name)} ${esc(visit.last_name)}</title>
|
|
<style>
|
|
@page { size: ${width}mm ${height}mm; margin: 0; }
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; padding: 0; background: #fff; }
|
|
.badge {
|
|
width: ${width}mm;
|
|
height: ${height}mm;
|
|
padding: ${pad}mm;
|
|
display: flex;
|
|
flex-direction: ${portrait ? 'column' : 'row'};
|
|
align-items: ${portrait ? 'center' : 'stretch'};
|
|
text-align: ${portrait ? 'center' : 'left'};
|
|
gap: ${unit * 0.05}mm;
|
|
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
|
|
color: #000;
|
|
overflow: hidden;
|
|
}
|
|
.photo {
|
|
width: ${photoWidth}mm;
|
|
height: ${photoWidth}mm;
|
|
flex: 0 0 auto;
|
|
object-fit: cover;
|
|
border: 0.3mm solid #000;
|
|
}
|
|
.body {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
${portrait ? 'align-items: center;' : ''}
|
|
}
|
|
.site {
|
|
width: 100%;
|
|
font-size: ${bodySize * 0.8}mm;
|
|
letter-spacing: 0.03em;
|
|
text-transform: uppercase;
|
|
color: ${accent};
|
|
border-bottom: 0.35mm solid ${accent};
|
|
padding-bottom: ${unit * 0.02}mm;
|
|
margin-bottom: ${unit * 0.035}mm;
|
|
}
|
|
.name {
|
|
font-size: ${nameSize}mm;
|
|
font-weight: 700;
|
|
line-height: 1.05;
|
|
letter-spacing: -0.01em;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.rows {
|
|
margin-top: auto;
|
|
padding-top: ${unit * 0.04}mm;
|
|
font-size: ${bodySize}mm;
|
|
line-height: 1.35;
|
|
}
|
|
.rows b { font-weight: 700; }
|
|
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.025}mm; }
|
|
.flag {
|
|
display: inline-block;
|
|
padding: 0 ${unit * 0.03}mm;
|
|
border: 0.35mm solid ${accent};
|
|
color: ${accent};
|
|
font-weight: 700;
|
|
font-size: ${bodySize * 0.9}mm;
|
|
}
|
|
@media screen {
|
|
body { background: #e7ecf0; padding: 12mm; }
|
|
.badge { background: #fff; box-shadow: 0 2mm 6mm rgba(0,0,0,.2); margin: 0 auto; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="badge">
|
|
${photo}
|
|
<div class="body">
|
|
<div class="site">${esc(site.name)} · Visitor</div>
|
|
<div class="name">${esc(visit.first_name)} ${esc(visit.last_name)}</div>
|
|
${details}
|
|
</div>
|
|
</div>
|
|
${autoPrint ? '<script>window.addEventListener("load", () => window.print());</script>' : ''}
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
export { esc as escapeHtml, localStamp };
|