From 4d16c14b79eb1f6745127a2bfe4fad5aea2e8a3c Mon Sep 17 00:00:00 2001 From: Squeaks72 Date: Tue, 22 Sep 2026 12:55:53 -0500 Subject: [PATCH 01/12] reader: single arrow keypress turns a page in paged mode The keydown handler had no ArrowLeft/ArrowRight cases at all, so in paged mode arrows fell through to the browser's native scrolling of the snap-x track: the first press nudged it a few pixels and the snap pulled it back, so it took two presses to land on the next page. Bind them (plus PageUp/ PageDown/Space) to the same scrollBy the tap zones use, with preventDefault so the native scroll stops competing. A focused form control (the page slider, the settings sheet) keeps its own arrow keys and Space in both modes, so it can still be nudged by keyboard. USAGE.md lists the reader's keys. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/USAGE.md | 3 +++ web/app/reader/page.tsx | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 7ee1b5816..fa67e2c39 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -546,6 +546,9 @@ you keep scrolling through a series without interruption. - **Per-series memory:** your zoom/theme choices are remembered per title. - **Jump to a chapter:** the chapter button in the top bar opens the full list, at every screen size. On a desktop `[` / `]` step to the previous/next chapter as well. +- **Keyboard:** in paged mode **→** / **↓** / **Page Down** / **Space** turn to the next page and **←** / **↑** / + **Page Up** to the previous one, one press per page, the same as tapping the edge of the page. In webtoon + scroll, **Space** / **↓** and **↑** scroll by most of a screen. - **Jump to a page:** tap the page counter (`4/18`) in the bottom bar for a thumbnail grid of the chapter. - **Desktop:** the page is centered with comfortable margins. diff --git a/web/app/reader/page.tsx b/web/app/reader/page.tsx index bf7822bbc..6818ff1ed 100644 --- a/web/app/reader/page.tsx +++ b/web/app/reader/page.tsx @@ -679,12 +679,25 @@ function ReaderInner() { useEffect(() => { const onKey = (e: KeyboardEvent) => { const el = scrollRef.current; + const paged = prefs.mode === 'paged'; + // Paged mode turns its own pages instead of leaving arrows to the browser: the track is a + // snap-x container, and a native arrow keypress only nudges it a few pixels before the snap + // pulls it back, so it took two presses to land on the next page. This is the same scrollBy + // the tap zones use, so keys and taps advance identically -- the track always runs + // left-to-right, only the page order inside an RTL spread flips. + const page = (dir: 1 | -1) => { if (el) el.scrollBy({ left: dir * (el.clientWidth || window.innerWidth), behavior: 'smooth' }); }; + // A focused control (the page slider in the bottom bar, anything in the settings sheet) owns its arrow + // keys and Space; turning the page underneath it instead would make the slider impossible to nudge. + const t = e.target as HTMLElement | null; + const owned = !!t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA' || t.isContentEditable); if (e.key === '[') goChapter(prevId); else if (e.key === ']') goChapter(nextId); else if (e.key === 'f') toggleFullscreen(); else if (e.key === 'Escape') back(); - else if (el && prefs.mode === 'vertical' && (e.key === ' ' || e.key === 'ArrowDown')) { e.preventDefault(); el.scrollBy({ top: el.clientHeight * 0.88, behavior: 'smooth' }); } - else if (el && prefs.mode === 'vertical' && e.key === 'ArrowUp') { e.preventDefault(); el.scrollBy({ top: -el.clientHeight * 0.88, behavior: 'smooth' }); } + else if (el && paged && !owned && (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ')) { e.preventDefault(); page(1); } + else if (el && paged && !owned && (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp')) { e.preventDefault(); page(-1); } + else if (el && prefs.mode === 'vertical' && !owned && (e.key === ' ' || e.key === 'ArrowDown')) { e.preventDefault(); el.scrollBy({ top: el.clientHeight * 0.88, behavior: 'smooth' }); } + else if (el && prefs.mode === 'vertical' && !owned && e.key === 'ArrowUp') { e.preventDefault(); el.scrollBy({ top: -el.clientHeight * 0.88, behavior: 'smooth' }); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); From 95f012fa7cb7fdbda0165a95686cfd00454951b8 Mon Sep 17 00:00:00 2001 From: Squeaks72 Date: Wed, 23 Sep 2026 08:00:17 -0500 Subject: [PATCH 02/12] feat(web): type-to-search opens the command palette pre-filled With a keyboard, pressing a letter or digit while no text field or dialog has focus opens the command palette with that character already typed, so searching the library is just "start typing". Ctrl/Cmd+K and "/" still open it empty. Modifier chords, punctuation, IME composition, claimed keys and open aria-modal dialogs are left alone; the reader is excluded as before. The palette input now focuses in a layout effect so the second keystroke isn't lost to . The palette's action list, including its Desktop filtering (DESKTOP_HIDDEN.paletteKeys), is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/USAGE.md | 7 ++++++ web/components/AppShell.tsx | 9 +++---- web/components/CommandPalette.tsx | 38 +++++++++++++++++++----------- web/lib/typeToSearch.ts | 34 +++++++++++++++++++++++++++ web/test/typeToSearch.test.ts | 39 +++++++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 18 deletions(-) create mode 100644 web/lib/typeToSearch.ts create mode 100644 web/test/typeToSearch.test.ts diff --git a/docs/USAGE.md b/docs/USAGE.md index 7ee1b5816..e54fff279 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -82,6 +82,13 @@ filters currently show. The top bar has **Home** (a daily-pick hero + "For you" rails), **Library**, **Lists** and **Discover**, plus search, the updates bell, a refresh button, and your profile. +Search opens the **command palette**: one box that finds any series in the library and runs the quick actions +(Surprise me, Updates, Refresh library and so on). With a keyboard there are three ways in: **Ctrl+K** (**⌘K** +on a Mac) or **/** open it empty, and simply **starting to type** a title opens it with that first letter +already in the box, so *"one p"* typed on the library page is a search. Only letters and digits do this, and +only when nothing else wants the key: not while you are typing in a field, not with a dialog open, not with +Ctrl, Alt or ⌘ held, and never in the reader, which keeps its own keys. + ### What counts as a chapter Point `LIBRARY_PATH` at what you already have. A chapter can be any of: diff --git a/web/components/AppShell.tsx b/web/components/AppShell.tsx index c8daa1a13..f5a839fc9 100644 --- a/web/components/AppShell.tsx +++ b/web/components/AppShell.tsx @@ -62,6 +62,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const path = usePathname(); const router = useRouter(); const [palette, setPalette] = useState(false); + const [paletteSeed, setPaletteSeed] = useState(''); /** * An offline launch arrives at `/` -- the manifest's `start_url` -- which is a home screen assembled @@ -75,8 +76,8 @@ export function AppShell({ children }: { children: React.ReactNode }) { if (path.startsWith('/downloads') || path.startsWith('/reader')) return; router.replace('/downloads/'); }, [status, path, router]); - // Ctrl/Cmd+K or "/" anywhere in the app (reader keeps its own keys; palette skipped there) - usePaletteHotkeys(setPalette, status === 'authed' && !path.startsWith('/reader')); + // Ctrl/Cmd+K, "/", or just typing, anywhere in the app (reader keeps its own keys; palette skipped there) + usePaletteHotkeys(setPalette, status === 'authed' && !path.startsWith('/reader'), setPaletteSeed); // smart offline: keep favorites' latest unread chapters downloaded. Never on desktop, where "Save offline" // is hidden: it would copy chapters already on this disk into the window's storage (lib/desktop.ts). @@ -112,14 +113,14 @@ export function AppShell({ children }: { children: React.ReactNode }) { <> {status === 'offline' && } - setPalette(true)} /> + { setPaletteSeed(''); setPalette(true); }} />
{children}
{/* Renders nothing unless something is downloading or has failed. */} {status === 'authed' && } - {status === 'authed' && setPalette(false)} />} + {status === 'authed' && setPalette(false)} />} ); } diff --git a/web/components/CommandPalette.tsx b/web/components/CommandPalette.tsx index da475d29d..ddae52ca1 100644 --- a/web/components/CommandPalette.tsx +++ b/web/components/CommandPalette.tsx @@ -1,7 +1,7 @@ 'use client'; -// Global command palette (Ctrl/Cmd+K or "/"): instant series search + quick actions. +// Global command palette (Ctrl/Cmd+K, "/", or just start typing): instant series search + quick actions. // No dependency — a fixed overlay + debounced POST /api/series/search, keyboard-navigable. -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { AnimatePresence, motion } from 'framer-motion'; import { api, img } from '@/lib/api'; @@ -12,10 +12,11 @@ import { Img } from './ui'; import { IcSearch, IcSparkle, IcRefresh, IcBell, IcDownload, IcGrid, IcMoments } from './icons'; import { t as tr } from '@/lib/i18n'; import { hiddenOnDesktop, DESKTOP_HIDDEN } from '@/lib/desktop'; +import { isTypingTarget, typeToSearchKey } from '@/lib/typeToSearch'; interface Action { key: string; label: string; hint?: string; icon: React.ReactNode; run: () => void | Promise } -export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) { +export function CommandPalette({ open, seed = '', onClose }: { open: boolean; seed?: string; onClose: () => void }) { const router = useRouter(); const toast = useToast(); const [q, setQ] = useState(''); @@ -25,15 +26,19 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () = const inputRef = useRef(null); const seq = useRef(0); - // reset on open; focus the input - useEffect(() => { + // Reset on open (to the typed-to-open character, if any) and focus the input. A layout effect, focusing + // in the same commit that mounts the input: with type-to-search the NEXT keystroke is usually already on + // its way, and a deferred focus let it land on and vanish. The timeout stays as a backstop for + // the enter animation. + useLayoutEffect(() => { if (open) { - setQ(''); + setQ(seed); setResults([]); setSel(0); + inputRef.current?.focus(); setTimeout(() => inputRef.current?.focus(), 30); } - }, [open]); + }, [open]); // seed is read at open time only // debounced instant search useEffect(() => { @@ -159,17 +164,22 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () = ); } -/** Global open-palette keybindings: Ctrl/Cmd+K anywhere, "/" when not typing. */ -export function usePaletteHotkeys(setOpen: (fn: (o: boolean) => boolean) => void, enabled: boolean) { +/** + * Global open-palette keybindings: Ctrl/Cmd+K anywhere, "/" when not typing, and type-to-search -- a letter + * or digit when not typing opens the palette with that character already in it (lib/typeToSearch.ts). + */ +export function usePaletteHotkeys(setOpen: (fn: (o: boolean) => boolean) => void, enabled: boolean, onSeed?: (seed: string) => void) { useEffect(() => { if (!enabled) return; const onKey = (e: KeyboardEvent) => { - const el = document.activeElement as HTMLElement | null; - const typing = !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable); - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setOpen((o) => !o); } - else if (e.key === '/' && !typing) { e.preventDefault(); setOpen(() => true); } + const typing = isTypingTarget(document.activeElement); + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); onSeed?.(''); setOpen((o) => !o); return; } + if (e.key === '/' && !typing) { e.preventDefault(); onSeed?.(''); setOpen(() => true); return; } + if (!onSeed) return; + const ch = typeToSearchKey(e, { typing, modalOpen: !!document.querySelector('[aria-modal="true"]') }); + if (ch) { e.preventDefault(); onSeed(ch); setOpen(() => true); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [setOpen, enabled]); + }, [setOpen, enabled, onSeed]); } diff --git a/web/lib/typeToSearch.ts b/web/lib/typeToSearch.ts new file mode 100644 index 000000000..01bf9092e --- /dev/null +++ b/web/lib/typeToSearch.ts @@ -0,0 +1,34 @@ +// Type-to-search: on desktop, a letter or digit pressed while nothing is focused for typing opens the +// command palette with that character already in the box, so "start typing a title" just works. +// +// Deliberately narrow. Only letters and digits count -- punctuation keeps any meaning a page may give it +// ("/" already opens the palette empty, and Space scrolls). Any modifier except Shift means a shortcut, not +// text. A key another handler already claimed (defaultPrevented), an IME mid-composition, and any open +// modal (`aria-modal="true"`: Modal, ConfirmDialog, ConsoleNav's sheet) are left alone -- a keystroke there +// belongs to the dialog, and a palette opening over it would steal focus from a half-filled form. + +export interface KeyLike { + key: string; + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; + isComposing?: boolean; + defaultPrevented?: boolean; + repeat?: boolean; +} + +const TYPEABLE = /^[\p{L}\p{N}]$/u; + +/** The character to seed the palette with, or null when this key should be left to the page. */ +export function typeToSearchKey(e: KeyLike, ctx: { typing: boolean; modalOpen: boolean }): string | null { + if (ctx.typing || ctx.modalOpen) return null; + if (e.ctrlKey || e.metaKey || e.altKey || e.isComposing || e.defaultPrevented || e.repeat) return null; + return TYPEABLE.test(e.key) ? e.key : null; +} + +/** An element that takes typed text, so keys pressed there are the element's, not the palette's. */ +export function isTypingTarget(el: Element | null): boolean { + if (!el) return false; + const h = el as HTMLElement; + return h.tagName === 'INPUT' || h.tagName === 'TEXTAREA' || h.tagName === 'SELECT' || !!h.isContentEditable; +} diff --git a/web/test/typeToSearch.test.ts b/web/test/typeToSearch.test.ts new file mode 100644 index 000000000..60bbe5e25 --- /dev/null +++ b/web/test/typeToSearch.test.ts @@ -0,0 +1,39 @@ +// Type-to-search opens the palette only for plain letters/digits pressed outside a text field or dialog. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { typeToSearchKey } from '../lib/typeToSearch'; + +const k = (key: string, extra: Partial[0]> = {}) => + ({ key, ctrlKey: false, metaKey: false, altKey: false, ...extra }); +const free = { typing: false, modalOpen: false }; + +test('letters and digits seed the palette, case kept', () => { + assert.equal(typeToSearchKey(k('o'), free), 'o'); + assert.equal(typeToSearchKey(k('O', {}), free), 'O'); + assert.equal(typeToSearchKey(k('7'), free), '7'); + assert.equal(typeToSearchKey(k('é'), free), 'é'); + assert.equal(typeToSearchKey(k('の'), free), 'の'); +}); + +test('punctuation, whitespace and named keys are left to the page', () => { + for (const key of ['/', ' ', '?', '.', 'Enter', 'Escape', 'ArrowDown', 'Tab', 'Shift', 'F5', 'Dead']) { + assert.equal(typeToSearchKey(k(key), free), null, key); + } +}); + +test('shortcuts are not text', () => { + assert.equal(typeToSearchKey(k('k', { ctrlKey: true }), free), null); + assert.equal(typeToSearchKey(k('c', { metaKey: true }), free), null); + assert.equal(typeToSearchKey(k('a', { altKey: true }), free), null); +}); + +test('claimed, composing and held keys are ignored', () => { + assert.equal(typeToSearchKey(k('a', { defaultPrevented: true }), free), null); + assert.equal(typeToSearchKey(k('a', { isComposing: true }), free), null); + assert.equal(typeToSearchKey(k('a', { repeat: true }), free), null); +}); + +test('never while typing in a field or with a dialog open', () => { + assert.equal(typeToSearchKey(k('a'), { typing: true, modalOpen: false }), null); + assert.equal(typeToSearchKey(k('a'), { typing: false, modalOpen: true }), null); +}); From d203438902b8e088777e5a4f517a37c8063bc37c Mon Sep 17 00:00:00 2001 From: Squeaks72 Date: Tue, 22 Sep 2026 11:18:47 -0500 Subject: [PATCH 03/12] Keep Discover's source filter through a search A term submitted with a source chosen now asks only that source (&source= on search-all) instead of fanning out to every one and answering with everything, and the picker stays on screen so the filter is visible and clearable rather than silently narrowing the results. `source` only narrows the route's existing surfaceable() set, so an adult source without adult=1, one above the account's age limit, or an unknown id asks nobody and answers the empty shape. The search query's key now includes the chosen source; discoverSearch.test.ts and discoverAdult.test.ts pin the new key and the picker being mounted in both modes, and searchAll.int.test.ts covers the narrowed fan-out. Documented in openapi.yaml, docs/api.md and USAGE.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- bff/openapi.yaml | 10 +++++++ bff/src/routes/sources.ts | 16 ++++++++-- bff/test/searchAll.int.test.ts | 30 +++++++++++++++++++ docs/USAGE.md | 3 ++ docs/api.md | 5 ++++ web/app/discover/page.tsx | 52 +++++++++++++++++++++------------ web/test/discoverAdult.test.ts | 16 ++++++---- web/test/discoverSearch.test.ts | 23 +++++++++++---- 8 files changed, 123 insertions(+), 32 deletions(-) diff --git a/bff/openapi.yaml b/bff/openapi.yaml index 9e103bf13..c14a3c148 100644 --- a/bff/openapi.yaml +++ b/bff/openapi.yaml @@ -1988,6 +1988,16 @@ paths: schema: type: string description: "`1` to include adult sources in the fan-out. Absent means they are not asked." + - name: source + in: query + required: false + schema: + type: string + description: >- + Ask only this source id (what Discover sends while it is filtered to one source). Narrows the + fan-out within the sources the caller may be shown, never past them: an id outside that set + (an adult source without `adult=1`, one above the account's age limit, an unknown id) asks + nobody and answers the empty shape. Absent means every source. - name: groupBy in: query required: false diff --git a/bff/src/routes/sources.ts b/bff/src/routes/sources.ts index 721f7f059..3003c79c4 100644 --- a/bff/src/routes/sources.ts +++ b/bff/src/routes/sources.ts @@ -1781,7 +1781,7 @@ export default async function sourceRoutes(app: FastifyInstance) { * polls the same URL with a short `wait` until it is 0. */ app.get('/api/sources/search-all', async (req) => { - const { q: rawQ, groupBy, wait } = req.query as { q?: string; groupBy?: string; wait?: string }; + const { q: rawQ, groupBy, wait, source } = req.query as { q?: string; groupBy?: string; wait?: string; source?: string }; const term = (rawQ || '').trim(); if (!term) return { content: [], sources: [], pending: 0, asked: 0 }; // Absent means the full first-answer wait, so a caller written before `wait` existed gets the most @@ -1795,7 +1795,19 @@ export default async function sourceRoutes(app: FastifyInstance) { // "Show 18+" chip. The chip belongs here and not only in the shaping below, because a source left in // `ask` is a source this request STARTS -- an outbound query to an adult site on behalf of someone who // asked not to see one, and its results would then also land in the shared entry under this term. - const ask = surfaceable(req); + const all = surfaceable(req); + // `source` narrows the fan-out to one source, for a search made while Discover is filtered to it. The + // filter was display-only before, and did not survive a search at all: submitting a term asked every + // source and answered with everything, so choosing a source and then searching within it was not + // possible. Narrowing here rather than filtering the answer also makes it one outbound request instead + // of a dozen, which is the difference between an instant answer and the slowest source's timeout. + // + // It only ever narrows `surfaceable`, never widens it: an id outside that set -- an adult source with + // the reveal off, one an age cap puts out of reach, or one that does not exist -- leaves `ask` empty, + // nobody is asked, and the answer is the ordinary nothing-found shape. The entry is still keyed by the + // term alone, so a narrowed search and a full one share whatever the sources have already answered. + const only = typeof source === 'string' ? source : ''; + const ask = only ? all.filter((s) => s.id === only) : all; const health = new Map((await healthAll().catch(() => [])).map((h) => [h.source_id, h] as const)); const ans = await searchAll(term, ask, { waitMs, health }); const byId = new Map(ask.map((s) => [s.id, s] as const)); diff --git a/bff/test/searchAll.int.test.ts b/bff/test/searchAll.int.test.ts index 571075dc0..f9ac258e1 100644 --- a/bff/test/searchAll.int.test.ts +++ b/bff/test/searchAll.int.test.ts @@ -263,6 +263,36 @@ test('an empty term answers the same shape without asking anyone', { skip }, asy assert.deepEqual(r.json(), { content: [], sources: [], pending: 0, asked: 0 }); }); +test('source= narrows the fan-out to that one source, and cannot reach past the caller\'s own set', { skip }, async () => { + // Discover filtered to one source keeps that filter through a search. Narrowing is done on the ASK, not on + // the answer, so the other sources are not even asked: one outbound request rather than a dozen, and the + // answer comes as soon as that source does. Reintroduce by ignoring `source` in the route: the other + // sources' counters move and `sources` lists all of them. + const before = { ...calls }; + const r = await app.inject({ method: 'GET', url: `/api/sources/search-all?q=Narrowed%20Term&source=${FAST}`, headers: tok(ids.plain) }); + assert.equal(r.statusCode, 200); + const j = r.json(); + assert.deepEqual(j.sources.map((x: any) => x.id), [FAST], 'the answer covers more than the chosen source'); + assert.equal(j.asked, 1); + assert.ok(j.content.length > 0 && j.content.every((g: any) => g.providers.every((p: any) => p.source === FAST)), + 'a card came from a source that was not chosen'); + assert.equal(calls[FAST] - before[FAST], 1, 'PREMISE: the chosen source was not asked'); + const others = ALL.filter((id) => id !== FAST && calls[id] !== before[id]); + assert.deepEqual(others, [], `sources outside the filter were asked: ${others.join(', ')}`); + + // The filter only ever narrows `surfaceable`: naming an adult source without the reveal, or naming one an + // age cap puts out of reach even with it, asks nobody and answers the ordinary empty shape. + const adultBefore = calls[ADULT]; + for (const [url, who] of [ + [`/api/sources/search-all?q=Narrowed%20Adult&source=${ADULT}`, ids.plain], + [`/api/sources/search-all?q=Narrowed%20Adult&source=${ADULT}&adult=1`, ids.capped], + ] as const) { + const a = (await app.inject({ method: 'GET', url, headers: tok(who) })).json(); + assert.deepEqual([a.content, a.sources, a.asked], [[], [], 0], `${url} reached a source outside the caller's set`); + } + assert.equal(calls[ADULT], adultBefore, 'naming the adult source asked it'); +}); + test('after the TTL the sources are asked again', { skip }, async () => { // Reintroduce by dropping the TTL check at the top of searchAll: the entry answers forever and the // counter does not move. diff --git a/docs/USAGE.md b/docs/USAGE.md index 7ee1b5816..58dff2a99 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -617,6 +617,9 @@ again. about six seconds); source rows show *Searching…*, empty, failed, disabled or cooling-down states while the rest arrive. Repeating the same search continues the in-flight work and a recent term opens from the five-minute cache. Results are still filtered for the signed-in account, including its age limit. + With one source chosen in the chip, a search asks **only that source** and the chip stays on screen while + the results are up, so you can see the search is narrowed and clear it with its × (which searches every + source again). Switching the toggle to *Newest* or *Popular* goes back to browsing. - **Add:** tap a card and pick which source to add it from — each with its favicon, the first marked *most used* (skipped when only one has it). The dialog then opens with *From {source} · Change*. Choose **Chapters to fetch now** (All, First N, Latest N, or **Nothing yet — pick chapters later**), toggle diff --git a/docs/api.md b/docs/api.md index 1e86a3990..31dbf4336 100644 --- a/docs/api.md +++ b/docs/api.md @@ -324,6 +324,11 @@ slot count) at a time, one lane for solver-fronted sources and one for the rest; budget is recorded as slow (never as a failure), one that throws is recorded against its health, and one that is disabled or in a cooldown is skipped rather than asked — exactly as the newest listing treats them. +`&source=` asks only that one source, which is what Discover sends while it is filtered to a source. It +narrows the set described above and never widens it: an adult source without `adult=1`, one above your age +limit, or an id that does not exist asks nobody and answers the empty shape. The entry is still keyed by the +term alone, so a narrowed search reads whatever a full one already heard, and the other way round. + `GET /api/sources/detail` is cached for ten minutes per source and series (it was ninety seconds), and concurrent requests for the same pair — the add dialog's pre-warm and the pick that follows it — collapse into one outbound fetch. A failed lookup is never cached, so an immediate retry asks the source again. diff --git a/web/app/discover/page.tsx b/web/app/discover/page.tsx index f97d15dc9..a0295d9be 100644 --- a/web/app/discover/page.tsx +++ b/web/app/discover/page.tsx @@ -211,12 +211,15 @@ export default function DiscoverPage() { * same term again inside five minutes is instant, and leaving search mode stops the polling by itself. */ const searchQ = useQuery({ - queryKey: ['search-all', term], + // `selected` is part of the key: narrowing to a source is a different question, and must not be + // answered from the unfiltered search's cache. + queryKey: ['search-all', term, selected], queryFn: ({ signal, queryKey, client }) => { // ⚠️ Only the first request may wait the long wait. A poll that also waited six seconds would hold // its answer until the server's grace expired, so the wall would fill in six seconds late every time. const first = (client.getQueryState(queryKey)?.dataUpdateCount ?? 0) === 0; - return api(`/api/sources/search-all?q=${encodeURIComponent(term)}&wait=${first ? SEARCH_FIRST_WAIT_MS : SEARCH_POLL_WAIT_MS}`, { signal }); + const only = selected ? `&source=${encodeURIComponent(selected)}` : ''; + return api(`/api/sources/search-all?q=${encodeURIComponent(term)}&wait=${first ? SEARCH_FIRST_WAIT_MS : SEARCH_POLL_WAIT_MS}${only}`, { signal }); }, enabled: mode === 'search' && !!term, // A failed search is shown as one; retrying it would be another fan-out to every source. @@ -227,11 +230,18 @@ export default function DiscoverPage() { }); // The grouped hits as wall rows, under today's mapping: the first provider's ids are the card's, the badge // counts every provider. Derived, so a poll's answer replaces the rows without anything being cleared. - const searchHits = useMemo(() => (searchQ.data?.content ?? []).map((g) => ({ - source: g.providers[0]?.source ?? '', sourceId: g.providers[0]?.sourceId ?? g.title, - title: g.title, coverUrl: g.coverUrl, updatedAt: g.updatedAt, - inLibrary: g.inLibrary, providerCount: g.providers.length, - })), [searchQ.data]); + const searchHits = useMemo(() => (searchQ.data?.content ?? []).flatMap((g) => { + // With a source chosen the server has already asked only that one, so this is belt-and-braces: keep + // the card only if that source is among its providers, and let that provider be the card's own, so + // tapping it opens the source being browsed rather than whichever the fold happened to rank first. + const pick = selected ? g.providers.find((p) => p.source === selected) : g.providers[0]; + if (!pick) return []; + return [{ + source: pick.source ?? '', sourceId: pick.sourceId ?? g.title, + title: g.title, coverUrl: g.coverUrl, updatedAt: g.updatedAt, + inLibrary: g.inLibrary, providerCount: g.providers.length, + }]; + }), [searchQ.data, selected]); const groupsRef = useRef>({}); // What each search stored, keyed the way the wall's own fold is, so open() offers the providers of a hit // the same way it offers the providers of a folded card. Written from the answer, never from state. @@ -449,18 +459,22 @@ export default function DiscoverPage() { - {mode === 'newest' && ( - { setListMode(m); setSelected(null); setPage(1); }} - /> - )} + {/* + In both modes, not just while browsing. The picker is the only place the chosen source is visible or + clearable, so hiding it during a search -- while the search itself is narrowed to that source -- + would leave the results silently filtered with nothing on screen to say why. Choosing a list tab is + a browse action, so from a search it returns to browsing that list. + */} + { setListMode(m); setSelected(null); setPage(1); if (mode === 'search') backToNewest(); }} + /> {/* One mounted child per budgeted source. Renders nothing; owns one request. The key carries the listing mode, so switching Newest/Popular REMOUNTS these and they fetch the diff --git a/web/test/discoverAdult.test.ts b/web/test/discoverAdult.test.ts index 67b7797cd..ca43b88b6 100644 --- a/web/test/discoverAdult.test.ts +++ b/web/test/discoverAdult.test.ts @@ -38,16 +38,22 @@ test('the reveal chip stays on screen while the reveal is on', () => { }); test('the chip is anchored where a search cannot unmount it', () => { - // Reintroduce by moving `` into the SourcePicker chip row: it is mounted only for - // `mode === 'newest'` (the line below it), so the chip would vanish as soon as anyone searched. + // Reintroduce by moving `` into the SourcePicker chip row: the header is the one place + // that is the same in both modes, and the picker's row is a browse control whose mounting has changed + // before. SourcePicker used to be newest-only, which is why this anchor exists; it now stays mounted + // while searching too, because a search made with a source chosen is narrowed to that source and the + // picker is where that filter is shown and cleared. Reintroduce the old gating by wrapping it in + // `{mode === 'newest' && (...)}` again: "SourcePicker is gated on the mode" fails, and a narrowed search + // loses the only thing on screen that says it is narrowed. const page = code(read('app/discover/page.tsx')); const header = page.slice(page.indexOf('')); assert.ok(header.includes(' { diff --git a/web/test/discoverSearch.test.ts b/web/test/discoverSearch.test.ts index 18127672e..9720f8da9 100644 --- a/web/test/discoverSearch.test.ts +++ b/web/test/discoverSearch.test.ts @@ -42,10 +42,16 @@ const PAGE = 'app/discover/page.tsx'; test('the search is a query keyed on the submitted term, not on the field', () => { // `q` is whatever is in the field; `term` is what was submitted. Keyed on `q`, every keystroke would be a // fan-out to every source, and keyed on a constant, a new term would paint under the old term's answer - // until its own landed. Reintroduce by writing `queryKey: ['search-all', q]`: "keyed on the field" fails; - // by bringing back `setSearchHits`: "the imperative search is back" fails. + // until its own landed. The chosen source is part of the key too: a search narrowed to one source is a + // different question (it sends `&source=`), and answering it from the unfiltered search's cache, or the + // other way round, would show one under the other. Reintroduce by writing `queryKey: ['search-all', q]`: + // "keyed on the field" fails; by dropping `selected` from the key: the same assertion fails; by dropping + // `&source=` from the request: "the chosen source is not sent" fails; by bringing back `setSearchHits`: + // "the imperative search is back" fails. const src = code(read(PAGE)); - assert.match(src, /queryKey: \['search-all', term\]/, 'the search is not keyed on the submitted term (or is keyed on the field)'); + assert.match(src, /queryKey: \['search-all', term, selected\]/, 'the search is not keyed on the submitted term and the chosen source (or is keyed on the field)'); + assert.match(src, /const only = selected \? `&source=\$\{encodeURIComponent\(selected\)\}` : '';/, 'the chosen source is not sent'); + assert.match(src, /SEARCH_POLL_WAIT_MS\}\$\{only\}`, \{ signal \}\)/, 'the chosen source is not sent'); assert.match(src, /const \[term, setTerm\] = useState\(''\)/, 'there is no separate submitted term'); assert.match(src, /enabled: mode === 'search' && !!term/, 'the search runs outside search mode or with an empty term'); assert.doesNotMatch(src, /setSearchHits|setSearching|useState\(\[\]\)/, 'the imperative search is back'); @@ -127,12 +133,17 @@ test('the wall pins still hold, and the hits are derived from the answer', () => // wall.test.ts pins `return foldByTitle(out, nameOf, rankOf);` and `wall.groups[key] ?? // groupsRef.current[key]` byte for byte; both survive the rebuild, and the hits and the groups are now // DERIVED from the answer (a memo and an effect on `searchQ.data`) rather than set by a handler, so a - // poll's answer replaces the rows without anything being cleared first. Reintroduce by filling groupsRef - // inside the queryFn: "groups are not filled from the answer" fails. + // poll's answer replaces the rows without anything being cleared first. With a source chosen, a hit keeps + // only if that source is among its providers, and that provider is the card's own, so tapping it opens the + // source being browsed rather than whichever one the fold ranked first. Reintroduce by filling groupsRef + // inside the queryFn: "groups are not filled from the answer" fails; by going back to `providers[0]` + // regardless of the filter: "a hit's provider ignores the chosen source" fails. const src = read(PAGE); assert.match(src, /return foldByTitle\(out, nameOf, rankOf\);/, 'the wall is not folded'); assert.match(src, /wall\.groups\[key\] \?\? groupsRef\.current\[key\]/, "open() does not read the wall's groups"); - assert.match(code(src), /const searchHits = useMemo\(\(\) => \(searchQ\.data\?\.content \?\? \[\]\)\.map\(/, 'the hits are not derived from the answer'); + assert.match(code(src), /const searchHits = useMemo\(\(\) => \(searchQ\.data\?\.content \?\? \[\]\)\.flatMap\(/, 'the hits are not derived from the answer'); + assert.match(code(src), /const pick = selected \? g\.providers\.find\(\(p\) => p\.source === selected\) : g\.providers\[0\];/, "a hit's provider ignores the chosen source"); + assert.match(code(src), /\}\), \[searchQ\.data, selected\]\);/, 'the hits are not re-derived when the chosen source changes'); assert.match(code(src), /useEffect\(\(\) => \{\s*groupsRef\.current = \{\};\s*\(searchQ\.data\?\.content \?\? \[\]\)\.forEach\(\(g\) => \{ groupsRef\.current\[normTitle\(g\.title\)\] = g\.providers; \}\);\s*\}, \[searchQ\.data\]\);/, 'groups are not replaced from the latest answer'); }); From deec264b859f3bb047d38223c68bf1cf1da6c4ec Mon Sep 17 00:00:00 2001 From: Squeaks72 Date: Thu, 24 Sep 2026 06:29:09 -0500 Subject: [PATCH 04/12] feat(reader): paged mode reads right to left when the series does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream's reader already knows a series' readingDirection (the `rtl` state, from the series or the downloaded record) but used it only to flip the order of a double spread; the paged track always ran left to right. New reader pref `pagedDirection` (series | ltr | rtl, default series). With `series`, a RIGHT_TO_LEFT series lays paged mode's track out right-to-left: the next page is to the left (swipe right, tap the left edge, ←), and spreads put their first page on the right. Every other series reads exactly as before -- including every series on the built-in library, which reports WEBTOON. `ltr`/`rtl` override it for all series. Toggle in the reader sheet and Profile → Reading. The track now always states its `dir`. Under the Arabic UI it used to inherit dir="rtl" from , and the positive-scrollLeft maths left the page counter on 1 and clamped a jump or resume to the first page. On a right-to-left track the footer row takes dir="rtl": the page slider fills and drags from the right, matching the page track, and previous/next chapter swap sides with their chevrons swapped to keep pointing outwards. The page counter stays LTR. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/USAGE.md | 9 ++++++ web/app/reader/page.tsx | 46 +++++++++++++++++++++++------- web/components/ProfileSettings.tsx | 5 ++++ web/components/ReaderSettings.tsx | 11 +++++++ web/lib/readerPrefs.ts | 19 ++++++++++++ web/public/locales/ar.json | 8 ++++-- web/public/locales/de.json | 8 ++++-- web/public/locales/es.json | 8 ++++-- web/public/locales/fr.json | 8 ++++-- web/public/locales/ja.json | 8 ++++-- web/public/locales/pt-BR.json | 8 ++++-- web/public/locales/ru.json | 8 ++++-- web/public/locales/zh.json | 8 ++++-- web/test/readerPrefsJunk.test.ts | 15 ++++++++++ 14 files changed, 143 insertions(+), 26 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 7ee1b5816..275113429 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -551,6 +551,15 @@ you keep scrolling through a series without interruption. It remembers your scroll position, so closing and reopening drops you right back where you were. +**Right-to-left paged reading.** In paged mode, *Reading direction* (in the reader's settings sheet and under +Profile → Settings → Reading) can lay the pages out right to left, the way manga is printed: the next page is +to the left, so you swipe right, tap the left edge or press ←. A double spread puts its first page on the +right, and the bottom bar mirrors with it — the page slider fills from the right, and the next-chapter button +moves to the left. The default, *Series default*, does this only for a series whose metadata says it reads +right to left (a Komga library can say so; the built-in library does not yet, so there every series reads left +to right as before). *Left to right* and *Right to left* override it for every series. The webtoon scroll is +unaffected. + **Reader defaults** — mode (webtoon scroll or paged), theme, repeated pages, fit, page gap, auto-scroll and brightness — live under **Profile → Settings → Reading**, where each one saves as you change it and says *Saved* beside the row. The reader's own sheet still changes them for the session you are in, and a series diff --git a/web/app/reader/page.tsx b/web/app/reader/page.tsx index bf7822bbc..72d2c7bf4 100644 --- a/web/app/reader/page.tsx +++ b/web/app/reader/page.tsx @@ -98,6 +98,14 @@ function ReaderInner() { const [prefs, setPrefs] = useState(loadPrefs()); const [zoom, setZoom] = useState(1); const [rtl, setRtl] = useState(false); // series reads right-to-left → double-spread pair order flips + // Paged mode's track direction: the reader setting (lib/readerPrefs.ts `pagedDirection`), or the series' + // own direction above when it says `series`. `trackSign` turns a slide index into a scrollLeft: an RTL + // track scrolls negative. + // ⚠️ The track states its `dir` either way rather than inheriting it. Under the Arabic UI is + // dir="rtl", so the track used to inherit a right-to-left layout that the positive-scrollLeft maths below + // never expected: the page counter sat on 1 and a jump or a resume clamped to the first page. + const pagedRtl = prefs.mode === 'paged' && (prefs.pagedDirection === 'rtl' || (prefs.pagedDirection === 'series' && rtl)); + const trackSign = pagedRtl ? -1 : 1; const [scrubbing, setScrubbing] = useState(false); // slider drag in progress → show the page preview const [chrome, setChrome] = useState(true); @@ -425,7 +433,7 @@ function ReaderInner() { const idx = Math.max(0, Math.min(flat.length - 1, startIndex(flat, 0, startPage))); if (prefs.mode === 'vertical' && scrollRef.current && idx > 0) scrollRef.current.scrollTop = tops[idx]; if (prefs.mode === 'paged' && scrollRef.current && idx > 0) - scrollRef.current.scrollLeft = (slideOf[idx] ?? idx) * scrollRef.current.clientWidth; + scrollRef.current.scrollLeft = trackSign * (slideOf[idx] ?? idx) * scrollRef.current.clientWidth; setCurrent(idx); // Seed the dedupe key so landing here does not immediately ping progress. Opening a saved Moment is // looking something up, not reading it, and it should not move where you were or add a reading event. @@ -435,12 +443,23 @@ function ReaderInner() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, colW, tops]); + // Flipping the direction mid-chapter mirrors the track, and the old scrollLeft now points at another page + // (or clamps to page 1). Put the reader back on the page they were looking at. + useEffect(() => { + const el = scrollRef.current; + if (!el || !didInitScroll.current || prefs.mode !== 'paged') return; + el.scrollLeft = trackSign * (slideOf[current] ?? current) * el.clientWidth; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [trackSign]); + // ---- track current page on scroll ---- const onScroll = useCallback(() => { const el = scrollRef.current; if (!el) return; if (prefs.mode === 'paged') { - const s = Math.round(el.scrollLeft / Math.max(1, el.clientWidth)); + // Math.abs: an RTL track scrolls from 0 into NEGATIVE scrollLeft (the spec'd behaviour every current + // engine follows), so page n sits at -n × width. + const s = Math.round(Math.abs(el.scrollLeft) / Math.max(1, el.clientWidth)); const idxs = slides[Math.max(0, Math.min(slides.length - 1, s))]; const i = idxs ? idxs[idxs.length - 1] : 0; // last page of a spread → completion fires on the final spread setCurrent((c) => (c === i ? c : i)); @@ -450,7 +469,7 @@ function ReaderInner() { let lo = 0, hi = tops.length - 1, ans = 0; while (lo <= hi) { const mid = (lo + hi) >> 1; if (tops[mid] <= probe) { ans = mid; lo = mid + 1; } else hi = mid - 1; } setCurrent((c) => (c === ans ? c : ans)); - }, [tops, prefs.mode, slides]); + }, [tops, prefs.mode, slides]); // the sign-free Math.abs above needs no trackSign dep // ---- continuous reading: append next chapter near the end ---- useEffect(() => { @@ -568,8 +587,8 @@ function ReaderInner() { setCurrent(i); if (!el) return; if (prefs.mode === 'vertical') el.scrollTo({ top: tops[i] || 0 }); - else el.scrollTo({ left: (slideOf[i] ?? i) * (el.clientWidth || 0) }); - }, [flat.length, prefs.mode, tops, slideOf]); + else el.scrollTo({ left: trackSign * (slideOf[i] ?? i) * (el.clientWidth || 0) }); + }, [flat.length, prefs.mode, tops, slideOf, trackSign]); /** * Mark or un-mark one page by hand, from the page grid. @@ -955,9 +974,12 @@ function ReaderInner() { ) : (
{slides.map((idxs) => { - const shown = rtl && idxs.length === 2 ? [idxs[1], idxs[0]] : idxs; // RTL manga: right page reads first + // RTL manga: right page reads first. On an RTL track `dir` already lays a spread out right to + // left, so flipping here as well would put it back the wrong way round. + const shown = rtl && !pagedRtl && idxs.length === 2 ? [idxs[1], idxs[0]] : idxs; return (
{shown.map((i) => { @@ -1043,7 +1065,11 @@ function ReaderInner() { -
+ {/* `dir` mirrors the bar with the track on a right-to-left read: previous chapter on the right, next + on the left, and the slider fills from the right, so dragging it moves the same way the pages + do. The chevrons swap to keep pointing outwards; the counter stays LTR so "12/40" never + reorders. Otherwise the bar inherits the page's direction, as it always has. */} +
{/* scrubber preview: a small render of the target page while dragging */} {scrubbing && flat[current] && (() => { const it = flat[current]; @@ -1064,11 +1090,11 @@ function ReaderInner() { })()} {/* The counter is the button. A long-press would be invisible on a phone, which this repo already learned once from a hover-only affordance nobody found. */} - @@ -1080,7 +1106,7 @@ function ReaderInner() { className="h-1 flex-1 accent-[rgb(var(--accent))]" />
diff --git a/web/components/ProfileSettings.tsx b/web/components/ProfileSettings.tsx index 06f174c7b..40a55e402 100644 --- a/web/components/ProfileSettings.tsx +++ b/web/components/ProfileSettings.tsx @@ -248,6 +248,11 @@ function ReadingSection({ weeklyGoal }: { weeklyGoal: number }) { options={[{ value: 'single', label: tr('Single') }, { value: 'double', label: tr('Double spread') }]} onChange={(v) => set({ spread: v === 'double' })} /> )} + {prefs.mode === 'paged' && ( + set({ pagedDirection: v })} /> + )} {/* Set in both modes. ⚠️ It cannot LOOK the same in both: a page-by-page view has no thin slide -- every slide is exactly one viewport wide -- so Collapse falls back to removing there, where an unwanted page costs one swipe rather than a scroll and there is no flow to interrupt. */} diff --git a/web/components/ReaderSettings.tsx b/web/components/ReaderSettings.tsx index f39e9c7e2..344e043b9 100644 --- a/web/components/ReaderSettings.tsx +++ b/web/components/ReaderSettings.tsx @@ -82,6 +82,17 @@ export function ReaderSettings({ )} + {prefs.mode === 'paged' && ( + +
+ {([['series', tr('Series default')], ['ltr', tr('Left to right')], ['rtl', tr('Right to left')]] as const).map(([v, label]) => ( + + ))} +
+
+ )} + {/* Set in both modes. ⚠️ It cannot LOOK the same in both: a page-by-page view has no thin slide -- every slide is exactly one viewport wide -- so Collapse falls back to removing there, where an unwanted page costs one swipe rather than a scroll and there is no flow to interrupt. */} diff --git a/web/lib/readerPrefs.ts b/web/lib/readerPrefs.ts index 456cbb2d3..f368849b6 100644 --- a/web/lib/readerPrefs.ts +++ b/web/lib/readerPrefs.ts @@ -11,6 +11,16 @@ export type ReaderTheme = 'amoled' | 'sepia' | 'gray'; export type JunkPages = 'show' | 'collapse' | 'hide'; const JUNK_MODES: readonly JunkPages[] = ['show', 'collapse', 'hide']; +/** + * Which way paged mode lays its pages out. `rtl` is the manga way: the next page is to the LEFT (swipe right, + * tap the left edge, ←), and a double spread puts its first page on the right. `series` follows the series' + * own `readingDirection` -- RIGHT_TO_LEFT reads right to left, anything else left to right. Only a Komga + * backend reports a real direction today; the built-in library answers WEBTOON for every series + * (lib/ownedCatalog seriesDto), so there `series` reads left to right. + */ +export type PagedDirection = 'ltr' | 'rtl' | 'series'; +const PAGED_DIRECTIONS: readonly PagedDirection[] = ['ltr', 'rtl', 'series']; + export interface ReaderPrefs { gap: number; // px between pages (0 = seamless webtoon) brightness: number; // 0.25 .. 1 @@ -19,6 +29,13 @@ export interface ReaderPrefs { fitWidth: boolean; theme: ReaderTheme; spread: boolean; // paged mode: two pages side by side (manga double-page convention) + /** + * Paged mode's page order (see `PagedDirection`). `series` by default: a series that says it reads right to + * left finally gets a right-to-left track, and every other series -- all of them on the built-in library -- + * reads left to right exactly as before. `ltr`/`rtl` are overrides. Stored settings without the key pick + * up the default through the spread in `migratePrefs`. + */ + pagedDirection: PagedDirection; junkPages: JunkPages; // what to do with pages that repeat across chapters /** * @deprecated Superseded by `junkPages`, and kept ONLY so that an older build does not fight this one. @@ -37,6 +54,7 @@ export const DEFAULT_PREFS: ReaderPrefs = { fitWidth: true, theme: 'amoled', spread: false, + pagedDirection: 'series', }; const KEY = 'yomi_reader_prefs'; @@ -77,6 +95,7 @@ export function migratePrefs(raw: unknown): ReaderPrefs { const chosen = JUNK_MODES.includes(r.junkPages as JunkPages); const p = { ...DEFAULT_PREFS, ...r }; if (!chosen) p.junkPages = r.skipJunk === false ? 'show' : DEFAULT_PREFS.junkPages; + if (!PAGED_DIRECTIONS.includes(p.pagedDirection)) p.pagedDirection = DEFAULT_PREFS.pagedDirection; p.skipJunk = p.junkPages !== 'show'; return p; } diff --git a/web/public/locales/ar.json b/web/public/locales/ar.json index f05546ae6..96a1a4e74 100644 --- a/web/public/locales/ar.json +++ b/web/public/locales/ar.json @@ -2,7 +2,7 @@ "_meta": { "language": "ar", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "فحص واحد وجد شيئًا", "1 member": "عضو واحد", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "قال المحرّك: {reason}", "The extension engine refused that address: {reason}": "رفض محرّك الإضافات هذا العنوان: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "يشغّل تثبيت Docker القياسي محرّكًا في الحاوية {name}. إذا عطّلته بإفراغ SUWAYOMI_URL، فأعِد ذلك السطر إلى ملف .env ثم شغّل {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "نصيحة: أخفِ أولًا اللغات التي لا تقرؤها — لا يمكن تفعيل أكثر من {n} مصدرًا في وقت واحد." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "نصيحة: أخفِ أولًا اللغات التي لا تقرؤها — لا يمكن تفعيل أكثر من {n} مصدرًا في وقت واحد.", + "Reading direction": "اتجاه القراءة", + "Right to left": "من اليمين إلى اليسار", + "Left to right": "من اليسار إلى اليمين", + "Series default": "حسب السلسلة" } diff --git a/web/public/locales/de.json b/web/public/locales/de.json index 465e0bc63..0a424dba7 100644 --- a/web/public/locales/de.json +++ b/web/public/locales/de.json @@ -2,7 +2,7 @@ "_meta": { "language": "de", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 Prüfung hat etwas gefunden", "1 member": "1 Mitglied", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "Die Engine meldete: {reason}", "The extension engine refused that address: {reason}": "Die Erweiterungs-Engine hat diese Adresse abgelehnt: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "Die Standard-Docker-Installation betreibt eine im Container {name}. Wenn du sie ausgeschaltet hast, indem du SUWAYOMI_URL geleert hast, stelle diese Zeile in .env wieder her und führe {command} aus.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Tipp: Blende zuerst die Sprachen aus, die du nicht liest — es können nur {n} Quellen gleichzeitig aktiv sein." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Tipp: Blende zuerst die Sprachen aus, die du nicht liest — es können nur {n} Quellen gleichzeitig aktiv sein.", + "Reading direction": "Leserichtung", + "Right to left": "Rechts nach links", + "Left to right": "Links nach rechts", + "Series default": "Wie die Serie" } diff --git a/web/public/locales/es.json b/web/public/locales/es.json index 7e5c0e5aa..2666bd78d 100644 --- a/web/public/locales/es.json +++ b/web/public/locales/es.json @@ -2,7 +2,7 @@ "_meta": { "language": "es", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 comprobación encontró algo", "1 member": "1 miembro", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "El motor dijo: {reason}", "The extension engine refused that address: {reason}": "El motor de extensiones rechazó esa dirección: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "La instalación estándar de Docker ejecuta uno en el contenedor {name}. Si lo desactivaste dejando vacío SUWAYOMI_URL, vuelve a poner esa línea en .env y ejecuta {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Consejo: oculta primero los idiomas que no lees — solo se pueden activar {n} fuentes a la vez." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Consejo: oculta primero los idiomas que no lees — solo se pueden activar {n} fuentes a la vez.", + "Reading direction": "Dirección de lectura", + "Right to left": "De derecha a izquierda", + "Left to right": "De izquierda a derecha", + "Series default": "Según la serie" } diff --git a/web/public/locales/fr.json b/web/public/locales/fr.json index 1ff5b5177..29c756c1f 100644 --- a/web/public/locales/fr.json +++ b/web/public/locales/fr.json @@ -2,7 +2,7 @@ "_meta": { "language": "fr", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 vérification a trouvé quelque chose", "1 member": "1 membre", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "Le moteur a répondu : {reason}", "The extension engine refused that address: {reason}": "Le moteur d’extensions a refusé cette adresse : {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "L’installation Docker standard en fait tourner un dans le conteneur {name}. Si vous l’avez désactivé en vidant SUWAYOMI_URL, remettez cette ligne dans .env et lancez {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Astuce : masquez d’abord les langues que vous ne lisez pas — seules {n} sources peuvent être activées à la fois." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Astuce : masquez d’abord les langues que vous ne lisez pas — seules {n} sources peuvent être activées à la fois.", + "Reading direction": "Sens de lecture", + "Right to left": "De droite à gauche", + "Left to right": "De gauche à droite", + "Series default": "Selon la série" } diff --git a/web/public/locales/ja.json b/web/public/locales/ja.json index a39bbd916..a72794ded 100644 --- a/web/public/locales/ja.json +++ b/web/public/locales/ja.json @@ -2,7 +2,7 @@ "_meta": { "language": "ja", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 件のチェックで問題を検出", "1 member": "メンバー 1 人", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "エンジンの応答: {reason}", "The extension engine refused that address: {reason}": "拡張機能エンジンがこのアドレスを拒否しました: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "標準の Docker インストールでは {name} コンテナーでエンジンが動作します。SUWAYOMI_URL を空にして無効にした場合は、.env にその行を戻して {command} を実行してください。", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "ヒント: まず読まない言語を隠しておきましょう — 同時に有効にできるソースは {n} 件までです。" + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "ヒント: まず読まない言語を隠しておきましょう — 同時に有効にできるソースは {n} 件までです。", + "Reading direction": "読む方向", + "Right to left": "右から左", + "Left to right": "左から右", + "Series default": "シリーズに従う" } diff --git a/web/public/locales/pt-BR.json b/web/public/locales/pt-BR.json index fb5206222..509d72ff6 100644 --- a/web/public/locales/pt-BR.json +++ b/web/public/locales/pt-BR.json @@ -2,7 +2,7 @@ "_meta": { "language": "pt-BR", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 verificação encontrou algo", "1 member": "1 membro", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "O motor respondeu: {reason}", "The extension engine refused that address: {reason}": "O motor de extensões recusou esse endereço: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "A instalação padrão do Docker roda um no contêiner {name}. Se você o desativou deixando SUWAYOMI_URL vazio, coloque essa linha de volta no .env e execute {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Dica: oculte primeiro os idiomas que você não lê — só {n} fontes podem ficar ativadas ao mesmo tempo." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Dica: oculte primeiro os idiomas que você não lê — só {n} fontes podem ficar ativadas ao mesmo tempo.", + "Reading direction": "Direção de leitura", + "Right to left": "Da direita para a esquerda", + "Left to right": "Da esquerda para a direita", + "Series default": "Conforme a série" } diff --git a/web/public/locales/ru.json b/web/public/locales/ru.json index 07c131555..1a1e15efe 100644 --- a/web/public/locales/ru.json +++ b/web/public/locales/ru.json @@ -2,7 +2,7 @@ "_meta": { "language": "ru", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 проверка нашла проблему", "1 member": "1 участник", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "Ответ движка: {reason}", "The extension engine refused that address: {reason}": "Движок расширений отклонил этот адрес: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "Стандартная установка Docker запускает его в контейнере {name}. Если вы отключили его, очистив SUWAYOMI_URL, верните эту строку в .env и выполните {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Совет: сначала скройте языки, которые вы не читаете, — одновременно можно включить не больше {n} источников." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Совет: сначала скройте языки, которые вы не читаете, — одновременно можно включить не больше {n} источников.", + "Reading direction": "Направление чтения", + "Right to left": "Справа налево", + "Left to right": "Слева направо", + "Series default": "Как у серии" } diff --git a/web/public/locales/zh.json b/web/public/locales/zh.json index e65db2136..3c244d5f9 100644 --- a/web/public/locales/zh.json +++ b/web/public/locales/zh.json @@ -2,7 +2,7 @@ "_meta": { "language": "zh", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 项检查发现问题", "1 member": "1 位成员", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "引擎返回:{reason}", "The extension engine refused that address: {reason}": "扩展引擎拒绝了该地址:{reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "标准 Docker 安装会在 {name} 容器中运行一个扩展引擎。如果你是通过清空 SUWAYOMI_URL 关闭的,请把那一行加回 .env,然后运行 {command}。", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "提示:先隐藏你不读的语言 — 同时最多只能启用 {n} 个来源。" + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "提示:先隐藏你不读的语言 — 同时最多只能启用 {n} 个来源。", + "Reading direction": "阅读方向", + "Right to left": "从右到左", + "Left to right": "从左到右", + "Series default": "跟随系列" } diff --git a/web/test/readerPrefsJunk.test.ts b/web/test/readerPrefsJunk.test.ts index 817003b67..6445ac315 100644 --- a/web/test/readerPrefsJunk.test.ts +++ b/web/test/readerPrefsJunk.test.ts @@ -54,3 +54,18 @@ test('everything else on the object is left alone', () => { assert.equal(out.brightness, 0.5); assert.equal(out.mode, DEFAULT_PREFS.mode, 'and missing keys still come from the defaults'); }); + +// `pagedDirection` (paged mode laid out as the series says, or forced either way) arrived after everyone +// already had stored settings. It follows the series by default, which leaves every left-to-right series -- +// every series on the built-in library, which answers WEBTOON -- exactly as it was; only a series that says +// RIGHT_TO_LEFT changes, and a deliberate 'ltr'/'rtl' always wins. +// Reintroduce by defaulting it to 'rtl': every stored reader without the key turns its pages the other way. +test('paged mode follows the series unless overridden', () => { + assert.equal(DEFAULT_PREFS.pagedDirection, 'series'); + assert.equal(migratePrefs({ mode: 'paged', spread: true }).pagedDirection, 'series'); + assert.equal(migratePrefs({}).pagedDirection, 'series'); + assert.equal(migratePrefs({ pagedDirection: 'rtl' }).pagedDirection, 'rtl'); + assert.equal(migratePrefs({ pagedDirection: 'ltr' }).pagedDirection, 'ltr'); + // Anything else out of storage (a typo, a future value an older build does not know) is not trusted. + assert.equal(migratePrefs({ pagedDirection: 'sideways' as never }).pagedDirection, 'series'); +}); From dfa8c849b8e7c5774223f0fd2e549793cd6ae679 Mon Sep 17 00:00:00 2001 From: Squeaks72 Date: Tue, 22 Sep 2026 11:39:23 -0500 Subject: [PATCH 05/12] Reader settings that can be pinned to a source A source is the closest thing the app has to a format: a webtoon source wants continuous vertical scroll, a manga source wants paged right-to-left. One global default was wrong for half the library and the alternative was fixing every series by hand. Reader prefs now resolve global < source < series, and the settings sheet can pin the current look to the chapter's source (or forget it again). Stored and synced beside the per-series memory, under a key prefix that cannot be picked up by the per-series collector. Tests pin the prefix, the forget path and the pull; the three new strings are translated in all eight locales, and USAGE.md describes the button. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/USAGE.md | 9 +++ web/app/reader/page.tsx | 30 +++++++-- web/components/ReaderSettings.tsx | 32 +++++++++ web/lib/readerPrefs.ts | 76 +++++++++++++++++++++- web/public/locales/ar.json | 7 +- web/public/locales/de.json | 7 +- web/public/locales/es.json | 7 +- web/public/locales/fr.json | 7 +- web/public/locales/ja.json | 7 +- web/public/locales/pt-BR.json | 7 +- web/public/locales/ru.json | 7 +- web/public/locales/zh.json | 7 +- web/test/readerSourcePrefs.test.ts | 101 +++++++++++++++++++++++++++++ 13 files changed, 281 insertions(+), 23 deletions(-) create mode 100644 web/test/readerSourcePrefs.test.ts diff --git a/docs/USAGE.md b/docs/USAGE.md index 7ee1b5816..61349ce08 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -557,6 +557,15 @@ brightness — live under **Profile → Settings → Reading**, where each one s you have adjusted keeps its own memory, which wins over the defaults. The weekly goal, offline downloads and new-chapter alerts are on the same tab. +**A default per source.** A source is usually one format: a webtoon site wants the continuous vertical scroll, +a manga site wants paged right-to-left. At the bottom of the reader's settings sheet, **Use this reader for +everything from *Source*** saves the current mode, theme and two-page spread for every title from the source +the chapter came from, so one choice fixes that whole part of the library. The order is *your defaults < +the source's default < this series*: a title you have adjusted by hand still keeps its own settings. **Forget +the default for *Source*** removes it again. Like the per-series memory it is saved to your account, so it +follows you to your other devices. A downloaded chapter opened offline carries no source, so the button does +not appear there. + ### Skipping the pages that are not the story Most scanlated chapters open with the same credit page, and some carry an advert or a "read the rest at…" diff --git a/web/app/reader/page.tsx b/web/app/reader/page.tsx index bf7822bbc..a4a73e1f3 100644 --- a/web/app/reader/page.tsx +++ b/web/app/reader/page.tsx @@ -15,7 +15,7 @@ import { chapterLabel } from '@/lib/format'; import { deviceId } from '@/lib/device'; import { getOfflineChapter, getPageBlob, queueProgress, noteOfflineProgress, listSeriesDownloads, setOfflinePageJunk } from '@/lib/downloads'; import { applyCover, clearCover } from '@/lib/theme'; -import { ReaderPrefs, loadPrefs, savePrefs, loadSeriesPrefs, saveSeriesPrefs, syncPrefsFromServer, THEME_FILTER } from '@/lib/readerPrefs'; +import { ReaderPrefs, loadPrefs, savePrefs, loadSeriesPrefs, saveSeriesPrefs, syncPrefsFromServer, THEME_FILTER, loadSourcePrefs, saveSourcePrefs, clearSourcePrefs } from '@/lib/readerPrefs'; import { ReaderSettings } from '@/components/ReaderSettings'; import { Rail, SectionTitle, useImgRetry } from '@/components/ui'; import { PageGrid } from '@/components/PageGrid'; @@ -658,15 +658,20 @@ function ReaderInner() { syncPrefsFromServer().then((p) => setPrefs((cur) => ({ ...cur, ...p }))).catch(() => {}); }, []); - // ---- per-series memory (mode/theme/zoom) ---- + // ---- per-source, then per-series, memory (mode/theme/zoom) ---- + // + // Applied in that order so the precedence is global default < source default < this series: a source + // default fixes everything from it in one go, and a title someone has adjusted by hand still wins. useEffect(() => { if (!seriesId) return; - const sp = loadSeriesPrefs(seriesId); + const src = chapters[0]?.sourceId || ''; + const base = src ? loadSourcePrefs(src) : {}; + const sp = { ...base, ...loadSeriesPrefs(seriesId) }; if (sp.mode || sp.theme || sp.spread !== undefined) setPrefs((cur) => ({ ...cur, ...(sp.mode ? { mode: sp.mode } : {}), ...(sp.theme ? { theme: sp.theme } : {}), ...(sp.spread !== undefined ? { spread: sp.spread } : {}) })); setZoom(sp.zoom && sp.zoom >= 1 ? sp.zoom : 1); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [seriesId]); + }, [seriesId, chapters[0]?.sourceId]); // ---- auto-hide chrome ---- useEffect(() => { @@ -1118,7 +1123,22 @@ function ReaderInner() { - {showSettings && setShowSettings(false)} />} + {showSettings && ( + setShowSettings(false)} + sourceName={sourceNameOf(chapters[0]?.sourceId) || (chapters[0]?.sourceId ?? undefined)} + sourceDefault={!!(chapters[0]?.sourceId && Object.keys(loadSourcePrefs(chapters[0].sourceId!)).length)} + onSourceDefault={(save) => { + const src = chapters[0]?.sourceId; + if (!src) return; + if (save) saveSourcePrefs(src, { mode: prefs.mode, theme: prefs.theme, spread: prefs.spread }); + else clearSourcePrefs(src); + setShowSettings(false); + }} + /> + )} {showPages && ( diff --git a/web/components/ReaderSettings.tsx b/web/components/ReaderSettings.tsx index f39e9c7e2..a1b9e059d 100644 --- a/web/components/ReaderSettings.tsx +++ b/web/components/ReaderSettings.tsx @@ -19,10 +19,19 @@ export function ReaderSettings({ prefs, set, onClose, + sourceName, + sourceDefault, + onSourceDefault, }: { prefs: ReaderPrefs; set: (p: Partial) => void; onClose: () => void; + /** The source this chapter came from, named for the button. Absent for a copy with no source on record. */ + sourceName?: string; + /** Whether that source already has a default saved, which decides what the button offers. */ + sourceDefault?: boolean; + /** Save the current mode/theme/spread as that source's default, or clear it when `false` is passed. */ + onSourceDefault?: (save: boolean) => void; }) { return ( @@ -122,6 +131,29 @@ export function ReaderSettings({ className={`rounded-2xl border py-3 text-sm ${!prefs.fitWidth ? 'border-accent bg-accent-soft text-accent' : 'border-ink-700 text-fog-300'}`}>{tr('Original')}
+ + {/* + One source is a good proxy for one FORMAT: a webtoon source wants continuous vertical scroll, a + manga source wants paged right-to-left. Pinning the current look to the source fixes every title + from it at once, instead of the global default being wrong for half the library or each series + having to be corrected by hand. A series you have already adjusted still wins over this. + */} + {sourceName && onSourceDefault && ( + +
+ + {sourceDefault && ( + + )} +
+
+ )} ); diff --git a/web/lib/readerPrefs.ts b/web/lib/readerPrefs.ts index 456cbb2d3..8f0da2ea6 100644 --- a/web/lib/readerPrefs.ts +++ b/web/lib/readerPrefs.ts @@ -106,7 +106,7 @@ function queueSync() { syncTimer = null; pushing++; void import('./api') - .then(({ api }) => api('/api/settings', { method: 'PUT', json: { reader: loadPrefs(), readerSeries: allSeriesPrefs() } })) + .then(({ api }) => api('/api/settings', { method: 'PUT', json: { reader: loadPrefs(), readerSeries: allSeriesPrefs(), readerSource: allSourcePrefs() } })) .catch(() => {}) .finally(() => { pushing--; }); }, SYNC_DELAY); @@ -130,7 +130,7 @@ export async function syncPrefsFromServer(): Promise { if (syncTimer || pushing) return loadPrefs(); try { const { api } = await import('./api'); - const s = await api<{ reader?: Partial; readerSeries?: Record }>('/api/settings'); + const s = await api<{ reader?: Partial; readerSeries?: Record; readerSource?: Record }>('/api/settings'); if (s?.reader && typeof s.reader === 'object') { // Through the same reconciliation as a local read: a settings row written by an older build carries // `skipJunk` and no `junkPages`, and must not land here as a raw object. @@ -143,6 +143,13 @@ export async function syncPrefsFromServer(): Promise { } } } + if (s?.readerSource && typeof s.readerSource === 'object') { + for (const [id, sp] of Object.entries(s.readerSource)) { + if (id && sp && typeof sp === 'object') { + localStorage.setItem(`${SOURCE_KEY}${id}`, JSON.stringify({ ...loadSourcePrefs(id), ...sp })); + } + } + } } catch { /* offline or signed out — keep whatever is local */ } return loadPrefs(); } @@ -165,6 +172,8 @@ function allSeriesPrefs(): Record { return out; } +const SOURCE_KEY = 'yomi_rp_'; + // ---- per-series memory (mode/theme/zoom remembered per title) ---- export interface SeriesPrefs { mode?: ReaderPrefs['mode']; @@ -191,6 +200,69 @@ export function saveSeriesPrefs(seriesId: string, partial: SeriesPrefs) { queueSync(); } +// ---- per-source memory (the source is the best proxy the app has for the FORMAT) ---- +/** + * Reader settings remembered per SOURCE. + * + * Which source a chapter came from is the most reliable signal available for what it actually is: a webtoon + * site serves long strips, a manga site serves paged volumes. Those want opposite readers -- one continuous + * vertical scroll, the other right-to-left paged spreads -- and until now the only options were a single global + * default that was wrong for half the library, or correcting it on every title forever. + * + * Same shape as the per-series memory, and resolved between it and the global default: + * + * global default < source default < this series + * + * so setting a source fixes everything from it at once, and a title you have adjusted by hand still wins. + * + * Stored under `yomi_rp_` rather than `yomi_rs_` deliberately: `allSeriesPrefs()` collects by that prefix, + * and a key that began with it would be pushed up as a series override under a source's id. + */ +export type SourcePrefs = SeriesPrefs; + +export function loadSourcePrefs(sourceId: string): SourcePrefs { + if (typeof window === 'undefined' || !sourceId) return {}; + try { + return JSON.parse(localStorage.getItem(`${SOURCE_KEY}${sourceId}`) || '{}'); + } catch { + return {}; + } +} + +export function saveSourcePrefs(sourceId: string, partial: SourcePrefs) { + if (!sourceId) return; + try { + const cur = loadSourcePrefs(sourceId); + localStorage.setItem(`${SOURCE_KEY}${sourceId}`, JSON.stringify({ ...cur, ...partial })); + } catch {} + queueSync(); +} + +/** Forget a source's default, so its titles fall back to the global one. */ +export function clearSourcePrefs(sourceId: string) { + if (!sourceId) return; + try { localStorage.removeItem(`${SOURCE_KEY}${sourceId}`); } catch {} + queueSync(); +} + +/** Every per-source default held locally, capped like the per-series map for the same reason. */ +function allSourcePrefs(): Record { + const out: Record = {}; + try { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k?.startsWith(SOURCE_KEY)) keys.push(k); + } + for (const k of keys.slice(-SERIES_CAP)) { + const id = k.slice(SOURCE_KEY.length); + const v = loadSourcePrefs(id); + if (Object.keys(v).length) out[id] = v; + } + } catch {} + return out; +} + export const THEME_FILTER: Record = { amoled: 'none', sepia: 'sepia(0.55) saturate(1.15) brightness(0.94)', diff --git a/web/public/locales/ar.json b/web/public/locales/ar.json index f05546ae6..35cd45884 100644 --- a/web/public/locales/ar.json +++ b/web/public/locales/ar.json @@ -2,7 +2,7 @@ "_meta": { "language": "ar", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1152 }, "1 check found something": "فحص واحد وجد شيئًا", "1 member": "عضو واحد", @@ -1152,5 +1152,8 @@ "The engine said: {reason}": "قال المحرّك: {reason}", "The extension engine refused that address: {reason}": "رفض محرّك الإضافات هذا العنوان: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "يشغّل تثبيت Docker القياسي محرّكًا في الحاوية {name}. إذا عطّلته بإفراغ SUWAYOMI_URL، فأعِد ذلك السطر إلى ملف .env ثم شغّل {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "نصيحة: أخفِ أولًا اللغات التي لا تقرؤها — لا يمكن تفعيل أكثر من {n} مصدرًا في وقت واحد." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "نصيحة: أخفِ أولًا اللغات التي لا تقرؤها — لا يمكن تفعيل أكثر من {n} مصدرًا في وقت واحد.", + "This source": "هذا المصدر", + "Use this reader for everything from {source}": "استخدم هذا القارئ لكل ما يأتي من {source}", + "Forget the default for {source}": "انسَ الإعداد الافتراضي لـ {source}" } diff --git a/web/public/locales/de.json b/web/public/locales/de.json index 465e0bc63..7226823dc 100644 --- a/web/public/locales/de.json +++ b/web/public/locales/de.json @@ -2,7 +2,7 @@ "_meta": { "language": "de", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1152 }, "1 check found something": "1 Prüfung hat etwas gefunden", "1 member": "1 Mitglied", @@ -1152,5 +1152,8 @@ "The engine said: {reason}": "Die Engine meldete: {reason}", "The extension engine refused that address: {reason}": "Die Erweiterungs-Engine hat diese Adresse abgelehnt: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "Die Standard-Docker-Installation betreibt eine im Container {name}. Wenn du sie ausgeschaltet hast, indem du SUWAYOMI_URL geleert hast, stelle diese Zeile in .env wieder her und führe {command} aus.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Tipp: Blende zuerst die Sprachen aus, die du nicht liest — es können nur {n} Quellen gleichzeitig aktiv sein." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Tipp: Blende zuerst die Sprachen aus, die du nicht liest — es können nur {n} Quellen gleichzeitig aktiv sein.", + "This source": "Diese Quelle", + "Use this reader for everything from {source}": "Diesen Leser für alles von {source} verwenden", + "Forget the default for {source}": "Standard für {source} vergessen" } diff --git a/web/public/locales/es.json b/web/public/locales/es.json index 7e5c0e5aa..6fb430f74 100644 --- a/web/public/locales/es.json +++ b/web/public/locales/es.json @@ -2,7 +2,7 @@ "_meta": { "language": "es", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1152 }, "1 check found something": "1 comprobación encontró algo", "1 member": "1 miembro", @@ -1152,5 +1152,8 @@ "The engine said: {reason}": "El motor dijo: {reason}", "The extension engine refused that address: {reason}": "El motor de extensiones rechazó esa dirección: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "La instalación estándar de Docker ejecuta uno en el contenedor {name}. Si lo desactivaste dejando vacío SUWAYOMI_URL, vuelve a poner esa línea en .env y ejecuta {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Consejo: oculta primero los idiomas que no lees — solo se pueden activar {n} fuentes a la vez." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Consejo: oculta primero los idiomas que no lees — solo se pueden activar {n} fuentes a la vez.", + "This source": "Esta fuente", + "Use this reader for everything from {source}": "Usar este lector para todo lo de {source}", + "Forget the default for {source}": "Olvidar el valor predeterminado de {source}" } diff --git a/web/public/locales/fr.json b/web/public/locales/fr.json index 1ff5b5177..c20474643 100644 --- a/web/public/locales/fr.json +++ b/web/public/locales/fr.json @@ -2,7 +2,7 @@ "_meta": { "language": "fr", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1152 }, "1 check found something": "1 vérification a trouvé quelque chose", "1 member": "1 membre", @@ -1152,5 +1152,8 @@ "The engine said: {reason}": "Le moteur a répondu : {reason}", "The extension engine refused that address: {reason}": "Le moteur d’extensions a refusé cette adresse : {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "L’installation Docker standard en fait tourner un dans le conteneur {name}. Si vous l’avez désactivé en vidant SUWAYOMI_URL, remettez cette ligne dans .env et lancez {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Astuce : masquez d’abord les langues que vous ne lisez pas — seules {n} sources peuvent être activées à la fois." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Astuce : masquez d’abord les langues que vous ne lisez pas — seules {n} sources peuvent être activées à la fois.", + "This source": "Cette source", + "Use this reader for everything from {source}": "Utiliser ce lecteur pour tout ce qui vient de {source}", + "Forget the default for {source}": "Oublier le réglage par défaut de {source}" } diff --git a/web/public/locales/ja.json b/web/public/locales/ja.json index a39bbd916..634b3d485 100644 --- a/web/public/locales/ja.json +++ b/web/public/locales/ja.json @@ -2,7 +2,7 @@ "_meta": { "language": "ja", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1152 }, "1 check found something": "1 件のチェックで問題を検出", "1 member": "メンバー 1 人", @@ -1152,5 +1152,8 @@ "The engine said: {reason}": "エンジンの応答: {reason}", "The extension engine refused that address: {reason}": "拡張機能エンジンがこのアドレスを拒否しました: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "標準の Docker インストールでは {name} コンテナーでエンジンが動作します。SUWAYOMI_URL を空にして無効にした場合は、.env にその行を戻して {command} を実行してください。", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "ヒント: まず読まない言語を隠しておきましょう — 同時に有効にできるソースは {n} 件までです。" + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "ヒント: まず読まない言語を隠しておきましょう — 同時に有効にできるソースは {n} 件までです。", + "This source": "このソース", + "Use this reader for everything from {source}": "{source} のすべてにこのリーダー設定を使う", + "Forget the default for {source}": "{source} の既定を解除" } diff --git a/web/public/locales/pt-BR.json b/web/public/locales/pt-BR.json index fb5206222..dd8350959 100644 --- a/web/public/locales/pt-BR.json +++ b/web/public/locales/pt-BR.json @@ -2,7 +2,7 @@ "_meta": { "language": "pt-BR", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1152 }, "1 check found something": "1 verificação encontrou algo", "1 member": "1 membro", @@ -1152,5 +1152,8 @@ "The engine said: {reason}": "O motor respondeu: {reason}", "The extension engine refused that address: {reason}": "O motor de extensões recusou esse endereço: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "A instalação padrão do Docker roda um no contêiner {name}. Se você o desativou deixando SUWAYOMI_URL vazio, coloque essa linha de volta no .env e execute {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Dica: oculte primeiro os idiomas que você não lê — só {n} fontes podem ficar ativadas ao mesmo tempo." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Dica: oculte primeiro os idiomas que você não lê — só {n} fontes podem ficar ativadas ao mesmo tempo.", + "This source": "Esta fonte", + "Use this reader for everything from {source}": "Usar este leitor para tudo de {source}", + "Forget the default for {source}": "Esquecer o padrão de {source}" } diff --git a/web/public/locales/ru.json b/web/public/locales/ru.json index 07c131555..10eefd222 100644 --- a/web/public/locales/ru.json +++ b/web/public/locales/ru.json @@ -2,7 +2,7 @@ "_meta": { "language": "ru", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1152 }, "1 check found something": "1 проверка нашла проблему", "1 member": "1 участник", @@ -1152,5 +1152,8 @@ "The engine said: {reason}": "Ответ движка: {reason}", "The extension engine refused that address: {reason}": "Движок расширений отклонил этот адрес: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "Стандартная установка Docker запускает его в контейнере {name}. Если вы отключили его, очистив SUWAYOMI_URL, верните эту строку в .env и выполните {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Совет: сначала скройте языки, которые вы не читаете, — одновременно можно включить не больше {n} источников." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Совет: сначала скройте языки, которые вы не читаете, — одновременно можно включить не больше {n} источников.", + "This source": "Этот источник", + "Use this reader for everything from {source}": "Использовать эту читалку для всего из {source}", + "Forget the default for {source}": "Сбросить настройки по умолчанию для {source}" } diff --git a/web/public/locales/zh.json b/web/public/locales/zh.json index e65db2136..5108712b9 100644 --- a/web/public/locales/zh.json +++ b/web/public/locales/zh.json @@ -2,7 +2,7 @@ "_meta": { "language": "zh", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1152 }, "1 check found something": "1 项检查发现问题", "1 member": "1 位成员", @@ -1152,5 +1152,8 @@ "The engine said: {reason}": "引擎返回:{reason}", "The extension engine refused that address: {reason}": "扩展引擎拒绝了该地址:{reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "标准 Docker 安装会在 {name} 容器中运行一个扩展引擎。如果你是通过清空 SUWAYOMI_URL 关闭的,请把那一行加回 .env,然后运行 {command}。", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "提示:先隐藏你不读的语言 — 同时最多只能启用 {n} 个来源。" + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "提示:先隐藏你不读的语言 — 同时最多只能启用 {n} 个来源。", + "This source": "此来源", + "Use this reader for everything from {source}": "来自 {source} 的所有内容都使用此阅读器设置", + "Forget the default for {source}": "清除 {source} 的默认设置" } diff --git a/web/test/readerSourcePrefs.test.ts b/web/test/readerSourcePrefs.test.ts new file mode 100644 index 000000000..528adf605 --- /dev/null +++ b/web/test/readerSourcePrefs.test.ts @@ -0,0 +1,101 @@ +// Reader settings pinned to a SOURCE: global default < source default < this series. +// +// The store is the per-series memory's sibling and rides the same settings blob, so the ways it can go wrong +// are the ways two maps sharing one localStorage can: a source default collected as if it were a series +// override (the per-series collector scans by key prefix), a forgotten default that is still pushed up, and a +// default saved on another device that never arrives here. These tests drive the real module against a +// stubbed server and read the PUT bodies it sends. +import test, { before, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.NEXT_PUBLIC_API_BASE = ''; + +// Both stubs go in BEFORE the dynamic imports below: the module returns defaults without touching storage when +// there is no `window`, and `api.ts` seeds itself from localStorage at load. +const mem = new Map(); +(globalThis as any).localStorage = { + get length() { return mem.size; }, + key: (i: number) => [...mem.keys()][i] ?? null, + getItem: (k: string) => (mem.has(k) ? mem.get(k)! : null), + setItem: (k: string, v: string) => void mem.set(k, String(v)), + removeItem: (k: string) => void mem.delete(k), + clear: () => mem.clear(), +}; +(globalThis as any).window = globalThis; + +/** What the stubbed server answers a GET with, and every PUT body it was sent. */ +let serverBlob: Record = {}; +const putBodies: any[] = []; +globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + if (method === 'PUT') { + putBodies.push(JSON.parse(String(init?.body ?? '{}'))); + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + } + return new Response(JSON.stringify(serverBlob), { status: 200, headers: { 'content-type': 'application/json' } }); +}) as any; + +let prefs: typeof import('../lib/readerPrefs'); +before(async () => { + prefs = await import('../lib/readerPrefs'); + // The push loads `api.ts` lazily; load it here so a flush below is not racing the first import. + await import('../lib/api'); +}); + +const settle = () => new Promise((resolve) => setImmediate(resolve)); +/** Fire the 1.5 s debounce and wait for its PUT to reach the stub. */ +async function flush() { + const before = putBodies.length; + mock.timers.tick(1500); + for (let i = 0; i < 200 && putBodies.length === before; i++) await settle(); + await settle(); +} + +test('a source default is pushed as readerSource, never as a series override', async (t) => { + // `allSeriesPrefs()` collects every key under the per-series prefix and pushes it as `readerSeries[id]`. A + // source default stored under that prefix would arrive on every other device as a SERIES override keyed by + // a source id -- inert at best, and the source default itself would never sync. + // Reintroduce by setting SOURCE_KEY to 'yomi_rs_' in lib/readerPrefs.ts. + mock.timers.enable({ apis: ['setTimeout'] }); + t.after(() => { mock.timers.reset(); mem.clear(); putBodies.length = 0; }); + mem.clear(); putBodies.length = 0; + + prefs.saveSourcePrefs('webtoon-src', { mode: 'vertical', theme: 'gray' }); + prefs.saveSeriesPrefs('series-1', { zoom: 1.5 }); + assert.deepEqual(prefs.loadSourcePrefs('webtoon-src'), { mode: 'vertical', theme: 'gray' }); + await flush(); + + assert.equal(putBodies.length, 1, 'the two writes were not debounced into one push'); + const body = putBodies[0]; + assert.deepEqual(body.readerSource, { 'webtoon-src': { mode: 'vertical', theme: 'gray' } }, 'the source default was not pushed'); + assert.deepEqual(Object.keys(body.readerSeries), ['series-1'], 'a source default leaked into the per-series map'); +}); + +test('forgetting a source default removes it locally and from the next push', async (t) => { + // The server merges the blob per top-level key, so the next push replaces `readerSource` whole: dropping + // the entry here is what removes it from the account. + // Reintroduce by making clearSourcePrefs a no-op. + mock.timers.enable({ apis: ['setTimeout'] }); + t.after(() => { mock.timers.reset(); mem.clear(); putBodies.length = 0; }); + mem.clear(); putBodies.length = 0; + + prefs.saveSourcePrefs('manga-src', { mode: 'paged' }); + prefs.saveSourcePrefs('webtoon-src', { mode: 'vertical' }); + await flush(); + prefs.clearSourcePrefs('manga-src'); + await flush(); + + assert.deepEqual(prefs.loadSourcePrefs('manga-src'), {}, 'the forgotten default is still stored locally'); + assert.deepEqual(putBodies.at(-1).readerSource, { 'webtoon-src': { mode: 'vertical' } }, 'the forgotten default was pushed again'); +}); + +test('a pull adopts source defaults saved on another device', async (t) => { + // Reintroduce by deleting the `s?.readerSource` block in syncPrefsFromServer. + t.after(() => { mem.clear(); putBodies.length = 0; }); + mem.clear(); putBodies.length = 0; + serverBlob = { reader: { theme: 'amoled' }, readerSource: { 'manga-src': { mode: 'paged', spread: true } } }; + + await prefs.syncPrefsFromServer(); + assert.deepEqual(prefs.loadSourcePrefs('manga-src'), { mode: 'paged', spread: true }); + assert.deepEqual(prefs.loadSeriesPrefs('manga-src'), {}, 'a source default was adopted as a series override'); +}); From 888a26bb8e42fbff9d8b61306d814ef1ff3d88ba Mon Sep 17 00:00:00 2001 From: Squeaks72 Date: Thu, 24 Sep 2026 06:27:13 -0500 Subject: [PATCH 06/12] fix: chapter list past chapter 1000; page it 100 at a time, opening at Continue Chapter list - The series page and the reader asked the books route for one page of 1000, so One Piece's chapters 1001+ never appeared in the list or in the reader's prev/next/jump list. lib/seriesBooks.ts fetchAllBooks() walks every page (tested). The smart-offline planner had the same cap server-side. - The list now renders 100 rows a page (lib/chapterPages.ts, tested) with a pager above and below (first/prev/range picker/next/last). It opens on the page holding the "Continue" chapter; a tap on the pager pins a page, and a new series, sort, or filter follows Continue again. A chapter chip in Sources & translations turns to the page holding that row before it scrolls to it. Co-Authored-By: Claude Opus 5.5 (1M context) --- bff/src/routes/catalog.ts | 4 ++- docs/USAGE.md | 5 +++ web/app/reader/page.tsx | 3 +- web/app/series/page.tsx | 55 ++++++++++++++++++++++++++++++--- web/components/SourcesSheet.tsx | 9 +++++- web/lib/chapterPages.ts | 27 ++++++++++++++++ web/lib/seriesBooks.ts | 26 ++++++++++++++++ web/public/locales/ar.json | 8 +++-- web/public/locales/de.json | 8 +++-- web/public/locales/es.json | 8 +++-- web/public/locales/fr.json | 8 +++-- web/public/locales/ja.json | 8 +++-- web/public/locales/pt-BR.json | 8 +++-- web/public/locales/ru.json | 8 +++-- web/public/locales/zh.json | 8 +++-- web/test/chapterPages.test.ts | 26 ++++++++++++++++ web/test/seriesBooks.test.ts | 42 +++++++++++++++++++++++++ 17 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 web/lib/chapterPages.ts create mode 100644 web/lib/seriesBooks.ts create mode 100644 web/test/chapterPages.test.ts create mode 100644 web/test/seriesBooks.test.ts diff --git a/bff/src/routes/catalog.ts b/bff/src/routes/catalog.ts index 9ebf72806..1dd4c3a6f 100644 --- a/bff/src/routes/catalog.ts +++ b/bff/src/routes/catalog.ts @@ -708,8 +708,10 @@ export default async function catalogRoutes(app: FastifyInstance) { const planShown = await browsableIds(favAll, vc(req)); const favIds = favAll.filter((id) => planShown.has(id)); const out: { bookId: string; seriesId: string }[] = []; + // Every chapter, not the first 1000: past chapter 1000 of One Piece the unread ones were all beyond + // the page and the plan came back empty. for (const sid of favIds) { - const raw = await komga.seriesBooks(vc(req), sid, 0, 1000, 'metadata.numberSort,asc').catch(() => null); + const raw = await komga.seriesBooks(vc(req), sid, 0, 100000, 'metadata.numberSort,asc').catch(() => null); if (!raw) continue; const books = await booksForUser(req, raw.content); // A pruned chapter has no pages to download; planning it would queue a manifest that answers 410. diff --git a/docs/USAGE.md b/docs/USAGE.md index 7ee1b5816..3dac7d230 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -119,6 +119,11 @@ genres, description, and the **chapter list**. - **Mark all read** does what it says to every chapter of the series; **Filter** narrows the list to one translation group or hides the grey rows; **Select** picks chapters one by one for the actions described in *Selecting chapters* below. +- A long series shows its chapters **100 at a time**, with a pager above and below the list (first, + previous, a picker named by the rows each page holds — *901–1000* — next, last). The list opens on the + page holding the chapter *Continue* would open, so a reader on chapter 956 lands among the 900s. Picking a + page keeps you there; a new series, sort order or filter goes back to following *Continue*. Every chapter + is listed — before this, the list and the reader's chapter list stopped at chapter 1000. - The line under the title — *MangaDex · Example Scans +2 · 4 not here yet ›* — is the series' source, who translates it and how many chapters the sources have that this server does not. Tap it for **Sources & translations**, described below. diff --git a/web/app/reader/page.tsx b/web/app/reader/page.tsx index bf7822bbc..ce1270e13 100644 --- a/web/app/reader/page.tsx +++ b/web/app/reader/page.tsx @@ -6,6 +6,7 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { AnimatePresence, motion } from 'framer-motion'; import { useQuery } from '@tanstack/react-query'; import { api, img } from '@/lib/api'; +import { fetchAllBooks } from '@/lib/seriesBooks'; import { chapterOutcome } from '@/lib/readerState'; import { openableChapters } from '@/lib/chapterRows'; import { buildFlow, startIndex, renderWindow } from '@/lib/readerFlow'; @@ -195,7 +196,7 @@ function ReaderInner() { } // chapter list for prev/next/jump try { - const list = await api>(`/api/series/${first.seriesId}/books?size=1000&sort=metadata.numberSort,asc`); + const list = await fetchAllBooks(first.seriesId); // ⚠️ A chapter the server's cleanup deleted is still a row in that list -- it has to be, it carries // everyone's progress -- and the first cut of "Chapter deleted" only handled the failure screen, so // next/prev walked straight onto the tombstone and showed it in the middle of a series that was diff --git a/web/app/series/page.tsx b/web/app/series/page.tsx index 8d8a2ca84..6ba9a368c 100644 --- a/web/app/series/page.tsx +++ b/web/app/series/page.tsx @@ -19,6 +19,8 @@ import { t as tr } from '@/lib/i18n'; import { FindMissingDialog } from '@/components/FindMissingDialog'; import { normGroup } from '@/lib/scanlators'; import { GHOST_CAP, mergeRows, whyLabel, runLabel, chunkNumbers, MARK_CHUNK } from '@/lib/chapterRows'; +import { CHAPTER_PAGE, clampPage, pageCount, pageOf, pageSlice } from '@/lib/chapterPages'; +import { fetchAllBooks } from '@/lib/seriesBooks'; import { ALL_GROUPS, copySourceId, groupsOfRow, matchesGroup } from '@/lib/groupFilter'; import { SourcesSheet, useSeriesGroups, useCheckNow } from '@/components/SourcesSheet'; import { SourcesExplainer } from '@/components/SourcesExplainer'; @@ -756,6 +758,27 @@ function readShowGhosts(): boolean { try { return localStorage.getItem(SHOW_GHOSTS_KEY) !== 'off'; } catch { return true; } } +/** + * Prev / a range picker / Next for the chapter list. The picker names each page by the rows it holds + * ("901–1000"), which is what a reader hunting for a chapter number actually scans for. + */ +function ChapterPager({ page, pages, total, onPage }: { page: number; pages: number; total: number; onPage: (p: number) => void }) { + const range = (p: number) => `${p * CHAPTER_PAGE + 1}–${Math.min(total, (p + 1) * CHAPTER_PAGE)}`; + return ( + + ); +} + function SeriesInner() { const id = useSearchParams().get('id') || ''; const router = useRouter(); @@ -810,7 +833,7 @@ function SeriesInner() { const { data: series } = useQuery({ queryKey: ['series', id], queryFn: () => api(`/api/series/${id}`), enabled: !!id }); const { data: books } = useQuery({ queryKey: ['series-books', id], - queryFn: () => api>(`/api/series/${id}/books?size=1000&sort=metadata.numberSort,asc`), + queryFn: () => fetchAllBooks(id), enabled: !!id, }); // What the sources list that the library lacks, as of the updater's last visit. A courtesy, never a @@ -971,6 +994,27 @@ function SeriesInner() { return c.find((b) => !b.readProgress?.completed && openable(b)) || c.find(openable) || c[0]; }, [books, downloaded]); + // The list a page at a time (lib/chapterPages.ts). `null` = follow "Continue": the page holding + // `resumeBook`, so a reader on chapter 956 lands among the 900s. Any tap on the pager pins a page; a new + // series, sort or filter drops the pin and follows Continue again. + const [chapterPage, setChapterPage] = useState(null); + useEffect(() => { setChapterPage(null); }, [id, asc, group, showGhosts]); + const autoPage = useMemo(() => (resumeBook ? pageOf(rows, (r) => r.kind === 'book' && r.book.id === resumeBook.id) : 0), [rows, resumeBook]); + const shownPage = clampPage(chapterPage ?? autoPage, rows.length); + const pages = pageCount(rows.length); + const pageRows = useMemo(() => pageSlice(rows, shownPage), [rows, shownPage]); + // A chip in Sources & translations jumps to a chapter's row, which may be on another page: turn to it + // first. A number with no row (folded into a run, or filtered out) leaves the page as it is. + const showChapter = (n: number) => { + const i = rows.findIndex((r) => (r.kind === 'book' && r.book.number === n) || (r.kind === 'ghost' && r.ghost.number === n)); + if (i >= 0) setChapterPage(Math.floor(i / CHAPTER_PAGE)); + }; + const chaptersTop = useRef(null); + const goPage = (p: number, scroll: boolean) => { + setChapterPage(clampPage(p, rows.length)); + if (scroll) chaptersTop.current?.scrollIntoView({ block: 'start', behavior: 'smooth' }); + }; + const inProgress = books?.content.some((b) => b.readProgress && !b.readProgress.completed); const back = () => (typeof window !== 'undefined' && window.history.length > 1 ? router.back() : router.push('/')); @@ -1437,7 +1481,7 @@ function SeriesInner() { // How many of the Filter sheet's two choices are off their default; the chip wears the number. const activeFilters = (group !== ALL_GROUPS ? 1 : 0) + (showGhosts ? 0 : 1); const Chapters = ( -
+
{/* The heading on its own line and ONE row of four short, text-only chips under it. Measured at 390 px: with icons and the two long chips this was five chips on two rows plus two sentences; the four fit one row in English, and `flex-wrap` (never nowrap) is the safety valve for German @@ -1469,8 +1513,9 @@ function SeriesInner() { {tr('{n} of {m} chapters match', { n: filteredBooks.length + filteredGhosts.length, m: allBooks.length + visibleGhosts.length })}

)} + {pages > 1 && goPage(p, false)} />}
- {rows.map((r) => { + {pageRows.map((r) => { if (r.kind === 'book') { const b = r.book; return ( @@ -1532,6 +1577,7 @@ function SeriesInner() { })} {!books && Array.from({ length: 8 }).map((_, i) =>
)}
+ {pages > 1 && goPage(p, true)} />}
); @@ -1636,7 +1682,8 @@ function SeriesInner() { onSaved={() => { for (const k of [['series', id], ['series-books', id], ['home'], ['library']]) qc.invalidateQueries({ queryKey: k }); }} onClose={() => setSourcesOpen(false)} onExplain={() => { setSourcesOpen(false); setExplaining(true); }} - onFindMissing={() => { setSourcesOpen(false); setFindingMissing(true); }} /> + onFindMissing={() => { setSourcesOpen(false); setFindingMissing(true); }} + onShowChapter={showChapter} /> )} {explaining && { setExplaining(false); setSourcesOpen(true); }} />} {filterOpen && ( diff --git a/web/components/SourcesSheet.tsx b/web/components/SourcesSheet.tsx index d6b1b771b..b3160b2e3 100644 --- a/web/components/SourcesSheet.tsx +++ b/web/components/SourcesSheet.tsx @@ -252,7 +252,7 @@ function GroupRow({ g, blocked, serverBlocked, haveNumbers, seriesStatus, contro * turn "follows the defaults" into a per-series copy of them on the first tap -- a copy that then stops * following when the defaults change. Blank patience means the same thing for the same reason. */ -export function SourcesSheet({ id, series, groups, admin, error, isLoading, haveNumbers, checkedAt, onSaved, onClose, onExplain, onFindMissing }: { +export function SourcesSheet({ id, series, groups, admin, error, isLoading, haveNumbers, checkedAt, onSaved, onClose, onExplain, onFindMissing, onShowChapter }: { id: string; series: Series | undefined; groups: GroupStat[]; @@ -270,6 +270,12 @@ export function SourcesSheet({ id, series, groups, admin, error, isLoading, have onExplain: () => void; /** Open Find missing chapters. The page closes this sheet first: a Modal (z-50) opened under a Sheet (z-60) is unreachable. */ onFindMissing: () => void; + /** + * Put chapter `n`'s row on screen before the jump scrolls to it. The chapter list shows 100 rows a page + * (lib/chapterPages.ts), so the row a chip names may be on a page that is not rendered at all, and + * `getElementById` finds nothing. + */ + onShowChapter?: (n: number) => void; }) { const toast = useToast(); const qc = useQueryClient(); @@ -377,6 +383,7 @@ export function SourcesSheet({ id, series, groups, admin, error, isLoading, have // anyone can see. The frame after the close, the sheet is gone and the row is there. const jump = (n: number) => { onClose(); + onShowChapter?.(n); requestAnimationFrame(() => document.getElementById(`ch-${n}`)?.scrollIntoView({ block: 'center', behavior: 'smooth' })); }; diff --git a/web/lib/chapterPages.ts b/web/lib/chapterPages.ts new file mode 100644 index 000000000..17f086b98 --- /dev/null +++ b/web/lib/chapterPages.ts @@ -0,0 +1,27 @@ +// The series page's chapter list, a page at a time. Long series (One Piece: 1,193 chapters) rendered every +// row at once; now the list shows CHAPTER_PAGE rows and a pager, and opens on the page holding the chapter +// "Continue" would open -- on chapter 956 nobody wants to start at chapter 1. +// +// Paged over the merged rows (chapters, ghosts, run rows), not the books, so ghosts sit on the page between +// the chapters they fall between, exactly as in the unpaged list. + +export const CHAPTER_PAGE = 100; + +export function pageCount(total: number, size = CHAPTER_PAGE): number { + return Math.max(1, Math.ceil(total / size)); +} + +/** The page holding the first row `isTarget` accepts; page 0 when none does. */ +export function pageOf(rows: readonly T[], isTarget: (r: T) => boolean, size = CHAPTER_PAGE): number { + const i = rows.findIndex(isTarget); + return i < 0 ? 0 : Math.floor(i / size); +} + +export function clampPage(page: number, total: number, size = CHAPTER_PAGE): number { + return Math.min(Math.max(0, page), pageCount(total, size) - 1); +} + +export function pageSlice(rows: readonly T[], page: number, size = CHAPTER_PAGE): T[] { + const p = clampPage(page, rows.length, size); + return rows.slice(p * size, p * size + size); +} diff --git a/web/lib/seriesBooks.ts b/web/lib/seriesBooks.ts new file mode 100644 index 000000000..d35d88518 --- /dev/null +++ b/web/lib/seriesBooks.ts @@ -0,0 +1,26 @@ +// Every chapter of a series, however many there are. The books route pages, and the series page and the +// reader used to ask for one page of 1000 -- so One Piece stopped at chapter 1000 in the list, and the +// reader's prev/next/jump list ended there too, although chapters 1001+ were on disk. +// Reintroduce by going back to one `?size=1000` request: "walks past the first 1000" in seriesBooks.test.ts +// gets 1000 chapters of 1193. +import { api } from './api'; +import type { Book, Page } from './types'; + +export const BATCH = 1000; +/** Backstop against a route that never reports a short page: 50 × 1000 is more chapters than exist. */ +const MAX_BATCHES = 50; + +/** `get` is the fetcher, `api` in the app; a test hands it a fake so no network is involved. */ +export async function fetchAllBooks(seriesId: string, get: (url: string) => Promise> = api): Promise> { + const url = (p: number) => `/api/series/${seriesId}/books?page=${p}&size=${BATCH}&sort=metadata.numberSort,asc`; + const first = await get(url(0)); + const content = [...(first.content ?? [])]; + const total = first.totalElements ?? content.length; + for (let p = 1; content.length < total && p < MAX_BATCHES; p++) { + const next = await get(url(p)); + // An empty page ends it even short of `total`: the route may hide rows from this viewer after counting. + if (!next.content?.length) break; + content.push(...next.content); + } + return { ...first, content, size: content.length, totalPages: 1, first: true, last: true }; +} diff --git a/web/public/locales/ar.json b/web/public/locales/ar.json index f05546ae6..64348abff 100644 --- a/web/public/locales/ar.json +++ b/web/public/locales/ar.json @@ -2,7 +2,7 @@ "_meta": { "language": "ar", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "فحص واحد وجد شيئًا", "1 member": "عضو واحد", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "قال المحرّك: {reason}", "The extension engine refused that address: {reason}": "رفض محرّك الإضافات هذا العنوان: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "يشغّل تثبيت Docker القياسي محرّكًا في الحاوية {name}. إذا عطّلته بإفراغ SUWAYOMI_URL، فأعِد ذلك السطر إلى ملف .env ثم شغّل {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "نصيحة: أخفِ أولًا اللغات التي لا تقرؤها — لا يمكن تفعيل أكثر من {n} مصدرًا في وقت واحد." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "نصيحة: أخفِ أولًا اللغات التي لا تقرؤها — لا يمكن تفعيل أكثر من {n} مصدرًا في وقت واحد.", + "Chapter pages": "صفحات الفصول", + "First page": "الصفحة الأولى", + "Last page": "الصفحة الأخيرة", + "of {n}": "من {n}" } diff --git a/web/public/locales/de.json b/web/public/locales/de.json index 465e0bc63..52d143cf6 100644 --- a/web/public/locales/de.json +++ b/web/public/locales/de.json @@ -2,7 +2,7 @@ "_meta": { "language": "de", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 Prüfung hat etwas gefunden", "1 member": "1 Mitglied", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "Die Engine meldete: {reason}", "The extension engine refused that address: {reason}": "Die Erweiterungs-Engine hat diese Adresse abgelehnt: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "Die Standard-Docker-Installation betreibt eine im Container {name}. Wenn du sie ausgeschaltet hast, indem du SUWAYOMI_URL geleert hast, stelle diese Zeile in .env wieder her und führe {command} aus.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Tipp: Blende zuerst die Sprachen aus, die du nicht liest — es können nur {n} Quellen gleichzeitig aktiv sein." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Tipp: Blende zuerst die Sprachen aus, die du nicht liest — es können nur {n} Quellen gleichzeitig aktiv sein.", + "Chapter pages": "Kapitelseiten", + "First page": "Erste Seite", + "Last page": "Letzte Seite", + "of {n}": "von {n}" } diff --git a/web/public/locales/es.json b/web/public/locales/es.json index 7e5c0e5aa..99aae8997 100644 --- a/web/public/locales/es.json +++ b/web/public/locales/es.json @@ -2,7 +2,7 @@ "_meta": { "language": "es", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 comprobación encontró algo", "1 member": "1 miembro", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "El motor dijo: {reason}", "The extension engine refused that address: {reason}": "El motor de extensiones rechazó esa dirección: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "La instalación estándar de Docker ejecuta uno en el contenedor {name}. Si lo desactivaste dejando vacío SUWAYOMI_URL, vuelve a poner esa línea en .env y ejecuta {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Consejo: oculta primero los idiomas que no lees — solo se pueden activar {n} fuentes a la vez." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Consejo: oculta primero los idiomas que no lees — solo se pueden activar {n} fuentes a la vez.", + "Chapter pages": "Páginas de capítulos", + "First page": "Primera página", + "Last page": "Última página", + "of {n}": "de {n}" } diff --git a/web/public/locales/fr.json b/web/public/locales/fr.json index 1ff5b5177..111d01871 100644 --- a/web/public/locales/fr.json +++ b/web/public/locales/fr.json @@ -2,7 +2,7 @@ "_meta": { "language": "fr", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 vérification a trouvé quelque chose", "1 member": "1 membre", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "Le moteur a répondu : {reason}", "The extension engine refused that address: {reason}": "Le moteur d’extensions a refusé cette adresse : {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "L’installation Docker standard en fait tourner un dans le conteneur {name}. Si vous l’avez désactivé en vidant SUWAYOMI_URL, remettez cette ligne dans .env et lancez {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Astuce : masquez d’abord les langues que vous ne lisez pas — seules {n} sources peuvent être activées à la fois." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Astuce : masquez d’abord les langues que vous ne lisez pas — seules {n} sources peuvent être activées à la fois.", + "Chapter pages": "Pages de chapitres", + "First page": "Première page", + "Last page": "Dernière page", + "of {n}": "sur {n}" } diff --git a/web/public/locales/ja.json b/web/public/locales/ja.json index a39bbd916..d18ac2e91 100644 --- a/web/public/locales/ja.json +++ b/web/public/locales/ja.json @@ -2,7 +2,7 @@ "_meta": { "language": "ja", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 件のチェックで問題を検出", "1 member": "メンバー 1 人", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "エンジンの応答: {reason}", "The extension engine refused that address: {reason}": "拡張機能エンジンがこのアドレスを拒否しました: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "標準の Docker インストールでは {name} コンテナーでエンジンが動作します。SUWAYOMI_URL を空にして無効にした場合は、.env にその行を戻して {command} を実行してください。", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "ヒント: まず読まない言語を隠しておきましょう — 同時に有効にできるソースは {n} 件までです。" + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "ヒント: まず読まない言語を隠しておきましょう — 同時に有効にできるソースは {n} 件までです。", + "Chapter pages": "章のページ", + "First page": "最初のページ", + "Last page": "最後のページ", + "of {n}": "/ {n}" } diff --git a/web/public/locales/pt-BR.json b/web/public/locales/pt-BR.json index fb5206222..c5b8ae1cf 100644 --- a/web/public/locales/pt-BR.json +++ b/web/public/locales/pt-BR.json @@ -2,7 +2,7 @@ "_meta": { "language": "pt-BR", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 verificação encontrou algo", "1 member": "1 membro", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "O motor respondeu: {reason}", "The extension engine refused that address: {reason}": "O motor de extensões recusou esse endereço: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "A instalação padrão do Docker roda um no contêiner {name}. Se você o desativou deixando SUWAYOMI_URL vazio, coloque essa linha de volta no .env e execute {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Dica: oculte primeiro os idiomas que você não lê — só {n} fontes podem ficar ativadas ao mesmo tempo." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Dica: oculte primeiro os idiomas que você não lê — só {n} fontes podem ficar ativadas ao mesmo tempo.", + "Chapter pages": "Páginas de capítulos", + "First page": "Primeira página", + "Last page": "Última página", + "of {n}": "de {n}" } diff --git a/web/public/locales/ru.json b/web/public/locales/ru.json index 07c131555..81ddcc1ae 100644 --- a/web/public/locales/ru.json +++ b/web/public/locales/ru.json @@ -2,7 +2,7 @@ "_meta": { "language": "ru", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 проверка нашла проблему", "1 member": "1 участник", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "Ответ движка: {reason}", "The extension engine refused that address: {reason}": "Движок расширений отклонил этот адрес: {reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "Стандартная установка Docker запускает его в контейнере {name}. Если вы отключили его, очистив SUWAYOMI_URL, верните эту строку в .env и выполните {command}.", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Совет: сначала скройте языки, которые вы не читаете, — одновременно можно включить не больше {n} источников." + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "Совет: сначала скройте языки, которые вы не читаете, — одновременно можно включить не больше {n} источников.", + "Chapter pages": "Страницы глав", + "First page": "Первая страница", + "Last page": "Последняя страница", + "of {n}": "из {n}" } diff --git a/web/public/locales/zh.json b/web/public/locales/zh.json index e65db2136..19df9e0a7 100644 --- a/web/public/locales/zh.json +++ b/web/public/locales/zh.json @@ -2,7 +2,7 @@ "_meta": { "language": "zh", "note": "Machine-assisted translation, reviewed for tone but not by a native speaker. Corrections are very welcome: edit this file and open a pull request. Keys are the English source strings, so a missing entry falls back to English.", - "strings": 1149 + "strings": 1153 }, "1 check found something": "1 项检查发现问题", "1 member": "1 位成员", @@ -1152,5 +1152,9 @@ "The engine said: {reason}": "引擎返回:{reason}", "The extension engine refused that address: {reason}": "扩展引擎拒绝了该地址:{reason}", "The standard Docker install runs one in the {name} container. If you turned it off by emptying SUWAYOMI_URL, put that line back in .env and run {command}.": "标准 Docker 安装会在 {name} 容器中运行一个扩展引擎。如果你是通过清空 SUWAYOMI_URL 关闭的,请把那一行加回 .env,然后运行 {command}。", - "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "提示:先隐藏你不读的语言 — 同时最多只能启用 {n} 个来源。" + "Tip: hide the languages you don’t read first — only {n} sources can be switched on at once.": "提示:先隐藏你不读的语言 — 同时最多只能启用 {n} 个来源。", + "Chapter pages": "章节分页", + "First page": "第一页", + "Last page": "最后一页", + "of {n}": "共 {n}" } diff --git a/web/test/chapterPages.test.ts b/web/test/chapterPages.test.ts new file mode 100644 index 000000000..0fee6f887 --- /dev/null +++ b/web/test/chapterPages.test.ts @@ -0,0 +1,26 @@ +// The chapter list pages over its merged rows and opens on the page holding "Continue". +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { pageCount, pageOf, pageSlice, clampPage } from '../lib/chapterPages'; + +const rows = Array.from({ length: 1193 }, (_, i) => i + 1); // One Piece, chapters 1..1193 + +test('page count rounds up and is never zero', () => { + assert.equal(pageCount(1193, 100), 12); + assert.equal(pageCount(100, 100), 1); + assert.equal(pageCount(0, 100), 1); +}); + +test('opens on the page holding the target', () => { + assert.equal(pageOf(rows, (n) => n === 956, 100), 9); + assert.equal(pageOf(rows, (n) => n === 1, 100), 0); + assert.equal(pageOf(rows, (n) => n === 1193, 100), 11); + assert.equal(pageOf(rows, () => false, 100), 0); +}); + +test('slices past chapter 1000 and clamps a stale page', () => { + assert.deepEqual(pageSlice(rows, 11, 100), rows.slice(1100)); + assert.deepEqual(pageSlice(rows, 99, 100), rows.slice(1100)); // filter shrank the list under the pager + assert.equal(clampPage(-1, 1193, 100), 0); + assert.equal(pageSlice([], 0, 100).length, 0); +}); diff --git a/web/test/seriesBooks.test.ts b/web/test/seriesBooks.test.ts new file mode 100644 index 000000000..ec5cf692a --- /dev/null +++ b/web/test/seriesBooks.test.ts @@ -0,0 +1,42 @@ +// The series page and the reader load EVERY chapter, not the route's first page of 1000. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { fetchAllBooks, BATCH } from '../lib/seriesBooks'; +import type { Book, Page } from '../lib/types'; + +/** A books route over `n` chapters that pages like the real one, and `hidden` rows it counts but never sends. */ +function route(n: number, hidden = 0) { + const all = Array.from({ length: n - hidden }, (_, i) => ({ id: `b${i + 1}`, number: i + 1 }) as unknown as Book); + const urls: string[] = []; + const get = async (url: string): Promise> => { + urls.push(url); + const q = new URL(url, 'http://x').searchParams; + const page = Number(q.get('page')), size = Number(q.get('size')); + const content = all.slice(page * size, page * size + size); + return { content, totalElements: n, totalPages: Math.ceil(n / size), number: page, size, first: page === 0, last: (page + 1) * size >= n } as Page; + }; + return { get, urls }; +} + +test('walks past the first 1000 (One Piece, 1193 chapters)', async () => { + const r = route(1193); + const res = await fetchAllBooks('s1', r.get); + assert.equal(res.content.length, 1193); + assert.equal(res.content.at(-1)!.number, 1193); + assert.equal(r.urls.length, 2); + assert.ok(r.urls.every((u) => u.includes(`size=${BATCH}`) && u.includes('sort=metadata.numberSort,asc'))); + assert.equal(res.last, true); +}); + +test('one request when a single page holds the series', async () => { + const r = route(40); + assert.equal((await fetchAllBooks('s1', r.get)).content.length, 40); + assert.equal(r.urls.length, 1); +}); + +test('an empty page ends the walk even when the count promised more', async () => { + const r = route(1500, 600); // counted 1500, sends 900 + const res = await fetchAllBooks('s1', r.get); + assert.equal(res.content.length, 900); + assert.equal(r.urls.length, 2); +}); From 790acea696de419fc48581e87bf320c8d1094f8c Mon Sep 17 00:00:00 2001 From: AngeloSha Date: Fri, 25 Sep 2026 10:18:43 +0200 Subject: [PATCH 07/12] Review fixes: pages named by chapter, a list that stays put, one row per book On top of @Squeaks72's paging, which fixes a real bug on main -- Continue opened chapter 1 for anyone past chapter 1000: - The picker named pages by row position. Ghost, run and "show all" rows are rows but not chapters, and newest first labelled the page holding 1193-1094 "1-100". Pages are now named by the first and last chapter numbers they show, in the list's own order, and the count beside it is chapters. - The page followed Continue live, so expanding an older-chapters run, "Show all" or "Mark all read" flipped the list away from what the reader was doing. It is decided once per series, sort and filter, once the chapters and the sources' listing (whose ghost rows would shift it) have both arrived. - A scan between the two requests could hand the same book back at the 1000 boundary: two rows, one React key. Kept once. Co-Authored-By: Claude Opus 5.5 --- web/app/series/page.tsx | 27 ++++++++++++++++----------- web/lib/chapterPages.ts | 26 ++++++++++++++++++++++++++ web/lib/seriesBooks.ts | 5 ++++- web/test/chapterPages.test.ts | 33 ++++++++++++++++++++++++++++++++- web/test/seriesBooks.test.ts | 14 ++++++++++++++ 5 files changed, 92 insertions(+), 13 deletions(-) diff --git a/web/app/series/page.tsx b/web/app/series/page.tsx index 6ba9a368c..f28c1bdea 100644 --- a/web/app/series/page.tsx +++ b/web/app/series/page.tsx @@ -18,8 +18,8 @@ import { IcChevronLeft, IcHeart, IcStar, IcPlay, IcDownload, IcCloudDownload, Ic import { t as tr } from '@/lib/i18n'; import { FindMissingDialog } from '@/components/FindMissingDialog'; import { normGroup } from '@/lib/scanlators'; -import { GHOST_CAP, mergeRows, whyLabel, runLabel, chunkNumbers, MARK_CHUNK } from '@/lib/chapterRows'; -import { CHAPTER_PAGE, clampPage, pageCount, pageOf, pageSlice } from '@/lib/chapterPages'; +import { GHOST_CAP, mergeRows, whyLabel, runLabel, chunkNumbers, MARK_CHUNK, type Row } from '@/lib/chapterRows'; +import { CHAPTER_PAGE, clampPage, pageCount, pageLabel, pageOf, pageSlice } from '@/lib/chapterPages'; import { fetchAllBooks } from '@/lib/seriesBooks'; import { ALL_GROUPS, copySourceId, groupsOfRow, matchesGroup } from '@/lib/groupFilter'; import { SourcesSheet, useSeriesGroups, useCheckNow } from '@/components/SourcesSheet'; @@ -759,11 +759,12 @@ function readShowGhosts(): boolean { } /** - * Prev / a range picker / Next for the chapter list. The picker names each page by the rows it holds - * ("901–1000"), which is what a reader hunting for a chapter number actually scans for. + * Prev / a range picker / Next for the chapter list. The picker names each page by the first and last chapter + * numbers it shows ("901–1000", or "1193–1094" newest first), which is what a reader hunting for a chapter + * number actually scans for; `total` is chapters, not rows. */ -function ChapterPager({ page, pages, total, onPage }: { page: number; pages: number; total: number; onPage: (p: number) => void }) { - const range = (p: number) => `${p * CHAPTER_PAGE + 1}–${Math.min(total, (p + 1) * CHAPTER_PAGE)}`; +function ChapterPager({ page, pages, rows, asc, total, onPage }: { page: number; pages: number; rows: readonly Row[]; asc: boolean; total: number; onPage: (p: number) => void }) { + const range = (p: number) => pageLabel(rows, p, asc); return (