yiekheng 26b620de2f Add reading-progress resume + multi-genre support
Reading progress
- New ReadingProgressButton client component that reads/writes
  localStorage key "sunnymh:last-read:{slug}"
- PageReader writes the currently-visible chapter as the user scrolls
  through continuous chapters
- Manga detail CTA now reads: "开始阅读" on first visit, or
  "继续阅读 · #{N} {title}" when a prior chapter is stored (with
  spacing and truncation for long titles)

Multiple genres
- lib/genres.ts with parseGenres() and collectGenres() helpers to
  split "冒险, 恋爱, 魔幻" into a list and aggregate across a collection
- Manga detail renders one pill per genre
- GenreTabs filters via parseGenres(...).includes(activeGenre) so a
  multi-genre manga appears under each of its genres
- Home / Genre pages compute the tab list from collectGenres(signedManga)
- Card captions (GenreTabs + TrendingCarousel) show "冒险 · 恋爱 · 魔幻"
  with truncation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:27:28 +08:00

440 lines
14 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { writeLastReadChapter } 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 hiddenByScrollRef = useRef(false);
const fetchChapterIdxRef = useRef(
chapters.findIndex((c) => c.number === startChapterNumber)
);
const offsetRef = useRef(0);
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);
}
let current = startChapterNumber;
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;
}
setCurrentChapterNum(current);
};
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 reading progress whenever the visible chapter changes
useEffect(() => {
writeLastReadChapter(mangaSlug, currentChapterNum);
}, [mangaSlug, currentChapterNum]);
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>
);
}