Compare commits

..

2 Commits

6 changed files with 296 additions and 129 deletions

View File

@@ -1,11 +1,11 @@
// StripStream Service Worker - Version 2 // StripStream Service Worker - Version 2
// Architecture: SWR (Stale-While-Revalidate) for all resources // Architecture: SWR (Stale-While-Revalidate) for all resources
const VERSION = "v2"; const VERSION = "v2.4";
const STATIC_CACHE = `stripstream-static-${VERSION}`; const STATIC_CACHE = `stripstream-static-${VERSION}`;
const PAGES_CACHE = `stripstream-pages-${VERSION}`; // Navigation + RSC (client-side navigation)
const API_CACHE = `stripstream-api-${VERSION}`; const API_CACHE = `stripstream-api-${VERSION}`;
const IMAGES_CACHE = `stripstream-images-${VERSION}`; const IMAGES_CACHE = `stripstream-images-${VERSION}`;
const RSC_CACHE = `stripstream-rsc-${VERSION}`;
const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
const OFFLINE_PAGE = "/offline.html"; const OFFLINE_PAGE = "/offline.html";
@@ -195,39 +195,50 @@ async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
} }
/** /**
* Network-First: Try network, fallback to cache * Navigation SWR: Serve from cache immediately, update in background
* Falls back to offline page if nothing cached
* Used for: Page navigations * Used for: Page navigations
*/ */
async function networkFirstStrategy(request, cacheName) { async function navigationSWRStrategy(request, cacheName) {
const cache = await caches.open(cacheName); const cache = await caches.open(cacheName);
const cached = await cache.match(request);
try { // Start network request in background
const response = await fetch(request); const fetchPromise = fetch(request)
if (response.ok) { .then(async (response) => {
cache.put(request, response.clone()); if (response.ok) {
} await cache.put(request, response.clone());
return response; }
} catch (error) { return response;
// Network failed - try cache })
const cached = await cache.match(request); .catch(() => null);
if (cached) {
return cached;
}
// Try to serve root page for SPA client-side routing // Return cached version immediately if available
const rootPage = await cache.match("/"); if (cached) {
if (rootPage) { return cached;
return rootPage;
}
// Last resort: offline page
const offlinePage = await cache.match(OFFLINE_PAGE);
if (offlinePage) {
return offlinePage;
}
throw error;
} }
// No cache - wait for network
const response = await fetchPromise;
if (response) {
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;
}
throw new Error("Offline and no cached page available");
} }
// ============================================================================ // ============================================================================
@@ -262,7 +273,7 @@ self.addEventListener("activate", (event) => {
(async () => { (async () => {
// Clean up old caches, but preserve BOOKS_CACHE // Clean up old caches, but preserve BOOKS_CACHE
const cacheNames = await caches.keys(); const cacheNames = await caches.keys();
const currentCaches = [STATIC_CACHE, API_CACHE, IMAGES_CACHE, RSC_CACHE, BOOKS_CACHE]; const currentCaches = [STATIC_CACHE, PAGES_CACHE, API_CACHE, IMAGES_CACHE, BOOKS_CACHE];
const cachesToDelete = cacheNames.filter( const cachesToDelete = cacheNames.filter(
(name) => name.startsWith("stripstream-") && !currentCaches.includes(name) (name) => name.startsWith("stripstream-") && !currentCaches.includes(name)
@@ -295,20 +306,23 @@ self.addEventListener("message", async (event) => {
switch (type) { switch (type) {
case "GET_CACHE_STATS": { case "GET_CACHE_STATS": {
try { try {
const [staticSize, apiSize, imagesSize, booksSize] = await Promise.all([ const [staticSize, pagesSize, apiSize, imagesSize, booksSize] = await Promise.all([
getCacheSize(STATIC_CACHE), getCacheSize(STATIC_CACHE),
getCacheSize(PAGES_CACHE),
getCacheSize(API_CACHE), getCacheSize(API_CACHE),
getCacheSize(IMAGES_CACHE), getCacheSize(IMAGES_CACHE),
getCacheSize(BOOKS_CACHE), getCacheSize(BOOKS_CACHE),
]); ]);
const staticCache = await caches.open(STATIC_CACHE); const staticCache = await caches.open(STATIC_CACHE);
const pagesCache = await caches.open(PAGES_CACHE);
const apiCache = await caches.open(API_CACHE); const apiCache = await caches.open(API_CACHE);
const imagesCache = await caches.open(IMAGES_CACHE); const imagesCache = await caches.open(IMAGES_CACHE);
const booksCache = await caches.open(BOOKS_CACHE); const booksCache = await caches.open(BOOKS_CACHE);
const [staticKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([ const [staticKeys, pagesKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([
staticCache.keys(), staticCache.keys(),
pagesCache.keys(),
apiCache.keys(), apiCache.keys(),
imagesCache.keys(), imagesCache.keys(),
booksCache.keys(), booksCache.keys(),
@@ -318,10 +332,11 @@ self.addEventListener("message", async (event) => {
type: "CACHE_STATS", type: "CACHE_STATS",
payload: { payload: {
static: { size: staticSize, entries: staticKeys.length }, static: { size: staticSize, entries: staticKeys.length },
pages: { size: pagesSize, entries: pagesKeys.length },
api: { size: apiSize, entries: apiKeys.length }, api: { size: apiSize, entries: apiKeys.length },
images: { size: imagesSize, entries: imagesKeys.length }, images: { size: imagesSize, entries: imagesKeys.length },
books: { size: booksSize, entries: booksKeys.length }, books: { size: booksSize, entries: booksKeys.length },
total: staticSize + apiSize + imagesSize + booksSize, total: staticSize + pagesSize + apiSize + imagesSize + booksSize,
}, },
}); });
} catch (error) { } catch (error) {
@@ -341,15 +356,15 @@ self.addEventListener("message", async (event) => {
if (cacheType === "all" || cacheType === "static") { if (cacheType === "all" || cacheType === "static") {
cachesToClear.push(STATIC_CACHE); cachesToClear.push(STATIC_CACHE);
} }
if (cacheType === "all" || cacheType === "pages") {
cachesToClear.push(PAGES_CACHE);
}
if (cacheType === "all" || cacheType === "api") { if (cacheType === "all" || cacheType === "api") {
cachesToClear.push(API_CACHE); cachesToClear.push(API_CACHE);
} }
if (cacheType === "all" || cacheType === "images") { if (cacheType === "all" || cacheType === "images") {
cachesToClear.push(IMAGES_CACHE); cachesToClear.push(IMAGES_CACHE);
} }
if (cacheType === "all" || cacheType === "rsc") {
cachesToClear.push(RSC_CACHE);
}
// Note: BOOKS_CACHE is not cleared by default, only explicitly // Note: BOOKS_CACHE is not cleared by default, only explicitly
await Promise.all( await Promise.all(
@@ -395,6 +410,9 @@ self.addEventListener("message", async (event) => {
case "static": case "static":
cacheName = STATIC_CACHE; cacheName = STATIC_CACHE;
break; break;
case "pages":
cacheName = PAGES_CACHE;
break;
case "api": case "api":
cacheName = API_CACHE; cacheName = API_CACHE;
break; break;
@@ -477,10 +495,10 @@ self.addEventListener("fetch", (event) => {
return; return;
} }
// Route 2: Next.js RSC payloads → Stale-While-Revalidate // Route 2: Next.js RSC payloads (client-side navigation) → SWR in PAGES_CACHE
if (isNextRSCRequest(request)) { if (isNextRSCRequest(request)) {
event.respondWith( event.respondWith(
staleWhileRevalidateStrategy(request, RSC_CACHE, { staleWhileRevalidateStrategy(request, PAGES_CACHE, {
notifyOnChange: false, notifyOnChange: false,
}) })
); );
@@ -515,9 +533,9 @@ self.addEventListener("fetch", (event) => {
return; return;
} }
// Route 6: Navigation → Network-First with SPA fallback // Route 6: Navigation → SWR (cache first, revalidate in background)
if (request.mode === "navigate") { if (request.mode === "navigate") {
event.respondWith(networkFirstStrategy(request, STATIC_CACHE)); event.respondWith(navigationSWRStrategy(request, PAGES_CACHE));
return; return;
} }

View File

@@ -8,18 +8,14 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Database, Database,
Trash2, Trash2,
RefreshCw, RefreshCw,
HardDrive, HardDrive,
Image, Image as ImageIcon,
FileJson, FileJson,
BookOpen, BookOpen,
CheckCircle2, CheckCircle2,
@@ -27,10 +23,13 @@ import {
Loader2, Loader2,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
LayoutGrid,
RotateCcw,
} from "lucide-react"; } from "lucide-react";
interface CacheStats { interface CacheStats {
static: { size: number; entries: number }; static: { size: number; entries: number };
pages: { size: number; entries: number };
api: { size: number; entries: number }; api: { size: number; entries: number };
images: { size: number; entries: number }; images: { size: number; entries: number };
books: { size: number; entries: number }; books: { size: number; entries: number };
@@ -42,7 +41,7 @@ interface CacheEntry {
size: number; size: number;
} }
type CacheType = "static" | "api" | "images" | "books"; type CacheType = "static" | "pages" | "api" | "images" | "books";
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B"; if (bytes === 0) return "0 B";
@@ -195,12 +194,20 @@ function CacheItem({
export function CacheSettings() { export function CacheSettings() {
const { t } = useTranslate(); const { t } = useTranslate();
const { toast } = useToast(); const { toast } = useToast();
const { isSupported, isReady, version, getCacheStats, getCacheEntries, clearCache } = const {
useServiceWorker(); isSupported,
isReady,
version,
getCacheStats,
getCacheEntries,
clearCache,
reinstallServiceWorker,
} = useServiceWorker();
const [stats, setStats] = useState<CacheStats | null>(null); const [stats, setStats] = useState<CacheStats | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [clearingCache, setClearingCache] = useState<string | null>(null); const [clearingCache, setClearingCache] = useState<string | null>(null);
const [isReinstalling, setIsReinstalling] = useState(false);
const loadStats = useCallback(async () => { const loadStats = useCallback(async () => {
if (!isReady) return; if (!isReady) return;
@@ -218,7 +225,7 @@ export function CacheSettings() {
loadStats(); loadStats();
}, [loadStats]); }, [loadStats]);
const handleClearCache = async (cacheType: "all" | "static" | "api" | "images") => { const handleClearCache = async (cacheType: "all" | "static" | "pages" | "api" | "images") => {
setClearingCache(cacheType); setClearingCache(cacheType);
try { try {
const success = await clearCache(cacheType); const success = await clearCache(cacheType);
@@ -247,6 +254,28 @@ export function CacheSettings() {
[getCacheEntries] [getCacheEntries]
); );
const handleReinstall = async () => {
setIsReinstalling(true);
try {
const success = await reinstallServiceWorker();
if (!success) {
toast({
variant: "destructive",
title: t("settings.error.title"),
description: t("settings.cache.reinstallError"),
});
}
// If success, the page will reload automatically
} catch {
toast({
variant: "destructive",
title: t("settings.error.title"),
description: t("settings.cache.reinstallError"),
});
setIsReinstalling(false);
}
};
// Calculer le pourcentage du cache utilisé (basé sur 100MB limite images) // Calculer le pourcentage du cache utilisé (basé sur 100MB limite images)
const maxCacheSize = 100 * 1024 * 1024; // 100MB const maxCacheSize = 100 * 1024 * 1024; // 100MB
const usagePercent = stats ? Math.min((stats.images.size / maxCacheSize) * 100, 100) : 0; const usagePercent = stats ? Math.min((stats.images.size / maxCacheSize) * 100, 100) : 0;
@@ -328,6 +357,17 @@ export function CacheSettings() {
isClearing={clearingCache === "static"} isClearing={clearingCache === "static"}
onLoadEntries={handleLoadEntries} onLoadEntries={handleLoadEntries}
/> />
<CacheItem
icon={<LayoutGrid className="h-4 w-4" />}
label={t("settings.cache.pages")}
size={stats.pages.size}
entries={stats.pages.entries}
cacheType="pages"
description={t("settings.cache.pagesDesc")}
onClear={() => handleClearCache("pages")}
isClearing={clearingCache === "pages"}
onLoadEntries={handleLoadEntries}
/>
<CacheItem <CacheItem
icon={<FileJson className="h-4 w-4" />} icon={<FileJson className="h-4 w-4" />}
label={t("settings.cache.api")} label={t("settings.cache.api")}
@@ -340,7 +380,7 @@ export function CacheSettings() {
onLoadEntries={handleLoadEntries} onLoadEntries={handleLoadEntries}
/> />
<CacheItem <CacheItem
icon={<Image className="h-4 w-4" />} icon={<ImageIcon className="h-4 w-4" />}
label={t("settings.cache.images")} label={t("settings.cache.images")}
size={stats.images.size} size={stats.images.size}
entries={stats.images.entries} entries={stats.images.entries}
@@ -365,27 +405,55 @@ export function CacheSettings() {
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : ( ) : (
<p className="text-center text-muted-foreground py-8"> <div className="text-center py-8 space-y-4">
{t("settings.cache.unavailable")} <p className="text-muted-foreground">{t("settings.cache.unavailable")}</p>
</p> <Button
variant="outline"
onClick={handleReinstall}
disabled={isReinstalling}
className="gap-2"
>
{isReinstalling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCcw className="h-4 w-4" />
)}
{t("settings.cache.reinstall")}
</Button>
</div>
)} )}
</div> </div>
{/* Bouton vider tout */} {/* Bouton vider tout */}
{stats && stats.total > 0 && ( {stats && stats.total > 0 && (
<Button <div className="space-y-2">
variant="destructive" <Button
className="w-full gap-2" variant="destructive"
onClick={() => handleClearCache("all")} className="w-full gap-2"
disabled={clearingCache !== null} onClick={() => handleClearCache("all")}
> disabled={clearingCache !== null}
{clearingCache === "all" ? ( >
<Loader2 className="h-4 w-4 animate-spin" /> {clearingCache === "all" ? (
) : ( <Loader2 className="h-4 w-4 animate-spin" />
<Trash2 className="h-4 w-4" /> ) : (
)} <Trash2 className="h-4 w-4" />
{t("settings.cache.clearAll")} )}
</Button> {t("settings.cache.clearAll")}
</Button>
<Button
variant="outline"
className="w-full gap-2"
onClick={handleReinstall}
disabled={isReinstalling}
>
{isReinstalling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCcw className="h-4 w-4" />
)}
{t("settings.cache.reinstall")}
</Button>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import * as React from "react";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root; const Collapsible = CollapsiblePrimitive.Root;

View File

@@ -2,11 +2,12 @@
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react"; import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { registerServiceWorker } from "@/lib/registerSW"; import { registerServiceWorker, unregisterServiceWorker } from "@/lib/registerSW";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
interface CacheStats { interface CacheStats {
static: { size: number; entries: number }; static: { size: number; entries: number };
pages: { size: number; entries: number };
api: { size: number; entries: number }; api: { size: number; entries: number };
images: { size: number; entries: number }; images: { size: number; entries: number };
books: { size: number; entries: number }; books: { size: number; entries: number };
@@ -23,7 +24,7 @@ interface CacheUpdate {
timestamp: number; timestamp: number;
} }
type CacheType = "all" | "static" | "api" | "images" | "rsc" | "books"; type CacheType = "all" | "static" | "pages" | "api" | "images" | "books";
interface ServiceWorkerContextValue { interface ServiceWorkerContextValue {
isSupported: boolean; isSupported: boolean;
@@ -38,6 +39,7 @@ interface ServiceWorkerContextValue {
clearCache: (cacheType?: CacheType) => Promise<boolean>; clearCache: (cacheType?: CacheType) => Promise<boolean>;
skipWaiting: () => void; skipWaiting: () => void;
reloadForUpdate: () => void; reloadForUpdate: () => void;
reinstallServiceWorker: () => Promise<boolean>;
} }
const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null); const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null);
@@ -53,76 +55,113 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
// Handle messages from service worker // Handle messages from service worker
const handleMessage = useCallback((event: MessageEvent) => { const handleMessage = useCallback((event: MessageEvent) => {
const { type, payload } = event.data || {}; try {
// Ignore messages without proper data structure
if (!event.data || typeof event.data !== "object") return;
switch (type) { // Only handle messages from our service worker (check for known message types)
case "SW_ACTIVATED": const knownTypes = [
setIsReady(true); "SW_ACTIVATED",
setVersion(payload?.version || null); "SW_VERSION",
break; "CACHE_UPDATED",
"CACHE_STATS",
"CACHE_STATS_ERROR",
"CACHE_CLEARED",
"CACHE_CLEAR_ERROR",
"CACHE_ENTRIES",
"CACHE_ENTRIES_ERROR",
];
case "SW_VERSION": const type = event.data.type;
setVersion(payload?.version || null); if (typeof type !== "string" || !knownTypes.includes(type)) return;
break;
case "CACHE_UPDATED": const payload = event.data.payload;
setCacheUpdates((prev) => {
// Avoid duplicates for the same URL within 1 second
const existing = prev.find(
(u) => u.url === payload.url && Date.now() - u.timestamp < 1000
);
if (existing) return prev;
return [...prev, { url: payload.url, timestamp: payload.timestamp }];
});
break;
case "CACHE_STATS": switch (type) {
const statsResolver = pendingRequests.current.get("CACHE_STATS"); case "SW_ACTIVATED":
if (statsResolver) { setIsReady(true);
statsResolver(payload); setVersion(payload?.version || null);
pendingRequests.current.delete("CACHE_STATS"); break;
case "SW_VERSION":
setVersion(payload?.version || null);
break;
case "CACHE_UPDATED": {
const url = typeof payload?.url === "string" ? payload.url : null;
const timestamp = typeof payload?.timestamp === "number" ? payload.timestamp : Date.now();
if (url) {
setCacheUpdates((prev) => {
// Avoid duplicates for the same URL within 1 second
const existing = prev.find((u) => u.url === url && Date.now() - u.timestamp < 1000);
if (existing) return prev;
return [...prev, { url, timestamp }];
});
}
break;
} }
break;
case "CACHE_STATS_ERROR": case "CACHE_STATS":
const statsErrorResolver = pendingRequests.current.get("CACHE_STATS"); const statsResolver = pendingRequests.current.get("CACHE_STATS");
if (statsErrorResolver) { if (statsResolver) {
statsErrorResolver(null); statsResolver(payload);
pendingRequests.current.delete("CACHE_STATS"); pendingRequests.current.delete("CACHE_STATS");
} }
break; break;
case "CACHE_CLEARED": case "CACHE_STATS_ERROR":
const clearResolver = pendingRequests.current.get("CACHE_CLEARED"); const statsErrorResolver = pendingRequests.current.get("CACHE_STATS");
if (clearResolver) { if (statsErrorResolver) {
clearResolver(true); statsErrorResolver(null);
pendingRequests.current.delete("CACHE_CLEARED"); pendingRequests.current.delete("CACHE_STATS");
} }
break; break;
case "CACHE_CLEAR_ERROR": case "CACHE_CLEARED":
const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED"); const clearResolver = pendingRequests.current.get("CACHE_CLEARED");
if (clearErrorResolver) { if (clearResolver) {
clearErrorResolver(false); clearResolver(true);
pendingRequests.current.delete("CACHE_CLEARED"); pendingRequests.current.delete("CACHE_CLEARED");
} }
break; break;
case "CACHE_ENTRIES": case "CACHE_CLEAR_ERROR":
const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES"); const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED");
if (entriesResolver) { if (clearErrorResolver) {
entriesResolver(payload.entries); clearErrorResolver(false);
pendingRequests.current.delete("CACHE_ENTRIES"); pendingRequests.current.delete("CACHE_CLEARED");
} }
break; break;
case "CACHE_ENTRIES_ERROR": case "CACHE_ENTRIES": {
const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES"); const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES");
if (entriesErrorResolver) { if (entriesResolver) {
entriesErrorResolver(null); entriesResolver(payload?.entries || null);
pendingRequests.current.delete("CACHE_ENTRIES"); pendingRequests.current.delete("CACHE_ENTRIES");
}
break;
} }
break;
case "CACHE_ENTRIES_ERROR": {
const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES");
if (entriesErrorResolver) {
entriesErrorResolver(null);
pendingRequests.current.delete("CACHE_ENTRIES");
}
break;
}
default:
// Ignore unknown message types
break;
}
} catch (error) {
// 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") {
// eslint-disable-next-line no-console
console.warn("[SW Context] Error handling message:", error, event.data);
}
} }
}, []); }, []);
@@ -263,6 +302,40 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
} }
}, []); }, []);
const reinstallServiceWorker = useCallback(async (): Promise<boolean> => {
try {
// Unregister all service workers
await unregisterServiceWorker();
setIsReady(false);
setVersion(null);
// Re-register
const registration = await registerServiceWorker({
onSuccess: () => {
setIsReady(true);
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
}
},
onError: (error) => {
logger.error({ err: error }, "Service worker re-registration failed");
},
});
if (registration) {
// Force update check
await registration.update();
// Reload page to ensure new SW takes control
window.location.reload();
return true;
}
return false;
} catch (error) {
logger.error({ err: error }, "Failed to reinstall service worker");
return false;
}
}, []);
return ( return (
<ServiceWorkerContext.Provider <ServiceWorkerContext.Provider
value={{ value={{
@@ -278,6 +351,7 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
clearCache, clearCache,
skipWaiting, skipWaiting,
reloadForUpdate, reloadForUpdate,
reinstallServiceWorker,
}} }}
> >
{children} {children}

View File

@@ -146,6 +146,8 @@
"imagesQuota": "{used}% of images quota used", "imagesQuota": "{used}% of images quota used",
"static": "Static resources", "static": "Static resources",
"staticDesc": "Next.js scripts, styles and assets", "staticDesc": "Next.js scripts, styles and assets",
"pages": "Visited pages",
"pagesDesc": "Home, libraries, series and details",
"api": "API data", "api": "API data",
"apiDesc": "Series, books and library metadata", "apiDesc": "Series, books and library metadata",
"images": "Images", "images": "Images",
@@ -157,6 +159,8 @@
"clearedDesc": "Cache has been cleared successfully", "clearedDesc": "Cache has been cleared successfully",
"clearError": "Error clearing cache", "clearError": "Error clearing cache",
"unavailable": "Cache statistics unavailable", "unavailable": "Cache statistics unavailable",
"reinstall": "Reinstall Service Worker",
"reinstallError": "Error reinstalling Service Worker",
"entry": "entry", "entry": "entry",
"entries": "entries", "entries": "entries",
"loadingEntries": "Loading entries...", "loadingEntries": "Loading entries...",

View File

@@ -146,6 +146,8 @@
"imagesQuota": "{used}% du quota images utilisé", "imagesQuota": "{used}% du quota images utilisé",
"static": "Ressources statiques", "static": "Ressources statiques",
"staticDesc": "Scripts, styles et assets Next.js", "staticDesc": "Scripts, styles et assets Next.js",
"pages": "Pages visitées",
"pagesDesc": "Home, bibliothèques, séries et détails",
"api": "Données API", "api": "Données API",
"apiDesc": "Métadonnées des séries, livres et bibliothèques", "apiDesc": "Métadonnées des séries, livres et bibliothèques",
"images": "Images", "images": "Images",
@@ -157,6 +159,8 @@
"clearedDesc": "Le cache a été vidé avec succès", "clearedDesc": "Le cache a été vidé avec succès",
"clearError": "Erreur lors du vidage du cache", "clearError": "Erreur lors du vidage du cache",
"unavailable": "Statistiques du cache non disponibles", "unavailable": "Statistiques du cache non disponibles",
"reinstall": "Réinstaller le Service Worker",
"reinstallError": "Erreur lors de la réinstallation du Service Worker",
"entry": "entrée", "entry": "entrée",
"entries": "entrées", "entries": "entrées",
"loadingEntries": "Chargement des entrées...", "loadingEntries": "Chargement des entrées...",