- Sign all image URLs server-side with 60s expiry presigned URLs - Add /api/pages endpoint for batched page fetching (7 per batch) - PageReader prefetches next batch when user scrolls to 3rd page - Move chapter count badge outside overflow-hidden for 3D effect - Fix missing URL signing on search and genre pages - Extract signCoverUrls helper to reduce duplication - Clamp API limit param to prevent abuse Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
304 lines
9.0 KiB
TypeScript
304 lines
9.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import Link from "next/link";
|
|
|
|
type PageData = {
|
|
number: number;
|
|
imageUrl: string;
|
|
};
|
|
|
|
type ChapterInfo = {
|
|
number: number;
|
|
title: string;
|
|
};
|
|
|
|
type PageReaderProps = {
|
|
chapterId: number;
|
|
totalPages: number;
|
|
mangaSlug: string;
|
|
mangaTitle: string;
|
|
chapterNumber: number;
|
|
chapterTitle: string;
|
|
prevChapter: number | null;
|
|
nextChapter: number | null;
|
|
chapters: ChapterInfo[];
|
|
};
|
|
|
|
const BATCH_SIZE = 7;
|
|
const PREFETCH_AT = 3;
|
|
|
|
export function PageReader({
|
|
chapterId,
|
|
totalPages,
|
|
mangaSlug,
|
|
mangaTitle,
|
|
chapterNumber,
|
|
chapterTitle,
|
|
prevChapter,
|
|
nextChapter,
|
|
chapters,
|
|
}: PageReaderProps) {
|
|
const [showUI, setShowUI] = useState(true);
|
|
const [showDrawer, setShowDrawer] = useState(false);
|
|
const [pages, setPages] = useState<PageData[]>([]);
|
|
const lastScrollY = useRef(0);
|
|
const offsetRef = useRef(0);
|
|
const doneRef = useRef(false);
|
|
const loadingRef = useRef(false);
|
|
const triggerIndicesRef = useRef<Set<number>>(new Set());
|
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
const pageRefsRef = useRef<Map<number, HTMLDivElement>>(new Map());
|
|
|
|
const fetchBatch = useCallback(async () => {
|
|
if (loadingRef.current || doneRef.current) return;
|
|
loadingRef.current = true;
|
|
try {
|
|
const res = await fetch(
|
|
`/api/pages?chapterId=${chapterId}&offset=${offsetRef.current}&limit=${BATCH_SIZE}`
|
|
);
|
|
const batch: PageData[] = await res.json();
|
|
if (batch.length === 0) {
|
|
doneRef.current = true;
|
|
} else {
|
|
const triggerIndex = offsetRef.current + PREFETCH_AT - 1;
|
|
triggerIndicesRef.current.add(triggerIndex);
|
|
|
|
// If trigger element is already mounted, observe it now
|
|
const existing = pageRefsRef.current.get(triggerIndex);
|
|
if (existing && observerRef.current) {
|
|
observerRef.current.observe(existing);
|
|
}
|
|
|
|
setPages((prev) => [...prev, ...batch]);
|
|
offsetRef.current += batch.length;
|
|
if (offsetRef.current >= totalPages) {
|
|
doneRef.current = true;
|
|
}
|
|
}
|
|
} catch {
|
|
// retry on next intersection
|
|
} finally {
|
|
loadingRef.current = false;
|
|
}
|
|
}, [chapterId, totalPages]);
|
|
|
|
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: "200px" }
|
|
);
|
|
return () => observerRef.current?.disconnect();
|
|
}, [fetchBatch]);
|
|
|
|
useEffect(() => {
|
|
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);
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const handleScroll = () => {
|
|
const currentY = window.scrollY;
|
|
if (currentY > lastScrollY.current && currentY > 50) {
|
|
setShowUI(false);
|
|
} else if (currentY < lastScrollY.current) {
|
|
setShowUI(true);
|
|
}
|
|
lastScrollY.current = currentY;
|
|
};
|
|
|
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
return () => window.removeEventListener("scroll", handleScroll);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-black relative">
|
|
{/* Top bar */}
|
|
<div
|
|
className={`fixed top-0 left-0 right-0 z-50 bg-black/90 backdrop-blur-sm transition-transform duration-300 ${
|
|
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-white/80 hover:text-white transition-colors"
|
|
>
|
|
<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-white text-sm font-medium truncate">
|
|
{mangaTitle}
|
|
</p>
|
|
<p className="text-white/50 text-xs truncate">
|
|
Ch. {chapterNumber} — {chapterTitle}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pages - vertical scroll (webtoon style, best for mobile) */}
|
|
<div
|
|
className="max-w-4xl mx-auto leading-[0]"
|
|
onClick={() => setShowUI(!showUI)}
|
|
>
|
|
{pages.map((page, i) => (
|
|
<div
|
|
key={page.number}
|
|
className="relative leading-[0]"
|
|
data-page-index={i}
|
|
ref={(el) => setPageRef(i, el)}
|
|
>
|
|
<img
|
|
src={page.imageUrl}
|
|
alt={`Page ${page.number}`}
|
|
className="w-full h-auto block align-bottom -mb-px"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Bottom navigation */}
|
|
<div
|
|
className={`fixed bottom-0 left-0 right-0 z-50 bg-black/90 backdrop-blur-sm transition-transform duration-300 pb-safe ${
|
|
showUI ? "translate-y-0" : "translate-y-full"
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between px-4 h-14 max-w-4xl mx-auto">
|
|
{prevChapter ? (
|
|
<Link
|
|
href={`/manga/${mangaSlug}/${prevChapter}`}
|
|
className="flex items-center gap-1 text-white/80 hover:text-white text-sm transition-colors"
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
className="w-5 h-5"
|
|
>
|
|
<polyline points="15 18 9 12 15 6" />
|
|
</svg>
|
|
Prev
|
|
</Link>
|
|
) : (
|
|
<div />
|
|
)}
|
|
<button
|
|
onClick={() => setShowDrawer(true)}
|
|
className="flex items-center gap-1 text-white/80 hover:text-white text-sm transition-colors"
|
|
>
|
|
{chapterTitle}
|
|
</button>
|
|
{nextChapter ? (
|
|
<Link
|
|
href={`/manga/${mangaSlug}/${nextChapter}`}
|
|
className="flex items-center gap-1 text-white/80 hover:text-white text-sm transition-colors"
|
|
>
|
|
Next
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
className="w-5 h-5"
|
|
>
|
|
<polyline points="9 18 15 12 9 6" />
|
|
</svg>
|
|
</Link>
|
|
) : (
|
|
<Link
|
|
href={`/manga/${mangaSlug}`}
|
|
className="text-accent text-sm font-medium"
|
|
>
|
|
Done
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Chapter drawer overlay */}
|
|
{showDrawer && (
|
|
<div
|
|
className="fixed inset-0 z-[60]"
|
|
onClick={() => setShowDrawer(false)}
|
|
>
|
|
{/* Backdrop */}
|
|
<div className="absolute inset-0 bg-black/60" />
|
|
|
|
{/* Bottom sheet */}
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 max-h-[60vh] bg-zinc-900 rounded-t-2xl overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Handle + header */}
|
|
<div className="sticky top-0 bg-zinc-900 z-10">
|
|
<div className="flex justify-center pt-2 pb-1">
|
|
<div className="w-10 h-1 rounded-full bg-white/20" />
|
|
</div>
|
|
<div className="px-4 pb-2 border-b border-white/10">
|
|
<span className="text-white text-sm font-medium">Chapters</span>
|
|
</div>
|
|
</div>
|
|
{/* Chapter list */}
|
|
<div className="overflow-y-auto max-h-[calc(60vh-3rem)] pb-safe">
|
|
{chapters.map((ch) => (
|
|
<Link
|
|
key={ch.number}
|
|
href={`/manga/${mangaSlug}/${ch.number}`}
|
|
className={`block px-4 py-3 text-sm transition-colors ${
|
|
ch.number === chapterNumber
|
|
? "bg-white/10 text-white font-medium"
|
|
: "text-white/70 hover:bg-white/5 hover:text-white"
|
|
}`}
|
|
onClick={() => setShowDrawer(false)}
|
|
>
|
|
Ch. {ch.number} — {ch.title}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|