Track reading progress at the page level and resume there
Upgrades the reader's last-read tracking from {chapter} to {chapter, page}.
- ReadingProgressButton: storage is now JSON {chapter, page}; legacy
bare-number values are read back as {chapter, page: 1}. Button label
is unchanged ("继续阅读 · #N title") — the extra precision lives in
the reader's first-fetch offset, not the label.
- PageReader: on mount with saved progress, seed offsetRef to
(page - 1) so the first /api/pages call starts AT the resumed page
instead of the beginning of the chapter. currentPageNum state is
initialized from storage too, so the first persist write is a no-op
that matches the saved value.
- Scroll tracker now also tracks currentPageNum (last page whose top
has crossed above viewport top+80), and persistence writes the
{chapter, page} pair on each change.
Known limitation: earlier pages of the resumed chapter aren't loaded
yet — a follow-up commit adds scroll-up prefetch for those.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
26b620de2f
commit
0c6425f0ff
@ -3,7 +3,10 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { writeLastReadChapter } from "@/components/ReadingProgressButton";
|
||||
import {
|
||||
readProgress,
|
||||
writeProgress,
|
||||
} from "@/components/ReadingProgressButton";
|
||||
|
||||
type ChapterMeta = {
|
||||
id: number;
|
||||
@ -48,12 +51,30 @@ export function PageReader({
|
||||
const [pages, setPages] = useState<LoadedPage[]>([]);
|
||||
const [currentChapterNum, setCurrentChapterNum] =
|
||||
useState(startChapterNumber);
|
||||
const [currentPageNum, setCurrentPageNum] = useState(() => {
|
||||
if (typeof window === "undefined") return 1;
|
||||
const p = readProgress(mangaSlug);
|
||||
if (p && p.chapter === startChapterNumber && p.page > 1) return p.page;
|
||||
return 1;
|
||||
});
|
||||
|
||||
const hiddenByScrollRef = useRef(false);
|
||||
const fetchChapterIdxRef = useRef(
|
||||
chapters.findIndex((c) => c.number === startChapterNumber)
|
||||
);
|
||||
// Initialize offset from saved progress so the first fetch starts AT the
|
||||
// user's last-read page — previous pages are skipped entirely
|
||||
const offsetRef = useRef(0);
|
||||
const initialPageRef = useRef(1);
|
||||
const offsetInitedRef = useRef(false);
|
||||
if (!offsetInitedRef.current && typeof window !== "undefined") {
|
||||
offsetInitedRef.current = true;
|
||||
const p = readProgress(mangaSlug);
|
||||
if (p && p.chapter === startChapterNumber && p.page > 1) {
|
||||
offsetRef.current = p.page - 1;
|
||||
initialPageRef.current = p.page;
|
||||
}
|
||||
}
|
||||
const loadingRef = useRef(false);
|
||||
const doneRef = useRef(false);
|
||||
// Count of pages already loaded — tracked via ref so fetchBatch stays stable
|
||||
@ -232,14 +253,20 @@ export function PageReader({
|
||||
hiddenByScrollRef.current = true;
|
||||
setShowUI(false);
|
||||
}
|
||||
let current = startChapterNumber;
|
||||
// Nothing loaded yet — don't overwrite the resumed-page state
|
||||
if (pages.length === 0) return;
|
||||
let chapter = pages[0].chapterNumber;
|
||||
let page = pages[0].pageNumber;
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const el = pageRefsRef.current.get(i);
|
||||
if (!el) continue;
|
||||
if (el.offsetTop <= y + 80) current = pages[i].chapterNumber;
|
||||
else break;
|
||||
if (el.offsetTop <= y + 80) {
|
||||
chapter = pages[i].chapterNumber;
|
||||
page = pages[i].pageNumber;
|
||||
} else break;
|
||||
}
|
||||
setCurrentChapterNum(current);
|
||||
setCurrentChapterNum(chapter);
|
||||
setCurrentPageNum(page);
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (rafId) return;
|
||||
@ -252,10 +279,15 @@ export function PageReader({
|
||||
};
|
||||
}, [pages, startChapterNumber]);
|
||||
|
||||
// Persist reading progress whenever the visible chapter changes
|
||||
// Persist progress as user scrolls. offsetRef has been pre-seeded above
|
||||
// so the first fetched page IS the resumed page — no scroll restoration
|
||||
// needed.
|
||||
useEffect(() => {
|
||||
writeLastReadChapter(mangaSlug, currentChapterNum);
|
||||
}, [mangaSlug, currentChapterNum]);
|
||||
writeProgress(mangaSlug, {
|
||||
chapter: currentChapterNum,
|
||||
page: currentPageNum,
|
||||
});
|
||||
}, [mangaSlug, currentChapterNum, currentPageNum]);
|
||||
|
||||
const currentChapter =
|
||||
chapters.find((c) => c.number === currentChapterNum) ??
|
||||
|
||||
@ -13,34 +13,59 @@ type Props = {
|
||||
chapters: ChapterLite[];
|
||||
};
|
||||
|
||||
export type ReadingProgress = {
|
||||
chapter: number;
|
||||
page: number;
|
||||
};
|
||||
|
||||
function storageKey(slug: string) {
|
||||
return `sunnymh:last-read:${slug}`;
|
||||
}
|
||||
|
||||
export function readLastReadChapter(slug: string): number | null {
|
||||
export function readProgress(slug: string): ReadingProgress | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = window.localStorage.getItem(storageKey(slug));
|
||||
if (!raw) return null;
|
||||
// New format: JSON { chapter, page }
|
||||
if (raw.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as ReadingProgress;
|
||||
if (
|
||||
typeof parsed.chapter === "number" &&
|
||||
typeof parsed.page === "number" &&
|
||||
parsed.chapter > 0 &&
|
||||
parsed.page > 0
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Legacy format: bare chapter number
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
return Number.isFinite(n) && n > 0 ? { chapter: n, page: 1 } : null;
|
||||
}
|
||||
|
||||
export function writeLastReadChapter(slug: string, chapter: number) {
|
||||
export function writeProgress(slug: string, progress: ReadingProgress) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(storageKey(slug), String(chapter));
|
||||
window.localStorage.setItem(storageKey(slug), JSON.stringify(progress));
|
||||
}
|
||||
|
||||
export function ReadingProgressButton({ mangaSlug, chapters }: Props) {
|
||||
const [lastRead, setLastRead] = useState<number | null>(null);
|
||||
const [progress, setProgress] = useState<ReadingProgress | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLastRead(readLastReadChapter(mangaSlug));
|
||||
setProgress(readProgress(mangaSlug));
|
||||
}, [mangaSlug]);
|
||||
|
||||
if (chapters.length === 0) return null;
|
||||
const first = chapters[0];
|
||||
const resumeChapter =
|
||||
lastRead !== null ? chapters.find((c) => c.number === lastRead) : null;
|
||||
progress !== null
|
||||
? chapters.find((c) => c.number === progress.chapter)
|
||||
: null;
|
||||
const target = resumeChapter ?? first;
|
||||
|
||||
return (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user