Compare commits

...

7 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
Julien Froidefond
2c8c0b5eb0 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
2026-01-04 06:48:17 +01:00
17 changed files with 1741 additions and 108 deletions

View File

@@ -18,10 +18,12 @@
"@prisma/client": "^6.17.1", "@prisma/client": "^6.17.1",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.3.8", "@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-select": "^2.1.6",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "1.2.3", "@radix-ui/react-slot": "1.2.3",

65
pnpm-lock.yaml generated
View File

@@ -21,6 +21,9 @@ importers:
'@radix-ui/react-checkbox': '@radix-ui/react-checkbox':
specifier: ^1.3.3 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) 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': '@radix-ui/react-dialog':
specifier: 1.1.15 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) 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': '@radix-ui/react-radio-group':
specifier: ^1.3.8 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) 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': '@radix-ui/react-select':
specifier: ^2.1.6 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) 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': '@types/react-dom':
optional: true 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': '@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies: peerDependencies:
@@ -894,6 +913,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true 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': '@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies: peerDependencies:
@@ -3543,6 +3575,22 @@ snapshots:
'@types/react': 19.2.2 '@types/react': 19.2.2
'@types/react-dom': 19.2.2(@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)': '@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: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) '@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': 19.2.2
'@types/react-dom': 19.2.2(@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)': '@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: dependencies:
'@radix-ui/number': 1.1.1 '@radix-ui/number': 1.1.1

View File

@@ -1,14 +1,20 @@
// StripStream Service Worker - Version 1 // StripStream Service Worker - Version 2
// Architecture: Cache-as-you-go for static resources only // Architecture: SWR (Stale-While-Revalidate) for all resources
const VERSION = "v1"; const VERSION = "v2.5";
const STATIC_CACHE = `stripstream-static-${VERSION}`; const STATIC_CACHE = `stripstream-static-${VERSION}`;
const RSC_CACHE = `stripstream-rsc-${VERSION}`; const PAGES_CACHE = `stripstream-pages-${VERSION}`; // Navigation + RSC (client-side navigation)
const API_CACHE = `stripstream-api-${VERSION}`;
const IMAGES_CACHE = `stripstream-images-${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";
const PRECACHE_ASSETS = [OFFLINE_PAGE, "/manifest.json"]; 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 // Utility Functions - Request Detection
// ============================================================================ // ============================================================================
@@ -22,13 +28,79 @@ function isNextRSCRequest(request) {
return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1"; 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 Strategies
// ============================================================================ // ============================================================================
/** /**
* Cache-First: Serve from cache, fallback to network * 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 = {}) { async function cacheFirstStrategy(request, cacheName, options = {}) {
const cache = await caches.open(cacheName); const cache = await caches.open(cacheName);
@@ -56,21 +128,70 @@ 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: RSC payloads * Used for: API calls, images
* Respects Cache-Control: no-cache to force network-first (for refresh buttons)
*/ */
async function staleWhileRevalidateStrategy(request, cacheName) { 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)
.then((response) => { .then(async (response) => {
if (response.ok) { 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; 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 // Return cached version immediately if available
if (cached) { if (cached) {
@@ -87,40 +208,50 @@ async function staleWhileRevalidateStrategy(request, cacheName) {
} }
/** /**
* Navigation Strategy: Network-First with SPA fallback * 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 navigationStrategy(request) { async function navigationSWRStrategy(request, cacheName) {
const cache = await caches.open(STATIC_CACHE); const cache = await caches.open(cacheName);
const cached = await cache.match(request);
try { // Start network request in background
// Try network first const fetchPromise = fetch(request)
const response = await fetch(request); .then(async (response) => {
if (response.ok) { if (response.ok) {
cache.put(request, response.clone()); await cache.put(request, response.clone());
} }
return response; return response;
} catch (error) { })
// Network failed - try cache .catch(() => null);
const cached = await cache.match(request);
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");
} }
// ============================================================================ // ============================================================================
@@ -155,9 +286,10 @@ 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, PAGES_CACHE, API_CACHE, IMAGES_CACHE, BOOKS_CACHE];
const cachesToDelete = cacheNames.filter( const cachesToDelete = cacheNames.filter(
(name) => (name) => name.startsWith("stripstream-") && !currentCaches.includes(name)
name.startsWith("stripstream-") && name !== BOOKS_CACHE && !name.endsWith(`-${VERSION}`)
); );
await Promise.all(cachesToDelete.map((name) => caches.delete(name))); await Promise.all(cachesToDelete.map((name) => caches.delete(name)));
@@ -170,10 +302,179 @@ self.addEventListener("activate", (event) => {
await self.clients.claim(); await self.clients.claim();
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log("[SW] Activated and claimed clients"); 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, pagesSize, apiSize, imagesSize, booksSize] = await Promise.all([
getCacheSize(STATIC_CACHE),
getCacheSize(PAGES_CACHE),
getCacheSize(API_CACHE),
getCacheSize(IMAGES_CACHE),
getCacheSize(BOOKS_CACHE),
]);
const staticCache = await caches.open(STATIC_CACHE);
const pagesCache = await caches.open(PAGES_CACHE);
const apiCache = await caches.open(API_CACHE);
const imagesCache = await caches.open(IMAGES_CACHE);
const booksCache = await caches.open(BOOKS_CACHE);
const [staticKeys, pagesKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([
staticCache.keys(),
pagesCache.keys(),
apiCache.keys(),
imagesCache.keys(),
booksCache.keys(),
]);
event.source.postMessage({
type: "CACHE_STATS",
payload: {
static: { size: staticSize, entries: staticKeys.length },
pages: { size: pagesSize, entries: pagesKeys.length },
api: { size: apiSize, entries: apiKeys.length },
images: { size: imagesSize, entries: imagesKeys.length },
books: { size: booksSize, entries: booksKeys.length },
total: staticSize + pagesSize + 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 === "pages") {
cachesToClear.push(PAGES_CACHE);
}
if (cacheType === "all" || cacheType === "api") {
cachesToClear.push(API_CACHE);
}
if (cacheType === "all" || cacheType === "images") {
cachesToClear.push(IMAGES_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 "pages":
cacheName = PAGES_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 // Fetch Handler - Request Routing
// ============================================================================ // ============================================================================
@@ -188,24 +489,68 @@ self.addEventListener("fetch", (event) => {
return; return;
} }
// Route 1: Next.js RSC payloads → Stale-While-Revalidate // Route 1: Book pages (handled by DownloadManager) - Check manual cache only, no SWR
if (isNextRSCRequest(request)) { if (isBookPageRequest(url.href)) {
event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE)); 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; return;
} }
// Route 2: Next.js static resources → Cache-First with ignoreSearch // Route 2: Next.js RSC payloads (client-side navigation) → SWR in PAGES_CACHE
if (isNextRSCRequest(request)) {
event.respondWith(
staleWhileRevalidateStrategy(request, PAGES_CACHE, {
notifyOnChange: false,
})
);
return;
}
// Route 3: Next.js static resources → Cache-First with ignoreSearch
if (isNextStaticResource(url.href)) { if (isNextStaticResource(url.href)) {
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true })); event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true }));
return; return;
} }
// Route 3: Navigation → Network-First with SPA fallback // Route 4: API requests (JSON) → SWR with notification
if (request.mode === "navigate") { if (isApiRequest(url.href)) {
event.respondWith(navigationStrategy(request)); event.respondWith(
staleWhileRevalidateStrategy(request, API_CACHE, {
notifyOnChange: true,
isJson: true,
})
);
return; return;
} }
// Route 4: Everything else → Network only (no caching) // Route 5: Image requests (thumbnails, covers) → SWR with cache size management
// This includes: API calls, images, and other dynamic content // 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 → SWR (cache first, revalidate in background)
if (request.mode === "navigate") {
event.respondWith(navigationSWRStrategy(request, PAGES_CACHE));
return;
}
// Route 7: Everything else → Network only (no caching)
}); });

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

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

View File

@@ -0,0 +1,461 @@
"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 as ImageIcon,
FileJson,
BookOpen,
CheckCircle2,
XCircle,
Loader2,
ChevronDown,
ChevronRight,
LayoutGrid,
RotateCcw,
} from "lucide-react";
interface CacheStats {
static: { size: number; entries: number };
pages: { 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" | "pages" | "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,
reinstallServiceWorker,
} = useServiceWorker();
const [stats, setStats] = useState<CacheStats | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [clearingCache, setClearingCache] = useState<string | null>(null);
const [isReinstalling, setIsReinstalling] = useState(false);
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" | "pages" | "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]
);
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)
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={<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
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={<ImageIcon 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>
) : (
<div className="text-center py-8 space-y-4">
<p className="text-muted-foreground">{t("settings.cache.unavailable")}</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>
{/* Bouton vider tout */}
{stats && stats.total > 0 && (
<div className="space-y-2">
<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>
<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>
</Card>
);
}

View File

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

View File

@@ -0,0 +1,12 @@
"use client";
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,368 @@
"use client";
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
import type { ReactNode } from "react";
import { registerServiceWorker, unregisterServiceWorker } from "@/lib/registerSW";
import logger from "@/lib/logger";
interface CacheStats {
static: { size: number; entries: number };
pages: { 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" | "pages" | "api" | "images" | "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;
reinstallServiceWorker: () => Promise<boolean>;
}
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) => {
try {
// Ignore messages without proper data structure
if (!event.data || typeof event.data !== "object") return;
// Only handle messages from our service worker (check for known message types)
const knownTypes = [
"SW_ACTIVATED",
"SW_VERSION",
"CACHE_UPDATED",
"CACHE_STATS",
"CACHE_STATS_ERROR",
"CACHE_CLEARED",
"CACHE_CLEAR_ERROR",
"CACHE_ENTRIES",
"CACHE_ENTRIES_ERROR",
];
const type = event.data.type;
if (typeof type !== "string" || !knownTypes.includes(type)) return;
const payload = event.data.payload;
switch (type) {
case "SW_ACTIVATED":
setIsReady(true);
setVersion(payload?.version || null);
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;
}
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 || null);
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;
}
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);
}
}
}, []);
// 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();
}
}, []);
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 (
<ServiceWorkerContext.Provider
value={{
isSupported,
isReady,
version,
hasNewVersion,
cacheUpdates,
clearCacheUpdate,
clearAllCacheUpdates,
getCacheStats,
getCacheEntries,
clearCache,
skipWaiting,
reloadForUpdate,
reinstallServiceWorker,
}}
>
{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,36 @@
"title": "Error saving configuration", "title": "Error saving configuration",
"message": "An error occurred while saving the 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",
"pages": "Visited pages",
"pagesDesc": "Home, libraries, series and details",
"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",
"reinstall": "Reinstall Service Worker",
"reinstallError": "Error reinstalling Service Worker",
"entry": "entry",
"entries": "entries",
"loadingEntries": "Loading entries...",
"noEntries": "No entries in this cache",
"loadError": "Error loading entries"
} }
}, },
"library": { "library": {

View File

@@ -136,6 +136,36 @@
"title": "Erreur lors de la sauvegarde de la configuration", "title": "Erreur lors de la sauvegarde de la configuration",
"message": "Une erreur est survenue 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",
"pages": "Pages visitées",
"pagesDesc": "Home, bibliothèques, séries et détails",
"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",
"reinstall": "Réinstaller le Service Worker",
"reinstallError": "Erreur lors de la réinstallation du Service Worker",
"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": { "library": {

View File

@@ -1,14 +1,137 @@
import logger from "@/lib/logger"; 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)) { 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 { try {
await navigator.serviceWorker.register("/sw.js"); const registrations = await navigator.serviceWorker.getRegistrations();
// logger.info("Service Worker registered with scope:", registration.scope); await Promise.all(registrations.map((reg) => reg.unregister()));
logger.info("All service workers unregistered");
return true;
} catch (error) { } 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;
} }
}; };