Files
ubuntu-based-kiosk/library/quickadd/index.html
T
Claude 10e991e158 library: staff signed in to Koha open Add books without a second login
Quick Add asks Koha (C4::Auth::check_cookie_auth via a secret-guarded svc script) whether the
browser's session may edit the catalogue; falls back to its own login otherwise. Cookie-authenticated
writes need an X-Quickadd header; the direct port never accepts cookies.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
2026-11-06 22:45:01 +00:00

523 lines
28 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>Add to 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; }
.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); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
</style>
</head>
<body>
<main>
<header>
<h1 id="title">Add to library</h1>
<nav class="tabs" aria-label="Sections">
<a id="tab-add" href="#add" class="on">Add books</a>
<a id="tab-find" href="#find">Find a book</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-find" 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="Title, author or ISBN" enterkeyhint="search">
<button class="primary" type="submit">Find</button>
</form>
<div id="found" aria-live="polite"></div>
</section>
<section id="view-add">
<fieldset class="types" id="types" style="border:0;padding:0" aria-label="What are you adding?"></fieldset>
<div class="shelf" id="shelfbox" hidden>
<label for="shelf">Shelf / room</label>
<select id="shelf"></select>
</div>
<form class="scan" id="scan" autocomplete="off">
<label class="sr" for="code">ISBN or barcode</label>
<input id="code" inputmode="numeric" placeholder="Scan or type an ISBN" autofocus>
<button class="primary" type="submit">Add</button>
</form>
<div class="cam-row">
<button type="button" id="cam">Scan with camera</button>
<input type="file" id="camfile" accept="image/*" capture="environment" hidden>
<span class="small">Opens your phone's camera: fill the frame with the barcode and hold steady.</span>
</div>
<div id="status" role="status" aria-live="polite"></div>
<div id="result"></div>
<h2>Added this session <span id="count" class="small"></span></h2>
<ul class="list" id="session"></ul>
<div class="labels" id="labels" hidden>
<strong id="range"></strong>
<div class="small">Print barcode labels for these: the button opens the label printer with the range filled in
(you may be asked to log in to the staff interface).</div>
<div class="actions"><a id="lnk-labels" href="#" target="_blank" rel="noopener"><button type="button">Print labels</button></a></div>
</div>
<details class="settings" id="settings">
<summary>Barcodes and settings</summary>
<fieldset>
<legend>Barcodes on new items</legend>
<label class="opt"><input type="radio" name="bcmode" value="auto"> Number them automatically</label>
<label class="opt"><input type="radio" name="bcmode" value="scan"> I stick a pre-printed barcode on each one: ask me to scan it</label>
<label class="opt"><input type="radio" name="bcmode" value="none"> No barcodes for now: I will add them later</label>
<div class="small" style="margin-top:6px">
Digits per barcode:
<select id="width"></select>
<span id="nextwrap">&nbsp;Next barcode: <strong id="nextbc"></strong></span>
</div>
<div class="small">Same-length numbers are what Koha's label printer needs for a range, so numbers are zero-padded (0000042). Seven or more digits keeps scanners from misreading a short all-number barcode as the pharmacy "Code 32" format.</div>
</fieldset>
<div class="tool">
<strong>Items without a barcode: <span id="missing">0</span></strong>
<div class="actions"><button type="button" id="assign">Give them barcodes</button></div>
</div>
<div class="tool">
<strong>Print a sheet of barcodes ahead of time</strong>
<div class="small">Reserve a block of numbers, print the sheet, and stick the labels on whenever you like.</div>
<div class="actions"><label for="reserve-count" class="sr">How many</label>
<input type="number" id="reserve-count" min="1" max="2000" value="80">
<button type="button" id="reserve">Reserve numbers</button></div>
</div>
<div id="tool-result" aria-live="polite"></div>
</details>
<h2>Earlier</h2>
<ul class="list" id="earlier"></ul>
</section>
</main>
<script>
(() => {
const $ = id => document.getElementById(id);
let types = {}, sessionItems = [], busy = false, lastAdded = null, shelf = '', awaiting = false, staffPort = '8081', settings = {}, type = localStorage.getItem('qa-type') || 'BOOK';
// --- small audible feedback so a stack of books can be scanned without looking
let actx;
function beep(freqs) {
try {
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
let t = actx.currentTime;
for (const f of freqs) {
const o = actx.createOscillator(), g = actx.createGain();
o.frequency.value = f; g.gain.setValueAtTime(0.15, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
o.connect(g); g.connect(actx.destination); o.start(t); o.stop(t + 0.17); t += 0.18;
}
} catch (e) {}
}
const SOUND = { added: [880], duplicate: [520, 520], notfound: [300], error: [220, 180] };
const el = (tag, props = {}, ...kids) => {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k.includes('-')) n.setAttribute(k, v); // aria-*, data-*: attributes, not JS properties
else n[k] = v;
}
for (const k of kids) n.append(k);
return n;
};
async function api(path, body) {
// X-Quickadd marks our own requests: a staff-site login (cookie) is only accepted for changes
// that carry it, which a page on another website cannot add (cross-site request forgery).
const r = await fetch(path.replace(/^\//, ''), body ? { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Quickadd': '1' },
body: JSON.stringify(body) } : { headers: { 'X-Quickadd': '1' } });
return r.json();
}
function cover(code) {
const img = el('img', { className: 'cover', alt: '', hidden: true });
if (code && /^\d{10,13}$/.test(code)) {
img.onload = () => { if (img.naturalWidth > 1) img.hidden = false; };
img.src = 'https://covers.openlibrary.org/b/isbn/' + code + '-M.jpg?default=false';
}
return img;
}
function show(kind, ...parts) { $('result').replaceChildren(el('div', { className: 'card ' + kind }, ...parts)); }
function bookBody(r, extra) {
return el('div', { className: 'body' },
el('div', { className: 'title' }, r.title || ''),
el('div', { className: 'meta' }, [r.author, r.callnumber && ('Call no. ' + r.callnumber), r.location].filter(Boolean).join(' · ')),
...extra);
}
function rowFor(e, withUndo) {
const li = el('li', {},
el('span', { className: 'badge' }, e.barcode || '–'),
el('span', { className: 't' }, el('div', {}, e.title), el('div', { className: 'u' },
[e.author, e.callnumber, e.location, e.source].filter(Boolean).join(' · '))));
if (withUndo) {
const b = el('button', { type: 'button' }, 'Undo');
b.onclick = async () => {
b.disabled = true;
const r = await api('/api/undo', { item_id: e.item_id, biblio_id: e.biblio_id });
if (r.status === 'undone') {
sessionItems = sessionItems.filter(x => x.item_id !== e.item_id); render();
if (lastAdded === e.item_id) $('result').replaceChildren();
$('status').textContent = 'Removed "' + e.title + '".';
} else b.disabled = false;
};
li.append(b);
}
return li;
}
function render() {
$('session').replaceChildren(...sessionItems.map(e => rowFor(e, true)));
$('count').textContent = sessionItems.length ? '(' + sessionItems.length + ')' : '';
const bcs = sessionItems.map(e => e.barcode).filter(b => b && /^\d+$/.test(b));
$('labels').hidden = bcs.length === 0;
if (bcs.length) {
const sameLen = bcs.every(b => b.length === bcs[0].length);
const sorted = [...bcs].sort((a, b) => (a.length - b.length) || (a < b ? -1 : 1));
const lo = sorted[0], hi = sorted[sorted.length - 1];
$('range').textContent = lo === hi ? 'Barcode ' + lo : 'Barcodes ' + lo + ' to ' + hi;
$('lnk-labels').href = sameLen ? printLink(lo, hi) : $('lnk-labels').href;
}
}
async function add(code, opts = {}) {
if (busy) return; busy = true;
$('status').textContent = 'Looking up ' + code + '…';
try {
const r = await api('/api/add', Object.assign({ code, type }, opts));
handle(r, code);
} catch (e) {
beep(SOUND.error); show('bad', el('div', { className: 'body' }, 'Could not reach the quick-add service.'));
} finally { busy = false; if (!awaiting) $('code').focus(); }
}
function handle(r, code) {
$('status').textContent = '';
if (r.status === 'added') {
beep(SOUND.added); sessionItems.unshift(r); lastAdded = r.item_id; render();
awaiting = false; $('code').focus();
show('ok', cover(r.code), bookBody(r, [el('div', { className: 'meta' },
r.barcode ? 'Added as barcode ' : 'Added (no barcode yet)',
r.barcode ? el('span', { className: 'badge' }, r.barcode) : '',
(r.source === 'extra copy' ? ' · another copy of a title you already have' : ' · from ' + r.source))]));
} else if (r.status === 'duplicate') {
beep(SOUND.duplicate);
const more = el('button', { className: 'primary', type: 'button' }, 'Add another copy');
more.onclick = () => add(code, { copy: true });
const skip = el('button', { type: 'button' }, 'Skip');
skip.onclick = () => { $('result').replaceChildren(); $('code').focus(); };
show('warn', cover(code), bookBody(r, [el('div', { className: 'meta' },
'You already have this (' + r.copies + (r.copies === 1 ? ' copy' : ' copies') + ').'),
el('div', { className: 'actions' }, more, skip)]));
} else if (r.status === 'need_barcode' || r.status === 'barcode_taken' || r.status === 'barcode_invalid') {
stickerPrompt(r);
} else if (r.status === 'notfound') {
beep(SOUND.notfound); manualForm(code);
} else {
beep(SOUND.error);
show('bad', el('div', { className: 'body' }, r.message || 'Something went wrong.'));
}
}
// ---- phone camera: take a photo, shrink it, have the server read the barcode
async function shrink(file, max = 2400) {
let bmp;
try { bmp = await createImageBitmap(file, { imageOrientation: 'from-image' }); }
catch (e) { bmp = await new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = URL.createObjectURL(file); }); }
const w = bmp.width, h = bmp.height, k = Math.min(1, max / Math.max(w, h));
const c = document.createElement('canvas'); c.width = Math.round(w * k); c.height = Math.round(h * k);
c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height);
return new Promise(ok => c.toBlob(ok, 'image/jpeg', 0.9));
}
// Opens the camera, resolves with the codes found ([] if none, null if cancelled).
function takePhoto(kind) {
return new Promise(resolve => {
const input = $('camfile');
input.onchange = async () => {
const f = input.files[0]; input.value = '';
if (!f) return resolve(null);
$('status').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: Add books / Find a book
function showView() {
const find = viewer || location.hash === '#find';
$('tab-add').hidden = viewer;
$('view-add').hidden = find; $('view-find').hidden = !find;
$('tab-add').classList.toggle('on', !find); $('tab-find').classList.toggle('on', find);
$('title').textContent = find ? 'Find a book' : 'Add to library';
document.title = find ? 'Find a book' : 'Add to library';
if (find) $('q').focus(); else $('code').focus();
}
window.addEventListener('hashchange', showView);
async function find(term) {
const box = $('found');
if (term.trim().length < 2) { box.replaceChildren(el('div', { className: 'small' }, 'Type at least two letters.')); return; }
box.replaceChildren(el('div', { className: 'small' }, 'Searching…'));
let r;
try { r = await api('/api/search?q=' + encodeURIComponent(term)); } catch (e) { r = { status: 'error' }; }
if (!r.results) { box.replaceChildren(el('div', { className: 'card warn' }, el('div', { className: 'body' }, r.message || 'Search failed.'))); return; }
if (!r.results.length) { box.replaceChildren(el('div', { className: 'small' }, 'No matches for "' + term + '".')); return; }
box.replaceChildren(...r.results.map(b => el('div', { className: 'hit' },
el('div', { className: 't' }, b.title), el('div', { className: 'meta' }, [b.author, b.year].filter(Boolean).join(' · ')),
...(b.copies.length ? b.copies.map(c => el('div', { className: 'copy' },
c.barcode ? el('span', { className: 'badge' }, c.barcode) : '',
el('span', {}, c.location || 'no shelf set'), c.callnumber ? el('span', { className: 'meta' }, c.callnumber) : '',
el('span', { className: 'chip ' + (c.status === 'On shelf' ? 'in' : 'out') }, c.status)))
: [el('div', { className: 'small' }, 'No copies recorded.')]),
viewer ? '' : el('div', { className: 'small', style: 'margin-top:6px' },
el('a', { href: staffRoot() + '/cgi-bin/koha/catalogue/detail.pl?biblionumber=' + b.biblio_id, target: '_blank', rel: 'noopener' }, 'Open full record')))));
}
$('findform').onsubmit = ev => { ev.preventDefault(); find($('q').value); };
(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)));
showView();
})();
})();
</script>
</body>
</html>