frontend/src/pages/Chapter.tsx

550 lines
16 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 { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useReadingProgressSync } from "@/features/chapter/hooks/useReadingProgressSync.ts";
import { MangaLoadingState } from "@/components/MangaLoadingState.tsx";
const Chapter = () => {
const navigate = useNavigate();
const params = useParams();
const mangaId = Number(params.mangaId);
const chapterNumber = Number(params.chapterId);
const {
currentPage: savedPage,
serverProgress,
isLoadingProgress,
saveLocalProgress,
applyServerProgress,
} = useReadingProgressSync(mangaId, chapterNumber);
const [currentPage, setCurrentPage] = useState(1);
const [hasInitialized, setHasInitialized] = useState(false);
const [showConflictDialog, setShowConflictDialog] = useState(false);
const [isAutoScrolling, setIsAutoScrolling] = useState(false);
const initialJumpDone = useRef(false);
const isFirstMount = useRef(true);
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);
/** Conflict Resolution & Initial Sync */
useEffect(() => {
if (isLoadingProgress || hasInitialized) return;
const localPage = savedPage;
const serverPage = serverProgress?.pageNumber;
if (localPage === null) {
// No local history for this chapter, use server or default to 1
const targetPage = serverPage || 1;
setCurrentPage(targetPage);
setVisibleCount(targetPage);
if (targetPage > 1) {
setIsAutoScrolling(true);
}
setHasInitialized(true);
return;
}
if (serverPage && localPage !== serverPage) {
// If local is just starting out (page 1), just take server
if (localPage <= 1) {
const targetPage = serverPage;
setCurrentPage(targetPage);
setVisibleCount(targetPage);
if (targetPage > 1) {
setIsAutoScrolling(true);
}
setHasInitialized(true);
} else {
setShowConflictDialog(true);
}
} else {
const targetPage = localPage || 1;
setCurrentPage(targetPage);
setVisibleCount(targetPage);
if (targetPage > 1) {
setIsAutoScrolling(true);
}
setHasInitialized(true);
}
}, [isLoadingProgress, savedPage, serverProgress, hasInitialized]);
/** Mark chapter as read when last page reached */
useEffect(() => {
if (!data || isLoading || !hasInitialized || isAutoScrolling) return;
if (currentPage === data.data?.contentImageKeys.length) {
mutate({ mangaContentId: chapterNumber });
}
}, [data, mutate, currentPage, hasInitialized, isAutoScrolling]);
/** Persist reading progress */
useEffect(() => {
if (hasInitialized && !isAutoScrolling && initialJumpDone.current) {
saveLocalProgress(currentPage);
}
}, [currentPage, hasInitialized, isAutoScrolling, saveLocalProgress]);
/** Auto-scroll to restored page */
useEffect(() => {
if (!hasInitialized || isLoading || !data?.data || initialJumpDone.current)
return;
if (infiniteScroll && currentPage > 1) {
setIsAutoScrolling(true);
setVisibleCount(currentPage);
let attempts = 0;
const maxAttempts = 10;
const attemptJump = () => {
const el = document.querySelector(`[data-page="${currentPage}"]`);
if (el && (el as HTMLElement).offsetTop > 0) {
el.scrollIntoView({ behavior: "auto", block: "start" });
setTimeout(() => {
initialJumpDone.current = true;
setIsAutoScrolling(false);
isFirstMount.current = false;
}, 300);
} else if (attempts < maxAttempts) {
attempts++;
setTimeout(attemptJump, 50);
} else {
initialJumpDone.current = true;
setIsAutoScrolling(false);
isFirstMount.current = false;
}
};
attemptJump();
} else {
initialJumpDone.current = true;
isFirstMount.current = false;
}
}, [hasInitialized, isLoading, data?.data, infiniteScroll, currentPage]);
/** Infinite scroll observer (load more) */
useEffect(() => {
if (!infiniteScroll || !hasInitialized) 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, hasInitialized]);
/** Track which image is currently visible (for progress update) */
useEffect(() => {
if (!infiniteScroll || !hasInitialized || isAutoScrolling) return;
const imgs = document.querySelectorAll("[data-page]");
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const el = entry.target as HTMLElement;
const pageNum = Number(el.dataset.page);
setCurrentPage(pageNum);
// We only need the first one that intersects the top area
break;
}
}
},
{ threshold: 0, rootMargin: "-20% 0px -70% 0px" },
);
imgs.forEach((img) => observer.observe(img));
return () => observer.disconnect();
}, [infiniteScroll, visibleCount, hasInitialized, isAutoScrolling]);
useEffect(() => {
if (!data?.data || !hasInitialized || isFirstMount.current) return;
// When manually switching modes:
if (infiniteScroll) {
setVisibleCount(currentPage);
setTimeout(() => {
const el = document.querySelector(`[data-page="${currentPage}"]`);
el?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 50);
} else {
window.scrollTo({ top: 0 });
}
}, [infiniteScroll]);
if (isLoading || (isLoadingProgress && !hasInitialized)) {
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;
const previousContentId = data.data.previousContentId;
const nextContentId = data.data.nextContentId;
const goToNextChapter = () => {
if (nextContentId) {
setCurrentPage(1);
setVisibleCount(1);
window.scrollTo({ top: 0 });
navigate(`/manga/${mangaId}/chapter/${nextContentId}`);
}
};
const goToPreviousChapter = () => {
if (previousContentId) {
setCurrentPage(1);
setVisibleCount(1);
window.scrollTo({ top: 0 });
navigate(`/manga/${mangaId}/chapter/${previousContentId}`);
}
};
/** 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">
{/* Conflict Resolution Dialog */}
<Dialog open={showConflictDialog} onOpenChange={setShowConflictDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reading Progress Conflict</DialogTitle>
<DialogDescription>
You have different progress saved locally and on the server for
this chapter.
<br />
<span className="mt-2 block">
Local: <strong>Page {savedPage}</strong>
</span>
<span className="block">
Server: <strong>Page {serverProgress?.pageNumber}</strong>
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex flex-row justify-end gap-3">
<Button
variant="outline"
onClick={() => {
if (savedPage && savedPage > 1) {
setCurrentPage(savedPage);
setVisibleCount(savedPage);
setIsAutoScrolling(true);
initialJumpDone.current = false;
saveLocalProgress(savedPage, true);
}
setHasInitialized(true);
setShowConflictDialog(false);
}}
>
Use Local
</Button>
<Button
onClick={() => {
const target = applyServerProgress();
if (target) {
setCurrentPage(target);
setVisibleCount(target);
if (target > 1) {
setIsAutoScrolling(true);
initialJumpDone.current = false;
}
}
setHasInitialized(true);
setShowConflictDialog(false);
}}
>
Use Server
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* HEADER */}
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 text-xs sm:text-sm">
<div className="px-2 py-2 sm:px-4 sm:py-4">
<div className="flex items-center justify-between gap-2 sm:gap-4">
<div className="flex items-center gap-1 sm:gap-4 flex-1 min-w-0">
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/manga/${mangaId}`)}
className="gap-2 px-2 sm:px-3"
>
<ArrowLeft className="h-4 w-4" />
<span className="hidden md:inline">Back</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/")}
className="gap-2 px-1.5 sm:px-3"
>
<Home className="h-4 w-4" />
<span className="hidden md:inline">Home</span>
</Button>
</div>
<div className="flex items-center gap-1 shrink-0">
{previousContentId && (
<Button
variant="ghost"
size="sm"
onClick={goToPreviousChapter}
className="gap-1 px-1.5"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden sm:inline">Prev</span>
</Button>
)}
{nextContentId && (
<Button
variant="ghost"
size="sm"
onClick={goToNextChapter}
className="gap-1 px-1.5"
>
<span className="hidden sm:inline">Next</span>
<ChevronRight className="h-4 w-4" />
</Button>
)}
</div>
{/* Title Info - now visible on all screens in the middle */}
<div className="flex-1 min-w-0 px-1 text-center">
<h1 className="text-[10px] sm:text-xs font-semibold text-foreground truncate">
{data.data.mangaTitle}
</h1>
<p className="text-[8px] sm:text-[10px] text-muted-foreground truncate">
Chapter {chapterNumber}
</p>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-4 shrink-0">
<span className="text-[10px] sm:text-sm text-muted-foreground whitespace-nowrap">
{currentPage} / {images.length}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setInfiniteScroll((v) => !v)}
className="h-8 px-2 sm:px-3 text-[10px] sm:text-xs"
>
{infiniteScroll ? (
<span className="">Single</span>
) : (
<span className="">Scroll</span>
)}
<span className="hidden sm:inline"> Mode</span>
</Button>
</div>
</div>
</div>
</header>
{/* MAIN */}
<main
className={`mx-auto max-w-4xl ${infiniteScroll ? "px-0 py-0" : "px-4 py-8"
}`}
>
{/* Removed mobile header from main as it's now in the sticky header */}
{/* ------------------------------------------------------------------ */}
{/* MODE 1 --- INFINITE SCROLL MODE */}
{/* ------------------------------------------------------------------ */}
{infiniteScroll ? (
<div className="flex flex-col space-y-0">
{images.slice(0, visibleCount).map((key, idx) => (
<div
key={idx}
data-page={idx + 1}
className="w-full m-0 p-0"
style={{ aspectRatio: "2/3" }}
onLoadCapture={(e) => {
const target = e.currentTarget as HTMLElement;
target.style.aspectRatio = "auto";
}}
>
<img
src={`${import.meta.env.VITE_OMV_BASE_URL}/${key}`}
className="w-full h-auto block m-0 p-0 border-none"
alt={`Page ${idx + 1}`}
loading="lazy"
/>
</div>
))}
{/* LOAD MORE SENTINEL */}
<div ref={loadMoreRef} className="h-40" />
{/* CHAPTER NAVIGATION (infinite scroll mode) */}
{(previousContentId || nextContentId) && (
<div className="flex items-center justify-center gap-3 border-t border-border py-6">
{previousContentId ? (
<Button
onClick={goToPreviousChapter}
variant="outline"
className="gap-2 bg-transparent"
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
) : (
<div className="flex-1" />
)}
{nextContentId ? (
<Button
onClick={goToNextChapter}
variant="outline"
className="gap-2 bg-transparent"
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
) : (
<div className="flex-1" />
)}
</div>
)}
</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>
{/* CHAPTER NAVIGATION */}
{(previousContentId || nextContentId) && (
<div className="flex items-center justify-center gap-3 border-t border-border pt-4">
{previousContentId ? (
<Button
onClick={goToPreviousChapter}
variant="outline"
className="gap-2 bg-transparent"
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
) : (
<div className="flex-1" />
)}
{nextContentId ? (
<Button
onClick={goToNextChapter}
variant="outline"
className="gap-2 bg-transparent"
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
) : (
<div className="flex-1" />
)}
</div>
)}
</div>
</>
)}
</main>
</div>
);
};
export default Chapter;