library: Library home page (cover grid, filters, search) and book page with shelf/call-number editing
Quick Add becomes the everyday interface: Library grid with shelf/format/status filters, a book page (copies, status, edit shelf and call number, add/remove copy, delete), read-only for the guest. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
+186
-37
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Add to library</title>
|
||||
<title>Library</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f5f1; --card: #ffffff; --ink: #1d2320; --muted: #5b655f; --line: #dcdad2;
|
||||
@@ -53,6 +53,7 @@
|
||||
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); }
|
||||
@@ -93,28 +94,70 @@
|
||||
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); }
|
||||
.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>
|
||||
<h1 id="title">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="tab-library" href="#library" class="on">Library</a>
|
||||
<a id="tab-add" href="#add">Add books</a>
|
||||
<a id="lnk-staff" class="ext" href="#" target="_blank" rel="noopener">Manage</a>
|
||||
<a id="lnk-opac" class="ext" href="#" target="_blank" rel="noopener">Public catalogue</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section id="view-find" hidden>
|
||||
<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="Title, author or ISBN" enterkeyhint="search">
|
||||
<button class="primary" type="submit">Find</button>
|
||||
<input id="q" type="search" placeholder="Search title, author or ISBN" enterkeyhint="search">
|
||||
<button class="primary" type="submit">Search</button>
|
||||
</form>
|
||||
<div id="found" aria-live="polite"></div>
|
||||
<div class="filters">
|
||||
<label class="sr" for="f-shelf">Shelf</label><select id="f-shelf"></select>
|
||||
<label class="sr" for="f-type">Format</label><select id="f-type"></select>
|
||||
<label class="sr" for="f-status">Status</label><select id="f-status">
|
||||
<option value="">Any status</option><option value="in">On the shelf</option><option value="out">Checked out</option></select>
|
||||
<label class="sr" for="f-sort">Sort</label><select id="f-sort">
|
||||
<option value="title">Sort: title</option><option value="author">Sort: author</option><option value="new">Sort: newest first</option></select>
|
||||
</div>
|
||||
<div id="libstatus" class="small" aria-live="polite"></div>
|
||||
<div class="grid" id="grid"></div>
|
||||
<div class="actions"><button type="button" id="more" hidden>Show more</button></div>
|
||||
</section>
|
||||
|
||||
<section id="view-book" hidden>
|
||||
<p><a href="#library" id="back">← Back to the library</a></p>
|
||||
<div id="bookbox" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-add">
|
||||
@@ -459,37 +502,143 @@
|
||||
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();
|
||||
// ---- 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;
|
||||
}
|
||||
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')))));
|
||||
let libPage = 1, libShelves = [], libLoaded = false;
|
||||
const filters = () => ({ q: $('q').value.trim(), shelf: $('f-shelf').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;
|
||||
}
|
||||
$('findform').onsubmit = ev => { ev.preventDefault(); find($('q').value); };
|
||||
|
||||
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 (!libShelves.length && r.shelves) {
|
||||
libShelves = r.shelves;
|
||||
$('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.type || f.status ? ' match' : '') :
|
||||
(f.q || f.shelf || f.type || f.status ? 'No books match.' : (viewer ? 'Nothing here yet.' : 'Nothing here yet - open Add books and scan an ISBN.'));
|
||||
}
|
||||
$('more').onclick = () => { libPage += 1; loadLibrary(false); };
|
||||
$('findform').onsubmit = ev => { ev.preventDefault(); loadLibrary(true); };
|
||||
for (const id of ['f-shelf', 'f-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;
|
||||
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);
|
||||
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)'));
|
||||
}
|
||||
}
|
||||
|
||||
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 = el('select', { 'aria-label': 'Shelf' }, el('option', { value: '' }, '(no shelf set)'),
|
||||
...libShelves.map(l => el('option', { value: l.value }, l.label)));
|
||||
sel.value = c.shelf;
|
||||
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, 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(); }
|
||||
};
|
||||
box.append(el('div', { className: 'row' }, sel, cn, save, rm));
|
||||
return box;
|
||||
}
|
||||
|
||||
function route() {
|
||||
const h = location.hash.replace(/^#/, '');
|
||||
const m = h.match(/^book\/(\d+)$/);
|
||||
let view = m ? 'book' : (h === 'add' && !viewer) ? 'add' : 'library';
|
||||
$('view-library').hidden = view !== 'library'; $('view-book').hidden = view !== 'book'; $('view-add').hidden = view !== 'add';
|
||||
$('tab-library').classList.toggle('on', view !== 'add'); $('tab-add').classList.toggle('on', view === 'add');
|
||||
$('tab-add').hidden = viewer;
|
||||
document.title = view === 'add' ? 'Add books' : (libraryName || 'Library');
|
||||
$('title').textContent = view === 'add' ? 'Add books' : (libraryName || 'Library');
|
||||
if (view === 'book') loadBook(m[1]);
|
||||
else if (view === 'add') $('code').focus();
|
||||
else { if (!libLoaded) { libLoaded = true; loadLibrary(true); } if (h === 'find') $('q').focus(); }
|
||||
}
|
||||
window.addEventListener('hashchange', route);
|
||||
let libraryName = '';
|
||||
|
||||
(async () => {
|
||||
const s = await api('/api/state');
|
||||
@@ -514,7 +663,7 @@
|
||||
}
|
||||
if (!viewer) api('/api/settings').then(applySettings);
|
||||
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
|
||||
showView();
|
||||
route();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user