feat: enhance service worker functionality with improved caching strategies, client communication, and service worker registration options
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m42s

This commit is contained in:
Julien Froidefond
2026-01-04 06:48:17 +01:00
parent b497746cfa
commit 2c8c0b5eb0
13 changed files with 1466 additions and 65 deletions

View File

@@ -18,10 +18,12 @@
"@prisma/client": "^6.17.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "1.2.3",

65
pnpm-lock.yaml generated
View File

@@ -21,6 +21,9 @@ importers:
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-dialog':
specifier: 1.1.15
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -33,6 +36,9 @@ importers:
'@radix-ui/react-radio-group':
specifier: ^1.3.8
version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-scroll-area':
specifier: ^1.2.10
version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-select':
specifier: ^2.1.6
version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -680,6 +686,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-collapsible@1.1.12':
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
@@ -894,6 +913,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-scroll-area@1.2.10':
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
@@ -3543,6 +3575,22 @@ snapshots:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
@@ -3765,6 +3813,23 @@ snapshots:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/number': 1.1.1

View File

@@ -1,14 +1,20 @@
// StripStream Service Worker - Version 1
// Architecture: Cache-as-you-go for static resources only
// StripStream Service Worker - Version 2
// Architecture: SWR (Stale-While-Revalidate) for all resources
const VERSION = "v1";
const VERSION = "v2";
const STATIC_CACHE = `stripstream-static-${VERSION}`;
const API_CACHE = `stripstream-api-${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 OFFLINE_PAGE = "/offline.html";
const PRECACHE_ASSETS = [OFFLINE_PAGE, "/manifest.json"];
// Cache size limits
const IMAGES_CACHE_MAX_SIZE = 100 * 1024 * 1024; // 100MB
const IMAGES_CACHE_MAX_ENTRIES = 500;
// ============================================================================
// Utility Functions - Request Detection
// ============================================================================
@@ -22,13 +28,79 @@ function isNextRSCRequest(request) {
return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1";
}
function isApiRequest(url) {
return url.includes("/api/komga/") && !url.includes("/api/komga/images/");
}
function isImageRequest(url) {
return url.includes("/api/komga/images/");
}
function isBookPageRequest(url) {
// Book pages: /api/komga/images/books/{id}/pages/{num} or /api/komga/books/{id}/pages/{num}
// These are handled by manual download (DownloadManager) - don't cache via SWR
return (
(url.includes("/api/komga/images/books/") || url.includes("/api/komga/books/")) &&
url.includes("/pages/")
);
}
function isBooksManualCache(url) {
// Check if this is a request that should be handled by the books manual cache
return url.includes("/api/komga/images/books/") && url.includes("/pages");
}
// ============================================================================
// Client Communication
// ============================================================================
async function notifyClients(message) {
const clients = await self.clients.matchAll({ type: "window" });
clients.forEach((client) => {
client.postMessage(message);
});
}
// ============================================================================
// Cache Management
// ============================================================================
async function getCacheSize(cacheName) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
let totalSize = 0;
for (const request of keys) {
const response = await cache.match(request);
if (response) {
const blob = await response.clone().blob();
totalSize += blob.size;
}
}
return totalSize;
}
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxEntries) {
// Remove oldest entries (FIFO)
const toDelete = keys.slice(0, keys.length - maxEntries);
await Promise.all(toDelete.map((key) => cache.delete(key)));
// eslint-disable-next-line no-console
console.log(`[SW] Trimmed ${toDelete.length} entries from ${cacheName}`);
}
}
// ============================================================================
// Cache Strategies
// ============================================================================
/**
* Cache-First: Serve from cache, fallback to network
* Used for: Next.js static resources
* Used for: Next.js static resources (immutable)
*/
async function cacheFirstStrategy(request, cacheName, options = {}) {
const cache = await caches.open(cacheName);
@@ -56,21 +128,57 @@ async function cacheFirstStrategy(request, cacheName, options = {}) {
/**
* Stale-While-Revalidate: Serve from cache immediately, update in background
* Used for: RSC payloads
* Used for: API calls, images
*/
async function staleWhileRevalidateStrategy(request, cacheName) {
async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
// Start network request (don't await)
const fetchPromise = fetch(request)
.then((response) => {
.then(async (response) => {
if (response.ok) {
cache.put(request, response.clone());
// Clone response for cache
const responseToCache = response.clone();
// Check if content changed (for notification)
if (cached && options.notifyOnChange) {
try {
const cachedResponse = await cache.match(request);
if (cachedResponse) {
// For JSON APIs, compare content
if (options.isJson) {
const oldText = await cachedResponse.text();
const newText = await response.clone().text();
if (oldText !== newText) {
notifyClients({
type: "CACHE_UPDATED",
url: request.url,
timestamp: Date.now(),
});
}
}
}
} catch {
// Ignore comparison errors
}
}
// Update cache
await cache.put(request, responseToCache);
// Trim cache if needed (for images)
if (options.maxEntries) {
trimCache(cacheName, options.maxEntries);
}
}
return response;
})
.catch(() => null);
.catch((error) => {
// eslint-disable-next-line no-console
console.log("[SW] Network request failed:", request.url, error.message);
return null;
});
// Return cached version immediately if available
if (cached) {
@@ -87,14 +195,13 @@ async function staleWhileRevalidateStrategy(request, cacheName) {
}
/**
* Navigation Strategy: Network-First with SPA fallback
* Network-First: Try network, fallback to cache
* Used for: Page navigations
*/
async function navigationStrategy(request) {
const cache = await caches.open(STATIC_CACHE);
async function networkFirstStrategy(request, cacheName) {
const cache = await caches.open(cacheName);
try {
// Try network first
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
@@ -155,9 +262,10 @@ self.addEventListener("activate", (event) => {
(async () => {
// Clean up old caches, but preserve BOOKS_CACHE
const cacheNames = await caches.keys();
const currentCaches = [STATIC_CACHE, API_CACHE, IMAGES_CACHE, RSC_CACHE, BOOKS_CACHE];
const cachesToDelete = cacheNames.filter(
(name) =>
name.startsWith("stripstream-") && name !== BOOKS_CACHE && !name.endsWith(`-${VERSION}`)
(name) => name.startsWith("stripstream-") && !currentCaches.includes(name)
);
await Promise.all(cachesToDelete.map((name) => caches.delete(name)));
@@ -170,10 +278,172 @@ self.addEventListener("activate", (event) => {
await self.clients.claim();
// eslint-disable-next-line no-console
console.log("[SW] Activated and claimed clients");
// Notify clients that SW is ready
notifyClients({ type: "SW_ACTIVATED", version: VERSION });
})()
);
});
// ============================================================================
// Message Handler - Client Communication
// ============================================================================
self.addEventListener("message", async (event) => {
const { type, payload } = event.data || {};
switch (type) {
case "GET_CACHE_STATS": {
try {
const [staticSize, apiSize, imagesSize, booksSize] = await Promise.all([
getCacheSize(STATIC_CACHE),
getCacheSize(API_CACHE),
getCacheSize(IMAGES_CACHE),
getCacheSize(BOOKS_CACHE),
]);
const staticCache = await caches.open(STATIC_CACHE);
const apiCache = await caches.open(API_CACHE);
const imagesCache = await caches.open(IMAGES_CACHE);
const booksCache = await caches.open(BOOKS_CACHE);
const [staticKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([
staticCache.keys(),
apiCache.keys(),
imagesCache.keys(),
booksCache.keys(),
]);
event.source.postMessage({
type: "CACHE_STATS",
payload: {
static: { size: staticSize, entries: staticKeys.length },
api: { size: apiSize, entries: apiKeys.length },
images: { size: imagesSize, entries: imagesKeys.length },
books: { size: booksSize, entries: booksKeys.length },
total: staticSize + apiSize + imagesSize + booksSize,
},
});
} catch (error) {
event.source.postMessage({
type: "CACHE_STATS_ERROR",
payload: { error: error.message },
});
}
break;
}
case "CLEAR_CACHE": {
try {
const cacheType = payload?.cacheType || "all";
const cachesToClear = [];
if (cacheType === "all" || cacheType === "static") {
cachesToClear.push(STATIC_CACHE);
}
if (cacheType === "all" || cacheType === "api") {
cachesToClear.push(API_CACHE);
}
if (cacheType === "all" || cacheType === "images") {
cachesToClear.push(IMAGES_CACHE);
}
if (cacheType === "all" || cacheType === "rsc") {
cachesToClear.push(RSC_CACHE);
}
// Note: BOOKS_CACHE is not cleared by default, only explicitly
await Promise.all(
cachesToClear.map(async (cacheName) => {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
await Promise.all(keys.map((key) => cache.delete(key)));
})
);
event.source.postMessage({
type: "CACHE_CLEARED",
payload: { caches: cachesToClear },
});
} catch (error) {
event.source.postMessage({
type: "CACHE_CLEAR_ERROR",
payload: { error: error.message },
});
}
break;
}
case "SKIP_WAITING": {
self.skipWaiting();
break;
}
case "GET_VERSION": {
event.source.postMessage({
type: "SW_VERSION",
payload: { version: VERSION },
});
break;
}
case "GET_CACHE_ENTRIES": {
try {
const cacheType = payload?.cacheType;
let cacheName;
switch (cacheType) {
case "static":
cacheName = STATIC_CACHE;
break;
case "api":
cacheName = API_CACHE;
break;
case "images":
cacheName = IMAGES_CACHE;
break;
case "books":
cacheName = BOOKS_CACHE;
break;
default:
throw new Error("Invalid cache type");
}
const cache = await caches.open(cacheName);
const keys = await cache.keys();
const entries = await Promise.all(
keys.map(async (request) => {
const response = await cache.match(request);
let size = 0;
if (response) {
const blob = await response.clone().blob();
size = blob.size;
}
return {
url: request.url,
size,
};
})
);
// Sort by size descending
entries.sort((a, b) => b.size - a.size);
event.source.postMessage({
type: "CACHE_ENTRIES",
payload: { cacheType, entries },
});
} catch (error) {
event.source.postMessage({
type: "CACHE_ENTRIES_ERROR",
payload: { error: error.message },
});
}
break;
}
}
});
// ============================================================================
// Fetch Handler - Request Routing
// ============================================================================
@@ -188,24 +458,68 @@ self.addEventListener("fetch", (event) => {
return;
}
// Route 1: Next.js RSC payloads → Stale-While-Revalidate
if (isNextRSCRequest(request)) {
event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE));
// Route 1: Book pages (handled by DownloadManager) - Check manual cache only, no SWR
if (isBookPageRequest(url.href)) {
event.respondWith(
(async () => {
// Check the manual books cache
const booksCache = await caches.open(BOOKS_CACHE);
const cached = await booksCache.match(request);
if (cached) {
return cached;
}
// Not in cache - fetch from network without caching
// Book pages are large and should only be cached via manual download
return fetch(request);
})()
);
return;
}
// Route 2: Next.js static resources → Cache-First with ignoreSearch
// Route 2: Next.js RSC payloads → Stale-While-Revalidate
if (isNextRSCRequest(request)) {
event.respondWith(
staleWhileRevalidateStrategy(request, RSC_CACHE, {
notifyOnChange: false,
})
);
return;
}
// Route 3: Next.js static resources → Cache-First with ignoreSearch
if (isNextStaticResource(url.href)) {
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true }));
return;
}
// Route 3: Navigation → Network-First with SPA fallback
if (request.mode === "navigate") {
event.respondWith(navigationStrategy(request));
// Route 4: API requests (JSON) → SWR with notification
if (isApiRequest(url.href)) {
event.respondWith(
staleWhileRevalidateStrategy(request, API_CACHE, {
notifyOnChange: true,
isJson: true,
})
);
return;
}
// Route 4: Everything else → Network only (no caching)
// This includes: API calls, images, and other dynamic content
// Route 5: Image requests (thumbnails, covers) → SWR with cache size management
// Note: Book pages are excluded (Route 1) and only use manual download cache
if (isImageRequest(url.href)) {
event.respondWith(
staleWhileRevalidateStrategy(request, IMAGES_CACHE, {
maxEntries: IMAGES_CACHE_MAX_ENTRIES,
})
);
return;
}
// Route 6: Navigation → Network-First with SPA fallback
if (request.mode === "navigate") {
event.respondWith(networkFirstStrategy(request, STATIC_CACHE));
return;
}
// Route 7: Everything else → Network only (no caching)
});

View File

@@ -7,9 +7,9 @@ import { Sidebar } from "@/components/layout/Sidebar";
import { InstallPWA } from "../ui/InstallPWA";
import { Toaster } from "@/components/ui/toaster";
import { usePathname } from "next/navigation";
import { registerServiceWorker } from "@/lib/registerSW";
import { NetworkStatus } from "../ui/NetworkStatus";
import { usePreferences } from "@/contexts/PreferencesContext";
import { ServiceWorkerProvider } from "@/contexts/ServiceWorkerContext";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
import logger from "@/lib/logger";
@@ -135,10 +135,6 @@ export default function ClientLayout({
};
}, [isSidebarOpen]);
useEffect(() => {
// Enregistrer le service worker
registerServiceWorker();
}, []);
// Ne pas afficher le header et la sidebar sur les routes publiques et le reader
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/");
@@ -151,37 +147,39 @@ export default function ClientLayout({
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{/* Background fixe pour les images et gradients */}
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
<div
className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`}
style={
hasCustomBackground
? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` }
: undefined
}
>
{!isPublicRoute && (
<Header
onToggleSidebar={handleToggleSidebar}
onRefreshBackground={fetchRandomBook}
showRefreshBackground={preferences.background.type === "komga-random"}
/>
)}
{!isPublicRoute && (
<Sidebar
isOpen={isSidebarOpen}
onClose={handleCloseSidebar}
initialLibraries={initialLibraries}
initialFavorites={initialFavorites}
userIsAdmin={userIsAdmin}
/>
)}
<main className={!isPublicRoute ? "pt-safe" : ""}>{children}</main>
<InstallPWA />
<Toaster />
<NetworkStatus />
</div>
<ServiceWorkerProvider>
{/* Background fixe pour les images et gradients */}
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
<div
className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`}
style={
hasCustomBackground
? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` }
: undefined
}
>
{!isPublicRoute && (
<Header
onToggleSidebar={handleToggleSidebar}
onRefreshBackground={fetchRandomBook}
showRefreshBackground={preferences.background.type === "komga-random"}
/>
)}
{!isPublicRoute && (
<Sidebar
isOpen={isSidebarOpen}
onClose={handleCloseSidebar}
initialLibraries={initialLibraries}
initialFavorites={initialFavorites}
userIsAdmin={userIsAdmin}
/>
)}
<main className={!isPublicRoute ? "pt-safe" : ""}>{children}</main>
<InstallPWA />
<Toaster />
<NetworkStatus />
</div>
</ServiceWorkerProvider>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,393 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslate } from "@/hooks/useTranslate";
import { useToast } from "@/components/ui/use-toast";
import { useServiceWorker } from "@/contexts/ServiceWorkerContext";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Database,
Trash2,
RefreshCw,
HardDrive,
Image,
FileJson,
BookOpen,
CheckCircle2,
XCircle,
Loader2,
ChevronDown,
ChevronRight,
} from "lucide-react";
interface CacheStats {
static: { size: number; entries: number };
api: { size: number; entries: number };
images: { size: number; entries: number };
books: { size: number; entries: number };
total: number;
}
interface CacheEntry {
url: string;
size: number;
}
type CacheType = "static" | "api" | "images" | "books";
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
function extractPathFromUrl(url: string): string {
try {
const urlObj = new URL(url);
return urlObj.pathname + urlObj.search;
} catch {
return url;
}
}
interface CacheItemProps {
icon: React.ReactNode;
label: string;
size: number;
entries: number;
cacheType: CacheType;
onClear?: () => void;
isClearing?: boolean;
description?: string;
onLoadEntries: (cacheType: CacheType) => Promise<CacheEntry[] | null>;
}
function CacheItem({
icon,
label,
size,
entries,
cacheType,
onClear,
isClearing,
description,
onLoadEntries,
}: CacheItemProps) {
const { t } = useTranslate();
const [isOpen, setIsOpen] = useState(false);
const [cacheEntries, setCacheEntries] = useState<CacheEntry[] | null>(null);
const [isLoadingEntries, setIsLoadingEntries] = useState(false);
const handleToggle = async (open: boolean) => {
setIsOpen(open);
if (open && !cacheEntries && !isLoadingEntries) {
setIsLoadingEntries(true);
const loadedEntries = await onLoadEntries(cacheType);
setCacheEntries(loadedEntries);
setIsLoadingEntries(false);
}
};
return (
<Collapsible open={isOpen} onOpenChange={handleToggle}>
<div className="border-b last:border-b-0">
<div className="flex items-center justify-between py-3 px-1">
<CollapsibleTrigger asChild disabled={entries === 0}>
<button
className="flex items-center gap-3 flex-1 hover:bg-muted/50 rounded-lg transition-colors text-left py-1 px-2 -ml-2"
disabled={entries === 0}
>
<div className="p-2 rounded-lg bg-muted">{icon}</div>
<div className="flex-1">
<p className="font-medium">{label}</p>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
{entries > 0 && (
<div className="w-5">
{isOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
)}
</button>
</CollapsibleTrigger>
<div className="flex items-center gap-4 ml-2">
<div className="text-right">
<p className="font-mono text-sm">{formatBytes(size)}</p>
<p className="text-xs text-muted-foreground">
{entries} {entries === 1 ? t("settings.cache.entry") : t("settings.cache.entries")}
</p>
</div>
{onClear && (
<Button
variant="ghost"
size="icon"
onClick={onClear}
disabled={isClearing || entries === 0}
className="h-8 w-8"
>
{isClearing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
<CollapsibleContent>
<div className="pb-3 pl-12 pr-1">
{isLoadingEntries ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
{t("settings.cache.loadingEntries")}
</span>
</div>
) : cacheEntries ? (
<ScrollArea className="h-[200px] rounded-md border">
<div className="p-2 space-y-1">
{cacheEntries.map((entry, index) => (
<div
key={index}
className="flex items-center justify-between py-1.5 px-2 text-xs hover:bg-muted/50 rounded"
>
<span className="font-mono truncate flex-1 mr-2" title={entry.url}>
{extractPathFromUrl(entry.url)}
</span>
<span className="font-mono text-muted-foreground whitespace-nowrap">
{formatBytes(entry.size)}
</span>
</div>
))}
{cacheEntries.length === 0 && (
<p className="text-center text-muted-foreground py-4">
{t("settings.cache.noEntries")}
</p>
)}
</div>
</ScrollArea>
) : (
<p className="text-center text-muted-foreground py-4">
{t("settings.cache.loadError")}
</p>
)}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}
export function CacheSettings() {
const { t } = useTranslate();
const { toast } = useToast();
const { isSupported, isReady, version, getCacheStats, getCacheEntries, clearCache } =
useServiceWorker();
const [stats, setStats] = useState<CacheStats | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [clearingCache, setClearingCache] = useState<string | null>(null);
const loadStats = useCallback(async () => {
if (!isReady) return;
setIsLoading(true);
try {
const cacheStats = await getCacheStats();
setStats(cacheStats);
} finally {
setIsLoading(false);
}
}, [isReady, getCacheStats]);
useEffect(() => {
loadStats();
}, [loadStats]);
const handleClearCache = async (cacheType: "all" | "static" | "api" | "images") => {
setClearingCache(cacheType);
try {
const success = await clearCache(cacheType);
if (success) {
toast({
title: t("settings.cache.cleared"),
description: t("settings.cache.clearedDesc"),
});
await loadStats();
} else {
toast({
variant: "destructive",
title: t("settings.error.title"),
description: t("settings.cache.clearError"),
});
}
} finally {
setClearingCache(null);
}
};
const handleLoadEntries = useCallback(
async (cacheType: CacheType): Promise<CacheEntry[] | null> => {
return getCacheEntries(cacheType);
},
[getCacheEntries]
);
// 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;
if (!isSupported) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-lg">{t("settings.cache.title")}</CardTitle>
</div>
<CardDescription>{t("settings.cache.notSupported")}</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-primary" />
<CardTitle className="text-lg">{t("settings.cache.title")}</CardTitle>
</div>
<div className="flex items-center gap-2">
{isReady ? (
<Badge variant="outline" className="gap-1">
<CheckCircle2 className="h-3 w-3 text-green-500" />
{version || "Active"}
</Badge>
) : (
<Badge variant="outline" className="gap-1">
<XCircle className="h-3 w-3 text-yellow-500" />
{t("settings.cache.initializing")}
</Badge>
)}
<Button
variant="ghost"
size="icon"
onClick={loadStats}
disabled={isLoading || !isReady}
className="h-8 w-8"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
<CardDescription>{t("settings.cache.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Barre de progression globale */}
{stats && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t("settings.cache.totalStorage")}</span>
<span className="font-mono font-medium">{formatBytes(stats.total)}</span>
</div>
<Progress value={usagePercent} className="h-2" />
<p className="text-xs text-muted-foreground text-right">
{t("settings.cache.imagesQuota", { used: Math.round(usagePercent) })}
</p>
</div>
)}
{/* Liste des caches */}
<div className="space-y-1">
{stats ? (
<>
<CacheItem
icon={<HardDrive className="h-4 w-4" />}
label={t("settings.cache.static")}
size={stats.static.size}
entries={stats.static.entries}
cacheType="static"
description={t("settings.cache.staticDesc")}
onClear={() => handleClearCache("static")}
isClearing={clearingCache === "static"}
onLoadEntries={handleLoadEntries}
/>
<CacheItem
icon={<FileJson className="h-4 w-4" />}
label={t("settings.cache.api")}
size={stats.api.size}
entries={stats.api.entries}
cacheType="api"
description={t("settings.cache.apiDesc")}
onClear={() => handleClearCache("api")}
isClearing={clearingCache === "api"}
onLoadEntries={handleLoadEntries}
/>
<CacheItem
icon={<Image className="h-4 w-4" />}
label={t("settings.cache.images")}
size={stats.images.size}
entries={stats.images.entries}
cacheType="images"
description={t("settings.cache.imagesDesc")}
onClear={() => handleClearCache("images")}
isClearing={clearingCache === "images"}
onLoadEntries={handleLoadEntries}
/>
<CacheItem
icon={<BookOpen className="h-4 w-4" />}
label={t("settings.cache.books")}
size={stats.books.size}
entries={stats.books.entries}
cacheType="books"
description={t("settings.cache.booksDesc")}
onLoadEntries={handleLoadEntries}
/>
</>
) : isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<p className="text-center text-muted-foreground py-8">
{t("settings.cache.unavailable")}
</p>
)}
</div>
{/* Bouton vider tout */}
{stats && stats.total > 0 && (
<Button
variant="destructive"
className="w-full gap-2"
onClick={() => handleClearCache("all")}
disabled={clearingCache !== null}
>
{clearingCache === "all" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
{t("settings.cache.clearAll")}
</Button>
)}
</CardContent>
</Card>
);
}

View File

@@ -6,6 +6,7 @@ import { DisplaySettings } from "./DisplaySettings";
import { KomgaSettings } from "./KomgaSettings";
import { BackgroundSettings } from "./BackgroundSettings";
import { AdvancedSettings } from "./AdvancedSettings";
import { CacheSettings } from "./CacheSettings";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Monitor, Network } from "lucide-react";
@@ -40,6 +41,7 @@ export function ClientSettings({ initialConfig }: ClientSettingsProps) {
<TabsContent value="connection" className="mt-6 space-y-6">
<KomgaSettings initialConfig={initialConfig} />
<AdvancedSettings />
<CacheSettings />
</TabsContent>
</Tabs>
</div>

View File

@@ -0,0 +1,13 @@
"use client";
import * as React from "react";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,294 @@
"use client";
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
import type { ReactNode } from "react";
import { registerServiceWorker } from "@/lib/registerSW";
import logger from "@/lib/logger";
interface CacheStats {
static: { size: number; entries: number };
api: { size: number; entries: number };
images: { size: number; entries: number };
books: { size: number; entries: number };
total: number;
}
interface CacheEntry {
url: string;
size: number;
}
interface CacheUpdate {
url: string;
timestamp: number;
}
type CacheType = "all" | "static" | "api" | "images" | "rsc" | "books";
interface ServiceWorkerContextValue {
isSupported: boolean;
isReady: boolean;
version: string | null;
hasNewVersion: boolean;
cacheUpdates: CacheUpdate[];
clearCacheUpdate: (url: string) => void;
clearAllCacheUpdates: () => void;
getCacheStats: () => Promise<CacheStats | null>;
getCacheEntries: (cacheType: CacheType) => Promise<CacheEntry[] | null>;
clearCache: (cacheType?: CacheType) => Promise<boolean>;
skipWaiting: () => void;
reloadForUpdate: () => void;
}
const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null);
export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
const [isSupported, setIsSupported] = useState(false);
const [isReady, setIsReady] = useState(false);
const [version, setVersion] = useState<string | null>(null);
const [hasNewVersion, setHasNewVersion] = useState(false);
const [cacheUpdates, setCacheUpdates] = useState<CacheUpdate[]>([]);
const pendingRequests = useRef<Map<string, (value: unknown) => void>>(new Map());
const waitingWorkerRef = useRef<ServiceWorker | null>(null);
// Handle messages from service worker
const handleMessage = useCallback((event: MessageEvent) => {
const { type, payload } = event.data || {};
switch (type) {
case "SW_ACTIVATED":
setIsReady(true);
setVersion(payload?.version || null);
break;
case "SW_VERSION":
setVersion(payload?.version || null);
break;
case "CACHE_UPDATED":
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":
const statsResolver = pendingRequests.current.get("CACHE_STATS");
if (statsResolver) {
statsResolver(payload);
pendingRequests.current.delete("CACHE_STATS");
}
break;
case "CACHE_STATS_ERROR":
const statsErrorResolver = pendingRequests.current.get("CACHE_STATS");
if (statsErrorResolver) {
statsErrorResolver(null);
pendingRequests.current.delete("CACHE_STATS");
}
break;
case "CACHE_CLEARED":
const clearResolver = pendingRequests.current.get("CACHE_CLEARED");
if (clearResolver) {
clearResolver(true);
pendingRequests.current.delete("CACHE_CLEARED");
}
break;
case "CACHE_CLEAR_ERROR":
const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED");
if (clearErrorResolver) {
clearErrorResolver(false);
pendingRequests.current.delete("CACHE_CLEARED");
}
break;
case "CACHE_ENTRIES":
const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES");
if (entriesResolver) {
entriesResolver(payload.entries);
pendingRequests.current.delete("CACHE_ENTRIES");
}
break;
case "CACHE_ENTRIES_ERROR":
const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES");
if (entriesErrorResolver) {
entriesErrorResolver(null);
pendingRequests.current.delete("CACHE_ENTRIES");
}
break;
}
}, []);
// Initialize service worker communication
useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
setIsSupported(false);
return;
}
setIsSupported(true);
// Register service worker
registerServiceWorker({
onSuccess: (registration) => {
logger.info({ scope: registration.scope }, "Service worker registered");
setIsReady(true);
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
}
},
onUpdate: (registration) => {
logger.info("New service worker version available");
setHasNewVersion(true);
waitingWorkerRef.current = registration.waiting;
},
onError: (error) => {
logger.error({ err: error }, "Service worker registration failed");
},
});
// Listen for messages
navigator.serviceWorker.addEventListener("message", handleMessage);
// Check if already controlled
if (navigator.serviceWorker.controller) {
setIsReady(true);
// Request version
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
}
// Listen for controller changes
const handleControllerChange = () => {
setIsReady(true);
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
}
};
navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange);
return () => {
navigator.serviceWorker.removeEventListener("message", handleMessage);
navigator.serviceWorker.removeEventListener("controllerchange", handleControllerChange);
};
}, [handleMessage]);
const clearCacheUpdate = useCallback((url: string) => {
setCacheUpdates((prev) => prev.filter((u) => u.url !== url));
}, []);
const clearAllCacheUpdates = useCallback(() => {
setCacheUpdates([]);
}, []);
const getCacheStats = useCallback(async (): Promise<CacheStats | null> => {
if (!navigator.serviceWorker.controller) return null;
return new Promise((resolve) => {
pendingRequests.current.set("CACHE_STATS", resolve as (value: unknown) => void);
navigator.serviceWorker.controller!.postMessage({ type: "GET_CACHE_STATS" });
// Timeout after 5 seconds
setTimeout(() => {
if (pendingRequests.current.has("CACHE_STATS")) {
pendingRequests.current.delete("CACHE_STATS");
resolve(null);
}
}, 5000);
});
}, []);
const getCacheEntries = useCallback(
async (cacheType: CacheType): Promise<CacheEntry[] | null> => {
if (!navigator.serviceWorker.controller) return null;
return new Promise((resolve) => {
pendingRequests.current.set("CACHE_ENTRIES", resolve as (value: unknown) => void);
navigator.serviceWorker.controller!.postMessage({
type: "GET_CACHE_ENTRIES",
payload: { cacheType },
});
// Timeout after 10 seconds (can be slow for large caches)
setTimeout(() => {
if (pendingRequests.current.has("CACHE_ENTRIES")) {
pendingRequests.current.delete("CACHE_ENTRIES");
resolve(null);
}
}, 10000);
});
},
[]
);
const clearCache = useCallback(async (cacheType: CacheType = "all"): Promise<boolean> => {
if (!navigator.serviceWorker.controller) return false;
return new Promise((resolve) => {
pendingRequests.current.set("CACHE_CLEARED", resolve as (value: unknown) => void);
navigator.serviceWorker.controller!.postMessage({
type: "CLEAR_CACHE",
payload: { cacheType },
});
// Timeout after 10 seconds
setTimeout(() => {
if (pendingRequests.current.has("CACHE_CLEARED")) {
pendingRequests.current.delete("CACHE_CLEARED");
resolve(false);
}
}, 10000);
});
}, []);
const skipWaiting = useCallback(() => {
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: "SKIP_WAITING" });
}
}, []);
const reloadForUpdate = useCallback(() => {
if (waitingWorkerRef.current) {
waitingWorkerRef.current.postMessage({ type: "SKIP_WAITING" });
setHasNewVersion(false);
// Reload will happen automatically when new SW takes control
window.location.reload();
}
}, []);
return (
<ServiceWorkerContext.Provider
value={{
isSupported,
isReady,
version,
hasNewVersion,
cacheUpdates,
clearCacheUpdate,
clearAllCacheUpdates,
getCacheStats,
getCacheEntries,
clearCache,
skipWaiting,
reloadForUpdate,
}}
>
{children}
</ServiceWorkerContext.Provider>
);
}
export function useServiceWorker() {
const context = useContext(ServiceWorkerContext);
if (!context) {
throw new Error("useServiceWorker must be used within a ServiceWorkerProvider");
}
return context;
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useMemo, useCallback } from "react";
import { useServiceWorker } from "@/contexts/ServiceWorkerContext";
interface UseCacheUpdateOptions {
/** Match exact URL or use pattern matching */
exact?: boolean;
}
interface UseCacheUpdateResult {
/** Whether there's a pending update for this URL pattern */
hasUpdate: boolean;
/** Timestamp of the last update */
lastUpdateTime: number | null;
/** Clear the update notification for this URL */
clearUpdate: () => void;
/** All matching updates */
updates: Array<{ url: string; timestamp: number }>;
}
/**
* Hook to listen for cache updates from the service worker
*
* @param urlPattern - URL or pattern to match against cache updates
* @param options - Options for matching behavior
*
* @example
* // Match exact URL
* const { hasUpdate, clearUpdate } = useCacheUpdate('/api/komga/home', { exact: true });
*
* @example
* // Match URL pattern (contains)
* const { hasUpdate, clearUpdate } = useCacheUpdate('/api/komga/series');
*
* @example
* // Use in component
* useEffect(() => {
* if (hasUpdate) {
* refetch();
* clearUpdate();
* }
* }, [hasUpdate, refetch, clearUpdate]);
*/
export function useCacheUpdate(
urlPattern: string,
options: UseCacheUpdateOptions = {}
): UseCacheUpdateResult {
const { exact = false } = options;
const { cacheUpdates, clearCacheUpdate } = useServiceWorker();
const matchingUpdates = useMemo(() => {
return cacheUpdates.filter((update) => {
if (exact) {
return update.url === urlPattern || update.url.endsWith(urlPattern);
}
return update.url.includes(urlPattern);
});
}, [cacheUpdates, urlPattern, exact]);
const hasUpdate = matchingUpdates.length > 0;
const lastUpdateTime = useMemo(() => {
if (matchingUpdates.length === 0) return null;
return Math.max(...matchingUpdates.map((u) => u.timestamp));
}, [matchingUpdates]);
const clearUpdate = useCallback(() => {
matchingUpdates.forEach((update) => {
clearCacheUpdate(update.url);
});
}, [matchingUpdates, clearCacheUpdate]);
return {
hasUpdate,
lastUpdateTime,
clearUpdate,
updates: matchingUpdates,
};
}
/**
* Hook to check if any cache update is available
* Useful for showing a global "refresh available" indicator
*/
export function useAnyCacheUpdate(): {
hasAnyUpdate: boolean;
updateCount: number;
clearAll: () => void;
} {
const { cacheUpdates, clearAllCacheUpdates } = useServiceWorker();
return {
hasAnyUpdate: cacheUpdates.length > 0,
updateCount: cacheUpdates.length,
clearAll: clearAllCacheUpdates,
};
}

View File

@@ -136,6 +136,32 @@
"title": "Error saving configuration",
"message": "An error occurred while saving the configuration"
}
},
"cache": {
"title": "Cache & Storage",
"description": "Manage local cache for optimal offline experience.",
"notSupported": "Offline cache is not supported by your browser.",
"initializing": "Initializing...",
"totalStorage": "Total storage",
"imagesQuota": "{used}% of images quota used",
"static": "Static resources",
"staticDesc": "Next.js scripts, styles and assets",
"api": "API data",
"apiDesc": "Series, books and library metadata",
"images": "Images",
"imagesDesc": "Covers and thumbnails (100 MB limit)",
"books": "Offline books",
"booksDesc": "Manually downloaded pages",
"clearAll": "Clear all cache",
"cleared": "Cache cleared",
"clearedDesc": "Cache has been cleared successfully",
"clearError": "Error clearing cache",
"unavailable": "Cache statistics unavailable",
"entry": "entry",
"entries": "entries",
"loadingEntries": "Loading entries...",
"noEntries": "No entries in this cache",
"loadError": "Error loading entries"
}
},
"library": {

View File

@@ -136,6 +136,32 @@
"title": "Erreur lors de la sauvegarde de la configuration",
"message": "Une erreur est survenue lors de la sauvegarde de la configuration"
}
},
"cache": {
"title": "Cache et stockage",
"description": "Gérez le cache local pour une expérience hors-ligne optimale.",
"notSupported": "Le cache hors-ligne n'est pas supporté par votre navigateur.",
"initializing": "Initialisation...",
"totalStorage": "Stockage total",
"imagesQuota": "{used}% du quota images utilisé",
"static": "Ressources statiques",
"staticDesc": "Scripts, styles et assets Next.js",
"api": "Données API",
"apiDesc": "Métadonnées des séries, livres et bibliothèques",
"images": "Images",
"imagesDesc": "Couvertures et vignettes (limite 100 Mo)",
"books": "Livres hors-ligne",
"booksDesc": "Pages téléchargées manuellement",
"clearAll": "Vider tout le cache",
"cleared": "Cache vidé",
"clearedDesc": "Le cache a été vidé avec succès",
"clearError": "Erreur lors du vidage du cache",
"unavailable": "Statistiques du cache non disponibles",
"entry": "entrée",
"entries": "entrées",
"loadingEntries": "Chargement des entrées...",
"noEntries": "Aucune entrée dans ce cache",
"loadError": "Erreur lors du chargement des entrées"
}
},
"library": {

View File

@@ -1,14 +1,137 @@
import logger from "@/lib/logger";
export const registerServiceWorker = async () => {
interface ServiceWorkerRegistrationOptions {
onUpdate?: (registration: ServiceWorkerRegistration) => void;
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onError?: (error: Error) => void;
}
/**
* Register the service worker with optional callbacks for update and success events
*/
export const registerServiceWorker = async (
options: ServiceWorkerRegistrationOptions = {}
): Promise<ServiceWorkerRegistration | null> => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return;
return null;
}
const { onUpdate, onSuccess, onError } = options;
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
// Check for updates immediately
registration.update().catch(() => {
// Ignore update check errors
});
// Handle updates
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
// New service worker available
logger.info("New service worker available");
onUpdate?.(registration);
} else {
// First install
logger.info("Service worker installed for the first time");
onSuccess?.(registration);
}
}
});
});
// If already active, call success
if (registration.active) {
onSuccess?.(registration);
}
return registration;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.error({ err }, "Service Worker registration failed");
onError?.(err);
return null;
}
};
/**
* Unregister all service workers
*/
export const unregisterServiceWorker = async (): Promise<boolean> => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return false;
}
try {
await navigator.serviceWorker.register("/sw.js");
// logger.info("Service Worker registered with scope:", registration.scope);
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((reg) => reg.unregister()));
logger.info("All service workers unregistered");
return true;
} catch (error) {
logger.error({ err: error }, "Service Worker registration failed:");
logger.error({ err: error }, "Failed to unregister service workers");
return false;
}
};
/**
* Send a message to the active service worker
*/
export const sendMessageToSW = <T = unknown>(message: unknown): Promise<T | null> => {
return new Promise((resolve) => {
if (!navigator.serviceWorker.controller) {
resolve(null);
return;
}
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = (event) => {
resolve(event.data as T);
};
navigator.serviceWorker.controller.postMessage(message, [messageChannel.port2]);
// Timeout after 5 seconds
setTimeout(() => {
resolve(null);
}, 5000);
});
};
/**
* Check if the app is running as a PWA (standalone mode)
*/
export const isPWA = (): boolean => {
if (typeof window === "undefined") return false;
return (
window.matchMedia("(display-mode: standalone)").matches ||
// iOS Safari
("standalone" in window.navigator &&
(window.navigator as { standalone?: boolean }).standalone === true)
);
};
/**
* Get the current service worker registration
*/
export const getServiceWorkerRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return null;
}
try {
return await navigator.serviceWorker.ready;
} catch {
return null;
}
};