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

1372 lines
90 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>Room for Books</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; }
header.top { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 0 8px; border-bottom: 1px solid var(--line); margin-bottom: 12px; }
.brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--ink); }
.brand .logo { font-size: 1.5rem; } .brand h1 { margin: 0; }
.menubtn { font-size: 1.5rem; line-height: 1; padding: 6px 14px; }
.menu { position: absolute; right: 0; top: 100%; z-index: 20; background: var(--card); border: 1px solid var(--line); border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,.18); padding: 8px; min-width: 250px; display: flex; flex-direction: column; gap: 2px; }
.menu[hidden] { display: none; }
.menu a { color: var(--ink); text-decoration: none; padding: 9px 12px; border-radius: 8px; font-weight: 600; }
.menu a:hover, .menu a:focus-visible { background: var(--bg); }
.menu a.on { background: var(--accent); color: var(--accent-ink); }
.menu a.ext::after { content: ' \2197'; font-size: .85em; }
.menu a[hidden] { display: none; }
.menugroup { border-top: 1px solid var(--line); margin-top: 6px; padding-top: 6px; display: flex; flex-direction: column; gap: 2px; }
.menugroup[hidden] { display: none; }
.menulabel { font-size: .8rem; color: var(--muted); padding: 2px 12px; text-transform: uppercase; letter-spacing: .05em; }
.find-form.big input { font-size: 1.5rem; padding: 16px 18px; }
.bigacts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin: 16px 0; }
.bigacts[hidden] { display: none; }
.bigact { display: flex; flex-direction: column; gap: 4px; padding: 18px 14px; border-radius: 14px; background: var(--accent); color: var(--accent-ink);
text-decoration: none; min-height: 96px; justify-content: center; }
.bigact strong { font-size: 1.25rem; } .bigact span { font-size: .88rem; opacity: .9; }
.bigact:hover, .bigact:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.foot { max-width: 760px; margin: 8px auto 28px; padding: 14px 16px 0; border-top: 1px solid var(--line); color: var(--muted); font-size: .9rem; text-align: center; }
.foot a { color: var(--accent); }
.cand { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; border-bottom: 1px solid var(--line); }
.cand .cv { width: 54px; height: 78px; border-radius: 4px; background: var(--line); flex: none; object-fit: cover; }
.cand .t { flex: 1; min-width: 0; overflow-wrap: anywhere; }
@media print { .foot, .menubtn, .menu { display: none !important; } }
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, .foot { display: none !important; } body { background: #fff; } main { padding: 0; max-width: none; } .libcard { border: 1px solid #000; } }
#live { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.92); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 12px; }
#live[hidden] { display: none; }
#live video { width: min(100%, 640px); max-height: 60vh; border-radius: 12px; background: #000; object-fit: cover; }
#live .frame { position: relative; width: min(100%, 640px); }
#live .frame::after { content: ''; position: absolute; inset: 18% 12%; border: 3px solid rgba(255,255,255,.85); border-radius: 12px; pointer-events: none; }
#live .msg { color: #fff; font-weight: 600; text-align: center; min-height: 3em; max-width: 640px; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
</style>
</head>
<body>
<main>
<header class="top">
<a href="#home" class="brand" aria-label="Room for Books, home"><span class="logo" aria-hidden="true">&#128214;</span> <h1 id="title">Room for Books</h1></a>
<button type="button" id="menubtn" class="menubtn" aria-expanded="false" aria-controls="menu" aria-label="Menu">&#9776;</button>
<nav id="menu" class="menu" aria-label="Menu" hidden>
<a id="tab-home" href="#home">Home</a>
<a id="tab-library" href="#library">Browse all books</a>
<a id="tab-mine" href="#mine" hidden>My books</a>
<div class="menugroup" id="menu-admin">
<div class="menulabel">Admin</div>
<a id="tab-lend" href="#lend">Lend / return</a>
<a id="tab-add" href="#add">Add books</a>
<a id="tab-people" href="#people">People and library cards</a>
<a id="tab-settings" href="#settings">Barcodes and settings</a>
<a id="tab-export" href="#export">Export / import</a>
<a id="lnk-staff" class="ext" href="#" target="_blank" rel="noopener">Koha (full staff screens)</a>
</div>
<a id="lnk-opac" class="ext" href="#" target="_blank" rel="noopener">Public catalogue</a>
</nav>
</header>
<section id="view-home" hidden>
<form class="find-form big" id="homeform" autocomplete="off" role="search">
<label class="sr" for="hq">Look up a book or a person</label>
<input id="hq" type="search" placeholder="Look up a book (title, author, ISBN)" enterkeyhint="search" autocomplete="off">
<button class="primary" type="submit">Look up</button>
</form>
<div id="homeresults" aria-live="polite"></div>
<div class="bigacts" id="homeacts">
<a class="bigact" href="#lend"><strong>Check out</strong><span>Scan a card, then the books</span></a>
<a class="bigact" href="#return"><strong>Return</strong><span>Scan the books coming back</span></a>
<a class="bigact" href="#add"><strong>Add books</strong><span>Scan an ISBN or search by title</span></a>
<a class="bigact" href="#newperson"><strong>Add a person</strong><span>Get a library card</span></a>
</div>
<div class="bigacts" id="patronacts" hidden>
<a class="bigact" href="#mine"><strong>My books</strong><span>What I have out, and when it is due</span></a>
<a class="bigact" href="#library"><strong>Browse</strong><span>Everything in the library</span></a>
</div>
<h2 id="recenth">Recently added</h2>
<div class="grid" id="recent"></div>
<p><a href="#library">Browse all books &rarr;</a></p>
</section>
<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-tag">Tag</label><select id="f-tag"></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> <button type="button" id="retlive" hidden>Live return</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> <button type="button" id="wholive" hidden>Live scan</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>
<button type="button" id="alllogins">Give everyone a sign-in</button></p>
<details class="settings"><summary>Sign-in passwords for new people</summary>
<fieldset><label class="opt"><input type="radio" name="pwmode" value="generated"> A different easy password for each person (like maple482)</label>
<label class="opt"><input type="radio" name="pwmode" value="same"> The same password for everyone:
<input type="text" id="pwsame" maxlength="40" placeholder="e.g. readmore" style="font:inherit;padding:4px 8px"></label>
<div class="small">People sign in to see their own books with their library card number and this password; they can change it themselves. Existing passwords are never changed by this setting.</div>
<div class="actions"><button type="button" id="pwsave">Save</button> <span id="pwsaved" class="small"></span></div></fieldset>
</details>
<div id="loginresult" aria-live="polite"></div>
<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-mine" hidden>
<div class="copybox"><strong id="mename"></strong> <span class="badge" id="mecard"></span>
<div class="small">What you have borrowed, and when it is due.</div></div>
<div id="meloans" aria-live="polite"></div>
<details class="settings" id="mepw"><summary>Change my password</summary>
<form class="manual" id="mepwform" autocomplete="off">
<input type="password" name="old" placeholder="Current password" autocomplete="current-password" required aria-label="Current password">
<input type="password" name="new" placeholder="New password (4 or more characters)" autocomplete="new-password" required minlength="4" aria-label="New password">
<div class="actions"><button class="primary" type="submit">Change password</button></div>
</form>
<div id="mepwresult" class="small" aria-live="polite"></div>
</details>
</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, title or author</label>
<input id="code" placeholder="Scan an ISBN, or type a title or author" autofocus autocomplete="off">
<button class="primary" type="submit">Add</button>
</form>
<div class="cam-row">
<button type="button" id="cam">Scan with camera</button>
<button type="button" id="livecam" hidden>Live scan</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>
<footer class="foot" id="foot"><span id="footname">Room for Books</span> &middot; <a href="#home">Home</a> &middot; <a href="#library">Browse</a><span id="footstaff"> &middot; <a id="footkoha" href="#" target="_blank" rel="noopener">Koha</a></span></footer>
<div id="live" hidden role="dialog" aria-modal="true" aria-label="Camera scanner">
<div class="frame"><video id="livevideo" playsinline muted></video></div>
<div class="msg" id="livemsg" aria-live="polite"></div>
<button type="button" id="livestop" class="primary">Done</button>
</div>
<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();
});
}
// ---- live camera: works where the browser allows camera access (HTTPS, or the machine itself).
// Chrome/Android read codes in the browser (BarcodeDetector); everything else sends small frames to
// the server's decoder a couple of times a second. Plain-HTTP pages fall back to the photo buttons.
const liveOK = !!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
let liveStop = null;
// onCode(code) may return text to show; continuous keeps scanning, otherwise the first code closes it.
async function liveScan(kind, title, onCode, continuous) {
const box = $('live'), video = $('livevideo'), msg = $('livemsg');
let stream;
try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 } }, audio: false }); }
catch (e) { alert('The camera is not available (' + (e.name === 'NotAllowedError' ? 'permission was refused' : e.message) + '). Use the photo button instead.'); return; }
box.hidden = false; msg.textContent = title; video.srcObject = stream; await video.play().catch(() => {});
let alive = true, busy = false, lastCode = '', lastAt = 0;
const detector = 'BarcodeDetector' in window ? new BarcodeDetector({ formats: ['qr_code', 'code_39', 'code_128', 'ean_13', 'ean_8', 'upc_a', 'upc_e'] }) : null;
const canvas = document.createElement('canvas');
const finish = () => { alive = false; stream.getTracks().forEach(t => t.stop()); video.srcObject = null; box.hidden = true; liveStop = null; };
liveStop = finish; $('livestop').onclick = finish;
const accept = async code => {
if (!code || busy) return;
if (kind === 'isbn') { code = clean(code); if (!code) return; }
const now = Date.now();
if (code === lastCode && now - lastAt < 4000) return; // the same book still in view
lastCode = code; lastAt = now; busy = true;
beep(SOUND.added);
try { const t = await onCode(code); if (t) msg.textContent = t; } catch (e) {}
busy = false;
if (!continuous) finish();
};
const clean = c => { c = String(c).replace(/[\s-]/g, '').toUpperCase(); if (!/^(\d{8,14}|\d{9}X)$/.test(c)) return ''; return c.length === 13 && c[0] === '0' ? c.slice(1) : c; };
while (alive) {
try {
if (video.readyState >= 2 && !busy) {
if (detector) {
const found = await detector.detect(video);
if (found.length) await accept(found[0].rawValue);
} else {
const k = Math.min(1, 1280 / Math.max(video.videoWidth, video.videoHeight));
canvas.width = Math.round(video.videoWidth * k); canvas.height = Math.round(video.videoHeight * k);
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
const blob = await new Promise(ok => canvas.toBlob(ok, 'image/jpeg', 0.85));
const r = await fetch('api/scan?kind=' + (kind === 'isbn' ? 'isbn' : 'sticker'), { method: 'POST', headers: { 'Content-Type': 'image/jpeg', 'X-Quickadd': '1' }, body: blob });
const codes = r.ok ? (await r.json()).codes : [];
if (codes.length && alive) await accept(codes[0]);
}
}
} catch (e) {}
await new Promise(ok => setTimeout(ok, detector ? 200 : 650));
}
}
window.addEventListener('hashchange', () => { if (liveStop) liveStop(); });
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.')));
}
$('livecam').hidden = !liveOK;
$('livecam').onclick = () => liveScan('isbn', 'Hold each book\'s barcode in the frame', async code => { await add(code); return 'Added - next book'; }, true);
$('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();
}
// An ISBN (or scanned barcode) is added straight away; anything else is a title/author search to pick from.
const looksLikeCode = t => /^[0-9][0-9\s-]{6,16}[0-9Xx]?$/.test(t);
async function searchTitles(term) {
$('status').textContent = 'Searching for "' + term + '"…';
$('result').replaceChildren();
let r; try { r = await api('/api/lookup?q=' + encodeURIComponent(term)); } catch (e) { r = {}; }
$('status').textContent = '';
if (!r.candidates) { show('bad', el('div', { className: 'body' }, r.message || 'The search failed - try again.')); return; }
if (!r.candidates.length) {
beep(SOUND.notfound);
const manual = el('button', { type: 'button' }, 'Type the details myself');
manual.onclick = () => manualForm('');
show('warn', el('div', { className: 'body' }, el('div', { className: 'title' }, 'Nothing found for "' + term + '"'),
el('div', { className: 'meta' }, 'Try fewer words, the author, or the ISBN printed on the back.'), el('div', { className: 'actions' }, manual)));
return;
}
const rows = r.candidates.map(c => {
const btn = el('button', { type: 'button', className: c.have ? '' : 'primary' }, c.have ? 'Add another copy' : 'Add this one');
btn.onclick = () => { $('result').replaceChildren(); add(c.isbn, c.have ? { copy: true } : {}); };
const img = el('img', { className: 'cv', alt: '', loading: 'lazy', src: 'https://covers.openlibrary.org/b/isbn/' + c.isbn + '-S.jpg?default=false' });
img.onerror = () => { img.style.visibility = 'hidden'; };
return el('div', { className: 'cand' }, img, el('div', { className: 't' }, el('strong', {}, c.title),
el('div', { className: 'meta' }, [c.author, c.year, c.publisher].filter(Boolean).join(' · ')),
el('div', { className: 'small' }, 'ISBN ' + c.isbn + ' · ' + c.source + (c.have ? ' · you already have this' : '')), el('div', { className: 'actions' }, btn)));
});
$('result').replaceChildren(el('div', { className: 'card' }, el('div', { className: 'body' }, el('div', { className: 'title' }, 'Which one is it?'), ...rows)));
}
$('scan').onsubmit = ev => {
ev.preventDefault();
const code = $('code').value.trim(); $('code').value = '';
if (!code) return;
if (looksLikeCode(code)) add(code); else searchTitles(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, isPatron = 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, tag: $('f-tag').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;
}
function fillTagFilter(tags) {
const cur = $('f-tag').value;
$('f-tag').replaceChildren(el('option', { value: '' }, 'Any tag'), ...tags.map(t => el('option', { value: t.tag }, t.tag + ' (' + t.count + ')')));
$('f-tag').value = [...$('f-tag').options].some(o => o.value === cur) ? cur : '';
}
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 (r.tags) fillTagFilter(r.tags);
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.tag || f.type || f.status ? ' match' : '') :
(f.q || f.shelf || f.collection || f.tag || 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-tag', '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);
box.append(tagBox(b));
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), inter = f('Interest (LG/MG/UG)', ar.interest, 9);
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');
const copyTitle = async () => { try { await navigator.clipboard.writeText(b.title); } catch (e) {} };
const arPop = el('button', { type: 'button' }, 'Open beside this page');
arPop.onclick = async () => { await copyTitle();
const w = Math.min(560, Math.floor(screen.availWidth / 2)), h = screen.availHeight - 80;
window.open('https://www.arbookfind.com/', 'arbookfind', 'popup=yes,width=' + w + ',height=' + h + ',left=' + (screen.availWidth - w) + ',top=40'); };
const arHolder = el('div', {});
const arFrame = el('button', { type: 'button' }, 'Show it here');
arFrame.onclick = async () => { await copyTitle();
if (arHolder.firstChild) { arHolder.replaceChildren(); arFrame.textContent = 'Show it here'; return; }
arFrame.textContent = 'Hide it';
arHolder.replaceChildren(el('iframe', { src: 'https://www.arbookfind.com/', title: 'AR BookFind', loading: 'lazy', referrerPolicy: 'no-referrer',
style: 'width:100%;height:520px;border:1px solid var(--line);border-radius:8px;margin-top:8px;background:#fff' }),
el('div', { className: 'small' }, 'If it keeps asking who you are, your browser is blocking its cookies inside another page: use "Open beside this page" instead.')); };
if (viewer) { wrap.append(el('strong', {}, 'Reading level: '), ar.level ? 'AR ' + ar.level + (ar.points ? ', ' + ar.points + ' points' : '') + (ar.interest ? ', interest ' + ar.interest : '') : '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, interest: inter.value });
msg.textContent = r.status === 'saved' ? ' Saved ✓' : ' ' + (r.message || 'Could not save.');
};
// Easiest path: on AR BookFind's book page select everything (Ctrl/Cmd+A), copy, and paste it here.
const paste = el('textarea', { rows: 2, placeholder: 'Paste the AR BookFind book page here (select all, copy, paste)', 'aria-label': 'Pasted AR BookFind text',
style: 'width:100%;margin-top:6px;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink)' });
paste.oninput = () => {
const t = paste.value, g = re => (t.match(re) || [])[1] || '';
const found = { level: g(/Book\s*Level:?\s*([0-9]{1,2}(?:\.[0-9]{1,2})?)/i), points: g(/(?:AR\s*)?Points:?\s*([0-9]{1,3}(?:\.[0-9]{1,2})?)/i),
quiz: g(/Quiz\s*(?:No\.?|Number|#)\s*:?\s*([0-9]{3,6})/i), interest: g(/Interest\s*Level:?\s*(LG|MG\+?|UG)/i) };
if (found.level) lvl.value = found.level; if (found.points) pts.value = found.points; if (found.quiz) quiz.value = found.quiz; if (found.interest) inter.value = found.interest;
msg.textContent = (found.level || found.points || found.quiz) ? ' Filled in from what you pasted - check it, then Save.' : (t.trim() ? " Couldn't find a level or points in that text." : '');
};
wrap.append(el('strong', {}, 'Reading level (AR): '), lvl, ' ', pts, ' ', quiz, ' ', inter, ' ', save, msg, paste,
el('div', { style: 'margin-top:6px' }, open, ' ', arPop, ' ', arFrame, ' ', copy),
el('div', { className: 'small' }, 'Search for the title there, select the whole book page, copy, and paste it below. (The title is copied for you when you open it.)'), arHolder);
return wrap;
}
// Tags: any number per book, free words ("to read", "kids", "signed"...). Click one to see every book with it.
function tagBox(b) {
const box = el('div', { className: 'copybox' });
let tags = [...(b.tags || [])];
const all = (b.all_tags || []).map(t => t.tag);
const draw = () => {
const chips = tags.map(t => {
const c = el('span', { className: 'chip', style: 'margin:0 6px 6px 0;display:inline-block' });
const a = el('a', { href: '#library', style: 'text-decoration:none' }, t);
a.onclick = () => { setTimeout(() => { $('f-tag').value = t; loadLibrary(true); }, 0); };
c.append(a);
if (!viewer) { const x = el('button', { type: 'button', 'aria-label': 'Remove tag ' + t, style: 'border:0;background:none;padding:0 0 0 6px;cursor:pointer;color:inherit' }, '×');
x.onclick = () => save(tags.filter(y => y !== t)); c.append(x); }
return c;
});
const parts = [el('strong', {}, 'Tags: '), ...(chips.length ? chips : [el('span', { className: 'small' }, viewer ? 'none' : 'none yet ')])];
if (!viewer) {
const list = el('datalist', { id: 'taglist' }, ...all.filter(t => !tags.includes(t)).map(t => el('option', { value: t })));
const inp = el('input', { type: 'text', placeholder: 'Add a tag, press Enter', 'aria-label': 'Add a tag', maxLength: 30,
style: 'font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink);min-width:12em' });
inp.setAttribute('list', 'taglist');
inp.onkeydown = ev => { if (ev.key === 'Enter') { ev.preventDefault(); const v = inp.value.trim(); if (v) save([...tags, v]); } };
parts.push(list, inp);
}
box.replaceChildren(...parts);
};
const save = async next => {
const r = await api('/api/tags/set', { biblio_id: b.id, tags: next });
if (r.status === 'saved') { tags = r.tags; draw(); fillTagFilter(r.all || []); }
};
draw();
return box;
}
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(); } };
$('retlive').hidden = $('wholive').hidden = !liveOK;
$('retlive').onclick = () => liveScan('sticker', 'Scan each returned book', async code => {
const r = await doReturn(code, $('retresult')); return $('retresult').innerText; }, true);
async function pickByCode(code) {
const r = await api('/api/patrons?q=' + encodeURIComponent(code));
const hit = (r.patrons || []).filter(p => p.card === code);
if (hit.length === 1) { location.hash = '#lend/' + hit[0].id; return true; }
return false;
}
$('wholive').onclick = () => liveScan('sticker', 'Hold the library card or the phone QR in the frame', async code => {
if (await pickByCode(code)) return; beep(SOUND.notfound); $('whostatus').textContent = 'No library card matches ' + code + '.'; }, false);
$('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 bookLive = el('button', { type: 'button', hidden: !liveOK }, 'Live scan books');
bookLive.onclick = () => liveScan('sticker', 'Scan each book to check out to ' + p.name, async code => {
const r = await api('/api/checkout', { patron_id: p.id, barcode: code });
openPatron(p.id, true); loadOut();
if (r.status === 'checked_out') return (r.title || code) + ' - due ' + fmtDue(r.due);
beep(SOUND.notfound); return (r.title ? r.title + ': ' : '') + (r.message || 'That did not work.');
}, true);
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, ' ', bookLive), 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'));
const reset = el('button', { type: 'button' }, 'New password');
reset.onclick = async () => { if (!confirm('Give ' + p.name + ' a new sign-in password?')) return; const r = await api('/api/patron/reset-login', { patron_id: p.id });
$('loginresult').replaceChildren(el('div', { className: 'result-note' }, r.status === 'saved' ? r.name + ': sign in with card number ' + r.card + ' and password ' : 'Could not do that.', r.password ? el('strong', {}, r.password) : '')); };
return [lend, card, reset];
});
}
$('alllogins').onclick = async () => {
if (!confirm('Create a sign-in for everyone who does not have one yet? You will see each password once.')) return;
const r = await api('/api/logins/all', {});
if (r.status !== 'done') { $('loginresult').textContent = r.message || 'Could not do that.'; return; }
$('loginresult').replaceChildren(r.made.length ? el('div', { className: 'result-note' }, el('strong', {}, r.made.length + ' sign-ins created - write these down or print this page:'),
...r.made.map(x => el('div', {}, x.name + ' - card ' + x.card + ' - password ', el('strong', {}, x.password)))) : el('div', { className: 'small' }, 'Everyone already has a sign-in.'));
};
let pwModeLoaded = false;
async function loadPwSettings() {
if (pwModeLoaded) return; const v = await api('/api/settings'); const st = v.settings || {}; pwModeLoaded = true;
document.querySelectorAll('input[name=pwmode]').forEach(i => { i.checked = i.value === (st.patron_password_mode || 'generated'); });
$('pwsame').value = st.patron_password || '';
}
$('pwsave').onclick = async () => {
const mode = document.querySelector('input[name=pwmode]:checked').value;
const r = await api('/api/settings', { patron_password_mode: mode, patron_password: $('pwsame').value });
$('pwsaved').textContent = r.settings && r.settings.patron_password_mode === mode ? 'Saved ✓' + (mode === 'same' && !r.settings.patron_password ? ' (enter a password of 4+ characters)' : '') : 'Could not save.';
};
$('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), r.password ? ' (sign-in: card number + password ' : '', r.password ? el('strong', {}, r.password) : '', r.password ? ')' : '', '. ',
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();
async function loadMine() {
let r; try { r = await api('/api/me'); } catch (e) { r = {}; }
if (!r.loans) { $('meloans').textContent = r.message || 'Could not load your books.'; return; }
$('mename').textContent = r.name; $('mecard').textContent = r.card;
const today = new Date(); today.setHours(12, 0, 0, 0);
const rows = r.loans.map(l => {
const days = Math.round((new Date(l.due + 'T12:00:00') - today) / 86400000);
const when = l.overdue ? 'Overdue by ' + (-days) + (days === -1 ? ' day' : ' days') : days === 0 ? 'Due today' : 'Due ' + fmtDue(l.due) + ' (in ' + days + (days === 1 ? ' day)' : ' days)');
const ren = el('button', { type: 'button' }, 'Renew');
ren.onclick = async () => { ren.disabled = true; const x = await api('/api/me/renew', { checkout_id: l.checkout_id });
if (x.status === 'renewed') loadMine(); else { ren.disabled = false; alert(x.message || 'That one cannot be renewed.'); } };
return el('div', { className: 'loan' }, el('span', { className: 't' }, el('a', { href: '#book/' + l.biblio_id }, l.title), el('div', { className: 'small' }, l.author)),
el('span', { className: 'chip ' + (l.overdue ? 'late' : 'in') }, when), ren);
});
$('meloans').replaceChildren(...(rows.length ? rows : [el('div', { className: 'card ok' }, el('div', { className: 'body' }, 'You have nothing checked out right now.'))]));
}
$('mepwform').onsubmit = async ev => {
ev.preventDefault(); const f = ev.target; const r = await api('/api/me/password', Object.fromEntries(new FormData(f)));
$('mepwresult').textContent = r.status === 'saved' ? 'Password changed. Next time your browser asks, use the new one.' : (r.message || 'Could not change it.');
if (r.status === 'saved') f.reset();
};
// ---- home: look up a book or a person, big actions, recently added
async function homeLookup(term) {
const box = $('homeresults');
if (term.length < 2) { box.replaceChildren(); return; }
box.replaceChildren(el('div', { className: 'small' }, 'Looking…'));
const [lib, ppl] = await Promise.all([
api('/api/library?q=' + encodeURIComponent(term)).catch(() => ({})),
viewer ? Promise.resolve({ patrons: [] }) : api('/api/patrons?q=' + encodeURIComponent(term)).catch(() => ({ patrons: [] }))]);
const parts = [];
if ((ppl.patrons || []).length) {
parts.push(el('h2', {}, 'People'), ...ppl.patrons.slice(0, 6).map(p => el('div', { className: 'person' },
el('span', { className: 't' }, el('strong', {}, p.name), el('div', { className: 'small' }, p.card)),
el('a', { href: '#lend/' + p.id }, el('button', { type: 'button', className: 'primary' }, 'Check out / returns')),
el('a', { href: '#card/' + p.id }, el('button', { type: 'button' }, 'Card')))));
}
const books = lib.books || [];
parts.push(el('h2', {}, 'Books'));
if (books.length) parts.push(el('div', { className: 'grid' }, ...books.slice(0, 12).map(tile)),
books.length > 12 || lib.more ? el('p', {}, el('a', { href: '#library', onclick: () => setTimeout(() => { $('q').value = term; loadLibrary(true); }, 0) }, 'See all matches')) : '');
else parts.push(el('div', { className: 'small' }, 'No book matches "' + term + '".' + (viewer ? '' : ' ') ), viewer ? '' : el('a', { href: '#add' }, 'Add it'));
box.replaceChildren(...parts);
}
$('homeform').onsubmit = ev => { ev.preventDefault(); homeLookup($('hq').value.trim()); };
let recentLoaded = 0;
async function loadRecent() {
if (Date.now() - recentLoaded < 5000) return; recentLoaded = Date.now();
let r; try { r = await api('/api/library?sort=new&page=1'); } catch (e) { return; }
const books = (r.books || []).slice(0, 12);
$('recent').replaceChildren(...books.map(tile));
$('recenth').hidden = !books.length; $('recent').hidden = !books.length;
}
$('menubtn').onclick = () => { const open = $('menu').hidden; $('menu').hidden = !open; $('menubtn').setAttribute('aria-expanded', String(open)); };
document.addEventListener('click', ev => { if (!$('menu').hidden && !ev.target.closest('#menu, #menubtn')) { $('menu').hidden = true; $('menubtn').setAttribute('aria-expanded', 'false'); } });
document.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !$('menu').hidden) { $('menu').hidden = true; $('menubtn').setAttribute('aria-expanded', 'false'); $('menubtn').focus(); } });
function route() {
const h = location.hash.replace(/^#/, '');
$('menu').hidden = true; $('menubtn').setAttribute('aria-expanded', 'false');
let m, view = 'home';
if ((m = h.match(/^book\/(\d+)$/))) view = 'book';
else if (h === 'library') view = 'library';
else if (h === 'mine' && isPatron) view = 'mine';
else if (viewer) view = h === 'library' ? 'library' : 'home';
else if (h === 'add' || h === 'settings') view = 'add';
else if (h === 'return' || (m = h.match(/^lend(?:\/(\d+))?$/))) view = 'lend';
else if (h === 'people' || h === 'newperson') view = 'people';
else if ((m = h.match(/^card\/(\d+)$/))) view = 'card';
else if (h === 'cards') view = 'cards';
else if (h === 'export') view = 'library';
for (const v of ['home', 'library', 'book', 'add', 'lend', 'people', 'card', 'cards', 'mine']) $('view-' + v).hidden = v !== view;
const tab = { home: 'home', library: 'library', book: 'library', add: 'add', lend: 'lend', people: 'people', card: 'people', cards: 'people', mine: 'mine' }[view];
for (const t of ['home', 'library', 'add', 'lend', 'people', 'mine', 'settings', 'export']) $('tab-' + t).classList.toggle('on', t === tab && !(t === 'add' && h === 'settings'));
$('tab-mine').hidden = !isPatron;
$('menu-admin').hidden = viewer;
$('footstaff').hidden = viewer;
$('csvtools').hidden = viewer;
const names = { home: 'Home', library: 'Browse', book: 'Book', add: 'Add books', lend: 'Lend / return', people: 'People', card: 'Library card', cards: 'Library cards', mine: 'My books' };
document.title = (view === 'home' ? '' : names[view] + ' - ') + 'Room for Books';
window.scrollTo(0, 0);
if (view === 'home') { $('homeacts').hidden = viewer; $('patronacts').hidden = !isPatron; loadRecent(); if (h === 'find') $('hq').focus(); }
else if (view === 'book') loadBook(m[1]);
else if (view === 'mine') loadMine();
else if (view === 'add') { if (h === 'settings') { $('settings').open = true; $('settings').scrollIntoView(); } else $('code').focus(); }
else if (view === 'lend') { loadOut(); curPatron = null; $('patronbox').replaceChildren();
if (m && m[1]) openPatron(m[1]); else if (h === 'return') $('ret').focus(); else { $('whoresults').replaceChildren(); $('who').focus(); } }
else if (view === 'people') { loadPeople($('pq').value.trim()); loadPwSettings(); if (h === 'newperson') { $('newperson').open = true; $('personform').querySelector('input').focus(); } }
else if (view === 'card') loadCard(m[1]);
else if (view === 'cards') loadCards();
else { if (!libLoaded) { libLoaded = true; loadLibrary(true); } if (h === 'export') { $('csvtools').open = true; $('csvtools').scrollIntoView(); } }
}
window.addEventListener('hashchange', route);
let libraryDisplay = '';
(async () => {
const s = await api('/api/state');
viewer = s.role !== 'admin'; isPatron = s.role === 'patron';
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; $('footkoha').href = staffRoot() + '/';
$('footname').textContent = 'Room for Books' + (s.library_name ? ' · ' + s.library_name : '');
$('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>