"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([]); 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>(new Set()); const observerRef = useRef(null); const pageRefsRef = useRef>(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 | 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 (

{mangaTitle}

Ch. {currentChapter?.number} — {currentChapter?.title}

e.preventDefault()} > {pages.map((page, i) => { const isChapterStart = i === 0 || pages[i - 1].chapterNumber !== page.chapterNumber; return (
setPageRef(i, el)} > {isChapterStart && i > 0 && (

Chapter {page.chapterNumber}

{ chapters.find((c) => c.number === page.chapterNumber) ?.title }

)} {`Page
); })}
{doneRef.current && pages.length > 0 && (

End of Manga

{mangaTitle}

Back to Home
)} {/* Chapter drawer overlay (modal — fixed is necessary to cover viewport) */} {showDrawer && (
setShowDrawer(false)} >
e.stopPropagation()} >
Chapters {chapters.length} total
{chapters.map((ch) => { const isActive = ch.number === currentChapterNum; return ( setShowDrawer(false)} > #{ch.number} {ch.title} {isActive && ( Current )} ); })}
)}
); }