292 lines
8.3 KiB
TypeScript
292 lines
8.3 KiB
TypeScript
import { ArrowLeft, ChevronLeft, ChevronRight, Home } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useNavigate, useParams } from "react-router";
|
|
import { useGetMangaContentImages } from "@/api/generated/content/content.ts";
|
|
import { useMarkContentAsRead } from "@/api/generated/user-interaction/user-interaction.ts";
|
|
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useReadingTracker } from "@/features/chapter/hooks/useReadingTracker.ts";
|
|
import { MangaLoadingState } from "@/components/MangaLoadingState.tsx";
|
|
|
|
const Chapter = () => {
|
|
const { setCurrentChapterPage, getCurrentChapterPage } = useReadingTracker();
|
|
|
|
const navigate = useNavigate();
|
|
const params = useParams();
|
|
const mangaId = Number(params.mangaId);
|
|
const chapterNumber = Number(params.chapterId);
|
|
|
|
const [currentPage, setCurrentPage] = useState(
|
|
getCurrentChapterPage(chapterNumber) ?? 1,
|
|
);
|
|
|
|
const [infiniteScroll, setInfiniteScroll] = useState(true);
|
|
|
|
const { data, isLoading } = useGetMangaContentImages(chapterNumber);
|
|
const { mutate } = useMarkContentAsRead();
|
|
|
|
// For infinite scroll mode
|
|
const [visibleCount, setVisibleCount] = useState(1);
|
|
const loadMoreRef = useRef(null);
|
|
|
|
/** Mark chapter as read when last page reached */
|
|
useEffect(() => {
|
|
if (!data || isLoading) return;
|
|
|
|
if (currentPage === data.data?.contentImageKeys.length) {
|
|
mutate({ mangaContentId: chapterNumber });
|
|
}
|
|
}, [data, mutate, currentPage]);
|
|
|
|
/** Persist reading progress */
|
|
useEffect(() => {
|
|
setCurrentChapterPage(chapterNumber, currentPage);
|
|
}, [chapterNumber, currentPage]);
|
|
|
|
/** Restore stored page */
|
|
useEffect(() => {
|
|
if (!isLoading && data?.data) {
|
|
const stored = getCurrentChapterPage(chapterNumber);
|
|
if (stored) {
|
|
setCurrentPage(stored);
|
|
setVisibleCount(stored); // for infinite scroll
|
|
}
|
|
}
|
|
}, [isLoading, data?.data]);
|
|
|
|
/** Infinite scroll observer */
|
|
useEffect(() => {
|
|
if (!infiniteScroll) return;
|
|
if (!loadMoreRef.current) return;
|
|
|
|
const obs = new IntersectionObserver((entries) => {
|
|
if (entries[0].isIntersecting) {
|
|
setVisibleCount((count) =>
|
|
Math.min(count + 2, data?.data?.contentImageKeys.length ?? 0),
|
|
);
|
|
}
|
|
});
|
|
|
|
obs.observe(loadMoreRef.current);
|
|
return () => obs.disconnect();
|
|
}, [infiniteScroll, data?.data]);
|
|
|
|
/** Track which image is currently visible (for progress update) */
|
|
useEffect(() => {
|
|
if (!infiniteScroll) return;
|
|
|
|
const imgs = document.querySelectorAll("[data-page]");
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
const el = entry.target as HTMLElement; // <-- FIX
|
|
if (entry.isIntersecting) {
|
|
const pageNum = Number(el.dataset.page); // <-- SAFE
|
|
setCurrentPage(pageNum);
|
|
}
|
|
});
|
|
},
|
|
{ threshold: 0.5 },
|
|
);
|
|
|
|
imgs.forEach((img) => observer.observe(img));
|
|
return () => observer.disconnect();
|
|
}, [infiniteScroll, visibleCount]);
|
|
|
|
useEffect(() => {
|
|
if (!data?.data) return;
|
|
|
|
// When switching modes:
|
|
if (infiniteScroll) {
|
|
// Scroll mode → show saved progress
|
|
setVisibleCount(currentPage);
|
|
setTimeout(() => {
|
|
const el = document.querySelector(`[data-page="${currentPage}"]`);
|
|
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}, 50);
|
|
} else {
|
|
// Single page mode → scroll to top
|
|
window.scrollTo({ top: 0 });
|
|
}
|
|
}, [infiniteScroll]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
|
<MangaLoadingState />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!data?.data) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
|
<div className="text-center">
|
|
<h1 className="text-2xl font-bold text-foreground">
|
|
Manga not found
|
|
</h1>
|
|
<Button onClick={() => navigate("/")} className="mt-4">
|
|
Go back home
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const images = data.data.contentImageKeys;
|
|
|
|
/** Standard navigation (non-infinite mode) */
|
|
const goToNextPage = () => {
|
|
if (currentPage < images.length) {
|
|
setCurrentPage((p) => p + 1);
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
}
|
|
};
|
|
const goToPreviousPage = () => {
|
|
if (currentPage > 1) {
|
|
setCurrentPage((p) => p - 1);
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
{/* HEADER */}
|
|
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
<div className="px-4 py-4 sm:px-8">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => navigate(`/manga/${mangaId}`)}
|
|
className="gap-2"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
<span className="hidden sm:inline">Back</span>
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => navigate("/")}
|
|
className="gap-2"
|
|
>
|
|
<Home className="h-4 w-4" />
|
|
<span className="hidden sm:inline">Home</span>
|
|
</Button>
|
|
|
|
<div className="hidden sm:block">
|
|
<h1 className="text-sm font-semibold text-foreground">
|
|
{data.data.mangaTitle}
|
|
</h1>
|
|
<p className="text-xs text-muted-foreground">
|
|
Chapter {chapterNumber}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-sm text-muted-foreground">
|
|
Page {currentPage} / {images.length}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setInfiniteScroll((v) => !v)}
|
|
className="text-xs"
|
|
>
|
|
{infiniteScroll ? "Single Page Mode" : "Scroll Mode"}
|
|
</Button>
|
|
<ThemeToggle />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* MAIN */}
|
|
<main className="mx-auto max-w-4xl px-4 py-8">
|
|
<div className="mb-4 sm:hidden">
|
|
<h1 className="text-lg font-semibold text-foreground">
|
|
{data.data.mangaTitle}
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Chapter {chapterNumber}
|
|
</p>
|
|
</div>
|
|
|
|
{/* ------------------------------------------------------------------ */}
|
|
{/* MODE 1 --- INFINITE SCROLL MODE */}
|
|
{/* ------------------------------------------------------------------ */}
|
|
{infiniteScroll ? (
|
|
<div className="flex flex-col space-y-0">
|
|
{images.slice(0, visibleCount).map((key, idx) => (
|
|
<img
|
|
key={idx}
|
|
data-page={idx + 1}
|
|
src={`${import.meta.env.VITE_OMV_BASE_URL}/${key}`}
|
|
className="w-full h-auto block"
|
|
alt={`Page ${idx + 1}`}
|
|
loading="lazy"
|
|
/>
|
|
))}
|
|
|
|
{/* LOAD MORE SENTINEL */}
|
|
<div ref={loadMoreRef} className="h-10" />
|
|
</div>
|
|
) : (
|
|
/* ------------------------------------------------------------------ */
|
|
/* MODE 2 --- STANDARD SINGLE-PAGE MODE */
|
|
/* ------------------------------------------------------------------ */
|
|
<>
|
|
<div className="relative mx-auto mb-8 overflow-hidden rounded-lg border border-border bg-muted">
|
|
<img
|
|
src={
|
|
import.meta.env.VITE_OMV_BASE_URL +
|
|
"/" +
|
|
images[currentPage - 1] || "/placeholder.svg"
|
|
}
|
|
alt={`Page ${currentPage}`}
|
|
width={1000}
|
|
height={1400}
|
|
className="h-auto w-full"
|
|
/>
|
|
</div>
|
|
|
|
{/* NAVIGATION BUTTONS */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-center gap-4">
|
|
<Button
|
|
onClick={goToPreviousPage}
|
|
disabled={currentPage === 1}
|
|
variant="outline"
|
|
className="gap-2 bg-transparent"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Previous Page
|
|
</Button>
|
|
|
|
<span className="text-sm font-medium text-foreground">
|
|
{currentPage} / {images.length}
|
|
</span>
|
|
|
|
<Button
|
|
onClick={goToNextPage}
|
|
disabled={currentPage === images.length}
|
|
variant="outline"
|
|
className="gap-2 bg-transparent"
|
|
>
|
|
Next Page
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Chapter;
|