diff options
| -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]); |
