From 4672532a3ae9d48a25595e5af53c92546ce293ee Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 17 Oct 2025 17:04:37 +0200 Subject: [PATCH] feat: integrate PhotoswipeReader component and remove BookReader for enhanced reading experience; add zoom functionality to control buttons --- package.json | 2 +- pnpm-lock.yaml | 24 +- src/components/reader/BookReader.tsx | 158 ------- src/components/reader/ClientBookReader.tsx | 4 +- src/components/reader/ClientBookWrapper.tsx | 4 +- src/components/reader/PhotoswipeReader.tsx | 407 ++++++++++++++++++ .../reader/components/ControlButtons.tsx | 14 + .../reader/components/ReaderContent.tsx | 57 --- .../reader/components/ZoomablePage.tsx | 93 ---- src/components/reader/hooks/usePageCache.ts | 137 ------ .../reader/hooks/usePageNavigation.ts | 280 ------------ src/components/reader/hooks/usePageUrls.ts | 75 ---- .../reader/hooks/usePreloadPages.ts | 61 --- src/components/reader/types.ts | 1 + src/i18n/messages/en/common.json | 1 + src/i18n/messages/fr/common.json | 1 + 16 files changed, 438 insertions(+), 881 deletions(-) delete mode 100644 src/components/reader/BookReader.tsx create mode 100644 src/components/reader/PhotoswipeReader.tsx delete mode 100644 src/components/reader/components/ReaderContent.tsx delete mode 100644 src/components/reader/components/ZoomablePage.tsx delete mode 100644 src/components/reader/hooks/usePageCache.ts delete mode 100644 src/components/reader/hooks/usePageNavigation.ts delete mode 100644 src/components/reader/hooks/usePageUrls.ts delete mode 100644 src/components/reader/hooks/usePreloadPages.ts diff --git a/package.json b/package.json index 798b732..922e113 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,10 @@ "next": "15.2.0", "next-auth": "5.0.0-beta.29", "next-themes": "0.2.1", + "photoswipe": "^5.4.4", "react": "19.2.0", "react-dom": "19.2.0", "react-i18next": "^15.4.1", - "react-zoom-pan-pinch": "^3.7.0", "sharp": "0.33.2", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a3deed..a293e50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: next-themes: specifier: 0.2.1 version: 0.2.1(next@15.2.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + photoswipe: + specifier: ^5.4.4 + version: 5.4.4 react: specifier: 19.2.0 version: 19.2.0 @@ -80,9 +83,6 @@ importers: react-i18next: specifier: ^15.4.1 version: 15.7.4(i18next@24.2.3(typescript@5.3.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.3.3) - react-zoom-pan-pinch: - specifier: ^3.7.0 - version: 3.7.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) sharp: specifier: 0.33.2 version: 0.33.2 @@ -2405,6 +2405,10 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + photoswipe@5.4.4: + resolution: {integrity: sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==} + engines: {node: '>= 0.12.0'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2568,13 +2572,6 @@ packages: '@types/react': optional: true - react-zoom-pan-pinch@3.7.0: - resolution: {integrity: sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==} - engines: {node: '>=8', npm: '>=5'} - peerDependencies: - react: '*' - react-dom: '*' - react@19.2.0: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} @@ -5344,6 +5341,8 @@ snapshots: perfect-debounce@1.0.0: {} + photoswipe@5.4.4: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5483,11 +5482,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 - react-zoom-pan-pinch@3.7.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): - dependencies: - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - react@19.2.0: {} read-cache@1.0.0: diff --git a/src/components/reader/BookReader.tsx b/src/components/reader/BookReader.tsx deleted file mode 100644 index 6ea1c16..0000000 --- a/src/components/reader/BookReader.tsx +++ /dev/null @@ -1,158 +0,0 @@ -/* eslint-disable @next/next/no-img-element */ -"use client"; - -import type { BookReaderProps } from "./types"; -import { useOrientation } from "./hooks/useOrientation"; -import { usePageNavigation } from "./hooks/usePageNavigation"; -import { usePageCache } from "./hooks/usePageCache"; -import { usePageUrls } from "./hooks/usePageUrls"; -import { usePreloadPages } from "./hooks/usePreloadPages"; -import { useFullscreen } from "./hooks/useFullscreen"; -import { useState, useEffect, useCallback, useRef } from "react"; -import { NavigationBar } from "./components/NavigationBar"; -import { ControlButtons } from "./components/ControlButtons"; -import { ReaderContent } from "./components/ReaderContent"; -import { useReadingDirection } from "./hooks/useReadingDirection"; -import { useTranslate } from "@/hooks/useTranslate"; - -export function BookReader({ book, pages, onClose, nextBook }: BookReaderProps) { - const [isDoublePage, setIsDoublePage] = useState(false); - const [showControls, setShowControls] = useState(false); - const [showThumbnails, setShowThumbnails] = useState(false); - const [isZoomed, setIsZoomed] = useState(false); - const readerRef = useRef(null); - const isLandscape = useOrientation(); - const { direction, toggleDirection, isRTL } = useReadingDirection(); - const { isFullscreen, toggleFullscreen } = useFullscreen(); - const { t } = useTranslate(); - - const { - currentPage, - navigateToPage, - isLoading, - setIsLoading, - secondPageLoading, - setSecondPageLoading, - handlePreviousPage, - handleNextPage, - shouldShowDoublePage, - showEndMessage, - } = usePageNavigation({ - book, - pages, - isDoublePage, - onClose, - direction, - nextBook, - isZoomed, - }); - - const { preloadPage, getPageUrl, cleanCache } = usePageCache({ - book, - pages, - }); - - const { currentPageUrl, nextPageUrl } = usePageUrls({ - currentPage, - isDoublePage, - shouldShowDoublePage, - getPageUrl, - setIsLoading, - setSecondPageLoading, - }); - - usePreloadPages({ - currentPage, - totalPages: pages.length, - isDoublePage, - shouldShowDoublePage, - preloadPage, - cleanCache, - }); - - // 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, setIsLoading, setSecondPageLoading] - ); - - return ( -
-
setShowControls(!showControls)} - > -
- {showEndMessage && ( -
-
-

{t("reader.endOfSeries")}

-

{t("reader.endOfSeriesMessage")}

- -
-
- )} - - 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)} - /> - - - - -
-
-
- ); -} diff --git a/src/components/reader/ClientBookReader.tsx b/src/components/reader/ClientBookReader.tsx index 1fc3258..a826cba 100644 --- a/src/components/reader/ClientBookReader.tsx +++ b/src/components/reader/ClientBookReader.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import type { KomgaBook } from "@/types/komga"; -import { BookReader } from "./BookReader"; +import { PhotoswipeReader } from "./PhotoswipeReader"; import { Button } from "@/components/ui/button"; interface ClientBookReaderProps { @@ -26,7 +26,7 @@ export function ClientBookReader({ book, pages }: ClientBookReaderProps) { }; if (isReading) { - return ; + return ; } return ( diff --git a/src/components/reader/ClientBookWrapper.tsx b/src/components/reader/ClientBookWrapper.tsx index 11e1b06..9051f79 100644 --- a/src/components/reader/ClientBookWrapper.tsx +++ b/src/components/reader/ClientBookWrapper.tsx @@ -1,7 +1,7 @@ "use client"; import type { KomgaBook } from "@/types/komga"; -import { BookReader } from "./BookReader"; +import { PhotoswipeReader } from "./PhotoswipeReader"; import { useRouter } from "next/navigation"; import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service"; @@ -23,5 +23,5 @@ export function ClientBookWrapper({ book, pages, nextBook }: ClientBookWrapperPr //router.back(); }; - return ; + return ; } diff --git a/src/components/reader/PhotoswipeReader.tsx b/src/components/reader/PhotoswipeReader.tsx new file mode 100644 index 0000000..4410c7d --- /dev/null +++ b/src/components/reader/PhotoswipeReader.tsx @@ -0,0 +1,407 @@ +/* eslint-disable @next/next/no-img-element */ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import PhotoSwipe from "photoswipe"; +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 { 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"; + +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 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); + + // Auto double page en paysage + useEffect(() => { + setIsDoublePage(isLandscape); + }, [isLandscape]); + + 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), 2000); + }, + [syncReadProgress] + ); + + const navigateToPage = useCallback( + (page: number) => { + if (page >= 1 && page <= pages.length) { + setCurrentPage(page); + debouncedSync(page); + } + }, + [pages.length, debouncedSync] + ); + + 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]); + + // 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; + } + + isPinchingRef.current = false; + touchStartXRef.current = e.touches[0].clientX; + touchStartYRef.current = e.touches[0].clientY; + }, []); + + const handleTouchEnd = useCallback((e: TouchEvent) => { + // Ignorer si c'était un pinch + if (isPinchingRef.current) { + isPinchingRef.current = false; + touchStartXRef.current = null; + touchStartYRef.current = null; + return; + } + + 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 100px pour changer de page + if (Math.abs(deltaX) > 100) { + if (deltaX > 0) { + // Swipe vers la droite + if (isRTL) { + handleNextPage(); + } else { + handlePreviousPage(); + } + } else { + // Swipe vers la gauche + if (isRTL) { + handlePreviousPage(); + } else { + handleNextPage(); + } + } + } + + touchStartXRef.current = null; + touchStartYRef.current = null; + }, [handleNextPage, handlePreviousPage, isRTL]); + + // Keyboard & Touch events + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowLeft") { + e.preventDefault(); + if (isRTL) { + handleNextPage(); + } else { + handlePreviousPage(); + } + } else if (e.key === "ArrowRight") { + e.preventDefault(); + if (isRTL) { + handlePreviousPage(); + } else { + handleNextPage(); + } + } else if (e.key === "Escape" && onClose) { + e.preventDefault(); + onClose(currentPage); + } + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("touchstart", handleTouchStart); + window.addEventListener("touchend", handleTouchEnd); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("touchstart", handleTouchStart); + window.removeEventListener("touchend", handleTouchEnd); + }; + }, [handleNextPage, handlePreviousPage, handleTouchStart, handleTouchEnd, onClose, isRTL, currentPage]); + + // Cleanup + useEffect(() => { + return () => { + if (syncTimeoutRef.current) { + clearTimeout(syncTimeoutRef.current); + syncReadProgress(currentPage); + } + ClientOfflineBookService.removeCurrentPage(book); + }; + }, [syncReadProgress, book, currentPage]); + + 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(); + } + }; + }, []); + + return ( +
setShowControls(!showControls)} + > +
+ {showEndMessage && ( +
+
+

{t("reader.endOfSeries")}

+

{t("reader.endOfSeriesMessage")}

+ +
+
+ )} + + 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} + /> + + {/* Reader content */} +
+
+ {/* Page 1 */} +
+ {`Page +
+ + {/* Page 2 (double page) */} + {isDoublePage && shouldShowDoublePage(currentPage) && ( +
+ {`Page +
+ )} +
+
+ + +
+
+ ); +} + diff --git a/src/components/reader/components/ControlButtons.tsx b/src/components/reader/components/ControlButtons.tsx index 5c2fc42..cd80b90 100644 --- a/src/components/reader/components/ControlButtons.tsx +++ b/src/components/reader/components/ControlButtons.tsx @@ -10,6 +10,7 @@ import { MoveRight, MoveLeft, Images, + ZoomIn, } from "lucide-react"; import { cn } from "@/lib/utils"; import { PageInput } from "./PageInput"; @@ -33,6 +34,7 @@ export const ControlButtons = ({ onPageChange, showThumbnails, onToggleThumbnails, + onZoom, }: ControlButtonsProps) => { const { t } = useTranslation(); @@ -111,6 +113,18 @@ export const ControlButtons = ({ iconClassName="h-6 w-6" className={cn("rounded-full", showThumbnails && "ring-2 ring-primary")} /> + { + e.stopPropagation(); + onZoom(); + }} + tooltip={t("reader.controls.zoom")} + iconClassName="h-6 w-6" + className="rounded-full" + />
e.stopPropagation()}> boolean; - isRTL: boolean; - onThumbnailLoad: (pageNumber: number) => void; - onZoomChange?: (isZoomed: boolean) => void; -} - -export const ReaderContent = ({ - currentPage, - currentPageUrl, - nextPageUrl, - isLoading, - secondPageLoading, - isDoublePage, - shouldShowDoublePage, - isRTL, - onThumbnailLoad, - onZoomChange, -}: ReaderContentProps) => { - return ( -
-
- - - {isDoublePage && shouldShowDoublePage(currentPage) && ( - - )} -
-
- ); -}; diff --git a/src/components/reader/components/ZoomablePage.tsx b/src/components/reader/components/ZoomablePage.tsx deleted file mode 100644 index 782fcd1..0000000 --- a/src/components/reader/components/ZoomablePage.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useState } from "react"; -import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; -import { cn } from "@/lib/utils"; - -interface ZoomablePageProps { - pageUrl: string | null; - pageNumber: number; - isLoading: boolean; - onLoad: (pageNumber: number) => void; - isDoublePage?: boolean; - isRTL?: boolean; - order?: "first" | "second"; - onZoomChange?: (isZoomed: boolean) => void; -} - -export const ZoomablePage = ({ - pageUrl, - pageNumber, - isLoading, - onLoad, - isDoublePage = false, - isRTL = false, - order = "first", - onZoomChange, -}: ZoomablePageProps) => { - const [isZoomed, setIsZoomed] = useState(false); - - const handleTransform = (ref: any, state: { scale: number; positionX: number; positionY: number }) => { - const zoomed = state.scale > 1.1; - setIsZoomed(zoomed); - onZoomChange?.(zoomed); - }; - - return ( -
- {isLoading && ( -
-
-
- )} - - {pageUrl && ( - - - {/* eslint-disable-next-line @next/next/no-img-element */} - {`Page onLoad(pageNumber)} - /> - - - )} -
- ); -}; diff --git a/src/components/reader/hooks/usePageCache.ts b/src/components/reader/hooks/usePageCache.ts deleted file mode 100644 index d7eb6ec..0000000 --- a/src/components/reader/hooks/usePageCache.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { useCallback, useRef } from "react"; -import type { PageCache } from "../types"; -import type { KomgaBook } from "@/types/komga"; -import { usePreferences } from "@/contexts/PreferencesContext"; - -interface UsePageCacheProps { - book: KomgaBook; - pages: number[]; -} - -export const usePageCache = ({ book, pages }: UsePageCacheProps) => { - const pageCache = useRef({}); - const { preferences } = usePreferences(); - - 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 startTime = performance.now(); - const response = await fetch(`/api/komga/books/${book.id}/pages/${pageNumber}`); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const endTime = performance.now(); - - // Logger la requête côté client seulement si le mode debug est activé et ce n'est pas une requête de debug - if (!url.includes('/api/debug') && preferences.debug) { - try { - await fetch("/api/debug", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: `/api/komga/books/${book.id}/pages/${pageNumber}`, - startTime, - endTime, - fromCache: false, - }), - }); - } catch { - // Ignorer les erreurs de logging - } - } - - 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, preferences.debug] - ); - - const getPageUrl = useCallback( - async (pageNumber: number) => { - if (pageCache.current[pageNumber]?.url) { - // Logger l'utilisation du cache côté client seulement si le mode debug est activé et ce n'est pas une requête de debug - const cacheUrl = `[CLIENT-CACHE] /api/komga/books/${book.id}/pages/${pageNumber}`; - if (!cacheUrl.includes('/api/debug') && preferences.debug) { - try { - await fetch("/api/debug", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: cacheUrl, - startTime: performance.now(), - endTime: performance.now(), - fromCache: true, - }), - }); - } catch { - // Ignorer les erreurs de logging - } - } - 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, preferences.debug] - ); - - 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 deleted file mode 100644 index d7b9f2f..0000000 --- a/src/components/reader/hooks/usePageNavigation.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { useState, useCallback, useEffect, useRef } from "react"; -import type { KomgaBook } from "@/types/komga"; -import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service"; -import { useRouter } from "next/navigation"; - -interface UsePageNavigationProps { - book: KomgaBook; - pages: number[]; - isDoublePage: boolean; - onClose?: (currentPage: number) => void; - direction: "ltr" | "rtl"; - nextBook?: KomgaBook | null; - isZoomed?: boolean; -} - -export const usePageNavigation = ({ - book, - pages, - isDoublePage, - onClose, - direction, - nextBook, - isZoomed = false, -}: UsePageNavigationProps) => { - const router = useRouter(); - const cPage = ClientOfflineBookService.getCurrentPage(book); - const [currentPage, setCurrentPage] = useState(cPage < 1 ? 1 : cPage); - const [isLoading, setIsLoading] = useState(true); - const [secondPageLoading, setSecondPageLoading] = useState(true); - const [showEndMessage, setShowEndMessage] = useState(false); - const timeoutRef = useRef(null); - const touchStartXRef = useRef(null); - const touchStartYRef = useRef(null); - const currentPageRef = useRef(currentPage); - const isRTL = direction === "rtl"; - - useEffect(() => { - currentPageRef.current = currentPage; - }, [currentPage]); - - 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) { - if (error instanceof Error) { - console.error( - `Erreur de synchronisation de la progression pour le livre ${book.id} à la page ${page}:`, - error.message - ); - } - } - }, - [book, pages.length] - ); - - const debouncedSyncReadProgress = useCallback( - (page: number) => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = setTimeout(() => { - syncReadProgress(page); - timeoutRef.current = null; - }, 2000); - }, - [syncReadProgress] - ); - - 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] - ); - - const navigateToPage = useCallback( - (page: number) => { - // if (page >= 1 && page <= pages.length) { - setCurrentPage(page); - setIsLoading(true); - setSecondPageLoading(true); - debouncedSyncReadProgress(page); - // } - }, - [debouncedSyncReadProgress] - ); - - const handlePreviousPage = useCallback(() => { - if (currentPage === 1) return; - if (isDoublePage && shouldShowDoublePage(currentPage - 2)) { - navigateToPage(Math.max(1, currentPage - 2)); - } else { - navigateToPage(Math.max(1, currentPage - 1)); - } - }, [currentPage, isDoublePage, navigateToPage, shouldShowDoublePage]); - - const handleNextPage = useCallback(() => { - if (currentPage === pages.length) { - if (nextBook) { - router.push(`/books/${nextBook.id}`); - return; - } else { - setShowEndMessage(true); - return; - } - } - if (isDoublePage && shouldShowDoublePage(currentPage)) { - navigateToPage(Math.min(pages.length, currentPage + 2)); - } else { - navigateToPage(Math.min(pages.length, currentPage + 1)); - } - }, [ - currentPage, - isDoublePage, - navigateToPage, - pages.length, - shouldShowDoublePage, - nextBook, - router, - ]); - - const handleTouchStart = useCallback( - (event: TouchEvent) => { - // Si on est zoomé, on ne gère pas la navigation - if (isZoomed) { - touchStartXRef.current = null; - touchStartYRef.current = null; - return; - } - - touchStartXRef.current = event.touches[0].clientX; - touchStartYRef.current = event.touches[0].clientY; - currentPageRef.current = currentPage; - }, - [currentPage, isZoomed] - ); - - const handleTouchEnd = useCallback( - (event: TouchEvent) => { - if (touchStartXRef.current === null || touchStartYRef.current === null) return; - - const touchEndX = event.changedTouches[0].clientX; - const touchEndY = event.changedTouches[0].clientY; - const deltaX = touchEndX - touchStartXRef.current; - const deltaY = touchEndY - touchStartYRef.current; - - // Si le déplacement vertical est plus important que le déplacement horizontal, - // on ne fait rien (pour éviter de confondre avec un scroll) - if (Math.abs(deltaY) > Math.abs(deltaX)) return; - - // Seuil pour éviter les changements de page accidentels - if (Math.abs(deltaX) > 100) { - if (deltaX > 0) { - // Swipe vers la droite - if (isRTL) { - handleNextPage(); - } else { - handlePreviousPage(); - } - } else { - // Swipe vers la gauche - if (isRTL) { - handlePreviousPage(); - } else { - handleNextPage(); - } - } - } - - touchStartXRef.current = null; - touchStartYRef.current = null; - }, - [handleNextPage, handlePreviousPage, isRTL] - ); - - - useEffect(() => { - setIsLoading(true); - setSecondPageLoading(true); - }, [isDoublePage]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "ArrowLeft") { - e.preventDefault(); - if (isRTL) { - handleNextPage(); - } else { - handlePreviousPage(); - } - } else if (e.key === "ArrowRight") { - e.preventDefault(); - if (isRTL) { - handlePreviousPage(); - } else { - handleNextPage(); - } - } else if (e.key === "Escape" && onClose) { - e.preventDefault(); - onClose(currentPage); - } - }; - - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("touchstart", handleTouchStart); - window.addEventListener("touchend", handleTouchEnd); - - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("touchstart", handleTouchStart); - window.removeEventListener("touchend", handleTouchEnd); - }; - }, [ - handleNextPage, - handlePreviousPage, - handleTouchStart, - handleTouchEnd, - onClose, - isRTL, - currentPage, - ]); - - useEffect(() => { - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - syncReadProgress(currentPageRef.current); - ClientOfflineBookService.removeCurrentPage(book); - } - }; - }, [syncReadProgress, book]); - - const handleDoubleClick = useCallback((transformRef?: any) => { - if (transformRef?.current) { - try { - // Utiliser setTransform au lieu de zoomIn pour éviter les NaN - const transform = transformRef.current; - const currentScale = transform.instance?.state?.scale || 1; - - if (currentScale <= 1.1) { - // Zoom à 2x - transform.setTransform(0, 0, 2); - } else { - // Reset à 1x - transform.setTransform(0, 0, 1); - } - } catch (error) { - console.error("Error in handleDoubleClick:", error); - } - } - }, []); - - return { - currentPage, - navigateToPage, - isLoading, - setIsLoading, - secondPageLoading, - setSecondPageLoading, - handlePreviousPage, - handleNextPage, - shouldShowDoublePage, - handleDoubleClick, - showEndMessage, - }; -}; diff --git a/src/components/reader/hooks/usePageUrls.ts b/src/components/reader/hooks/usePageUrls.ts deleted file mode 100644 index 41c8c3c..0000000 --- a/src/components/reader/hooks/usePageUrls.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useState, useEffect } from "react"; - -interface UsePageUrlsProps { - currentPage: number; - isDoublePage: boolean; - shouldShowDoublePage: (page: number) => boolean; - getPageUrl: (page: number) => Promise; - setIsLoading: (loading: boolean) => void; - setSecondPageLoading: (loading: boolean) => void; -} - -export const usePageUrls = ({ - currentPage, - isDoublePage, - shouldShowDoublePage, - getPageUrl, - setIsLoading, - setSecondPageLoading, -}: UsePageUrlsProps) => { - const [currentPageUrl, setCurrentPageUrl] = useState(""); - const [nextPageUrl, setNextPageUrl] = useState(""); - - useEffect(() => { - let isMounted = true; - - const loadPageUrls = async () => { - try { - const url = await getPageUrl(currentPage); - if (isMounted) { - setCurrentPageUrl(url); - setIsLoading(false); - } - - if (isDoublePage && shouldShowDoublePage(currentPage)) { - const nextUrl = await getPageUrl(currentPage + 1); - if (isMounted) { - setNextPageUrl(nextUrl); - setSecondPageLoading(false); - } - } - } catch (error) { - if (error instanceof Error) { - console.error( - `Erreur de chargement des URLs pour la page ${currentPage}:`, - error.message - ); - } - if (isMounted) { - setIsLoading(false); - setSecondPageLoading(false); - } - } - }; - - setIsLoading(true); - setSecondPageLoading(true); - loadPageUrls(); - - return () => { - isMounted = false; - }; - }, [ - currentPage, - isDoublePage, - shouldShowDoublePage, - getPageUrl, - setIsLoading, - setSecondPageLoading, - ]); - - return { - currentPageUrl, - nextPageUrl, - }; -}; diff --git a/src/components/reader/hooks/usePreloadPages.ts b/src/components/reader/hooks/usePreloadPages.ts deleted file mode 100644 index 7e562ea..0000000 --- a/src/components/reader/hooks/usePreloadPages.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useEffect } from "react"; - -interface UsePreloadPagesProps { - currentPage: number; - totalPages: number; - isDoublePage: boolean; - shouldShowDoublePage: (page: number) => boolean; - preloadPage: (page: number) => Promise; - cleanCache: (currentPage: number) => void; -} - -export const usePreloadPages = ({ - currentPage, - totalPages, - isDoublePage, - shouldShowDoublePage, - preloadPage, - cleanCache, -}: UsePreloadPagesProps) => { - useEffect(() => { - let isMounted = true; - - const preloadCurrentPages = async () => { - if (!isMounted) return; - - await preloadPage(currentPage); - - if (!isMounted) return; - - if (isDoublePage && shouldShowDoublePage(currentPage)) { - await preloadPage(currentPage + 1); - } - - if (!isMounted) return; - - const pagesToPreload = []; - - // Précharger les 2 pages précédentes en priorité - for (let i = 1; i <= 2 && currentPage - i >= 1; i++) { - pagesToPreload.push(currentPage - i); - } - - // Précharger les 4 pages suivantes - for (let i = 1; i <= 4 && currentPage + i <= totalPages; i++) { - pagesToPreload.push(currentPage + i); - } - - for (const page of pagesToPreload) { - if (!isMounted) break; - await preloadPage(page); - } - }; - - preloadCurrentPages(); - cleanCache(currentPage); - - return () => { - isMounted = false; - }; - }, [currentPage, isDoublePage, shouldShowDoublePage, preloadPage, cleanCache, totalPages]); -}; diff --git a/src/components/reader/types.ts b/src/components/reader/types.ts index 8b607ec..494d609 100644 --- a/src/components/reader/types.ts +++ b/src/components/reader/types.ts @@ -52,6 +52,7 @@ export interface ControlButtonsProps { onToggleDirection: () => void; showThumbnails: boolean; onToggleThumbnails: () => void; + onZoom: () => void; } export interface UsePageNavigationProps { diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index c1346b8..1e62cc9 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -417,6 +417,7 @@ "show": "Show thumbnails", "hide": "Hide thumbnails" }, + "zoom": "Zoom", "close": "Close", "previousPage": "Previous page", "nextPage": "Next page" diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index 97054cf..534f476 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -415,6 +415,7 @@ "show": "Afficher les vignettes", "hide": "Masquer les vignettes" }, + "zoom": "Zoom", "close": "Fermer", "previousPage": "Page précédente", "nextPage": "Page suivante"