550 lines
16 KiB
TypeScript
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;
|