Rewrite reader for iOS Safari with continuous multi-chapter flow

Replaces the fixed-position reader with a sticky layout that works
correctly on iPhone Safari and Edge, while also auto-appending the
next chapter's pages when the current one finishes.

Layout
- Swap all position:fixed for sticky (Header, BottomNav, reader top nav)
  — fixed-positioning quirks broke the bottom nav in Edge and
  prevented Safari's URL bar from collapsing on scroll
- Viewport: viewport-fit=cover + interactiveWidget=overlays-content
  so manga extends edge-to-edge and the URL bar overlays content
  without resizing the viewport
- Add pt-safe / pb-safe utilities; apply on nav bars so chrome
  respects the notch and home-indicator
- Drop fixed-positioning bottom padding now that BottomNav is in flow

Continuous reading
- PageReader now receives the full chapter manifest (id + totalPages)
  and auto-fetches the next chapter when the current one is done
- Subtle chapter divider strip appears between chapters in the scroll
- Top nav chapter title updates as the user scrolls into a new chapter
  (rAF-throttled scroll listener, cached offsetTop)
- Double-tap on left/right viewport half navigates prev/next chapter
- End-of-manga footer fills the viewport with a Back-to-Manga action

Theme polish
- Light theme: white body/background, blue accent preserved for
  chapter numbers, badges, active states
- Modern chapter drawer: white sheet, rounded-t-3xl, two-column
  rows with chapter-number badge, blue highlight for current chapter
- Suppress hydration warnings for extension-injected attributes on
  <html> and the search input
- Manga detail CTA localized to 开始阅读

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-04-12 10:17:54 +08:00
parent c099673f6b
commit 06dcf0a649
8 changed files with 295 additions and 165 deletions

View File

@ -30,8 +30,12 @@
-webkit-tap-highlight-color: transparent;
}
html,
body {
background-color: var(--background);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans), system-ui, sans-serif;
overflow-x: hidden;
@ -71,4 +75,8 @@ html {
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
.pt-safe {
padding-top: env(safe-area-inset-top);
}
}

View File

@ -28,7 +28,12 @@ export const viewport: Viewport = {
initialScale: 1,
maximumScale: 1,
viewportFit: "cover",
themeColor: "#ffffff",
interactiveWidget: "overlays-content",
colorScheme: "light",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#ffffff" },
],
};
export default function RootLayout({
@ -37,10 +42,15 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${geistSans.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col bg-background text-foreground">
<html
lang="en"
className={`${geistSans.variable} antialiased`}
data-scroll-behavior="smooth"
suppressHydrationWarning
>
<body className="min-h-dvh flex flex-col bg-background text-foreground">
<Header />
<main className="flex-1 pb-20 sm:pb-0">{children}</main>
<main className="flex-1 bg-background">{children}</main>
<BottomNav />
</body>
</html>

View File

@ -54,18 +54,17 @@ export default async function ChapterReaderPage({ params }: Props) {
: null;
const allChapters = manga.chapters.map((c) => ({
id: c.id,
number: c.number,
title: c.title,
totalPages: c._count.pages,
}));
return (
<PageReader
chapterId={currentChapter.id}
totalPages={currentChapter._count.pages}
mangaSlug={manga.slug}
mangaTitle={manga.title}
chapterNumber={currentChapter.number}
chapterTitle={currentChapter.title}
startChapterNumber={currentChapter.number}
prevChapter={prevChapter}
nextChapter={nextChapter}
chapters={allChapters}

View File

@ -80,7 +80,7 @@ export default async function MangaDetailPage({ params }: Props) {
href={`/manga/${manga.slug}/${manga.chapters[0].number}`}
className="block w-full py-3 mb-6 text-center text-sm font-semibold bg-accent hover:bg-accent-hover text-white rounded-xl transition-colors active:scale-[0.98]"
>
Start Reading Ch. {manga.chapters[0].number}
</a>
)}

View File

@ -47,7 +47,7 @@ export function BottomNav() {
}
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-background/95 backdrop-blur-xl shadow-[0_-1px_3px_rgba(0,0,0,0.08)] sm:hidden pb-safe">
<nav className="sticky bottom-0 z-50 bg-background text-foreground backdrop-blur-xl shadow-[0_-1px_3px_rgba(0,0,0,0.08)] sm:hidden pb-safe">
<div className="flex items-center justify-around h-14">
{navItems.map((item) => {
const isActive =

View File

@ -18,7 +18,7 @@ export function Header() {
}
return (
<header className="sticky top-0 z-40 bg-background/95 backdrop-blur-xl shadow-sm">
<header className="sticky top-0 z-40 bg-background text-foreground backdrop-blur-xl shadow-sm pt-safe">
{/* Top row: logo + search */}
<div className="max-w-6xl mx-auto px-4 h-14 flex items-center gap-3">
<Link href="/" className="flex items-center gap-2.5 shrink-0">

View File

@ -2,86 +2,125 @@
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
type PageData = {
type ChapterMeta = {
id: number;
number: number;
title: string;
totalPages: number;
};
type LoadedPage = {
chapterNumber: number;
pageNumber: number;
imageUrl: string;
};
type RawPage = {
number: number;
imageUrl: string;
};
type ChapterInfo = {
number: number;
title: string;
};
type PageReaderProps = {
chapterId: number;
totalPages: number;
mangaSlug: string;
mangaTitle: string;
chapterNumber: number;
chapterTitle: string;
startChapterNumber: number;
prevChapter: number | null;
nextChapter: number | null;
chapters: ChapterInfo[];
chapters: ChapterMeta[];
};
const BATCH_SIZE = 7;
const PREFETCH_AT = 3;
export function PageReader({
chapterId,
totalPages,
mangaSlug,
mangaTitle,
chapterNumber,
chapterTitle,
startChapterNumber,
prevChapter,
nextChapter,
chapters,
}: PageReaderProps) {
const [showUI, setShowUI] = useState(true);
const [showDrawer, setShowDrawer] = useState(false);
const [pages, setPages] = useState<PageData[]>([]);
const [pages, setPages] = useState<LoadedPage[]>([]);
const [currentChapterNum, setCurrentChapterNum] =
useState(startChapterNumber);
const hiddenByScrollRef = useRef(false);
const fetchChapterIdxRef = useRef(
chapters.findIndex((c) => c.number === startChapterNumber)
);
const offsetRef = useRef(0);
const doneRef = useRef(false);
const loadingRef = useRef(false);
const doneRef = useRef(false);
// Count of pages already loaded — tracked via ref so fetchBatch stays stable
// (otherwise every batch re-creates fetchBatch and tears down the observer)
const loadedCountRef = useRef(0);
const triggerIndicesRef = useRef<Set<number>>(new Set());
const observerRef = useRef<IntersectionObserver | null>(null);
const pageRefsRef = useRef<Map<number, HTMLDivElement>>(new Map());
const advanceChapterOrFinish = useCallback(() => {
if (fetchChapterIdxRef.current + 1 < chapters.length) {
fetchChapterIdxRef.current += 1;
offsetRef.current = 0;
} else {
doneRef.current = true;
}
}, [chapters.length]);
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=${chapterId}&offset=${offsetRef.current}&limit=${BATCH_SIZE}`
`/api/pages?chapterId=${chapter.id}&offset=${offsetRef.current}&limit=${BATCH_SIZE}`
);
const batch: PageData[] = await res.json();
const batch: RawPage[] = await res.json();
if (batch.length === 0) {
doneRef.current = true;
} else {
const triggerIndex = offsetRef.current + PREFETCH_AT - 1;
triggerIndicesRef.current.add(triggerIndex);
advanceChapterOrFinish();
return;
}
// If trigger element is already mounted, observe it now
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);
}
setPages((prev) => [...prev, ...batch]);
offsetRef.current += batch.length;
if (offsetRef.current >= totalPages) {
doneRef.current = true;
}
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;
}
}, [chapterId, totalPages]);
}, [chapters, advanceChapterOrFinish]);
useEffect(() => {
observerRef.current = new IntersectionObserver(
@ -98,12 +137,15 @@ export function PageReader({
}
}
},
{ rootMargin: "200px" }
{ rootMargin: "400px" }
);
return () => observerRef.current?.disconnect();
}, [fetchBatch]);
const initialFetchRef = useRef(false);
useEffect(() => {
if (initialFetchRef.current) return;
initialFetchRef.current = true;
fetchBatch();
}, [fetchBatch]);
@ -111,10 +153,8 @@ export function PageReader({
(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)) {
@ -127,47 +167,106 @@ export function PageReader({
[]
);
// Distinguish tap from scroll on touch devices
const router = useRouter();
const touchMovedRef = useRef(false);
// Pending single-tap toggle, delayed so we can detect a double-tap first
const singleTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastTapAtRef = useRef(0);
const DOUBLE_TAP_MS = 280;
const onTouchStart = useCallback(() => {
touchMovedRef.current = false;
}, []);
const onTouchMove = useCallback(() => {
touchMovedRef.current = true;
}, []);
const onTap = useCallback(() => {
const onTap = useCallback(
(e: React.MouseEvent) => {
if (touchMovedRef.current) return;
setShowUI((v) => !v);
}, []);
const now = Date.now();
const isDoubleTap = now - lastTapAtRef.current < DOUBLE_TAP_MS;
if (isDoubleTap) {
// Cancel pending single-tap, navigate instead
if (singleTapTimerRef.current) {
clearTimeout(singleTapTimerRef.current);
singleTapTimerRef.current = null;
}
lastTapAtRef.current = 0;
const midX = window.innerWidth / 2;
if (e.clientX >= midX) {
if (nextChapter)
router.push(`/manga/${mangaSlug}/${nextChapter}`);
} else {
if (prevChapter)
router.push(`/manga/${mangaSlug}/${prevChapter}`);
}
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);
},
[]
);
// Hide nav on first scroll down; after that, only tap toggles
useEffect(() => {
const handleScroll = () => {
if (!hiddenByScrollRef.current && window.scrollY > 50) {
let rafId = 0;
const tick = () => {
rafId = 0;
const y = window.scrollY;
if (!hiddenByScrollRef.current && y > 50) {
hiddenByScrollRef.current = true;
setShowUI(false);
window.removeEventListener("scroll", handleScroll);
}
let current = startChapterNumber;
for (let i = 0; i < pages.length; i++) {
const el = pageRefsRef.current.get(i);
if (!el) continue;
if (el.offsetTop <= y + 80) current = pages[i].chapterNumber;
else break;
}
setCurrentChapterNum(current);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
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]);
const currentChapter =
chapters.find((c) => c.number === currentChapterNum) ??
chapters.find((c) => c.number === startChapterNumber);
return (
<div className="min-h-screen bg-black relative">
{/* Top bar */}
<div className="min-h-dvh bg-background">
<div
className={`fixed top-0 left-0 right-0 z-50 bg-black/90 backdrop-blur-sm transition-transform duration-300 ${
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}`}
className="text-white/80 hover:text-white transition-colors"
className="text-foreground/80 hover:text-foreground transition-colors shrink-0"
aria-label="Back to manga"
>
<svg
viewBox="0 0 24 24"
@ -180,17 +279,33 @@ export function PageReader({
</svg>
</Link>
<div className="min-w-0 flex-1">
<p className="text-white text-sm font-medium truncate">
<p className="text-foreground text-sm font-medium truncate">
{mangaTitle}
</p>
<p className="text-white/50 text-xs truncate">
Ch. {chapterNumber} {chapterTitle}
<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>
{/* Pages - vertical scroll (webtoon style, best for mobile) */}
<div
className="max-w-4xl mx-auto leading-[0] select-none"
onClick={onTap}
@ -198,120 +313,117 @@ export function PageReader({
onTouchMove={onTouchMove}
onContextMenu={(e) => e.preventDefault()}
>
{pages.map((page, i) => (
{pages.map((page, i) => {
const isChapterStart =
i === 0 || pages[i - 1].chapterNumber !== page.chapterNumber;
return (
<div
key={page.number}
key={`${page.chapterNumber}-${page.pageNumber}`}
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">
<p className="text-xs uppercase tracking-wider text-muted">
Chapter {page.chapterNumber}
</p>
<p className="text-sm font-semibold text-foreground">
{
chapters.find((c) => c.number === page.chapterNumber)
?.title
}
</p>
</div>
)}
<img
src={page.imageUrl}
alt={`Page ${page.number}`}
alt={`Page ${page.pageNumber}`}
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>
) : (
{doneRef.current && pages.length > 0 && (
<div className="min-h-dvh max-w-4xl mx-auto px-4 flex flex-col items-center justify-center text-center leading-normal gap-4">
<p className="text-xs uppercase tracking-wider text-muted">
End of Manga
</p>
<p className="text-base font-semibold">{mangaTitle}</p>
<Link
href={`/manga/${mangaSlug}`}
className="text-accent text-sm font-medium"
className="px-5 py-2 rounded-lg bg-accent text-white text-sm font-semibold transition-colors hover:bg-accent-hover"
>
Done
Back to Manga
</Link>
</div>
)}
</div>
</div>
{/* Chapter drawer overlay */}
{/* Chapter drawer overlay (modal — fixed is necessary to cover viewport) */}
{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 inset-0 bg-black/50 backdrop-blur-sm" />
<div
className="absolute bottom-0 left-0 right-0 max-h-[60vh] bg-zinc-900 rounded-t-2xl overflow-hidden"
className="absolute bottom-0 left-0 right-0 max-h-[75vh] bg-background rounded-t-3xl shadow-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 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-4 pb-2 border-b border-white/10">
<span className="text-white text-sm font-medium">Chapters</span>
<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>
{/* Chapter list */}
<div className="overflow-y-auto max-h-[calc(60vh-3rem)] pb-safe">
{chapters.map((ch) => (
<div className="overflow-y-auto max-h-[calc(75vh-4rem)] pb-safe">
{chapters.map((ch) => {
const isActive = ch.number === currentChapterNum;
return (
<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"
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)}
>
Ch. {ch.number} {ch.title}
<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>

View File

@ -73,6 +73,7 @@ export function SearchBar() {
onFocus={() => results.length > 0 && setOpen(true)}
placeholder="Search manga..."
className="w-full pl-10 pr-4 py-2 text-sm bg-surface border border-border rounded-xl focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent placeholder:text-muted transition-colors"
suppressHydrationWarning
/>
{loading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">