- Apache in the Koha container serves Quick Add at /add/ on the staff site (KOHA_QUICKADD_URL); koha now starts after quickadd so the name resolves - Quick Add: relative API paths so it works at / and /add/; Add books / Find a book tabs; search by title, author or ISBN showing each copy's shelf and status (/api/search); Manage and Public catalogue links - staff home gets a 'Library tools' block (HTML customizations, how Koha 26.05 stores it) linking to Add books - installer, kiosk add-on and READMEs advertise one main address; the public catalogue stays on its own port (Koha gives public and staff sites the same URL shapes, so they cannot share one address) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
515 lines
27 KiB
HTML
515 lines
27 KiB
HTML
<!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"> 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) {
|
||
const r = await fetch(path.replace(/^\//, ''), body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body) } : {});
|
||
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' }, 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/');
|
||
|
||
// ---- views: Add books / Find a book
|
||
function showView() {
|
||
const find = location.hash === '#find';
|
||
$('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.')]),
|
||
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');
|
||
types = s.types; if (!types[type]) type = 'BOOK'; typeButtons();
|
||
staffPort = s.staff_port; settings = s.settings || {}; shelf = settings.location || '';
|
||
staffBase = s.staff_url || '';
|
||
$('lnk-opac').href = s.opac_url ? s.opac_url + '/' : 'http://' + location.hostname + ':' + s.opac_port + '/';
|
||
$('lnk-staff').href = staffRoot() + '/';
|
||
$('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;
|
||
}
|
||
api('/api/settings').then(applySettings);
|
||
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
|
||
showView();
|
||
})();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|