diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-02 13:28:29 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-02 13:28:29 +0200 |
| commit | 402fbef0cd555a8c3288698b0231f22147f57880 (patch) | |
| tree | 25c6333606110b6499f24849707f36bb5377d7ca | |
| parent | c5a0d1ca39a0ae61e03b0f38a30579b811b4b858 (diff) | |
| download | book-reader-master.tar.gz book-reader-master.zip | |
LIBRARY is now a folder with one subfolder per series; every image
folder inside a series is a book. The library screen shows a grid of
series that drills into multi-book series, with search (/) and Esc to go
back. Folders are scanned in parallel.
Book ids are paths relative to the library, so reading progress
survives moving the library. The server links LIBRARY as "library/".
The floating HUD becomes a fixed full-width bottom bar: library button
and "series / book" on the left, progress in the middle, page numbers
and fit/fullscreen on the right. Pages are sized to leave it clear.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | README.md | 20 | ||||
| -rwxr-xr-x | rc.book-reader | 4 | ||||
| -rw-r--r-- | reader.html | 119 |
3 files changed, 101 insertions, 42 deletions
@@ -4,15 +4,29 @@ Browser viewer for books stored as folders of page images. Shows two pages side by side with a 3D page-turn animation, flips with keyboard, mouse wheel or click, and remembers where you stopped in each book. -Every folder under the library that contains images is a book, named after -the folder. Pages are sorted in natural order (`2.jpg` before `10.jpg`). +The library is one folder with a subfolder per series. Every folder inside a +series that contains images is a book, named after its folder. Pages are +sorted in natural order (`2.jpg` before `10.jpg`). + +``` +library/ + Series A/ <- images here: the main book "Series A" + Extras/ <- another book of Series A + Series B/ + Vol 1/ + Vol 2/ +``` + +The library screen (`b`) shows a grid of series; a series with more than one +book opens to its books, a single-book series opens straight away. `/` +searches the current grid, Esc goes back. ## Usage Create `~/.config/book-reader.conf`: ```sh -LIBRARY="/path/to/collection" +LIBRARY="/path/to/library" PORT=8642 ``` diff --git a/rc.book-reader b/rc.book-reader index 5ba30a3..aed5a34 100755 --- a/rc.book-reader +++ b/rc.book-reader @@ -4,7 +4,7 @@ # SPDX-License-Identifier: GPL-2.0-only # # Config (shell syntax): ${XDG_CONFIG_HOME:-~/.config}/book-reader.conf -# LIBRARY=/path/to/collection # required; every folder with images inside is a book +# LIBRARY=/path/to/library # required; one folder per series, every folder with images is a book # PORT=8642 # keep it fixed: reading progress is stored per origin # BIND=127.0.0.1 # READER=/path/to/book-reader/reader.html # needed when this script is copied out of the repo @@ -39,7 +39,7 @@ start() { mkdir -p -m 700 "$RUN" "$RUN/root" find "$RUN/root" -mindepth 1 -maxdepth 1 -type l -delete ln -s "$(readlink -f "$READER")" "$RUN/root/reader.html" - ln -s "$(readlink -f "$LIBRARY")" "$RUN/root/$(basename "$LIBRARY")" + ln -s "$(readlink -f "$LIBRARY")" "$RUN/root/library" setsid python3 -m http.server -b "$BIND" -d "$RUN/root" "$PORT" >"$RUN/log" 2>&1 & echo $! >"$PIDFILE" sleep 0.5 diff --git a/reader.html b/reader.html index 5bc7057..e38d2ac 100644 --- a/reader.html +++ b/reader.html @@ -10,7 +10,7 @@ <meta name="viewport" content="width=device-width,initial-scale=1"> <title>Reader</title> <style> -:root { --ar: .707; --dur: 650ms; --accent: #e8b86a; } +:root { --hud: 30px; --ar: .707; --dur: 650ms; --accent: #e8b86a; } * { box-sizing: border-box; margin: 0; } html, body { height: 100%; overflow: hidden; } /* fit-width: pages span the screen, document scrolls vertically */ @@ -19,7 +19,7 @@ html.wide body { height: auto; min-height: 100%; overflow: visible; place-items: body { background: #070607; color: #ddd; font: 14px/1.4 system-ui, sans-serif; user-select: none; - display: grid; place-items: center; + display: grid; place-items: center; padding-bottom: var(--hud); /* keep pages clear of the bar */ } /* own fixed layer: stays put while scrolling, no repaint (unlike background-attachment: fixed) */ body::before { @@ -30,7 +30,7 @@ body::before { position: relative; display: flex; perspective: 2800px; filter: drop-shadow(0 30px 40px rgba(0,0,0,.6)); } -.page, #leaf { height: min(100vh, calc(50vw / var(--ar))); aspect-ratio: var(--ar); } +.page, #leaf { height: min(calc(100vh - var(--hud)), calc(50vw / var(--ar))); aspect-ratio: var(--ar); } .wide .page, .wide #leaf { height: calc(50vw / var(--ar)); } .page { position: relative; } .page img, .face img { width: 100%; height: 100%; object-fit: contain; display: block; } @@ -48,14 +48,14 @@ img:not([src]) { visibility: hidden; } .shade { position: absolute; inset: 0; background: #000; opacity: 0; pointer-events: none; } #hud { - position: fixed; left: 50%; bottom: 14px; transform: translateX(-50%); - display: flex; gap: 14px; align-items: center; padding: 8px 14px; - background: rgba(20,18,18,.75); backdrop-filter: blur(8px); border-radius: 999px; - border: 1px solid rgba(255,255,255,.08); transition: opacity .4s; + position: fixed; left: 0; right: 0; bottom: 0; height: var(--hud); z-index: 1; + display: flex; gap: 12px; align-items: center; padding: 0 8px; font-size: 13px; + background: rgba(14,12,13,.85); backdrop-filter: blur(8px); border-top: 1px solid rgba(255,255,255,.07); } -body.idle #hud { opacity: 0; } body.idle { cursor: none; } -#bar { width: min(30vw, 260px); height: 4px; background: rgba(255,255,255,.12); border-radius: 2px; overflow: hidden; } +#title, #count { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +#count { text-align: right; font-variant-numeric: tabular-nums; } +#bar { width: min(40vw, 480px); height: 4px; background: rgba(255,255,255,.12); border-radius: 2px; overflow: hidden; } #fill { height: 100%; width: 0; background: var(--accent); transition: width .3s; } button { background: none; border: 0; color: inherit; font: inherit; cursor: pointer; padding: 2px 6px; border-radius: 6px; } button:hover, button:focus-visible { background: rgba(255,255,255,.1); outline: none; } @@ -66,7 +66,13 @@ button:hover, button:focus-visible { background: rgba(255,255,255,.1); outline: opacity: 0; pointer-events: none; transition: opacity .25s; } #shelf.open { opacity: 1; pointer-events: auto; } -#shelf h1 { font-weight: 300; letter-spacing: .08em; margin-bottom: 24px; } +#top { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; } +#top h1 { font-weight: 300; letter-spacing: .08em; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#back { font-size: 22px; } +#back[hidden] { display: none; } +#q { background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.12); color: inherit; font: inherit; + padding: 6px 12px; border-radius: 999px; width: min(260px, 40vw); outline: none; } +#q:focus { border-color: var(--accent); } #grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 28px; } .card { text-align: left; padding: 0; transition: transform .2s; } .card:hover, .card:focus-visible { transform: translateY(-4px); background: none; } @@ -100,10 +106,14 @@ button:hover, button:focus-visible { background: rgba(255,255,255,.1); outline: </div> <div id="shelf"> - <h1>Library</h1> + <div id="top"> + <button id="back" title="Back (Esc)" hidden>←</button> + <h1 id="where">Library</h1> + <input id="q" type="search" placeholder="Search /" aria-label="Search"> + </div> <div id="grid"></div> <p id="help">→ / Space / PgDn / wheel down: next · ← / PgUp / wheel up: previous · Home / End · - W: fit width / height (wheel and ↑↓ scroll, flip at page end) · C: toggle cover offset · F: fullscreen · B: library · click left/right half to flip</p> + W: fit width / height (wheel and ↑↓ scroll, flip at page end) · C: toggle cover offset · F: fullscreen · B: library (/ search, Esc back) · click left/right half to flip</p> </div> <script> @@ -120,17 +130,21 @@ let books = [], book = null, s = 0, cur = null; const L = $('.left img'), R = $('.right img'), leaf = $('#leaf'); const F = $('.front img'), B = $('.back img'), shades = document.querySelectorAll('.shade'); -// Every directory holding images is a book, named after its folder. Needs a server that lists dirs (rc.book-reader). +// Every directory holding images is a book, named after its folder; the first-level folder +// under the library is its series. Needs a server that lists dirs (rc.book-reader). +const ROOT = 'library/'; async function walk(path) { - const html = await (await fetch(path || './')).text(); + const html = await (await fetch(path)).text(); const hrefs = [...new DOMParser().parseFromString(html, 'text/html').querySelectorAll('a[href]')] .map(a => a.getAttribute('href')).filter(h => !/^([?\/#]|\.\.|[a-z]+:)/i.test(h)); const pages = hrefs.filter(h => IMG.test(h)).sort((a, b) => coll.compare(dec(a), dec(b))).map(h => path + h); - const out = pages.length ? [{ id: path, name: dec(path.slice(0, -1)).split('/').pop(), pages }] : []; - for (const d of hrefs.filter(h => h.endsWith('/')).sort((a, b) => coll.compare(dec(a), dec(b)))) - out.push(...await walk(path + d)); - return out; + const id = path.slice(ROOT.length), seg = id.split('/').filter(Boolean).map(dec); // id: stable across LIBRARY moves + const out = pages.length ? [{ id, name: seg.at(-1) || 'Library', series: seg[0] || 'Library', pages }] : []; + const dirs = hrefs.filter(h => h.endsWith('/')).sort((a, b) => coll.compare(dec(a), dec(b))); + return out.concat(...await Promise.all(dirs.map(d => walk(path + d)))); } +// series in library order, main book (the series folder itself) first since walk lists parents first +const seriesList = () => [...Map.groupBy(books, b => b.series)].map(([name, list]) => ({ name, books: list })); const align = i => i - ((i + (store.cover ? 1 : 0)) % 2); // left index of spread containing page i (may be -1) const src = (img, i) => book.pages[i] ? img.src = book.pages[i] : img.removeAttribute('src'); @@ -139,8 +153,8 @@ const first = () => store.cover ? -1 : 0; function show() { src(L, s); src(R, s + 1); const n = book.pages.length, a = Math.max(s, 0) + 1, b = Math.min(s + 2, n); - $('#title').textContent = book.name; - $('#count').textContent = (a === b ? a : `${a}–${b}`) + ` / ${n}`; + $('#title').textContent = book.name === book.series ? book.name : `${book.series} / ${book.name}`; + $('#count').textContent = (a === b ? `page ${a}` : `pages ${a}–${b}`) + ` of ${n}`; $('#fill').style.width = (b / n * 100) + '%'; store.pos[book.id] = Math.max(s, 0); store.last = book.id; save(); for (let i = s - 2; i < s + 6; i++) if (book.pages[i]) new Image().src = book.pages[i]; // preload @@ -176,28 +190,59 @@ function open(b) { s = align(store.pos[b.id] ?? 0); show(); } -function shelf() { - const g = $('#grid'); g.replaceChildren(); - for (const b of books) { - const c = document.createElement('button'), img = new Image(), name = document.createElement('b'), - sm = document.createElement('small'), p = document.createElement('div'), pi = document.createElement('i'); - const at = Math.min((store.pos[b.id] ?? -1) + 2, b.pages.length); - c.className = 'card' + (b === book ? ' current' : ''); - img.loading = 'lazy'; img.src = b.pages[0]; img.alt = ''; - name.textContent = b.name; - sm.textContent = `${b.pages.length} pages` + (store.pos[b.id] != null ? ` · at ${Math.max(at, 1)}` : ''); - p.className = 'p'; pi.style.width = (store.pos[b.id] != null ? at / b.pages.length * 100 : 0) + '%'; - p.append(pi); c.append(img, name, sm, p); c.onclick = () => open(b); g.append(c); - } - $('#shelf').classList.add('open'); g.querySelector('.current')?.focus(); +const read = b => store.pos[b.id] == null ? 0 : Math.min(store.pos[b.id] + 2, b.pages.length); + +function card(cover, title, sub, pct, current, onclick) { + const c = document.createElement('button'), img = new Image(), name = document.createElement('b'), + sm = document.createElement('small'), p = document.createElement('div'), pi = document.createElement('i'); + c.className = 'card' + (current ? ' current' : ''); + img.loading = 'lazy'; img.src = cover; img.alt = ''; + name.textContent = title; sm.textContent = sub; + p.className = 'p'; pi.style.width = pct * 100 + '%'; + p.append(pi); c.append(img, name, sm, p); c.onclick = onclick; c.dataset.name = title.toLowerCase(); + return c; } -const toggleShelf = () => $('#shelf').classList.contains('open') ? book && $('#shelf').classList.remove('open') : shelf(); +let view = null; // null: series grid; else the series being browsed +function shelf(v = view) { + view = v; + const g = $('#grid'), q = $('#q'); + g.replaceChildren(...(v ? v.books.map(b => card(b.pages[0], b.name, + `${b.pages.length} pages` + (read(b) ? ` · at ${read(b)}` : ''), read(b) / b.pages.length, b === book, () => open(b))) + : seriesList().map(sr => { + const n = sr.books.reduce((a, b) => a + b.pages.length, 0), r = sr.books.reduce((a, b) => a + read(b), 0); + return card(sr.books[0].pages[0], sr.name, + (sr.books.length > 1 ? `${sr.books.length} books · ` : '') + `${n} pages`, r / n, book?.series === sr.name, + () => sr.books.length > 1 ? shelf(sr) : open(sr.books[0])); + }))); + $('#where').textContent = v ? v.name : 'Library'; + $('#back').hidden = !v; + q.value = ''; filter(); + $('#shelf').classList.add('open'); + (g.querySelector('.current') || g.firstChild)?.focus(); +} +function filter() { + const t = $('#q').value.trim().toLowerCase(); + for (const c of $('#grid').children) c.hidden = !c.dataset.name.includes(t); +} +$('#q').oninput = filter; +$('#back').onclick = () => shelf(null); + +const toggleShelf = () => $('#shelf').classList.contains('open') ? book && $('#shelf').classList.remove('open') + : shelf(book && seriesList().find(sr => sr.name === book.series && sr.books.length > 1) || null); +// Esc: series -> library grid -> back to the open book +const shelfBack = () => view ? shelf(null) : toggleShelf(); const fullscreen = () => document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen(); addEventListener('keydown', e => { if (e.ctrlKey || e.metaKey || e.altKey) return; - if ($('#shelf').classList.contains('open')) { if (e.key === 'Escape' || e.key === 'b') toggleShelf(); return; } + if (e.target === $('#q')) { if (e.key === 'Escape') { e.target.value ? (e.target.value = '', filter()) : e.target.blur(); } return; } + if ($('#shelf').classList.contains('open')) { + if (e.key === 'Escape' || e.key === 'Backspace') shelfBack(); + else if (e.key === 'b') toggleShelf(); + else if (e.key === '/') { e.preventDefault(); $('#q').focus(); } + return; + } const k = e.key, act = { ArrowRight: () => go(s + 2), PageDown: () => page(1), ' ': () => page(e.shiftKey ? -1 : 1), l: () => go(s + 2), ArrowLeft: () => go(s - 2), PageUp: () => page(-1), Backspace: () => go(s - 2), h: () => go(s - 2), @@ -241,7 +286,7 @@ addEventListener('mousemove', () => { idle = setTimeout(() => document.body.classList.add('idle'), 2500); }); -walk('').then(b => { +walk(ROOT).then(b => { books = b; if (!books.length) return $('#title').textContent = 'No images found under the library directory'; open(books.find(x => x.id === store.last) || books[0]); |
