@@ -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),
+ };
+}