feat: implement pull-to-refresh functionality across ClientLibraryPage, ClientSeriesPage, and ClientHomePage for improved user experience
This commit is contained in:
@@ -5,6 +5,8 @@ import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
|
|||||||
import { RefreshButton } from "@/components/library/RefreshButton";
|
import { RefreshButton } from "@/components/library/RefreshButton";
|
||||||
import { LibraryHeader } from "@/components/library/LibraryHeader";
|
import { LibraryHeader } from "@/components/library/LibraryHeader";
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
|
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
||||||
|
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons";
|
import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons";
|
||||||
import type { LibraryResponse } from "@/types/library";
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -230,6 +239,13 @@ export function ClientLibraryPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<PullToRefreshIndicator
|
||||||
|
isPulling={pullToRefresh.isPulling}
|
||||||
|
isRefreshing={pullToRefresh.isRefreshing}
|
||||||
|
progress={pullToRefresh.progress}
|
||||||
|
canRefresh={pullToRefresh.canRefresh}
|
||||||
|
isHiding={pullToRefresh.isHiding}
|
||||||
|
/>
|
||||||
<LibraryHeader
|
<LibraryHeader
|
||||||
library={library}
|
library={library}
|
||||||
seriesCount={series.totalElements}
|
seriesCount={series.totalElements}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useEffect, useState } from "react";
|
|||||||
import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid";
|
import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid";
|
||||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
|
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
||||||
|
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
||||||
import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons";
|
import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons";
|
||||||
import type { LibraryResponse } from "@/types/library";
|
import type { LibraryResponse } from "@/types/library";
|
||||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||||
@@ -137,6 +139,13 @@ export function ClientSeriesPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pullToRefresh = usePullToRefresh({
|
||||||
|
onRefresh: async () => {
|
||||||
|
await handleRefresh(seriesId);
|
||||||
|
},
|
||||||
|
enabled: !loading && !error && !!series && !!books,
|
||||||
|
});
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="container py-8 space-y-8">
|
<div className="container py-8 space-y-8">
|
||||||
@@ -172,17 +181,26 @@ export function ClientSeriesPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<>
|
||||||
<SeriesHeader series={series} refreshSeries={handleRefresh} />
|
<PullToRefreshIndicator
|
||||||
<PaginatedBookGrid
|
isPulling={pullToRefresh.isPulling}
|
||||||
books={books.content || []}
|
isRefreshing={pullToRefresh.isRefreshing}
|
||||||
currentPage={currentPage}
|
progress={pullToRefresh.progress}
|
||||||
totalPages={books.totalPages}
|
canRefresh={pullToRefresh.canRefresh}
|
||||||
totalElements={books.totalElements}
|
isHiding={pullToRefresh.isHiding}
|
||||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
|
||||||
showOnlyUnread={unreadOnly}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="container">
|
||||||
|
<SeriesHeader series={series} refreshSeries={handleRefresh} />
|
||||||
|
<PaginatedBookGrid
|
||||||
|
books={books.content || []}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={books.totalPages}
|
||||||
|
totalElements={books.totalElements}
|
||||||
|
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||||
|
showOnlyUnread={unreadOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
83
src/components/common/PullToRefreshIndicator.tsx
Normal file
83
src/components/common/PullToRefreshIndicator.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 left-1/2 transform -translate-x-1/2 z-50 transition-all",
|
||||||
|
isHiding ? "duration-300 ease-out" : "duration-200",
|
||||||
|
(isPulling || isRefreshing) && !isHiding ? "translate-y-0 opacity-100" : "-translate-y-full opacity-0"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: `translate(-50%, ${(isPulling || isRefreshing) && !isHiding ? (isRefreshing ? 60 : progress * 60) : -100}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Barre de fond */}
|
||||||
|
<div className="w-48 h-1 bg-muted rounded-full overflow-hidden">
|
||||||
|
{/* Barre de progression */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full transition-all duration-200 rounded-full",
|
||||||
|
(canRefresh || isRefreshing) ? "bg-primary" : "bg-muted-foreground"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: `${isRefreshing ? 200 : barWidth}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icône centrée */}
|
||||||
|
<div className="flex justify-center mt-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200",
|
||||||
|
(canRefresh || isRefreshing) ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 transition-all duration-200",
|
||||||
|
isRefreshing && "animate-spin"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: isRefreshing ? "rotate(0deg)" : `rotate(${rotation}deg)`,
|
||||||
|
animationDuration: isRefreshing ? "2s" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-2 text-center text-xs transition-opacity duration-200",
|
||||||
|
(canRefresh || isRefreshing) ? "text-primary opacity-100" : "text-muted-foreground opacity-70"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isRefreshing ? "Actualisation..." : canRefresh ? "Relâchez pour actualiser" : "Tirez pour actualiser"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { useRouter } from "next/navigation";
|
|||||||
import { HomeContent } from "./HomeContent";
|
import { HomeContent } from "./HomeContent";
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
import { HomePageSkeleton } from "@/components/skeletons/OptimizedSkeletons";
|
import { HomePageSkeleton } from "@/components/skeletons/OptimizedSkeletons";
|
||||||
|
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
||||||
|
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import type { HomeData } from "@/types/home";
|
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) {
|
if (loading) {
|
||||||
return <HomePageSkeleton />;
|
return <HomePageSkeleton />;
|
||||||
}
|
}
|
||||||
@@ -105,6 +114,17 @@ export function ClientHomePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <HomeContent data={data} refreshHome={handleRefresh} />;
|
return (
|
||||||
|
<>
|
||||||
|
<PullToRefreshIndicator
|
||||||
|
isPulling={pullToRefresh.isPulling}
|
||||||
|
isRefreshing={pullToRefresh.isRefreshing}
|
||||||
|
progress={pullToRefresh.progress}
|
||||||
|
canRefresh={pullToRefresh.canRefresh}
|
||||||
|
isHiding={pullToRefresh.isHiding}
|
||||||
|
/>
|
||||||
|
<HomeContent data={data} refreshHome={handleRefresh} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import { Library, BookOpen } from "lucide-react";
|
import { Library, BookOpen } from "lucide-react";
|
||||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||||
import { RefreshButton } from "./RefreshButton";
|
import { RefreshButton } from "./RefreshButton";
|
||||||
@@ -17,13 +18,18 @@ interface LibraryHeaderProps {
|
|||||||
export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }: LibraryHeaderProps) => {
|
export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }: LibraryHeaderProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
// Sélectionner une série aléatoire pour l'image centrale
|
// Mémoriser la sélection des séries pour éviter les rerenders inutiles
|
||||||
const randomSeries = series.length > 0 ? series[Math.floor(Math.random() * series.length)] : null;
|
const { randomSeries, backgroundSeries } = useMemo(() => {
|
||||||
|
// Sélectionner une série aléatoire pour l'image centrale
|
||||||
// Sélectionner une autre série aléatoire pour le fond (différente de celle du centre)
|
const random = series.length > 0 ? series[Math.floor(Math.random() * series.length)] : null;
|
||||||
const backgroundSeries = series.length > 1
|
|
||||||
? series.filter(s => s.id !== randomSeries?.id)[Math.floor(Math.random() * (series.length - 1))]
|
// Sélectionner une autre série aléatoire pour le fond (différente de celle du centre)
|
||||||
: randomSeries;
|
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 (
|
return (
|
||||||
<div className="relative min-h-[200px] md:h-[200px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden">
|
<div className="relative min-h-[200px] md:h-[200px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden">
|
||||||
|
|||||||
183
src/hooks/usePullToRefresh.ts
Normal file
183
src/hooks/usePullToRefresh.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface UsePullToRefreshOptions {
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
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<PullToRefreshState>({
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user