aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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]);