yiekheng c099673f6b Switch nav toggle to tap-only and harden image protection
Replace scroll-direction nav reappear with a one-shot scroll-down hide
plus tap-on-image toggle. Distinguish tap from scroll on touch via
touchstart/touchmove tracking so swipes don't re-show the nav.
Discourage casual image saving with contextmenu prevent, draggable=false,
select-none, and -webkit-touch-callout:none. Add 10.8.0.2 to
allowedDevOrigins for VPN dev access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 07:56:39 +08:00

322 lines
9.6 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
type PageData = {
number: number;
imageUrl: string;
};
type ChapterInfo = {
number: number;
title: string;
};
type PageReaderProps = {
chapterId: number;
totalPages: number;
mangaSlug: string;
mangaTitle: string;
chapterNumber: number;
chapterTitle: string;
prevChapter: number | null;
nextChapter: number | null;
chapters: ChapterInfo[];
};
const BATCH_SIZE = 7;
const PREFETCH_AT = 3;
export function PageReader({
chapterId,
totalPages,
mangaSlug,
mangaTitle,
chapterNumber,
chapterTitle,
prevChapter,
nextChapter,
chapters,
}: PageReaderProps) {
const [showUI, setShowUI] = useState(true);
const [showDrawer, setShowDrawer] = useState(false);
const [pages, setPages] = useState<PageData[]>([]);
const hiddenByScrollRef = useRef(false);
const offsetRef = useRef(0);
const doneRef = useRef(false);
const loadingRef = useRef(false);
const triggerIndicesRef = useRef<Set<number>>(new Set());
const observerRef = useRef<IntersectionObserver | null>(null);
const pageRefsRef = useRef<Map<number, HTMLDivElement>>(new Map());
const fetchBatch = useCallback(async () => {
if (loadingRef.current || doneRef.current) return;
loadingRef.current = true;
try {
const res = await fetch(
`/api/pages?chapterId=${chapterId}&offset=${offsetRef.current}&limit=${BATCH_SIZE}`
);
const batch: PageData[] = await res.json();
if (batch.length === 0) {
doneRef.current = true;
} else {
const triggerIndex = offsetRef.current + PREFETCH_AT - 1;
triggerIndicesRef.current.add(triggerIndex);
// If trigger element is already mounted, observe it now
const existing = pageRefsRef.current.get(triggerIndex);
if (existing && observerRef.current) {
observerRef.current.observe(existing);
}
setPages((prev) => [...prev, ...batch]);
offsetRef.current += batch.length;
if (offsetRef.current >= totalPages) {
doneRef.current = true;
}
}
} catch {
// retry on next intersection
} finally {
loadingRef.current = false;
}
}, [chapterId, totalPages]);
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const index = Number(
(entry.target as HTMLElement).dataset.pageIndex
);
if (triggerIndicesRef.current.has(index)) {
triggerIndicesRef.current.delete(index);
fetchBatch();
}
}
}
},
{ rootMargin: "200px" }
);
return () => observerRef.current?.disconnect();
}, [fetchBatch]);
useEffect(() => {
fetchBatch();
}, [fetchBatch]);
const setPageRef = useCallback(
(index: number, el: HTMLDivElement | null) => {
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);
}
},
[]
);
// Distinguish tap from scroll on touch devices
const touchMovedRef = useRef(false);
const onTouchStart = useCallback(() => {
touchMovedRef.current = false;
}, []);
const onTouchMove = useCallback(() => {
touchMovedRef.current = true;
}, []);
const onTap = useCallback(() => {
if (touchMovedRef.current) return;
setShowUI((v) => !v);
}, []);
// Hide nav on first scroll down; after that, only tap toggles
useEffect(() => {
const handleScroll = () => {
if (!hiddenByScrollRef.current && window.scrollY > 50) {
hiddenByScrollRef.current = true;
setShowUI(false);
window.removeEventListener("scroll", handleScroll);
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<div className="min-h-screen bg-black relative">
{/* Top bar */}
<div
className={`fixed top-0 left-0 right-0 z-50 bg-black/90 backdrop-blur-sm transition-transform duration-300 ${
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}`}
className="text-white/80 hover:text-white transition-colors"
>
<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-white text-sm font-medium truncate">
{mangaTitle}
</p>
<p className="text-white/50 text-xs truncate">
Ch. {chapterNumber} {chapterTitle}
</p>
</div>
</div>
</div>
{/* Pages - vertical scroll (webtoon style, best for mobile) */}
<div
className="max-w-4xl mx-auto leading-[0] select-none"
onClick={onTap}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onContextMenu={(e) => e.preventDefault()}
>
{pages.map((page, i) => (
<div
key={page.number}
className="relative leading-[0]"
data-page-index={i}
ref={(el) => setPageRef(i, el)}
>
<img
src={page.imageUrl}
alt={`Page ${page.number}`}
className="w-full h-auto block align-bottom -mb-px [-webkit-touch-callout:none]"
draggable={false}
/>
</div>
))}
</div>
{/* Bottom navigation */}
<div
className={`fixed bottom-0 left-0 right-0 z-50 bg-black/90 backdrop-blur-sm transition-transform duration-300 pb-safe ${
showUI ? "translate-y-0" : "translate-y-full"
}`}
>
<div className="flex items-center justify-between px-4 h-14 max-w-4xl mx-auto">
{prevChapter ? (
<Link
href={`/manga/${mangaSlug}/${prevChapter}`}
className="flex items-center gap-1 text-white/80 hover:text-white text-sm transition-colors"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className="w-5 h-5"
>
<polyline points="15 18 9 12 15 6" />
</svg>
Prev
</Link>
) : (
<div />
)}
<button
onClick={() => setShowDrawer(true)}
className="flex items-center gap-1 text-white/80 hover:text-white text-sm transition-colors"
>
{chapterTitle}
</button>
{nextChapter ? (
<Link
href={`/manga/${mangaSlug}/${nextChapter}`}
className="flex items-center gap-1 text-white/80 hover:text-white text-sm transition-colors"
>
Next
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className="w-5 h-5"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</Link>
) : (
<Link
href={`/manga/${mangaSlug}`}
className="text-accent text-sm font-medium"
>
Done
</Link>
)}
</div>
</div>
{/* Chapter drawer overlay */}
{showDrawer && (
<div
className="fixed inset-0 z-[60]"
onClick={() => setShowDrawer(false)}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60" />
{/* Bottom sheet */}
<div
className="absolute bottom-0 left-0 right-0 max-h-[60vh] bg-zinc-900 rounded-t-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Handle + header */}
<div className="sticky top-0 bg-zinc-900 z-10">
<div className="flex justify-center pt-2 pb-1">
<div className="w-10 h-1 rounded-full bg-white/20" />
</div>
<div className="px-4 pb-2 border-b border-white/10">
<span className="text-white text-sm font-medium">Chapters</span>
</div>
</div>
{/* Chapter list */}
<div className="overflow-y-auto max-h-[calc(60vh-3rem)] pb-safe">
{chapters.map((ch) => (
<Link
key={ch.number}
href={`/manga/${mangaSlug}/${ch.number}`}
className={`block px-4 py-3 text-sm transition-colors ${
ch.number === chapterNumber
? "bg-white/10 text-white font-medium"
: "text-white/70 hover:bg-white/5 hover:text-white"
}`}
onClick={() => setShowDrawer(false)}
>
Ch. {ch.number} {ch.title}
</Link>
))}
</div>
</div>
</div>
)}
</div>
);
}