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 (
); } if (!data?.data) { return (

Manga not found

); } 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 (
{/* Conflict Resolution Dialog */} Reading Progress Conflict You have different progress saved locally and on the server for this chapter.
Local: Page {savedPage} Server: Page {serverProgress?.pageNumber}
{/* HEADER */}
{previousContentId && ( )} {nextContentId && ( )}
{/* Title Info - now visible on all screens in the middle */}

{data.data.mangaTitle}

Chapter {chapterNumber}

{currentPage} / {images.length}
{/* MAIN */}
{/* Removed mobile header from main as it's now in the sticky header */} {/* ------------------------------------------------------------------ */} {/* MODE 1 --- INFINITE SCROLL MODE */} {/* ------------------------------------------------------------------ */} {infiniteScroll ? (
{images.slice(0, visibleCount).map((key, idx) => (
{ const target = e.currentTarget as HTMLElement; target.style.aspectRatio = "auto"; }} > {`Page
))} {/* LOAD MORE SENTINEL */}
{/* CHAPTER NAVIGATION (infinite scroll mode) */} {(previousContentId || nextContentId) && (
{previousContentId ? ( ) : (
)} {nextContentId ? ( ) : (
)}
)}
) : ( /* ------------------------------------------------------------------ */ /* MODE 2 --- STANDARD SINGLE-PAGE MODE */ /* ------------------------------------------------------------------ */ <>
{`Page
{/* NAVIGATION BUTTONS */}
{currentPage} / {images.length}
{/* CHAPTER NAVIGATION */} {(previousContentId || nextContentId) && (
{previousContentId ? ( ) : (
)} {nextContentId ? ( ) : (
)}
)}
)}
); }; export default Chapter;