diff --git a/src/app/libraries/[libraryId]/ClientLibraryPage.tsx b/src/app/libraries/[libraryId]/ClientLibraryPage.tsx
index 5320713..3496770 100644
--- a/src/app/libraries/[libraryId]/ClientLibraryPage.tsx
+++ b/src/app/libraries/[libraryId]/ClientLibraryPage.tsx
@@ -107,6 +107,39 @@ export function ClientLibraryPage({
}
};
+ const handleRetry = async () => {
+ setError(null);
+ setLoading(true);
+
+ try {
+ const params = new URLSearchParams({
+ page: String(currentPage - 1),
+ size: String(effectivePageSize),
+ unread: String(unreadOnly),
+ });
+
+ if (search) {
+ params.append("search", search);
+ }
+
+ const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`);
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR");
+ }
+
+ const data = await response.json();
+ setLibrary(data.library);
+ setSeries(data.series);
+ } catch (err) {
+ console.error("Error fetching library series:", err);
+ setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR");
+ } finally {
+ setLoading(false);
+ }
+ };
+
if (loading) {
return (
@@ -132,7 +165,7 @@ export function ClientLibraryPage({
-
+
);
}
@@ -140,7 +173,7 @@ export function ClientLibraryPage({
if (!library || !series) {
return (
-
+
);
}
diff --git a/src/app/series/[seriesId]/ClientSeriesPage.tsx b/src/app/series/[seriesId]/ClientSeriesPage.tsx
index f10d6e7..f512979 100644
--- a/src/app/series/[seriesId]/ClientSeriesPage.tsx
+++ b/src/app/series/[seriesId]/ClientSeriesPage.tsx
@@ -97,6 +97,35 @@ export function ClientSeriesPage({
}
};
+ const handleRetry = async () => {
+ setError(null);
+ setLoading(true);
+
+ try {
+ const params = new URLSearchParams({
+ page: String(currentPage - 1),
+ size: String(effectivePageSize),
+ unread: String(unreadOnly),
+ });
+
+ const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`);
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
+ }
+
+ const data = await response.json();
+ setSeries(data.series);
+ setBooks(data.books);
+ } catch (err) {
+ console.error("Error fetching series books:", err);
+ setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
+ } finally {
+ setLoading(false);
+ }
+ };
+
if (loading) {
return (
@@ -117,7 +146,7 @@ export function ClientSeriesPage({
return (
Série
-
+
);
}
@@ -126,7 +155,7 @@ export function ClientSeriesPage({
return (
Série
-
+
);
}
diff --git a/src/components/ui/ErrorMessage.tsx b/src/components/ui/ErrorMessage.tsx
index 72d3322..bf58f12 100644
--- a/src/components/ui/ErrorMessage.tsx
+++ b/src/components/ui/ErrorMessage.tsx
@@ -1,15 +1,24 @@
"use client";
-import { AlertCircle } from "lucide-react";
+import { AlertCircle, RefreshCw } from "lucide-react";
import { useTranslate } from "@/hooks/useTranslate";
+import { Button } from "@/components/ui/button";
interface ErrorMessageProps {
errorCode: string;
error?: Error;
variant?: "default" | "form";
+ onRetry?: () => void;
+ retryLabel?: string;
}
-export const ErrorMessage = ({ errorCode, error, variant = "default" }: ErrorMessageProps) => {
+export const ErrorMessage = ({
+ errorCode,
+ error,
+ variant = "default",
+ onRetry,
+ retryLabel,
+}: ErrorMessageProps) => {
const { t } = useTranslate();
const message = t(`errors.${errorCode}`);
@@ -26,6 +35,16 @@ export const ErrorMessage = ({ errorCode, error, variant = "default" }: ErrorMes
>
{message}
+ {onRetry && (
+
+ )}
);
}
@@ -48,6 +67,18 @@ export const ErrorMessage = ({ errorCode, error, variant = "default" }: ErrorMes
{t("errors.GENERIC_ERROR")}
{message}
+
+ {onRetry && (
+
+ )}
diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json
index 57ab1ef..1c7d378 100644
--- a/src/i18n/messages/en/common.json
+++ b/src/i18n/messages/en/common.json
@@ -412,6 +412,9 @@
"scrollRight": "Scroll right",
"volume": "Volume {number}"
},
+ "common": {
+ "retry": "Retry"
+ },
"debug": {
"title": "DEBUG",
"entries": "entry",
diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json
index 258cadc..6fa7adf 100644
--- a/src/i18n/messages/fr/common.json
+++ b/src/i18n/messages/fr/common.json
@@ -414,6 +414,9 @@
"scrollRight": "Défiler vers la droite",
"volume": "Tome {number}"
},
+ "common": {
+ "retry": "Réessayer"
+ },
"debug": {
"title": "DEBUG",
"entries": "entrée",
diff --git a/src/lib/services/server-cache.service.ts b/src/lib/services/server-cache.service.ts
index 2f2bb43..fae6fbb 100644
--- a/src/lib/services/server-cache.service.ts
+++ b/src/lib/services/server-cache.service.ts
@@ -289,6 +289,40 @@ class ServerCacheService {
}
}
+ /**
+ * Récupère des données du cache même si elles sont expirées (stale)
+ * Retourne { data, isStale } ou null si pas de cache
+ */
+ private getStale(key: string): { data: any; isStale: boolean } | null {
+ if (this.config.mode === "memory") {
+ const cached = this.memoryCache.get(key);
+ if (!cached) return null;
+
+ return {
+ data: cached.data,
+ isStale: cached.expiry <= Date.now(),
+ };
+ }
+
+ const filePath = this.getCacheFilePath(key);
+ if (!fs.existsSync(filePath)) {
+ return null;
+ }
+
+ try {
+ const content = fs.readFileSync(filePath, "utf-8");
+ const cached = JSON.parse(content);
+
+ return {
+ data: cached.data,
+ isStale: cached.expiry <= Date.now(),
+ };
+ } catch (error) {
+ console.error(`Error reading cache file ${filePath}:`, error);
+ return null;
+ }
+ }
+
/**
* Supprime une entrée du cache
*/
@@ -383,6 +417,10 @@ class ServerCacheService {
/**
* Récupère des données du cache ou exécute la fonction si nécessaire
+ * Stratégie stale-while-revalidate:
+ * - Cache valide → retourne immédiatement
+ * - Cache expiré → retourne le cache expiré ET revalide en background
+ * - Pas de cache → fetch normalement
*/
async getOrSet(
key: string,
@@ -396,24 +434,33 @@ class ServerCacheService {
}
const cacheKey = `${user.id}-${key}`;
- const cached = this.get(cacheKey);
- if (cached !== null) {
+ const cachedResult = this.getStale(cacheKey);
+
+ if (cachedResult !== null) {
+ const { data, isStale } = cachedResult;
const endTime = performance.now();
- // Log la requête avec l'indication du cache (URL plus claire)
+ // Log la requête avec l'indication du cache
await DebugService.logRequest({
- url: `[CACHE] ${key}`,
+ url: `[CACHE${isStale ? '-STALE' : ''}] ${key}`,
startTime,
endTime,
fromCache: true,
cacheType: type,
});
- return cached as T;
+
+ // Si le cache est expiré, revalider en background sans bloquer la réponse
+ if (isStale) {
+ // Fire and forget - revalidate en background
+ this.revalidateInBackground(cacheKey, fetcher, type, key);
+ }
+
+ return data as T;
}
+ // Pas de cache du tout, fetch normalement
try {
const data = await fetcher();
-
this.set(cacheKey, data, type);
return data;
} catch (error) {
@@ -421,6 +468,34 @@ class ServerCacheService {
}
}
+ /**
+ * Revalide le cache en background
+ */
+ private async revalidateInBackground(
+ cacheKey: string,
+ fetcher: () => Promise,
+ type: keyof typeof ServerCacheService.DEFAULT_TTL,
+ debugKey: string
+ ): Promise {
+ try {
+ const startTime = performance.now();
+ const data = await fetcher();
+ this.set(cacheKey, data, type);
+
+ const endTime = performance.now();
+ await DebugService.logRequest({
+ url: `[REVALIDATE] ${debugKey}`,
+ startTime,
+ endTime,
+ fromCache: false,
+ cacheType: type,
+ });
+ } catch (error) {
+ console.error(`Background revalidation failed for ${debugKey}:`, error);
+ // Ne pas relancer l'erreur car c'est en background
+ }
+ }
+
invalidate(key: string): void {
this.delete(key);
}