aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-02 13:28:29 +0200
committerDanilo M. <danix@danix.xyz>2026-10-02 13:28:29 +0200
commit402fbef0cd555a8c3288698b0231f22147f57880 (patch)
tree25c6333606110b6499f24849707f36bb5377d7ca
parentc5a0d1ca39a0ae61e03b0f38a30579b811b4b858 (diff)
downloadbook-reader-master.tar.gz
book-reader-master.zip
Turn the reader into a series library with a bottom status barHEADmaster
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.md20
-rwxr-xr-xrc.book-reader4
-rw-r--r--reader.html119
3 files changed, 101 insertions, 42 deletions
diff --git a/README.md b/README.md
index 5ad3942..cab4bde 100644
--- a/README.md
+++ b/README.md
@@ -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]);