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 (
Chapter {chapterNumber}