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 /reader.html | |
| 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>
Diffstat (limited to 'reader.html')
| -rw-r--r-- | reader.html | 119 |
1 files changed, 82 insertions, 37 deletions
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]); |
