feat: refresh buttons invalidate cache and show spinner during refresh
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m56s

- Add revalidateForRefresh(scope, id) server action for home/library/series
- Library/Series wrappers: revalidate cache then router.refresh(), 400ms delay for animation
- Home: revalidate home-data + path before refresh
- RefreshButton uses refreshLibrary from RefreshContext when not passed as prop
- Library/Series pages pass id to wrapper for context and pull-to-refresh
- read-progress: pass 'max' to revalidateTag for Next 16 types

Made-with: Cursor
This commit is contained in:
2026-03-02 13:38:45 +01:00
parent 30e3529be3
commit 99d9f41299
8 changed files with 110 additions and 39 deletions

View File

@@ -20,8 +20,8 @@ export async function updateReadProgress(
await BookService.updateReadProgress(bookId, page, completed); await BookService.updateReadProgress(bookId, page, completed);
// Invalider le cache home et libraries (statut de lecture des séries) // Invalider le cache home et libraries (statut de lecture des séries)
revalidateTag(HOME_CACHE_TAG); revalidateTag(HOME_CACHE_TAG, "max");
revalidateTag(LIBRARY_SERIES_CACHE_TAG); revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
return { success: true, message: "Progression mise à jour" }; return { success: true, message: "Progression mise à jour" };
} catch (error) { } catch (error) {
@@ -42,8 +42,8 @@ export async function deleteReadProgress(
await BookService.deleteReadProgress(bookId); await BookService.deleteReadProgress(bookId);
// Invalider le cache home et libraries (statut de lecture des séries) // Invalider le cache home et libraries (statut de lecture des séries)
revalidateTag(HOME_CACHE_TAG); revalidateTag(HOME_CACHE_TAG, "max");
revalidateTag(LIBRARY_SERIES_CACHE_TAG); revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
return { success: true, message: "Progression supprimée" }; return { success: true, message: "Progression supprimée" };
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,32 @@
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
import { LIBRARY_SERIES_CACHE_TAG } from "@/lib/services/library.service";
const HOME_CACHE_TAG = "home-data";
export type RefreshScope = "home" | "library" | "series";
/**
* Invalide le cache Next.js pour forcer un re-fetch au prochain router.refresh().
* À appeler côté client avant router.refresh() sur les boutons / pull-to-refresh.
*/
export async function revalidateForRefresh(scope: RefreshScope, id: string): Promise<void> {
switch (scope) {
case "home":
revalidateTag(HOME_CACHE_TAG, "max");
revalidatePath("/");
break;
case "library":
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
revalidatePath(`/libraries/${id}`);
revalidatePath("/libraries");
break;
case "series":
revalidatePath(`/series/${id}`);
revalidatePath("/series");
break;
default:
break;
}
}

View File

@@ -1,29 +1,44 @@
"use client"; "use client";
import { useState, type ReactNode } from "react"; import { useState, useCallback, type ReactNode } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator"; import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh"; import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { RefreshProvider } from "@/contexts/RefreshContext";
import { revalidateForRefresh } from "@/app/actions/refresh";
interface LibraryClientWrapperProps { interface LibraryClientWrapperProps {
children: ReactNode; children: ReactNode;
libraryId?: string;
} }
export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) { const REFRESH_ANIMATION_MS = 400;
export function LibraryClientWrapper({ children, libraryId }: LibraryClientWrapperProps) {
const router = useRouter(); const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => { const handleRefresh = useCallback(
try { async (libraryIdArg?: string) => {
setIsRefreshing(true); const id = libraryIdArg ?? libraryId;
router.refresh(); if (!id) {
return { success: true }; router.refresh();
} catch { return { success: true };
return { success: false, error: "Error refreshing library" }; }
} finally { try {
setIsRefreshing(false); setIsRefreshing(true);
} await revalidateForRefresh("library", id);
}; router.refresh();
await new Promise((r) => setTimeout(r, REFRESH_ANIMATION_MS));
return { success: true };
} catch {
return { success: false, error: "Error refreshing library" };
} finally {
setIsRefreshing(false);
}
},
[libraryId, router]
);
const pullToRefresh = usePullToRefresh({ const pullToRefresh = usePullToRefresh({
onRefresh: async () => { onRefresh: async () => {
@@ -33,7 +48,9 @@ export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) {
}); });
return ( return (
<> <RefreshProvider
refreshLibrary={libraryId ? (id) => handleRefresh(id) : undefined}
>
<PullToRefreshIndicator <PullToRefreshIndicator
isPulling={pullToRefresh.isPulling} isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing || isRefreshing} isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
@@ -42,6 +59,6 @@ export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) {
isHiding={pullToRefresh.isHiding} isHiding={pullToRefresh.isHiding}
/> />
{children} {children}
</> </RefreshProvider>
); );
} }

View File

@@ -43,7 +43,7 @@ export default async function LibraryPage({ params, searchParams }: PageProps) {
]); ]);
return ( return (
<LibraryClientWrapper> <LibraryClientWrapper libraryId={libraryId}>
<LibraryContent <LibraryContent
library={library} library={library}
series={series} series={series}

View File

@@ -1,32 +1,45 @@
"use client"; "use client";
import { useState, type ReactNode } from "react"; import { useState, useCallback, type ReactNode } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator"; import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh"; import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { RefreshProvider } from "@/contexts/RefreshContext"; import { RefreshProvider } from "@/contexts/RefreshContext";
import { revalidateForRefresh } from "@/app/actions/refresh";
interface SeriesClientWrapperProps { interface SeriesClientWrapperProps {
children: ReactNode; children: ReactNode;
seriesId?: string;
} }
const REFRESH_ANIMATION_MS = 400;
export function SeriesClientWrapper({ export function SeriesClientWrapper({
children, children,
seriesId,
}: SeriesClientWrapperProps) { }: SeriesClientWrapperProps) {
const router = useRouter(); const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => { const handleRefresh = useCallback(
try { async (seriesIdArg?: string) => {
setIsRefreshing(true); const id = seriesIdArg ?? seriesId;
router.refresh(); try {
return { success: true }; setIsRefreshing(true);
} catch { if (id) {
return { success: false, error: "Error refreshing series" }; await revalidateForRefresh("series", id);
} finally { }
setIsRefreshing(false); router.refresh();
} await new Promise((r) => setTimeout(r, REFRESH_ANIMATION_MS));
}; return { success: true };
} catch {
return { success: false, error: "Error refreshing series" };
} finally {
setIsRefreshing(false);
}
},
[seriesId, router]
);
const pullToRefresh = usePullToRefresh({ const pullToRefresh = usePullToRefresh({
onRefresh: async () => { onRefresh: async () => {
@@ -44,7 +57,9 @@ export function SeriesClientWrapper({
canRefresh={pullToRefresh.canRefresh} canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding} isHiding={pullToRefresh.isHiding}
/> />
<RefreshProvider refreshSeries={handleRefresh}>{children}</RefreshProvider> <RefreshProvider refreshSeries={seriesId ? (id) => handleRefresh(id) : undefined}>
{children}
</RefreshProvider>
</> </>
); );
} }

View File

@@ -36,7 +36,7 @@ export default async function SeriesPage({ params, searchParams }: PageProps) {
]); ]);
return ( return (
<SeriesClientWrapper> <SeriesClientWrapper seriesId={seriesId}>
<SeriesContent <SeriesContent
series={series} series={series}
books={books} books={books}

View File

@@ -1,31 +1,35 @@
"use client"; "use client";
import { useState, type ReactNode } from "react"; import { useState, useCallback, type ReactNode } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { RefreshButton } from "@/components/library/RefreshButton"; import { RefreshButton } from "@/components/library/RefreshButton";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator"; import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh"; import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { revalidateForRefresh } from "@/app/actions/refresh";
interface HomeClientWrapperProps { interface HomeClientWrapperProps {
children: ReactNode; children: ReactNode;
} }
const REFRESH_ANIMATION_MS = 400;
export function HomeClientWrapper({ children }: HomeClientWrapperProps) { export function HomeClientWrapper({ children }: HomeClientWrapperProps) {
const router = useRouter(); const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => { const handleRefresh = useCallback(async () => {
try { try {
setIsRefreshing(true); setIsRefreshing(true);
// Re-fetch server-side data await revalidateForRefresh("home", "home");
router.refresh(); router.refresh();
await new Promise((r) => setTimeout(r, REFRESH_ANIMATION_MS));
return { success: true }; return { success: true };
} catch (_error) { } catch (_error) {
return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" }; return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" };
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
} }
}; }, [router]);
const pullToRefresh = usePullToRefresh({ const pullToRefresh = usePullToRefresh({
onRefresh: async () => { onRefresh: async () => {

View File

@@ -7,17 +7,20 @@ import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useRefresh } from "@/contexts/RefreshContext";
interface RefreshButtonProps { interface RefreshButtonProps {
libraryId: string; libraryId: string;
refreshLibrary?: (libraryId: string) => Promise<{ success: boolean; error?: string }>; refreshLibrary?: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
} }
export function RefreshButton({ libraryId, refreshLibrary }: RefreshButtonProps) { export function RefreshButton({ libraryId, refreshLibrary: refreshLibraryProp }: RefreshButtonProps) {
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslation(); const { t } = useTranslation();
const { refreshLibrary: refreshLibraryFromContext } = useRefresh();
const refreshLibrary = refreshLibraryProp ?? refreshLibraryFromContext;
const handleRefresh = async () => { const handleRefresh = async () => {
setIsRefreshing(true); setIsRefreshing(true);