Compare commits

..

No commits in common. "43a2a6d3f8b4f1af6be2049b37bc81b4e12b367e" and "3745f1f3166347cccc1f0221001ca2290980915a" have entirely different histories.

11 changed files with 294 additions and 406 deletions

View File

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

View File

@ -25,7 +25,6 @@ export function ChapterList({
<Link
key={ch.id}
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"
>
<div className="flex items-center gap-3 min-w-0">

View File

@ -1,14 +1,6 @@
"use client";
import {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
@ -23,7 +15,16 @@ type ChapterMeta = {
totalPages: number;
};
type PageMeta = { number: number; width: number; height: number };
type LoadedPage = {
chapterNumber: number;
pageNumber: number;
imageUrl: string;
};
type RawPage = {
number: number;
imageUrl: string;
};
type PageReaderProps = {
mangaSlug: string;
@ -32,20 +33,10 @@ type PageReaderProps = {
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;
};
const BATCH_SIZE = 7;
const PREFETCH_AT = 3;
export function PageReader({
mangaSlug,
@ -54,14 +45,10 @@ export function PageReader({
prevChapter,
nextChapter,
chapters,
initialChapterMeta,
}: PageReaderProps) {
const [showUI, setShowUI] = useState(true);
const [showDrawer, setShowDrawer] = useState(false);
const [chapterMetas, setChapterMetas] = useState<Record<number, PageMeta[]>>({
[startChapterNumber]: initialChapterMeta,
});
const [images, setImages] = useState<Record<string, string>>({});
const [pages, setPages] = useState<LoadedPage[]>([]);
const [currentChapterNum, setCurrentChapterNum] =
useState(startChapterNumber);
const [currentPageNum, setCurrentPageNum] = useState(() => {
@ -71,209 +58,216 @@ export function PageReader({
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<Set<number>>(new Set());
const imagesInflightRef = useRef<Set<string>>(new Set());
const pageElRef = useRef<Map<string, HTMLDivElement>>(new Map());
const observerRef = useRef<IntersectionObserver | null>(null);
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());
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<number, ChapterMeta>();
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);
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;
}
if (toFetch.length === 0) return;
const minP = toFetch[0];
const maxP = toFetch[toFetch.length - 1];
}
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 pageRefsRef = useRef<Map<number, HTMLDivElement>>(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 advanceChapterOrFinish = useCallback(() => {
if (fetchChapterIdxRef.current + 1 < chapters.length) {
fetchChapterIdxRef.current += 1;
offsetRef.current = 0;
} else {
doneRef.current = true;
}
}, [chapters.length]);
const startChapter = useMemo(
() => chapters.find((c) => c.number === startChapterNumber),
[chapters, startChapterNumber]
);
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=${chapter.id}&offset=${minP - 1}&limit=${
maxP - minP + 1
}`
`/api/pages?chapterId=${startChapter.id}&offset=${newOffset}&limit=${limit}`
);
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;
const batch: RawPage[] = await res.json();
if (batch.length === 0) {
prependExhaustedRef.current = true;
return;
}
return next;
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<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 {
// observer will re-trigger on next intersection
// ignore — user can scroll-up again to retry
} finally {
for (const p of toFetch)
imagesInflightRef.current.delete(pageKey(chapterNum, p));
prependLoadingRef.current = false;
}
},
[chapters]
);
}, [startChapter]);
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);
const fetchBatch = useCallback(async () => {
if (loadingRef.current || doneRef.current) return;
const chapter = chapters[fetchChapterIdxRef.current];
if (!chapter) {
doneRef.current = true;
return;
}
},
[chapters]
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 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);
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();
}
} else {
intersectingPagesRef.current.delete(key);
}
}
},
{ rootMargin: "1200px" }
{ rootMargin: "400px" }
);
for (const el of pageElRef.current.values()) {
observerRef.current.observe(el);
}
return () => observerRef.current?.disconnect();
}, [fetchImagesAround, prefetchNextChapterMeta, chapterByNumber]);
}, [fetchBatch]);
const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => {
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;
const prev = pageElRef.current.get(key);
if (prev && observer) observer.unobserve(prev);
if (!observer) return;
const prev = pageRefsRef.current.get(index);
if (prev) observer.unobserve(prev);
if (el) {
pageElRef.current.set(key, el);
if (observer) observer.observe(el);
pageRefsRef.current.set(index, el);
if (triggerIndicesRef.current.has(index)) {
observer.observe(el);
}
} else {
pageElRef.current.delete(key);
pageRefsRef.current.delete(index);
}
}, []);
// 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);
// Pending single-tap toggle, delayed so we can detect a double-tap first
const singleTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastTapAtRef = useRef(0);
const DOUBLE_TAP_MS = 280;
const onTouchStart = useCallback(() => {
touchMovedRef.current = false;
@ -281,12 +275,15 @@ 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;
@ -295,17 +292,14 @@ export function PageReader({
const midX = window.innerWidth / 2;
if (e.clientX >= midX) {
if (nextChapter)
router.push(`/manga/${mangaSlug}/${nextChapter}`, {
scroll: false,
});
router.push(`/manga/${mangaSlug}/${nextChapter}`);
} else {
if (prevChapter)
router.push(`/manga/${mangaSlug}/${prevChapter}`, {
scroll: false,
});
router.push(`/manga/${mangaSlug}/${prevChapter}`);
}
return;
}
lastTapAtRef.current = now;
singleTapTimerRef.current = setTimeout(() => {
setShowUI((v) => !v);
@ -322,15 +316,64 @@ 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 (
<div className="min-h-dvh bg-background">
<div
@ -389,54 +432,41 @@ export function PageReader({
onTouchMove={onTouchMove}
onContextMenu={(e) => e.preventDefault()}
>
{loadedChapterNumbers.map((chNum, idx) => {
const meta = chapterMetas[chNum];
const chapter = chapters.find((c) => c.number === chNum);
return (
<Fragment key={chNum}>
{idx > 0 && (
<div className="bg-surface py-4 text-center leading-normal">
<p className="text-xs uppercase tracking-wider text-muted">
Chapter {chNum}
</p>
<p className="text-sm font-semibold text-foreground">
{chapter?.title}
</p>
</div>
)}
{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";
{pages.map((page, i) => {
const isChapterStart =
i === 0 || pages[i - 1].chapterNumber !== page.chapterNumber;
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 }}
key={`${page.chapterNumber}-${page.pageNumber}`}
className="relative leading-[0]"
data-page-index={i}
ref={(el) => setPageRef(i, el)}
>
{url && (
{isChapterStart && i > 0 && (
<div className="bg-surface py-4 text-center leading-normal">
<p className="text-xs uppercase tracking-wider text-muted">
Chapter {page.chapterNumber}
</p>
<p className="text-sm font-semibold text-foreground">
{
chapters.find((c) => c.number === page.chapterNumber)
?.title
}
</p>
</div>
)}
<img
src={url}
alt={`Page ${p.number}`}
className="w-full h-auto block [-webkit-touch-callout:none]"
src={page.imageUrl}
alt={`Page ${page.pageNumber}`}
className="w-full h-auto block align-bottom -mb-px aspect-[3/4] [-webkit-touch-callout:none]"
draggable={false}
/>
)}
</div>
);
})}
</Fragment>
);
})}
</div>
{atEnd && (
{doneRef.current && pages.length > 0 && (
<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">
End of Manga
@ -451,6 +481,7 @@ export function PageReader({
</div>
)}
{/* Chapter drawer overlay (modal — fixed is necessary to cover viewport) */}
{showDrawer && (
<div
className="fixed inset-0 z-[60]"
@ -481,7 +512,6 @@ export function PageReader({
<Link
key={ch.number}
href={`/manga/${mangaSlug}/${ch.number}`}
scroll={false}
className={`flex items-center gap-3 px-5 py-3 text-sm transition-colors ${
isActive
? "bg-accent/10"

View File

@ -71,7 +71,6 @@ export function ReadingProgressButton({ mangaSlug, chapters }: Props) {
return (
<Link
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]"
>
{resumeChapter ? (

View File

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

13
package-lock.json generated
View File

@ -11,7 +11,6 @@
"@aws-sdk/client-s3": "^3.1015.0",
"@aws-sdk/s3-request-presigner": "^3.1015.0",
"@prisma/client": "^6.19.2",
"image-size": "^2.0.2",
"next": "16.2.1",
"prisma": "^6.19.2",
"react": "19.2.4",
@ -6356,18 +6355,6 @@
"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": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",

View File

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

View File

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

View File

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

View File

@ -1,89 +0,0 @@
/**
* 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);
});