Compare commits

...

6 Commits

Author SHA1 Message Date
Julien Froidefond
034aa69f8d feat: update service worker to version 2.5 and enhance caching strategies for network requests, including cache bypass for refresh actions in LibraryClientWrapper, SeriesClientWrapper, and HomeClientWrapper components
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m3s
2026-01-04 11:44:50 +01:00
Julien Froidefond
060dfb3099 fix: adjust thumbnail size and optimize image loading in BookDownloadCard component
Some checks are pending
Deploy with Docker Compose / deploy (push) Has started running
2026-01-04 11:41:13 +01:00
Julien Froidefond
ad11bce308 revert: restore page-by-page download method (old method works better) 2026-01-04 11:39:55 +01:00
Julien Froidefond
1ffe99285d feat: add fflate library for file decompression and implement file download functionality in BookOfflineButton component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m18s
2026-01-04 11:32:48 +01:00
Julien Froidefond
0d33462349 feat: update service worker to version 2.4, enhance caching strategies for pages, and add service worker reinstallation functionality in CacheSettings component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
2026-01-04 07:39:07 +01:00
Julien Froidefond
b8a0b85c54 refactor: rename Image import to ImageIcon for clarity in CacheSettings component and remove unused React import in collapsible component 2026-01-04 07:18:22 +01:00
10 changed files with 378 additions and 146 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.5";
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";
@@ -129,10 +129,23 @@ async function cacheFirstStrategy(request, cacheName, options = {}) {
/** /**
* Stale-While-Revalidate: Serve from cache immediately, update in background * Stale-While-Revalidate: Serve from cache immediately, update in background
* Used for: API calls, images * Used for: API calls, images
* Respects Cache-Control: no-cache to force network-first (for refresh buttons)
*/ */
async function staleWhileRevalidateStrategy(request, cacheName, options = {}) { async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
const cache = await caches.open(cacheName); const cache = await caches.open(cacheName);
const cached = await cache.match(request);
// Check if client requested no-cache (refresh button, router.refresh(), etc.)
// 1. Check Cache-Control header
const cacheControl = request.headers.get("Cache-Control");
const noCacheHeader =
cacheControl && (cacheControl.includes("no-cache") || cacheControl.includes("no-store"));
// 2. Check request.cache mode (used by Next.js router.refresh())
const noCacheMode =
request.cache === "no-cache" || request.cache === "no-store" || request.cache === "reload";
const noCache = noCacheHeader || noCacheMode;
// If no-cache, skip cached response and go network-first
const cached = noCache ? null : await cache.match(request);
// Start network request (don't await) // Start network request (don't await)
const fetchPromise = fetch(request) const fetchPromise = fetch(request)
@@ -195,39 +208,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 +286,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 +319,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 +345,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 +369,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 +423,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 +508,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 +546,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

@@ -17,21 +17,45 @@ interface LibraryClientWrapperProps {
preferences: UserPreferences; preferences: UserPreferences;
} }
export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) { export function LibraryClientWrapper({
children,
libraryId,
currentPage,
unreadOnly,
search,
pageSize,
}: LibraryClientWrapperProps) {
const router = useRouter(); const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
setIsRefreshing(true); setIsRefreshing(true);
// Revalider la page côté serveur
// Fetch fresh data from network with cache bypass
const params = new URLSearchParams({
page: String(currentPage),
size: String(pageSize),
...(unreadOnly && { unreadOnly: "true" }),
...(search && { search }),
});
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
cache: "no-store",
headers: { "Cache-Control": "no-cache" },
});
if (!response.ok) {
throw new Error("Failed to refresh library");
}
// Trigger Next.js revalidation to update the UI
router.refresh(); router.refresh();
return { success: true }; return { success: true };
} catch { } catch {
return { success: false, error: "Error refreshing library" }; return { success: false, error: "Error refreshing library" };
} finally { } finally {
// Petit délai pour laisser le temps au serveur de revalider setIsRefreshing(false);
setTimeout(() => setIsRefreshing(false), 500);
} }
}; };

View File

@@ -18,6 +18,10 @@ interface SeriesClientWrapperProps {
export function SeriesClientWrapper({ export function SeriesClientWrapper({
children, children,
seriesId,
currentPage,
unreadOnly,
pageSize,
}: SeriesClientWrapperProps) { }: SeriesClientWrapperProps) {
const router = useRouter(); const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
@@ -25,14 +29,30 @@ export function SeriesClientWrapper({
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
setIsRefreshing(true); setIsRefreshing(true);
// Revalider la page côté serveur
// Fetch fresh data from network with cache bypass
const params = new URLSearchParams({
page: String(currentPage),
size: String(pageSize),
...(unreadOnly && { unreadOnly: "true" }),
});
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
cache: "no-store",
headers: { "Cache-Control": "no-cache" },
});
if (!response.ok) {
throw new Error("Failed to refresh series");
}
// Trigger Next.js revalidation to update the UI
router.refresh(); router.refresh();
return { success: true }; return { success: true };
} catch { } catch {
return { success: false, error: "Error refreshing series" }; return { success: false, error: "Error refreshing series" };
} finally { } finally {
// Petit délai pour laisser le temps au serveur de revalider setIsRefreshing(false);
setTimeout(() => setIsRefreshing(false), 500);
} }
}; };
@@ -52,10 +72,7 @@ export function SeriesClientWrapper({
canRefresh={pullToRefresh.canRefresh} canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding} isHiding={pullToRefresh.isHiding}
/> />
<RefreshProvider refreshSeries={handleRefresh}> <RefreshProvider refreshSeries={handleRefresh}>{children}</RefreshProvider>
{children}
</RefreshProvider>
</> </>
); );
} }

View File

@@ -311,14 +311,15 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
return ( return (
<Card className="p-4"> <Card className="p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative w-12 aspect-[2/3] bg-muted/80 backdrop-blur-md rounded overflow-hidden"> <div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
<Image <Image
src={`/api/komga/images/books/${book.id}/thumbnail`} src={`/api/komga/images/books/${book.id}/thumbnail`}
alt={t("books.coverAlt", { title: book.metadata?.title })} alt={t("books.coverAlt", { title: book.metadata?.title })}
className="object-cover" className="object-cover"
fill fill
sizes="48px" sizes="64px"
priority={false} priority={false}
unoptimized
/> />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">

View File

@@ -20,15 +20,25 @@ export function HomeClientWrapper({ children }: HomeClientWrapperProps) {
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
setIsRefreshing(true); setIsRefreshing(true);
// Revalider la page côté serveur
// Fetch fresh data from network with cache bypass
const response = await fetch("/api/komga/home", {
cache: "no-store",
headers: { "Cache-Control": "no-cache" },
});
if (!response.ok) {
throw new Error("Failed to refresh home");
}
// Trigger Next.js revalidation to update the UI
router.refresh(); router.refresh();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors du rafraîchissement:"); logger.error({ err: error }, "Erreur lors du rafraîchissement:");
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 {
// Petit délai pour laisser le temps au serveur de revalider setIsRefreshing(false);
setTimeout(() => setIsRefreshing(false), 500);
} }
}; };

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...",