Compare commits

..

2 Commits

Author SHA1 Message Date
43a2a6d3f8 Rewrite reader around known-dimension page placeholders
Replaces the prepend/flushSync/scrollBy gymnastics with placeholder divs
sized by each page's width/height. Document height is correct from the
first paint, so resume + backward scroll just work — no scroll
compensation, no gesture fights, no forced aspect ratio distorting images.

- New /api/chapters/[id]/meta returns the dim skeleton for any chapter.
- Chapter page pre-fetches the starting chapter's meta server-side and
  parallelizes the two Prisma queries via Promise.all.
- Reader renders placeholders with aspectRatio: w/h, lazy-loads image
  URLs in batches via IntersectionObserver, and prefetches the next
  chapter's meta ~3 pages from the end.
- Scroll tracker walks only the intersecting-pages set (~3–5 elements)
  instead of every loaded page per rAF.
- scroll={false} on all Links into the reader + { scroll: false } on
  double-tap router.push, plus a belt-and-suspenders rAF re-scroll, so
  resume survives soft navigation and browser scroll-restoration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:01:41 +08:00
0a1365a743 Store page dimensions to reserve layout space upfront
- Add width/height columns to Page (default 0, migration SQL committed).
- Backfill script ranges first 16KB of each R2 object and parses with
  image-size. Probed all 26,209 existing pages successfully.
- Export keyFromPublicUrl from lib/r2 so the script reuses the existing
  URL→key logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:01:27 +08:00
11 changed files with 407 additions and 295 deletions

View File

@ -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);
}

View File

@ -26,17 +26,24 @@ export default async function ChapterReaderPage({ params }: Props) {
const chapterNum = parseInt(chapter, 10); const chapterNum = parseInt(chapter, 10);
if (isNaN(chapterNum)) notFound(); if (isNaN(chapterNum)) notFound();
const manga = await prisma.manga.findUnique({ const [manga, initialChapterMeta] = await Promise.all([
where: { slug }, prisma.manga.findUnique({
include: { where: { slug },
chapters: { include: {
orderBy: { number: "asc" }, chapters: {
include: { orderBy: { number: "asc" },
_count: { select: { pages: true } }, 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(); if (!manga) notFound();
@ -68,6 +75,7 @@ export default async function ChapterReaderPage({ params }: Props) {
prevChapter={prevChapter} prevChapter={prevChapter}
nextChapter={nextChapter} nextChapter={nextChapter}
chapters={allChapters} chapters={allChapters}
initialChapterMeta={initialChapterMeta}
/> />
); );
} }

View File

@ -25,6 +25,7 @@ export function ChapterList({
<Link <Link
key={ch.id} key={ch.id}
href={`/manga/${mangaSlug}/${ch.number}`} href={`/manga/${mangaSlug}/${ch.number}`}
scroll={false}
className="flex items-center justify-between px-4 py-3 bg-surface rounded-xl hover:bg-surface-hover active:scale-[0.98] transition-all" className="flex items-center justify-between px-4 py-3 bg-surface rounded-xl hover:bg-surface-hover active:scale-[0.98] transition-all"
> >
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">

View File

@ -1,6 +1,14 @@
"use client"; "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 Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
@ -15,16 +23,7 @@ type ChapterMeta = {
totalPages: number; totalPages: number;
}; };
type LoadedPage = { type PageMeta = { number: number; width: number; height: number };
chapterNumber: number;
pageNumber: number;
imageUrl: string;
};
type RawPage = {
number: number;
imageUrl: string;
};
type PageReaderProps = { type PageReaderProps = {
mangaSlug: string; mangaSlug: string;
@ -33,10 +32,20 @@ type PageReaderProps = {
prevChapter: number | null; prevChapter: number | null;
nextChapter: number | null; nextChapter: number | null;
chapters: ChapterMeta[]; chapters: ChapterMeta[];
initialChapterMeta: PageMeta[];
}; };
const BATCH_SIZE = 7; const PREFETCH_NEXT_AT = 3;
const PREFETCH_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({ export function PageReader({
mangaSlug, mangaSlug,
@ -45,10 +54,14 @@ export function PageReader({
prevChapter, prevChapter,
nextChapter, nextChapter,
chapters, chapters,
initialChapterMeta,
}: PageReaderProps) { }: PageReaderProps) {
const [showUI, setShowUI] = useState(true); const [showUI, setShowUI] = useState(true);
const [showDrawer, setShowDrawer] = useState(false); const [showDrawer, setShowDrawer] = useState(false);
const [pages, setPages] = useState<LoadedPage[]>([]); const [chapterMetas, setChapterMetas] = useState<Record<number, PageMeta[]>>({
[startChapterNumber]: initialChapterMeta,
});
const [images, setImages] = useState<Record<string, string>>({});
const [currentChapterNum, setCurrentChapterNum] = const [currentChapterNum, setCurrentChapterNum] =
useState(startChapterNumber); useState(startChapterNumber);
const [currentPageNum, setCurrentPageNum] = useState(() => { const [currentPageNum, setCurrentPageNum] = useState(() => {
@ -58,216 +71,209 @@ export function PageReader({
return 1; return 1;
}); });
const hiddenByScrollRef = useRef(false); // Observer stays stable across state updates.
const fetchChapterIdxRef = useRef( const imagesRef = useRef(images);
chapters.findIndex((c) => c.number === startChapterNumber) const chapterMetasRef = useRef(chapterMetas);
); useEffect(() => {
// Initialize offset from saved progress so the first fetch starts AT the imagesRef.current = images;
// user's last-read page — previous pages are skipped entirely }, [images]);
const offsetRef = useRef(0); useEffect(() => {
const initialPageRef = useRef(1); chapterMetasRef.current = chapterMetas;
const offsetInitedRef = useRef(false); }, [chapterMetas]);
if (!offsetInitedRef.current && typeof window !== "undefined") {
offsetInitedRef.current = true; const metaInflightRef = useRef<Set<number>>(new Set());
const p = readProgress(mangaSlug); const imagesInflightRef = useRef<Set<string>>(new Set());
if (p && p.chapter === startChapterNumber && p.page > 1) { const pageElRef = useRef<Map<string, HTMLDivElement>>(new Map());
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<Set<number>>(new Set());
const observerRef = useRef<IntersectionObserver | null>(null); const observerRef = useRef<IntersectionObserver | null>(null);
const pageRefsRef = useRef<Map<number, HTMLDivElement>>(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<Map<string, IntersectingPage>>(new Map());
// Reverse-fetch cursor: the offset of the first loaded page of the const loadedChapterNumbers = useMemo(() => {
// starting chapter. Used to prepend previous pages when the user scrolls return Object.keys(chapterMetas)
// up from a resumed mid-chapter position. .map(Number)
const prependOffsetRef = useRef(offsetRef.current); .filter((n) => n >= startChapterNumber)
const prependLoadingRef = useRef(false); .sort((a, b) => a - b);
const prependExhaustedRef = useRef(offsetRef.current === 0); }, [chapterMetas, startChapterNumber]);
const advanceChapterOrFinish = useCallback(() => { const chapterByNumber = useMemo(() => {
if (fetchChapterIdxRef.current + 1 < chapters.length) { const m = new Map<number, ChapterMeta>();
fetchChapterIdxRef.current += 1; for (const c of chapters) m.set(c.number, c);
offsetRef.current = 0; return m;
} else { }, [chapters]);
doneRef.current = true;
}
}, [chapters.length]);
const startChapter = useMemo( const fetchImagesAround = useCallback(
() => chapters.find((c) => c.number === startChapterNumber), async (chapterNum: number, pageNum: number) => {
[chapters, startChapterNumber] 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 () => { const prefetchNextChapterMeta = useCallback(
if ( async (currentChapterNumArg: number) => {
prependLoadingRef.current || const idx = chapters.findIndex((c) => c.number === currentChapterNumArg);
prependExhaustedRef.current || if (idx < 0 || idx >= chapters.length - 1) return;
!startChapter const next = chapters[idx + 1];
) if (chapterMetasRef.current[next.number]) return;
return; if (metaInflightRef.current.has(next.number)) return;
const currentOffset = prependOffsetRef.current; metaInflightRef.current.add(next.number);
const newOffset = Math.max(0, currentOffset - BATCH_SIZE); try {
const limit = currentOffset - newOffset; const res = await fetch(`/api/chapters/${next.id}/meta`);
if (limit <= 0) { const meta: PageMeta[] = await res.json();
prependExhaustedRef.current = true; setChapterMetas((prev) => ({ ...prev, [next.number]: meta }));
return; } catch {
} // will retry next observer fire
prependLoadingRef.current = true; } finally {
const beforeHeight = document.documentElement.scrollHeight; metaInflightRef.current.delete(next.number);
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;
} }
prependOffsetRef.current = newOffset; },
if (newOffset === 0) prependExhaustedRef.current = true; [chapters]
);
// 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<number>();
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 <img>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]);
useEffect(() => { useEffect(() => {
observerRef.current = new IntersectionObserver( observerRef.current = new IntersectionObserver(
(entries) => { (entries) => {
for (const entry of entries) { for (const e of entries) {
if (entry.isIntersecting) { const el = e.target as HTMLDivElement;
const index = Number( const chNum = Number(el.dataset.chapter);
(entry.target as HTMLElement).dataset.pageIndex const pNum = Number(el.dataset.page);
); if (!chNum || !pNum) continue;
if (triggerIndicesRef.current.has(index)) { const key = pageKey(chNum, pNum);
triggerIndicesRef.current.delete(index); if (e.isIntersecting) {
fetchBatch(); 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(); 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(() => { useEffect(() => {
if (initialFetchRef.current) return; let rafId = 0;
initialFetchRef.current = true; const tick = () => {
fetchBatch(); rafId = 0;
}, [fetchBatch]); const y = window.scrollY;
if (!hiddenByScrollRef.current && y > 50) {
const setPageRef = useCallback( hiddenByScrollRef.current = true;
(index: number, el: HTMLDivElement | null) => { setShowUI(false);
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);
} }
}, // 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 router = useRouter();
const touchMovedRef = useRef(false); const touchMovedRef = useRef(false);
// Pending single-tap toggle, delayed so we can detect a double-tap first
const singleTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const singleTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastTapAtRef = useRef(0); const lastTapAtRef = useRef(0);
const DOUBLE_TAP_MS = 280;
const onTouchStart = useCallback(() => { const onTouchStart = useCallback(() => {
touchMovedRef.current = false; touchMovedRef.current = false;
@ -275,15 +281,12 @@ export function PageReader({
const onTouchMove = useCallback(() => { const onTouchMove = useCallback(() => {
touchMovedRef.current = true; touchMovedRef.current = true;
}, []); }, []);
const onTap = useCallback( const onTap = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (touchMovedRef.current) return; if (touchMovedRef.current) return;
const now = Date.now(); const now = Date.now();
const isDoubleTap = now - lastTapAtRef.current < DOUBLE_TAP_MS; const isDoubleTap = now - lastTapAtRef.current < DOUBLE_TAP_MS;
if (isDoubleTap) { if (isDoubleTap) {
// Cancel pending single-tap, navigate instead
if (singleTapTimerRef.current) { if (singleTapTimerRef.current) {
clearTimeout(singleTapTimerRef.current); clearTimeout(singleTapTimerRef.current);
singleTapTimerRef.current = null; singleTapTimerRef.current = null;
@ -292,14 +295,17 @@ export function PageReader({
const midX = window.innerWidth / 2; const midX = window.innerWidth / 2;
if (e.clientX >= midX) { if (e.clientX >= midX) {
if (nextChapter) if (nextChapter)
router.push(`/manga/${mangaSlug}/${nextChapter}`); router.push(`/manga/${mangaSlug}/${nextChapter}`, {
scroll: false,
});
} else { } else {
if (prevChapter) if (prevChapter)
router.push(`/manga/${mangaSlug}/${prevChapter}`); router.push(`/manga/${mangaSlug}/${prevChapter}`, {
scroll: false,
});
} }
return; return;
} }
lastTapAtRef.current = now; lastTapAtRef.current = now;
singleTapTimerRef.current = setTimeout(() => { singleTapTimerRef.current = setTimeout(() => {
setShowUI((v) => !v); 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 = const currentChapter =
chapters.find((c) => c.number === currentChapterNum) ?? chapters.find((c) => c.number === currentChapterNum) ??
chapters.find((c) => c.number === startChapterNumber); chapters.find((c) => c.number === startChapterNumber);
const lastChapter = chapters[chapters.length - 1];
const atEnd =
currentChapterNum === lastChapter?.number &&
currentPageNum >= (chapterMetas[currentChapterNum]?.length ?? 0);
return ( return (
<div className="min-h-dvh bg-background"> <div className="min-h-dvh bg-background">
<div <div
@ -432,41 +389,54 @@ export function PageReader({
onTouchMove={onTouchMove} onTouchMove={onTouchMove}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{pages.map((page, i) => { {loadedChapterNumbers.map((chNum, idx) => {
const isChapterStart = const meta = chapterMetas[chNum];
i === 0 || pages[i - 1].chapterNumber !== page.chapterNumber; const chapter = chapters.find((c) => c.number === chNum);
return ( return (
<div <Fragment key={chNum}>
key={`${page.chapterNumber}-${page.pageNumber}`} {idx > 0 && (
className="relative leading-[0]"
data-page-index={i}
ref={(el) => setPageRef(i, el)}
>
{isChapterStart && i > 0 && (
<div className="bg-surface py-4 text-center leading-normal"> <div className="bg-surface py-4 text-center leading-normal">
<p className="text-xs uppercase tracking-wider text-muted"> <p className="text-xs uppercase tracking-wider text-muted">
Chapter {page.chapterNumber} Chapter {chNum}
</p> </p>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{ {chapter?.title}
chapters.find((c) => c.number === page.chapterNumber)
?.title
}
</p> </p>
</div> </div>
)} )}
<img {meta.map((p) => {
src={page.imageUrl} const key = pageKey(chNum, p.number);
alt={`Page ${page.pageNumber}`} const url = images[key];
className="w-full h-auto block align-bottom -mb-px aspect-[3/4] [-webkit-touch-callout:none]" const aspect =
draggable={false} p.width > 0 && p.height > 0
/> ? `${p.width} / ${p.height}`
</div> : "3 / 4";
return (
<div
key={key}
ref={(el) => setPageRef(key, el)}
data-chapter={chNum}
data-page={p.number}
className="relative leading-[0] w-full"
style={{ aspectRatio: aspect }}
>
{url && (
<img
src={url}
alt={`Page ${p.number}`}
className="w-full h-auto block [-webkit-touch-callout:none]"
draggable={false}
/>
)}
</div>
);
})}
</Fragment>
); );
})} })}
</div> </div>
{doneRef.current && pages.length > 0 && ( {atEnd && (
<div className="min-h-dvh max-w-4xl mx-auto px-4 flex flex-col items-center justify-center text-center leading-normal gap-4"> <div className="min-h-dvh max-w-4xl mx-auto px-4 flex flex-col items-center justify-center text-center leading-normal gap-4">
<p className="text-xs uppercase tracking-wider text-muted"> <p className="text-xs uppercase tracking-wider text-muted">
End of Manga End of Manga
@ -481,7 +451,6 @@ export function PageReader({
</div> </div>
)} )}
{/* Chapter drawer overlay (modal — fixed is necessary to cover viewport) */}
{showDrawer && ( {showDrawer && (
<div <div
className="fixed inset-0 z-[60]" className="fixed inset-0 z-[60]"
@ -512,6 +481,7 @@ export function PageReader({
<Link <Link
key={ch.number} key={ch.number}
href={`/manga/${mangaSlug}/${ch.number}`} href={`/manga/${mangaSlug}/${ch.number}`}
scroll={false}
className={`flex items-center gap-3 px-5 py-3 text-sm transition-colors ${ className={`flex items-center gap-3 px-5 py-3 text-sm transition-colors ${
isActive isActive
? "bg-accent/10" ? "bg-accent/10"

View File

@ -71,6 +71,7 @@ export function ReadingProgressButton({ mangaSlug, chapters }: Props) {
return ( return (
<Link <Link
href={`/manga/${mangaSlug}/${target.number}`} href={`/manga/${mangaSlug}/${target.number}`}
scroll={false}
className="flex items-center justify-center gap-3 w-full py-3 mb-6 px-4 text-sm font-semibold bg-accent hover:bg-accent-hover text-white rounded-xl transition-colors active:scale-[0.98]" className="flex items-center justify-center gap-3 w-full py-3 mb-6 px-4 text-sm font-semibold bg-accent hover:bg-accent-hover text-white rounded-xl transition-colors active:scale-[0.98]"
> >
{resumeChapter ? ( {resumeChapter ? (

View File

@ -35,10 +35,15 @@ export async function getPresignedReadUrl(key: string) {
return getSignedUrl(s3, command, { expiresIn: 60 }); return getSignedUrl(s3, command, { expiresIn: 60 });
} }
export async function signUrl(publicUrl: string) { export function keyFromPublicUrl(publicUrl: string): string | null {
const prefix = process.env.R2_PUBLIC_URL!; const prefix = process.env.R2_PUBLIC_URL!;
if (!publicUrl.startsWith(prefix)) return publicUrl; if (!publicUrl.startsWith(prefix)) return null;
const key = publicUrl.replace(prefix, "").replace(/^\//, ""); return publicUrl.replace(prefix, "").replace(/^\//, "");
}
export async function signUrl(publicUrl: string) {
const key = keyFromPublicUrl(publicUrl);
if (key === null) return publicUrl;
return getPresignedReadUrl(key); return getPresignedReadUrl(key);
} }

13
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@aws-sdk/client-s3": "^3.1015.0", "@aws-sdk/client-s3": "^3.1015.0",
"@aws-sdk/s3-request-presigner": "^3.1015.0", "@aws-sdk/s3-request-presigner": "^3.1015.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"image-size": "^2.0.2",
"next": "16.2.1", "next": "16.2.1",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"react": "19.2.4", "react": "19.2.4",
@ -6355,6 +6356,18 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/image-size": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
"license": "MIT",
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",

View File

@ -16,6 +16,7 @@
"@aws-sdk/client-s3": "^3.1015.0", "@aws-sdk/client-s3": "^3.1015.0",
"@aws-sdk/s3-request-presigner": "^3.1015.0", "@aws-sdk/s3-request-presigner": "^3.1015.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"image-size": "^2.0.2",
"next": "16.2.1", "next": "16.2.1",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"react": "19.2.4", "react": "19.2.4",

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Page" ADD COLUMN "height" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "width" INTEGER NOT NULL DEFAULT 0;

View File

@ -36,6 +36,8 @@ model Page {
chapterId Int chapterId Int
number Int number Int
imageUrl String imageUrl String
width Int @default(0)
height Int @default(0)
chapter Chapter @relation(fields: [chapterId], references: [id]) chapter Chapter @relation(fields: [chapterId], references: [id])
@@unique([chapterId, number]) @@unique([chapterId, number])

View File

@ -0,0 +1,89 @@
/**
* Backfill `width` and `height` on Page rows by range-fetching the first
* 16 KB of each image from R2 and parsing its header with `image-size`.
*
* Idempotent: only targets rows where width=0 or height=0.
*
* Usage: npx tsx scripts/backfill-page-dims.ts
*/
import { PrismaClient } from "@prisma/client";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { imageSize } from "image-size";
import { keyFromPublicUrl } from "@/lib/r2";
const prisma = new PrismaClient();
const BUCKET = process.env.R2_BUCKET;
if (!BUCKET) throw new Error("R2_BUCKET must be set");
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY!,
secretAccessKey: process.env.R2_SECRET_KEY!,
},
});
const CONCURRENCY = 10;
const HEADER_BYTES = 16_384;
async function fetchHeader(key: string): Promise<Uint8Array> {
const res = await s3.send(
new GetObjectCommand({
Bucket: BUCKET,
Key: key,
Range: `bytes=0-${HEADER_BYTES - 1}`,
})
);
if (!res.Body) throw new Error(`No body for ${key}`);
return res.Body.transformToByteArray();
}
async function main() {
const pages = await prisma.page.findMany({
where: { OR: [{ width: 0 }, { height: 0 }] },
orderBy: { id: "asc" },
});
console.log(`Probing ${pages.length} pages with dims unset`);
let done = 0;
let failed = 0;
for (let i = 0; i < pages.length; i += CONCURRENCY) {
const batch = pages.slice(i, i + CONCURRENCY);
await Promise.all(
batch.map(async (page) => {
try {
const key = keyFromPublicUrl(page.imageUrl);
if (!key) throw new Error(`URL outside R2 prefix: ${page.imageUrl}`);
const header = await fetchHeader(key);
const { width, height } = imageSize(header);
if (!width || !height) {
throw new Error("image-size returned no dimensions");
}
await prisma.page.update({
where: { id: page.id },
data: { width, height },
});
done++;
} catch (err) {
failed++;
console.error(
`✗ page ${page.id} (${page.imageUrl}):`,
err instanceof Error ? err.message : err
);
}
})
);
console.log(`${Math.min(i + CONCURRENCY, pages.length)}/${pages.length}`);
}
console.log(`\nDone. Probed: ${done}, failed: ${failed}`);
await prisma.$disconnect();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});