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:
parent
c099673f6b
commit
06dcf0a649
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user