Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
987 lines
60 KiB
HTML
987 lines
60 KiB
HTML
<!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>
|
||
</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…</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">← Back to people</a> <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">← Back to people</a> <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">← 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"> 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.'));
|
||
}
|
||
$('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;
|
||
}
|
||
|
||
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(el('div', { className: 'small' }, 'AR BookFind has no public feed and its terms forbid automated look-ups, so open it and search for "' + b.title + '" yourself.'));
|
||
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;
|
||
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>
|