Compare commits
12 Commits
512e9a480f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
034aa69f8d | ||
|
|
060dfb3099 | ||
|
|
ad11bce308 | ||
|
|
1ffe99285d | ||
|
|
0d33462349 | ||
|
|
b8a0b85c54 | ||
|
|
2c8c0b5eb0 | ||
|
|
b497746cfa | ||
|
|
489e570348 | ||
|
|
117ad2d0ce | ||
|
|
0d7d27ef82 | ||
|
|
e903b55a46 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -53,4 +53,6 @@ mongo-keyfile
|
|||||||
prisma/data/
|
prisma/data/
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
|
temp/
|
||||||
@@ -18,10 +18,12 @@
|
|||||||
"@prisma/client": "^6.17.1",
|
"@prisma/client": "^6.17.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "1.1.15",
|
"@radix-ui/react-dialog": "1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||||
"@radix-ui/react-progress": "^1.1.2",
|
"@radix-ui/react-progress": "^1.1.2",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "1.2.3",
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
|||||||
65
pnpm-lock.yaml
generated
65
pnpm-lock.yaml
generated
@@ -21,6 +21,9 @@ importers:
|
|||||||
'@radix-ui/react-checkbox':
|
'@radix-ui/react-checkbox':
|
||||||
specifier: ^1.3.3
|
specifier: ^1.3.3
|
||||||
version: 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-collapsible':
|
||||||
|
specifier: ^1.1.12
|
||||||
|
version: 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
'@radix-ui/react-dialog':
|
'@radix-ui/react-dialog':
|
||||||
specifier: 1.1.15
|
specifier: 1.1.15
|
||||||
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@@ -33,6 +36,9 @@ importers:
|
|||||||
'@radix-ui/react-radio-group':
|
'@radix-ui/react-radio-group':
|
||||||
specifier: ^1.3.8
|
specifier: ^1.3.8
|
||||||
version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-scroll-area':
|
||||||
|
specifier: ^1.2.10
|
||||||
|
version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
'@radix-ui/react-select':
|
'@radix-ui/react-select':
|
||||||
specifier: ^2.1.6
|
specifier: ^2.1.6
|
||||||
version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@@ -680,6 +686,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-collapsible@1.1.12':
|
||||||
|
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-collection@1.1.7':
|
'@radix-ui/react-collection@1.1.7':
|
||||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -894,6 +913,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-scroll-area@1.2.10':
|
||||||
|
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-select@2.2.6':
|
'@radix-ui/react-select@2.2.6':
|
||||||
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3543,6 +3575,22 @@ snapshots:
|
|||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
|
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
@@ -3765,6 +3813,23 @@ snapshots:
|
|||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
|
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/number': 1.1.1
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/number': 1.1.1
|
'@radix-ui/number': 1.1.1
|
||||||
|
|||||||
447
public/sw.js
447
public/sw.js
@@ -1,14 +1,20 @@
|
|||||||
// StripStream Service Worker - Version 1
|
// StripStream Service Worker - Version 2
|
||||||
// Architecture: Cache-as-you-go for static resources only
|
// Architecture: SWR (Stale-While-Revalidate) for all resources
|
||||||
|
|
||||||
const VERSION = "v1";
|
const VERSION = "v2.5";
|
||||||
const STATIC_CACHE = `stripstream-static-${VERSION}`;
|
const STATIC_CACHE = `stripstream-static-${VERSION}`;
|
||||||
const RSC_CACHE = `stripstream-rsc-${VERSION}`;
|
const PAGES_CACHE = `stripstream-pages-${VERSION}`; // Navigation + RSC (client-side navigation)
|
||||||
|
const API_CACHE = `stripstream-api-${VERSION}`;
|
||||||
|
const IMAGES_CACHE = `stripstream-images-${VERSION}`;
|
||||||
const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
|
const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
|
||||||
|
|
||||||
const OFFLINE_PAGE = "/offline.html";
|
const OFFLINE_PAGE = "/offline.html";
|
||||||
const PRECACHE_ASSETS = [OFFLINE_PAGE, "/manifest.json"];
|
const PRECACHE_ASSETS = [OFFLINE_PAGE, "/manifest.json"];
|
||||||
|
|
||||||
|
// Cache size limits
|
||||||
|
const IMAGES_CACHE_MAX_SIZE = 100 * 1024 * 1024; // 100MB
|
||||||
|
const IMAGES_CACHE_MAX_ENTRIES = 500;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Utility Functions - Request Detection
|
// Utility Functions - Request Detection
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -22,13 +28,79 @@ function isNextRSCRequest(request) {
|
|||||||
return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1";
|
return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isApiRequest(url) {
|
||||||
|
return url.includes("/api/komga/") && !url.includes("/api/komga/images/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageRequest(url) {
|
||||||
|
return url.includes("/api/komga/images/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBookPageRequest(url) {
|
||||||
|
// Book pages: /api/komga/images/books/{id}/pages/{num} or /api/komga/books/{id}/pages/{num}
|
||||||
|
// These are handled by manual download (DownloadManager) - don't cache via SWR
|
||||||
|
return (
|
||||||
|
(url.includes("/api/komga/images/books/") || url.includes("/api/komga/books/")) &&
|
||||||
|
url.includes("/pages/")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBooksManualCache(url) {
|
||||||
|
// Check if this is a request that should be handled by the books manual cache
|
||||||
|
return url.includes("/api/komga/images/books/") && url.includes("/pages");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Client Communication
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function notifyClients(message) {
|
||||||
|
const clients = await self.clients.matchAll({ type: "window" });
|
||||||
|
clients.forEach((client) => {
|
||||||
|
client.postMessage(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cache Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function getCacheSize(cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
for (const request of keys) {
|
||||||
|
const response = await cache.match(request);
|
||||||
|
if (response) {
|
||||||
|
const blob = await response.clone().blob();
|
||||||
|
totalSize += blob.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trimCache(cacheName, maxEntries) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
|
||||||
|
if (keys.length > maxEntries) {
|
||||||
|
// Remove oldest entries (FIFO)
|
||||||
|
const toDelete = keys.slice(0, keys.length - maxEntries);
|
||||||
|
await Promise.all(toDelete.map((key) => cache.delete(key)));
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[SW] Trimmed ${toDelete.length} entries from ${cacheName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Cache Strategies
|
// Cache Strategies
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache-First: Serve from cache, fallback to network
|
* Cache-First: Serve from cache, fallback to network
|
||||||
* Used for: Next.js static resources
|
* Used for: Next.js static resources (immutable)
|
||||||
*/
|
*/
|
||||||
async function cacheFirstStrategy(request, cacheName, options = {}) {
|
async function cacheFirstStrategy(request, cacheName, options = {}) {
|
||||||
const cache = await caches.open(cacheName);
|
const cache = await caches.open(cacheName);
|
||||||
@@ -56,21 +128,70 @@ async function cacheFirstStrategy(request, cacheName, options = {}) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stale-While-Revalidate: Serve from cache immediately, update in background
|
* Stale-While-Revalidate: Serve from cache immediately, update in background
|
||||||
* Used for: RSC payloads
|
* Used for: API calls, images
|
||||||
|
* Respects Cache-Control: no-cache to force network-first (for refresh buttons)
|
||||||
*/
|
*/
|
||||||
async function staleWhileRevalidateStrategy(request, cacheName) {
|
async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
|
||||||
const cache = await caches.open(cacheName);
|
const cache = await caches.open(cacheName);
|
||||||
const cached = await cache.match(request);
|
|
||||||
|
// Check if client requested no-cache (refresh button, router.refresh(), etc.)
|
||||||
|
// 1. Check Cache-Control header
|
||||||
|
const cacheControl = request.headers.get("Cache-Control");
|
||||||
|
const noCacheHeader =
|
||||||
|
cacheControl && (cacheControl.includes("no-cache") || cacheControl.includes("no-store"));
|
||||||
|
// 2. Check request.cache mode (used by Next.js router.refresh())
|
||||||
|
const noCacheMode =
|
||||||
|
request.cache === "no-cache" || request.cache === "no-store" || request.cache === "reload";
|
||||||
|
const noCache = noCacheHeader || noCacheMode;
|
||||||
|
|
||||||
|
// If no-cache, skip cached response and go network-first
|
||||||
|
const cached = noCache ? null : await cache.match(request);
|
||||||
|
|
||||||
// Start network request (don't await)
|
// Start network request (don't await)
|
||||||
const fetchPromise = fetch(request)
|
const fetchPromise = fetch(request)
|
||||||
.then((response) => {
|
.then(async (response) => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
cache.put(request, response.clone());
|
// Clone response for cache
|
||||||
|
const responseToCache = response.clone();
|
||||||
|
|
||||||
|
// Check if content changed (for notification)
|
||||||
|
if (cached && options.notifyOnChange) {
|
||||||
|
try {
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
// For JSON APIs, compare content
|
||||||
|
if (options.isJson) {
|
||||||
|
const oldText = await cachedResponse.text();
|
||||||
|
const newText = await response.clone().text();
|
||||||
|
if (oldText !== newText) {
|
||||||
|
notifyClients({
|
||||||
|
type: "CACHE_UPDATED",
|
||||||
|
url: request.url,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore comparison errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
await cache.put(request, responseToCache);
|
||||||
|
|
||||||
|
// Trim cache if needed (for images)
|
||||||
|
if (options.maxEntries) {
|
||||||
|
trimCache(cacheName, options.maxEntries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.catch(() => null);
|
.catch((error) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[SW] Network request failed:", request.url, error.message);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
// Return cached version immediately if available
|
// Return cached version immediately if available
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -87,40 +208,50 @@ async function staleWhileRevalidateStrategy(request, cacheName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation Strategy: Network-First with SPA fallback
|
* Navigation SWR: Serve from cache immediately, update in background
|
||||||
|
* Falls back to offline page if nothing cached
|
||||||
* Used for: Page navigations
|
* Used for: Page navigations
|
||||||
*/
|
*/
|
||||||
async function navigationStrategy(request) {
|
async function navigationSWRStrategy(request, cacheName) {
|
||||||
const cache = await caches.open(STATIC_CACHE);
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
|
||||||
try {
|
// Start network request in background
|
||||||
// Try network first
|
const fetchPromise = fetch(request)
|
||||||
const response = await fetch(request);
|
.then(async (response) => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
cache.put(request, response.clone());
|
await cache.put(request, response.clone());
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
})
|
||||||
// Network failed - try cache
|
.catch(() => null);
|
||||||
const cached = await cache.match(request);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to serve root page for SPA client-side routing
|
// Return cached version immediately if available
|
||||||
const rootPage = await cache.match("/");
|
if (cached) {
|
||||||
if (rootPage) {
|
return cached;
|
||||||
return rootPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last resort: offline page
|
|
||||||
const offlinePage = await cache.match(OFFLINE_PAGE);
|
|
||||||
if (offlinePage) {
|
|
||||||
return offlinePage;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No cache - wait for network
|
||||||
|
const response = await fetchPromise;
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network failed and no cache - try fallbacks
|
||||||
|
// Try to serve root page for SPA client-side routing
|
||||||
|
const rootPage = await cache.match("/");
|
||||||
|
if (rootPage) {
|
||||||
|
return rootPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: offline page (in static cache)
|
||||||
|
const staticCache = await caches.open(STATIC_CACHE);
|
||||||
|
const offlinePage = await staticCache.match(OFFLINE_PAGE);
|
||||||
|
if (offlinePage) {
|
||||||
|
return offlinePage;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Offline and no cached page available");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -155,9 +286,10 @@ self.addEventListener("activate", (event) => {
|
|||||||
(async () => {
|
(async () => {
|
||||||
// Clean up old caches, but preserve BOOKS_CACHE
|
// Clean up old caches, but preserve BOOKS_CACHE
|
||||||
const cacheNames = await caches.keys();
|
const cacheNames = await caches.keys();
|
||||||
|
const currentCaches = [STATIC_CACHE, PAGES_CACHE, API_CACHE, IMAGES_CACHE, BOOKS_CACHE];
|
||||||
|
|
||||||
const cachesToDelete = cacheNames.filter(
|
const cachesToDelete = cacheNames.filter(
|
||||||
(name) =>
|
(name) => name.startsWith("stripstream-") && !currentCaches.includes(name)
|
||||||
name.startsWith("stripstream-") && name !== BOOKS_CACHE && !name.endsWith(`-${VERSION}`)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(cachesToDelete.map((name) => caches.delete(name)));
|
await Promise.all(cachesToDelete.map((name) => caches.delete(name)));
|
||||||
@@ -170,10 +302,179 @@ self.addEventListener("activate", (event) => {
|
|||||||
await self.clients.claim();
|
await self.clients.claim();
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log("[SW] Activated and claimed clients");
|
console.log("[SW] Activated and claimed clients");
|
||||||
|
|
||||||
|
// Notify clients that SW is ready
|
||||||
|
notifyClients({ type: "SW_ACTIVATED", version: VERSION });
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Message Handler - Client Communication
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
self.addEventListener("message", async (event) => {
|
||||||
|
const { type, payload } = event.data || {};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "GET_CACHE_STATS": {
|
||||||
|
try {
|
||||||
|
const [staticSize, pagesSize, apiSize, imagesSize, booksSize] = await Promise.all([
|
||||||
|
getCacheSize(STATIC_CACHE),
|
||||||
|
getCacheSize(PAGES_CACHE),
|
||||||
|
getCacheSize(API_CACHE),
|
||||||
|
getCacheSize(IMAGES_CACHE),
|
||||||
|
getCacheSize(BOOKS_CACHE),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const staticCache = await caches.open(STATIC_CACHE);
|
||||||
|
const pagesCache = await caches.open(PAGES_CACHE);
|
||||||
|
const apiCache = await caches.open(API_CACHE);
|
||||||
|
const imagesCache = await caches.open(IMAGES_CACHE);
|
||||||
|
const booksCache = await caches.open(BOOKS_CACHE);
|
||||||
|
|
||||||
|
const [staticKeys, pagesKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([
|
||||||
|
staticCache.keys(),
|
||||||
|
pagesCache.keys(),
|
||||||
|
apiCache.keys(),
|
||||||
|
imagesCache.keys(),
|
||||||
|
booksCache.keys(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "CACHE_STATS",
|
||||||
|
payload: {
|
||||||
|
static: { size: staticSize, entries: staticKeys.length },
|
||||||
|
pages: { size: pagesSize, entries: pagesKeys.length },
|
||||||
|
api: { size: apiSize, entries: apiKeys.length },
|
||||||
|
images: { size: imagesSize, entries: imagesKeys.length },
|
||||||
|
books: { size: booksSize, entries: booksKeys.length },
|
||||||
|
total: staticSize + pagesSize + apiSize + imagesSize + booksSize,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "CACHE_STATS_ERROR",
|
||||||
|
payload: { error: error.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "CLEAR_CACHE": {
|
||||||
|
try {
|
||||||
|
const cacheType = payload?.cacheType || "all";
|
||||||
|
const cachesToClear = [];
|
||||||
|
|
||||||
|
if (cacheType === "all" || cacheType === "static") {
|
||||||
|
cachesToClear.push(STATIC_CACHE);
|
||||||
|
}
|
||||||
|
if (cacheType === "all" || cacheType === "pages") {
|
||||||
|
cachesToClear.push(PAGES_CACHE);
|
||||||
|
}
|
||||||
|
if (cacheType === "all" || cacheType === "api") {
|
||||||
|
cachesToClear.push(API_CACHE);
|
||||||
|
}
|
||||||
|
if (cacheType === "all" || cacheType === "images") {
|
||||||
|
cachesToClear.push(IMAGES_CACHE);
|
||||||
|
}
|
||||||
|
// Note: BOOKS_CACHE is not cleared by default, only explicitly
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
cachesToClear.map(async (cacheName) => {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
await Promise.all(keys.map((key) => cache.delete(key)));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "CACHE_CLEARED",
|
||||||
|
payload: { caches: cachesToClear },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "CACHE_CLEAR_ERROR",
|
||||||
|
payload: { error: error.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SKIP_WAITING": {
|
||||||
|
self.skipWaiting();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GET_VERSION": {
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "SW_VERSION",
|
||||||
|
payload: { version: VERSION },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GET_CACHE_ENTRIES": {
|
||||||
|
try {
|
||||||
|
const cacheType = payload?.cacheType;
|
||||||
|
let cacheName;
|
||||||
|
|
||||||
|
switch (cacheType) {
|
||||||
|
case "static":
|
||||||
|
cacheName = STATIC_CACHE;
|
||||||
|
break;
|
||||||
|
case "pages":
|
||||||
|
cacheName = PAGES_CACHE;
|
||||||
|
break;
|
||||||
|
case "api":
|
||||||
|
cacheName = API_CACHE;
|
||||||
|
break;
|
||||||
|
case "images":
|
||||||
|
cacheName = IMAGES_CACHE;
|
||||||
|
break;
|
||||||
|
case "books":
|
||||||
|
cacheName = BOOKS_CACHE;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error("Invalid cache type");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
|
||||||
|
const entries = await Promise.all(
|
||||||
|
keys.map(async (request) => {
|
||||||
|
const response = await cache.match(request);
|
||||||
|
let size = 0;
|
||||||
|
if (response) {
|
||||||
|
const blob = await response.clone().blob();
|
||||||
|
size = blob.size;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: request.url,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort by size descending
|
||||||
|
entries.sort((a, b) => b.size - a.size);
|
||||||
|
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "CACHE_ENTRIES",
|
||||||
|
payload: { cacheType, entries },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
event.source.postMessage({
|
||||||
|
type: "CACHE_ENTRIES_ERROR",
|
||||||
|
payload: { error: error.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Fetch Handler - Request Routing
|
// Fetch Handler - Request Routing
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -188,24 +489,68 @@ self.addEventListener("fetch", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route 1: Next.js RSC payloads → Stale-While-Revalidate
|
// Route 1: Book pages (handled by DownloadManager) - Check manual cache only, no SWR
|
||||||
if (isNextRSCRequest(request)) {
|
if (isBookPageRequest(url.href)) {
|
||||||
event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE));
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
// Check the manual books cache
|
||||||
|
const booksCache = await caches.open(BOOKS_CACHE);
|
||||||
|
const cached = await booksCache.match(request);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in cache - fetch from network without caching
|
||||||
|
// Book pages are large and should only be cached via manual download
|
||||||
|
return fetch(request);
|
||||||
|
})()
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route 2: Next.js static resources → Cache-First with ignoreSearch
|
// Route 2: Next.js RSC payloads (client-side navigation) → SWR in PAGES_CACHE
|
||||||
|
if (isNextRSCRequest(request)) {
|
||||||
|
event.respondWith(
|
||||||
|
staleWhileRevalidateStrategy(request, PAGES_CACHE, {
|
||||||
|
notifyOnChange: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route 3: Next.js static resources → Cache-First with ignoreSearch
|
||||||
if (isNextStaticResource(url.href)) {
|
if (isNextStaticResource(url.href)) {
|
||||||
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true }));
|
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route 3: Navigation → Network-First with SPA fallback
|
// Route 4: API requests (JSON) → SWR with notification
|
||||||
if (request.mode === "navigate") {
|
if (isApiRequest(url.href)) {
|
||||||
event.respondWith(navigationStrategy(request));
|
event.respondWith(
|
||||||
|
staleWhileRevalidateStrategy(request, API_CACHE, {
|
||||||
|
notifyOnChange: true,
|
||||||
|
isJson: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route 4: Everything else → Network only (no caching)
|
// Route 5: Image requests (thumbnails, covers) → SWR with cache size management
|
||||||
// This includes: API calls, images, and other dynamic content
|
// Note: Book pages are excluded (Route 1) and only use manual download cache
|
||||||
|
if (isImageRequest(url.href)) {
|
||||||
|
event.respondWith(
|
||||||
|
staleWhileRevalidateStrategy(request, IMAGES_CACHE, {
|
||||||
|
maxEntries: IMAGES_CACHE_MAX_ENTRIES,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route 6: Navigation → SWR (cache first, revalidate in background)
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
event.respondWith(navigationSWRStrategy(request, PAGES_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route 7: Everything else → Network only (no caching)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,40 @@ export async function PATCH(
|
|||||||
{ params }: { params: Promise<{ bookId: string }> }
|
{ params }: { params: Promise<{ bookId: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { page, completed } = await request.json();
|
|
||||||
const bookId: string = (await params).bookId;
|
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") {
|
if (typeof page !== "number") {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -71,14 +71,17 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const locale = cookieStore.get("NEXT_LOCALE")?.value || "fr";
|
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 preferences: UserPreferences = defaultPreferences;
|
||||||
let userIsAdmin = false;
|
let userIsAdmin = false;
|
||||||
|
let libraries: any[] = [];
|
||||||
|
let favorites: any[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [preferencesData, isAdminCheck] = await Promise.allSettled([
|
const [preferencesData, isAdminCheck, librariesData, favoritesData] = await Promise.allSettled([
|
||||||
PreferencesService.getPreferences(),
|
PreferencesService.getPreferences(),
|
||||||
import("@/lib/auth-utils").then((m) => m.isAdmin()),
|
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") {
|
if (preferencesData.status === "fulfilled") {
|
||||||
@@ -88,8 +91,16 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
if (isAdminCheck.status === "fulfilled") {
|
if (isAdminCheck.status === "fulfilled") {
|
||||||
userIsAdmin = isAdminCheck.value;
|
userIsAdmin = isAdminCheck.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (librariesData.status === "fulfilled") {
|
||||||
|
libraries = librariesData.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (favoritesData.status === "fulfilled") {
|
||||||
|
favorites = favoritesData.value;
|
||||||
|
}
|
||||||
} catch (error) {
|
} 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 (
|
return (
|
||||||
@@ -155,7 +166,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<I18nProvider locale={locale}>
|
<I18nProvider locale={locale}>
|
||||||
<PreferencesProvider initialPreferences={preferences}>
|
<PreferencesProvider initialPreferences={preferences}>
|
||||||
<ClientLayout initialLibraries={[]} initialFavorites={[]} userIsAdmin={userIsAdmin}>
|
<ClientLayout initialLibraries={libraries} initialFavorites={favorites} userIsAdmin={userIsAdmin}>
|
||||||
{children}
|
{children}
|
||||||
</ClientLayout>
|
</ClientLayout>
|
||||||
</PreferencesProvider>
|
</PreferencesProvider>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
81
src/app/libraries/[libraryId]/LibraryClientWrapper.tsx
Normal file
81
src/app/libraries/[libraryId]/LibraryClientWrapper.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/app/libraries/[libraryId]/LibraryContent.tsx
Normal file
53
src/app/libraries/[libraryId]/LibraryContent.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
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";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -7,6 +12,8 @@ interface PageProps {
|
|||||||
searchParams: Promise<{ page?: string; unread?: string; search?: string; size?: string }>;
|
searchParams: Promise<{ page?: string; unread?: string; search?: string; size?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
export default async function LibraryPage({ params, searchParams }: PageProps) {
|
export default async function LibraryPage({ params, searchParams }: PageProps) {
|
||||||
const libraryId = (await params).libraryId;
|
const libraryId = (await params).libraryId;
|
||||||
const unread = (await searchParams).unread;
|
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
|
// 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 unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
|
||||||
|
const effectivePageSize = size
|
||||||
|
? parseInt(size)
|
||||||
|
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
return (
|
try {
|
||||||
<ClientLibraryPage
|
const [series, library] = await Promise.all([
|
||||||
currentPage={currentPage}
|
LibraryService.getLibrarySeries(
|
||||||
libraryId={libraryId}
|
libraryId,
|
||||||
preferences={preferences}
|
currentPage - 1,
|
||||||
unreadOnly={unreadOnly}
|
effectivePageSize,
|
||||||
search={search}
|
unreadOnly,
|
||||||
pageSize={size ? parseInt(size) : undefined}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
export default async function HomePage() {
|
||||||
return <ClientHomePage />;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
78
src/app/series/[seriesId]/SeriesClientWrapper.tsx
Normal file
78
src/app/series/[seriesId]/SeriesClientWrapper.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/app/series/[seriesId]/SeriesContent.tsx
Normal file
48
src/app/series/[seriesId]/SeriesContent.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
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";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -7,6 +12,8 @@ interface PageProps {
|
|||||||
searchParams: Promise<{ page?: string; unread?: string; size?: string }>;
|
searchParams: Promise<{ page?: string; unread?: string; size?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
export default async function SeriesPage({ params, searchParams }: PageProps) {
|
export default async function SeriesPage({ params, searchParams }: PageProps) {
|
||||||
const seriesId = (await params).seriesId;
|
const seriesId = (await params).seriesId;
|
||||||
const page = (await searchParams).page;
|
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
|
// 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 unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
|
||||||
|
const effectivePageSize = size ? parseInt(size) : preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
return (
|
try {
|
||||||
<ClientSeriesPage
|
const [books, series] = await Promise.all([
|
||||||
seriesId={seriesId}
|
SeriesService.getSeriesBooks(seriesId, currentPage - 1, effectivePageSize, unreadOnly),
|
||||||
currentPage={currentPage}
|
SeriesService.getSeries(seriesId),
|
||||||
preferences={preferences}
|
]);
|
||||||
unreadOnly={unreadOnly}
|
|
||||||
pageSize={size ? parseInt(size) : undefined}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,14 +311,15 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
|
|||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative w-12 aspect-[2/3] bg-muted/80 backdrop-blur-md rounded overflow-hidden">
|
<div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
|
||||||
<Image
|
<Image
|
||||||
src={`/api/komga/images/books/${book.id}/thumbnail`}
|
src={`/api/komga/images/books/${book.id}/thumbnail`}
|
||||||
alt={t("books.coverAlt", { title: book.metadata?.title })}
|
alt={t("books.coverAlt", { title: book.metadata?.title })}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
fill
|
fill
|
||||||
sizes="48px"
|
sizes="64px"
|
||||||
priority={false}
|
priority={false}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -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} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
70
src/components/home/HomeClientWrapper.tsx
Normal file
70
src/components/home/HomeClientWrapper.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,122 +1,74 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { HeroSection } from "./HeroSection";
|
|
||||||
import { MediaRow } from "./MediaRow";
|
import { MediaRow } from "./MediaRow";
|
||||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||||
import type { HomeData } from "@/types/home";
|
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 {
|
interface HomeContentProps {
|
||||||
data: HomeData;
|
data: HomeData;
|
||||||
refreshHome: () => Promise<{ success: boolean; error?: string }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeContent({ data, refreshHome }: HomeContentProps) {
|
const optimizeSeriesData = (series: KomgaSeries[]) => {
|
||||||
const { t } = useTranslate();
|
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
|
||||||
const [showHero, setShowHero] = useState(false);
|
id,
|
||||||
|
metadata: { title: metadata.title },
|
||||||
|
booksCount,
|
||||||
|
booksReadCount,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// Vérifier si la HeroSection a déjà été affichée
|
const optimizeBookData = (books: KomgaBook[]) => {
|
||||||
useEffect(() => {
|
return books.map(({ id, metadata, readProgress, media }) => ({
|
||||||
const heroShown = localStorage.getItem("heroSectionShown");
|
id,
|
||||||
if (!heroShown && data.ongoing && data.ongoing.length > 0) {
|
metadata: {
|
||||||
setShowHero(true);
|
title: metadata.title,
|
||||||
localStorage.setItem("heroSectionShown", "true");
|
number: metadata.number,
|
||||||
}
|
},
|
||||||
}, [data.ongoing]);
|
readProgress: readProgress || { page: 0 },
|
||||||
|
media,
|
||||||
// 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,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export function HomeContent({ data }: HomeContentProps) {
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8 space-y-12">
|
<div className="space-y-12">
|
||||||
<div className="flex justify-between items-center">
|
{data.ongoing && data.ongoing.length > 0 && (
|
||||||
<h1 className="text-3xl font-bold">{t("home.title")}</h1>
|
<MediaRow
|
||||||
<RefreshButton libraryId="home" refreshLibrary={refreshHome} />
|
titleKey="home.sections.continue_series"
|
||||||
</div>
|
items={optimizeSeriesData(data.ongoing)}
|
||||||
{/* Hero Section - Afficher uniquement si nous avons des séries en cours et si elle n'a jamais été affichée */}
|
iconName="LibraryBig"
|
||||||
{showHero && data.ongoing && data.ongoing.length > 0 && (
|
/>
|
||||||
<HeroSection series={optimizeHeroSeriesData(data.ongoing)} />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sections de contenu */}
|
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
||||||
<div className="space-y-12">
|
<MediaRow
|
||||||
{data.ongoing && data.ongoing.length > 0 && (
|
titleKey="home.sections.continue_reading"
|
||||||
<MediaRow
|
items={optimizeBookData(data.ongoingBooks)}
|
||||||
title={t("home.sections.continue_series")}
|
iconName="BookOpen"
|
||||||
items={optimizeSeriesData(data.ongoing)}
|
/>
|
||||||
icon={LibraryBig}
|
)}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
{data.onDeck && data.onDeck.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
title={t("home.sections.continue_reading")}
|
titleKey="home.sections.up_next"
|
||||||
items={optimizeBookData(data.ongoingBooks)}
|
items={optimizeBookData(data.onDeck)}
|
||||||
icon={BookOpen}
|
iconName="Clock"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.onDeck && data.onDeck.length > 0 && (
|
{data.latestSeries && data.latestSeries.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
title={t("home.sections.up_next")}
|
titleKey="home.sections.latest_series"
|
||||||
items={optimizeBookData(data.onDeck)}
|
items={optimizeSeriesData(data.latestSeries)}
|
||||||
icon={Clock}
|
iconName="Sparkles"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.latestSeries && data.latestSeries.length > 0 && (
|
{data.recentlyRead && data.recentlyRead.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
title={t("home.sections.latest_series")}
|
titleKey="home.sections.recently_added"
|
||||||
items={optimizeSeriesData(data.latestSeries)}
|
items={optimizeBookData(data.recentlyRead)}
|
||||||
icon={Sparkles}
|
iconName="History"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{data.recentlyRead && data.recentlyRead.length > 0 && (
|
|
||||||
<MediaRow
|
|
||||||
title={t("home.sections.recently_added")}
|
|
||||||
items={optimizeBookData(data.recentlyRead)}
|
|
||||||
icon={History}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { SeriesCover } from "../ui/series-cover";
|
|||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { ScrollContainer } from "@/components/ui/scroll-container";
|
import { ScrollContainer } from "@/components/ui/scroll-container";
|
||||||
import { Section } from "@/components/ui/section";
|
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 { Card } from "@/components/ui/card";
|
||||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -38,14 +38,23 @@ interface OptimizedBook extends BaseItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MediaRowProps {
|
interface MediaRowProps {
|
||||||
title: string;
|
titleKey: string;
|
||||||
items: (OptimizedSeries | OptimizedBook)[];
|
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 router = useRouter();
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined;
|
||||||
|
|
||||||
const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
|
const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
|
||||||
const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`;
|
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;
|
if (!items.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={title} icon={icon}>
|
<Section title={t(titleKey)} icon={icon}>
|
||||||
<ScrollContainer
|
<ScrollContainer
|
||||||
showArrows={true}
|
showArrows={true}
|
||||||
scrollAmount={400}
|
scrollAmount={400}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { Sidebar } from "@/components/layout/Sidebar";
|
|||||||
import { InstallPWA } from "../ui/InstallPWA";
|
import { InstallPWA } from "../ui/InstallPWA";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { registerServiceWorker } from "@/lib/registerSW";
|
|
||||||
import { NetworkStatus } from "../ui/NetworkStatus";
|
import { NetworkStatus } from "../ui/NetworkStatus";
|
||||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||||
|
import { ServiceWorkerProvider } from "@/contexts/ServiceWorkerContext";
|
||||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
@@ -135,10 +135,6 @@ export default function ClientLayout({
|
|||||||
};
|
};
|
||||||
}, [isSidebarOpen]);
|
}, [isSidebarOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Enregistrer le service worker
|
|
||||||
registerServiceWorker();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Ne pas afficher le header et la sidebar sur les routes publiques et le reader
|
// Ne pas afficher le header et la sidebar sur les routes publiques et le reader
|
||||||
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/");
|
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/");
|
||||||
@@ -151,37 +147,39 @@ export default function ClientLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
{/* Background fixe pour les images et gradients */}
|
<ServiceWorkerProvider>
|
||||||
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
|
{/* Background fixe pour les images et gradients */}
|
||||||
<div
|
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
|
||||||
className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`}
|
<div
|
||||||
style={
|
className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`}
|
||||||
hasCustomBackground
|
style={
|
||||||
? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` }
|
hasCustomBackground
|
||||||
: undefined
|
? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` }
|
||||||
}
|
: undefined
|
||||||
>
|
}
|
||||||
{!isPublicRoute && (
|
>
|
||||||
<Header
|
{!isPublicRoute && (
|
||||||
onToggleSidebar={handleToggleSidebar}
|
<Header
|
||||||
onRefreshBackground={fetchRandomBook}
|
onToggleSidebar={handleToggleSidebar}
|
||||||
showRefreshBackground={preferences.background.type === "komga-random"}
|
onRefreshBackground={fetchRandomBook}
|
||||||
/>
|
showRefreshBackground={preferences.background.type === "komga-random"}
|
||||||
)}
|
/>
|
||||||
{!isPublicRoute && (
|
)}
|
||||||
<Sidebar
|
{!isPublicRoute && (
|
||||||
isOpen={isSidebarOpen}
|
<Sidebar
|
||||||
onClose={handleCloseSidebar}
|
isOpen={isSidebarOpen}
|
||||||
initialLibraries={initialLibraries}
|
onClose={handleCloseSidebar}
|
||||||
initialFavorites={initialFavorites}
|
initialLibraries={initialLibraries}
|
||||||
userIsAdmin={userIsAdmin}
|
initialFavorites={initialFavorites}
|
||||||
/>
|
userIsAdmin={userIsAdmin}
|
||||||
)}
|
/>
|
||||||
<main className={!isPublicRoute ? "pt-safe" : ""}>{children}</main>
|
)}
|
||||||
<InstallPWA />
|
<main className={!isPublicRoute ? "pt-safe" : ""}>{children}</main>
|
||||||
<Toaster />
|
<InstallPWA />
|
||||||
<NetworkStatus />
|
<Toaster />
|
||||||
</div>
|
<NetworkStatus />
|
||||||
|
</div>
|
||||||
|
</ServiceWorkerProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { cn } from "@/lib/utils";
|
|||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import { getErrorMessage } from "@/utils/errors";
|
import { getErrorMessage } from "@/utils/errors";
|
||||||
@@ -44,37 +43,12 @@ export function Sidebar({
|
|||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { preferences } = usePreferences();
|
|
||||||
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
|
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
|
||||||
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []);
|
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const { toast } = useToast();
|
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 () => {
|
const refreshFavorites = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const favoritesResponse = await fetch("/api/komga/favorites");
|
const favoritesResponse = await fetch("/api/komga/favorites");
|
||||||
@@ -111,13 +85,6 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Object.keys(preferences).length > 0) {
|
|
||||||
refreshLibraries();
|
|
||||||
refreshFavorites();
|
|
||||||
}
|
|
||||||
}, [preferences, refreshLibraries, refreshFavorites]);
|
|
||||||
|
|
||||||
// Mettre à jour les favoris quand ils changent
|
// Mettre à jour les favoris quand ils changent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFavoritesChange = () => {
|
const handleFavoritesChange = () => {
|
||||||
@@ -133,7 +100,10 @@ export function Sidebar({
|
|||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setIsRefreshing(true);
|
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 () => {
|
const handleLogout = async () => {
|
||||||
|
|||||||
461
src/components/settings/CacheSettings.tsx
Normal file
461
src/components/settings/CacheSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { DisplaySettings } from "./DisplaySettings";
|
|||||||
import { KomgaSettings } from "./KomgaSettings";
|
import { KomgaSettings } from "./KomgaSettings";
|
||||||
import { BackgroundSettings } from "./BackgroundSettings";
|
import { BackgroundSettings } from "./BackgroundSettings";
|
||||||
import { AdvancedSettings } from "./AdvancedSettings";
|
import { AdvancedSettings } from "./AdvancedSettings";
|
||||||
|
import { CacheSettings } from "./CacheSettings";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
import { Monitor, Network } from "lucide-react";
|
import { Monitor, Network } from "lucide-react";
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export function ClientSettings({ initialConfig }: ClientSettingsProps) {
|
|||||||
<TabsContent value="connection" className="mt-6 space-y-6">
|
<TabsContent value="connection" className="mt-6 space-y-6">
|
||||||
<KomgaSettings initialConfig={initialConfig} />
|
<KomgaSettings initialConfig={initialConfig} />
|
||||||
<AdvancedSettings />
|
<AdvancedSettings />
|
||||||
|
<CacheSettings />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
12
src/components/ui/collapsible.tsx
Normal file
12
src/components/ui/collapsible.tsx
Normal 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 };
|
||||||
|
|
||||||
47
src/components/ui/scroll-area.tsx
Normal file
47
src/components/ui/scroll-area.tsx
Normal 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 };
|
||||||
|
|
||||||
@@ -16,6 +16,10 @@ interface PreferencesContextType {
|
|||||||
|
|
||||||
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
|
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({
|
export function PreferencesProvider({
|
||||||
children,
|
children,
|
||||||
initialPreferences,
|
initialPreferences,
|
||||||
@@ -29,7 +33,17 @@ export function PreferencesProvider({
|
|||||||
);
|
);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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 () => {
|
const fetchPreferences = useCallback(async () => {
|
||||||
|
// Prevent concurrent fetches
|
||||||
|
if (preferencesFetchInProgress || preferencesFetched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
preferencesFetchInProgress = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/preferences");
|
const response = await fetch("/api/preferences");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -45,25 +59,30 @@ export function PreferencesProvider({
|
|||||||
viewMode: data.displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
|
viewMode: data.displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
preferencesFetched = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération des préférences");
|
logger.error({ err: error }, "Erreur lors de la récupération des préférences");
|
||||||
setPreferences(defaultPreferences);
|
setPreferences(defaultPreferences);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
preferencesFetchInProgress = false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Recharger les préférences quand la session change (connexion/déconnexion)
|
|
||||||
if (status === "authenticated") {
|
if (status === "authenticated") {
|
||||||
// Toujours recharger depuis l'API pour avoir les dernières valeurs
|
// Skip refetch if we already have valid initial preferences from server
|
||||||
// même si on a des initialPreferences (qui peuvent être en cache)
|
if (hasValidInitialPreferences) {
|
||||||
|
preferencesFetched = true; // Mark as fetched since we have server data
|
||||||
|
return;
|
||||||
|
}
|
||||||
fetchPreferences();
|
fetchPreferences();
|
||||||
} else if (status === "unauthenticated") {
|
} 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);
|
setPreferences(defaultPreferences);
|
||||||
|
preferencesFetched = false; // Allow refetch on next login
|
||||||
}
|
}
|
||||||
}, [status, fetchPreferences]);
|
}, [status, fetchPreferences, hasValidInitialPreferences]);
|
||||||
|
|
||||||
const updatePreferences = useCallback(async (newPreferences: Partial<UserPreferences>) => {
|
const updatePreferences = useCallback(async (newPreferences: Partial<UserPreferences>) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
31
src/contexts/RefreshContext.tsx
Normal file
31
src/contexts/RefreshContext.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
368
src/contexts/ServiceWorkerContext.tsx
Normal file
368
src/contexts/ServiceWorkerContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
98
src/hooks/useCacheUpdate.ts
Normal file
98
src/hooks/useCacheUpdate.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -136,6 +136,36 @@
|
|||||||
"title": "Error saving configuration",
|
"title": "Error saving configuration",
|
||||||
"message": "An error occurred while saving the configuration"
|
"message": "An error occurred while saving the configuration"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cache": {
|
||||||
|
"title": "Cache & Storage",
|
||||||
|
"description": "Manage local cache for optimal offline experience.",
|
||||||
|
"notSupported": "Offline cache is not supported by your browser.",
|
||||||
|
"initializing": "Initializing...",
|
||||||
|
"totalStorage": "Total storage",
|
||||||
|
"imagesQuota": "{used}% of images quota used",
|
||||||
|
"static": "Static resources",
|
||||||
|
"staticDesc": "Next.js scripts, styles and assets",
|
||||||
|
"pages": "Visited pages",
|
||||||
|
"pagesDesc": "Home, libraries, series and details",
|
||||||
|
"api": "API data",
|
||||||
|
"apiDesc": "Series, books and library metadata",
|
||||||
|
"images": "Images",
|
||||||
|
"imagesDesc": "Covers and thumbnails (100 MB limit)",
|
||||||
|
"books": "Offline books",
|
||||||
|
"booksDesc": "Manually downloaded pages",
|
||||||
|
"clearAll": "Clear all cache",
|
||||||
|
"cleared": "Cache cleared",
|
||||||
|
"clearedDesc": "Cache has been cleared successfully",
|
||||||
|
"clearError": "Error clearing cache",
|
||||||
|
"unavailable": "Cache statistics unavailable",
|
||||||
|
"reinstall": "Reinstall Service Worker",
|
||||||
|
"reinstallError": "Error reinstalling Service Worker",
|
||||||
|
"entry": "entry",
|
||||||
|
"entries": "entries",
|
||||||
|
"loadingEntries": "Loading entries...",
|
||||||
|
"noEntries": "No entries in this cache",
|
||||||
|
"loadError": "Error loading entries"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
|
|||||||
@@ -136,6 +136,36 @@
|
|||||||
"title": "Erreur lors de la sauvegarde de la configuration",
|
"title": "Erreur lors de la sauvegarde de la configuration",
|
||||||
"message": "Une erreur est survenue lors de la sauvegarde de la configuration"
|
"message": "Une erreur est survenue lors de la sauvegarde de la configuration"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cache": {
|
||||||
|
"title": "Cache et stockage",
|
||||||
|
"description": "Gérez le cache local pour une expérience hors-ligne optimale.",
|
||||||
|
"notSupported": "Le cache hors-ligne n'est pas supporté par votre navigateur.",
|
||||||
|
"initializing": "Initialisation...",
|
||||||
|
"totalStorage": "Stockage total",
|
||||||
|
"imagesQuota": "{used}% du quota images utilisé",
|
||||||
|
"static": "Ressources statiques",
|
||||||
|
"staticDesc": "Scripts, styles et assets Next.js",
|
||||||
|
"pages": "Pages visitées",
|
||||||
|
"pagesDesc": "Home, bibliothèques, séries et détails",
|
||||||
|
"api": "Données API",
|
||||||
|
"apiDesc": "Métadonnées des séries, livres et bibliothèques",
|
||||||
|
"images": "Images",
|
||||||
|
"imagesDesc": "Couvertures et vignettes (limite 100 Mo)",
|
||||||
|
"books": "Livres hors-ligne",
|
||||||
|
"booksDesc": "Pages téléchargées manuellement",
|
||||||
|
"clearAll": "Vider tout le cache",
|
||||||
|
"cleared": "Cache vidé",
|
||||||
|
"clearedDesc": "Le cache a été vidé avec succès",
|
||||||
|
"clearError": "Erreur lors du vidage du cache",
|
||||||
|
"unavailable": "Statistiques du cache non disponibles",
|
||||||
|
"reinstall": "Réinstaller le Service Worker",
|
||||||
|
"reinstallError": "Erreur lors de la réinstallation du Service Worker",
|
||||||
|
"entry": "entrée",
|
||||||
|
"entries": "entrées",
|
||||||
|
"loadingEntries": "Chargement des entrées...",
|
||||||
|
"noEntries": "Aucune entrée dans ce cache",
|
||||||
|
"loadError": "Erreur lors du chargement des entrées"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
|
|||||||
@@ -1,14 +1,137 @@
|
|||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export const registerServiceWorker = async () => {
|
interface ServiceWorkerRegistrationOptions {
|
||||||
|
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||||
|
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the service worker with optional callbacks for update and success events
|
||||||
|
*/
|
||||||
|
export const registerServiceWorker = async (
|
||||||
|
options: ServiceWorkerRegistrationOptions = {}
|
||||||
|
): Promise<ServiceWorkerRegistration | null> => {
|
||||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||||
return;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onUpdate, onSuccess, onError } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.register("/sw.js", {
|
||||||
|
scope: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for updates immediately
|
||||||
|
registration.update().catch(() => {
|
||||||
|
// Ignore update check errors
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle updates
|
||||||
|
registration.addEventListener("updatefound", () => {
|
||||||
|
const newWorker = registration.installing;
|
||||||
|
|
||||||
|
if (!newWorker) return;
|
||||||
|
|
||||||
|
newWorker.addEventListener("statechange", () => {
|
||||||
|
if (newWorker.state === "installed") {
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
// New service worker available
|
||||||
|
logger.info("New service worker available");
|
||||||
|
onUpdate?.(registration);
|
||||||
|
} else {
|
||||||
|
// First install
|
||||||
|
logger.info("Service worker installed for the first time");
|
||||||
|
onSuccess?.(registration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// If already active, call success
|
||||||
|
if (registration.active) {
|
||||||
|
onSuccess?.(registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return registration;
|
||||||
|
} catch (error) {
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
logger.error({ err }, "Service Worker registration failed");
|
||||||
|
onError?.(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister all service workers
|
||||||
|
*/
|
||||||
|
export const unregisterServiceWorker = async (): Promise<boolean> => {
|
||||||
|
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.serviceWorker.register("/sw.js");
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
// logger.info("Service Worker registered with scope:", registration.scope);
|
await Promise.all(registrations.map((reg) => reg.unregister()));
|
||||||
|
logger.info("All service workers unregistered");
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Service Worker registration failed:");
|
logger.error({ err: error }, "Failed to unregister service workers");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to the active service worker
|
||||||
|
*/
|
||||||
|
export const sendMessageToSW = <T = unknown>(message: unknown): Promise<T | null> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!navigator.serviceWorker.controller) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageChannel = new MessageChannel();
|
||||||
|
|
||||||
|
messageChannel.port1.onmessage = (event) => {
|
||||||
|
resolve(event.data as T);
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.serviceWorker.controller.postMessage(message, [messageChannel.port2]);
|
||||||
|
|
||||||
|
// Timeout after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(null);
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the app is running as a PWA (standalone mode)
|
||||||
|
*/
|
||||||
|
export const isPWA = (): boolean => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
window.matchMedia("(display-mode: standalone)").matches ||
|
||||||
|
// iOS Safari
|
||||||
|
("standalone" in window.navigator &&
|
||||||
|
(window.navigator as { standalone?: boolean }).standalone === true)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current service worker registration
|
||||||
|
*/
|
||||||
|
export const getServiceWorkerRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
|
||||||
|
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await navigator.serviceWorker.ready;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { BaseApiService } from "./base-api.service";
|
import { BaseApiService } from "./base-api.service";
|
||||||
import type { KomgaBook, KomgaBookWithPages } from "@/types/komga";
|
import type { KomgaBook, KomgaBookWithPages } from "@/types/komga";
|
||||||
import type { ImageResponse } from "./image.service";
|
|
||||||
import { ImageService } from "./image.service";
|
import { ImageService } from "./image.service";
|
||||||
import { PreferencesService } from "./preferences.service";
|
import { PreferencesService } from "./preferences.service";
|
||||||
import { SeriesService } from "./series.service";
|
|
||||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||||
import { AppError } from "../../utils/errors";
|
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 {
|
export class BookService extends BaseApiService {
|
||||||
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
|
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
|
||||||
try {
|
try {
|
||||||
@@ -111,22 +106,10 @@ export class BookService extends BaseApiService {
|
|||||||
try {
|
try {
|
||||||
// Ajuster le numéro de page pour l'API Komga (zero-based)
|
// Ajuster le numéro de page pour l'API Komga (zero-based)
|
||||||
const adjustedPageNumber = pageNumber - 1;
|
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`
|
`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) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, 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
|
// Récupérer les préférences de l'utilisateur
|
||||||
const preferences = await PreferencesService.getPreferences();
|
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) {
|
if (preferences.showThumbnails) {
|
||||||
const response: ImageResponse = await ImageService.getImage(`books/${bookId}/thumbnail`);
|
return ImageService.streamImage(`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`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sinon, récupérer la première page
|
// Sinon, récupérer la première page (streaming)
|
||||||
return this.getPage(bookId, 1);
|
return this.getPage(bookId, 1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, 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> {
|
static async getPageThumbnail(bookId: string, pageNumber: number): Promise<Response> {
|
||||||
try {
|
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`
|
`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) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, 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");
|
// Use books/list directly with library filter to avoid extra series/list call
|
||||||
|
|
||||||
// Faire une requête légère : prendre une page de séries d'une bibliothèque au hasard
|
|
||||||
const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length);
|
const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length);
|
||||||
const randomLibraryId = libraryIds[randomLibraryIndex];
|
const randomLibraryId = libraryIds[randomLibraryIndex];
|
||||||
|
|
||||||
// Récupérer juste une page de séries (pas toutes)
|
// Random page offset for variety (assuming most libraries have at least 100 books)
|
||||||
const seriesResponse = await LibraryService.getLibrarySeries(randomLibraryId, 0, 20);
|
const randomPage = Math.floor(Math.random() * 5); // Pages 0-4
|
||||||
|
|
||||||
if (seriesResponse.content.length === 0) {
|
const searchBody = {
|
||||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
|
condition: {
|
||||||
message: "Aucune série trouvée dans les bibliothèques sélectionnées",
|
libraryId: {
|
||||||
});
|
operator: "is",
|
||||||
}
|
value: randomLibraryId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Choisir une série au hasard parmi celles récupérées
|
const booksResponse = await this.fetchFromApi<{
|
||||||
const randomSeriesIndex = Math.floor(Math.random() * seriesResponse.content.length);
|
content: KomgaBook[];
|
||||||
const randomSeries = seriesResponse.content[randomSeriesIndex];
|
totalElements: number;
|
||||||
|
}>(
|
||||||
// Récupérer les books de cette série avec pagination
|
{
|
||||||
const booksResponse = await SeriesService.getSeriesBooks(randomSeries.id, 0, 100);
|
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) {
|
if (booksResponse.content.length === 0) {
|
||||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
|
// Fallback to page 0 if random page was empty
|
||||||
message: "Aucun livre trouvé dans la série",
|
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);
|
const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length);
|
||||||
|
|||||||
@@ -2,30 +2,52 @@ import type { KomgaBook } from "@/types/komga";
|
|||||||
|
|
||||||
export class ClientOfflineBookService {
|
export class ClientOfflineBookService {
|
||||||
static setCurrentPage(book: KomgaBook, page: number) {
|
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) {
|
static getCurrentPage(book: KomgaBook) {
|
||||||
const readProgressPage = book.readProgress?.page || 0;
|
const readProgressPage = book.readProgress?.page || 0;
|
||||||
if (typeof localStorage !== "undefined") {
|
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem) {
|
||||||
const cPageLS = localStorage.getItem(`${book.id}-page`) || "0";
|
try {
|
||||||
const currentPage = parseInt(cPageLS);
|
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 readProgressPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentPage;
|
|
||||||
} else {
|
} else {
|
||||||
return readProgressPage;
|
return readProgressPage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeCurrentPage(book: KomgaBook) {
|
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) {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/lib/services/favorites.service.ts
Normal file
39
src/lib/services/favorites.service.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,28 +3,34 @@ import { ERROR_CODES } from "../../constants/errorCodes";
|
|||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export interface ImageResponse {
|
// Cache HTTP navigateur : 30 jours (immutable car les thumbnails ne changent pas)
|
||||||
buffer: Buffer;
|
const IMAGE_CACHE_MAX_AGE = 2592000;
|
||||||
contentType: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ImageService extends BaseApiService {
|
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 {
|
try {
|
||||||
const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" };
|
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 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 {
|
// Stream the response body directly without buffering
|
||||||
buffer,
|
return new Response(response.body, {
|
||||||
contentType,
|
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) {
|
} 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);
|
throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,47 @@ import { ERROR_CODES } from "../../constants/errorCodes";
|
|||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
import type { KomgaLibrary } from "@/types/komga";
|
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 {
|
export class LibraryService extends BaseApiService {
|
||||||
static async getLibraries(): Promise<KomgaLibrary[]> {
|
static async getLibraries(): Promise<KomgaLibrary[]> {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, 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> {
|
static async getLibrary(libraryId: string): Promise<KomgaLibrary> {
|
||||||
try {
|
try {
|
||||||
const libraries = await this.getLibraries();
|
return this.fetchFromApi<KomgaLibrary>({ path: `libraries/${libraryId}` });
|
||||||
const library = libraries.find((library) => library.id === libraryId);
|
|
||||||
if (!library) {
|
|
||||||
throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND, { libraryId });
|
|
||||||
}
|
|
||||||
return library;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { BaseApiService } from "./base-api.service";
|
|||||||
import type { LibraryResponse } from "@/types/library";
|
import type { LibraryResponse } from "@/types/library";
|
||||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||||
import { BookService } from "./book.service";
|
import { BookService } from "./book.service";
|
||||||
import type { ImageResponse } from "./image.service";
|
|
||||||
import { ImageService } from "./image.service";
|
import { ImageService } from "./image.service";
|
||||||
import { PreferencesService } from "./preferences.service";
|
import { PreferencesService } from "./preferences.service";
|
||||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||||
@@ -10,9 +9,6 @@ import { AppError } from "../../utils/errors";
|
|||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
import logger from "@/lib/logger";
|
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 {
|
export class SeriesService extends BaseApiService {
|
||||||
static async getSeries(seriesId: string): Promise<KomgaSeries> {
|
static async getSeries(seriesId: string): Promise<KomgaSeries> {
|
||||||
try {
|
try {
|
||||||
@@ -123,21 +119,14 @@ export class SeriesService extends BaseApiService {
|
|||||||
// Récupérer les préférences de l'utilisateur
|
// Récupérer les préférences de l'utilisateur
|
||||||
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
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) {
|
if (preferences.showThumbnails) {
|
||||||
const response: ImageResponse = await ImageService.getImage(`series/${seriesId}/thumbnail`);
|
return ImageService.streamImage(`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`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sinon, récupérer la première page
|
// Sinon, récupérer la première page (streaming)
|
||||||
const firstBookId = await this.getFirstBook(seriesId);
|
const firstBookId = await this.getFirstBook(seriesId);
|
||||||
const response = await BookService.getPage(firstBookId, 1);
|
return BookService.getPage(firstBookId, 1);
|
||||||
return response;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
import tailwindcssAnimate from "tailwindcss-animate";
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
@@ -77,7 +78,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [tailwindcssAnimate],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -23,5 +23,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "temp"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user