library: simple home page (look up, check out, return, add, add person, recently added), hamburger menu, footer; add books by title with matches to choose

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
Claude
2026-11-07 03:15:01 +00:00
parent 0af833a39d
commit 6fdc5f4dd1
4 changed files with 270 additions and 30 deletions
+12 -1
View File
@@ -208,7 +208,18 @@ quickadd/ the scan-to-add service (Python standard library + yaz)
Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before.
## Everyday use (the Library site at `/add/`)
## The home page (the Room for Books site at `/add/`)
One simple page: a **Look up** box (books by title, author or ISBN, and people by name or card number), four big buttons
(**Check out**, **Return**, **Add books**, **Add a person**) and **Recently added** books with their covers. The **menu**
(top right) holds the rest: browse all books, lend / return, people and library cards, barcodes and settings, export / import,
and a link to Koha's own staff screens. Borrowers and the read-only guest see just the lookup, recent books and (borrowers) My books.
**Adding by title:** on Add books, scan or type an ISBN to add it straight away; type a title or author instead and you get a list
of matches (cover, author, year, publisher) from Open Library and the Library of Congress to choose from. Books you already have
are marked, with an *Add another copy* button.
## Everyday use (details)
One page for the day-to-day, so you rarely need Koha's own screens:
Binary file not shown.
+101
View File
@@ -1527,6 +1527,101 @@ def op_logins_all(payload):
return 200, {"status": "done", "made": made}
# ------------------------------------------------------------ title / author search
def z3950_candidates(spec, term, limit=8):
"""Title search (falling back to any field) on a Z39.50 server; returns summaries with an ISBN."""
out = []
for attr in ("@attr 1=4", "@attr 1=1016"):
with tempfile.TemporaryDirectory() as tmp:
dump = os.path.join(tmp, "rec.mrc")
q = term.replace('"', " ")
script = 'format usmarc\nfind %s "%s"\nshow 1+%d\nquit\n' % (attr, q, limit)
try:
res = subprocess.run(["yaz-client", "-m", dump, spec], input=script, capture_output=True, text=True,
timeout=Z3950_TIMEOUT).stdout
except (subprocess.TimeoutExpired, OSError):
return out
m = re.search(r"Number of hits: (\d+)", res)
if not m or int(m.group(1)) == 0 or not os.path.exists(dump):
continue
raw = open(dump, "rb").read()
args = ["yaz-marcdump", "-o", "marcxml"]
if len(raw) > 9 and raw[9:10] == b" ":
args += ["-f", "MARC-8", "-t", "UTF-8", "-l", "9=97"]
conv = subprocess.run(args + [dump], capture_output=True, text=True, timeout=15)
if conv.returncode != 0:
continue
try:
root = ET.fromstring(conv.stdout if conv.stdout.lstrip().startswith("<collection") else "<c xmlns='%s'>%s</c>" % (NS["m"], conv.stdout))
except ET.ParseError:
continue
for rec in root.iter("{%s}record" % NS["m"]):
info = describe(rec)
isbn = ""
for df in rec.findall("m:datafield[@tag='020']", NS):
for sf in df.findall("m:subfield[@code='a']", NS):
c = clean_code((sf.text or "").split(" ")[0])
if c and len(c) in (10, 13) and not isbn:
isbn = isbn10_to_13(c) if len(c) == 10 else c
if isbn:
out.append(dict(info, isbn=isbn, source="Library of Congress"))
if out:
return out
return out
def openlibrary_candidates(term, limit=10):
url = "https://openlibrary.org/search.json?" + urllib.parse.urlencode(
{"q": term, "limit": str(limit), "fields": "title,author_name,first_publish_year,isbn,publisher,cover_i"})
try:
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "library-quickadd/1.0"}), timeout=10) as r:
docs = json.load(r).get("docs") or []
except (urllib.error.URLError, OSError, ValueError):
return []
out = []
for d in docs:
isbns = [clean_code(i) for i in (d.get("isbn") or [])]
isbns = [i for i in isbns if i and len(i) == 13 and i[:3] in ("978", "979")] or [isbn10_to_13(i) for i in isbns if i and len(i) == 10]
if not isbns or not d.get("title"):
continue
out.append({"title": d["title"], "author": ", ".join((d.get("author_name") or [])[:2]), "year": str(d.get("first_publish_year") or ""),
"publisher": ((d.get("publisher") or [""])[0])[:60], "isbn": isbns[0], "source": "Open Library"})
return out
def op_lookup(term):
"""Books matching a title/author, to choose from; each has an ISBN so choosing one is a normal add."""
term = re.sub(r"\s+", " ", term or "").strip()[:100]
if len(term) < 3:
return {"candidates": []}
found, seen = [], set()
for src in SOURCES:
got = []
if src == "openlibrary":
got = openlibrary_candidates(term)
elif src.startswith("z3950:"):
got = z3950_candidates(src[len("z3950:"):], term)
for c in got:
key = (c["isbn"], (c["title"] + c["author"]).lower()[:40])
if c["isbn"] in {k[0] for k in seen} or key in seen:
continue
seen.add(key)
found.append(c)
found = found[:12]
if found: # which of these do we already own? one query for all
conds = [{"isbn": {"-like": "%" + c["isbn"] + "%"}} for c in found] + [{"ean": {"-like": "%" + c["isbn"] + "%"}} for c in found]
try:
owned = " ".join((b.get("isbn") or "") + " " + (b.get("ean") or "") for b in
(koha("GET", "/biblios", query={"q": json.dumps({"-or": conds}), "_per_page": "50"}) or []))
except KohaError:
owned = ""
for c in found:
alt = isbn13_to_10(c["isbn"]) or ""
c["have"] = c["isbn"] in owned or bool(alt and alt in owned)
have = found
return {"candidates": have}
# ------------------------------------------------------------ photo scanning
MAX_IMAGE = 12 * 1024 * 1024
ZBAR_TIMEOUT = 20
@@ -1726,6 +1821,12 @@ class Handler(BaseHTTPRequestHandler):
except KohaError as e:
return self._send(404 if e.status == 404 else 502, {"status": "error", "message":
"Koha is not answering yet - try again in a minute." if e.status == 503 else "Failed: %s" % e.body[:120]})
if path == "/api/lookup":
q = (urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("q") or [""])[0]
try:
return self._send(200, op_lookup(q))
except KohaError:
return self._send(502, {"status": "error", "message": "Koha is not answering yet - try again in a minute."})
if path == "/api/me":
if self.role != "patron":
return self._send(403, {"status": "error", "message": "Sign in with a library card to see your books."})
+157 -29
View File
@@ -44,6 +44,34 @@
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); }
@@ -134,7 +162,7 @@
.libcard .no { font-size: 9pt; text-align: center; letter-spacing: .15em; }
.cardsheet { display: grid; grid-template-columns: repeat(2, 85.6mm); gap: 6mm; justify-content: start; }
.cardsheet .libcard { break-inside: avoid; }
@media print { header, .noprint, nav { display: none !important; } body { background: #fff; } main { padding: 0; max-width: none; } .libcard { border: 1px solid #000; } }
@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; }
@@ -146,19 +174,48 @@
</head>
<body>
<main>
<header>
<h1 id="title">Room for Books</h1>
<nav class="tabs" aria-label="Sections">
<a id="tab-library" href="#library" class="on">Library</a>
<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>
<a id="tab-lend" href="#lend">Lend / return</a>
<a id="tab-people" href="#people">People</a>
<a id="tab-add" href="#add">Add books</a>
<a id="lnk-staff" class="ext" href="#" target="_blank" rel="noopener">Manage</a>
<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>
@@ -287,8 +344,8 @@
</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>
<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">
@@ -341,6 +398,7 @@
<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>
@@ -662,10 +720,38 @@
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) add(code);
if (!code) return;
if (looksLikeCode(code)) add(code); else searchTitles(code);
};
function typeButtons() {
@@ -1179,34 +1265,75 @@
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(/^#/, '');
let m, view = 'library';
$('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 = 'library';
else if (h === 'add') view = 'add';
else if ((m = h.match(/^lend(?:\/(\d+))?$/))) view = 'lend';
else if (h === 'people') view = 'people';
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';
for (const v of ['library', 'book', 'add', 'lend', 'people', 'card', 'cards', 'mine']) $('view-' + v).hidden = v !== view;
const tab = { library: 'library', book: 'library', add: 'add', lend: 'lend', people: 'people', card: 'people', cards: 'people', mine: 'mine' }[view];
for (const t of ['library', 'add', 'lend', 'people', 'mine']) $('tab-' + t).classList.toggle('on', t === tab);
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;
for (const t of ['add', 'lend', 'people']) $('tab-' + t).hidden = viewer;
$('menu-admin').hidden = viewer;
$('footstaff').hidden = viewer;
$('csvtools').hidden = viewer;
const names = { library: 'The library', book: 'The library', add: 'Add books', lend: 'Lend / return', people: 'People', card: 'Library card', cards: 'Library cards', mine: 'My books' };
document.title = names[view] + ' - Room for Books';
if (view === 'book') loadBook(m[1]);
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') $('code').focus();
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 { $('whoresults').replaceChildren(); $('who').focus(); } }
else if (view === 'people') { loadPeople($('pq').value.trim()); loadPwSettings(); }
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 === 'find') $('q').focus(); }
else { if (!libLoaded) { libLoaded = true; loadLibrary(true); } if (h === 'export') { $('csvtools').open = true; $('csvtools').scrollIntoView(); } }
}
window.addEventListener('hashchange', route);
let libraryDisplay = '';
@@ -1221,7 +1348,8 @@
$('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-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');