Files
ubuntu-based-kiosk/library/quickadd/index.html
T

1025 lines
63 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Library</title>
<style>
:root {
--bg: #f6f5f1; --card: #ffffff; --ink: #1d2320; --muted: #5b655f; --line: #dcdad2;
--accent: #2f6b4f; --accent-ink: #ffffff; --ok: #1f7a4a; --warn: #8a5a00; --bad: #a3321f;
--ok-bg: #e6f3ec; --warn-bg: #fbf1dc; --bad-bg: #f9e5e1; --focus: #1a5fb4;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #151a17; --card: #1e2521; --ink: #e8ece9; --muted: #a2ada6; --line: #333c36;
--accent: #5bb58a; --accent-ink: #0d1a13; --ok: #6fd3a0; --warn: #e8bd6a; --bad: #f08a78;
--ok-bg: #1c3226; --warn-bg: #352c15; --bad-bg: #3a2220; --focus: #7ab0ff;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink);
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 760px; margin: 0 auto; padding: 16px; }
header { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: baseline; justify-content: space-between; }
h1 { font-size: 1.35rem; margin: 8px 0; }
nav a { color: var(--accent); margin-right: 14px; text-decoration: none; font-weight: 600; }
nav a:hover { text-decoration: underline; }
.types { display: flex; gap: 6px; margin: 10px 0 14px; flex-wrap: wrap; }
.types label { cursor: pointer; }
.types input { position: absolute; opacity: 0; }
.types span { display: inline-block; padding: 8px 16px; border: 1px solid var(--line);
border-radius: 999px; background: var(--card); font-weight: 600; }
.types input:checked + span { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
.types input:focus-visible + span { outline: 3px solid var(--focus); outline-offset: 2px; }
.shelf { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; }
.shelf label { font-weight: 600; }
.shelf select { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--card); color: var(--ink); max-width: 100%; }
.scan { display: flex; gap: 8px; }
.scan input { flex: 1; font-size: 1.5rem; padding: 14px 16px; border: 2px solid var(--line);
border-radius: 12px; background: var(--card); color: var(--ink); min-width: 0; }
.scan input:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); }
button { font: inherit; cursor: pointer; border-radius: 10px; border: 1px solid var(--line);
padding: 10px 16px; background: var(--card); color: var(--ink); font-weight: 600; }
button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
nav.tabs { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; }
nav.tabs a { color: var(--accent); text-decoration: none; font-weight: 600; padding: 6px 12px; border-radius: 999px; }
nav.tabs a.on { background: var(--accent); color: var(--accent-ink); }
nav.tabs a.ext::after { content: ' \2197'; font-size: .85em; }
.find-form { display: flex; gap: 8px; margin: 10px 0; }
.find-form input { flex: 1; font-size: 1.3rem; padding: 12px 14px; border: 2px solid var(--line); border-radius: 12px;
background: var(--card); color: var(--ink); min-width: 0; }
.hit { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; margin: 10px 0; }
.hit .t { font-weight: 700; font-size: 1.05rem; overflow-wrap: anywhere; }
a { color: var(--accent); }
.hit a { color: var(--accent); }
.copy { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; padding: 6px 0 0; }
.chip { border-radius: 999px; padding: 1px 10px; font-size: .88rem; font-weight: 600; border: 1px solid var(--line); }
.chip.in { background: var(--ok-bg); border-color: var(--ok); color: var(--ok); }
.chip.out { background: var(--warn-bg); border-color: var(--warn); color: var(--warn); }
.cam-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0 0; }
#status { min-height: 1.5em; margin: 10px 2px; color: var(--muted); }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px;
padding: 14px; margin: 10px 0; display: flex; gap: 14px; }
.card.ok { border-color: var(--ok); background: var(--ok-bg); }
.card.warn { border-color: var(--warn); background: var(--warn-bg); }
.card.bad { border-color: var(--bad); background: var(--bad-bg); }
.cover { width: 64px; height: 92px; border-radius: 4px; background: var(--line); flex: none; object-fit: cover; }
.cover[hidden] { display: none; }
.body { flex: 1; min-width: 0; }
.title { font-weight: 700; font-size: 1.1rem; overflow-wrap: anywhere; }
.meta { color: var(--muted); overflow-wrap: anywhere; }
.badge { display: inline-block; background: var(--ink); color: var(--bg); border-radius: 6px;
padding: 1px 9px; font-weight: 700; font-variant-numeric: tabular-nums; }
.actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
form.manual { display: grid; gap: 8px; margin-top: 8px; }
form.manual input { padding: 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--card); color: var(--ink); font: inherit; width: 100%; }
h2 { font-size: 1.05rem; margin: 24px 0 6px; }
ul.list { list-style: none; margin: 0; padding: 0; }
ul.list li { display: flex; gap: 10px; align-items: center; padding: 9px 2px; border-bottom: 1px solid var(--line); }
ul.list .t { flex: 1; min-width: 0; overflow-wrap: anywhere; }
ul.list .u { color: var(--muted); font-size: .9rem; }
.labels { background: var(--card); border: 1px dashed var(--line); border-radius: 12px; padding: 12px; margin-top: 14px; }
.small { color: var(--muted); font-size: .92rem; }
details.settings { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 14px; margin: 18px 0 0; }
details.settings summary { cursor: pointer; font-weight: 700; }
details.settings fieldset { border: 0; padding: 0; margin: 12px 0; }
details.settings legend { font-weight: 700; padding: 0; margin-bottom: 4px; }
details.settings label.opt { display: block; padding: 4px 0; }
details.settings .tool { border-top: 1px solid var(--line); padding: 12px 0 4px; }
details.settings select, details.settings input[type=number] { font: inherit; padding: 6px 8px; border: 1px solid var(--line);
border-radius: 8px; background: var(--card); color: var(--ink); }
details.settings input[type=number] { width: 6em; }
.result-note { margin-top: 8px; padding: 8px 10px; border-radius: 8px; background: var(--ok-bg); border: 1px solid var(--ok); }
.filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 14px; }
.filters select { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); max-width: 100%; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 14px 12px; margin: 6px 0 16px; }
.tile { display: block; text-decoration: none; color: inherit; }
.tile .art { position: relative; aspect-ratio: 2 / 3; border-radius: 6px; overflow: hidden; border: 1px solid var(--line);
display: flex; align-items: flex-end; padding: 8px; font-weight: 700; font-size: .88rem; line-height: 1.2;
color: #fff; background: var(--tint, #5b6f63); box-shadow: 0 1px 3px rgba(0,0,0,.18); overflow-wrap: anywhere; }
.tile .art img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: var(--card); }
.tile .art img[hidden] { display: none; }
.tile:hover .art, .tile:focus-visible .art { outline: 3px solid var(--accent); outline-offset: 2px; }
.tile .tt { margin-top: 6px; font-weight: 600; font-size: .9rem; line-height: 1.25; overflow-wrap: anywhere;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.tile .aa { color: var(--muted); font-size: .82rem; overflow-wrap: anywhere; }
.tile .flag { position: absolute; top: 6px; right: 6px; z-index: 1; font-size: .72rem; padding: 1px 8px; border-radius: 999px;
background: var(--warn-bg); color: var(--warn); border: 1px solid var(--warn); font-weight: 700; }
.tile .num { position: absolute; top: 6px; left: 6px; z-index: 1; font-size: .72rem; padding: 1px 7px; border-radius: 999px;
background: rgba(0,0,0,.65); color: #fff; font-weight: 700; }
.bookhead { display: flex; gap: 16px; flex-wrap: wrap; margin: 8px 0 14px; }
.bookhead .art { width: 140px; aspect-ratio: 2 / 3; border-radius: 6px; overflow: hidden; border: 1px solid var(--line); position: relative;
background: var(--tint, #5b6f63); color: #fff; font-weight: 700; padding: 10px; display: flex; align-items: flex-end; flex: none; overflow-wrap: anywhere; }
.bookhead .art img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: var(--card); }
.bookhead .art img[hidden] { display: none; }
.bookhead h2 { margin: 0 0 4px; font-size: 1.4rem; }
.copybox { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; margin: 10px 0; }
.copybox .row { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; margin-top: 8px; }
.copybox select, .copybox input[type=text] { font: inherit; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); min-width: 0; }
button.danger { color: var(--bad); border-color: var(--bad); }
.person { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; padding: 10px 2px; border-bottom: 1px solid var(--line); }
.person .t { flex: 1; min-width: 180px; }
.loan { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--line); }
.loan .t { flex: 1; min-width: 160px; overflow-wrap: anywhere; }
.chip.late { background: var(--bad-bg); border-color: var(--bad); color: var(--bad); }
.libcard { width: 85.6mm; height: 54mm; border: 1px solid #888; border-radius: 3mm; padding: 5mm; background: #fff; color: #000;
display: flex; flex-direction: column; justify-content: space-between; font-family: system-ui, sans-serif; }
.libcard .lib { font-size: 11pt; font-weight: 700; } .libcard .nm { font-size: 14pt; font-weight: 700; }
.libcard .codes { display: flex; gap: 3mm; align-items: center; } .libcard .bc { flex: 1; min-width: 0; }
.libcard .bc svg { width: 100%; height: 14mm; display: block; } .libcard img.qr { width: 19mm; height: 19mm; flex: none; }
.libcard .no { font-size: 9pt; text-align: center; letter-spacing: .15em; }
.cardsheet { display: grid; grid-template-columns: repeat(2, 85.6mm); gap: 6mm; justify-content: start; }
.cardsheet .libcard { break-inside: avoid; }
@media print { header, .noprint, nav { display: none !important; } body { background: #fff; } main { padding: 0; max-width: none; } .libcard { border: 1px solid #000; } }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
</style>
</head>
<body>
<main>
<header>
<h1 id="title">Library</h1>
<nav class="tabs" aria-label="Sections">
<a id="tab-library" href="#library" class="on">Library</a>
<a id="tab-lend" href="#lend">Lend / return</a>
<a id="tab-people" href="#people">People</a>
<a id="tab-add" href="#add">Add books</a>
<a id="lnk-staff" class="ext" href="#" target="_blank" rel="noopener">Manage</a>
<a id="lnk-opac" class="ext" href="#" target="_blank" rel="noopener">Public catalogue</a>
</nav>
</header>
<section id="view-library" hidden>
<form class="find-form" id="findform" autocomplete="off">
<label class="sr" for="q">Title, author or ISBN</label>
<input id="q" type="search" placeholder="Search title, author or ISBN" enterkeyhint="search">
<button class="primary" type="submit">Search</button>
</form>
<div class="filters">
<label class="sr" for="f-shelf">Shelf</label><select id="f-shelf"></select>
<label class="sr" for="f-coll">Collection</label><select id="f-coll"></select>
<label class="sr" for="f-type">Format</label><select id="f-type"></select>
<label class="sr" for="f-status">Status</label><select id="f-status">
<option value="">Any status</option><option value="in">On the shelf</option><option value="out">Checked out</option></select>
<label class="sr" for="f-sort">Sort</label><select id="f-sort">
<option value="title">Sort: title</option><option value="author">Sort: author</option><option value="new">Sort: newest first</option></select>
</div>
<div id="libstatus" class="small" aria-live="polite"></div>
<div class="grid" id="grid"></div>
<div class="actions"><button type="button" id="more" hidden>Show more</button></div>
<details class="settings" id="csvtools">
<summary>Export / import</summary>
<div class="tool"><strong>Export your books</strong>
<div class="small">A spreadsheet (CSV) with one row per copy: title, author, ISBN, barcode, shelf, collection, status and any reading level on file.</div>
<div class="actions"><a href="api/export.csv" download><button type="button">Download library.csv</button></a></div></div>
<div class="tool"><strong>Import reading levels</strong>
<div class="small">A CSV with a header row and columns <code>isbn</code>, <code>level</code>, <code>points</code> (and optionally <code>quiz</code>). Existing levels for the same ISBN are updated.</div>
<div class="actions"><input type="file" id="arfile" accept=".csv,text/csv"><button type="button" id="arimport">Import</button></div>
<div id="arresult" class="small" aria-live="polite"></div></div>
</details>
</section>
<section id="view-lend" hidden>
<div class="copybox">
<strong>Return a book</strong>
<form class="scan" id="retform" autocomplete="off" style="margin-top:8px">
<label class="sr" for="ret">Barcode to return</label>
<input id="ret" inputmode="numeric" placeholder="Scan the book's barcode" autocomplete="off">
<button class="primary" type="submit">Return</button>
</form>
<div class="cam-row"><button type="button" id="retcam">Return with camera</button></div>
<div id="retresult" role="status" aria-live="polite" class="small" style="margin-top:8px"></div>
</div>
<div class="copybox">
<strong>Check out to&hellip;</strong>
<form class="find-form" id="whoform" autocomplete="off">
<label class="sr" for="who">Name or library card number</label>
<input id="who" type="search" placeholder="Name, or scan the library card" enterkeyhint="search" autocomplete="off">
<button type="submit">Find</button>
</form>
<div class="cam-row"><button type="button" id="whocam">Scan card or phone QR with camera</button>
<span class="small">Photograph the library card, or the QR picture on the borrower's phone.</span></div>
<div id="whostatus" class="small"></div>
<div id="whoresults"></div>
<div id="patronbox"></div>
</div>
<h2>Checked out right now <span id="outcount" class="small"></span></h2>
<div id="outlist"></div>
</section>
<section id="view-people" hidden>
<details class="settings" id="newperson" open>
<summary>Add a person</summary>
<form class="manual" id="personform" autocomplete="off">
<input name="firstname" placeholder="First name" aria-label="First name">
<input name="surname" placeholder="Last name" required aria-label="Last name">
<input name="phone" placeholder="Phone (optional)" aria-label="Phone">
<input name="email" type="email" placeholder="Email (optional)" aria-label="Email">
<div class="actions"><button class="primary" type="submit">Add person</button></div>
</form>
<div id="personresult" aria-live="polite"></div>
</details>
<p><a href="#cards"><button type="button">Print everyone's cards</button></a></p>
<form class="find-form" id="peopleform" autocomplete="off">
<label class="sr" for="pq">Search people</label>
<input id="pq" type="search" placeholder="Search by name or card number" enterkeyhint="search">
<button type="submit">Search</button>
</form>
<div id="peoplelist"></div>
</section>
<section id="view-card" hidden>
<p class="noprint"><a href="#people">&larr; Back to people</a> &nbsp; <button type="button" id="printcard" class="primary">Print this card</button> <button type="button" id="savecard">Save as picture (to send to a phone)</button></p>
<div id="cardbox"></div>
</section>
<section id="view-cards" hidden>
<p class="noprint"><a href="#people">&larr; Back to people</a> &nbsp; <button type="button" id="printcards" class="primary">Print these cards</button>
<span class="small" id="cardsnote"></span></p>
<div class="cardsheet" id="cardsheet"></div>
</section>
<section id="view-book" hidden>
<p><a href="#library" id="back">&larr; Back to the library</a></p>
<div id="bookbox" aria-live="polite"></div>
</section>
<section id="view-add">
<fieldset class="types" id="types" style="border:0;padding:0" aria-label="What are you adding?"></fieldset>
<div class="shelf" id="shelfbox" hidden>
<label for="shelf">Shelf / room</label>
<select id="shelf"></select>
</div>
<form class="scan" id="scan" autocomplete="off">
<label class="sr" for="code">ISBN or barcode</label>
<input id="code" inputmode="numeric" placeholder="Scan or type an ISBN" autofocus>
<button class="primary" type="submit">Add</button>
</form>
<div class="cam-row">
<button type="button" id="cam">Scan with camera</button>
<input type="file" id="camfile" accept="image/*" capture="environment" hidden>
<span class="small">Opens your phone's camera: fill the frame with the barcode and hold steady.</span>
</div>
<div id="status" role="status" aria-live="polite"></div>
<div id="result"></div>
<h2>Added this session <span id="count" class="small"></span></h2>
<ul class="list" id="session"></ul>
<div class="labels" id="labels" hidden>
<strong id="range"></strong>
<div class="small">Print barcode labels for these: the button opens the label printer with the range filled in
(you may be asked to log in to the staff interface).</div>
<div class="actions"><a id="lnk-labels" href="#" target="_blank" rel="noopener"><button type="button">Print labels</button></a></div>
</div>
<details class="settings" id="settings">
<summary>Barcodes and settings</summary>
<fieldset>
<legend>Barcodes on new items</legend>
<label class="opt"><input type="radio" name="bcmode" value="auto"> Number them automatically</label>
<label class="opt"><input type="radio" name="bcmode" value="scan"> I stick a pre-printed barcode on each one: ask me to scan it</label>
<label class="opt"><input type="radio" name="bcmode" value="none"> No barcodes for now: I will add them later</label>
<div class="small" style="margin-top:6px">
Digits per barcode:
<select id="width"></select>
<span id="nextwrap">&nbsp;Next barcode: <strong id="nextbc"></strong></span>
</div>
<div class="small">Same-length numbers are what Koha's label printer needs for a range, so numbers are zero-padded (0000042). Seven or more digits keeps scanners from misreading a short all-number barcode as the pharmacy "Code 32" format.</div>
</fieldset>
<div class="tool">
<strong>Items without a barcode: <span id="missing">0</span></strong>
<div class="actions"><button type="button" id="assign">Give them barcodes</button></div>
</div>
<div class="tool">
<strong>Print a sheet of barcodes ahead of time</strong>
<div class="small">Reserve a block of numbers, print the sheet, and stick the labels on whenever you like.</div>
<div class="actions"><label for="reserve-count" class="sr">How many</label>
<input type="number" id="reserve-count" min="1" max="2000" value="80">
<button type="button" id="reserve">Reserve numbers</button></div>
</div>
<div id="tool-result" aria-live="polite"></div>
</details>
<h2>Earlier</h2>
<ul class="list" id="earlier"></ul>
</section>
</main>
<script>
(() => {
const $ = id => document.getElementById(id);
let types = {}, sessionItems = [], busy = false, lastAdded = null, shelf = '', awaiting = false, staffPort = '8081', settings = {}, type = localStorage.getItem('qa-type') || 'BOOK';
// --- small audible feedback so a stack of books can be scanned without looking
let actx;
function beep(freqs) {
try {
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
let t = actx.currentTime;
for (const f of freqs) {
const o = actx.createOscillator(), g = actx.createGain();
o.frequency.value = f; g.gain.setValueAtTime(0.15, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
o.connect(g); g.connect(actx.destination); o.start(t); o.stop(t + 0.17); t += 0.18;
}
} catch (e) {}
}
const SOUND = { added: [880], duplicate: [520, 520], notfound: [300], error: [220, 180] };
const el = (tag, props = {}, ...kids) => {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k.includes('-')) n.setAttribute(k, v); // aria-*, data-*: attributes, not JS properties
else n[k] = v;
}
for (const k of kids) n.append(k);
return n;
};
async function api(path, body) {
// X-Quickadd marks our own requests: a staff-site login (cookie) is only accepted for changes
// that carry it, which a page on another website cannot add (cross-site request forgery).
const r = await fetch(path.replace(/^\//, ''), body ? { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Quickadd': '1' },
body: JSON.stringify(body) } : { headers: { 'X-Quickadd': '1' } });
return r.json();
}
function cover(code) {
const img = el('img', { className: 'cover', alt: '', hidden: true });
if (code && /^\d{10,13}$/.test(code)) {
img.onload = () => { if (img.naturalWidth > 1) img.hidden = false; };
img.src = 'https://covers.openlibrary.org/b/isbn/' + code + '-M.jpg?default=false';
}
return img;
}
function show(kind, ...parts) { $('result').replaceChildren(el('div', { className: 'card ' + kind }, ...parts)); }
function bookBody(r, extra) {
return el('div', { className: 'body' },
el('div', { className: 'title' }, r.title || ''),
el('div', { className: 'meta' }, [r.author, r.callnumber && ('Call no. ' + r.callnumber), r.location].filter(Boolean).join(' · ')),
...extra);
}
function rowFor(e, withUndo) {
const li = el('li', {},
el('span', { className: 'badge' }, e.barcode || '–'),
el('span', { className: 't' }, el('div', {}, e.title), el('div', { className: 'u' },
[e.author, e.callnumber, e.location, e.source].filter(Boolean).join(' · '))));
if (withUndo) {
const b = el('button', { type: 'button' }, 'Undo');
b.onclick = async () => {
b.disabled = true;
const r = await api('/api/undo', { item_id: e.item_id, biblio_id: e.biblio_id });
if (r.status === 'undone') {
sessionItems = sessionItems.filter(x => x.item_id !== e.item_id); render();
if (lastAdded === e.item_id) $('result').replaceChildren();
$('status').textContent = 'Removed "' + e.title + '".';
} else b.disabled = false;
};
li.append(b);
}
return li;
}
function render() {
$('session').replaceChildren(...sessionItems.map(e => rowFor(e, true)));
$('count').textContent = sessionItems.length ? '(' + sessionItems.length + ')' : '';
const bcs = sessionItems.map(e => e.barcode).filter(b => b && /^\d+$/.test(b));
$('labels').hidden = bcs.length === 0;
if (bcs.length) {
const sameLen = bcs.every(b => b.length === bcs[0].length);
const sorted = [...bcs].sort((a, b) => (a.length - b.length) || (a < b ? -1 : 1));
const lo = sorted[0], hi = sorted[sorted.length - 1];
$('range').textContent = lo === hi ? 'Barcode ' + lo : 'Barcodes ' + lo + ' to ' + hi;
$('lnk-labels').href = sameLen ? printLink(lo, hi) : $('lnk-labels').href;
}
}
async function add(code, opts = {}) {
if (busy) return; busy = true;
$('status').textContent = 'Looking up ' + code + '…';
try {
const r = await api('/api/add', Object.assign({ code, type }, opts));
handle(r, code);
} catch (e) {
beep(SOUND.error); show('bad', el('div', { className: 'body' }, 'Could not reach the quick-add service.'));
} finally { busy = false; if (!awaiting) $('code').focus(); }
}
function handle(r, code) {
$('status').textContent = '';
if (r.status === 'added') {
beep(SOUND.added); sessionItems.unshift(r); lastAdded = r.item_id; render();
awaiting = false; $('code').focus();
show('ok', cover(r.code), bookBody(r, [el('div', { className: 'meta' },
r.barcode ? 'Added as barcode ' : 'Added (no barcode yet)',
r.barcode ? el('span', { className: 'badge' }, r.barcode) : '',
(r.source === 'extra copy' ? ' · another copy of a title you already have' : ' · from ' + r.source))]));
} else if (r.status === 'duplicate') {
beep(SOUND.duplicate);
const more = el('button', { className: 'primary', type: 'button' }, 'Add another copy');
more.onclick = () => add(code, { copy: true });
const skip = el('button', { type: 'button' }, 'Skip');
skip.onclick = () => { $('result').replaceChildren(); $('code').focus(); };
show('warn', cover(code), bookBody(r, [el('div', { className: 'meta' },
'You already have this (' + r.copies + (r.copies === 1 ? ' copy' : ' copies') + ').'),
el('div', { className: 'actions' }, more, skip)]));
} else if (r.status === 'need_barcode' || r.status === 'barcode_taken' || r.status === 'barcode_invalid') {
stickerPrompt(r);
} else if (r.status === 'notfound') {
beep(SOUND.notfound); manualForm(code);
} else {
beep(SOUND.error);
show('bad', el('div', { className: 'body' }, r.message || 'Something went wrong.'));
}
}
// ---- phone camera: take a photo, shrink it, have the server read the barcode
async function shrink(file, max = 2400) {
let bmp;
try { bmp = await createImageBitmap(file, { imageOrientation: 'from-image' }); }
catch (e) { bmp = await new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = URL.createObjectURL(file); }); }
const w = bmp.width, h = bmp.height, k = Math.min(1, max / Math.max(w, h));
const c = document.createElement('canvas'); c.width = Math.round(w * k); c.height = Math.round(h * k);
c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height);
return new Promise(ok => c.toBlob(ok, 'image/jpeg', 0.9));
}
// Opens the camera, resolves with the codes found ([] if none, null if cancelled).
function takePhoto(kind, statusEl) {
statusEl = statusEl || $('status');
return new Promise(resolve => {
const input = $('camfile');
input.onchange = async () => {
const f = input.files[0]; input.value = '';
if (!f) return resolve(null);
statusEl.textContent = 'Reading the barcode…';
try {
const blob = await shrink(f);
const r = await fetch('api/scan?kind=' + kind, { method: 'POST', headers: { 'Content-Type': 'image/jpeg', 'X-Quickadd': '1' }, body: blob });
resolve(r.ok ? (await r.json()).codes : []);
} catch (e) { resolve([]); }
};
input.click();
});
}
function noBarcode() {
beep(SOUND.notfound);
$('status').textContent = '';
show('warn', el('div', { className: 'body' }, el('div', { className: 'title' }, "Couldn't read a barcode from that photo"),
el('div', { className: 'meta' }, 'Move closer so the barcode fills the frame, keep the phone steady and the barcode flat and well lit, then try again. You can also type the number.')));
}
$('cam').onclick = async () => {
const codes = await takePhoto('isbn');
if (codes === null) { $('status').textContent = ''; return; }
if (!codes.length) return noBarcode();
add(codes[0]);
};
function stickerPrompt(r) {
awaiting = true;
const input = el('input', { placeholder: 'Scan the barcode sticker', autocomplete: 'off', 'aria-label': 'Barcode sticker', inputMode: 'text' });
const form = el('form', { className: 'manual' });
const cancel = el('button', { type: 'button' }, 'Cancel');
cancel.onclick = async () => { await api('/api/cancel', { token: r.token }); awaiting = false;
$('result').replaceChildren(); $('code').focus(); };
const camBtn = el('button', { type: 'button' }, 'Scan sticker with camera');
camBtn.onclick = async () => {
const codes = await takePhoto('sticker');
if (codes === null) return;
if (!codes.length) { beep(SOUND.notfound); $('status').textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
handle(await api('/api/finish', { token: r.token, barcode: codes[0] }), r.code);
};
form.append(input, el('div', { className: 'actions' }, el('button', { className: 'primary', type: 'submit' }, 'Add'), camBtn, cancel));
form.onsubmit = async ev => {
ev.preventDefault();
const bc = input.value.trim(); if (!bc) return;
handle(await api('/api/finish', { token: r.token, barcode: bc }), r.code);
};
const msg = r.status === 'barcode_taken' ? 'Barcode ' + r.barcode + ' is already on another item. Scan a different sticker.'
: r.status === 'barcode_invalid' ? (r.message || 'That barcode cannot be used.')
: 'Now scan the barcode sticker you put on this one.';
if (r.status !== 'need_barcode') beep(SOUND.duplicate);
show('warn', bookBody(r, [el('div', { className: 'meta' }, msg), form]));
input.focus();
}
let staffBase = '';
const staffRoot = () => staffBase || (location.pathname.startsWith('/add/') ? location.origin : 'http://' + location.hostname + ':' + staffPort);
const printLink = (from, to) => staffRoot() +
'/cgi-bin/koha/labels/label-print.pl?from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to);
function rangeNote(text, from, to) {
const note = el('div', { className: 'result-note' }, text + ' ');
if (from) {
const a = el('a', { href: printLink(from, to), target: '_blank', rel: 'noopener' }, 'Print these labels');
note.append(a, el('div', { className: 'small' }, 'Pick a template and layout on the next screen (you will be asked to log in to the staff interface).'));
}
$('tool-result').replaceChildren(note);
}
function applySettings(v) {
settings = v.settings;
document.querySelectorAll('input[name=bcmode]').forEach(i => { i.checked = i.value === settings.barcode_mode; });
const w = $('width');
if (!w.options.length) for (let n = 7; n <= 10; n++) w.append(el('option', { value: n }, String(n)));
w.value = settings.width;
$('nextwrap').hidden = settings.barcode_mode !== 'auto';
$('nextbc').textContent = v.next || '';
$('missing').textContent = v.without_barcode == null ? '?' : v.without_barcode;
$('assign').disabled = !v.without_barcode;
}
async function saveSettings(patch) { applySettings(await api('/api/settings', patch)); }
document.querySelectorAll('input[name=bcmode]').forEach(i => i.onchange = () => saveSettings({ barcode_mode: i.value }));
$('width').onchange = () => saveSettings({ width: parseInt($('width').value, 10) });
$('assign').onclick = async () => {
$('assign').disabled = true;
const r = await api('/api/barcodes/assign', {});
rangeNote(r.count ? r.count + ' item(s) now have barcodes ' + r.first + ' to ' + r.last + '.' : 'Every item already has a barcode.', r.first, r.last);
saveSettings({});
};
$('reserve').onclick = async () => {
const r = await api('/api/barcodes/reserve', { count: parseInt($('reserve-count').value, 10) });
if (r.status === 'reserved') { rangeNote('Reserved ' + r.first + ' to ' + r.last + ' (' + r.count + ' labels).', r.first, r.last); saveSettings({}); }
else rangeNote(r.message || 'Could not reserve that many.');
};
function manualForm(code) {
const f = el('form', { className: 'manual' });
const field = (name, ph, req) => el('input', { name, placeholder: ph, required: !!req, 'aria-label': ph });
const submit = el('button', { className: 'primary', type: 'submit' }, 'Add it anyway');
const cancel = el('button', { type: 'button' }, 'Cancel');
cancel.onclick = () => { $('result').replaceChildren(); $('code').focus(); };
f.append(field('title', 'Title', true), field('author', 'Author'), field('year', 'Year'),
field('publisher', 'Publisher'), el('div', { className: 'actions' }, submit, cancel));
f.onsubmit = async ev => {
ev.preventDefault();
const d = Object.fromEntries(new FormData(f));
handle(await api('/api/manual', Object.assign(d, { code, type })), code);
};
show('warn', el('div', { className: 'body' }, el('div', { className: 'title' }, 'Not found: ' + code),
el('div', { className: 'meta' }, 'None of the catalogues knew this one. Type the details and it is added anyway.'), f));
f.querySelector('input').focus();
}
$('scan').onsubmit = ev => {
ev.preventDefault();
const code = $('code').value.trim(); $('code').value = '';
if (code) add(code);
};
function typeButtons() {
$('types').replaceChildren(...Object.entries(types).map(([k, label]) => {
const i = el('input', { type: 'radio', name: 'type', value: k, checked: k === type });
i.onchange = () => { type = k; localStorage.setItem('qa-type', k); $('code').focus(); };
return el('label', {}, i, el('span', {}, label));
}));
}
// ---- under the staff site (/add/) everything is same-origin
const embedded = location.pathname.startsWith('/add/');
let viewer = false; // the read-only guest login: search only
// ---- views: Library / Book / Add books
const TINTS = ['#5b6f63', '#6b5b73', '#73655b', '#4f6a7a', '#7a5252', '#6a6f3f', '#4a5f7a', '#7a6a3a'];
const tint = t => TINTS[[...(t || '')].reduce((n, c) => (n * 31 + c.charCodeAt(0)) % 997, 7) % TINTS.length];
function art(b, cls) {
const a = el('span', { className: cls || 'art' }, b.title);
a.style.setProperty('--tint', tint(b.title));
if (b.isbn && /^\d{10,13}$/.test(b.isbn.replace(/\D/g, '').slice(0, 13))) {
const img = el('img', { alt: '', hidden: true, loading: 'lazy' });
img.onload = () => { if (img.naturalWidth > 1) img.hidden = false; };
img.src = 'https://covers.openlibrary.org/b/isbn/' + b.isbn.replace(/\D/g, '').slice(0, 13) + '-M.jpg?default=false';
a.append(img);
}
return a;
}
let libPage = 1, libShelves = [], libCollections = [], libLoaded = false, filtersReady = false;
const filters = () => ({ q: $('q').value.trim(), shelf: $('f-shelf').value, collection: $('f-coll').value, type: $('f-type').value,
status: $('f-status').value, sort: $('f-sort').value });
const filterQuery = () => { const f = filters(); return Object.entries(f).filter(([, v]) => v).map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&'); };
function tile(b) {
const a = el('a', { className: 'tile', href: '#book/' + b.id });
const picture = art(b);
if (b.copies > 1) picture.append(el('span', { className: 'num' }, '×' + b.copies));
if (!viewer && b.out) picture.append(el('span', { className: 'flag' }, b.out === b.copies ? 'Out' : b.out + ' out'));
else if (viewer && b.out === b.copies && b.copies) picture.append(el('span', { className: 'flag' }, 'Out'));
a.append(picture, el('div', { className: 'tt' }, b.title), el('div', { className: 'aa' }, b.author));
return a;
}
let libSeq = 0;
async function loadLibrary(reset) {
const seq = ++libSeq; // a newer search makes an older, slower answer irrelevant
if (reset) libPage = 1;
const grid = $('grid');
if (reset) grid.replaceChildren();
$('libstatus').textContent = 'Loading…';
let r;
try { r = await api('/api/library?' + filterQuery() + '&page=' + libPage); } catch (e) { r = {}; }
if (seq !== libSeq) return;
if (!r.books) { $('libstatus').textContent = r.message || 'Koha is not answering yet - try again in a minute.'; return; }
if (r.collections) libCollections = r.collections;
if (!filtersReady && r.shelves) {
filtersReady = true;
libShelves = r.shelves;
$('f-coll').replaceChildren(el('option', { value: '' }, 'Any collection'), el('option', { value: '-' }, '(no collection)'),
...(r.collections || []).map(l => el('option', { value: l.value }, l.label)));
$('f-shelf').replaceChildren(el('option', { value: '' }, 'Any shelf'), el('option', { value: '-' }, '(no shelf set)'),
...r.shelves.map(l => el('option', { value: l.value }, l.label)));
$('f-type').replaceChildren(el('option', { value: '' }, 'Any format'),
...Object.entries(r.types || {}).map(([k, v]) => el('option', { value: k }, v)));
}
grid.append(...r.books.map(tile));
$('more').hidden = !r.more;
const shown = grid.children.length;
const f = filters();
$('libstatus').textContent = shown ? shown + (r.more ? '+' : '') + (shown === 1 ? ' book' : ' books') + (f.q || f.shelf || f.collection || f.type || f.status ? ' match' : '') :
(f.q || f.shelf || f.collection || f.type || f.status ? 'No books match.' : (viewer ? 'Nothing here yet.' : 'Nothing here yet - open Add books and scan an ISBN.'));
}
$('arimport').onclick = async () => {
const f = $('arfile').files[0]; if (!f) { $('arresult').textContent = 'Choose a CSV file first.'; return; }
const r = await api('/api/ar/import', { csv: await f.text() });
$('arresult').textContent = r.status === 'imported' ? 'Imported ' + r.count + ' reading level' + (r.count === 1 ? '' : 's') + (r.skipped ? ' (' + r.skipped + ' rows skipped: no valid ISBN or level)' : '') + '.' : (r.message || 'Import failed.');
};
$('more').onclick = () => { libPage += 1; loadLibrary(false); };
$('findform').onsubmit = ev => { ev.preventDefault(); loadLibrary(true); };
for (const id of ['f-shelf', 'f-coll', 'f-type', 'f-status', 'f-sort']) $(id).onchange = () => loadLibrary(true);
// ---- one book
async function loadBook(id) {
const box = $('bookbox');
box.replaceChildren(el('div', { className: 'small' }, 'Loading…'));
let b;
try { b = await api('/api/book/' + id); } catch (e) { b = {}; }
if (!b.copies) { box.replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, b.message || 'Book not found.'))); return; }
document.title = b.title;
if (b.shelves && b.shelves.length) libShelves = b.shelves;
if (b.collections) libCollections = b.collections;
const facts = [b.author, b.year, b.publisher, b.pages, b.edition, b.series].filter(Boolean);
const head = el('div', { className: 'bookhead' }, art(b),
el('div', { className: 'body' }, el('h2', {}, b.title), b.subtitle ? el('div', { className: 'meta' }, b.subtitle) : '',
el('div', { className: 'meta' }, facts.join(' · ')), b.isbn ? el('div', { className: 'small' }, 'ISBN ' + b.isbn) : '',
b.summary ? el('p', {}, b.summary) : ''));
box.replaceChildren(head);
const refresh = () => loadBook(id);
const moreBox = el('div', { className: 'copybox' }, el('div', { className: 'small' }, 'Looking for more about this book…'));
box.append(moreBox);
api('/api/more/' + id).then(m => fillMore(moreBox, b, m)).catch(() => moreBox.remove());
for (const c of b.copies) box.append(copyBox(b, c, refresh));
if (!b.copies.length) box.append(el('div', { className: 'small' }, 'No copies recorded.'));
if (!viewer) {
const more = el('button', { type: 'button' }, 'Add another copy');
more.onclick = async () => { more.disabled = true; const r = await api('/api/item/copy', { biblio_id: b.id }); if (r.status === 'added') refresh(); else more.disabled = false; };
const del = el('button', { type: 'button', className: 'danger' }, 'Delete this book');
del.onclick = async () => {
if (!confirm('Delete "' + b.title + '" and all its copies from the library?')) return;
const r = await api('/api/book/delete', { biblio_id: b.id });
if (r.status === 'deleted') location.hash = '#library'; else alert(r.message || 'Could not delete it.');
};
const full = el('a', { href: staffRoot() + '/cgi-bin/koha/catalogue/detail.pl?biblionumber=' + b.id, target: '_blank', rel: 'noopener' }, 'Open the full record in Koha');
box.append(el('div', { className: 'actions' }, more, del), el('p', { className: 'small' }, full, ' (edit title, author and other details there)'));
}
}
// A shelf/collection chooser whose last entry adds a new one (Koha's own screens are not needed).
function valueSelect(kind, label, none, list, current) {
const sel = el('select', { 'aria-label': label });
const fill = (items, cur) => { sel.replaceChildren(el('option', { value: '' }, none), ...items.map(l => el('option', { value: l.value }, l.label)),
el('option', { value: '__new' }, '+ New ' + kind + '…')); sel.value = cur || ''; };
fill(list, current);
let last = current || '';
sel.onchange = async () => {
if (sel.value !== '__new') { last = sel.value; return; }
const name = prompt('Name of the new ' + kind + ':');
if (!name || !name.trim()) { sel.value = last; return; }
const r = await api('/api/value/add', { kind, name });
if (r.status !== 'added') { alert(r.message || 'Could not add that.'); sel.value = last; return; }
const items = (kind === 'shelf' ? libShelves : libCollections); items.push({ value: r.value, label: r.label });
fill(items, r.value); last = r.value;
};
return sel;
}
// Reading level (AR): typed in here or imported from a CSV. AR BookFind itself is only a link: it has no
// public feed and its terms forbid automated look-ups.
function arBox(b, m) {
const ar = m.ar || {};
const f = (ph, v, w) => el('input', { type: 'text', placeholder: ph, value: v || '', 'aria-label': ph, inputMode: 'decimal', style: 'width:' + w + 'em;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink)' });
const lvl = f('Level', ar.level, 5), pts = f('Points', ar.points, 5), quiz = f('Quiz no.', ar.quiz, 7);
const wrap = el('div', { className: 'small', style: 'margin-top:10px;border-top:1px solid var(--line);padding-top:8px' });
const copy = el('button', { type: 'button' }, 'Copy title');
copy.onclick = async () => { try { await navigator.clipboard.writeText(b.title); copy.textContent = 'Copied ✓'; } catch (e) { copy.textContent = 'Copy failed'; } setTimeout(() => copy.textContent = 'Copy title', 1500); };
const open = el('a', { href: 'https://www.arbookfind.com/', target: '_blank', rel: 'noopener noreferrer' }, 'Open AR BookFind');
if (viewer) { wrap.append(el('strong', {}, 'Reading level: '), ar.level ? 'AR ' + ar.level + (ar.points ? ', ' + ar.points + ' points' : '') : 'not recorded'); return wrap; }
const save = el('button', { type: 'button', className: 'primary' }, 'Save');
const msg = el('span', {});
save.onclick = async () => {
const r = await api('/api/ar/set', { isbn: m.isbn, level: lvl.value, points: pts.value, quiz: quiz.value });
msg.textContent = r.status === 'saved' ? ' Saved ✓' : ' ' + (r.message || 'Could not save.');
};
wrap.append(el('strong', {}, 'Reading level (AR): '), lvl, ' ', pts, ' ', quiz, ' ', save, msg,
el('div', { style: 'margin-top:6px' }, open, ' (a new tab: search for the title there, then type what it shows) ', copy));
return wrap;
}
function fillMore(box, b, m) {
const parts = [];
if (m.description && !b.summary) parts.push(el('p', {}, m.description), el('div', { className: 'small' }, 'Description from Open Library.'));
if (m.subjects && m.subjects.length) parts.push(el('div', { style: 'margin:8px 0' }, ...m.subjects.map(t => el('span', { className: 'chip', style: 'margin:0 6px 6px 0;display:inline-block' }, t))));
if (m.first_published) parts.push(el('div', { className: 'small' }, 'First published: ' + m.first_published));
const row = el('div', { className: 'small', style: 'margin-top:8px' }, 'Look it up: ');
(m.links || []).forEach((l, i) => { if (i) row.append(' · '); row.append(el('a', { href: l.url, target: '_blank', rel: 'noopener noreferrer' }, l.label)); });
parts.push(row);
if ((m.links || []).some(l => l.note)) parts.push(arBox(b, m));
box.replaceChildren(...parts);
}
function copyBox(b, c, refresh) {
const box = el('div', { className: 'copybox' });
const out = c.status === 'Checked out';
const status = el('span', { className: 'chip ' + (c.status === 'On shelf' ? 'in' : 'out') }, c.status +
(out && c.out ? (c.out.who ? ' to ' + c.out.who : '') + (c.out.due ? ', due ' + c.out.due : '') : ''));
box.append(el('div', {}, c.barcode ? el('span', { className: 'badge' }, c.barcode) : el('span', { className: 'small' }, 'no barcode yet'), ' ', status));
if (viewer) {
box.append(el('div', { className: 'meta' }, [c.shelf_label || 'no shelf set', c.callnumber && 'Call no. ' + c.callnumber].filter(Boolean).join(' · ')));
return box;
}
const sel = valueSelect('shelf', 'Shelf', '(no shelf set)', libShelves, c.shelf);
const csel = valueSelect('collection', 'Collection', '(no collection)', libCollections, c.collection);
const cn = el('input', { type: 'text', value: c.callnumber, placeholder: 'Call number', 'aria-label': 'Call number', maxLength: 80 });
const save = el('button', { type: 'button', className: 'primary' }, 'Save');
save.onclick = async () => {
save.disabled = true;
const r = await api('/api/item/update', { item_id: c.item_id, shelf: sel.value, collection: csel.value, callnumber: cn.value });
save.textContent = r.status === 'saved' ? 'Saved ✓' : 'Failed'; setTimeout(() => { save.textContent = 'Save'; save.disabled = false; }, 1500);
};
const rm = el('button', { type: 'button', className: 'danger' }, 'Remove copy');
rm.disabled = out;
rm.onclick = async () => {
if (!confirm('Remove this copy' + (c.barcode ? ' (barcode ' + c.barcode + ')' : '') + '?')) return;
const r = await api('/api/item/delete', { item_id: c.item_id, biblio_id: b.id });
if (r.status === 'undone') { if (b.copies.length <= 1) location.hash = '#library'; else refresh(); }
};
const lab = el('a', { href: printLink(c.barcode, c.barcode), target: '_blank', rel: 'noopener' }, el('button', { type: 'button' }, 'Print label'));
box.append(el('div', { className: 'row' }, sel, csel, cn, save, c.barcode ? lab : '', rm));
return box;
}
// ---- lend / return
const fmtDue = d => d ? new Date(d + 'T12:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '';
let curPatron = null, lendBusy = false;
function say(box, kind, text) { box.replaceChildren(el('div', { className: 'card ' + kind }, el('div', { className: 'body' }, text))); }
async function doReturn(code, box) {
box.textContent = 'Returning…';
let r; try { r = await api('/api/return', { barcode: code }); } catch (e) { r = { status: 'error', message: 'Could not reach the service.' }; }
if (r.status === 'returned') { beep(SOUND.added); say(box, 'ok', 'Returned: ' + (r.title || code) + (r.patron ? ' (from ' + r.patron + ')' : '')); }
else if (r.status === 'not_out') { beep(SOUND.duplicate); say(box, 'warn', (r.title || code) + ' was not checked out.'); }
else if (r.status === 'unknown_barcode') { beep(SOUND.notfound); say(box, 'warn', 'No book has the barcode ' + code + '.'); }
else { beep(SOUND.error); say(box, 'bad', r.message || 'Could not return that.'); }
loadOut(); if (curPatron) openPatron(curPatron.id, true);
return r;
}
$('retform').onsubmit = async ev => { ev.preventDefault(); const c = $('ret').value.trim(); $('ret').value = ''; if (c) { await doReturn(c, $('retresult')); $('ret').focus(); } };
$('retcam').onclick = async () => {
const codes = await takePhoto('sticker', $('retresult'));
if (codes === null) { $('retresult').textContent = ''; return; }
if (!codes.length) { $('retresult').textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
doReturn(codes[0], $('retresult'));
};
$('whocam').onclick = async () => {
const codes = await takePhoto('sticker', $('whostatus'));
if (codes === null) { $('whostatus').textContent = ''; return; }
$('whostatus').textContent = '';
if (!codes.length) { $('whostatus').textContent = "Couldn't read a code from that photo - move closer and try again."; return; }
const r = await api('/api/patrons?q=' + encodeURIComponent(codes[0]));
const hit = (r.patrons || []).filter(p => p.card === codes[0]);
if (hit.length === 1) { beep(SOUND.added); location.hash = '#lend/' + hit[0].id; }
else { beep(SOUND.notfound); $('whostatus').textContent = 'No library card matches ' + codes[0] + '.'; }
};
async function loadOut() {
let r; try { r = await api('/api/out'); } catch (e) { return; }
if (!r.loans) return;
$('outcount').textContent = r.loans.length ? '(' + r.loans.length + ')' : '';
const late = r.loans.filter(l => l.overdue).length;
if (!r.loans.length) { $('outlist').replaceChildren(el('div', { className: 'small' }, 'Nothing is checked out.')); return; }
$('outlist').replaceChildren(...(late ? [el('div', { className: 'small' }, late + ' overdue')] : []), ...r.loans.map(l => loanRow(l, true)));
}
function loanRow(l, showWho) {
const ret = el('button', { type: 'button' }, 'Return');
ret.onclick = async () => { ret.disabled = true; await doReturn(l.barcode, $('retresult')); };
const ren = el('button', { type: 'button' }, 'Renew');
ren.onclick = async () => {
ren.disabled = true; const r = await api('/api/renew', { checkout_id: l.checkout_id });
if (r.status === 'renewed') { loadOut(); if (curPatron) openPatron(curPatron.id, true); } else { ren.disabled = false; alert(r.message); }
};
const a = el('a', { href: '#book/' + l.biblio_id }, l.title);
return el('div', { className: 'loan' }, el('span', { className: 'badge' }, l.barcode),
el('span', { className: 't' }, a, showWho ? el('div', { className: 'small' }, l.who) : ''),
el('span', { className: 'chip ' + (l.overdue ? 'late' : 'in') }, (l.overdue ? 'Overdue, was due ' : 'Due ') + fmtDue(l.due)), ret, ren);
}
async function openPatron(id, quiet) {
let p; try { p = await api('/api/patron/' + id); } catch (e) { p = {}; }
if (!p.name) { $('patronbox').textContent = 'Could not load that person.'; return; }
curPatron = p;
$('whoresults').replaceChildren();
const scan = el('input', { placeholder: 'Scan a book to check out', inputMode: 'numeric', autocomplete: 'off', 'aria-label': 'Barcode to check out' });
const form = el('form', { className: 'scan' }, scan, el('button', { className: 'primary', type: 'submit' }, 'Check out'));
const out = (quiet && document.getElementById('coresult')) || el('div', { id: 'coresult', role: 'status', className: 'small', style: 'margin-top:8px' });
form.onsubmit = async ev => {
ev.preventDefault(); const code = scan.value.trim(); scan.value = ''; if (!code || lendBusy) return; lendBusy = true;
try {
const r = await api('/api/checkout', { patron_id: p.id, barcode: code });
if (r.status === 'checked_out') { beep(SOUND.added); say(out, 'ok', (r.title || code) + ' - due ' + fmtDue(r.due)); }
else if (r.status === 'already_out') {
beep(SOUND.duplicate);
const rb = el('button', { type: 'button', className: 'primary' }, 'Return it, then check it out');
rb.onclick = async () => { rb.disabled = true; await doReturn(code, out); const r2 = await api('/api/checkout', { patron_id: p.id, barcode: code });
if (r2.status === 'checked_out') say(out, 'ok', (r2.title || code) + ' - due ' + fmtDue(r2.due)); await openPatron(p.id, true); loadOut(); };
out.replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, (r.title || code) + ' is already checked out. ', rb)));
} else { beep(SOUND.notfound); say(out, 'bad', r.message || 'That did not work.'); }
} catch (e) { say(out, 'bad', 'Could not reach the service.'); }
lendBusy = false; await openPatron(p.id, true); loadOut(); document.querySelector('#patronbox form input').focus();
};
const bookCam = el('button', { type: 'button' }, 'Scan book with camera');
bookCam.onclick = async () => {
const codes = await takePhoto('sticker', out);
if (codes === null) { out.textContent = ''; return; }
if (!codes.length) { out.textContent = "Couldn't read a barcode from that photo - move closer and try again."; return; }
scan.value = codes[0]; form.requestSubmit();
};
const loans = p.loans.length ? p.loans.map(l => loanRow(l, false)) : [el('div', { className: 'small' }, 'Nothing checked out to ' + p.name + ' yet.')];
$('patronbox').replaceChildren(el('div', { className: 'title', style: 'margin-top:10px' }, p.name, ' ', el('span', { className: 'badge' }, p.card || 'no card')),
form, el('div', { className: 'cam-row' }, bookCam), out, el('h2', {}, 'Has out'), ...loans,
el('div', { className: 'small', style: 'margin-top:8px' }, el('a', { href: '#card/' + p.id }, 'Print library card'), ' · ',
el('a', { href: '#lend' }, 'Choose someone else')));
if (!quiet) scan.focus();
}
async function findPeople(term, into, withActions) {
let r; try { r = await api('/api/patrons?q=' + encodeURIComponent(term)); } catch (e) { r = {}; }
if (!r.patrons) { into.textContent = (r && r.message) || 'Search failed.'; return []; }
if (!r.patrons.length) { into.replaceChildren(el('div', { className: 'small' }, term ? 'Nobody matches "' + term + '".' : 'No people yet.')); return []; }
into.replaceChildren(...r.patrons.map(p => el('div', { className: 'person' },
el('span', { className: 't' }, el('strong', {}, p.name), el('div', { className: 'small' }, [p.card, p.phone, p.email].filter(Boolean).join(' · '))),
...withActions(p))));
return r.patrons;
}
$('whoform').onsubmit = async ev => {
ev.preventDefault(); const term = $('who').value.trim(); if (!term) return;
const list = await findPeople(term, $('whoresults'), p => { const b = el('button', { type: 'button', className: 'primary' }, 'Choose');
b.onclick = () => { location.hash = '#lend/' + p.id; }; return [b]; });
const exact = list.filter(p => p.card === term);
if (exact.length === 1) { $('who').value = ''; location.hash = '#lend/' + exact[0].id; } // a scanned card goes straight through
};
// ---- people
async function loadPeople(term) {
await findPeople(term, $('peoplelist'), p => {
const lend = el('a', { href: '#lend/' + p.id }, el('button', { type: 'button' }, 'Lend / returns'));
const card = el('a', { href: '#card/' + p.id }, el('button', { type: 'button' }, 'Print card'));
return [lend, card];
});
}
$('peopleform').onsubmit = ev => { ev.preventDefault(); loadPeople($('pq').value.trim()); };
$('personform').onsubmit = async ev => {
ev.preventDefault();
const f = ev.target, confirm = f.dataset.confirm === '1'; delete f.dataset.confirm;
const r = await api('/api/patron/add', Object.assign(Object.fromEntries(new FormData(f)), confirm ? { confirm: true } : {}));
if (r.status === 'added') {
f.reset();
$('personresult').replaceChildren(el('div', { className: 'result-note' }, r.name + ' added with library card number ', el('span', { className: 'badge' }, r.card), '. ',
el('a', { href: '#card/' + r.id }, 'Print the card'), ' or ', el('a', { href: '#lend/' + r.id }, 'check something out')));
loadPeople('');
} else if (r.status === 'duplicate') {
const again = el('button', { type: 'button', className: 'primary' }, 'Add another person with this name');
again.onclick = () => { f.requestSubmit && (f.dataset.confirm = '1'); f.requestSubmit(); };
$('personresult').replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, r.message + ' ', again)));
} else $('personresult').replaceChildren(el('div', { className: 'card bad' }, el('div', { className: 'body' }, r.message || 'Could not add that person.')));
};
// ---- printable library card (Code 39 barcode drawn as SVG)
const C39 = { '0': 'nnnwwnwnn', '1': 'wnnwnnnnw', '2': 'nnwwnnnnw', '3': 'wnwwnnnnn', '4': 'nnnwwnnnw', '5': 'wnnwwnnnn',
'6': 'nnwwwnnnn', '7': 'nnnwnnwnw', '8': 'wnnwnnwnn', '9': 'nnwwnnwnn', '*': 'nwnnwnwnn' };
function code39(text) {
const W = 3; let x = 10, bars = '';
for (const ch of '*' + text + '*') {
const pat = C39[ch]; if (!pat) continue;
for (let i = 0; i < 9; i++) { const w = pat[i] === 'w' ? W : 1; if (i % 2 === 0) bars += '<rect x="' + x + '" y="0" width="' + w + '" height="50"/>'; x += w; }
x += 1; // narrow gap between characters
}
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + (x + 9) + ' 50" preserveAspectRatio="none" fill="#000" role="img" aria-label="Barcode ' + text + '">' + bars + '</svg>';
}
function cardEl(p) {
const svg = el('div', { className: 'bc' });
svg.innerHTML = code39(p.card);
const qr = el('img', { className: 'qr', alt: 'QR code ' + p.card, src: 'api/qr?t=' + encodeURIComponent(p.card) });
return el('div', { className: 'libcard' }, el('div', { className: 'lib' }, libraryDisplay || 'Library card'), el('div', { className: 'nm' }, p.name),
el('div', {}, el('div', { className: 'codes' }, svg, qr), el('div', { className: 'no' }, p.card)));
}
async function loadCard(id) {
let p; try { p = await api('/api/patron/' + id); } catch (e) { p = {}; }
if (!p.name) { $('cardbox').textContent = 'Could not load that person.'; return; }
curCard = p;
$('cardbox').replaceChildren(cardEl(p));
}
let curCard = null;
async function loadCards() {
let r; try { r = await api('/api/patrons?q=' + encodeURIComponent(($('pq').value || '').trim())); } catch (e) { r = {}; }
const list = (r.patrons || []).filter(p => p.card);
$('cardsheet').replaceChildren(...list.map(cardEl));
$('cardsnote').textContent = list.length ? list.length + ' card' + (list.length === 1 ? '' : 's') + (($('pq').value || '').trim() ? ' (matching your search)' : '') : 'Nobody to print yet.';
}
$('printcards').onclick = () => window.print();
// A picture of the QR (name + number under it) the person can keep on their phone and hold up to be scanned.
$('savecard').onclick = async () => {
if (!curCard) return;
const svgText = await (await fetch('api/qr?t=' + encodeURIComponent(curCard.card), { headers: { 'X-Quickadd': '1' } })).text();
const img = new Image(); await new Promise((ok, no) => { img.onload = ok; img.onerror = no; img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText); });
const c = document.createElement('canvas'); c.width = 640; c.height = 780; const g = c.getContext('2d');
g.fillStyle = '#fff'; g.fillRect(0, 0, c.width, c.height); g.fillStyle = '#000'; g.textAlign = 'center';
g.font = 'bold 34px sans-serif'; g.fillText(libraryDisplay || 'Library card', 320, 60);
g.imageSmoothingEnabled = false; g.drawImage(img, 70, 90, 500, 500);
g.font = 'bold 40px sans-serif'; g.fillText(curCard.name, 320, 650); g.font = '34px monospace'; g.fillText(curCard.card, 320, 705);
const a = document.createElement('a'); a.href = c.toDataURL('image/png'); a.download = 'library-card-' + curCard.card + '.png'; a.click();
};
$('printcard').onclick = () => window.print();
function route() {
const h = location.hash.replace(/^#/, '');
let m, view = 'library';
if ((m = h.match(/^book\/(\d+)$/))) view = 'book';
else if (viewer) view = 'library';
else if (h === 'add') view = 'add';
else if ((m = h.match(/^lend(?:\/(\d+))?$/))) view = 'lend';
else if (h === 'people') view = 'people';
else if ((m = h.match(/^card\/(\d+)$/))) view = 'card';
else if (h === 'cards') view = 'cards';
for (const v of ['library', 'book', 'add', 'lend', 'people', 'card', 'cards']) $('view-' + v).hidden = v !== view;
const tab = { library: 'library', book: 'library', add: 'add', lend: 'lend', people: 'people', card: 'people', cards: 'people' }[view];
for (const t of ['library', 'add', 'lend', 'people']) $('tab-' + t).classList.toggle('on', t === tab);
for (const t of ['add', 'lend', 'people']) $('tab-' + t).hidden = viewer;
$('csvtools').hidden = viewer;
const names = { library: 'Library', book: 'Library', add: 'Add books', lend: 'Lend / return', people: 'People', card: 'Library card', cards: 'Library cards' };
$('title').textContent = names[view]; document.title = names[view];
if (view === 'book') loadBook(m[1]);
else if (view === 'add') $('code').focus();
else if (view === 'lend') { loadOut(); curPatron = null; $('patronbox').replaceChildren();
if (m && m[1]) openPatron(m[1]); else { $('whoresults').replaceChildren(); $('who').focus(); } }
else if (view === 'people') { loadPeople($('pq').value.trim()); }
else if (view === 'card') loadCard(m[1]);
else if (view === 'cards') loadCards();
else { if (!libLoaded) { libLoaded = true; loadLibrary(true); } if (h === 'find') $('q').focus(); }
}
window.addEventListener('hashchange', route);
let libraryDisplay = '';
(async () => {
const s = await api('/api/state');
viewer = s.role === 'viewer';
types = s.types || {}; if (!types[type]) type = 'BOOK'; if (!viewer) typeButtons();
staffPort = s.staff_port; settings = s.settings || {}; shelf = settings.location || '';
staffBase = s.staff_url || '';
// The public catalogue is off by default: only link to it when a public address was configured.
$('lnk-opac').hidden = !s.opac_url;
if (s.opac_url) $('lnk-opac').href = s.opac_url + '/';
$('lnk-staff').href = staffRoot() + '/';
$('lnk-staff').hidden = viewer;
$('lnk-labels').href = staffRoot() + '/cgi-bin/koha/labels/label-edit-range.pl';
if (s.locations && s.locations.length) {
const sel = $('shelf');
sel.replaceChildren(el('option', { value: '' }, '(not set / all in one place)'),
...s.locations.map(l => el('option', { value: l.value }, l.label)));
if (![...sel.options].some(o => o.value === shelf)) shelf = '';
sel.value = shelf;
sel.onchange = () => { shelf = sel.value; saveSettings({ location: shelf }); $('code').focus(); };
$('shelfbox').hidden = false;
}
if (!viewer) api('/api/settings').then(applySettings);
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
libraryDisplay = s.library_name || '';
route();
})();
})();
</script>
</body>
</html>