diff --git a/app/api/chapters/[chapterId]/meta/route.ts b/app/api/chapters/[chapterId]/meta/route.ts new file mode 100644 index 0000000..2c324ab --- /dev/null +++ b/app/api/chapters/[chapterId]/meta/route.ts @@ -0,0 +1,19 @@ +import { prisma } from "@/lib/db"; + +type Params = { params: Promise<{ chapterId: string }> }; + +export async function GET(_request: Request, { params }: Params) { + const { chapterId: raw } = await params; + const chapterId = parseInt(raw, 10); + if (isNaN(chapterId)) { + return Response.json({ error: "Invalid chapterId" }, { status: 400 }); + } + + const pages = await prisma.page.findMany({ + where: { chapterId }, + orderBy: { number: "asc" }, + select: { number: true, width: true, height: true }, + }); + + return Response.json(pages); +} diff --git a/app/manga/[slug]/[chapter]/page.tsx b/app/manga/[slug]/[chapter]/page.tsx index cd8aefb..ee1ba58 100644 --- a/app/manga/[slug]/[chapter]/page.tsx +++ b/app/manga/[slug]/[chapter]/page.tsx @@ -26,17 +26,24 @@ export default async function ChapterReaderPage({ params }: Props) { const chapterNum = parseInt(chapter, 10); if (isNaN(chapterNum)) notFound(); - const manga = await prisma.manga.findUnique({ - where: { slug }, - include: { - chapters: { - orderBy: { number: "asc" }, - include: { - _count: { select: { pages: true } }, + const [manga, initialChapterMeta] = await Promise.all([ + prisma.manga.findUnique({ + where: { slug }, + include: { + chapters: { + orderBy: { number: "asc" }, + include: { + _count: { select: { pages: true } }, + }, }, }, - }, - }); + }), + prisma.page.findMany({ + where: { chapter: { number: chapterNum, manga: { slug } } }, + orderBy: { number: "asc" }, + select: { number: true, width: true, height: true }, + }), + ]); if (!manga) notFound(); @@ -68,6 +75,7 @@ export default async function ChapterReaderPage({ params }: Props) { prevChapter={prevChapter} nextChapter={nextChapter} chapters={allChapters} + initialChapterMeta={initialChapterMeta} /> ); } diff --git a/components/ChapterList.tsx b/components/ChapterList.tsx index 1853b0d..b2c5fd1 100644 --- a/components/ChapterList.tsx +++ b/components/ChapterList.tsx @@ -25,6 +25,7 @@ export function ChapterList({
diff --git a/components/PageReader.tsx b/components/PageReader.tsx index 9270a2f..aa4c5fd 100644 --- a/components/PageReader.tsx +++ b/components/PageReader.tsx @@ -1,6 +1,14 @@ "use client"; -import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { + Fragment, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { @@ -15,16 +23,7 @@ type ChapterMeta = { totalPages: number; }; -type LoadedPage = { - chapterNumber: number; - pageNumber: number; - imageUrl: string; -}; - -type RawPage = { - number: number; - imageUrl: string; -}; +type PageMeta = { number: number; width: number; height: number }; type PageReaderProps = { mangaSlug: string; @@ -33,10 +32,20 @@ type PageReaderProps = { prevChapter: number | null; nextChapter: number | null; chapters: ChapterMeta[]; + initialChapterMeta: PageMeta[]; }; -const BATCH_SIZE = 7; -const PREFETCH_AT = 3; +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, @@ -45,10 +54,14 @@ export function PageReader({ prevChapter, nextChapter, chapters, + initialChapterMeta, }: PageReaderProps) { const [showUI, setShowUI] = useState(true); const [showDrawer, setShowDrawer] = useState(false); - const [pages, setPages] = useState([]); + const [chapterMetas, setChapterMetas] = useState>({ + [startChapterNumber]: initialChapterMeta, + }); + const [images, setImages] = useState>({}); const [currentChapterNum, setCurrentChapterNum] = useState(startChapterNumber); const [currentPageNum, setCurrentPageNum] = useState(() => { @@ -58,216 +71,209 @@ export function PageReader({ return 1; }); - const hiddenByScrollRef = useRef(false); - const fetchChapterIdxRef = useRef( - chapters.findIndex((c) => c.number === startChapterNumber) - ); - // Initialize offset from saved progress so the first fetch starts AT the - // user's last-read page — previous pages are skipped entirely - const offsetRef = useRef(0); - const initialPageRef = useRef(1); - const offsetInitedRef = useRef(false); - if (!offsetInitedRef.current && typeof window !== "undefined") { - offsetInitedRef.current = true; - const p = readProgress(mangaSlug); - if (p && p.chapter === startChapterNumber && p.page > 1) { - offsetRef.current = p.page - 1; - initialPageRef.current = p.page; - } - } - 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()); + // 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 pageRefsRef = useRef>(new Map()); + const hiddenByScrollRef = useRef(false); + // 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()); - // Reverse-fetch cursor: the offset of the first loaded page of the - // starting chapter. Used to prepend previous pages when the user scrolls - // up from a resumed mid-chapter position. - const prependOffsetRef = useRef(offsetRef.current); - const prependLoadingRef = useRef(false); - const prependExhaustedRef = useRef(offsetRef.current === 0); + const loadedChapterNumbers = useMemo(() => { + return Object.keys(chapterMetas) + .map(Number) + .filter((n) => n >= startChapterNumber) + .sort((a, b) => a - b); + }, [chapterMetas, startChapterNumber]); - const advanceChapterOrFinish = useCallback(() => { - if (fetchChapterIdxRef.current + 1 < chapters.length) { - fetchChapterIdxRef.current += 1; - offsetRef.current = 0; - } else { - doneRef.current = true; - } - }, [chapters.length]); + const chapterByNumber = useMemo(() => { + const m = new Map(); + for (const c of chapters) m.set(c.number, c); + return m; + }, [chapters]); - const startChapter = useMemo( - () => chapters.find((c) => c.number === startChapterNumber), - [chapters, startChapterNumber] + 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 prependBatch = useCallback(async () => { - if ( - prependLoadingRef.current || - prependExhaustedRef.current || - !startChapter - ) - return; - const currentOffset = prependOffsetRef.current; - const newOffset = Math.max(0, currentOffset - BATCH_SIZE); - const limit = currentOffset - newOffset; - if (limit <= 0) { - prependExhaustedRef.current = true; - return; - } - prependLoadingRef.current = true; - const beforeHeight = document.documentElement.scrollHeight; - try { - const res = await fetch( - `/api/pages?chapterId=${startChapter.id}&offset=${newOffset}&limit=${limit}` - ); - const batch: RawPage[] = await res.json(); - if (batch.length === 0) { - prependExhaustedRef.current = true; - return; + 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); } - prependOffsetRef.current = newOffset; - if (newOffset === 0) prependExhaustedRef.current = true; - - // Shift forward-fetch trigger indices + total count since every - // already-loaded page has moved right by batch.length - const shift = batch.length; - const shifted = new Set(); - for (const t of triggerIndicesRef.current) shifted.add(t + shift); - triggerIndicesRef.current = shifted; - loadedCountRef.current += shift; - - setPages((prev) => [ - ...batch.map((p) => ({ - chapterNumber: startChapter.number, - pageNumber: p.number, - imageUrl: p.imageUrl, - })), - ...prev, - ]); - - // aspect-[3/4] on the prepended s reserves height before their - // bytes arrive, so scrollHeight is accurate immediately after React - // commits. One scrollBy keeps the previously-visible page anchored. - requestAnimationFrame(() => { - const afterHeight = document.documentElement.scrollHeight; - const delta = afterHeight - beforeHeight; - if (delta > 0) window.scrollBy({ top: delta }); - }); - } catch { - // ignore — user can scroll-up again to retry - } finally { - prependLoadingRef.current = false; - } - }, [startChapter]); - - 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]); + }, + [chapters] + ); 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(); + 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: "400px" } + { rootMargin: "1200px" } ); + for (const el of pageElRef.current.values()) { + observerRef.current.observe(el); + } return () => observerRef.current?.disconnect(); - }, [fetchBatch]); + }, [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]); - 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); + 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); - // 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; @@ -275,15 +281,12 @@ export function PageReader({ 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; @@ -292,14 +295,17 @@ export function PageReader({ const midX = window.innerWidth / 2; if (e.clientX >= midX) { if (nextChapter) - router.push(`/manga/${mangaSlug}/${nextChapter}`); + router.push(`/manga/${mangaSlug}/${nextChapter}`, { + scroll: false, + }); } else { if (prevChapter) - router.push(`/manga/${mangaSlug}/${prevChapter}`); + router.push(`/manga/${mangaSlug}/${prevChapter}`, { + scroll: false, + }); } return; } - lastTapAtRef.current = now; singleTapTimerRef.current = setTimeout(() => { setShowUI((v) => !v); @@ -316,64 +322,15 @@ export function PageReader({ [] ); - useEffect(() => { - let rafId = 0; - const tick = () => { - rafId = 0; - const y = window.scrollY; - if (!hiddenByScrollRef.current && y > 50) { - hiddenByScrollRef.current = true; - setShowUI(false); - } - // Trigger backward prepend when user approaches the top — fire early - // so the DOM/pics are already spawned by the time the user scrolls there - if ( - y < 2500 && - !prependLoadingRef.current && - !prependExhaustedRef.current - ) { - prependBatch(); - } - // Nothing loaded yet — don't overwrite the resumed-page state - if (pages.length === 0) return; - let chapter = pages[0].chapterNumber; - let page = pages[0].pageNumber; - for (let i = 0; i < pages.length; i++) { - const el = pageRefsRef.current.get(i); - if (!el) continue; - if (el.offsetTop <= y + 80) { - chapter = pages[i].chapterNumber; - page = pages[i].pageNumber; - } else break; - } - setCurrentChapterNum(chapter); - setCurrentPageNum(page); - }; - 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, prependBatch]); - - // Persist progress as user scrolls. offsetRef has been pre-seeded above - // so the first fetched page IS the resumed page — no scroll restoration - // needed. - useEffect(() => { - writeProgress(mangaSlug, { - chapter: currentChapterNum, - page: currentPageNum, - }); - }, [mangaSlug, currentChapterNum, currentPageNum]); - 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 (
e.preventDefault()} > - {pages.map((page, i) => { - const isChapterStart = - i === 0 || pages[i - 1].chapterNumber !== page.chapterNumber; + {loadedChapterNumbers.map((chNum, idx) => { + const meta = chapterMetas[chNum]; + const chapter = chapters.find((c) => c.number === chNum); return ( -
setPageRef(i, el)} - > - {isChapterStart && i > 0 && ( + + {idx > 0 && (

- Chapter {page.chapterNumber} + Chapter {chNum}

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

)} - {`Page -
+ {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 + )} +
+ ); + })} + ); })}
- {doneRef.current && pages.length > 0 && ( + {atEnd && (

End of Manga @@ -481,7 +451,6 @@ export function PageReader({

)} - {/* Chapter drawer overlay (modal — fixed is necessary to cover viewport) */} {showDrawer && (
{resumeChapter ? (