diff --git a/src/components/reader/BookReader.tsx b/src/components/reader/BookReader.tsx index 47cfe45..d7a5f88 100644 --- a/src/components/reader/BookReader.tsx +++ b/src/components/reader/BookReader.tsx @@ -1,224 +1,43 @@ "use client"; -import { KomgaBook } from "@/types/komga"; -import { - ChevronLeft, - ChevronRight, - ImageOff, - Loader2, - LayoutTemplate, - SplitSquareVertical, - ChevronUp, -} from "lucide-react"; -import Image from "next/image"; -import { useEffect, useState, useCallback, useRef } from "react"; -import { cn } from "@/lib/utils"; +import { BookReaderProps } from "./types"; +import { useOrientation } from "./hooks/useOrientation"; +import { usePageNavigation } from "./hooks/usePageNavigation"; +import { usePageCache } from "./hooks/usePageCache"; +import { useState, useEffect, useCallback } from "react"; +import { NavigationBar } from "./components/NavigationBar"; +import { ControlButtons } from "./components/ControlButtons"; import { ImageLoader } from "@/components/ui/image-loader"; - -interface PageCache { - [pageNumber: number]: { - blob: Blob; - url: string; - timestamp: number; - loading?: Promise; - }; -} - -interface BookReaderProps { - book: KomgaBook; - pages: number[]; - onClose?: () => void; -} - -// Ajout du hook pour détecter l'orientation -const useOrientation = () => { - const [isLandscape, setIsLandscape] = useState(false); - - useEffect(() => { - const checkOrientation = () => { - // Vérifier si la fenêtre est plus large que haute - setIsLandscape(window.innerWidth > window.innerHeight); - }; - - // Vérification initiale - checkOrientation(); - - // Écouter les changements de taille de fenêtre - window.addEventListener("resize", checkOrientation); - - return () => { - window.removeEventListener("resize", checkOrientation); - }; - }, []); - - return isLandscape; -}; +import { cn } from "@/lib/utils"; export function BookReader({ book, pages, onClose }: BookReaderProps) { - const [currentPage, setCurrentPage] = useState(book.readProgress?.page || 1); - const [isLoading, setIsLoading] = useState(true); - const [secondPageLoading, setSecondPageLoading] = useState(true); - const [imageError, setImageError] = useState(false); const [isDoublePage, setIsDoublePage] = useState(false); - const [showNavigation, setShowNavigation] = useState(false); const [showControls, setShowControls] = useState(false); - const pageCache = useRef({}); const isLandscape = useOrientation(); - // Ajout d'un état pour le chargement des miniatures - const [loadedThumbnails, setLoadedThumbnails] = useState<{ [key: number]: boolean }>({}); + const { + currentPage, + setCurrentPage, + isLoading, + setIsLoading, + secondPageLoading, + setSecondPageLoading, + imageError, + setImageError, + handlePreviousPage, + handleNextPage, + shouldShowDoublePage, + syncReadProgress, + } = usePageNavigation({ + book, + pages, + isDoublePage, + }); - // Ajout d'un état pour les miniatures visibles - const [visibleThumbnails, setVisibleThumbnails] = useState([]); - const thumbnailObserver = useRef(null); - const thumbnailRefs = useRef<{ [key: number]: HTMLButtonElement | null }>({}); - - // Effet pour synchroniser la progression initiale - useEffect(() => { - if (book.readProgress?.page) { - syncReadProgress(book.readProgress.page); - } - }, []); - - // Fonction pour synchroniser la progression - const syncReadProgress = useCallback( - async (page: number) => { - try { - const completed = page === pages.length; - await fetch(`/api/komga/books/${book.id}/read-progress`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ page, completed }), - }); - } catch (error) { - console.error("Erreur lors de la synchronisation de la progression:", error); - } - }, - [book.id, pages.length] - ); - - // Fonction pour déterminer si on doit afficher une ou deux pages - const shouldShowDoublePage = useCallback( - (pageNumber: number) => { - if (!isDoublePage) return false; - // Toujours afficher la première page seule (couverture) - if (pageNumber === 1) return false; - // Vérifier si on a une page suivante disponible - return pageNumber < pages.length; - }, - [isDoublePage, pages.length] - ); - - const handlePreviousPage = useCallback(() => { - if (currentPage > 1) { - const newPage = isDoublePage && currentPage > 2 ? currentPage - 2 : currentPage - 1; - setCurrentPage(newPage); - setIsLoading(true); - setSecondPageLoading(true); - setImageError(false); - - // Synchroniser la progression après un court délai - const timer = setTimeout(() => { - syncReadProgress(newPage); - }, 300); - return () => clearTimeout(timer); - } - }, [currentPage, isDoublePage, syncReadProgress]); - - const handleNextPage = useCallback(() => { - if (currentPage < pages.length) { - const newPage = isDoublePage ? Math.min(currentPage + 2, pages.length) : currentPage + 1; - setCurrentPage(newPage); - setIsLoading(true); - setSecondPageLoading(true); - setImageError(false); - - // Synchroniser la progression après un court délai - const timer = setTimeout(() => { - syncReadProgress(newPage); - }, 300); - return () => clearTimeout(timer); - } - }, [currentPage, pages.length, isDoublePage, syncReadProgress]); - - // Réinitialiser l'état de chargement lors du changement de mode double page - useEffect(() => { - setIsLoading(true); - setSecondPageLoading(true); - }, [isDoublePage]); - - // Fonction pour précharger une page - const preloadPage = useCallback( - async (pageNumber: number) => { - if (pageNumber > pages.length || pageNumber < 1) return; - - // Si la page est déjà en cache, on ne fait rien - if (pageCache.current[pageNumber]?.url) return; - - // Si la page est en cours de chargement, on attend - if (pageCache.current[pageNumber]?.loading) { - await pageCache.current[pageNumber].loading; - return; - } - - // On crée une promesse pour le chargement - let resolveLoading: () => void; - const loadingPromise = new Promise((resolve) => { - resolveLoading = resolve; - }); - - // On initialise l'entrée dans le cache avec la promesse de chargement - pageCache.current[pageNumber] = { - ...pageCache.current[pageNumber], - loading: loadingPromise, - }; - - try { - const response = await fetch(`/api/komga/books/${book.id}/pages/${pageNumber}`); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - - pageCache.current[pageNumber] = { - blob, - url, - timestamp: Date.now(), - }; - - resolveLoading!(); - } catch (error) { - console.error(`Erreur lors du préchargement de la page ${pageNumber}:`, error); - delete pageCache.current[pageNumber]; - resolveLoading!(); - } - }, - [book.id, pages.length] - ); - - // Fonction pour obtenir l'URL d'une page - const getPageUrl = useCallback( - async (pageNumber: number) => { - // Si la page est dans le cache, utiliser l'URL du cache - if (pageCache.current[pageNumber]?.url) { - return pageCache.current[pageNumber].url; - } - - // Si la page est en cours de chargement, attendre - if (pageCache.current[pageNumber]?.loading) { - await pageCache.current[pageNumber].loading; - return pageCache.current[pageNumber].url; - } - - // Sinon, lancer le préchargement et attendre - await preloadPage(pageNumber); - return ( - pageCache.current[pageNumber]?.url || - `/api/komga/images/books/${book.id}/pages/${pageNumber}` - ); - }, - [book.id, preloadPage] - ); + const { preloadPage, getPageUrl, cleanCache } = usePageCache({ + book, + pages, + }); // État pour stocker les URLs des images const [currentPageUrl, setCurrentPageUrl] = useState(""); @@ -258,32 +77,6 @@ export function BookReader({ book, pages, onClose }: BookReaderProps) { }; }, [currentPage, isDoublePage, shouldShowDoublePage, getPageUrl]); - // Fonction pour obtenir l'URL d'une miniature - const getThumbnailUrl = useCallback( - (pageNumber: number) => { - return `/api/komga/images/books/${book.id}/pages/${pageNumber}/thumbnail`; - }, - [book.id] - ); - - // Nettoyer le cache des pages trop anciennes - const cleanCache = useCallback( - (currentPageNumber: number) => { - const maxDistance = 8; // On garde plus de pages en cache - const minPage = Math.max(1, currentPageNumber - maxDistance); - const maxPage = Math.min(pages.length, currentPageNumber + maxDistance); - - Object.entries(pageCache.current).forEach(([pageNum, cache]) => { - const page = parseInt(pageNum); - if (page < minPage || page > maxPage) { - URL.revokeObjectURL(cache.url); - delete pageCache.current[page]; - } - }); - }, - [pages.length] - ); - // Effet pour précharger la page courante et les pages adjacentes useEffect(() => { let isMounted = true; @@ -291,32 +84,26 @@ export function BookReader({ book, pages, onClose }: BookReaderProps) { const preloadCurrentPages = async () => { if (!isMounted) return; - // Précharger la page courante await preloadPage(currentPage); if (!isMounted) return; - // En mode double page, précharger la page suivante si nécessaire if (isDoublePage && shouldShowDoublePage(currentPage)) { await preloadPage(currentPage + 1); } if (!isMounted) return; - // Précharger les pages suivantes et précédentes const pagesToPreload = []; - // Pages suivantes (max 4) for (let i = 1; i <= 4 && currentPage + i <= pages.length; i++) { pagesToPreload.push(currentPage + i); } - // Pages précédentes (max 2) for (let i = 1; i <= 2 && currentPage - i >= 1; i++) { pagesToPreload.push(currentPage - i); } - // Précharger en séquence pour éviter de surcharger for (const page of pagesToPreload) { if (!isMounted) break; await preloadPage(page); @@ -331,190 +118,50 @@ export function BookReader({ book, pages, onClose }: BookReaderProps) { }; }, [currentPage, isDoublePage, shouldShowDoublePage, preloadPage, cleanCache, pages.length]); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "ArrowLeft") { - handlePreviousPage(); - } else if (event.key === "ArrowRight") { - handleNextPage(); - } else if (event.key === "Escape" && onClose) { - onClose(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [handlePreviousPage, handleNextPage, onClose]); - - // Fonction pour marquer une miniature comme chargée - const handleThumbnailLoad = (pageNumber: number) => { - setLoadedThumbnails((prev) => ({ ...prev, [pageNumber]: true })); - }; - - // Effet pour scroller jusqu'à la miniature active - const scrollToActiveThumbnail = useCallback(() => { - const thumbnail = document.getElementById(`thumbnail-${currentPage}`); - if (thumbnail) { - thumbnail.scrollIntoView({ - behavior: "smooth", - block: "nearest", - inline: "center", - }); - } - }, [currentPage]); - - // Effet pour centrer la miniature active quand les contrôles deviennent visibles - useEffect(() => { - if (showControls) { - scrollToActiveThumbnail(); - } - }, [showControls, scrollToActiveThumbnail]); - - // Effet pour centrer la miniature active au changement de page - useEffect(() => { - if (showControls) { - scrollToActiveThumbnail(); - } - }, [currentPage, showControls, scrollToActiveThumbnail]); - - // Fonction pour calculer les miniatures à afficher autour de la page courante - const updateVisibleThumbnails = useCallback(() => { - const windowSize = 20; // Nombre de miniatures à charger de chaque côté - const start = Math.max(1, currentPage - windowSize); - const end = Math.min(pages.length, currentPage + windowSize); - const visibleRange = Array.from({ length: end - start + 1 }, (_, i) => start + i); - setVisibleThumbnails(visibleRange); - }, [currentPage, pages.length]); - - // Effet pour mettre à jour les miniatures visibles lors du changement de page - useEffect(() => { - updateVisibleThumbnails(); - }, [currentPage, updateVisibleThumbnails]); - - // Fonction pour observer les miniatures - const observeThumbnail = useCallback( - (pageNumber: number) => { - if (!thumbnailRefs.current[pageNumber]) return; - - if (!thumbnailObserver.current) { - thumbnailObserver.current = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - const pageNumber = parseInt(entry.target.getAttribute("data-page") || "0"); - if (entry.isIntersecting && !loadedThumbnails[pageNumber]) { - // Charger la miniature uniquement si elle devient visible - setLoadedThumbnails((prev) => ({ ...prev, [pageNumber]: false })); - } - }); - }, - { - root: document.getElementById("thumbnails-container"), - rootMargin: "50px", - threshold: 0.1, - } - ); - } - - thumbnailObserver.current.observe(thumbnailRefs.current[pageNumber]); - }, - [loadedThumbnails] - ); - - // Nettoyer l'observer - useEffect(() => { - return () => { - if (thumbnailObserver.current) { - thumbnailObserver.current.disconnect(); - } - }; - }, []); - - const [touchStart, setTouchStart] = useState(null); - const [touchEnd, setTouchEnd] = useState(null); - - // Seuil minimum de déplacement pour déclencher un swipe - const minSwipeDistance = 50; - - const onTouchStart = (e: React.TouchEvent) => { - setTouchEnd(null); - setTouchStart(e.targetTouches[0].clientX); - }; - - const onTouchMove = (e: React.TouchEvent) => { - setTouchEnd(e.targetTouches[0].clientX); - }; - - const onTouchEnd = () => { - if (!touchStart || !touchEnd) return; - - const distance = touchStart - touchEnd; - const isLeftSwipe = distance > minSwipeDistance; - const isRightSwipe = distance < -minSwipeDistance; - - if (isLeftSwipe && currentPage < pages.length) { - handleNextPage(); - } - if (isRightSwipe && currentPage > 1) { - handlePreviousPage(); - } - }; - // Effet pour gérer le mode double page automatiquement en paysage useEffect(() => { setIsDoublePage(isLandscape); }, [isLandscape]); + const handleThumbnailLoad = useCallback( + (pageNumber: number) => { + if (pageNumber === currentPage) { + setIsLoading(false); + } else if (pageNumber === currentPage + 1) { + setSecondPageLoading(false); + } + }, + [currentPage] + ); + + const handlePageChange = useCallback( + (page: number) => { + setCurrentPage(page); + setIsLoading(true); + setImageError(false); + syncReadProgress(page); + }, + [setCurrentPage, setIsLoading, setImageError, syncReadProgress] + ); + return (
setShowControls(!showControls)} > {/* Contenu principal */}
- {/* Boutons en haut */} -
- -
- {/* Bouton précédent */} - {currentPage > 1 && ( - - )} + setShowControls(!showControls)} + onPreviousPage={handlePreviousPage} + onNextPage={handleNextPage} + onClose={onClose} + currentPage={currentPage} + totalPages={pages.length} + /> + {/* Pages */}
@@ -529,10 +176,7 @@ export function BookReader({ book, pages, onClose }: BookReaderProps) { "max-h-full w-auto object-contain transition-opacity duration-300", isLoading ? "opacity-0" : "opacity-100" )} - onLoad={() => { - setIsLoading(false); - handleThumbnailLoad(currentPage); - }} + onLoad={() => handleThumbnailLoad(currentPage)} /> )}
@@ -549,139 +193,22 @@ export function BookReader({ book, pages, onClose }: BookReaderProps) { "max-h-full w-auto object-contain transition-opacity duration-300", secondPageLoading ? "opacity-0" : "opacity-100" )} - onLoad={() => { - setSecondPageLoading(false); - handleThumbnailLoad(currentPage + 1); - }} + onLoad={() => handleThumbnailLoad(currentPage + 1)} /> )}
)}
- {/* Bouton suivant */} - {currentPage < pages.length && ( - - )} - {/* Bouton fermer */} - {onClose && ( - - )}
- {/* Barre de navigation des pages */} -
- {showControls && ( - <> -
- {/* Ajouter un élément vide au début pour le centrage */} -
- {pages.map((_, index) => { - const pageNumber = index + 1; - const isVisible = visibleThumbnails.includes(pageNumber); - - return ( - - ); - })} - {/* Ajouter un élément vide à la fin pour le centrage */} -
-
- - {/* Indicateur de page */} -
- Page {currentPage} - {shouldShowDoublePage(currentPage) ? `-${currentPage + 1}` : ""} / {pages.length} -
- - )} -
+
); diff --git a/src/components/reader/components/ControlButtons.tsx b/src/components/reader/components/ControlButtons.tsx new file mode 100644 index 0000000..2188e9a --- /dev/null +++ b/src/components/reader/components/ControlButtons.tsx @@ -0,0 +1,107 @@ +import { ControlButtonsProps } from "../types"; +import { cn } from "@/lib/utils"; +import { ChevronLeft, ChevronRight, LayoutTemplate, SplitSquareVertical } from "lucide-react"; + +export const ControlButtons = ({ + showControls, + onPrevious, + onNext, + onClose, + currentPage, + totalPages, + isDoublePage, + onToggleDoublePage, +}: ControlButtonsProps) => { + return ( + <> + {/* Boutons en haut */} +
+ +
+ + {/* Bouton précédent */} + {currentPage > 1 && ( + + )} + + {/* Bouton suivant */} + {currentPage < totalPages && ( + + )} + + {/* Bouton fermer */} + {onClose && ( + + )} + + ); +}; diff --git a/src/components/reader/components/NavigationBar.tsx b/src/components/reader/components/NavigationBar.tsx new file mode 100644 index 0000000..c732f60 --- /dev/null +++ b/src/components/reader/components/NavigationBar.tsx @@ -0,0 +1,71 @@ +import { NavigationBarProps } from "../types"; +import { cn } from "@/lib/utils"; +import { Thumbnail } from "./Thumbnail"; +import { useThumbnails } from "../hooks/useThumbnails"; +import { useEffect } from "react"; + +export const NavigationBar = ({ + currentPage, + pages, + onPageChange, + showControls, + book, +}: NavigationBarProps) => { + const { + loadedThumbnails, + handleThumbnailLoad, + getThumbnailUrl, + visibleThumbnails, + scrollToActiveThumbnail, + } = useThumbnails({ + book, + currentPage, + }); + + useEffect(() => { + if (showControls) { + scrollToActiveThumbnail(); + } + }, [showControls, currentPage, scrollToActiveThumbnail]); + + return ( +
+ {showControls && ( + <> +
+
+ {pages.map((_, index) => { + const pageNumber = index + 1; + const isVisible = visibleThumbnails.includes(pageNumber); + return ( + + ); + })} +
+
+ +
+ Page {currentPage} / {pages.length} +
+ + )} +
+ ); +}; diff --git a/src/components/reader/components/Thumbnail.tsx b/src/components/reader/components/Thumbnail.tsx new file mode 100644 index 0000000..36f010c --- /dev/null +++ b/src/components/reader/components/Thumbnail.tsx @@ -0,0 +1,76 @@ +import { ThumbnailProps } from "../types"; +import { ImageLoader } from "@/components/ui/image-loader"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { forwardRef, useEffect, useState } from "react"; + +export const Thumbnail = forwardRef( + ( + { + pageNumber, + currentPage, + onPageChange, + getThumbnailUrl, + loadedThumbnails, + onThumbnailLoad, + isVisible, + }, + ref + ) => { + const [imageUrl, setImageUrl] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (isVisible) { + const url = getThumbnailUrl(pageNumber); + setImageUrl(url); + if (!loadedThumbnails[pageNumber]) { + setIsLoading(true); + } + } + }, [isVisible, pageNumber, getThumbnailUrl, loadedThumbnails]); + + const handleImageLoad = () => { + setIsLoading(false); + if (!loadedThumbnails[pageNumber]) { + onThumbnailLoad(pageNumber); + } + }; + + return ( + + ); + } +); + +Thumbnail.displayName = "Thumbnail"; diff --git a/src/components/reader/hooks/useOrientation.ts b/src/components/reader/hooks/useOrientation.ts new file mode 100644 index 0000000..ad8de3a --- /dev/null +++ b/src/components/reader/hooks/useOrientation.ts @@ -0,0 +1,20 @@ +import { useState, useEffect } from "react"; + +export const useOrientation = () => { + const [isLandscape, setIsLandscape] = useState(false); + + useEffect(() => { + const checkOrientation = () => { + setIsLandscape(window.innerWidth > window.innerHeight); + }; + + checkOrientation(); + window.addEventListener("resize", checkOrientation); + + return () => { + window.removeEventListener("resize", checkOrientation); + }; + }, []); + + return isLandscape; +}; diff --git a/src/components/reader/hooks/usePageCache.ts b/src/components/reader/hooks/usePageCache.ts new file mode 100644 index 0000000..14619b7 --- /dev/null +++ b/src/components/reader/hooks/usePageCache.ts @@ -0,0 +1,97 @@ +import { useCallback, useRef } from "react"; +import { PageCache } from "../types"; +import { KomgaBook } from "@/types/komga"; + +interface UsePageCacheProps { + book: KomgaBook; + pages: number[]; +} + +export const usePageCache = ({ book, pages }: UsePageCacheProps) => { + const pageCache = useRef({}); + + const preloadPage = useCallback( + async (pageNumber: number) => { + if (pageNumber > pages.length || pageNumber < 1) return; + + if (pageCache.current[pageNumber]?.url) return; + + if (pageCache.current[pageNumber]?.loading) { + await pageCache.current[pageNumber].loading; + return; + } + + let resolveLoading: () => void; + const loadingPromise = new Promise((resolve) => { + resolveLoading = resolve; + }); + + pageCache.current[pageNumber] = { + ...pageCache.current[pageNumber], + loading: loadingPromise, + }; + + try { + const response = await fetch(`/api/komga/books/${book.id}/pages/${pageNumber}`); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + + pageCache.current[pageNumber] = { + blob, + url, + timestamp: Date.now(), + }; + + resolveLoading!(); + } catch (error) { + console.error(`Erreur lors du préchargement de la page ${pageNumber}:`, error); + delete pageCache.current[pageNumber]; + resolveLoading!(); + } + }, + [book.id, pages.length] + ); + + const getPageUrl = useCallback( + async (pageNumber: number) => { + if (pageCache.current[pageNumber]?.url) { + return pageCache.current[pageNumber].url; + } + + if (pageCache.current[pageNumber]?.loading) { + await pageCache.current[pageNumber].loading; + return pageCache.current[pageNumber].url; + } + + await preloadPage(pageNumber); + return ( + pageCache.current[pageNumber]?.url || + `/api/komga/images/books/${book.id}/pages/${pageNumber}` + ); + }, + [book.id, preloadPage] + ); + + const cleanCache = useCallback( + (currentPageNumber: number) => { + const maxDistance = 8; + const minPage = Math.max(1, currentPageNumber - maxDistance); + const maxPage = Math.min(pages.length, currentPageNumber + maxDistance); + + Object.entries(pageCache.current).forEach(([pageNum, cache]) => { + const page = parseInt(pageNum); + if (page < minPage || page > maxPage) { + URL.revokeObjectURL(cache.url); + delete pageCache.current[page]; + } + }); + }, + [pages.length] + ); + + return { + preloadPage, + getPageUrl, + cleanCache, + }; +}; diff --git a/src/components/reader/hooks/usePageNavigation.ts b/src/components/reader/hooks/usePageNavigation.ts new file mode 100644 index 0000000..095f38d --- /dev/null +++ b/src/components/reader/hooks/usePageNavigation.ts @@ -0,0 +1,105 @@ +import { useState, useCallback, useEffect } from "react"; +import { KomgaBook } from "@/types/komga"; + +interface UsePageNavigationProps { + book: KomgaBook; + pages: number[]; + isDoublePage: boolean; +} + +export const usePageNavigation = ({ book, pages, isDoublePage }: UsePageNavigationProps) => { + const [currentPage, setCurrentPage] = useState(book.readProgress?.page || 1); + const [isLoading, setIsLoading] = useState(true); + const [secondPageLoading, setSecondPageLoading] = useState(true); + const [imageError, setImageError] = useState(false); + + const syncReadProgress = useCallback( + async (page: number) => { + try { + const completed = page === pages.length; + await fetch(`/api/komga/books/${book.id}/read-progress`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ page, completed }), + }); + } catch (error) { + console.error("Erreur lors de la synchronisation de la progression:", error); + } + }, + [book.id, pages.length] + ); + + const shouldShowDoublePage = useCallback( + (pageNumber: number) => { + if (!isDoublePage) return false; + if (pageNumber === 1) return false; + return pageNumber < pages.length; + }, + [isDoublePage, pages.length] + ); + + const handlePreviousPage = useCallback(() => { + if (currentPage > 1) { + const newPage = isDoublePage && currentPage > 2 ? currentPage - 2 : currentPage - 1; + setCurrentPage(newPage); + setIsLoading(true); + setSecondPageLoading(true); + setImageError(false); + + const timer = setTimeout(() => { + syncReadProgress(newPage); + }, 300); + return () => clearTimeout(timer); + } + }, [currentPage, isDoublePage, syncReadProgress]); + + const handleNextPage = useCallback(() => { + if (currentPage < pages.length) { + const newPage = isDoublePage ? Math.min(currentPage + 2, pages.length) : currentPage + 1; + setCurrentPage(newPage); + setIsLoading(true); + setSecondPageLoading(true); + setImageError(false); + + const timer = setTimeout(() => { + syncReadProgress(newPage); + }, 300); + return () => clearTimeout(timer); + } + }, [currentPage, pages.length, isDoublePage, syncReadProgress]); + + useEffect(() => { + setIsLoading(true); + setSecondPageLoading(true); + }, [isDoublePage]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowLeft") { + handlePreviousPage(); + } else if (event.key === "ArrowRight") { + handleNextPage(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handlePreviousPage, handleNextPage]); + + return { + currentPage, + setCurrentPage, + isLoading, + setIsLoading, + secondPageLoading, + setSecondPageLoading, + imageError, + setImageError, + handlePreviousPage, + handleNextPage, + shouldShowDoublePage, + syncReadProgress, + }; +}; diff --git a/src/components/reader/hooks/useThumbnails.ts b/src/components/reader/hooks/useThumbnails.ts new file mode 100644 index 0000000..c53a743 --- /dev/null +++ b/src/components/reader/hooks/useThumbnails.ts @@ -0,0 +1,51 @@ +import { useState, useCallback, useEffect } from "react"; +import { KomgaBook } from "@/types/komga"; + +interface UseThumbnailsProps { + book: KomgaBook; + currentPage: number; +} + +export const useThumbnails = ({ book, currentPage }: UseThumbnailsProps) => { + const [loadedThumbnails, setLoadedThumbnails] = useState<{ [key: number]: boolean }>({}); + const [visibleThumbnails, setVisibleThumbnails] = useState([]); + + const handleThumbnailLoad = useCallback((pageNumber: number) => { + setLoadedThumbnails((prev) => ({ ...prev, [pageNumber]: true })); + }, []); + + const getThumbnailUrl = useCallback( + (pageNumber: number) => { + return `/api/komga/images/books/${book.id}/pages/${pageNumber}/thumbnail`; + }, + [book.id] + ); + + // Mettre à jour les thumbnails visibles autour de la page courante + useEffect(() => { + const windowSize = 10; // Nombre de pages à charger de chaque côté + const start = Math.max(1, currentPage - windowSize); + const end = currentPage + windowSize; + const newVisibleThumbnails = Array.from({ length: end - start + 1 }, (_, i) => start + i); + setVisibleThumbnails(newVisibleThumbnails); + }, [currentPage]); + + const scrollToActiveThumbnail = useCallback(() => { + const thumbnail = document.getElementById(`thumbnail-${currentPage}`); + if (thumbnail) { + thumbnail.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); + } + }, [currentPage]); + + return { + loadedThumbnails, + handleThumbnailLoad, + getThumbnailUrl, + visibleThumbnails, + scrollToActiveThumbnail, + }; +}; diff --git a/src/components/reader/types.ts b/src/components/reader/types.ts new file mode 100644 index 0000000..6498802 --- /dev/null +++ b/src/components/reader/types.ts @@ -0,0 +1,43 @@ +import { KomgaBook } from "@/types/komga"; + +export interface PageCache { + [pageNumber: number]: { + blob: Blob; + url: string; + timestamp: number; + loading?: Promise; + }; +} + +export interface BookReaderProps { + book: KomgaBook; + pages: number[]; + onClose?: () => void; +} + +export interface ThumbnailProps { + pageNumber: number; + currentPage: number; + onPageChange: (page: number) => void; + getThumbnailUrl: (pageNumber: number) => string; + loadedThumbnails: { [key: number]: boolean }; + onThumbnailLoad: (pageNumber: number) => void; + isVisible: boolean; +} + +export interface NavigationBarProps { + currentPage: number; + pages: number[]; + onPageChange: (page: number) => void; + showControls: boolean; + book: KomgaBook; +} + +export interface ControlButtonsProps { + showControls: boolean; + onToggleControls: () => void; + onPreviousPage: () => void; + onNextPage: () => void; + currentPage: number; + totalPages: number; +}