Compare commits

...

12 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
Julien Froidefond
b497746cfa feat: enhance home and library pages by integrating new data fetching methods, improving error handling, and refactoring components for better structure
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m17s
2026-01-04 06:19:45 +01:00
Julien Froidefond
489e570348 feat: enrich library data by fetching book counts from the API and handling errors gracefully 2026-01-04 05:57:22 +01:00
Julien Froidefond
117ad2d0ce fix: enhance error handling in read progress update by validating request body and returning appropriate error responses
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10m22s
2026-01-03 22:06:28 +01:00
Julien Froidefond
0d7d27ef82 refactor: streamline image handling by implementing direct streaming in BookService and ImageService, and update .gitignore to include temp directory 2026-01-03 22:03:35 +01:00
Julien Froidefond
e903b55a46 refactor: implement abort controller for fetch requests in multiple components to prevent memory leaks and improve error handling 2026-01-03 21:51:07 +01:00
41 changed files with 2495 additions and 944 deletions

2
.gitignore vendored
View File

@@ -54,3 +54,5 @@ prisma/data/
*.db
*.sqlite
*.sqlite3
temp/

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.5";
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 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,70 @@ 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
* 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 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)
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,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
*/
async function navigationStrategy(request) {
const cache = await caches.open(STATIC_CACHE);
async function navigationSWRStrategy(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
try {
// Try network first
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Network failed - try cache
const cached = await cache.match(request);
if (cached) {
return cached;
}
// Start network request in background
const fetchPromise = fetch(request)
.then(async (response) => {
if (response.ok) {
await cache.put(request, response.clone());
}
return response;
})
.catch(() => null);
// Try to serve root page for SPA client-side routing
const rootPage = await cache.match("/");
if (rootPage) {
return rootPage;
}
// Last resort: offline page
const offlinePage = await cache.match(OFFLINE_PAGE);
if (offlinePage) {
return offlinePage;
}
throw error;
// Return cached version immediately if available
if (cached) {
return cached;
}
// 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 () => {
// Clean up old caches, but preserve BOOKS_CACHE
const cacheNames = await caches.keys();
const currentCaches = [STATIC_CACHE, PAGES_CACHE, API_CACHE, IMAGES_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 +302,179 @@ 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, 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
// ============================================================================
@@ -188,24 +489,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 (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)) {
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 → 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

@@ -11,9 +11,40 @@ export async function PATCH(
{ params }: { params: Promise<{ bookId: string }> }
) {
try {
const { page, completed } = await request.json();
const bookId: string = (await params).bookId;
// Handle empty or invalid body (can happen when request is aborted during navigation)
let body: { page?: unknown; completed?: boolean };
try {
const text = await request.text();
if (!text) {
return NextResponse.json(
{
error: {
code: ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR,
name: "Progress update error",
message: "Empty request body",
},
},
{ status: 400 }
);
}
body = JSON.parse(text);
} catch {
return NextResponse.json(
{
error: {
code: ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR,
name: "Progress update error",
message: "Invalid JSON body",
},
},
{ status: 400 }
);
}
const { page, completed } = body;
if (typeof page !== "number") {
return NextResponse.json(
{

View File

@@ -71,14 +71,17 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const cookieStore = await cookies();
const locale = cookieStore.get("NEXT_LOCALE")?.value || "fr";
// Les libraries et favorites sont chargés côté client par la Sidebar
let preferences: UserPreferences = defaultPreferences;
let userIsAdmin = false;
let libraries: any[] = [];
let favorites: any[] = [];
try {
const [preferencesData, isAdminCheck] = await Promise.allSettled([
const [preferencesData, isAdminCheck, librariesData, favoritesData] = await Promise.allSettled([
PreferencesService.getPreferences(),
import("@/lib/auth-utils").then((m) => m.isAdmin()),
import("@/lib/services/library.service").then((m) => m.LibraryService.getLibraries()),
import("@/lib/services/favorites.service").then((m) => m.FavoritesService.getFavorites()),
]);
if (preferencesData.status === "fulfilled") {
@@ -88,8 +91,16 @@ export default async function RootLayout({ children }: { children: React.ReactNo
if (isAdminCheck.status === "fulfilled") {
userIsAdmin = isAdminCheck.value;
}
if (librariesData.status === "fulfilled") {
libraries = librariesData.value;
}
if (favoritesData.status === "fulfilled") {
favorites = favoritesData.value;
}
} catch (error) {
logger.error({ err: error }, "Erreur lors du chargement des préférences:");
logger.error({ err: error }, "Erreur lors du chargement des données initiales:");
}
return (
@@ -155,7 +166,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<AuthProvider>
<I18nProvider locale={locale}>
<PreferencesProvider initialPreferences={preferences}>
<ClientLayout initialLibraries={[]} initialFavorites={[]} userIsAdmin={userIsAdmin}>
<ClientLayout initialLibraries={libraries} initialFavorites={favorites} userIsAdmin={userIsAdmin}>
{children}
</ClientLayout>
</PreferencesProvider>

View File

@@ -1,257 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
import { RefreshButton } from "@/components/library/RefreshButton";
import { LibraryHeader } from "@/components/library/LibraryHeader";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { useTranslate } from "@/hooks/useTranslate";
import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons";
import type { LibraryResponse } from "@/types/library";
import type { KomgaSeries, KomgaLibrary } from "@/types/komga";
import type { UserPreferences } from "@/types/preferences";
import { Container } from "@/components/ui/container";
import { Section } from "@/components/ui/section";
import logger from "@/lib/logger";
interface ClientLibraryPageProps {
currentPage: number;
libraryId: string;
preferences: UserPreferences;
unreadOnly: boolean;
search?: string;
pageSize?: number;
}
const DEFAULT_PAGE_SIZE = 20;
export function ClientLibraryPage({
currentPage,
libraryId,
preferences,
unreadOnly,
search,
pageSize,
}: ClientLibraryPageProps) {
const { t } = useTranslate();
const [library, setLibrary] = useState<KomgaLibrary | null>(null);
const [series, setSeries] = useState<LibraryResponse<KomgaSeries> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
page: String(currentPage - 1),
size: String(effectivePageSize),
unread: String(unreadOnly),
});
if (search) {
params.append("search", search);
}
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR");
}
const data = await response.json();
setLibrary(data.library);
setSeries(data.series);
} catch (err) {
logger.error({ err }, "Error fetching library series");
setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR");
} finally {
setLoading(false);
}
};
fetchData();
}, [libraryId, currentPage, unreadOnly, search, effectivePageSize]);
const handleRefresh = async (libraryId: string) => {
try {
const params = new URLSearchParams({
page: String(currentPage - 1),
size: String(effectivePageSize),
unread: String(unreadOnly),
});
if (search) {
params.append("search", search);
}
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
cache: "reload",
});
if (!response.ok) {
throw new Error("Error refreshing library");
}
const data = await response.json();
setLibrary(data.library);
setSeries(data.series);
return { success: true };
} catch (error) {
logger.error({ err: error }, "Error during refresh:");
return { success: false, error: "Error refreshing library" };
}
};
const handleRetry = async () => {
setError(null);
setLoading(true);
try {
const params = new URLSearchParams({
page: String(currentPage - 1),
size: String(effectivePageSize),
unread: String(unreadOnly),
});
if (search) {
params.append("search", search);
}
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
cache: "reload",
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR");
}
const data = await response.json();
setLibrary(data.library);
setSeries(data.series);
} catch (err) {
logger.error({ err }, "Error fetching library series");
setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR");
} finally {
setLoading(false);
}
};
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
await handleRefresh(libraryId);
},
enabled: !loading && !error && !!library && !!series,
});
if (loading) {
return (
<>
{/* Header skeleton */}
<div className="relative min-h-[200px] md:h-[200px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden mb-8">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-primary/10 to-background" />
<div className="relative container mx-auto px-4 py-8 h-full">
<div className="flex flex-col md:flex-row gap-6 items-center md:items-start h-full">
<OptimizedSkeleton className="w-[120px] h-[120px] rounded-lg" />
<div className="flex-1 space-y-3">
<OptimizedSkeleton className="h-10 w-64" />
<div className="flex gap-4">
<OptimizedSkeleton className="h-8 w-32" />
<OptimizedSkeleton className="h-8 w-32" />
<OptimizedSkeleton className="h-10 w-10 rounded-full" />
</div>
</div>
</div>
</div>
</div>
<Container>
{/* Filters */}
<div className="flex flex-col gap-4 mb-8">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="w-full">
<OptimizedSkeleton className="h-10 w-full" />
</div>
<div className="flex items-center justify-end gap-2">
<OptimizedSkeleton className="h-10 w-24" />
<OptimizedSkeleton className="h-10 w-10 rounded" />
<OptimizedSkeleton className="h-10 w-10 rounded" />
</div>
</div>
</div>
{/* Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
{Array.from({ length: effectivePageSize }).map((_, i) => (
<OptimizedSkeleton key={i} className="aspect-[2/3] w-full rounded-lg" />
))}
</div>
{/* Pagination */}
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
<OptimizedSkeleton className="h-5 w-32 order-2 sm:order-1" />
<OptimizedSkeleton className="h-10 w-64 order-1 sm:order-2" />
</div>
</Container>
</>
);
}
if (error) {
return (
<Container>
<Section
title={library?.name || t("series.empty")}
actions={<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />}
/>
<ErrorMessage errorCode={error} onRetry={handleRetry} />
</Container>
);
}
if (!library || !series) {
return (
<Container>
<ErrorMessage errorCode="SERIES_FETCH_ERROR" onRetry={handleRetry} />
</Container>
);
}
return (
<>
<PullToRefreshIndicator
isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing}
progress={pullToRefresh.progress}
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<LibraryHeader
library={library}
seriesCount={series.totalElements}
series={series.content || []}
refreshLibrary={handleRefresh}
/>
<Container>
<PaginatedSeriesGrid
series={series.content || []}
currentPage={currentPage}
totalPages={series.totalPages}
totalElements={series.totalElements}
defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly}
pageSize={effectivePageSize}
/>
</Container>
</>
);
}

View File

@@ -0,0 +1,81 @@
"use client";
import { useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { RefreshProvider } from "@/contexts/RefreshContext";
import type { UserPreferences } from "@/types/preferences";
interface LibraryClientWrapperProps {
children: ReactNode;
libraryId: string;
currentPage: number;
unreadOnly: boolean;
search?: string;
pageSize: number;
preferences: UserPreferences;
}
export function LibraryClientWrapper({
children,
libraryId,
currentPage,
unreadOnly,
search,
pageSize,
}: LibraryClientWrapperProps) {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
try {
setIsRefreshing(true);
// 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();
return { success: true };
} catch {
return { success: false, error: "Error refreshing library" };
} finally {
setIsRefreshing(false);
}
};
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
await handleRefresh();
},
enabled: !isRefreshing,
});
return (
<>
<PullToRefreshIndicator
isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
progress={pullToRefresh.progress}
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<RefreshProvider refreshLibrary={handleRefresh}>{children}</RefreshProvider>
</>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { LibraryHeader } from "@/components/library/LibraryHeader";
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
import { Container } from "@/components/ui/container";
import { useRefresh } from "@/contexts/RefreshContext";
import type { KomgaLibrary } from "@/types/komga";
import type { LibraryResponse } from "@/types/library";
import type { Series } from "@/types/series";
import type { UserPreferences } from "@/types/preferences";
interface LibraryContentProps {
library: KomgaLibrary;
series: LibraryResponse<Series>;
currentPage: number;
preferences: UserPreferences;
unreadOnly: boolean;
search?: string;
pageSize: number;
}
export function LibraryContent({
library,
series,
currentPage,
preferences,
unreadOnly,
pageSize,
}: LibraryContentProps) {
const { refreshLibrary } = useRefresh();
return (
<>
<LibraryHeader
library={library}
seriesCount={series.totalElements}
series={series.content || []}
refreshLibrary={refreshLibrary || (async () => ({ success: false }))}
/>
<Container>
<PaginatedSeriesGrid
series={series.content || []}
currentPage={currentPage}
totalPages={series.totalPages}
totalElements={series.totalElements}
defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly}
pageSize={pageSize}
/>
</Container>
</>
);
}

View File

@@ -1,5 +1,10 @@
import { PreferencesService } from "@/lib/services/preferences.service";
import { ClientLibraryPage } from "./ClientLibraryPage";
import { LibraryService } from "@/lib/services/library.service";
import { LibraryClientWrapper } from "./LibraryClientWrapper";
import { LibraryContent } from "./LibraryContent";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { UserPreferences } from "@/types/preferences";
interface PageProps {
@@ -7,6 +12,8 @@ interface PageProps {
searchParams: Promise<{ page?: string; unread?: string; search?: string; size?: string }>;
}
const DEFAULT_PAGE_SIZE = 20;
export default async function LibraryPage({ params, searchParams }: PageProps) {
const libraryId = (await params).libraryId;
const unread = (await searchParams).unread;
@@ -19,15 +26,49 @@ export default async function LibraryPage({ params, searchParams }: PageProps) {
// Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
const effectivePageSize = size
? parseInt(size)
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
return (
<ClientLibraryPage
currentPage={currentPage}
libraryId={libraryId}
preferences={preferences}
unreadOnly={unreadOnly}
search={search}
pageSize={size ? parseInt(size) : undefined}
/>
);
try {
const [series, library] = await Promise.all([
LibraryService.getLibrarySeries(
libraryId,
currentPage - 1,
effectivePageSize,
unreadOnly,
search
),
LibraryService.getLibrary(libraryId),
]);
return (
<LibraryClientWrapper
libraryId={libraryId}
currentPage={currentPage}
unreadOnly={unreadOnly}
search={search}
pageSize={effectivePageSize}
preferences={preferences}
>
<LibraryContent
library={library}
series={series}
currentPage={currentPage}
preferences={preferences}
unreadOnly={unreadOnly}
search={search}
pageSize={effectivePageSize}
/>
</LibraryClientWrapper>
);
} catch (error) {
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.SERIES.FETCH_ERROR;
return (
<main className="container mx-auto px-4 py-8">
<ErrorMessage errorCode={errorCode} />
</main>
);
}
}

View File

@@ -1,5 +1,33 @@
import { ClientHomePage } from "@/components/home/ClientHomePage";
import { HomeService } from "@/lib/services/home.service";
import { HomeContent } from "@/components/home/HomeContent";
import { HomeClientWrapper } from "@/components/home/HomeClientWrapper";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import { redirect } from "next/navigation";
export default function HomePage() {
return <ClientHomePage />;
export default async function HomePage() {
try {
const data = await HomeService.getHomeData();
return (
<HomeClientWrapper>
<HomeContent data={data} />
</HomeClientWrapper>
);
} catch (error) {
// Si la config Komga est manquante, rediriger vers les settings
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) {
redirect("/settings");
}
// Afficher une erreur pour les autres cas
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
return (
<main className="container mx-auto px-4 py-8">
<ErrorMessage errorCode={errorCode} />
</main>
);
}
}

View File

@@ -1,195 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons";
import type { LibraryResponse } from "@/types/library";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { UserPreferences } from "@/types/preferences";
import { ERROR_CODES } from "@/constants/errorCodes";
import logger from "@/lib/logger";
interface ClientSeriesPageProps {
seriesId: string;
currentPage: number;
preferences: UserPreferences;
unreadOnly: boolean;
pageSize?: number;
}
const DEFAULT_PAGE_SIZE = 20;
export function ClientSeriesPage({
seriesId,
currentPage,
preferences,
unreadOnly,
pageSize,
}: ClientSeriesPageProps) {
const [series, setSeries] = useState<KomgaSeries | null>(null);
const [books, setBooks] = useState<LibraryResponse<KomgaBook> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
page: String(currentPage - 1),
size: String(effectivePageSize),
unread: String(unreadOnly),
});
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
}
const data = await response.json();
setSeries(data.series);
setBooks(data.books);
} catch (err) {
logger.error({ err }, "Error fetching series books");
setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
} finally {
setLoading(false);
}
};
fetchData();
}, [seriesId, currentPage, unreadOnly, effectivePageSize]);
const handleRefresh = async (seriesId: string) => {
try {
const params = new URLSearchParams({
page: String(currentPage - 1),
size: String(effectivePageSize),
unread: String(unreadOnly),
});
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
cache: "reload",
});
if (!response.ok) {
throw new Error("Erreur lors du rafraîchissement de la série");
}
const data = await response.json();
setSeries(data.series);
setBooks(data.books);
return { success: true };
} catch (error) {
logger.error({ err: error }, "Erreur lors du rafraîchissement:");
return { success: false, error: "Erreur lors du rafraîchissement de la série" };
}
};
const handleRetry = async () => {
setError(null);
setLoading(true);
try {
const params = new URLSearchParams({
page: String(currentPage - 1),
size: String(effectivePageSize),
unread: String(unreadOnly),
});
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
cache: "reload",
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
}
const data = await response.json();
setSeries(data.series);
setBooks(data.books);
} catch (err) {
logger.error({ err }, "Error fetching series books");
setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
} finally {
setLoading(false);
}
};
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
await handleRefresh(seriesId);
},
enabled: !loading && !error && !!series && !!books,
});
if (loading) {
return (
<div className="container py-8 space-y-8">
<div className="space-y-4">
<OptimizedSkeleton className="h-64 w-full rounded" />
<OptimizedSkeleton className="h-10 w-64" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{Array.from({ length: effectivePageSize }).map((_, i) => (
<OptimizedSkeleton key={i} className="aspect-[3/4] w-full rounded" />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="container py-8 space-y-8">
<h1 className="text-3xl font-bold">Série</h1>
<ErrorMessage errorCode={error} onRetry={handleRetry} />
</div>
);
}
if (!series || !books) {
return (
<div className="container py-8 space-y-8">
<h1 className="text-3xl font-bold">Série</h1>
<ErrorMessage errorCode={ERROR_CODES.SERIES.FETCH_ERROR} onRetry={handleRetry} />
</div>
);
}
return (
<>
<PullToRefreshIndicator
isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing}
progress={pullToRefresh.progress}
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<div className="container">
<SeriesHeader series={series} refreshSeries={handleRefresh} />
<PaginatedBookGrid
books={books.content || []}
currentPage={currentPage}
totalPages={books.totalPages}
totalElements={books.totalElements}
defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly}
onRefresh={() => handleRefresh(seriesId)}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { RefreshProvider } from "@/contexts/RefreshContext";
import type { UserPreferences } from "@/types/preferences";
interface SeriesClientWrapperProps {
children: ReactNode;
seriesId: string;
currentPage: number;
unreadOnly: boolean;
pageSize: number;
preferences: UserPreferences;
}
export function SeriesClientWrapper({
children,
seriesId,
currentPage,
unreadOnly,
pageSize,
}: SeriesClientWrapperProps) {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
try {
setIsRefreshing(true);
// 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();
return { success: true };
} catch {
return { success: false, error: "Error refreshing series" };
} finally {
setIsRefreshing(false);
}
};
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
await handleRefresh();
},
enabled: !isRefreshing,
});
return (
<>
<PullToRefreshIndicator
isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
progress={pullToRefresh.progress}
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<RefreshProvider refreshSeries={handleRefresh}>{children}</RefreshProvider>
</>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { Container } from "@/components/ui/container";
import { useRefresh } from "@/contexts/RefreshContext";
import type { LibraryResponse } from "@/types/library";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { UserPreferences } from "@/types/preferences";
interface SeriesContentProps {
series: KomgaSeries;
books: LibraryResponse<KomgaBook>;
currentPage: number;
preferences: UserPreferences;
unreadOnly: boolean;
pageSize: number;
}
export function SeriesContent({
series,
books,
currentPage,
preferences,
unreadOnly,
}: SeriesContentProps) {
const { refreshSeries } = useRefresh();
return (
<>
<SeriesHeader
series={series}
refreshSeries={refreshSeries || (async () => ({ success: false }))}
/>
<Container>
<PaginatedBookGrid
books={books.content || []}
currentPage={currentPage}
totalPages={books.totalPages}
totalElements={books.totalElements}
defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly}
/>
</Container>
</>
);
}

View File

@@ -1,5 +1,10 @@
import { PreferencesService } from "@/lib/services/preferences.service";
import { ClientSeriesPage } from "./ClientSeriesPage";
import { SeriesService } from "@/lib/services/series.service";
import { SeriesClientWrapper } from "./SeriesClientWrapper";
import { SeriesContent } from "./SeriesContent";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { UserPreferences } from "@/types/preferences";
interface PageProps {
@@ -7,6 +12,8 @@ interface PageProps {
searchParams: Promise<{ page?: string; unread?: string; size?: string }>;
}
const DEFAULT_PAGE_SIZE = 20;
export default async function SeriesPage({ params, searchParams }: PageProps) {
const seriesId = (await params).seriesId;
const page = (await searchParams).page;
@@ -18,14 +25,41 @@ export default async function SeriesPage({ params, searchParams }: PageProps) {
// Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
const effectivePageSize = size ? parseInt(size) : preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
return (
<ClientSeriesPage
seriesId={seriesId}
currentPage={currentPage}
preferences={preferences}
unreadOnly={unreadOnly}
pageSize={size ? parseInt(size) : undefined}
/>
);
try {
const [books, series] = await Promise.all([
SeriesService.getSeriesBooks(seriesId, currentPage - 1, effectivePageSize, unreadOnly),
SeriesService.getSeries(seriesId),
]);
return (
<SeriesClientWrapper
seriesId={seriesId}
currentPage={currentPage}
unreadOnly={unreadOnly}
pageSize={effectivePageSize}
preferences={preferences}
>
<SeriesContent
series={series}
books={books}
currentPage={currentPage}
preferences={preferences}
unreadOnly={unreadOnly}
pageSize={effectivePageSize}
/>
</SeriesClientWrapper>
);
} catch (error) {
const errorCode = error instanceof AppError
? error.code
: ERROR_CODES.BOOK.PAGES_FETCH_ERROR;
return (
<main className="container mx-auto px-4 py-8">
<ErrorMessage errorCode={errorCode} />
</main>
);
}
}

View File

@@ -311,14 +311,15 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
return (
<Card className="p-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
src={`/api/komga/images/books/${book.id}/thumbnail`}
alt={t("books.coverAlt", { title: book.metadata?.title })}
className="object-cover"
fill
sizes="48px"
sizes="64px"
priority={false}
unoptimized
/>
</div>
<div className="flex-1 min-w-0">

View File

@@ -1,118 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { HomeContent } from "./HomeContent";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { HomePageSkeleton } from "@/components/skeletons/OptimizedSkeletons";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { HomeData } from "@/types/home";
import logger from "@/lib/logger";
export function ClientHomePage() {
const router = useRouter();
const [data, setData] = useState<HomeData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/komga/home");
if (!response.ok) {
const errorData = await response.json();
const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
// Si la config Komga est manquante, rediriger vers les settings
if (errorCode === ERROR_CODES.KOMGA.MISSING_CONFIG) {
router.push("/settings");
return;
}
throw new Error(errorCode);
}
const homeData = await response.json();
setData(homeData);
} catch (err) {
logger.error({ err }, "Error fetching home data");
setError(err instanceof Error ? err.message : ERROR_CODES.KOMGA.SERVER_UNREACHABLE);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleRefresh = async () => {
try {
const response = await fetch("/api/komga/home", {
cache: "reload",
});
if (!response.ok) {
throw new Error("Erreur lors du rafraîchissement de la page d'accueil");
}
const homeData = await response.json();
setData(homeData);
return { success: true };
} catch (error) {
logger.error({ err: error }, "Erreur lors du rafraîchissement:");
return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" };
}
};
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
await handleRefresh();
},
enabled: !loading && !error && !!data,
});
if (loading) {
return <HomePageSkeleton />;
}
const handleRetry = () => {
fetchData();
};
if (error) {
return (
<main className="container mx-auto px-4 py-8">
<ErrorMessage errorCode={error} onRetry={handleRetry} />
</main>
);
}
if (!data) {
return (
<main className="container mx-auto px-4 py-8">
<ErrorMessage errorCode={ERROR_CODES.KOMGA.SERVER_UNREACHABLE} onRetry={handleRetry} />
</main>
);
}
return (
<>
<PullToRefreshIndicator
isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing}
progress={pullToRefresh.progress}
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<HomeContent data={data} refreshHome={handleRefresh} />
</>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { RefreshButton } from "@/components/library/RefreshButton";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { useTranslate } from "@/hooks/useTranslate";
import logger from "@/lib/logger";
interface HomeClientWrapperProps {
children: ReactNode;
}
export function HomeClientWrapper({ children }: HomeClientWrapperProps) {
const router = useRouter();
const { t } = useTranslate();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
try {
setIsRefreshing(true);
// 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();
return { success: true };
} catch (error) {
logger.error({ err: error }, "Erreur lors du rafraîchissement:");
return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" };
} finally {
setIsRefreshing(false);
}
};
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
await handleRefresh();
},
enabled: !isRefreshing,
});
return (
<>
<PullToRefreshIndicator
isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
progress={pullToRefresh.progress}
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<main className="container mx-auto px-4 py-8 space-y-12">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">{t("home.title")}</h1>
<RefreshButton libraryId="home" refreshLibrary={handleRefresh} />
</div>
{children}
</main>
</>
);
}

View File

@@ -1,122 +1,74 @@
"use client";
import { HeroSection } from "./HeroSection";
import { MediaRow } from "./MediaRow";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { HomeData } from "@/types/home";
import { RefreshButton } from "@/components/library/RefreshButton";
import { History, Sparkles, Clock, LibraryBig, BookOpen } from "lucide-react";
import { useTranslate } from "@/hooks/useTranslate";
import { useEffect, useState } from "react";
interface HomeContentProps {
data: HomeData;
refreshHome: () => Promise<{ success: boolean; error?: string }>;
}
export function HomeContent({ data, refreshHome }: HomeContentProps) {
const { t } = useTranslate();
const [showHero, setShowHero] = useState(false);
const optimizeSeriesData = (series: KomgaSeries[]) => {
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
id,
metadata: { title: metadata.title },
booksCount,
booksReadCount,
}));
};
// Vérifier si la HeroSection a déjà été affichée
useEffect(() => {
const heroShown = localStorage.getItem("heroSectionShown");
if (!heroShown && data.ongoing && data.ongoing.length > 0) {
setShowHero(true);
localStorage.setItem("heroSectionShown", "true");
}
}, [data.ongoing]);
// Vérification des données pour le debug
// logger.info("HomeContent - Données reçues:", {
// ongoingCount: data.ongoing?.length || 0,
// recentlyReadCount: data.recentlyRead?.length || 0,
// onDeckCount: data.onDeck?.length || 0,
// });
const optimizeSeriesData = (series: KomgaSeries[]) => {
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
id,
metadata: { title: metadata.title },
booksCount,
booksReadCount,
}));
};
const optimizeHeroSeriesData = (series: KomgaSeries[]) => {
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
id,
metadata: { title: metadata.title },
booksCount,
booksReadCount,
}));
};
const optimizeBookData = (books: KomgaBook[]) => {
return books.map(({ id, metadata, readProgress, media }) => ({
id,
metadata: {
title: metadata.title,
number: metadata.number,
},
readProgress: readProgress || { page: 0 },
media,
}));
};
const optimizeBookData = (books: KomgaBook[]) => {
return books.map(({ id, metadata, readProgress, media }) => ({
id,
metadata: {
title: metadata.title,
number: metadata.number,
},
readProgress: readProgress || { page: 0 },
media,
}));
};
export function HomeContent({ data }: HomeContentProps) {
return (
<main className="container mx-auto px-4 py-8 space-y-12">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">{t("home.title")}</h1>
<RefreshButton libraryId="home" refreshLibrary={refreshHome} />
</div>
{/* Hero Section - Afficher uniquement si nous avons des séries en cours et si elle n'a jamais été affichée */}
{showHero && data.ongoing && data.ongoing.length > 0 && (
<HeroSection series={optimizeHeroSeriesData(data.ongoing)} />
<div className="space-y-12">
{data.ongoing && data.ongoing.length > 0 && (
<MediaRow
titleKey="home.sections.continue_series"
items={optimizeSeriesData(data.ongoing)}
iconName="LibraryBig"
/>
)}
{/* Sections de contenu */}
<div className="space-y-12">
{data.ongoing && data.ongoing.length > 0 && (
<MediaRow
title={t("home.sections.continue_series")}
items={optimizeSeriesData(data.ongoing)}
icon={LibraryBig}
/>
)}
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
<MediaRow
titleKey="home.sections.continue_reading"
items={optimizeBookData(data.ongoingBooks)}
iconName="BookOpen"
/>
)}
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
<MediaRow
title={t("home.sections.continue_reading")}
items={optimizeBookData(data.ongoingBooks)}
icon={BookOpen}
/>
)}
{data.onDeck && data.onDeck.length > 0 && (
<MediaRow
titleKey="home.sections.up_next"
items={optimizeBookData(data.onDeck)}
iconName="Clock"
/>
)}
{data.onDeck && data.onDeck.length > 0 && (
<MediaRow
title={t("home.sections.up_next")}
items={optimizeBookData(data.onDeck)}
icon={Clock}
/>
)}
{data.latestSeries && data.latestSeries.length > 0 && (
<MediaRow
titleKey="home.sections.latest_series"
items={optimizeSeriesData(data.latestSeries)}
iconName="Sparkles"
/>
)}
{data.latestSeries && data.latestSeries.length > 0 && (
<MediaRow
title={t("home.sections.latest_series")}
items={optimizeSeriesData(data.latestSeries)}
icon={Sparkles}
/>
)}
{data.recentlyRead && data.recentlyRead.length > 0 && (
<MediaRow
title={t("home.sections.recently_added")}
items={optimizeBookData(data.recentlyRead)}
icon={History}
/>
)}
</div>
</main>
{data.recentlyRead && data.recentlyRead.length > 0 && (
<MediaRow
titleKey="home.sections.recently_added"
items={optimizeBookData(data.recentlyRead)}
iconName="History"
/>
)}
</div>
);
}

View File

@@ -7,7 +7,7 @@ import { SeriesCover } from "../ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate";
import { ScrollContainer } from "@/components/ui/scroll-container";
import { Section } from "@/components/ui/section";
import type { LucideIcon } from "lucide-react";
import { History, Sparkles, Clock, LibraryBig, BookOpen } from "lucide-react";
import { Card } from "@/components/ui/card";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { cn } from "@/lib/utils";
@@ -38,14 +38,23 @@ interface OptimizedBook extends BaseItem {
}
interface MediaRowProps {
title: string;
titleKey: string;
items: (OptimizedSeries | OptimizedBook)[];
icon?: LucideIcon;
iconName?: string;
}
export function MediaRow({ title, items, icon }: MediaRowProps) {
const iconMap = {
LibraryBig,
BookOpen,
Clock,
Sparkles,
History,
};
export function MediaRow({ titleKey, items, iconName }: MediaRowProps) {
const router = useRouter();
const { t } = useTranslate();
const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined;
const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`;
@@ -55,7 +64,7 @@ export function MediaRow({ title, items, icon }: MediaRowProps) {
if (!items.length) return null;
return (
<Section title={title} icon={icon}>
<Section title={t(titleKey)} icon={icon}>
<ScrollContainer
showArrows={true}
scrollAmount={400}

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

@@ -16,7 +16,6 @@ import { cn } from "@/lib/utils";
import { signOut } from "next-auth/react";
import { useEffect, useState, useCallback } from "react";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
import { usePreferences } from "@/contexts/PreferencesContext";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors";
@@ -44,37 +43,12 @@ export function Sidebar({
const { t } = useTranslate();
const pathname = usePathname();
const router = useRouter();
const { preferences } = usePreferences();
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []);
const [isRefreshing, setIsRefreshing] = useState(false);
const { toast } = useToast();
const refreshLibraries = useCallback(async () => {
setIsRefreshing(true);
try {
const response = await fetch("/api/komga/libraries");
if (!response.ok) {
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR);
}
const data = await response.json();
setLibraries(data);
} catch (error) {
logger.error({ err: error }, "Erreur de chargement des bibliothèques:");
toast({
title: "Erreur",
description:
error instanceof AppError
? error.message
: getErrorMessage(ERROR_CODES.LIBRARY.FETCH_ERROR),
variant: "destructive",
});
} finally {
setIsRefreshing(false);
}
}, [toast]);
const refreshFavorites = useCallback(async () => {
try {
const favoritesResponse = await fetch("/api/komga/favorites");
@@ -111,13 +85,6 @@ export function Sidebar({
}
}, [toast]);
useEffect(() => {
if (Object.keys(preferences).length > 0) {
refreshLibraries();
refreshFavorites();
}
}, [preferences, refreshLibraries, refreshFavorites]);
// Mettre à jour les favoris quand ils changent
useEffect(() => {
const handleFavoritesChange = () => {
@@ -133,7 +100,10 @@ export function Sidebar({
const handleRefresh = async () => {
setIsRefreshing(true);
await Promise.all([refreshLibraries(), refreshFavorites()]);
// Revalider côté serveur via router.refresh()
router.refresh();
// Petit délai pour laisser le temps au serveur
setTimeout(() => setIsRefreshing(false), 500);
};
const handleLogout = async () => {

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 { 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,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

@@ -16,6 +16,10 @@ interface PreferencesContextType {
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
// Module-level flag to prevent duplicate fetches (survives StrictMode remounts)
let preferencesFetchInProgress = false;
let preferencesFetched = false;
export function PreferencesProvider({
children,
initialPreferences,
@@ -29,7 +33,17 @@ export function PreferencesProvider({
);
const [isLoading, setIsLoading] = useState(false);
// Check if we have valid initial preferences from server
const hasValidInitialPreferences =
initialPreferences && Object.keys(initialPreferences).length > 0;
const fetchPreferences = useCallback(async () => {
// Prevent concurrent fetches
if (preferencesFetchInProgress || preferencesFetched) {
return;
}
preferencesFetchInProgress = true;
try {
const response = await fetch("/api/preferences");
if (!response.ok) {
@@ -45,25 +59,30 @@ export function PreferencesProvider({
viewMode: data.displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
},
});
preferencesFetched = true;
} catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération des préférences");
setPreferences(defaultPreferences);
} finally {
setIsLoading(false);
preferencesFetchInProgress = false;
}
}, []);
useEffect(() => {
// Recharger les préférences quand la session change (connexion/déconnexion)
if (status === "authenticated") {
// Toujours recharger depuis l'API pour avoir les dernières valeurs
// même si on a des initialPreferences (qui peuvent être en cache)
// Skip refetch if we already have valid initial preferences from server
if (hasValidInitialPreferences) {
preferencesFetched = true; // Mark as fetched since we have server data
return;
}
fetchPreferences();
} else if (status === "unauthenticated") {
// Réinitialiser aux préférences par défaut quand l'utilisateur se déconnecte
// Reset to defaults when user logs out
setPreferences(defaultPreferences);
preferencesFetched = false; // Allow refetch on next login
}
}, [status, fetchPreferences]);
}, [status, fetchPreferences, hasValidInitialPreferences]);
const updatePreferences = useCallback(async (newPreferences: Partial<UserPreferences>) => {
try {

View File

@@ -0,0 +1,31 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
interface RefreshContextType {
refreshLibrary?: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
refreshSeries?: (seriesId: string) => Promise<{ success: boolean; error?: string }>;
}
const RefreshContext = createContext<RefreshContextType>({});
export function RefreshProvider({
children,
refreshLibrary,
refreshSeries,
}: {
children: ReactNode;
refreshLibrary?: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
refreshSeries?: (seriesId: string) => Promise<{ success: boolean; error?: string }>;
}) {
return (
<RefreshContext.Provider value={{ refreshLibrary, refreshSeries }}>
{children}
</RefreshContext.Provider>
);
}
export function useRefresh() {
return useContext(RefreshContext);
}

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",
"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": {

View File

@@ -136,6 +136,36 @@
"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",
"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": {

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;
}
};

View File

@@ -1,15 +1,10 @@
import { BaseApiService } from "./base-api.service";
import type { KomgaBook, KomgaBookWithPages } from "@/types/komga";
import type { ImageResponse } from "./image.service";
import { ImageService } from "./image.service";
import { PreferencesService } from "./preferences.service";
import { SeriesService } from "./series.service";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
// Cache HTTP navigateur : 30 jours (immutable car les images ne changent pas)
const IMAGE_CACHE_MAX_AGE = 2592000;
export class BookService extends BaseApiService {
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
try {
@@ -111,22 +106,10 @@ export class BookService extends BaseApiService {
try {
// Ajuster le numéro de page pour l'API Komga (zero-based)
const adjustedPageNumber = pageNumber - 1;
const response: ImageResponse = await ImageService.getImage(
// Stream directement sans buffer en mémoire
return ImageService.streamImage(
`books/${bookId}/pages/${adjustedPageNumber}?zero_based=true`
);
// Convertir le Buffer Node.js en ArrayBuffer proprement
const arrayBuffer = response.buffer.buffer.slice(
response.buffer.byteOffset,
response.buffer.byteOffset + response.buffer.byteLength
) as ArrayBuffer;
return new Response(arrayBuffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
},
});
} catch (error) {
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
}
@@ -137,18 +120,12 @@ export class BookService extends BaseApiService {
// Récupérer les préférences de l'utilisateur
const preferences = await PreferencesService.getPreferences();
// Si l'utilisateur préfère les vignettes, utiliser la miniature
// Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
if (preferences.showThumbnails) {
const response: ImageResponse = await ImageService.getImage(`books/${bookId}/thumbnail`);
return new Response(response.buffer.buffer as ArrayBuffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
},
});
return ImageService.streamImage(`books/${bookId}/thumbnail`);
}
// Sinon, récupérer la première page
// Sinon, récupérer la première page (streaming)
return this.getPage(bookId, 1);
} catch (error) {
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
@@ -165,16 +142,10 @@ export class BookService extends BaseApiService {
static async getPageThumbnail(bookId: string, pageNumber: number): Promise<Response> {
try {
const response: ImageResponse = await ImageService.getImage(
// Stream directement sans buffer en mémoire
return ImageService.streamImage(
`books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true`
);
return new Response(response.buffer.buffer as ArrayBuffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
},
});
} catch (error) {
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
}
@@ -192,32 +163,53 @@ export class BookService extends BaseApiService {
});
}
const { LibraryService } = await import("./library.service");
// Faire une requête légère : prendre une page de séries d'une bibliothèque au hasard
// Use books/list directly with library filter to avoid extra series/list call
const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length);
const randomLibraryId = libraryIds[randomLibraryIndex];
// Récupérer juste une page de séries (pas toutes)
const seriesResponse = await LibraryService.getLibrarySeries(randomLibraryId, 0, 20);
// Random page offset for variety (assuming most libraries have at least 100 books)
const randomPage = Math.floor(Math.random() * 5); // Pages 0-4
if (seriesResponse.content.length === 0) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
message: "Aucune série trouvée dans les bibliothèques sélectionnées",
});
}
const searchBody = {
condition: {
libraryId: {
operator: "is",
value: randomLibraryId,
},
},
};
// Choisir une série au hasard parmi celles récupérées
const randomSeriesIndex = Math.floor(Math.random() * seriesResponse.content.length);
const randomSeries = seriesResponse.content[randomSeriesIndex];
// Récupérer les books de cette série avec pagination
const booksResponse = await SeriesService.getSeriesBooks(randomSeries.id, 0, 100);
const booksResponse = await this.fetchFromApi<{
content: KomgaBook[];
totalElements: number;
}>(
{
path: "books/list",
params: { page: String(randomPage), size: "20", sort: "number,asc" },
},
{ "Content-Type": "application/json" },
{ method: "POST", body: JSON.stringify(searchBody) }
);
if (booksResponse.content.length === 0) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
message: "Aucun livre trouvé dans la série",
});
// Fallback to page 0 if random page was empty
const fallbackResponse = await this.fetchFromApi<{
content: KomgaBook[];
totalElements: number;
}>(
{ path: "books/list", params: { page: "0", size: "20", sort: "number,asc" } },
{ "Content-Type": "application/json" },
{ method: "POST", body: JSON.stringify(searchBody) }
);
if (fallbackResponse.content.length === 0) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
message: "Aucun livre trouvé dans les bibliothèques sélectionnées",
});
}
const randomBookIndex = Math.floor(Math.random() * fallbackResponse.content.length);
return fallbackResponse.content[randomBookIndex].id;
}
const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length);

View File

@@ -2,30 +2,52 @@ import type { KomgaBook } from "@/types/komga";
export class ClientOfflineBookService {
static setCurrentPage(book: KomgaBook, page: number) {
localStorage.setItem(`${book.id}-page`, page.toString());
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.setItem) {
try {
localStorage.setItem(`${book.id}-page`, page.toString());
} catch {
// Ignore localStorage errors in SSR
}
}
}
static getCurrentPage(book: KomgaBook) {
const readProgressPage = book.readProgress?.page || 0;
if (typeof localStorage !== "undefined") {
const cPageLS = localStorage.getItem(`${book.id}-page`) || "0";
const currentPage = parseInt(cPageLS);
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem) {
try {
const cPageLS = localStorage.getItem(`${book.id}-page`) || "0";
const currentPage = parseInt(cPageLS);
if (currentPage < readProgressPage) {
if (currentPage < readProgressPage) {
return readProgressPage;
}
return currentPage;
} catch {
return readProgressPage;
}
return currentPage;
} else {
return readProgressPage;
}
}
static removeCurrentPage(book: KomgaBook) {
localStorage.removeItem(`${book.id}-page`);
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.removeItem) {
try {
localStorage.removeItem(`${book.id}-page`);
} catch {
// Ignore localStorage errors in SSR
}
}
}
static removeCurrentPageById(bookId: string) {
localStorage.removeItem(`${bookId}-page`);
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.removeItem) {
try {
localStorage.removeItem(`${bookId}-page`);
} catch {
// Ignore localStorage errors in SSR
}
}
}
}

View File

@@ -0,0 +1,39 @@
import { FavoriteService } from "./favorite.service";
import { SeriesService } from "./series.service";
import type { KomgaSeries } from "@/types/komga";
import logger from "@/lib/logger";
export class FavoritesService {
static async getFavorites(): Promise<KomgaSeries[]> {
try {
const favoriteIds = await FavoriteService.getAllFavoriteIds();
if (favoriteIds.length === 0) {
return [];
}
// Fetch toutes les séries en parallèle
const promises = favoriteIds.map(async (id: string) => {
try {
return await SeriesService.getSeries(id);
} catch (error) {
logger.error({ err: error, seriesId: id }, "Error fetching favorite series");
// Si la série n'existe plus, la retirer des favoris
try {
await FavoriteService.removeFromFavorites(id);
} catch {
// Ignore cleanup errors
}
return null;
}
});
const results = await Promise.all(promises);
return results.filter((series): series is KomgaSeries => series !== null);
} catch (error) {
logger.error({ err: error }, "Error fetching favorites");
return [];
}
}
}

View File

@@ -3,28 +3,34 @@ import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
import logger from "@/lib/logger";
export interface ImageResponse {
buffer: Buffer;
contentType: string | null;
}
// Cache HTTP navigateur : 30 jours (immutable car les thumbnails ne changent pas)
const IMAGE_CACHE_MAX_AGE = 2592000;
export class ImageService extends BaseApiService {
static async getImage(path: string): Promise<ImageResponse> {
/**
* Stream an image directly from Komga without buffering in memory
* Returns a Response that can be directly returned to the client
*/
static async streamImage(
path: string,
cacheMaxAge: number = IMAGE_CACHE_MAX_AGE
): Promise<Response> {
try {
const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" };
// NE PAS mettre en cache - les images sont trop grosses et les Buffers ne sérialisent pas bien
const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true });
const contentType = response.headers.get("content-type");
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return {
buffer,
contentType,
};
// Stream the response body directly without buffering
return new Response(response.body, {
status: response.status,
headers: {
"Content-Type": response.headers.get("content-type") || "image/jpeg",
"Content-Length": response.headers.get("content-length") || "",
"Cache-Control": `public, max-age=${cacheMaxAge}, immutable`,
},
});
} catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de l'image");
logger.error({ err: error }, "Erreur lors du streaming de l'image");
throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error);
}
}

View File

@@ -5,10 +5,47 @@ import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
import type { KomgaLibrary } from "@/types/komga";
// Raw library type from Komga API (without booksCount)
interface KomgaLibraryRaw {
id: string;
name: string;
root: string;
unavailable: boolean;
}
export class LibraryService extends BaseApiService {
static async getLibraries(): Promise<KomgaLibrary[]> {
try {
return this.fetchFromApi<KomgaLibrary[]>({ path: "libraries" });
const libraries = await this.fetchFromApi<KomgaLibraryRaw[]>({ path: "libraries" });
// Enrich each library with book counts
const enrichedLibraries = await Promise.all(
libraries.map(async (library) => {
try {
const booksResponse = await this.fetchFromApi<{ totalElements: number }>({
path: "books",
params: { library_id: library.id, size: "0" },
});
return {
...library,
importLastModified: "",
lastModified: "",
booksCount: booksResponse.totalElements,
booksReadCount: 0,
} as KomgaLibrary;
} catch {
return {
...library,
importLastModified: "",
lastModified: "",
booksCount: 0,
booksReadCount: 0,
} as KomgaLibrary;
}
})
);
return enrichedLibraries;
} catch (error) {
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error);
}
@@ -16,12 +53,7 @@ export class LibraryService extends BaseApiService {
static async getLibrary(libraryId: string): Promise<KomgaLibrary> {
try {
const libraries = await this.getLibraries();
const library = libraries.find((library) => library.id === libraryId);
if (!library) {
throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND, { libraryId });
}
return library;
return this.fetchFromApi<KomgaLibrary>({ path: `libraries/${libraryId}` });
} catch (error) {
if (error instanceof AppError) {
throw error;

View File

@@ -2,7 +2,6 @@ import { BaseApiService } from "./base-api.service";
import type { LibraryResponse } from "@/types/library";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import { BookService } from "./book.service";
import type { ImageResponse } from "./image.service";
import { ImageService } from "./image.service";
import { PreferencesService } from "./preferences.service";
import { ERROR_CODES } from "../../constants/errorCodes";
@@ -10,9 +9,6 @@ import { AppError } from "../../utils/errors";
import type { UserPreferences } from "@/types/preferences";
import logger from "@/lib/logger";
// Cache HTTP navigateur : 30 jours (immutable car les images ne changent pas)
const IMAGE_CACHE_MAX_AGE = 2592000;
export class SeriesService extends BaseApiService {
static async getSeries(seriesId: string): Promise<KomgaSeries> {
try {
@@ -123,21 +119,14 @@ export class SeriesService extends BaseApiService {
// Récupérer les préférences de l'utilisateur
const preferences: UserPreferences = await PreferencesService.getPreferences();
// Si l'utilisateur préfère les vignettes, utiliser la miniature
// Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
if (preferences.showThumbnails) {
const response: ImageResponse = await ImageService.getImage(`series/${seriesId}/thumbnail`);
return new Response(response.buffer.buffer as ArrayBuffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
},
});
return ImageService.streamImage(`series/${seriesId}/thumbnail`);
}
// Sinon, récupérer la première page
// Sinon, récupérer la première page (streaming)
const firstBookId = await this.getFirstBook(seriesId);
const response = await BookService.getPage(firstBookId, 1);
return response;
return BookService.getPage(firstBookId, 1);
} catch (error) {
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
}

View File

@@ -1,4 +1,5 @@
import type { Config } from "tailwindcss";
import tailwindcssAnimate from "tailwindcss-animate";
const config = {
darkMode: ["class"],
@@ -77,7 +78,7 @@ const config = {
},
},
},
plugins: [require("tailwindcss-animate")],
plugins: [tailwindcssAnimate],
} satisfies Config;
export default config;

View File

@@ -23,5 +23,5 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "temp"]
}