"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"; import { LoadingLogo } from "@/components/LoadingLogo"; import { calcScrollRatio, scrollOffsetFromRatio, } from "@/lib/scroll-ratio"; type ChapterMeta = { id: string; number: number; title: string; totalPages: number; }; type PageMeta = { number: number; width: number; height: number }; type PageReaderProps = { mangaSlug: string; mangaTitle: string; startChapterNumber: number; chapters: ChapterMeta[]; initialChapterMeta: PageMeta[]; resume: boolean; }; const PREFETCH_NEXT_AT = 3; const IMAGE_BATCH_RADIUS = 3; const DOUBLE_TAP_MS = 280; const KEEP_PREV_CHAPTER_PAGES = 5; const pageKey = (chNum: number, pNum: number) => `${chNum}-${pNum}`; type IntersectingPage = { chNum: number; pNum: number; el: HTMLDivElement; }; export function PageReader({ mangaSlug, mangaTitle, startChapterNumber, chapters, initialChapterMeta, resume, }: PageReaderProps) { const [showUI, setShowUI] = useState(true); const [showDrawer, setShowDrawer] = useState(false); const [chapterMetas, setChapterMetas] = useState>({ [startChapterNumber]: initialChapterMeta, }); const [images, setImages] = useState>({}); const [visibleKeys, setVisibleKeys] = useState>(new Set()); const [currentChapterNum, setCurrentChapterNum] = useState(startChapterNumber); const [currentPageNum, setCurrentPageNum] = useState(() => { if (typeof window === "undefined" || !resume) return 1; const p = readProgress(mangaSlug); if (p && p.chapter === startChapterNumber && p.page > 1) return p.page; return 1; }); const currentRatioRef = useRef(0); 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 forceInflightRef = useRef>(new Set()); const radiusAbortRef = useRef>(new Map()); const pageElRef = useRef>(new Map()); const observerRef = useRef(null); const viewportObserverRef = useRef(null); const hiddenByScrollRef = useRef(false); const drawerScrollRef = useRef(null); const drawerActiveRef = useRef(null); const intersectingPagesRef = useRef>(new Map()); const visibleKeysRef = useRef>(new Set()); 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]; // One controller per live chapter — every batch for this chapter // reuses the signal so chapter-unmount aborts them all in one shot. let controller = radiusAbortRef.current.get(chapterNum); if (!controller) { controller = new AbortController(); radiusAbortRef.current.set(chapterNum, controller); } try { const res = await fetch( `/api/pages?chapter=${chapter.id}&offset=${minP - 1}&limit=${ maxP - minP + 1 }`, { signal: controller.signal } ); if (!res.ok) return; const batch: { number: number; imageUrl: string }[] = await res.json(); if (!Array.isArray(batch)) return; setImages((prev) => { const next = { ...prev }; for (const item of batch) { next[pageKey(chapterNum, item.number)] = item.imageUrl; } return next; }); } catch { // aborted or failed — observer will re-trigger on next intersection } finally { for (const p of toFetch) imagesInflightRef.current.delete(pageKey(chapterNum, p)); } }, [chapterByNumber] ); // Tracked separately from imagesInflightRef so rapid taps dedup against // each other but don't block on a slow radius fetch already in flight. const forceFetchPage = useCallback( async (chapterNum: number, pageNum: number) => { const chapter = chapterByNumber.get(chapterNum); if (!chapter) return; const key = pageKey(chapterNum, pageNum); if (forceInflightRef.current.has(key)) return; forceInflightRef.current.add(key); try { const res = await fetch( `/api/pages?chapter=${chapter.id}&offset=${pageNum - 1}&limit=1` ); if (!res.ok) return; const batch: { number: number; imageUrl: string }[] = await res.json(); if (!Array.isArray(batch) || batch.length === 0) return; setImages((prev) => ({ ...prev, [pageKey(chapterNum, batch[0].number)]: batch[0].imageUrl, })); } catch { // user can tap again } finally { forceInflightRef.current.delete(key); } }, [chapterByNumber] ); 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`); if (!res.ok) return; const meta: PageMeta[] = await res.json(); if (!Array.isArray(meta)) return; 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" } ); viewportObserverRef.current = new IntersectionObserver( (entries) => { let changed = false; 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) { if (!visibleKeysRef.current.has(key)) { visibleKeysRef.current.add(key); changed = true; } if ( !imagesRef.current[key] && !imagesInflightRef.current.has(key) ) { forceFetchPage(chNum, pNum); } } else if (visibleKeysRef.current.delete(key)) { changed = true; } } if (changed) setVisibleKeys(new Set(visibleKeysRef.current)); }, { rootMargin: "0px" } ); for (const el of pageElRef.current.values()) { observerRef.current.observe(el); viewportObserverRef.current.observe(el); } return () => { observerRef.current?.disconnect(); viewportObserverRef.current?.disconnect(); }; }, [ fetchImagesAround, forceFetchPage, prefetchNextChapterMeta, chapterByNumber, ]); const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => { const observer = observerRef.current; const viewportObserver = viewportObserverRef.current; const prev = pageElRef.current.get(key); if (prev) { observer?.unobserve(prev); viewportObserver?.unobserve(prev); } if (el) { pageElRef.current.set(key, el); observer?.observe(el); viewportObserver?.observe(el); } else { pageElRef.current.delete(key); } }, []); // All reader Links use scroll={false} to preserve scroll during in-reader // nav (natural scroll between chapters updates URL without remount). On // a fresh mount we must actively position the scroll: resume-to-saved // if ?resume=1 AND the saved chapter matches; otherwise top. const resumeDoneRef = useRef(false); useLayoutEffect(() => { if (resumeDoneRef.current) return; resumeDoneRef.current = true; const instantTop = (top: number) => window.scrollTo({ top, behavior: "instant" as ScrollBehavior }); if (!resume) { instantTop(0); return; } const p = readProgress(mangaSlug); if (!p || p.chapter !== startChapterNumber) { instantTop(0); return; } if (p.page <= 1 && p.ratio <= 0) { instantTop(0); return; } const scrollToResume = () => { const el = pageElRef.current.get(pageKey(startChapterNumber, p.page)); if (!el) return; instantTop( scrollOffsetFromRatio(el.offsetTop, el.offsetHeight, p.ratio) ); }; scrollToResume(); requestAnimationFrame(scrollToResume); }, [mangaSlug, startChapterNumber, resume]); useEffect(() => { let rafId = 0; const tick = () => { rafId = 0; const y = window.scrollY; if (!hiddenByScrollRef.current && y > 50) { hiddenByScrollRef.current = true; setShowUI(false); } // Pick the topmost page whose top edge is above y+80 (top edge of the // content below the sticky header); walking the small intersecting set. let bestCh = 0; let bestPg = 0; let bestTop = -1; let bestEl: HTMLDivElement | null = null; 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; bestEl = el; } } if (!bestEl) return; currentRatioRef.current = calcScrollRatio( y, bestTop, bestEl.offsetHeight ); setCurrentChapterNum(bestCh); 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); }; }, []); useEffect(() => { writeProgress(mangaSlug, { chapter: currentChapterNum, page: currentPageNum, ratio: currentRatioRef.current, }); }, [mangaSlug, currentChapterNum, currentPageNum]); // Aspect-ratio placeholders stay so layout is preserved; observer // re-fetches images on scrollback into an unmounted chapter. useEffect(() => { const keep = new Set([currentChapterNum]); if (currentPageNum <= KEEP_PREV_CHAPTER_PAGES) { keep.add(currentChapterNum - 1); } for (const [ch, ctrl] of radiusAbortRef.current) { if (!keep.has(ch)) { ctrl.abort(); radiusAbortRef.current.delete(ch); } } setImages((prev) => { let changed = false; const next: Record = {}; for (const [k, v] of Object.entries(prev)) { const ch = Number(k.split("-")[0]); if (keep.has(ch)) { next[k] = v; } else { changed = true; } } return changed ? next : prev; }); }, [currentChapterNum, currentPageNum]); useEffect(() => { const url = `/manga/${mangaSlug}/${currentChapterNum}`; if (window.location.pathname === url) return; window.history.replaceState(window.history.state, "", url); }, [mangaSlug, currentChapterNum]); const { prevChapter, nextChapter } = useMemo(() => { const idx = chapters.findIndex((c) => c.number === currentChapterNum); return { prevChapter: idx > 0 ? chapters[idx - 1].number : null, nextChapter: idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1].number : null, }; }, [chapters, currentChapterNum]); 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 isVisible = visibleKeys.has(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 ) : ( forceFetchPage(chNum, p.number)} /> )}
); })}
); })}
{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 )} ); })}
)}
); }