diff --git a/public/sw.js b/public/sw.js index b7f6325..8ed45c2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -44,6 +44,30 @@ function isBookPageRequest(url) { ); } +function shouldCacheResponse(response) { + if (!response || !response.ok) return false; + const cacheControl = response.headers.get("Cache-Control") || ""; + return !/no-store|private/i.test(cacheControl); +} + +async function getOfflineFallbackResponse() { + // Try root page first for app-shell style recovery + const pagesCache = await caches.open(PAGES_CACHE); + const rootPage = await pagesCache.match("/"); + if (rootPage) { + return rootPage; + } + + // Last resort: static offline page + const staticCache = await caches.open(STATIC_CACHE); + const offlinePage = await staticCache.match(OFFLINE_PAGE); + if (offlinePage) { + return offlinePage; + } + + return null; +} + // ============================================================================ // Client Communication // ============================================================================ @@ -106,7 +130,7 @@ async function cacheFirstStrategy(request, cacheName, options = {}) { try { const response = await fetch(request); - if (response.ok) { + if (shouldCacheResponse(response)) { cache.put(request, response.clone()); } return response; @@ -144,7 +168,7 @@ async function staleWhileRevalidateStrategy(request, cacheName, options = {}) { // Start network request (don't await) const fetchPromise = fetch(request) .then(async (response) => { - if (response.ok) { + if (shouldCacheResponse(response)) { // Clone response for cache const responseToCache = response.clone(); @@ -198,6 +222,14 @@ async function staleWhileRevalidateStrategy(request, cacheName, options = {}) { return response; } + if (options.fallbackToOffline) { + // For RSC/data requests, return an HTML fallback to avoid hard failures offline + const fallbackResponse = await getOfflineFallbackResponse(); + if (fallbackResponse) { + return fallbackResponse; + } + } + throw new Error("Network failed and no cache available"); } @@ -213,7 +245,7 @@ async function navigationSWRStrategy(request, cacheName) { // Start network request in background const fetchPromise = fetch(request) .then(async (response) => { - if (response.ok) { + if (shouldCacheResponse(response)) { await cache.put(request, response.clone()); } return response; @@ -231,18 +263,10 @@ async function navigationSWRStrategy(request, cacheName) { return response; } - // Network failed and no cache - try fallbacks - // Try to serve root page for SPA client-side routing - const rootPage = await cache.match("/"); - if (rootPage) { - return rootPage; - } - - // Last resort: offline page (in static cache) - const staticCache = await caches.open(STATIC_CACHE); - const offlinePage = await staticCache.match(OFFLINE_PAGE); - if (offlinePage) { - return offlinePage; + // Network failed and no cache - try shared fallbacks + const fallbackResponse = await getOfflineFallbackResponse(); + if (fallbackResponse) { + return fallbackResponse; } throw new Error("Offline and no cached page available"); @@ -264,7 +288,6 @@ self.addEventListener("install", (event) => { // eslint-disable-next-line no-console console.log("[SW] Precached assets"); } catch (error) { - console.error("[SW] Precache failed:", error); } await self.skipWaiting(); @@ -507,6 +530,7 @@ self.addEventListener("fetch", (event) => { event.respondWith( staleWhileRevalidateStrategy(request, PAGES_CACHE, { notifyOnChange: false, + fallbackToOffline: true, }) ); return; diff --git a/src/components/settings/CacheSettings.tsx b/src/components/settings/CacheSettings.tsx index f8680ed..bbde2e0 100644 --- a/src/components/settings/CacheSettings.tsx +++ b/src/components/settings/CacheSettings.tsx @@ -8,6 +8,8 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { ScrollArea } from "@/components/ui/scroll-area"; import { @@ -197,12 +199,15 @@ export function CacheSettings() { const { isSupported, isReady, + isDevModeEnabled, version, getCacheStats, getCacheEntries, clearCache, reinstallServiceWorker, + setDevModeEnabled, } = useServiceWorker(); + const isDevelopment = process.env.NODE_ENV === "development"; const [stats, setStats] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -276,6 +281,25 @@ export function CacheSettings() { } }; + const handleServiceWorkerDevToggle = async (checked: boolean) => { + try { + const success = await setDevModeEnabled(checked); + if (!success) { + throw new Error("Failed to toggle service worker in development"); + } + toast({ + title: t("settings.title"), + description: t("settings.cache.devServiceWorker.saved"), + }); + } catch { + toast({ + variant: "destructive", + title: t("settings.error.title"), + description: t("settings.cache.devServiceWorker.error"), + }); + } + }; + // Calculer le pourcentage du cache utilisé (basé sur 100MB limite images) const maxCacheSize = 100 * 1024 * 1024; // 100MB const usagePercent = stats ? Math.min((stats.images.size / maxCacheSize) * 100, 100) : 0; @@ -328,6 +352,22 @@ export function CacheSettings() { {t("settings.cache.description")} + {isDevelopment && ( +
+
+ +

+ {t("settings.cache.devServiceWorker.description")} +

+
+ +
+ )} + {/* Barre de progression globale */} {stats && (
diff --git a/src/components/settings/ClientSettings.tsx b/src/components/settings/ClientSettings.tsx index 0648f61..f8caf4a 100644 --- a/src/components/settings/ClientSettings.tsx +++ b/src/components/settings/ClientSettings.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useState } from "react"; import type { KomgaConfig } from "@/types/komga"; import type { KomgaLibrary } from "@/types/komga"; import { useTranslate } from "@/hooks/useTranslate"; @@ -16,15 +17,35 @@ interface ClientSettingsProps { initialLibraries: KomgaLibrary[]; } +const SETTINGS_TAB_STORAGE_KEY = "stripstream:settings-active-tab"; + export function ClientSettings({ initialConfig, initialLibraries }: ClientSettingsProps) { const { t } = useTranslate(); + const [activeTab, setActiveTab] = useState<"display" | "connection">("display"); + + useEffect(() => { + const savedTab = window.sessionStorage.getItem(SETTINGS_TAB_STORAGE_KEY); + if (savedTab === "display" || savedTab === "connection") { + const rafId = window.requestAnimationFrame(() => { + setActiveTab(savedTab); + }); + return () => window.cancelAnimationFrame(rafId); + } + }, []); + + const handleTabChange = (tab: string) => { + if (tab === "display" || tab === "connection") { + setActiveTab(tab); + window.sessionStorage.setItem(SETTINGS_TAB_STORAGE_KEY, tab); + } + }; return (

{t("settings.title")}

- + diff --git a/src/components/ui/NetworkStatus.tsx b/src/components/ui/NetworkStatus.tsx index c6c9334..4fe1d36 100644 --- a/src/components/ui/NetworkStatus.tsx +++ b/src/components/ui/NetworkStatus.tsx @@ -9,7 +9,7 @@ export function NetworkStatus() { if (isOnline) return null; return ( -
+
Hors ligne
diff --git a/src/contexts/ServiceWorkerContext.tsx b/src/contexts/ServiceWorkerContext.tsx index 08af324..05310f3 100644 --- a/src/contexts/ServiceWorkerContext.tsx +++ b/src/contexts/ServiceWorkerContext.tsx @@ -2,7 +2,12 @@ import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react"; import type { ReactNode } from "react"; -import { registerServiceWorker, unregisterServiceWorker } from "@/lib/registerSW"; +import { + registerServiceWorker, + unregisterServiceWorker, + isServiceWorkerEnabledInDev, + setServiceWorkerEnabledInDev, +} from "@/lib/registerSW"; import logger from "@/lib/logger"; interface CacheStats { @@ -29,6 +34,7 @@ type CacheType = "all" | "static" | "pages" | "api" | "images" | "books"; interface ServiceWorkerContextValue { isSupported: boolean; isReady: boolean; + isDevModeEnabled: boolean; version: string | null; hasNewVersion: boolean; cacheUpdates: CacheUpdate[]; @@ -40,6 +46,7 @@ interface ServiceWorkerContextValue { skipWaiting: () => void; reloadForUpdate: () => void; reinstallServiceWorker: () => Promise; + setDevModeEnabled: (enabled: boolean) => Promise; } const ServiceWorkerContext = createContext(null); @@ -47,6 +54,7 @@ const ServiceWorkerContext = createContext(nul export function ServiceWorkerProvider({ children }: { children: ReactNode }) { const [isSupported, setIsSupported] = useState(false); const [isReady, setIsReady] = useState(false); + const [isDevModeEnabled, setIsDevModeEnabled] = useState(process.env.NODE_ENV !== "development"); const [version, setVersion] = useState(null); const [hasNewVersion, setHasNewVersion] = useState(false); const [cacheUpdates, setCacheUpdates] = useState([]); @@ -159,7 +167,6 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) { // Silently ignore message handling errors to prevent app crashes // This can happen with malformed messages or during SW reinstall if (process.env.NODE_ENV === "development") { - console.warn("[SW Context] Error handling message:", error, event.data); } } @@ -172,8 +179,10 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) { return; } - if (process.env.NODE_ENV === "development") { - setIsSupported(false); + if (process.env.NODE_ENV === "development" && !isServiceWorkerEnabledInDev()) { + setIsDevModeEnabled(false); + // Browser still supports SW, it is only disabled by preference in dev + setIsSupported(true); setIsReady(false); setVersion(null); @@ -184,6 +193,10 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) { return; } + if (process.env.NODE_ENV === "development") { + setIsDevModeEnabled(true); + } + setIsSupported(true); // Register service worker @@ -348,11 +361,37 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) { } }, []); + const setDevModeEnabled = useCallback(async (enabled: boolean): Promise => { + if (process.env.NODE_ENV !== "development") { + return true; + } + + try { + setServiceWorkerEnabledInDev(enabled); + setIsDevModeEnabled(enabled); + + if (!enabled) { + await unregisterServiceWorker(); + setIsSupported("serviceWorker" in navigator); + setIsReady(false); + setVersion(null); + setHasNewVersion(false); + } + + window.location.reload(); + return true; + } catch (error) { + logger.error({ err: error }, "Failed to toggle service worker in development"); + return false; + } + }, []); + return ( {children} diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts index b4d2904..9f0eb15 100644 --- a/src/i18n/i18n.ts +++ b/src/i18n/i18n.ts @@ -41,6 +41,10 @@ if (!i18n.isInitialized) { transKeepBasicHtmlNodesFor: ["br", "strong", "i", "p", "span"], // Liste des balises autorisées }, }); +} else { + // Keep translation resources in sync during HMR/dev without full re-init. + i18n.addResourceBundle("fr", "common", frCommon, true, true); + i18n.addResourceBundle("en", "common", enCommon, true, true); } export default i18n; diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index 6594394..d73a701 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -161,6 +161,12 @@ "unavailable": "Cache statistics unavailable", "reinstall": "Reinstall Service Worker", "reinstallError": "Error reinstalling Service Worker", + "devServiceWorker": { + "label": "Service Worker in development", + "description": "Enable Service Worker in dev mode to test cache/offline behavior. A reload is applied.", + "saved": "Dev Service Worker preference updated", + "error": "Failed to update dev Service Worker preference" + }, "entry": "entry", "entries": "entries", "loadingEntries": "Loading entries...", diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index 6aa21b7..5adc14c 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -161,6 +161,12 @@ "unavailable": "Statistiques du cache non disponibles", "reinstall": "Réinstaller le Service Worker", "reinstallError": "Erreur lors de la réinstallation du Service Worker", + "devServiceWorker": { + "label": "Service Worker en développement", + "description": "Active le Service Worker en mode dev pour tester le cache/hors-ligne. Un rechargement est appliqué.", + "saved": "Préférence Service Worker dev mise à jour", + "error": "Impossible de mettre à jour la préférence Service Worker dev" + }, "entry": "entrée", "entries": "entrées", "loadingEntries": "Chargement des entrées...", diff --git a/src/lib/registerSW.ts b/src/lib/registerSW.ts index 74aa4f8..1832883 100644 --- a/src/lib/registerSW.ts +++ b/src/lib/registerSW.ts @@ -6,6 +6,19 @@ interface ServiceWorkerRegistrationOptions { onError?: (error: Error) => void; } +const DEV_SW_ENABLED_STORAGE_KEY = "stripstream:sw-dev-enabled"; + +export const isServiceWorkerEnabledInDev = (): boolean => { + if (typeof window === "undefined") return false; + if (process.env.NODE_ENV !== "development") return true; + return window.localStorage.getItem(DEV_SW_ENABLED_STORAGE_KEY) === "true"; +}; + +export const setServiceWorkerEnabledInDev = (enabled: boolean): void => { + if (typeof window === "undefined" || process.env.NODE_ENV !== "development") return; + window.localStorage.setItem(DEV_SW_ENABLED_STORAGE_KEY, enabled ? "true" : "false"); +}; + /** * Register the service worker with optional callbacks for update and success events */