yiekheng b993de43bc Reader: fix resume bug, add loading skeleton, scraping protection, bounded image cache
- Resume scroll position only when arriving via 继续阅读 (?resume=1).
  Plain chapter-list / drawer clicks now actively scroll to top on mount.
- Progress format extended to {chapter, page, ratio} for within-page
  precision; legacy bare-number and {chapter, page} still read correctly.
- Tappable skeleton logo (sunflower outline, spins) while a page loads;
  tap force-fetches a fresh signed URL.
- Viewport-priority image loading: second IntersectionObserver at margin 0
  marks truly-visible pages, drives <img fetchpriority="high"> and fires
  immediate single-page fetches that cut the batch queue.
- Bounded image cache: unmount previous chapter's <img> elements when
  currentPage > 5 into the new chapter; placeholders stay for layout.
  One AbortController per live chapter; unmount aborts in-flight batches.
- Hashed chapter IDs on the wire via hashids; DB PKs unchanged.
- Origin/Referer allowlist + rate limiting on all /api/* routes via a
  withGuards(opts, handler) wrapper (eliminates 6-line boilerplate x5).
- robots.txt allows Googlebot/Bingbot/Slurp/DuckDuckBot/Baiduspider/
  YandexBot only; disallows /api/ for all UAs.
- Extract pure helpers for future TDD: lib/scroll-ratio.ts (calcScrollRatio,
  scrollOffsetFromRatio), lib/progress.ts (parseProgress + injectable
  StorageLike), lib/rate-limit.ts (optional { now, store, ipOf } deps),
  lib/api-guards.ts.
- New env keys: HASHIDS_SALT, ALLOWED_ORIGINS (wired into docker-compose).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:48:15 +08:00

711 lines
23 KiB
TypeScript

"use client";
import {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
readProgress,
writeProgress,
} from "@/components/ReadingProgressButton";
import { LoadingLogo } from "@/components/LoadingLogo";
import {
calcScrollRatio,
scrollOffsetFromRatio,
} from "@/lib/scroll-ratio";
type ChapterMeta = {
id: string;
number: number;
title: string;
totalPages: number;
};
type PageMeta = { number: number; width: number; height: number };
type PageReaderProps = {
mangaSlug: string;
mangaTitle: string;
startChapterNumber: number;
chapters: ChapterMeta[];
initialChapterMeta: PageMeta[];
resume: boolean;
};
const PREFETCH_NEXT_AT = 3;
const IMAGE_BATCH_RADIUS = 3;
const DOUBLE_TAP_MS = 280;
const KEEP_PREV_CHAPTER_PAGES = 5;
const pageKey = (chNum: number, pNum: number) => `${chNum}-${pNum}`;
type IntersectingPage = {
chNum: number;
pNum: number;
el: HTMLDivElement;
};
export function PageReader({
mangaSlug,
mangaTitle,
startChapterNumber,
chapters,
initialChapterMeta,
resume,
}: PageReaderProps) {
const [showUI, setShowUI] = useState(true);
const [showDrawer, setShowDrawer] = useState(false);
const [chapterMetas, setChapterMetas] = useState<Record<number, PageMeta[]>>({
[startChapterNumber]: initialChapterMeta,
});
const [images, setImages] = useState<Record<string, string>>({});
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
const [currentChapterNum, setCurrentChapterNum] =
useState(startChapterNumber);
const [currentPageNum, setCurrentPageNum] = useState(() => {
if (typeof window === "undefined" || !resume) return 1;
const p = readProgress(mangaSlug);
if (p && p.chapter === startChapterNumber && p.page > 1) return p.page;
return 1;
});
const currentRatioRef = useRef(0);
const imagesRef = useRef(images);
const chapterMetasRef = useRef(chapterMetas);
useEffect(() => {
imagesRef.current = images;
}, [images]);
useEffect(() => {
chapterMetasRef.current = chapterMetas;
}, [chapterMetas]);
const metaInflightRef = useRef<Set<number>>(new Set());
const imagesInflightRef = useRef<Set<string>>(new Set());
const forceInflightRef = useRef<Set<string>>(new Set());
const radiusAbortRef = useRef<Map<number, AbortController>>(new Map());
const pageElRef = useRef<Map<string, HTMLDivElement>>(new Map());
const observerRef = useRef<IntersectionObserver | null>(null);
const viewportObserverRef = useRef<IntersectionObserver | null>(null);
const hiddenByScrollRef = useRef(false);
const drawerScrollRef = useRef<HTMLDivElement | null>(null);
const drawerActiveRef = useRef<HTMLAnchorElement | null>(null);
const intersectingPagesRef = useRef<Map<string, IntersectingPage>>(new Map());
const visibleKeysRef = useRef<Set<string>>(new Set());
const loadedChapterNumbers = useMemo(() => {
return Object.keys(chapterMetas)
.map(Number)
.filter((n) => n >= startChapterNumber)
.sort((a, b) => a - b);
}, [chapterMetas, startChapterNumber]);
const chapterByNumber = useMemo(() => {
const m = new Map<number, ChapterMeta>();
for (const c of chapters) m.set(c.number, c);
return m;
}, [chapters]);
const fetchImagesAround = useCallback(
async (chapterNum: number, pageNum: number) => {
const meta = chapterMetasRef.current[chapterNum];
const chapter = chapterByNumber.get(chapterNum);
if (!meta || !chapter) return;
const start = Math.max(1, pageNum - IMAGE_BATCH_RADIUS);
const end = Math.min(meta.length, pageNum + IMAGE_BATCH_RADIUS);
const toFetch: number[] = [];
for (let p = start; p <= end; p++) {
const k = pageKey(chapterNum, p);
if (imagesRef.current[k] || imagesInflightRef.current.has(k)) continue;
imagesInflightRef.current.add(k);
toFetch.push(p);
}
if (toFetch.length === 0) return;
const minP = toFetch[0];
const maxP = toFetch[toFetch.length - 1];
// One controller per live chapter — every batch for this chapter
// reuses the signal so chapter-unmount aborts them all in one shot.
let controller = radiusAbortRef.current.get(chapterNum);
if (!controller) {
controller = new AbortController();
radiusAbortRef.current.set(chapterNum, controller);
}
try {
const res = await fetch(
`/api/pages?chapter=${chapter.id}&offset=${minP - 1}&limit=${
maxP - minP + 1
}`,
{ signal: controller.signal }
);
if (!res.ok) return;
const batch: { number: number; imageUrl: string }[] = await res.json();
if (!Array.isArray(batch)) return;
setImages((prev) => {
const next = { ...prev };
for (const item of batch) {
next[pageKey(chapterNum, item.number)] = item.imageUrl;
}
return next;
});
} catch {
// aborted or failed — observer will re-trigger on next intersection
} finally {
for (const p of toFetch)
imagesInflightRef.current.delete(pageKey(chapterNum, p));
}
},
[chapterByNumber]
);
// Tracked separately from imagesInflightRef so rapid taps dedup against
// each other but don't block on a slow radius fetch already in flight.
const forceFetchPage = useCallback(
async (chapterNum: number, pageNum: number) => {
const chapter = chapterByNumber.get(chapterNum);
if (!chapter) return;
const key = pageKey(chapterNum, pageNum);
if (forceInflightRef.current.has(key)) return;
forceInflightRef.current.add(key);
try {
const res = await fetch(
`/api/pages?chapter=${chapter.id}&offset=${pageNum - 1}&limit=1`
);
if (!res.ok) return;
const batch: { number: number; imageUrl: string }[] = await res.json();
if (!Array.isArray(batch) || batch.length === 0) return;
setImages((prev) => ({
...prev,
[pageKey(chapterNum, batch[0].number)]: batch[0].imageUrl,
}));
} catch {
// user can tap again
} finally {
forceInflightRef.current.delete(key);
}
},
[chapterByNumber]
);
const prefetchNextChapterMeta = useCallback(
async (currentChapterNumArg: number) => {
const idx = chapters.findIndex((c) => c.number === currentChapterNumArg);
if (idx < 0 || idx >= chapters.length - 1) return;
const next = chapters[idx + 1];
if (chapterMetasRef.current[next.number]) return;
if (metaInflightRef.current.has(next.number)) return;
metaInflightRef.current.add(next.number);
try {
const res = await fetch(`/api/chapters/${next.id}/meta`);
if (!res.ok) return;
const meta: PageMeta[] = await res.json();
if (!Array.isArray(meta)) return;
setChapterMetas((prev) => ({ ...prev, [next.number]: meta }));
} catch {
// will retry next observer fire
} finally {
metaInflightRef.current.delete(next.number);
}
},
[chapters]
);
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
for (const e of entries) {
const el = e.target as HTMLDivElement;
const chNum = Number(el.dataset.chapter);
const pNum = Number(el.dataset.page);
if (!chNum || !pNum) continue;
const key = pageKey(chNum, pNum);
if (e.isIntersecting) {
intersectingPagesRef.current.set(key, { chNum, pNum, el });
fetchImagesAround(chNum, pNum);
const chapter = chapterByNumber.get(chNum);
if (chapter && pNum >= chapter.totalPages - PREFETCH_NEXT_AT) {
prefetchNextChapterMeta(chNum);
}
} else {
intersectingPagesRef.current.delete(key);
}
}
},
{ rootMargin: "1200px" }
);
viewportObserverRef.current = new IntersectionObserver(
(entries) => {
let changed = false;
for (const e of entries) {
const el = e.target as HTMLDivElement;
const chNum = Number(el.dataset.chapter);
const pNum = Number(el.dataset.page);
if (!chNum || !pNum) continue;
const key = pageKey(chNum, pNum);
if (e.isIntersecting) {
if (!visibleKeysRef.current.has(key)) {
visibleKeysRef.current.add(key);
changed = true;
}
if (
!imagesRef.current[key] &&
!imagesInflightRef.current.has(key)
) {
forceFetchPage(chNum, pNum);
}
} else if (visibleKeysRef.current.delete(key)) {
changed = true;
}
}
if (changed) setVisibleKeys(new Set(visibleKeysRef.current));
},
{ rootMargin: "0px" }
);
for (const el of pageElRef.current.values()) {
observerRef.current.observe(el);
viewportObserverRef.current.observe(el);
}
return () => {
observerRef.current?.disconnect();
viewportObserverRef.current?.disconnect();
};
}, [
fetchImagesAround,
forceFetchPage,
prefetchNextChapterMeta,
chapterByNumber,
]);
const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => {
const observer = observerRef.current;
const viewportObserver = viewportObserverRef.current;
const prev = pageElRef.current.get(key);
if (prev) {
observer?.unobserve(prev);
viewportObserver?.unobserve(prev);
}
if (el) {
pageElRef.current.set(key, el);
observer?.observe(el);
viewportObserver?.observe(el);
} else {
pageElRef.current.delete(key);
}
}, []);
// All reader Links use scroll={false} to preserve scroll during in-reader
// nav (natural scroll between chapters updates URL without remount). On
// a fresh mount we must actively position the scroll: resume-to-saved
// if ?resume=1 AND the saved chapter matches; otherwise top.
const resumeDoneRef = useRef(false);
useLayoutEffect(() => {
if (resumeDoneRef.current) return;
resumeDoneRef.current = true;
const instantTop = (top: number) =>
window.scrollTo({ top, behavior: "instant" as ScrollBehavior });
if (!resume) {
instantTop(0);
return;
}
const p = readProgress(mangaSlug);
if (!p || p.chapter !== startChapterNumber) {
instantTop(0);
return;
}
if (p.page <= 1 && p.ratio <= 0) {
instantTop(0);
return;
}
const scrollToResume = () => {
const el = pageElRef.current.get(pageKey(startChapterNumber, p.page));
if (!el) return;
instantTop(
scrollOffsetFromRatio(el.offsetTop, el.offsetHeight, p.ratio)
);
};
scrollToResume();
requestAnimationFrame(scrollToResume);
}, [mangaSlug, startChapterNumber, resume]);
useEffect(() => {
let rafId = 0;
const tick = () => {
rafId = 0;
const y = window.scrollY;
if (!hiddenByScrollRef.current && y > 50) {
hiddenByScrollRef.current = true;
setShowUI(false);
}
// Pick the topmost page whose top edge is above y+80 (top edge of the
// content below the sticky header); walking the small intersecting set.
let bestCh = 0;
let bestPg = 0;
let bestTop = -1;
let bestEl: HTMLDivElement | null = null;
for (const { chNum, pNum, el } of intersectingPagesRef.current.values()) {
const top = el.offsetTop;
if (top <= y + 80 && top > bestTop) {
bestTop = top;
bestCh = chNum;
bestPg = pNum;
bestEl = el;
}
}
if (!bestEl) return;
currentRatioRef.current = calcScrollRatio(
y,
bestTop,
bestEl.offsetHeight
);
setCurrentChapterNum(bestCh);
setCurrentPageNum(bestPg);
};
const onScroll = () => {
if (rafId) return;
rafId = requestAnimationFrame(tick);
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
useEffect(() => {
writeProgress(mangaSlug, {
chapter: currentChapterNum,
page: currentPageNum,
ratio: currentRatioRef.current,
});
}, [mangaSlug, currentChapterNum, currentPageNum]);
// Aspect-ratio placeholders stay so layout is preserved; observer
// re-fetches images on scrollback into an unmounted chapter.
useEffect(() => {
const keep = new Set<number>([currentChapterNum]);
if (currentPageNum <= KEEP_PREV_CHAPTER_PAGES) {
keep.add(currentChapterNum - 1);
}
for (const [ch, ctrl] of radiusAbortRef.current) {
if (!keep.has(ch)) {
ctrl.abort();
radiusAbortRef.current.delete(ch);
}
}
setImages((prev) => {
let changed = false;
const next: Record<string, string> = {};
for (const [k, v] of Object.entries(prev)) {
const ch = Number(k.split("-")[0]);
if (keep.has(ch)) {
next[k] = v;
} else {
changed = true;
}
}
return changed ? next : prev;
});
}, [currentChapterNum, currentPageNum]);
useEffect(() => {
const url = `/manga/${mangaSlug}/${currentChapterNum}`;
if (window.location.pathname === url) return;
window.history.replaceState(window.history.state, "", url);
}, [mangaSlug, currentChapterNum]);
const { prevChapter, nextChapter } = useMemo(() => {
const idx = chapters.findIndex((c) => c.number === currentChapterNum);
return {
prevChapter: idx > 0 ? chapters[idx - 1].number : null,
nextChapter:
idx >= 0 && idx < chapters.length - 1
? chapters[idx + 1].number
: null,
};
}, [chapters, currentChapterNum]);
const router = useRouter();
const touchMovedRef = useRef(false);
const singleTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastTapAtRef = useRef(0);
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) {
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}`, {
scroll: false,
});
} else {
if (prevChapter)
router.push(`/manga/${mangaSlug}/${prevChapter}`, {
scroll: false,
});
}
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);
},
[]
);
useLayoutEffect(() => {
if (!showDrawer) return;
const scroll = drawerScrollRef.current;
const active = drawerActiveRef.current;
if (!scroll || !active) return;
const scrollRect = scroll.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
const delta =
activeRect.top -
scrollRect.top -
scroll.clientHeight / 2 +
active.clientHeight / 2;
scroll.scrollTop = Math.max(0, scroll.scrollTop + delta);
}, [showDrawer]);
const currentChapter =
chapters.find((c) => c.number === currentChapterNum) ??
chapters.find((c) => c.number === startChapterNumber);
const lastChapter = chapters[chapters.length - 1];
const atEnd =
currentChapterNum === lastChapter?.number &&
currentPageNum >= (chapterMetas[currentChapterNum]?.length ?? 0);
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}`}
scroll={false}
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()}
>
{loadedChapterNumbers.map((chNum, idx) => {
const meta = chapterMetas[chNum];
const chapter = chapters.find((c) => c.number === chNum);
return (
<Fragment key={chNum}>
{idx > 0 && (
<div className="bg-surface py-4 text-center leading-normal">
<p className="text-xs uppercase tracking-wider text-muted">
Chapter {chNum}
</p>
<p className="text-sm font-semibold text-foreground">
{chapter?.title}
</p>
</div>
)}
{meta.map((p) => {
const key = pageKey(chNum, p.number);
const url = images[key];
const isVisible = visibleKeys.has(key);
const aspect =
p.width > 0 && p.height > 0
? `${p.width} / ${p.height}`
: "3 / 4";
return (
<div
key={key}
ref={(el) => setPageRef(key, el)}
data-chapter={chNum}
data-page={p.number}
className="relative leading-[0] w-full"
style={{ aspectRatio: aspect }}
>
{url ? (
<img
src={url}
alt={`Page ${p.number}`}
fetchPriority={isVisible ? "high" : "low"}
className="w-full h-auto block [-webkit-touch-callout:none]"
draggable={false}
/>
) : (
<LoadingLogo
onTap={() => forceFetchPage(chNum, p.number)}
/>
)}
</div>
);
})}
</Fragment>
);
})}
</div>
{atEnd && (
<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="/"
scroll={false}
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>
)}
{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
ref={drawerScrollRef}
className="overflow-y-auto max-h-[calc(75vh-4rem)] pb-safe"
>
{chapters.map((ch) => {
const isActive = ch.number === currentChapterNum;
return (
<Link
key={ch.number}
ref={isActive ? drawerActiveRef : undefined}
href={`/manga/${mangaSlug}/${ch.number}`}
scroll={false}
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>
);
}