Compare commits
2 Commits
3745f1f316
...
43a2a6d3f8
| Author | SHA1 | Date | |
|---|---|---|---|
| 43a2a6d3f8 | |||
| 0a1365a743 |
19
app/api/chapters/[chapterId]/meta/route.ts
Normal file
19
app/api/chapters/[chapterId]/meta/route.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
11
lib/r2.ts
11
lib/r2.ts
@ -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
13
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Page" ADD COLUMN "height" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "width" INTEGER NOT NULL DEFAULT 0;
|
||||||
@ -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])
|
||||||
|
|||||||
89
scripts/backfill-page-dims.ts
Normal file
89
scripts/backfill-page-dims.ts
Normal 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);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user