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>
472 lines
15 KiB
TypeScript
472 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import {
|
|
readProgress,
|
|
writeProgress,
|
|
} from "@/components/ReadingProgressButton";
|
|
|
|
type ChapterMeta = {
|
|
id: number;
|
|
number: number;
|
|
title: string;
|
|
totalPages: number;
|
|
};
|
|
|
|
type LoadedPage = {
|
|
chapterNumber: number;
|
|
pageNumber: number;
|
|
imageUrl: string;
|
|
};
|
|
|
|
type RawPage = {
|
|
number: number;
|
|
imageUrl: string;
|
|
};
|
|
|
|
type PageReaderProps = {
|
|
mangaSlug: string;
|
|
mangaTitle: string;
|
|
startChapterNumber: number;
|
|
prevChapter: number | null;
|
|
nextChapter: number | null;
|
|
chapters: ChapterMeta[];
|
|
};
|
|
|
|
const BATCH_SIZE = 7;
|
|
const PREFETCH_AT = 3;
|
|
|
|
export function PageReader({
|
|
mangaSlug,
|
|
mangaTitle,
|
|
startChapterNumber,
|
|
prevChapter,
|
|
nextChapter,
|
|
chapters,
|
|
}: PageReaderProps) {
|
|
const [showUI, setShowUI] = useState(true);
|
|
const [showDrawer, setShowDrawer] = useState(false);
|
|
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
|
|
// (otherwise every batch re-creates fetchBatch and tears down the observer)
|
|
const loadedCountRef = useRef(0);
|
|
const triggerIndicesRef = useRef<Set<number>>(new Set());
|
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
const pageRefsRef = useRef<Map<number, HTMLDivElement>>(new Map());
|
|
|
|
const advanceChapterOrFinish = useCallback(() => {
|
|
if (fetchChapterIdxRef.current + 1 < chapters.length) {
|
|
fetchChapterIdxRef.current += 1;
|
|
offsetRef.current = 0;
|
|
} else {
|
|
doneRef.current = true;
|
|
}
|
|
}, [chapters.length]);
|
|
|
|
const fetchBatch = useCallback(async () => {
|
|
if (loadingRef.current || doneRef.current) return;
|
|
const chapter = chapters[fetchChapterIdxRef.current];
|
|
if (!chapter) {
|
|
doneRef.current = true;
|
|
return;
|
|
}
|
|
loadingRef.current = true;
|
|
try {
|
|
const res = await fetch(
|
|
`/api/pages?chapterId=${chapter.id}&offset=${offsetRef.current}&limit=${BATCH_SIZE}`
|
|
);
|
|
const batch: RawPage[] = await res.json();
|
|
if (batch.length === 0) {
|
|
advanceChapterOrFinish();
|
|
return;
|
|
}
|
|
|
|
const baseIndex = loadedCountRef.current;
|
|
const willHaveMore =
|
|
offsetRef.current + batch.length < chapter.totalPages ||
|
|
fetchChapterIdxRef.current + 1 < chapters.length;
|
|
if (willHaveMore) {
|
|
const triggerIndex = baseIndex + PREFETCH_AT - 1;
|
|
triggerIndicesRef.current.add(triggerIndex);
|
|
const existing = pageRefsRef.current.get(triggerIndex);
|
|
if (existing && observerRef.current) {
|
|
observerRef.current.observe(existing);
|
|
}
|
|
}
|
|
|
|
loadedCountRef.current += batch.length;
|
|
setPages((prev) => [
|
|
...prev,
|
|
...batch.map((p) => ({
|
|
chapterNumber: chapter.number,
|
|
pageNumber: p.number,
|
|
imageUrl: p.imageUrl,
|
|
})),
|
|
]);
|
|
offsetRef.current += batch.length;
|
|
if (offsetRef.current >= chapter.totalPages) {
|
|
advanceChapterOrFinish();
|
|
}
|
|
} catch {
|
|
// retry on next intersection
|
|
} finally {
|
|
loadingRef.current = false;
|
|
}
|
|
}, [chapters, advanceChapterOrFinish]);
|
|
|
|
useEffect(() => {
|
|
observerRef.current = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
const index = Number(
|
|
(entry.target as HTMLElement).dataset.pageIndex
|
|
);
|
|
if (triggerIndicesRef.current.has(index)) {
|
|
triggerIndicesRef.current.delete(index);
|
|
fetchBatch();
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{ rootMargin: "400px" }
|
|
);
|
|
return () => observerRef.current?.disconnect();
|
|
}, [fetchBatch]);
|
|
|
|
const initialFetchRef = useRef(false);
|
|
useEffect(() => {
|
|
if (initialFetchRef.current) return;
|
|
initialFetchRef.current = true;
|
|
fetchBatch();
|
|
}, [fetchBatch]);
|
|
|
|
const setPageRef = useCallback(
|
|
(index: number, el: HTMLDivElement | null) => {
|
|
const observer = observerRef.current;
|
|
if (!observer) return;
|
|
const prev = pageRefsRef.current.get(index);
|
|
if (prev) observer.unobserve(prev);
|
|
if (el) {
|
|
pageRefsRef.current.set(index, el);
|
|
if (triggerIndicesRef.current.has(index)) {
|
|
observer.observe(el);
|
|
}
|
|
} else {
|
|
pageRefsRef.current.delete(index);
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
const router = useRouter();
|
|
|
|
const touchMovedRef = useRef(false);
|
|
// Pending single-tap toggle, delayed so we can detect a double-tap first
|
|
const singleTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const lastTapAtRef = useRef(0);
|
|
const DOUBLE_TAP_MS = 280;
|
|
|
|
const onTouchStart = useCallback(() => {
|
|
touchMovedRef.current = false;
|
|
}, []);
|
|
const onTouchMove = useCallback(() => {
|
|
touchMovedRef.current = true;
|
|
}, []);
|
|
|
|
const onTap = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (touchMovedRef.current) return;
|
|
const now = Date.now();
|
|
const isDoubleTap = now - lastTapAtRef.current < DOUBLE_TAP_MS;
|
|
|
|
if (isDoubleTap) {
|
|
// Cancel pending single-tap, navigate instead
|
|
if (singleTapTimerRef.current) {
|
|
clearTimeout(singleTapTimerRef.current);
|
|
singleTapTimerRef.current = null;
|
|
}
|
|
lastTapAtRef.current = 0;
|
|
const midX = window.innerWidth / 2;
|
|
if (e.clientX >= midX) {
|
|
if (nextChapter)
|
|
router.push(`/manga/${mangaSlug}/${nextChapter}`);
|
|
} else {
|
|
if (prevChapter)
|
|
router.push(`/manga/${mangaSlug}/${prevChapter}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
lastTapAtRef.current = now;
|
|
singleTapTimerRef.current = setTimeout(() => {
|
|
setShowUI((v) => !v);
|
|
singleTapTimerRef.current = null;
|
|
}, DOUBLE_TAP_MS);
|
|
},
|
|
[router, mangaSlug, nextChapter, prevChapter]
|
|
);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (singleTapTimerRef.current) clearTimeout(singleTapTimerRef.current);
|
|
},
|
|
[]
|
|
);
|
|
|
|
useEffect(() => {
|
|
let rafId = 0;
|
|
const tick = () => {
|
|
rafId = 0;
|
|
const y = window.scrollY;
|
|
if (!hiddenByScrollRef.current && y > 50) {
|
|
hiddenByScrollRef.current = true;
|
|
setShowUI(false);
|
|
}
|
|
// 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) {
|
|
chapter = pages[i].chapterNumber;
|
|
page = pages[i].pageNumber;
|
|
} else break;
|
|
}
|
|
setCurrentChapterNum(chapter);
|
|
setCurrentPageNum(page);
|
|
};
|
|
const onScroll = () => {
|
|
if (rafId) return;
|
|
rafId = requestAnimationFrame(tick);
|
|
};
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
return () => {
|
|
window.removeEventListener("scroll", onScroll);
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
};
|
|
}, [pages, startChapterNumber]);
|
|
|
|
// 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(() => {
|
|
writeProgress(mangaSlug, {
|
|
chapter: currentChapterNum,
|
|
page: currentPageNum,
|
|
});
|
|
}, [mangaSlug, currentChapterNum, currentPageNum]);
|
|
|
|
const currentChapter =
|
|
chapters.find((c) => c.number === currentChapterNum) ??
|
|
chapters.find((c) => c.number === startChapterNumber);
|
|
|
|
return (
|
|
<div className="min-h-dvh bg-background">
|
|
<div
|
|
className={`sticky top-0 z-50 bg-background backdrop-blur-sm shadow-sm transition-transform duration-300 pt-safe ${
|
|
showUI ? "translate-y-0" : "-translate-y-full"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3 px-4 h-12 max-w-4xl mx-auto">
|
|
<Link
|
|
href={`/manga/${mangaSlug}`}
|
|
className="text-foreground/80 hover:text-foreground transition-colors shrink-0"
|
|
aria-label="Back to manga"
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
className="w-6 h-6"
|
|
>
|
|
<polyline points="15 18 9 12 15 6" />
|
|
</svg>
|
|
</Link>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-foreground text-sm font-medium truncate">
|
|
{mangaTitle}
|
|
</p>
|
|
<p className="text-muted text-xs truncate">
|
|
Ch. {currentChapter?.number} — {currentChapter?.title}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowDrawer(true)}
|
|
className="text-foreground/80 hover:text-foreground transition-colors shrink-0"
|
|
aria-label="Chapter list"
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
className="w-6 h-6"
|
|
>
|
|
<line x1="3" y1="6" x2="21" y2="6" />
|
|
<line x1="3" y1="12" x2="21" y2="12" />
|
|
<line x1="3" y1="18" x2="21" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="max-w-4xl mx-auto leading-[0] select-none"
|
|
onClick={onTap}
|
|
onTouchStart={onTouchStart}
|
|
onTouchMove={onTouchMove}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
>
|
|
{pages.map((page, i) => {
|
|
const isChapterStart =
|
|
i === 0 || pages[i - 1].chapterNumber !== page.chapterNumber;
|
|
return (
|
|
<div
|
|
key={`${page.chapterNumber}-${page.pageNumber}`}
|
|
className="relative leading-[0]"
|
|
data-page-index={i}
|
|
ref={(el) => setPageRef(i, el)}
|
|
>
|
|
{isChapterStart && i > 0 && (
|
|
<div className="bg-surface py-4 text-center leading-normal">
|
|
<p className="text-xs uppercase tracking-wider text-muted">
|
|
Chapter {page.chapterNumber}
|
|
</p>
|
|
<p className="text-sm font-semibold text-foreground">
|
|
{
|
|
chapters.find((c) => c.number === page.chapterNumber)
|
|
?.title
|
|
}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<img
|
|
src={page.imageUrl}
|
|
alt={`Page ${page.pageNumber}`}
|
|
className="w-full h-auto block align-bottom -mb-px [-webkit-touch-callout:none]"
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{doneRef.current && pages.length > 0 && (
|
|
<div className="min-h-dvh max-w-4xl mx-auto px-4 flex flex-col items-center justify-center text-center leading-normal gap-4">
|
|
<p className="text-xs uppercase tracking-wider text-muted">
|
|
End of Manga
|
|
</p>
|
|
<p className="text-base font-semibold">{mangaTitle}</p>
|
|
<Link
|
|
href="/"
|
|
className="px-5 py-2 rounded-lg bg-accent text-white text-sm font-semibold transition-colors hover:bg-accent-hover"
|
|
>
|
|
Back to Home
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{/* Chapter drawer overlay (modal — fixed is necessary to cover viewport) */}
|
|
{showDrawer && (
|
|
<div
|
|
className="fixed inset-0 z-[60]"
|
|
onClick={() => setShowDrawer(false)}
|
|
>
|
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 max-h-[75vh] bg-background rounded-t-3xl shadow-2xl overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="sticky top-0 bg-background z-10 border-b border-border">
|
|
<div className="flex justify-center pt-2.5 pb-1.5">
|
|
<div className="w-10 h-1 rounded-full bg-muted/40" />
|
|
</div>
|
|
<div className="px-5 py-3 flex items-center justify-between">
|
|
<span className="text-foreground text-base font-bold">
|
|
Chapters
|
|
</span>
|
|
<span className="text-xs text-muted tabular-nums">
|
|
{chapters.length} total
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-y-auto max-h-[calc(75vh-4rem)] pb-safe">
|
|
{chapters.map((ch) => {
|
|
const isActive = ch.number === currentChapterNum;
|
|
return (
|
|
<Link
|
|
key={ch.number}
|
|
href={`/manga/${mangaSlug}/${ch.number}`}
|
|
className={`flex items-center gap-3 px-5 py-3 text-sm transition-colors ${
|
|
isActive
|
|
? "bg-accent/10"
|
|
: "hover:bg-surface active:bg-surface-hover"
|
|
}`}
|
|
onClick={() => setShowDrawer(false)}
|
|
>
|
|
<span
|
|
className={`font-bold tabular-nums w-10 shrink-0 ${
|
|
isActive ? "text-accent" : "text-muted"
|
|
}`}
|
|
>
|
|
#{ch.number}
|
|
</span>
|
|
<span
|
|
className={`truncate ${
|
|
isActive
|
|
? "text-accent font-semibold"
|
|
: "text-foreground"
|
|
}`}
|
|
>
|
|
{ch.title}
|
|
</span>
|
|
{isActive && (
|
|
<span className="ml-auto shrink-0 text-[10px] uppercase tracking-wider font-bold text-accent">
|
|
Current
|
|
</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|