"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"; type ChapterMeta = { id: number; number: number; title: string; totalPages: number; }; type PageMeta = { number: number; width: number; height: number }; type PageReaderProps = { mangaSlug: string; mangaTitle: string; startChapterNumber: number; prevChapter: number | null; nextChapter: number | null; chapters: ChapterMeta[]; initialChapterMeta: PageMeta[]; }; const PREFETCH_NEXT_AT = 3; const IMAGE_BATCH_RADIUS = 3; const DOUBLE_TAP_MS = 280; const pageKey = (chNum: number, pNum: number) => `${chNum}-${pNum}`; type IntersectingPage = { chNum: number; pNum: number; el: HTMLDivElement; }; export function PageReader({ mangaSlug, mangaTitle, startChapterNumber, prevChapter, nextChapter, chapters, initialChapterMeta, }: PageReaderProps) { const [showUI, setShowUI] = useState(true); const [showDrawer, setShowDrawer] = useState(false); const [chapterMetas, setChapterMetas] = useState>({ [startChapterNumber]: initialChapterMeta, }); const [images, setImages] = useState>({}); const [currentChapterNum, setCurrentChapterNum] = useState(startChapterNumber); const [currentPageNum, setCurrentPageNum] = useState(() => { if (typeof window === "undefined") return 1; const p = readProgress(mangaSlug); if (p && p.chapter === startChapterNumber && p.page > 1) return p.page; return 1; }); // Observer stays stable across state updates. const imagesRef = useRef(images); const chapterMetasRef = useRef(chapterMetas); useEffect(() => { imagesRef.current = images; }, [images]); useEffect(() => { chapterMetasRef.current = chapterMetas; }, [chapterMetas]); const metaInflightRef = useRef>(new Set()); const imagesInflightRef = useRef>(new Set()); const pageElRef = useRef>(new Map()); const observerRef = useRef(null); const hiddenByScrollRef = useRef(false); const drawerScrollRef = useRef(null); const drawerActiveRef = useRef(null); // Pages currently inside the observer's viewport margin. The scroll tick // walks this small set instead of every loaded page. const intersectingPagesRef = useRef>(new Map()); 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(); 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]; try { const res = await fetch( `/api/pages?chapterId=${chapter.id}&offset=${minP - 1}&limit=${ maxP - minP + 1 }` ); const batch: { number: number; imageUrl: string }[] = await res.json(); setImages((prev) => { const next = { ...prev }; for (const item of batch) { next[pageKey(chapterNum, item.number)] = item.imageUrl; } return next; }); } catch { // observer will re-trigger on next intersection } finally { for (const p of toFetch) imagesInflightRef.current.delete(pageKey(chapterNum, p)); } }, [chapters] ); 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`); const meta: PageMeta[] = await res.json(); 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" } ); for (const el of pageElRef.current.values()) { observerRef.current.observe(el); } return () => observerRef.current?.disconnect(); }, [fetchImagesAround, prefetchNextChapterMeta, chapterByNumber]); const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => { const observer = observerRef.current; const prev = pageElRef.current.get(key); if (prev && observer) observer.unobserve(prev); if (el) { pageElRef.current.set(key, el); if (observer) observer.observe(el); } else { pageElRef.current.delete(key); } }, []); // Sync scroll + rAF re-scroll: defends against browser scroll-restoration // on hard reload (the sync pass handles soft nav where Link scroll={false}). const resumeDoneRef = useRef(false); useLayoutEffect(() => { if (resumeDoneRef.current) return; resumeDoneRef.current = true; const p = readProgress(mangaSlug); if (!p || p.chapter !== startChapterNumber || p.page <= 1) return; const scrollToResume = () => { const el = pageElRef.current.get(pageKey(startChapterNumber, p.page)); if (!el) return; window.scrollTo({ top: el.offsetTop, behavior: "instant" as ScrollBehavior, }); }; scrollToResume(); requestAnimationFrame(scrollToResume); }, [mangaSlug, startChapterNumber]); useEffect(() => { let rafId = 0; const tick = () => { rafId = 0; const y = window.scrollY; if (!hiddenByScrollRef.current && y > 50) { hiddenByScrollRef.current = true; setShowUI(false); } // Walk only the pages currently inside the 1200px viewport margin // (maintained by the observer) and pick the one with the greatest // offsetTop still above y+80 — that's the topmost visible page. let bestCh = currentChapterNum; let bestPg = currentPageNum; let bestTop = -1; 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; } } if (bestCh !== currentChapterNum) setCurrentChapterNum(bestCh); if (bestPg !== currentPageNum) 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); }; }, [currentChapterNum, currentPageNum]); useEffect(() => { writeProgress(mangaSlug, { chapter: currentChapterNum, page: currentPageNum, }); }, [mangaSlug, currentChapterNum, currentPageNum]); const router = useRouter(); const touchMovedRef = useRef(false); const singleTapTimerRef = useRef | 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 (

{mangaTitle}

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

e.preventDefault()} > {loadedChapterNumbers.map((chNum, idx) => { const meta = chapterMetas[chNum]; const chapter = chapters.find((c) => c.number === chNum); return ( {idx > 0 && (

Chapter {chNum}

{chapter?.title}

)} {meta.map((p) => { const key = pageKey(chNum, p.number); const url = images[key]; const aspect = p.width > 0 && p.height > 0 ? `${p.width} / ${p.height}` : "3 / 4"; return (
setPageRef(key, el)} data-chapter={chNum} data-page={p.number} className="relative leading-[0] w-full" style={{ aspectRatio: aspect }} > {url && ( {`Page )}
); })}
); })}
{atEnd && (

End of Manga

{mangaTitle}

Back to Home
)} {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 )} ); })}
)}
); }