Compare commits
No commits in common. "43a2a6d3f8b4f1af6be2049b37bc81b4e12b367e" and "3745f1f3166347cccc1f0221001ca2290980915a" have entirely different histories.
43a2a6d3f8
...
3745f1f316
@ -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);
|
|
||||||
}
|
|
||||||
@ -26,8 +26,7 @@ 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, initialChapterMeta] = await Promise.all([
|
const manga = await prisma.manga.findUnique({
|
||||||
prisma.manga.findUnique({
|
|
||||||
where: { slug },
|
where: { slug },
|
||||||
include: {
|
include: {
|
||||||
chapters: {
|
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();
|
if (!manga) notFound();
|
||||||
|
|
||||||
@ -75,7 +68,6 @@ export default async function ChapterReaderPage({ params }: Props) {
|
|||||||
prevChapter={prevChapter}
|
prevChapter={prevChapter}
|
||||||
nextChapter={nextChapter}
|
nextChapter={nextChapter}
|
||||||
chapters={allChapters}
|
chapters={allChapters}
|
||||||
initialChapterMeta={initialChapterMeta}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,6 @@ 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,14 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
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 {
|
||||||
@ -23,7 +15,16 @@ type ChapterMeta = {
|
|||||||
totalPages: number;
|
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 = {
|
type PageReaderProps = {
|
||||||
mangaSlug: string;
|
mangaSlug: string;
|
||||||
@ -32,20 +33,10 @@ type PageReaderProps = {
|
|||||||
prevChapter: number | null;
|
prevChapter: number | null;
|
||||||
nextChapter: number | null;
|
nextChapter: number | null;
|
||||||
chapters: ChapterMeta[];
|
chapters: ChapterMeta[];
|
||||||
initialChapterMeta: PageMeta[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const PREFETCH_NEXT_AT = 3;
|
const BATCH_SIZE = 7;
|
||||||
const IMAGE_BATCH_RADIUS = 3;
|
const PREFETCH_AT = 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,
|
||||||
@ -54,14 +45,10 @@ 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 [chapterMetas, setChapterMetas] = useState<Record<number, PageMeta[]>>({
|
const [pages, setPages] = useState<LoadedPage[]>([]);
|
||||||
[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(() => {
|
||||||
@ -71,209 +58,216 @@ export function PageReader({
|
|||||||
return 1;
|
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);
|
const hiddenByScrollRef = useRef(false);
|
||||||
// Pages currently inside the observer's viewport margin. The scroll tick
|
const fetchChapterIdxRef = useRef(
|
||||||
// walks this small set instead of every loaded page.
|
chapters.findIndex((c) => c.number === startChapterNumber)
|
||||||
const intersectingPagesRef = useRef<Map<string, IntersectingPage>>(new Map());
|
);
|
||||||
|
// Initialize offset from saved progress so the first fetch starts AT the
|
||||||
const loadedChapterNumbers = useMemo(() => {
|
// user's last-read page — previous pages are skipped entirely
|
||||||
return Object.keys(chapterMetas)
|
const offsetRef = useRef(0);
|
||||||
.map(Number)
|
const initialPageRef = useRef(1);
|
||||||
.filter((n) => n >= startChapterNumber)
|
const offsetInitedRef = useRef(false);
|
||||||
.sort((a, b) => a - b);
|
if (!offsetInitedRef.current && typeof window !== "undefined") {
|
||||||
}, [chapterMetas, startChapterNumber]);
|
offsetInitedRef.current = true;
|
||||||
|
const p = readProgress(mangaSlug);
|
||||||
const chapterByNumber = useMemo(() => {
|
if (p && p.chapter === startChapterNumber && p.page > 1) {
|
||||||
const m = new Map<number, ChapterMeta>();
|
offsetRef.current = p.page - 1;
|
||||||
for (const c of chapters) m.set(c.number, c);
|
initialPageRef.current = p.page;
|
||||||
return m;
|
|
||||||
}, [chapters]);
|
|
||||||
|
|
||||||
const fetchImagesAround = useCallback(
|
|
||||||
async (chapterNum: number, pageNum: number) => {
|
|
||||||
const meta = chapterMetasRef.current[chapterNum];
|
|
||||||
const chapter = chapterByNumber.get(chapterNum);
|
|
||||||
if (!meta || !chapter) return;
|
|
||||||
const start = Math.max(1, pageNum - IMAGE_BATCH_RADIUS);
|
|
||||||
const end = Math.min(meta.length, pageNum + IMAGE_BATCH_RADIUS);
|
|
||||||
const toFetch: number[] = [];
|
|
||||||
for (let p = start; p <= end; p++) {
|
|
||||||
const k = pageKey(chapterNum, p);
|
|
||||||
if (imagesRef.current[k] || imagesInflightRef.current.has(k)) continue;
|
|
||||||
imagesInflightRef.current.add(k);
|
|
||||||
toFetch.push(p);
|
|
||||||
}
|
}
|
||||||
if (toFetch.length === 0) return;
|
}
|
||||||
const minP = toFetch[0];
|
const loadingRef = useRef(false);
|
||||||
const maxP = toFetch[toFetch.length - 1];
|
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 {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/pages?chapterId=${chapter.id}&offset=${minP - 1}&limit=${
|
`/api/pages?chapterId=${startChapter.id}&offset=${newOffset}&limit=${limit}`
|
||||||
maxP - minP + 1
|
|
||||||
}`
|
|
||||||
);
|
);
|
||||||
const batch: { number: number; imageUrl: string }[] = await res.json();
|
const batch: RawPage[] = await res.json();
|
||||||
setImages((prev) => {
|
if (batch.length === 0) {
|
||||||
const next = { ...prev };
|
prependExhaustedRef.current = true;
|
||||||
for (const item of batch) {
|
return;
|
||||||
next[pageKey(chapterNum, item.number)] = item.imageUrl;
|
|
||||||
}
|
}
|
||||||
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 {
|
} catch {
|
||||||
// observer will re-trigger on next intersection
|
// ignore — user can scroll-up again to retry
|
||||||
} finally {
|
} finally {
|
||||||
for (const p of toFetch)
|
prependLoadingRef.current = false;
|
||||||
imagesInflightRef.current.delete(pageKey(chapterNum, p));
|
|
||||||
}
|
}
|
||||||
},
|
}, [startChapter]);
|
||||||
[chapters]
|
|
||||||
);
|
|
||||||
|
|
||||||
const prefetchNextChapterMeta = useCallback(
|
const fetchBatch = useCallback(async () => {
|
||||||
async (currentChapterNumArg: number) => {
|
if (loadingRef.current || doneRef.current) return;
|
||||||
const idx = chapters.findIndex((c) => c.number === currentChapterNumArg);
|
const chapter = chapters[fetchChapterIdxRef.current];
|
||||||
if (idx < 0 || idx >= chapters.length - 1) return;
|
if (!chapter) {
|
||||||
const next = chapters[idx + 1];
|
doneRef.current = true;
|
||||||
if (chapterMetasRef.current[next.number]) return;
|
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);
|
|
||||||
}
|
}
|
||||||
},
|
loadingRef.current = true;
|
||||||
[chapters]
|
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 e of entries) {
|
for (const entry of entries) {
|
||||||
const el = e.target as HTMLDivElement;
|
if (entry.isIntersecting) {
|
||||||
const chNum = Number(el.dataset.chapter);
|
const index = Number(
|
||||||
const pNum = Number(el.dataset.page);
|
(entry.target as HTMLElement).dataset.pageIndex
|
||||||
if (!chNum || !pNum) continue;
|
);
|
||||||
const key = pageKey(chNum, pNum);
|
if (triggerIndicesRef.current.has(index)) {
|
||||||
if (e.isIntersecting) {
|
triggerIndicesRef.current.delete(index);
|
||||||
intersectingPagesRef.current.set(key, { chNum, pNum, el });
|
fetchBatch();
|
||||||
fetchImagesAround(chNum, pNum);
|
|
||||||
const chapter = chapterByNumber.get(chNum);
|
|
||||||
if (chapter && pNum >= chapter.totalPages - PREFETCH_NEXT_AT) {
|
|
||||||
prefetchNextChapterMeta(chNum);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
intersectingPagesRef.current.delete(key);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ rootMargin: "1200px" }
|
{ rootMargin: "400px" }
|
||||||
);
|
);
|
||||||
for (const el of pageElRef.current.values()) {
|
|
||||||
observerRef.current.observe(el);
|
|
||||||
}
|
|
||||||
return () => observerRef.current?.disconnect();
|
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 observer = observerRef.current;
|
||||||
const prev = pageElRef.current.get(key);
|
if (!observer) return;
|
||||||
if (prev && observer) observer.unobserve(prev);
|
const prev = pageRefsRef.current.get(index);
|
||||||
|
if (prev) observer.unobserve(prev);
|
||||||
if (el) {
|
if (el) {
|
||||||
pageElRef.current.set(key, el);
|
pageRefsRef.current.set(index, el);
|
||||||
if (observer) observer.observe(el);
|
if (triggerIndicesRef.current.has(index)) {
|
||||||
|
observer.observe(el);
|
||||||
|
}
|
||||||
} else {
|
} 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 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;
|
||||||
@ -281,12 +275,15 @@ 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;
|
||||||
@ -295,17 +292,14 @@ 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);
|
||||||
@ -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 =
|
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
|
||||||
@ -389,54 +432,41 @@ export function PageReader({
|
|||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{loadedChapterNumbers.map((chNum, idx) => {
|
{pages.map((page, i) => {
|
||||||
const meta = chapterMetas[chNum];
|
const isChapterStart =
|
||||||
const chapter = chapters.find((c) => c.number === chNum);
|
i === 0 || pages[i - 1].chapterNumber !== page.chapterNumber;
|
||||||
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";
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={`${page.chapterNumber}-${page.pageNumber}`}
|
||||||
ref={(el) => setPageRef(key, el)}
|
className="relative leading-[0]"
|
||||||
data-chapter={chNum}
|
data-page-index={i}
|
||||||
data-page={p.number}
|
ref={(el) => setPageRef(i, el)}
|
||||||
className="relative leading-[0] w-full"
|
|
||||||
style={{ aspectRatio: aspect }}
|
|
||||||
>
|
>
|
||||||
{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
|
<img
|
||||||
src={url}
|
src={page.imageUrl}
|
||||||
alt={`Page ${p.number}`}
|
alt={`Page ${page.pageNumber}`}
|
||||||
className="w-full h-auto block [-webkit-touch-callout:none]"
|
className="w-full h-auto block align-bottom -mb-px aspect-[3/4] [-webkit-touch-callout:none]"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</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">
|
<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
|
||||||
@ -451,6 +481,7 @@ 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]"
|
||||||
@ -481,7 +512,6 @@ 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,7 +71,6 @@ 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,15 +35,10 @@ export async function getPresignedReadUrl(key: string) {
|
|||||||
return getSignedUrl(s3, command, { expiresIn: 60 });
|
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) {
|
export async function signUrl(publicUrl: string) {
|
||||||
const key = keyFromPublicUrl(publicUrl);
|
const prefix = process.env.R2_PUBLIC_URL!;
|
||||||
if (key === null) return publicUrl;
|
if (!publicUrl.startsWith(prefix)) return publicUrl;
|
||||||
|
const key = publicUrl.replace(prefix, "").replace(/^\//, "");
|
||||||
return getPresignedReadUrl(key);
|
return getPresignedReadUrl(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
package-lock.json
generated
13
package-lock.json
generated
@ -11,7 +11,6 @@
|
|||||||
"@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",
|
||||||
@ -6356,18 +6355,6 @@
|
|||||||
"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,7 +16,6 @@
|
|||||||
"@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",
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Page" ADD COLUMN "height" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
ADD COLUMN "width" INTEGER NOT NULL DEFAULT 0;
|
|
||||||
@ -36,8 +36,6 @@ 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])
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
Loading…
x
Reference in New Issue
Block a user