"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([]); const hiddenByScrollRef = useRef(false); const offsetRef = useRef(0); const doneRef = useRef(false); const loadingRef = useRef(false); const triggerIndicesRef = useRef>(new Set()); const observerRef = useRef(null); const pageRefsRef = useRef>(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); } }, [] ); // Distinguish tap from scroll on touch devices const touchMovedRef = useRef(false); const onTouchStart = useCallback(() => { touchMovedRef.current = false; }, []); const onTouchMove = useCallback(() => { touchMovedRef.current = true; }, []); const onTap = useCallback(() => { if (touchMovedRef.current) return; setShowUI((v) => !v); }, []); // Hide nav on first scroll down; after that, only tap toggles useEffect(() => { const handleScroll = () => { if (!hiddenByScrollRef.current && window.scrollY > 50) { hiddenByScrollRef.current = true; setShowUI(false); window.removeEventListener("scroll", handleScroll); } }; window.addEventListener("scroll", handleScroll, { passive: true }); return () => window.removeEventListener("scroll", handleScroll); }, []); return (
{/* Top bar */}

{mangaTitle}

Ch. {chapterNumber} — {chapterTitle}

{/* Pages - vertical scroll (webtoon style, best for mobile) */}
e.preventDefault()} > {pages.map((page, i) => (
setPageRef(i, el)} > {`Page
))}
{/* Bottom navigation */}
{prevChapter ? ( Prev ) : (
)} {nextChapter ? ( Next ) : ( Done )}
{/* Chapter drawer overlay */} {showDrawer && (
setShowDrawer(false)} > {/* Backdrop */}
{/* Bottom sheet */}
e.stopPropagation()} > {/* Handle + header */}
Chapters
{/* Chapter list */}
{chapters.map((ch) => ( setShowDrawer(false)} > Ch. {ch.number} — {ch.title} ))}
)}
); }