diff --git a/src/app/libraries/[libraryId]/ClientLibraryPage.tsx b/src/app/libraries/[libraryId]/ClientLibraryPage.tsx index 9086dc2..2dba7a0 100644 --- a/src/app/libraries/[libraryId]/ClientLibraryPage.tsx +++ b/src/app/libraries/[libraryId]/ClientLibraryPage.tsx @@ -5,6 +5,8 @@ import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid"; import { RefreshButton } from "@/components/library/RefreshButton"; import { LibraryHeader } from "@/components/library/LibraryHeader"; import { ErrorMessage } from "@/components/ui/ErrorMessage"; +import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator"; +import { usePullToRefresh } from "@/hooks/usePullToRefresh"; import { useTranslate } from "@/hooks/useTranslate"; import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons"; import type { LibraryResponse } from "@/types/library"; @@ -155,6 +157,13 @@ export function ClientLibraryPage({ } }; + const pullToRefresh = usePullToRefresh({ + onRefresh: async () => { + await handleRefresh(libraryId); + }, + enabled: !loading && !error && !!library && !!series, + }); + if (loading) { return ( <> @@ -230,6 +239,13 @@ export function ClientLibraryPage({ return ( <> + { + await handleRefresh(seriesId); + }, + enabled: !loading && !error && !!series && !!books, + }); + if (loading) { return (
@@ -172,17 +181,26 @@ export function ClientSeriesPage({ } return ( -
- - + -
+
+ + +
+ ); } diff --git a/src/components/common/PullToRefreshIndicator.tsx b/src/components/common/PullToRefreshIndicator.tsx new file mode 100644 index 0000000..5a5bd2d --- /dev/null +++ b/src/components/common/PullToRefreshIndicator.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { RefreshCw } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface PullToRefreshIndicatorProps { + isPulling: boolean; + isRefreshing: boolean; + progress: number; + canRefresh: boolean; + isHiding: boolean; +} + +export function PullToRefreshIndicator({ + isPulling, + isRefreshing, + progress, + canRefresh, + isHiding, +}: PullToRefreshIndicatorProps) { + if (!isPulling && !isRefreshing && !isHiding) return null; + + const rotation = progress * 180; + const barWidth = Math.min(progress * 200, 200); // Barre de 200px max + + return ( +
+ {/* Barre de fond */} +
+ {/* Barre de progression */} +
+
+ + {/* Icône centrée */} +
+
+ +
+
+ + {/* Message */} +
+ {isRefreshing ? "Actualisation..." : canRefresh ? "Relâchez pour actualiser" : "Tirez pour actualiser"} +
+
+ ); +} diff --git a/src/components/home/ClientHomePage.tsx b/src/components/home/ClientHomePage.tsx index ec864cc..35114fe 100644 --- a/src/components/home/ClientHomePage.tsx +++ b/src/components/home/ClientHomePage.tsx @@ -5,6 +5,8 @@ import { useRouter } from "next/navigation"; import { HomeContent } from "./HomeContent"; import { ErrorMessage } from "@/components/ui/ErrorMessage"; import { HomePageSkeleton } from "@/components/skeletons/OptimizedSkeletons"; +import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator"; +import { usePullToRefresh } from "@/hooks/usePullToRefresh"; import { ERROR_CODES } from "@/constants/errorCodes"; import type { HomeData } from "@/types/home"; @@ -81,6 +83,13 @@ export function ClientHomePage() { } }; + const pullToRefresh = usePullToRefresh({ + onRefresh: async () => { + await handleRefresh(); + }, + enabled: !loading && !error && !!data, + }); + if (loading) { return ; } @@ -105,6 +114,17 @@ export function ClientHomePage() { ); } - return ; + return ( + <> + + + + ); } diff --git a/src/components/library/LibraryHeader.tsx b/src/components/library/LibraryHeader.tsx index f40fdf4..1e19597 100644 --- a/src/components/library/LibraryHeader.tsx +++ b/src/components/library/LibraryHeader.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import { Library, BookOpen } from "lucide-react"; import type { KomgaLibrary, KomgaSeries } from "@/types/komga"; import { RefreshButton } from "./RefreshButton"; @@ -17,13 +18,18 @@ interface LibraryHeaderProps { export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }: LibraryHeaderProps) => { const { t } = useTranslate(); - // Sélectionner une série aléatoire pour l'image centrale - const randomSeries = series.length > 0 ? series[Math.floor(Math.random() * series.length)] : null; - - // Sélectionner une autre série aléatoire pour le fond (différente de celle du centre) - const backgroundSeries = series.length > 1 - ? series.filter(s => s.id !== randomSeries?.id)[Math.floor(Math.random() * (series.length - 1))] - : randomSeries; + // Mémoriser la sélection des séries pour éviter les rerenders inutiles + const { randomSeries, backgroundSeries } = useMemo(() => { + // Sélectionner une série aléatoire pour l'image centrale + const random = series.length > 0 ? series[Math.floor(Math.random() * series.length)] : null; + + // Sélectionner une autre série aléatoire pour le fond (différente de celle du centre) + const background = series.length > 1 + ? series.filter(s => s.id !== random?.id)[Math.floor(Math.random() * (series.length - 1))] + : random; + + return { randomSeries: random, backgroundSeries: background }; + }, [series]); return (
diff --git a/src/hooks/usePullToRefresh.ts b/src/hooks/usePullToRefresh.ts new file mode 100644 index 0000000..8faf2ee --- /dev/null +++ b/src/hooks/usePullToRefresh.ts @@ -0,0 +1,183 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +interface UsePullToRefreshOptions { + onRefresh: () => Promise; + threshold?: number; + resistance?: number; + enabled?: boolean; +} + +interface PullToRefreshState { + isPulling: boolean; + isRefreshing: boolean; + pullDistance: number; + canRefresh: boolean; + isHiding: boolean; +} + +export function usePullToRefresh({ + onRefresh, + threshold = 80, + resistance = 0.5, + enabled = true, +}: UsePullToRefreshOptions) { + const [state, setState] = useState({ + isPulling: false, + isRefreshing: false, + pullDistance: 0, + canRefresh: false, + isHiding: false, + }); + + const startY = useRef(0); + const currentY = useRef(0); + const startScrollTop = useRef(0); + const isValidPull = useRef(false); + const isRefreshingRef = useRef(false); + + useEffect(() => { + if (!enabled) return; + + const handleTouchStart = (e: TouchEvent) => { + if (isRefreshingRef.current) return; + + // Ignorer les touches sur les éléments interactifs (boutons, liens, menu, etc.) + const target = e.target as HTMLElement; + if ( + target.closest('button') || + target.closest('a') || + target.closest('[role="button"]') || + target.closest('nav') || + target.closest('header') || + target.closest('[data-no-pull-refresh]') + ) { + isValidPull.current = false; + return; + } + + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + startScrollTop.current = scrollTop; + + // Ne démarrer que si on est vraiment en haut de la page + if (scrollTop === 0) { + startY.current = e.touches[0].clientY; + currentY.current = e.touches[0].clientY; + isValidPull.current = true; + } else { + isValidPull.current = false; + } + }; + + const handleTouchMove = (e: TouchEvent) => { + if (!isValidPull.current || isRefreshingRef.current) return; + + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + currentY.current = e.touches[0].clientY; + const deltaY = currentY.current - startY.current; + + // Vérifier qu'on est toujours en haut ET qu'on tire vers le bas + if (scrollTop === 0 && deltaY > 0) { + const pullDistance = Math.min(deltaY * resistance, threshold * 1.5); + const canRefresh = pullDistance >= threshold; + + setState(prev => ({ + ...prev, + isPulling: true, + pullDistance, + canRefresh, + })); + + // Empêcher le scroll par défaut quand on tire vers le bas + if (pullDistance > 10) { + e.preventDefault(); + } + } else if (scrollTop > 0 || deltaY < 0) { + // Si on scrolle ou qu'on tire vers le haut, annuler + isValidPull.current = false; + setState(prev => ({ + ...prev, + isPulling: false, + pullDistance: 0, + canRefresh: false, + })); + } + }; + + const handleTouchEnd = async () => { + if (!isValidPull.current || isRefreshingRef.current) { + isValidPull.current = false; + setState(prev => ({ + ...prev, + isPulling: false, + pullDistance: 0, + canRefresh: false, + })); + return; + } + + const shouldRefresh = state.canRefresh; + + setState(prev => ({ + ...prev, + isPulling: false, + })); + + if (shouldRefresh) { + isRefreshingRef.current = true; + setState(prev => ({ + ...prev, + isRefreshing: true, + pullDistance: 0, + })); + + try { + await onRefresh(); + } catch (error) { + console.error("Pull to refresh error:", error); + } finally { + isRefreshingRef.current = false; + // Activer l'animation de disparition + setState(prev => ({ + ...prev, + isHiding: true, + })); + + // Attendre la fin de l'animation avant de masquer complètement + setTimeout(() => { + setState(prev => ({ + ...prev, + isRefreshing: false, + isHiding: false, + })); + }, 300); // Durée de l'animation en ms + } + } else { + // Animation de retour + setState(prev => ({ + ...prev, + pullDistance: 0, + canRefresh: false, + })); + } + + isValidPull.current = false; + }; + + document.addEventListener("touchstart", handleTouchStart, { passive: false }); + document.addEventListener("touchmove", handleTouchMove, { passive: false }); + document.addEventListener("touchend", handleTouchEnd); + + return () => { + document.removeEventListener("touchstart", handleTouchStart); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchend", handleTouchEnd); + }; + }, [state.isPulling, state.canRefresh, onRefresh, threshold, resistance, enabled]); + + return { + ...state, + progress: Math.min(state.pullDistance / threshold, 1), + }; +}