- Resume scroll position only when arriving via 继续阅读 (?resume=1).
Plain chapter-list / drawer clicks now actively scroll to top on mount.
- Progress format extended to {chapter, page, ratio} for within-page
precision; legacy bare-number and {chapter, page} still read correctly.
- Tappable skeleton logo (sunflower outline, spins) while a page loads;
tap force-fetches a fresh signed URL.
- Viewport-priority image loading: second IntersectionObserver at margin 0
marks truly-visible pages, drives <img fetchpriority="high"> and fires
immediate single-page fetches that cut the batch queue.
- Bounded image cache: unmount previous chapter's <img> elements when
currentPage > 5 into the new chapter; placeholders stay for layout.
One AbortController per live chapter; unmount aborts in-flight batches.
- Hashed chapter IDs on the wire via hashids; DB PKs unchanged.
- Origin/Referer allowlist + rate limiting on all /api/* routes via a
withGuards(opts, handler) wrapper (eliminates 6-line boilerplate x5).
- robots.txt allows Googlebot/Bingbot/Slurp/DuckDuckBot/Baiduspider/
YandexBot only; disallows /api/ for all UAs.
- Extract pure helpers for future TDD: lib/scroll-ratio.ts (calcScrollRatio,
scrollOffsetFromRatio), lib/progress.ts (parseProgress + injectable
StorageLike), lib/rate-limit.ts (optional { now, store, ipOf } deps),
lib/api-guards.ts.
- New env keys: HASHIDS_SALT, ALLOWED_ORIGINS (wired into docker-compose).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
711 lines
23 KiB
TypeScript
711 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
Fragment,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import {
|
|
readProgress,
|
|
writeProgress,
|
|
} from "@/components/ReadingProgressButton";
|
|
import { LoadingLogo } from "@/components/LoadingLogo";
|
|
import {
|
|
calcScrollRatio,
|
|
scrollOffsetFromRatio,
|
|
} from "@/lib/scroll-ratio";
|
|
|
|
type ChapterMeta = {
|
|
id: string;
|
|
number: number;
|
|
title: string;
|
|
totalPages: number;
|
|
};
|
|
|
|
type PageMeta = { number: number; width: number; height: number };
|
|
|
|
type PageReaderProps = {
|
|
mangaSlug: string;
|
|
mangaTitle: string;
|
|
startChapterNumber: number;
|
|
chapters: ChapterMeta[];
|
|
initialChapterMeta: PageMeta[];
|
|
resume: boolean;
|
|
};
|
|
|
|
const PREFETCH_NEXT_AT = 3;
|
|
const IMAGE_BATCH_RADIUS = 3;
|
|
const DOUBLE_TAP_MS = 280;
|
|
const KEEP_PREV_CHAPTER_PAGES = 5;
|
|
|
|
const pageKey = (chNum: number, pNum: number) => `${chNum}-${pNum}`;
|
|
|
|
type IntersectingPage = {
|
|
chNum: number;
|
|
pNum: number;
|
|
el: HTMLDivElement;
|
|
};
|
|
|
|
export function PageReader({
|
|
mangaSlug,
|
|
mangaTitle,
|
|
startChapterNumber,
|
|
chapters,
|
|
initialChapterMeta,
|
|
resume,
|
|
}: 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 [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
|
|
const [currentChapterNum, setCurrentChapterNum] =
|
|
useState(startChapterNumber);
|
|
const [currentPageNum, setCurrentPageNum] = useState(() => {
|
|
if (typeof window === "undefined" || !resume) return 1;
|
|
const p = readProgress(mangaSlug);
|
|
if (p && p.chapter === startChapterNumber && p.page > 1) return p.page;
|
|
return 1;
|
|
});
|
|
const currentRatioRef = useRef(0);
|
|
|
|
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 forceInflightRef = useRef<Set<string>>(new Set());
|
|
const radiusAbortRef = useRef<Map<number, AbortController>>(new Map());
|
|
const pageElRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
const viewportObserverRef = useRef<IntersectionObserver | null>(null);
|
|
const hiddenByScrollRef = useRef(false);
|
|
const drawerScrollRef = useRef<HTMLDivElement | null>(null);
|
|
const drawerActiveRef = useRef<HTMLAnchorElement | null>(null);
|
|
const intersectingPagesRef = useRef<Map<string, IntersectingPage>>(new Map());
|
|
const visibleKeysRef = useRef<Set<string>>(new Set());
|
|
|
|
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);
|
|
}
|
|
if (toFetch.length === 0) return;
|
|
const minP = toFetch[0];
|
|
const maxP = toFetch[toFetch.length - 1];
|
|
// One controller per live chapter — every batch for this chapter
|
|
// reuses the signal so chapter-unmount aborts them all in one shot.
|
|
let controller = radiusAbortRef.current.get(chapterNum);
|
|
if (!controller) {
|
|
controller = new AbortController();
|
|
radiusAbortRef.current.set(chapterNum, controller);
|
|
}
|
|
try {
|
|
const res = await fetch(
|
|
`/api/pages?chapter=${chapter.id}&offset=${minP - 1}&limit=${
|
|
maxP - minP + 1
|
|
}`,
|
|
{ signal: controller.signal }
|
|
);
|
|
if (!res.ok) return;
|
|
const batch: { number: number; imageUrl: string }[] = await res.json();
|
|
if (!Array.isArray(batch)) return;
|
|
setImages((prev) => {
|
|
const next = { ...prev };
|
|
for (const item of batch) {
|
|
next[pageKey(chapterNum, item.number)] = item.imageUrl;
|
|
}
|
|
return next;
|
|
});
|
|
} catch {
|
|
// aborted or failed — observer will re-trigger on next intersection
|
|
} finally {
|
|
for (const p of toFetch)
|
|
imagesInflightRef.current.delete(pageKey(chapterNum, p));
|
|
}
|
|
},
|
|
[chapterByNumber]
|
|
);
|
|
|
|
// Tracked separately from imagesInflightRef so rapid taps dedup against
|
|
// each other but don't block on a slow radius fetch already in flight.
|
|
const forceFetchPage = useCallback(
|
|
async (chapterNum: number, pageNum: number) => {
|
|
const chapter = chapterByNumber.get(chapterNum);
|
|
if (!chapter) return;
|
|
const key = pageKey(chapterNum, pageNum);
|
|
if (forceInflightRef.current.has(key)) return;
|
|
forceInflightRef.current.add(key);
|
|
try {
|
|
const res = await fetch(
|
|
`/api/pages?chapter=${chapter.id}&offset=${pageNum - 1}&limit=1`
|
|
);
|
|
if (!res.ok) return;
|
|
const batch: { number: number; imageUrl: string }[] = await res.json();
|
|
if (!Array.isArray(batch) || batch.length === 0) return;
|
|
setImages((prev) => ({
|
|
...prev,
|
|
[pageKey(chapterNum, batch[0].number)]: batch[0].imageUrl,
|
|
}));
|
|
} catch {
|
|
// user can tap again
|
|
} finally {
|
|
forceInflightRef.current.delete(key);
|
|
}
|
|
},
|
|
[chapterByNumber]
|
|
);
|
|
|
|
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`);
|
|
if (!res.ok) return;
|
|
const meta: PageMeta[] = await res.json();
|
|
if (!Array.isArray(meta)) return;
|
|
setChapterMetas((prev) => ({ ...prev, [next.number]: meta }));
|
|
} catch {
|
|
// will retry next observer fire
|
|
} finally {
|
|
metaInflightRef.current.delete(next.number);
|
|
}
|
|
},
|
|
[chapters]
|
|
);
|
|
|
|
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);
|
|
}
|
|
} else {
|
|
intersectingPagesRef.current.delete(key);
|
|
}
|
|
}
|
|
},
|
|
{ rootMargin: "1200px" }
|
|
);
|
|
|
|
viewportObserverRef.current = new IntersectionObserver(
|
|
(entries) => {
|
|
let changed = false;
|
|
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) {
|
|
if (!visibleKeysRef.current.has(key)) {
|
|
visibleKeysRef.current.add(key);
|
|
changed = true;
|
|
}
|
|
if (
|
|
!imagesRef.current[key] &&
|
|
!imagesInflightRef.current.has(key)
|
|
) {
|
|
forceFetchPage(chNum, pNum);
|
|
}
|
|
} else if (visibleKeysRef.current.delete(key)) {
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) setVisibleKeys(new Set(visibleKeysRef.current));
|
|
},
|
|
{ rootMargin: "0px" }
|
|
);
|
|
|
|
for (const el of pageElRef.current.values()) {
|
|
observerRef.current.observe(el);
|
|
viewportObserverRef.current.observe(el);
|
|
}
|
|
return () => {
|
|
observerRef.current?.disconnect();
|
|
viewportObserverRef.current?.disconnect();
|
|
};
|
|
}, [
|
|
fetchImagesAround,
|
|
forceFetchPage,
|
|
prefetchNextChapterMeta,
|
|
chapterByNumber,
|
|
]);
|
|
|
|
const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => {
|
|
const observer = observerRef.current;
|
|
const viewportObserver = viewportObserverRef.current;
|
|
const prev = pageElRef.current.get(key);
|
|
if (prev) {
|
|
observer?.unobserve(prev);
|
|
viewportObserver?.unobserve(prev);
|
|
}
|
|
if (el) {
|
|
pageElRef.current.set(key, el);
|
|
observer?.observe(el);
|
|
viewportObserver?.observe(el);
|
|
} else {
|
|
pageElRef.current.delete(key);
|
|
}
|
|
}, []);
|
|
|
|
// All reader Links use scroll={false} to preserve scroll during in-reader
|
|
// nav (natural scroll between chapters updates URL without remount). On
|
|
// a fresh mount we must actively position the scroll: resume-to-saved
|
|
// if ?resume=1 AND the saved chapter matches; otherwise top.
|
|
const resumeDoneRef = useRef(false);
|
|
useLayoutEffect(() => {
|
|
if (resumeDoneRef.current) return;
|
|
resumeDoneRef.current = true;
|
|
const instantTop = (top: number) =>
|
|
window.scrollTo({ top, behavior: "instant" as ScrollBehavior });
|
|
if (!resume) {
|
|
instantTop(0);
|
|
return;
|
|
}
|
|
const p = readProgress(mangaSlug);
|
|
if (!p || p.chapter !== startChapterNumber) {
|
|
instantTop(0);
|
|
return;
|
|
}
|
|
if (p.page <= 1 && p.ratio <= 0) {
|
|
instantTop(0);
|
|
return;
|
|
}
|
|
const scrollToResume = () => {
|
|
const el = pageElRef.current.get(pageKey(startChapterNumber, p.page));
|
|
if (!el) return;
|
|
instantTop(
|
|
scrollOffsetFromRatio(el.offsetTop, el.offsetHeight, p.ratio)
|
|
);
|
|
};
|
|
scrollToResume();
|
|
requestAnimationFrame(scrollToResume);
|
|
}, [mangaSlug, startChapterNumber, resume]);
|
|
|
|
useEffect(() => {
|
|
let rafId = 0;
|
|
const tick = () => {
|
|
rafId = 0;
|
|
const y = window.scrollY;
|
|
if (!hiddenByScrollRef.current && y > 50) {
|
|
hiddenByScrollRef.current = true;
|
|
setShowUI(false);
|
|
}
|
|
// Pick the topmost page whose top edge is above y+80 (top edge of the
|
|
// content below the sticky header); walking the small intersecting set.
|
|
let bestCh = 0;
|
|
let bestPg = 0;
|
|
let bestTop = -1;
|
|
let bestEl: HTMLDivElement | null = null;
|
|
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;
|
|
bestEl = el;
|
|
}
|
|
}
|
|
if (!bestEl) return;
|
|
currentRatioRef.current = calcScrollRatio(
|
|
y,
|
|
bestTop,
|
|
bestEl.offsetHeight
|
|
);
|
|
setCurrentChapterNum(bestCh);
|
|
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);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
writeProgress(mangaSlug, {
|
|
chapter: currentChapterNum,
|
|
page: currentPageNum,
|
|
ratio: currentRatioRef.current,
|
|
});
|
|
}, [mangaSlug, currentChapterNum, currentPageNum]);
|
|
|
|
// Aspect-ratio placeholders stay so layout is preserved; observer
|
|
// re-fetches images on scrollback into an unmounted chapter.
|
|
useEffect(() => {
|
|
const keep = new Set<number>([currentChapterNum]);
|
|
if (currentPageNum <= KEEP_PREV_CHAPTER_PAGES) {
|
|
keep.add(currentChapterNum - 1);
|
|
}
|
|
for (const [ch, ctrl] of radiusAbortRef.current) {
|
|
if (!keep.has(ch)) {
|
|
ctrl.abort();
|
|
radiusAbortRef.current.delete(ch);
|
|
}
|
|
}
|
|
setImages((prev) => {
|
|
let changed = false;
|
|
const next: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(prev)) {
|
|
const ch = Number(k.split("-")[0]);
|
|
if (keep.has(ch)) {
|
|
next[k] = v;
|
|
} else {
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed ? next : prev;
|
|
});
|
|
}, [currentChapterNum, currentPageNum]);
|
|
|
|
useEffect(() => {
|
|
const url = `/manga/${mangaSlug}/${currentChapterNum}`;
|
|
if (window.location.pathname === url) return;
|
|
window.history.replaceState(window.history.state, "", url);
|
|
}, [mangaSlug, currentChapterNum]);
|
|
|
|
const { prevChapter, nextChapter } = useMemo(() => {
|
|
const idx = chapters.findIndex((c) => c.number === currentChapterNum);
|
|
return {
|
|
prevChapter: idx > 0 ? chapters[idx - 1].number : null,
|
|
nextChapter:
|
|
idx >= 0 && idx < chapters.length - 1
|
|
? chapters[idx + 1].number
|
|
: null,
|
|
};
|
|
}, [chapters, currentChapterNum]);
|
|
|
|
const router = useRouter();
|
|
const touchMovedRef = useRef(false);
|
|
const singleTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const lastTapAtRef = useRef(0);
|
|
|
|
const onTouchStart = useCallback(() => {
|
|
touchMovedRef.current = false;
|
|
}, []);
|
|
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) {
|
|
if (singleTapTimerRef.current) {
|
|
clearTimeout(singleTapTimerRef.current);
|
|
singleTapTimerRef.current = null;
|
|
}
|
|
lastTapAtRef.current = 0;
|
|
const midX = window.innerWidth / 2;
|
|
if (e.clientX >= midX) {
|
|
if (nextChapter)
|
|
router.push(`/manga/${mangaSlug}/${nextChapter}`, {
|
|
scroll: false,
|
|
});
|
|
} else {
|
|
if (prevChapter)
|
|
router.push(`/manga/${mangaSlug}/${prevChapter}`, {
|
|
scroll: false,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
lastTapAtRef.current = now;
|
|
singleTapTimerRef.current = setTimeout(() => {
|
|
setShowUI((v) => !v);
|
|
singleTapTimerRef.current = null;
|
|
}, DOUBLE_TAP_MS);
|
|
},
|
|
[router, mangaSlug, nextChapter, prevChapter]
|
|
);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (singleTapTimerRef.current) clearTimeout(singleTapTimerRef.current);
|
|
},
|
|
[]
|
|
);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!showDrawer) return;
|
|
const scroll = drawerScrollRef.current;
|
|
const active = drawerActiveRef.current;
|
|
if (!scroll || !active) return;
|
|
const scrollRect = scroll.getBoundingClientRect();
|
|
const activeRect = active.getBoundingClientRect();
|
|
const delta =
|
|
activeRect.top -
|
|
scrollRect.top -
|
|
scroll.clientHeight / 2 +
|
|
active.clientHeight / 2;
|
|
scroll.scrollTop = Math.max(0, scroll.scrollTop + delta);
|
|
}, [showDrawer]);
|
|
|
|
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
|
|
className={`sticky top-0 z-50 bg-background backdrop-blur-sm shadow-sm transition-transform duration-300 pt-safe ${
|
|
showUI ? "translate-y-0" : "-translate-y-full"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3 px-4 h-12 max-w-4xl mx-auto">
|
|
<Link
|
|
href={`/manga/${mangaSlug}`}
|
|
scroll={false}
|
|
className="text-foreground/80 hover:text-foreground transition-colors shrink-0"
|
|
aria-label="Back to manga"
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
className="w-6 h-6"
|
|
>
|
|
<polyline points="15 18 9 12 15 6" />
|
|
</svg>
|
|
</Link>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-foreground text-sm font-medium truncate">
|
|
{mangaTitle}
|
|
</p>
|
|
<p className="text-muted text-xs truncate">
|
|
Ch. {currentChapter?.number} — {currentChapter?.title}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowDrawer(true)}
|
|
className="text-foreground/80 hover:text-foreground transition-colors shrink-0"
|
|
aria-label="Chapter list"
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
className="w-6 h-6"
|
|
>
|
|
<line x1="3" y1="6" x2="21" y2="6" />
|
|
<line x1="3" y1="12" x2="21" y2="12" />
|
|
<line x1="3" y1="18" x2="21" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="max-w-4xl mx-auto leading-[0] select-none"
|
|
onClick={onTap}
|
|
onTouchStart={onTouchStart}
|
|
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 isVisible = visibleKeys.has(key);
|
|
const aspect =
|
|
p.width > 0 && p.height > 0
|
|
? `${p.width} / ${p.height}`
|
|
: "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}`}
|
|
fetchPriority={isVisible ? "high" : "low"}
|
|
className="w-full h-auto block [-webkit-touch-callout:none]"
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<LoadingLogo
|
|
onTap={() => forceFetchPage(chNum, p.number)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{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">
|
|
<p className="text-xs uppercase tracking-wider text-muted">
|
|
End of Manga
|
|
</p>
|
|
<p className="text-base font-semibold">{mangaTitle}</p>
|
|
<Link
|
|
href="/"
|
|
scroll={false}
|
|
className="px-5 py-2 rounded-lg bg-accent text-white text-sm font-semibold transition-colors hover:bg-accent-hover"
|
|
>
|
|
Back to Home
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{showDrawer && (
|
|
<div
|
|
className="fixed inset-0 z-[60]"
|
|
onClick={() => setShowDrawer(false)}
|
|
>
|
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 max-h-[75vh] bg-background rounded-t-3xl shadow-2xl overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="sticky top-0 bg-background z-10 border-b border-border">
|
|
<div className="flex justify-center pt-2.5 pb-1.5">
|
|
<div className="w-10 h-1 rounded-full bg-muted/40" />
|
|
</div>
|
|
<div className="px-5 py-3 flex items-center justify-between">
|
|
<span className="text-foreground text-base font-bold">
|
|
Chapters
|
|
</span>
|
|
<span className="text-xs text-muted tabular-nums">
|
|
{chapters.length} total
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={drawerScrollRef}
|
|
className="overflow-y-auto max-h-[calc(75vh-4rem)] pb-safe"
|
|
>
|
|
{chapters.map((ch) => {
|
|
const isActive = ch.number === currentChapterNum;
|
|
return (
|
|
<Link
|
|
key={ch.number}
|
|
ref={isActive ? drawerActiveRef : undefined}
|
|
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"
|
|
: "hover:bg-surface active:bg-surface-hover"
|
|
}`}
|
|
onClick={() => setShowDrawer(false)}
|
|
>
|
|
<span
|
|
className={`font-bold tabular-nums w-10 shrink-0 ${
|
|
isActive ? "text-accent" : "text-muted"
|
|
}`}
|
|
>
|
|
#{ch.number}
|
|
</span>
|
|
<span
|
|
className={`truncate ${
|
|
isActive
|
|
? "text-accent font-semibold"
|
|
: "text-foreground"
|
|
}`}
|
|
>
|
|
{ch.title}
|
|
</span>
|
|
{isActive && (
|
|
<span className="ml-auto shrink-0 text-[10px] uppercase tracking-wider font-bold text-accent">
|
|
Current
|
|
</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|