From 0ba027b625e1b8c0d1eee40887d87be9433822e2 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Wed, 22 Oct 2025 21:05:10 +0200 Subject: [PATCH] feat: refactor PhotoswipeReader to enhance modularity with new components and hooks for improved navigation, image loading, and touch handling --- src/components/reader/PhotoswipeReader.tsx | 585 +++--------------- .../reader/components/EndOfSeriesModal.tsx | 28 + .../reader/components/PageDisplay.tsx | 128 ++++ .../reader/components/ReaderContainer.tsx | 26 + .../reader/components/SinglePage.tsx | 63 -- .../reader/hooks/useDoublePageMode.ts | 34 + src/components/reader/hooks/useImageLoader.ts | 108 ++++ .../reader/hooks/usePageNavigation.ts | 117 ++++ .../reader/hooks/usePhotoSwipeZoom.ts | 74 +++ .../reader/hooks/useTouchNavigation.ts | 117 ++++ src/lib/services/circuit-breaker.service.ts | 2 + 11 files changed, 724 insertions(+), 558 deletions(-) create mode 100644 src/components/reader/components/EndOfSeriesModal.tsx create mode 100644 src/components/reader/components/PageDisplay.tsx create mode 100644 src/components/reader/components/ReaderContainer.tsx delete mode 100644 src/components/reader/components/SinglePage.tsx create mode 100644 src/components/reader/hooks/useDoublePageMode.ts create mode 100644 src/components/reader/hooks/useImageLoader.ts create mode 100644 src/components/reader/hooks/usePageNavigation.ts create mode 100644 src/components/reader/hooks/usePhotoSwipeZoom.ts create mode 100644 src/components/reader/hooks/useTouchNavigation.ts diff --git a/src/components/reader/PhotoswipeReader.tsx b/src/components/reader/PhotoswipeReader.tsx index 9ec1c8c..0a63356 100644 --- a/src/components/reader/PhotoswipeReader.tsx +++ b/src/components/reader/PhotoswipeReader.tsx @@ -1,52 +1,53 @@ /* eslint-disable @next/next/no-img-element */ "use client"; -import { useEffect, useRef, useState, useCallback } from "react"; -import PhotoSwipe from "photoswipe"; +import { useEffect, useState, useCallback, useRef } from "react"; import "photoswipe/style.css"; import type { BookReaderProps } from "./types"; -import { useOrientation } from "./hooks/useOrientation"; import { useFullscreen } from "./hooks/useFullscreen"; import { useReadingDirection } from "./hooks/useReadingDirection"; -import { useTranslate } from "@/hooks/useTranslate"; +import { useDoublePageMode } from "./hooks/useDoublePageMode"; +import { useImageLoader } from "./hooks/useImageLoader"; +import { usePageNavigation } from "./hooks/usePageNavigation"; +import { useTouchNavigation } from "./hooks/useTouchNavigation"; +import { usePhotoSwipeZoom } from "./hooks/usePhotoSwipeZoom"; import { ControlButtons } from "./components/ControlButtons"; import { NavigationBar } from "./components/NavigationBar"; -import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service"; -import { useRouter } from "next/navigation"; -import { cn } from "@/lib/utils"; +import { EndOfSeriesModal } from "./components/EndOfSeriesModal"; +import { PageDisplay } from "./components/PageDisplay"; +import { ReaderContainer } from "./components/ReaderContainer"; export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderProps) { - const router = useRouter(); - const { t } = useTranslate(); - const readerRef = useRef(null); - const pswpRef = useRef(null); - const [currentPage, setCurrentPage] = useState(() => { - const saved = ClientOfflineBookService.getCurrentPage(book); - return saved < 1 ? 1 : saved; - }); - const [isDoublePage, setIsDoublePage] = useState(false); const [showControls, setShowControls] = useState(false); const [showThumbnails, setShowThumbnails] = useState(false); - const [showEndMessage, setShowEndMessage] = useState(false); - const [loadedImages, setLoadedImages] = useState>({}); - const [isLoading, setIsLoading] = useState(true); - const [secondPageLoading, setSecondPageLoading] = useState(true); - const [imageBlobUrls, setImageBlobUrls] = useState>({}); - const isLandscape = useOrientation(); - const { direction, toggleDirection, isRTL } = useReadingDirection(); - const { isFullscreen, toggleFullscreen } = useFullscreen(); - const syncTimeoutRef = useRef(null); - const touchStartXRef = useRef(null); - const touchStartYRef = useRef(null); - const isPinchingRef = useRef(false); - const currentPageRef = useRef(currentPage); const lastClickTimeRef = useRef(0); const clickTimeoutRef = useRef(null); - // Garder currentPage à jour dans la ref pour le cleanup - useEffect(() => { - currentPageRef.current = currentPage; - }, [currentPage]); + // Hooks + const { direction, toggleDirection, isRTL } = useReadingDirection(); + const { isFullscreen, toggleFullscreen } = useFullscreen(); + const { isDoublePage, shouldShowDoublePage, toggleDoublePage } = useDoublePageMode(); + const { loadedImages, imageBlobUrls, loadImageDimensions, handleForceReload, getPageUrl } = useImageLoader(book.id, pages); + const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } = usePageNavigation({ + book, + pages, + isDoublePage, + shouldShowDoublePage: (page) => shouldShowDoublePage(page, pages.length), + onClose, + nextBook, + }); + const { pswpRef, handleZoom } = usePhotoSwipeZoom({ + loadedImages, + currentPage, + getPageUrl, + }); + + // Touch navigation + useTouchNavigation({ + onPreviousPage: handlePreviousPage, + onNextPage: handleNextPage, + pswpRef, + }); // Activer le zoom dans le reader en enlevant la classe no-pinch-zoom useEffect(() => { @@ -57,170 +58,23 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP }; }, []); - // Auto double page en paysage + + // Preload current and next pages dimensions useEffect(() => { - setIsDoublePage(isLandscape); - }, [isLandscape]); - - // Reset loading quand le mode double page change - useEffect(() => { - setIsLoading(true); - setSecondPageLoading(true); - }, [isDoublePage]); - - const shouldShowDoublePage = useCallback( - (pageNumber: number) => { - const isMobile = window.innerHeight < 700; - if (isMobile) return false; - if (!isDoublePage) return false; - if (pageNumber === 1) return false; - return pageNumber < pages.length; - }, - [isDoublePage, pages.length] - ); - - // Sync progress - const syncReadProgress = useCallback( - async (page: number) => { - try { - ClientOfflineBookService.setCurrentPage(book, page); - 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("Sync error:", error); - } - }, - [book, pages.length] - ); - - const debouncedSync = useCallback( - (page: number) => { - if (syncTimeoutRef.current) { - clearTimeout(syncTimeoutRef.current); - } - syncTimeoutRef.current = setTimeout(() => syncReadProgress(page), 500); - }, - [syncReadProgress] - ); - - const navigateToPage = useCallback( - (page: number) => { - if (page >= 1 && page <= pages.length) { - setCurrentPage(page); - setIsLoading(true); - setSecondPageLoading(true); - // Mettre à jour le localStorage immédiatement - ClientOfflineBookService.setCurrentPage(book, page); - // Débouncer seulement l'API Komga - debouncedSync(page); - } - }, - [pages.length, debouncedSync, book] - ); - - const handlePreviousPage = useCallback(() => { - if (currentPage === 1) return; - const step = isDoublePage && shouldShowDoublePage(currentPage - 2) ? 2 : 1; - navigateToPage(Math.max(1, currentPage - step)); - }, [currentPage, isDoublePage, navigateToPage, shouldShowDoublePage]); - - const handleNextPage = useCallback(() => { - if (currentPage === pages.length) { - if (nextBook) { - router.push(`/books/${nextBook.id}`); - return; - } - setShowEndMessage(true); - return; + loadImageDimensions(currentPage); + if (isDoublePage && shouldShowDoublePage(currentPage, pages.length)) { + loadImageDimensions(currentPage + 1); } - const step = isDoublePage && shouldShowDoublePage(currentPage) ? 2 : 1; - navigateToPage(Math.min(pages.length, currentPage + step)); - }, [currentPage, pages.length, isDoublePage, shouldShowDoublePage, navigateToPage, nextBook, router]); - - // Touch handlers for swipe navigation - const handleTouchStart = useCallback((e: TouchEvent) => { - // Ne pas gérer si Photoswipe est ouvert - if (pswpRef.current) return; - - // Détecter si c'est un pinch (2+ doigts) - if (e.touches.length > 1) { - isPinchingRef.current = true; - touchStartXRef.current = null; - touchStartYRef.current = null; - return; - } - - // Un seul doigt - seulement si on n'était pas en train de pinch - // On réinitialise isPinchingRef seulement ici, quand on commence un nouveau geste à 1 doigt - if (e.touches.length === 1) { - isPinchingRef.current = false; - touchStartXRef.current = e.touches[0].clientX; - touchStartYRef.current = e.touches[0].clientY; - } - }, []); - - const handleTouchMove = useCallback((e: TouchEvent) => { - // Détecter le pinch pendant le mouvement - if (e.touches.length > 1) { - isPinchingRef.current = true; - touchStartXRef.current = null; - touchStartYRef.current = null; - } - }, []); - - const handleTouchEnd = useCallback((e: TouchEvent) => { - // Si on était en mode pinch, ne JAMAIS traiter le swipe - if (isPinchingRef.current) { - touchStartXRef.current = null; - touchStartYRef.current = null; - // Ne PAS réinitialiser isPinchingRef ici, on le fera au prochain touchstart - return; - } - - // Vérifier qu'on a bien des coordonnées de départ - if (touchStartXRef.current === null || touchStartYRef.current === null) return; - if (pswpRef.current) return; // Ne pas gérer si Photoswipe est ouvert - - const touchEndX = e.changedTouches[0].clientX; - const touchEndY = e.changedTouches[0].clientY; - const deltaX = touchEndX - touchStartXRef.current; - const deltaY = touchEndY - touchStartYRef.current; - - // Si le déplacement vertical est plus important, on ignore (scroll) - if (Math.abs(deltaY) > Math.abs(deltaX)) { - touchStartXRef.current = null; - touchStartYRef.current = null; - return; - } - - // Seuil de 50px pour changer de page - if (Math.abs(deltaX) > 50) { - if (deltaX > 0) { - // Swipe vers la droite - if (isRTL) { - handleNextPage(); - } else { - handlePreviousPage(); - } - } else { - // Swipe vers la gauche - if (isRTL) { - handlePreviousPage(); - } else { - handleNextPage(); - } + // Preload next + if (currentPage < pages.length) { + loadImageDimensions(currentPage + 1); + if (isDoublePage && currentPage + 1 < pages.length) { + loadImageDimensions(currentPage + 2); } } + }, [currentPage, isDoublePage, shouldShowDoublePage, loadImageDimensions, pages.length]); - touchStartXRef.current = null; - touchStartYRef.current = null; - }, [handleNextPage, handlePreviousPage, isRTL]); - - // Keyboard & Touch events + // Keyboard events useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowLeft") { @@ -244,178 +98,11 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP }; window.addEventListener("keydown", handleKeyDown); - window.addEventListener("touchstart", handleTouchStart); - window.addEventListener("touchmove", handleTouchMove); - window.addEventListener("touchend", handleTouchEnd); return () => { window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("touchstart", handleTouchStart); - window.removeEventListener("touchmove", handleTouchMove); - window.removeEventListener("touchend", handleTouchEnd); }; - }, [handleNextPage, handlePreviousPage, handleTouchStart, handleTouchMove, handleTouchEnd, onClose, isRTL, currentPage]); - - // Cleanup - Sync final sans debounce - useEffect(() => { - return () => { - if (syncTimeoutRef.current) { - clearTimeout(syncTimeoutRef.current); - } - // Sync immédiatement au cleanup avec la VRAIE valeur actuelle - syncReadProgress(currentPageRef.current); - }; - }, [syncReadProgress]); - - const getPageUrl = useCallback((pageNum: number) => `/api/komga/books/${book.id}/pages/${pageNum}`, [book.id]); - - // Load image dimensions - const loadImageDimensions = useCallback((pageNum: number) => { - if (loadedImages[pageNum]) return; - - const img = new Image(); - img.onload = () => { - setLoadedImages(prev => ({ - ...prev, - [pageNum]: { width: img.naturalWidth, height: img.naturalHeight } - })); - }; - img.src = getPageUrl(pageNum); - }, [loadedImages, getPageUrl]); - - // Preload current and next pages dimensions - useEffect(() => { - loadImageDimensions(currentPage); - if (isDoublePage && shouldShowDoublePage(currentPage)) { - loadImageDimensions(currentPage + 1); - } - // Preload next - if (currentPage < pages.length) { - loadImageDimensions(currentPage + 1); - if (isDoublePage && currentPage + 1 < pages.length) { - loadImageDimensions(currentPage + 2); - } - } - }, [currentPage, isDoublePage, shouldShowDoublePage, loadImageDimensions, pages.length]); - - // Handle zoom via button - const handleZoom = useCallback(() => { - const dims = loadedImages[currentPage]; - if (!dims) return; - - const dataSource = [{ - src: getPageUrl(currentPage), - width: dims.width, - height: dims.height, - alt: `Page ${currentPage}` - }]; - - // Close any existing instance - if (pswpRef.current) { - pswpRef.current.close(); - } - - // Create and open PhotoSwipe - const pswp = new PhotoSwipe({ - dataSource, - index: 0, - bgOpacity: 0.9, - showHideAnimationType: 'fade', - initialZoomLevel: 0.25, - secondaryZoomLevel: 0.5, // Niveau de zoom au double-clic - maxZoomLevel: 4, - clickToCloseNonZoomable: true, // Ferme au clic simple - tapAction: 'zoom', // Ferme au tap - wheelToZoom: true, - pinchToClose: false, // Pinch pour fermer - closeOnVerticalDrag: true, // Swipe vertical pour fermer - escKey: true, // ESC ferme le zoom - arrowKeys: false, // On gère les flèches nous-mêmes - }); - - pswpRef.current = pswp; - pswp.init(); - - // Clean up on close - pswp.on('close', () => { - pswpRef.current = null; - }); - }, [loadedImages, currentPage, getPageUrl]); - - // Cleanup PhotoSwipe on unmount - useEffect(() => { - return () => { - if (pswpRef.current) { - pswpRef.current.close(); - } - // Révoquer toutes les blob URLs - Object.values(imageBlobUrls).forEach(url => { - if (url) URL.revokeObjectURL(url); - }); - }; - }, [imageBlobUrls]); - - // Force reload handler - const handleForceReload = useCallback(async () => { - setIsLoading(true); - setSecondPageLoading(true); - - // Révoquer les anciennes URLs blob - if (imageBlobUrls[currentPage]) { - URL.revokeObjectURL(imageBlobUrls[currentPage]); - } - if (imageBlobUrls[currentPage + 1]) { - URL.revokeObjectURL(imageBlobUrls[currentPage + 1]); - } - - try { - // Fetch page 1 avec cache: reload - const response1 = await fetch(getPageUrl(currentPage), { - cache: 'reload', - headers: { - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache' - } - }); - - if (!response1.ok) { - throw new Error(`HTTP ${response1.status}`); - } - - const blob1 = await response1.blob(); - const blobUrl1 = URL.createObjectURL(blob1); - - const newUrls: Record = { - ...imageBlobUrls, - [currentPage]: blobUrl1 - }; - - // Fetch page 2 si double page - if (isDoublePage && shouldShowDoublePage(currentPage)) { - const response2 = await fetch(getPageUrl(currentPage + 1), { - cache: 'reload', - headers: { - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache' - } - }); - - if (!response2.ok) { - throw new Error(`HTTP ${response2.status}`); - } - - const blob2 = await response2.blob(); - const blobUrl2 = URL.createObjectURL(blob2); - newUrls[currentPage + 1] = blobUrl2; - } - - setImageBlobUrls(newUrls); - } catch (error) { - console.error('Error reloading images:', error); - setIsLoading(false); - setSecondPageLoading(false); - } - }, [currentPage, imageBlobUrls, isDoublePage, shouldShowDoublePage, getPageUrl]); + }, [handleNextPage, handlePreviousPage, onClose, isRTL, currentPage]); const handleContainerClick = useCallback((e: React.MouseEvent) => { // Vérifier si c'est un double-clic sur une image @@ -450,144 +137,52 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP }, [showControls, handleZoom]); return ( -
-
- {showEndMessage && ( -
-
-

{t("reader.endOfSeries")}

-

{t("reader.endOfSeriesMessage")}

- -
-
- )} + + undefined)} + currentPage={currentPage} + /> - setShowControls(!showControls)} - onPreviousPage={handlePreviousPage} - onNextPage={handleNextPage} - onPageChange={navigateToPage} - onClose={onClose} - currentPage={currentPage} - totalPages={pages.length} - isDoublePage={isDoublePage} - onToggleDoublePage={() => setIsDoublePage(!isDoublePage)} - isFullscreen={isFullscreen} - onToggleFullscreen={() => toggleFullscreen(readerRef.current)} - direction={direction} - onToggleDirection={toggleDirection} - showThumbnails={showThumbnails} - onToggleThumbnails={() => setShowThumbnails(!showThumbnails)} - onZoom={handleZoom} - onForceReload={handleForceReload} - /> + setShowControls(!showControls)} + onPreviousPage={handlePreviousPage} + onNextPage={handleNextPage} + onPageChange={navigateToPage} + onClose={onClose} + currentPage={currentPage} + totalPages={pages.length} + isDoublePage={isDoublePage} + onToggleDoublePage={toggleDoublePage} + isFullscreen={isFullscreen} + onToggleFullscreen={() => toggleFullscreen(document.body)} + direction={direction} + onToggleDirection={toggleDirection} + showThumbnails={showThumbnails} + onToggleThumbnails={() => setShowThumbnails(!showThumbnails)} + onZoom={handleZoom} + onForceReload={() => handleForceReload(currentPage, isDoublePage, (page) => shouldShowDoublePage(page, pages.length))} + /> - {/* Reader content */} -
-
- {/* Page 1 */} -
- {isLoading && ( -
-
-
-
-
-
- )} - {`Page setIsLoading(false)} - onError={() => setIsLoading(false)} - ref={(img) => { - // Si l'image est déjà en cache, onLoad ne sera pas appelé - if (img?.complete && img?.naturalHeight !== 0) { - setIsLoading(false); - } - }} - /> -
+ shouldShowDoublePage(page, pages.length)} + imageBlobUrls={imageBlobUrls} + getPageUrl={getPageUrl} + /> - {/* Page 2 (double page) */} - {isDoublePage && shouldShowDoublePage(currentPage) && ( -
- {secondPageLoading && ( -
-
-
-
-
-
- )} - {`Page setSecondPageLoading(false)} - onError={() => setSecondPageLoading(false)} - ref={(img) => { - // Si l'image est déjà en cache, onLoad ne sera pas appelé - if (img?.complete && img?.naturalHeight !== 0) { - setSecondPageLoading(false); - } - }} - /> -
- )} -
-
- - -
-
+ + ); } diff --git a/src/components/reader/components/EndOfSeriesModal.tsx b/src/components/reader/components/EndOfSeriesModal.tsx new file mode 100644 index 0000000..70c4a4e --- /dev/null +++ b/src/components/reader/components/EndOfSeriesModal.tsx @@ -0,0 +1,28 @@ +import { useTranslate } from "@/hooks/useTranslate"; + +interface EndOfSeriesModalProps { + show: boolean; + onClose: (currentPage: number) => void; + currentPage: number; +} + +export function EndOfSeriesModal({ show, onClose, currentPage }: EndOfSeriesModalProps) { + const { t } = useTranslate(); + + if (!show) return null; + + return ( +
+
+

{t("reader.endOfSeries")}

+

{t("reader.endOfSeriesMessage")}

+ +
+
+ ); +} diff --git a/src/components/reader/components/PageDisplay.tsx b/src/components/reader/components/PageDisplay.tsx new file mode 100644 index 0000000..b4bc243 --- /dev/null +++ b/src/components/reader/components/PageDisplay.tsx @@ -0,0 +1,128 @@ +import { useState, useCallback, useEffect } from "react"; +import { cn } from "@/lib/utils"; +import { useReadingDirection } from "../hooks/useReadingDirection"; + +interface PageDisplayProps { + currentPage: number; + pages: number[]; + isDoublePage: boolean; + shouldShowDoublePage: (page: number) => boolean; + imageBlobUrls: Record; + getPageUrl: (pageNum: number) => string; +} + +export function PageDisplay({ + currentPage, + pages: _pages, + isDoublePage, + shouldShowDoublePage, + imageBlobUrls, + getPageUrl, +}: PageDisplayProps) { + const [isLoading, setIsLoading] = useState(true); + const [secondPageLoading, setSecondPageLoading] = useState(true); + const { isRTL } = useReadingDirection(); + + const handleImageLoad = useCallback(() => { + setIsLoading(false); + }, []); + + const handleSecondImageLoad = useCallback(() => { + setSecondPageLoading(false); + }, []); + + // Reset loading when page changes + useEffect(() => { + setIsLoading(true); + setSecondPageLoading(true); + }, [currentPage, isDoublePage]); + + return ( +
+
+ {/* Page 1 */} +
+ {isLoading && ( +
+
+
+
+
+
+ )} + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Page { + // Si l'image est déjà en cache, onLoad ne sera pas appelé + if (img?.complete && img?.naturalHeight !== 0) { + handleImageLoad(); + } + }} + /> +
+ + {/* Page 2 (double page) */} + {isDoublePage && shouldShowDoublePage(currentPage) && ( +
+ {secondPageLoading && ( +
+
+
+
+
+
+ )} + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Page { + // Si l'image est déjà en cache, onLoad ne sera pas appelé + if (img?.complete && img?.naturalHeight !== 0) { + handleSecondImageLoad(); + } + }} + /> +
+ )} +
+
+ ); +} diff --git a/src/components/reader/components/ReaderContainer.tsx b/src/components/reader/components/ReaderContainer.tsx new file mode 100644 index 0000000..f8a0034 --- /dev/null +++ b/src/components/reader/components/ReaderContainer.tsx @@ -0,0 +1,26 @@ +import { useRef, useCallback } from "react"; + +interface ReaderContainerProps { + children: React.ReactNode; + onContainerClick: (e: React.MouseEvent) => void; +} + +export function ReaderContainer({ children, onContainerClick }: ReaderContainerProps) { + const readerRef = useRef(null); + + const handleContainerClick = useCallback((e: React.MouseEvent) => { + onContainerClick(e); + }, [onContainerClick]); + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/components/reader/components/SinglePage.tsx b/src/components/reader/components/SinglePage.tsx deleted file mode 100644 index 6d9f074..0000000 --- a/src/components/reader/components/SinglePage.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { cn } from "@/lib/utils"; -import { ImageLoader } from "@/components/ui/image-loader"; - -interface SinglePageProps { - pageUrl: string; - pageNumber: number; - isLoading: boolean; - onLoad: (pageNumber: number) => void; - isDoublePage?: boolean; - isRTL?: boolean; - order?: "first" | "second"; - zoomLevel?: number; - panPosition?: { x: number; y: number }; - onDoubleClick?: () => void; -} - -export const SinglePage = ({ - pageUrl, - pageNumber, - isLoading, - onLoad, - isDoublePage = false, - isRTL = false, - order = "first", - zoomLevel = 1, - panPosition = { x: 0, y: 0 }, - onDoubleClick, -}: SinglePageProps) => { - return ( -
- - {pageUrl && ( - // eslint-disable-next-line @next/next/no-img-element - {`Page onLoad(pageNumber)} - onDoubleClick={onDoubleClick} - /> - )} -
- ); -}; diff --git a/src/components/reader/hooks/useDoublePageMode.ts b/src/components/reader/hooks/useDoublePageMode.ts new file mode 100644 index 0000000..5577afd --- /dev/null +++ b/src/components/reader/hooks/useDoublePageMode.ts @@ -0,0 +1,34 @@ +import { useState, useEffect, useCallback } from "react"; +import { useOrientation } from "./useOrientation"; + +export function useDoublePageMode() { + const isLandscape = useOrientation(); + const [isDoublePage, setIsDoublePage] = useState(false); + + // Auto double page en paysage + useEffect(() => { + setIsDoublePage(isLandscape); + }, [isLandscape]); + + const shouldShowDoublePage = useCallback( + (pageNumber: number, totalPages: number) => { + const isMobile = window.innerHeight < 700; + if (isMobile) return false; + if (!isDoublePage) return false; + if (pageNumber === 1) return false; + return pageNumber < totalPages; + }, + [isDoublePage] + ); + + const toggleDoublePage = useCallback(() => { + setIsDoublePage(prev => !prev); + }, []); + + return { + isDoublePage, + setIsDoublePage, + shouldShowDoublePage, + toggleDoublePage, + }; +} diff --git a/src/components/reader/hooks/useImageLoader.ts b/src/components/reader/hooks/useImageLoader.ts new file mode 100644 index 0000000..0cfae1d --- /dev/null +++ b/src/components/reader/hooks/useImageLoader.ts @@ -0,0 +1,108 @@ +import { useState, useCallback, useEffect, useRef } from "react"; + +interface ImageDimensions { + width: number; + height: number; +} + +export function useImageLoader(bookId: string, _pages: number[]) { + const [loadedImages, setLoadedImages] = useState>({}); + const [imageBlobUrls, setImageBlobUrls] = useState>({}); + const loadedImagesRef = useRef(loadedImages); + + // Keep ref in sync with state + useEffect(() => { + loadedImagesRef.current = loadedImages; + }, [loadedImages]); + + const getPageUrl = useCallback((pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`, [bookId]); + + // Load image dimensions + const loadImageDimensions = useCallback((pageNum: number) => { + if (loadedImagesRef.current[pageNum]) return; + + const img = new Image(); + img.onload = () => { + setLoadedImages(prev => ({ + ...prev, + [pageNum]: { width: img.naturalWidth, height: img.naturalHeight } + })); + }; + img.src = getPageUrl(pageNum); + }, [getPageUrl]); + + // Force reload handler + const handleForceReload = useCallback(async (currentPage: number, isDoublePage: boolean, shouldShowDoublePage: (page: number) => boolean) => { + // Révoquer les anciennes URLs blob + if (imageBlobUrls[currentPage]) { + URL.revokeObjectURL(imageBlobUrls[currentPage]); + } + if (imageBlobUrls[currentPage + 1]) { + URL.revokeObjectURL(imageBlobUrls[currentPage + 1]); + } + + try { + // Fetch page 1 avec cache: reload + const response1 = await fetch(getPageUrl(currentPage), { + cache: 'reload', + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }); + + if (!response1.ok) { + throw new Error(`HTTP ${response1.status}`); + } + + const blob1 = await response1.blob(); + const blobUrl1 = URL.createObjectURL(blob1); + + const newUrls: Record = { + ...imageBlobUrls, + [currentPage]: blobUrl1 + }; + + // Fetch page 2 si double page + if (isDoublePage && shouldShowDoublePage(currentPage)) { + const response2 = await fetch(getPageUrl(currentPage + 1), { + cache: 'reload', + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }); + + if (!response2.ok) { + throw new Error(`HTTP ${response2.status}`); + } + + const blob2 = await response2.blob(); + const blobUrl2 = URL.createObjectURL(blob2); + newUrls[currentPage + 1] = blobUrl2; + } + + setImageBlobUrls(newUrls); + } catch (error) { + console.error('Error reloading images:', error); + throw error; + } + }, [imageBlobUrls, getPageUrl]); + + // Cleanup blob URLs on unmount + useEffect(() => { + return () => { + Object.values(imageBlobUrls).forEach(url => { + if (url) URL.revokeObjectURL(url); + }); + }; + }, [imageBlobUrls]); + + return { + loadedImages, + imageBlobUrls, + loadImageDimensions, + handleForceReload, + getPageUrl, + }; +} diff --git a/src/components/reader/hooks/usePageNavigation.ts b/src/components/reader/hooks/usePageNavigation.ts new file mode 100644 index 0000000..6e39b49 --- /dev/null +++ b/src/components/reader/hooks/usePageNavigation.ts @@ -0,0 +1,117 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service"; +import type { KomgaBook } from "@/types/komga"; + +interface UsePageNavigationProps { + book: KomgaBook; + pages: number[]; + isDoublePage: boolean; + shouldShowDoublePage: (page: number) => boolean; + onClose?: (currentPage: number) => void; + nextBook?: KomgaBook | null; +} + +export function usePageNavigation({ + book, + pages, + isDoublePage, + shouldShowDoublePage, + onClose: _onClose, + nextBook, +}: UsePageNavigationProps) { + const router = useRouter(); + const [currentPage, setCurrentPage] = useState(() => { + const saved = ClientOfflineBookService.getCurrentPage(book); + return saved < 1 ? 1 : saved; + }); + const [showEndMessage, setShowEndMessage] = useState(false); + const syncTimeoutRef = useRef(null); + const currentPageRef = useRef(currentPage); + + // Garder currentPage à jour dans la ref pour le cleanup + useEffect(() => { + currentPageRef.current = currentPage; + }, [currentPage]); + + // Sync progress + const syncReadProgress = useCallback( + async (page: number) => { + try { + ClientOfflineBookService.setCurrentPage(book, page); + 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("Sync error:", error); + } + }, + [book, pages.length] + ); + + const debouncedSync = useCallback( + (page: number) => { + if (syncTimeoutRef.current) { + clearTimeout(syncTimeoutRef.current); + } + syncTimeoutRef.current = setTimeout(() => syncReadProgress(page), 500); + }, + [syncReadProgress] + ); + + const navigateToPage = useCallback( + (page: number) => { + if (page >= 1 && page <= pages.length) { + setCurrentPage(page); + // Mettre à jour le localStorage immédiatement + ClientOfflineBookService.setCurrentPage(book, page); + // Débouncer seulement l'API Komga + debouncedSync(page); + } + }, + [pages.length, debouncedSync, book] + ); + + const handlePreviousPage = useCallback(() => { + if (currentPage === 1) return; + const step = isDoublePage && shouldShowDoublePage(currentPage - 2) ? 2 : 1; + navigateToPage(Math.max(1, currentPage - step)); + }, [currentPage, isDoublePage, navigateToPage, shouldShowDoublePage]); + + const handleNextPage = useCallback(() => { + if (currentPage === pages.length) { + if (nextBook) { + router.push(`/books/${nextBook.id}`); + return; + } + setShowEndMessage(true); + return; + } + const step = isDoublePage && shouldShowDoublePage(currentPage) ? 2 : 1; + navigateToPage(Math.min(pages.length, currentPage + step)); + }, [currentPage, pages.length, isDoublePage, shouldShowDoublePage, navigateToPage, nextBook, router]); + + // Cleanup - Sync final sans debounce + useEffect(() => { + return () => { + if (syncTimeoutRef.current) { + clearTimeout(syncTimeoutRef.current); + } + // Sync immédiatement au cleanup avec la VRAIE valeur actuelle + syncReadProgress(currentPageRef.current); + }; + }, [syncReadProgress]); + + return { + currentPage, + setCurrentPage, + showEndMessage, + setShowEndMessage, + navigateToPage, + handlePreviousPage, + handleNextPage, + }; +} \ No newline at end of file diff --git a/src/components/reader/hooks/usePhotoSwipeZoom.ts b/src/components/reader/hooks/usePhotoSwipeZoom.ts new file mode 100644 index 0000000..b2843b1 --- /dev/null +++ b/src/components/reader/hooks/usePhotoSwipeZoom.ts @@ -0,0 +1,74 @@ +import { useRef, useCallback, useEffect } from "react"; +import PhotoSwipe from "photoswipe"; + +interface UsePhotoSwipeZoomProps { + loadedImages: Record; + currentPage: number; + getPageUrl: (pageNum: number) => string; +} + +export function usePhotoSwipeZoom({ + loadedImages, + currentPage, + getPageUrl, +}: UsePhotoSwipeZoomProps) { + const pswpRef = useRef(null); + + // Handle zoom via button + const handleZoom = useCallback(() => { + const dims = loadedImages[currentPage]; + if (!dims) return; + + const dataSource = [{ + src: getPageUrl(currentPage), + width: dims.width, + height: dims.height, + alt: `Page ${currentPage}` + }]; + + // Close any existing instance + if (pswpRef.current) { + pswpRef.current.close(); + } + + // Create and open PhotoSwipe + const pswp = new PhotoSwipe({ + dataSource, + index: 0, + bgOpacity: 0.9, + showHideAnimationType: 'fade', + initialZoomLevel: 0.25, + secondaryZoomLevel: 0.5, // Niveau de zoom au double-clic + maxZoomLevel: 4, + clickToCloseNonZoomable: true, // Ferme au clic simple + tapAction: 'zoom', // Ferme au tap + wheelToZoom: true, + pinchToClose: false, // Pinch pour fermer + closeOnVerticalDrag: true, // Swipe vertical pour fermer + escKey: true, // ESC ferme le zoom + arrowKeys: false, // On gère les flèches nous-mêmes + }); + + pswpRef.current = pswp; + pswp.init(); + + // Clean up on close + pswp.on('close', () => { + pswpRef.current = null; + }); + }, [loadedImages, currentPage, getPageUrl]); + + // Cleanup PhotoSwipe on unmount + useEffect(() => { + return () => { + if (pswpRef.current) { + pswpRef.current.close(); + } + }; + }, []); + + return { + pswpRef, + handleZoom, + }; +} diff --git a/src/components/reader/hooks/useTouchNavigation.ts b/src/components/reader/hooks/useTouchNavigation.ts new file mode 100644 index 0000000..96fa574 --- /dev/null +++ b/src/components/reader/hooks/useTouchNavigation.ts @@ -0,0 +1,117 @@ +import { useCallback, useRef, useEffect } from "react"; +import { useReadingDirection } from "./useReadingDirection"; + +interface UseTouchNavigationProps { + onPreviousPage: () => void; + onNextPage: () => void; + pswpRef: React.MutableRefObject; +} + +export function useTouchNavigation({ + onPreviousPage, + onNextPage, + pswpRef, +}: UseTouchNavigationProps) { + const { isRTL } = useReadingDirection(); + const touchStartXRef = useRef(null); + const touchStartYRef = useRef(null); + const isPinchingRef = useRef(false); + + // Touch handlers for swipe navigation + const handleTouchStart = useCallback((e: TouchEvent) => { + // Ne pas gérer si Photoswipe est ouvert + if (pswpRef.current) return; + + // Détecter si c'est un pinch (2+ doigts) + if (e.touches.length > 1) { + isPinchingRef.current = true; + touchStartXRef.current = null; + touchStartYRef.current = null; + return; + } + + // Un seul doigt - seulement si on n'était pas en train de pinch + // On réinitialise isPinchingRef seulement ici, quand on commence un nouveau geste à 1 doigt + if (e.touches.length === 1) { + isPinchingRef.current = false; + touchStartXRef.current = e.touches[0].clientX; + touchStartYRef.current = e.touches[0].clientY; + } + }, [pswpRef]); + + const handleTouchMove = useCallback((e: TouchEvent) => { + // Détecter le pinch pendant le mouvement + if (e.touches.length > 1) { + isPinchingRef.current = true; + touchStartXRef.current = null; + touchStartYRef.current = null; + } + }, []); + + const handleTouchEnd = useCallback((e: TouchEvent) => { + // Si on était en mode pinch, ne JAMAIS traiter le swipe + if (isPinchingRef.current) { + touchStartXRef.current = null; + touchStartYRef.current = null; + // Ne PAS réinitialiser isPinchingRef ici, on le fera au prochain touchstart + return; + } + + // Vérifier qu'on a bien des coordonnées de départ + if (touchStartXRef.current === null || touchStartYRef.current === null) return; + if (pswpRef.current) return; // Ne pas gérer si Photoswipe est ouvert + + const touchEndX = e.changedTouches[0].clientX; + const touchEndY = e.changedTouches[0].clientY; + const deltaX = touchEndX - touchStartXRef.current; + const deltaY = touchEndY - touchStartYRef.current; + + // Si le déplacement vertical est plus important, on ignore (scroll) + if (Math.abs(deltaY) > Math.abs(deltaX)) { + touchStartXRef.current = null; + touchStartYRef.current = null; + return; + } + + // Seuil de 50px pour changer de page + if (Math.abs(deltaX) > 50) { + if (deltaX > 0) { + // Swipe vers la droite + if (isRTL) { + onNextPage(); + } else { + onPreviousPage(); + } + } else { + // Swipe vers la gauche + if (isRTL) { + onPreviousPage(); + } else { + onNextPage(); + } + } + } + + touchStartXRef.current = null; + touchStartYRef.current = null; + }, [onNextPage, onPreviousPage, isRTL, pswpRef]); + + // Setup touch event listeners + useEffect(() => { + window.addEventListener("touchstart", handleTouchStart); + window.addEventListener("touchmove", handleTouchMove); + window.addEventListener("touchend", handleTouchEnd); + + return () => { + window.removeEventListener("touchstart", handleTouchStart); + window.removeEventListener("touchmove", handleTouchMove); + window.removeEventListener("touchend", handleTouchEnd); + }; + }, [handleTouchStart, handleTouchMove, handleTouchEnd]); + + return { + handleTouchStart, + handleTouchMove, + handleTouchEnd, + }; +} diff --git a/src/lib/services/circuit-breaker.service.ts b/src/lib/services/circuit-breaker.service.ts index ea035a4..703e65e 100644 --- a/src/lib/services/circuit-breaker.service.ts +++ b/src/lib/services/circuit-breaker.service.ts @@ -45,6 +45,7 @@ class CircuitBreaker { if (this.state.state === 'HALF_OPEN') { this.state.failureCount = 0; this.state.state = 'CLOSED'; + // eslint-disable-next-line no-console console.log('[CIRCUIT-BREAKER] ✅ Circuit closed - Komga recovered'); } } @@ -71,6 +72,7 @@ class CircuitBreaker { lastFailureTime: 0, nextAttemptTime: 0, }; + // eslint-disable-next-line no-console console.log('[CIRCUIT-BREAKER] 🔄 Circuit reset'); } }