frontend/src/pages/Chapter.tsx

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;