feat: enhance service worker functionality with improved caching strategies, client communication, and service worker registration options
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m42s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m42s
This commit is contained in:
@@ -18,10 +18,12 @@
|
||||
"@prisma/client": "^6.17.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
|
||||
65
pnpm-lock.yaml
generated
65
pnpm-lock.yaml
generated
@@ -21,6 +21,9 @@ importers:
|
||||
'@radix-ui/react-checkbox':
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-collapsible':
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: 1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -33,6 +36,9 @@ importers:
|
||||
'@radix-ui/react-radio-group':
|
||||
specifier: ^1.3.8
|
||||
version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: ^1.2.10
|
||||
version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.1.6
|
||||
version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -680,6 +686,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12':
|
||||
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collection@1.1.7':
|
||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||
peerDependencies:
|
||||
@@ -894,6 +913,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-scroll-area@1.2.10':
|
||||
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-select@2.2.6':
|
||||
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
||||
peerDependencies:
|
||||
@@ -3543,6 +3575,22 @@ snapshots:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
@@ -3765,6 +3813,23 @@ snapshots:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
|
||||
362
public/sw.js
362
public/sw.js
@@ -1,14 +1,20 @@
|
||||
// StripStream Service Worker - Version 1
|
||||
// Architecture: Cache-as-you-go for static resources only
|
||||
// StripStream Service Worker - Version 2
|
||||
// Architecture: SWR (Stale-While-Revalidate) for all resources
|
||||
|
||||
const VERSION = "v1";
|
||||
const VERSION = "v2";
|
||||
const STATIC_CACHE = `stripstream-static-${VERSION}`;
|
||||
const API_CACHE = `stripstream-api-${VERSION}`;
|
||||
const IMAGES_CACHE = `stripstream-images-${VERSION}`;
|
||||
const RSC_CACHE = `stripstream-rsc-${VERSION}`;
|
||||
const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
|
||||
|
||||
const OFFLINE_PAGE = "/offline.html";
|
||||
const PRECACHE_ASSETS = [OFFLINE_PAGE, "/manifest.json"];
|
||||
|
||||
// Cache size limits
|
||||
const IMAGES_CACHE_MAX_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
const IMAGES_CACHE_MAX_ENTRIES = 500;
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions - Request Detection
|
||||
// ============================================================================
|
||||
@@ -22,13 +28,79 @@ function isNextRSCRequest(request) {
|
||||
return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1";
|
||||
}
|
||||
|
||||
function isApiRequest(url) {
|
||||
return url.includes("/api/komga/") && !url.includes("/api/komga/images/");
|
||||
}
|
||||
|
||||
function isImageRequest(url) {
|
||||
return url.includes("/api/komga/images/");
|
||||
}
|
||||
|
||||
function isBookPageRequest(url) {
|
||||
// Book pages: /api/komga/images/books/{id}/pages/{num} or /api/komga/books/{id}/pages/{num}
|
||||
// These are handled by manual download (DownloadManager) - don't cache via SWR
|
||||
return (
|
||||
(url.includes("/api/komga/images/books/") || url.includes("/api/komga/books/")) &&
|
||||
url.includes("/pages/")
|
||||
);
|
||||
}
|
||||
|
||||
function isBooksManualCache(url) {
|
||||
// Check if this is a request that should be handled by the books manual cache
|
||||
return url.includes("/api/komga/images/books/") && url.includes("/pages");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Client Communication
|
||||
// ============================================================================
|
||||
|
||||
async function notifyClients(message) {
|
||||
const clients = await self.clients.matchAll({ type: "window" });
|
||||
clients.forEach((client) => {
|
||||
client.postMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cache Management
|
||||
// ============================================================================
|
||||
|
||||
async function getCacheSize(cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
let totalSize = 0;
|
||||
|
||||
for (const request of keys) {
|
||||
const response = await cache.match(request);
|
||||
if (response) {
|
||||
const blob = await response.clone().blob();
|
||||
totalSize += blob.size;
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
async function trimCache(cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
|
||||
if (keys.length > maxEntries) {
|
||||
// Remove oldest entries (FIFO)
|
||||
const toDelete = keys.slice(0, keys.length - maxEntries);
|
||||
await Promise.all(toDelete.map((key) => cache.delete(key)));
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[SW] Trimmed ${toDelete.length} entries from ${cacheName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cache Strategies
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Cache-First: Serve from cache, fallback to network
|
||||
* Used for: Next.js static resources
|
||||
* Used for: Next.js static resources (immutable)
|
||||
*/
|
||||
async function cacheFirstStrategy(request, cacheName, options = {}) {
|
||||
const cache = await caches.open(cacheName);
|
||||
@@ -56,21 +128,57 @@ async function cacheFirstStrategy(request, cacheName, options = {}) {
|
||||
|
||||
/**
|
||||
* Stale-While-Revalidate: Serve from cache immediately, update in background
|
||||
* Used for: RSC payloads
|
||||
* Used for: API calls, images
|
||||
*/
|
||||
async function staleWhileRevalidateStrategy(request, cacheName) {
|
||||
async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
// Start network request (don't await)
|
||||
const fetchPromise = fetch(request)
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
// Clone response for cache
|
||||
const responseToCache = response.clone();
|
||||
|
||||
// Check if content changed (for notification)
|
||||
if (cached && options.notifyOnChange) {
|
||||
try {
|
||||
const cachedResponse = await cache.match(request);
|
||||
if (cachedResponse) {
|
||||
// For JSON APIs, compare content
|
||||
if (options.isJson) {
|
||||
const oldText = await cachedResponse.text();
|
||||
const newText = await response.clone().text();
|
||||
if (oldText !== newText) {
|
||||
notifyClients({
|
||||
type: "CACHE_UPDATED",
|
||||
url: request.url,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore comparison errors
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
await cache.put(request, responseToCache);
|
||||
|
||||
// Trim cache if needed (for images)
|
||||
if (options.maxEntries) {
|
||||
trimCache(cacheName, options.maxEntries);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => null);
|
||||
.catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[SW] Network request failed:", request.url, error.message);
|
||||
return null;
|
||||
});
|
||||
|
||||
// Return cached version immediately if available
|
||||
if (cached) {
|
||||
@@ -87,14 +195,13 @@ async function staleWhileRevalidateStrategy(request, cacheName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation Strategy: Network-First with SPA fallback
|
||||
* Network-First: Try network, fallback to cache
|
||||
* Used for: Page navigations
|
||||
*/
|
||||
async function navigationStrategy(request) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
async function networkFirstStrategy(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
|
||||
try {
|
||||
// Try network first
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
@@ -155,9 +262,10 @@ self.addEventListener("activate", (event) => {
|
||||
(async () => {
|
||||
// Clean up old caches, but preserve BOOKS_CACHE
|
||||
const cacheNames = await caches.keys();
|
||||
const currentCaches = [STATIC_CACHE, API_CACHE, IMAGES_CACHE, RSC_CACHE, BOOKS_CACHE];
|
||||
|
||||
const cachesToDelete = cacheNames.filter(
|
||||
(name) =>
|
||||
name.startsWith("stripstream-") && name !== BOOKS_CACHE && !name.endsWith(`-${VERSION}`)
|
||||
(name) => name.startsWith("stripstream-") && !currentCaches.includes(name)
|
||||
);
|
||||
|
||||
await Promise.all(cachesToDelete.map((name) => caches.delete(name)));
|
||||
@@ -170,10 +278,172 @@ self.addEventListener("activate", (event) => {
|
||||
await self.clients.claim();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[SW] Activated and claimed clients");
|
||||
|
||||
// Notify clients that SW is ready
|
||||
notifyClients({ type: "SW_ACTIVATED", version: VERSION });
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Message Handler - Client Communication
|
||||
// ============================================================================
|
||||
|
||||
self.addEventListener("message", async (event) => {
|
||||
const { type, payload } = event.data || {};
|
||||
|
||||
switch (type) {
|
||||
case "GET_CACHE_STATS": {
|
||||
try {
|
||||
const [staticSize, apiSize, imagesSize, booksSize] = await Promise.all([
|
||||
getCacheSize(STATIC_CACHE),
|
||||
getCacheSize(API_CACHE),
|
||||
getCacheSize(IMAGES_CACHE),
|
||||
getCacheSize(BOOKS_CACHE),
|
||||
]);
|
||||
|
||||
const staticCache = await caches.open(STATIC_CACHE);
|
||||
const apiCache = await caches.open(API_CACHE);
|
||||
const imagesCache = await caches.open(IMAGES_CACHE);
|
||||
const booksCache = await caches.open(BOOKS_CACHE);
|
||||
|
||||
const [staticKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([
|
||||
staticCache.keys(),
|
||||
apiCache.keys(),
|
||||
imagesCache.keys(),
|
||||
booksCache.keys(),
|
||||
]);
|
||||
|
||||
event.source.postMessage({
|
||||
type: "CACHE_STATS",
|
||||
payload: {
|
||||
static: { size: staticSize, entries: staticKeys.length },
|
||||
api: { size: apiSize, entries: apiKeys.length },
|
||||
images: { size: imagesSize, entries: imagesKeys.length },
|
||||
books: { size: booksSize, entries: booksKeys.length },
|
||||
total: staticSize + apiSize + imagesSize + booksSize,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
event.source.postMessage({
|
||||
type: "CACHE_STATS_ERROR",
|
||||
payload: { error: error.message },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "CLEAR_CACHE": {
|
||||
try {
|
||||
const cacheType = payload?.cacheType || "all";
|
||||
const cachesToClear = [];
|
||||
|
||||
if (cacheType === "all" || cacheType === "static") {
|
||||
cachesToClear.push(STATIC_CACHE);
|
||||
}
|
||||
if (cacheType === "all" || cacheType === "api") {
|
||||
cachesToClear.push(API_CACHE);
|
||||
}
|
||||
if (cacheType === "all" || cacheType === "images") {
|
||||
cachesToClear.push(IMAGES_CACHE);
|
||||
}
|
||||
if (cacheType === "all" || cacheType === "rsc") {
|
||||
cachesToClear.push(RSC_CACHE);
|
||||
}
|
||||
// Note: BOOKS_CACHE is not cleared by default, only explicitly
|
||||
|
||||
await Promise.all(
|
||||
cachesToClear.map(async (cacheName) => {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
await Promise.all(keys.map((key) => cache.delete(key)));
|
||||
})
|
||||
);
|
||||
|
||||
event.source.postMessage({
|
||||
type: "CACHE_CLEARED",
|
||||
payload: { caches: cachesToClear },
|
||||
});
|
||||
} catch (error) {
|
||||
event.source.postMessage({
|
||||
type: "CACHE_CLEAR_ERROR",
|
||||
payload: { error: error.message },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "SKIP_WAITING": {
|
||||
self.skipWaiting();
|
||||
break;
|
||||
}
|
||||
|
||||
case "GET_VERSION": {
|
||||
event.source.postMessage({
|
||||
type: "SW_VERSION",
|
||||
payload: { version: VERSION },
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "GET_CACHE_ENTRIES": {
|
||||
try {
|
||||
const cacheType = payload?.cacheType;
|
||||
let cacheName;
|
||||
|
||||
switch (cacheType) {
|
||||
case "static":
|
||||
cacheName = STATIC_CACHE;
|
||||
break;
|
||||
case "api":
|
||||
cacheName = API_CACHE;
|
||||
break;
|
||||
case "images":
|
||||
cacheName = IMAGES_CACHE;
|
||||
break;
|
||||
case "books":
|
||||
cacheName = BOOKS_CACHE;
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid cache type");
|
||||
}
|
||||
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
|
||||
const entries = await Promise.all(
|
||||
keys.map(async (request) => {
|
||||
const response = await cache.match(request);
|
||||
let size = 0;
|
||||
if (response) {
|
||||
const blob = await response.clone().blob();
|
||||
size = blob.size;
|
||||
}
|
||||
return {
|
||||
url: request.url,
|
||||
size,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Sort by size descending
|
||||
entries.sort((a, b) => b.size - a.size);
|
||||
|
||||
event.source.postMessage({
|
||||
type: "CACHE_ENTRIES",
|
||||
payload: { cacheType, entries },
|
||||
});
|
||||
} catch (error) {
|
||||
event.source.postMessage({
|
||||
type: "CACHE_ENTRIES_ERROR",
|
||||
payload: { error: error.message },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Fetch Handler - Request Routing
|
||||
// ============================================================================
|
||||
@@ -188,24 +458,68 @@ self.addEventListener("fetch", (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Route 1: Next.js RSC payloads → Stale-While-Revalidate
|
||||
if (isNextRSCRequest(request)) {
|
||||
event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE));
|
||||
// Route 1: Book pages (handled by DownloadManager) - Check manual cache only, no SWR
|
||||
if (isBookPageRequest(url.href)) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
// Check the manual books cache
|
||||
const booksCache = await caches.open(BOOKS_CACHE);
|
||||
const cached = await booksCache.match(request);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Not in cache - fetch from network without caching
|
||||
// Book pages are large and should only be cached via manual download
|
||||
return fetch(request);
|
||||
})()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route 2: Next.js static resources → Cache-First with ignoreSearch
|
||||
// Route 2: Next.js RSC payloads → Stale-While-Revalidate
|
||||
if (isNextRSCRequest(request)) {
|
||||
event.respondWith(
|
||||
staleWhileRevalidateStrategy(request, RSC_CACHE, {
|
||||
notifyOnChange: false,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route 3: Next.js static resources → Cache-First with ignoreSearch
|
||||
if (isNextStaticResource(url.href)) {
|
||||
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Route 3: Navigation → Network-First with SPA fallback
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(navigationStrategy(request));
|
||||
// Route 4: API requests (JSON) → SWR with notification
|
||||
if (isApiRequest(url.href)) {
|
||||
event.respondWith(
|
||||
staleWhileRevalidateStrategy(request, API_CACHE, {
|
||||
notifyOnChange: true,
|
||||
isJson: true,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route 4: Everything else → Network only (no caching)
|
||||
// This includes: API calls, images, and other dynamic content
|
||||
// Route 5: Image requests (thumbnails, covers) → SWR with cache size management
|
||||
// Note: Book pages are excluded (Route 1) and only use manual download cache
|
||||
if (isImageRequest(url.href)) {
|
||||
event.respondWith(
|
||||
staleWhileRevalidateStrategy(request, IMAGES_CACHE, {
|
||||
maxEntries: IMAGES_CACHE_MAX_ENTRIES,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route 6: Navigation → Network-First with SPA fallback
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(networkFirstStrategy(request, STATIC_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// Route 7: Everything else → Network only (no caching)
|
||||
});
|
||||
|
||||
@@ -7,9 +7,9 @@ import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { InstallPWA } from "../ui/InstallPWA";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { registerServiceWorker } from "@/lib/registerSW";
|
||||
import { NetworkStatus } from "../ui/NetworkStatus";
|
||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||
import { ServiceWorkerProvider } from "@/contexts/ServiceWorkerContext";
|
||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
@@ -135,10 +135,6 @@ export default function ClientLayout({
|
||||
};
|
||||
}, [isSidebarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
// Enregistrer le service worker
|
||||
registerServiceWorker();
|
||||
}, []);
|
||||
|
||||
// Ne pas afficher le header et la sidebar sur les routes publiques et le reader
|
||||
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/");
|
||||
@@ -151,6 +147,7 @@ export default function ClientLayout({
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ServiceWorkerProvider>
|
||||
{/* Background fixe pour les images et gradients */}
|
||||
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
|
||||
<div
|
||||
@@ -182,6 +179,7 @@ export default function ClientLayout({
|
||||
<Toaster />
|
||||
<NetworkStatus />
|
||||
</div>
|
||||
</ServiceWorkerProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
393
src/components/settings/CacheSettings.tsx
Normal file
393
src/components/settings/CacheSettings.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { useServiceWorker } from "@/contexts/ServiceWorkerContext";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Database,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
HardDrive,
|
||||
Image,
|
||||
FileJson,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
|
||||
interface CacheStats {
|
||||
static: { size: number; entries: number };
|
||||
api: { size: number; entries: number };
|
||||
images: { size: number; entries: number };
|
||||
books: { size: number; entries: number };
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
type CacheType = "static" | "api" | "images" | "books";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function extractPathFromUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.pathname + urlObj.search;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
interface CacheItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
size: number;
|
||||
entries: number;
|
||||
cacheType: CacheType;
|
||||
onClear?: () => void;
|
||||
isClearing?: boolean;
|
||||
description?: string;
|
||||
onLoadEntries: (cacheType: CacheType) => Promise<CacheEntry[] | null>;
|
||||
}
|
||||
|
||||
function CacheItem({
|
||||
icon,
|
||||
label,
|
||||
size,
|
||||
entries,
|
||||
cacheType,
|
||||
onClear,
|
||||
isClearing,
|
||||
description,
|
||||
onLoadEntries,
|
||||
}: CacheItemProps) {
|
||||
const { t } = useTranslate();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cacheEntries, setCacheEntries] = useState<CacheEntry[] | null>(null);
|
||||
const [isLoadingEntries, setIsLoadingEntries] = useState(false);
|
||||
|
||||
const handleToggle = async (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (open && !cacheEntries && !isLoadingEntries) {
|
||||
setIsLoadingEntries(true);
|
||||
const loadedEntries = await onLoadEntries(cacheType);
|
||||
setCacheEntries(loadedEntries);
|
||||
setIsLoadingEntries(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={handleToggle}>
|
||||
<div className="border-b last:border-b-0">
|
||||
<div className="flex items-center justify-between py-3 px-1">
|
||||
<CollapsibleTrigger asChild disabled={entries === 0}>
|
||||
<button
|
||||
className="flex items-center gap-3 flex-1 hover:bg-muted/50 rounded-lg transition-colors text-left py-1 px-2 -ml-2"
|
||||
disabled={entries === 0}
|
||||
>
|
||||
<div className="p-2 rounded-lg bg-muted">{icon}</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{label}</p>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{entries > 0 && (
|
||||
<div className="w-5">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<div className="flex items-center gap-4 ml-2">
|
||||
<div className="text-right">
|
||||
<p className="font-mono text-sm">{formatBytes(size)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{entries} {entries === 1 ? t("settings.cache.entry") : t("settings.cache.entries")}
|
||||
</p>
|
||||
</div>
|
||||
{onClear && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClear}
|
||||
disabled={isClearing || entries === 0}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{isClearing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="pb-3 pl-12 pr-1">
|
||||
{isLoadingEntries ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{t("settings.cache.loadingEntries")}
|
||||
</span>
|
||||
</div>
|
||||
) : cacheEntries ? (
|
||||
<ScrollArea className="h-[200px] rounded-md border">
|
||||
<div className="p-2 space-y-1">
|
||||
{cacheEntries.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between py-1.5 px-2 text-xs hover:bg-muted/50 rounded"
|
||||
>
|
||||
<span className="font-mono truncate flex-1 mr-2" title={entry.url}>
|
||||
{extractPathFromUrl(entry.url)}
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground whitespace-nowrap">
|
||||
{formatBytes(entry.size)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{cacheEntries.length === 0 && (
|
||||
<p className="text-center text-muted-foreground py-4">
|
||||
{t("settings.cache.noEntries")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-4">
|
||||
{t("settings.cache.loadError")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function CacheSettings() {
|
||||
const { t } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const { isSupported, isReady, version, getCacheStats, getCacheEntries, clearCache } =
|
||||
useServiceWorker();
|
||||
|
||||
const [stats, setStats] = useState<CacheStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [clearingCache, setClearingCache] = useState<string | null>(null);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!isReady) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const cacheStats = await getCacheStats();
|
||||
setStats(cacheStats);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isReady, getCacheStats]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
const handleClearCache = async (cacheType: "all" | "static" | "api" | "images") => {
|
||||
setClearingCache(cacheType);
|
||||
try {
|
||||
const success = await clearCache(cacheType);
|
||||
if (success) {
|
||||
toast({
|
||||
title: t("settings.cache.cleared"),
|
||||
description: t("settings.cache.clearedDesc"),
|
||||
});
|
||||
await loadStats();
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settings.error.title"),
|
||||
description: t("settings.cache.clearError"),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setClearingCache(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadEntries = useCallback(
|
||||
async (cacheType: CacheType): Promise<CacheEntry[] | null> => {
|
||||
return getCacheEntries(cacheType);
|
||||
},
|
||||
[getCacheEntries]
|
||||
);
|
||||
|
||||
// Calculer le pourcentage du cache utilisé (basé sur 100MB limite images)
|
||||
const maxCacheSize = 100 * 1024 * 1024; // 100MB
|
||||
const usagePercent = stats ? Math.min((stats.images.size / maxCacheSize) * 100, 100) : 0;
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-lg">{t("settings.cache.title")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{t("settings.cache.notSupported")}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-lg">{t("settings.cache.title")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isReady ? (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
{version || "Active"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<XCircle className="h-3 w-3 text-yellow-500" />
|
||||
{t("settings.cache.initializing")}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={loadStats}
|
||||
disabled={isLoading || !isReady}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>{t("settings.cache.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Barre de progression globale */}
|
||||
{stats && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t("settings.cache.totalStorage")}</span>
|
||||
<span className="font-mono font-medium">{formatBytes(stats.total)}</span>
|
||||
</div>
|
||||
<Progress value={usagePercent} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{t("settings.cache.imagesQuota", { used: Math.round(usagePercent) })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste des caches */}
|
||||
<div className="space-y-1">
|
||||
{stats ? (
|
||||
<>
|
||||
<CacheItem
|
||||
icon={<HardDrive className="h-4 w-4" />}
|
||||
label={t("settings.cache.static")}
|
||||
size={stats.static.size}
|
||||
entries={stats.static.entries}
|
||||
cacheType="static"
|
||||
description={t("settings.cache.staticDesc")}
|
||||
onClear={() => handleClearCache("static")}
|
||||
isClearing={clearingCache === "static"}
|
||||
onLoadEntries={handleLoadEntries}
|
||||
/>
|
||||
<CacheItem
|
||||
icon={<FileJson className="h-4 w-4" />}
|
||||
label={t("settings.cache.api")}
|
||||
size={stats.api.size}
|
||||
entries={stats.api.entries}
|
||||
cacheType="api"
|
||||
description={t("settings.cache.apiDesc")}
|
||||
onClear={() => handleClearCache("api")}
|
||||
isClearing={clearingCache === "api"}
|
||||
onLoadEntries={handleLoadEntries}
|
||||
/>
|
||||
<CacheItem
|
||||
icon={<Image className="h-4 w-4" />}
|
||||
label={t("settings.cache.images")}
|
||||
size={stats.images.size}
|
||||
entries={stats.images.entries}
|
||||
cacheType="images"
|
||||
description={t("settings.cache.imagesDesc")}
|
||||
onClear={() => handleClearCache("images")}
|
||||
isClearing={clearingCache === "images"}
|
||||
onLoadEntries={handleLoadEntries}
|
||||
/>
|
||||
<CacheItem
|
||||
icon={<BookOpen className="h-4 w-4" />}
|
||||
label={t("settings.cache.books")}
|
||||
size={stats.books.size}
|
||||
entries={stats.books.entries}
|
||||
cacheType="books"
|
||||
description={t("settings.cache.booksDesc")}
|
||||
onLoadEntries={handleLoadEntries}
|
||||
/>
|
||||
</>
|
||||
) : isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
{t("settings.cache.unavailable")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bouton vider tout */}
|
||||
{stats && stats.total > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full gap-2"
|
||||
onClick={() => handleClearCache("all")}
|
||||
disabled={clearingCache !== null}
|
||||
>
|
||||
{clearingCache === "all" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.cache.clearAll")}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { DisplaySettings } from "./DisplaySettings";
|
||||
import { KomgaSettings } from "./KomgaSettings";
|
||||
import { BackgroundSettings } from "./BackgroundSettings";
|
||||
import { AdvancedSettings } from "./AdvancedSettings";
|
||||
import { CacheSettings } from "./CacheSettings";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Monitor, Network } from "lucide-react";
|
||||
|
||||
@@ -40,6 +41,7 @@ export function ClientSettings({ initialConfig }: ClientSettingsProps) {
|
||||
<TabsContent value="connection" className="mt-6 space-y-6">
|
||||
<KomgaSettings initialConfig={initialConfig} />
|
||||
<AdvancedSettings />
|
||||
<CacheSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
13
src/components/ui/collapsible.tsx
Normal file
13
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
||||
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 };
|
||||
|
||||
294
src/contexts/ServiceWorkerContext.tsx
Normal file
294
src/contexts/ServiceWorkerContext.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { registerServiceWorker } from "@/lib/registerSW";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
interface CacheStats {
|
||||
static: { size: number; entries: number };
|
||||
api: { size: number; entries: number };
|
||||
images: { size: number; entries: number };
|
||||
books: { size: number; entries: number };
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface CacheUpdate {
|
||||
url: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type CacheType = "all" | "static" | "api" | "images" | "rsc" | "books";
|
||||
|
||||
interface ServiceWorkerContextValue {
|
||||
isSupported: boolean;
|
||||
isReady: boolean;
|
||||
version: string | null;
|
||||
hasNewVersion: boolean;
|
||||
cacheUpdates: CacheUpdate[];
|
||||
clearCacheUpdate: (url: string) => void;
|
||||
clearAllCacheUpdates: () => void;
|
||||
getCacheStats: () => Promise<CacheStats | null>;
|
||||
getCacheEntries: (cacheType: CacheType) => Promise<CacheEntry[] | null>;
|
||||
clearCache: (cacheType?: CacheType) => Promise<boolean>;
|
||||
skipWaiting: () => void;
|
||||
reloadForUpdate: () => void;
|
||||
}
|
||||
|
||||
const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null);
|
||||
|
||||
export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||
const [cacheUpdates, setCacheUpdates] = useState<CacheUpdate[]>([]);
|
||||
const pendingRequests = useRef<Map<string, (value: unknown) => void>>(new Map());
|
||||
const waitingWorkerRef = useRef<ServiceWorker | null>(null);
|
||||
|
||||
// Handle messages from service worker
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
const { type, payload } = event.data || {};
|
||||
|
||||
switch (type) {
|
||||
case "SW_ACTIVATED":
|
||||
setIsReady(true);
|
||||
setVersion(payload?.version || null);
|
||||
break;
|
||||
|
||||
case "SW_VERSION":
|
||||
setVersion(payload?.version || null);
|
||||
break;
|
||||
|
||||
case "CACHE_UPDATED":
|
||||
setCacheUpdates((prev) => {
|
||||
// Avoid duplicates for the same URL within 1 second
|
||||
const existing = prev.find(
|
||||
(u) => u.url === payload.url && Date.now() - u.timestamp < 1000
|
||||
);
|
||||
if (existing) return prev;
|
||||
return [...prev, { url: payload.url, timestamp: payload.timestamp }];
|
||||
});
|
||||
break;
|
||||
|
||||
case "CACHE_STATS":
|
||||
const statsResolver = pendingRequests.current.get("CACHE_STATS");
|
||||
if (statsResolver) {
|
||||
statsResolver(payload);
|
||||
pendingRequests.current.delete("CACHE_STATS");
|
||||
}
|
||||
break;
|
||||
|
||||
case "CACHE_STATS_ERROR":
|
||||
const statsErrorResolver = pendingRequests.current.get("CACHE_STATS");
|
||||
if (statsErrorResolver) {
|
||||
statsErrorResolver(null);
|
||||
pendingRequests.current.delete("CACHE_STATS");
|
||||
}
|
||||
break;
|
||||
|
||||
case "CACHE_CLEARED":
|
||||
const clearResolver = pendingRequests.current.get("CACHE_CLEARED");
|
||||
if (clearResolver) {
|
||||
clearResolver(true);
|
||||
pendingRequests.current.delete("CACHE_CLEARED");
|
||||
}
|
||||
break;
|
||||
|
||||
case "CACHE_CLEAR_ERROR":
|
||||
const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED");
|
||||
if (clearErrorResolver) {
|
||||
clearErrorResolver(false);
|
||||
pendingRequests.current.delete("CACHE_CLEARED");
|
||||
}
|
||||
break;
|
||||
|
||||
case "CACHE_ENTRIES":
|
||||
const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES");
|
||||
if (entriesResolver) {
|
||||
entriesResolver(payload.entries);
|
||||
pendingRequests.current.delete("CACHE_ENTRIES");
|
||||
}
|
||||
break;
|
||||
|
||||
case "CACHE_ENTRIES_ERROR":
|
||||
const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES");
|
||||
if (entriesErrorResolver) {
|
||||
entriesErrorResolver(null);
|
||||
pendingRequests.current.delete("CACHE_ENTRIES");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize service worker communication
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||
setIsSupported(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSupported(true);
|
||||
|
||||
// Register service worker
|
||||
registerServiceWorker({
|
||||
onSuccess: (registration) => {
|
||||
logger.info({ scope: registration.scope }, "Service worker registered");
|
||||
setIsReady(true);
|
||||
if (navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
|
||||
}
|
||||
},
|
||||
onUpdate: (registration) => {
|
||||
logger.info("New service worker version available");
|
||||
setHasNewVersion(true);
|
||||
waitingWorkerRef.current = registration.waiting;
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error({ err: error }, "Service worker registration failed");
|
||||
},
|
||||
});
|
||||
|
||||
// Listen for messages
|
||||
navigator.serviceWorker.addEventListener("message", handleMessage);
|
||||
|
||||
// Check if already controlled
|
||||
if (navigator.serviceWorker.controller) {
|
||||
setIsReady(true);
|
||||
// Request version
|
||||
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
|
||||
}
|
||||
|
||||
// Listen for controller changes
|
||||
const handleControllerChange = () => {
|
||||
setIsReady(true);
|
||||
if (navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange);
|
||||
|
||||
return () => {
|
||||
navigator.serviceWorker.removeEventListener("message", handleMessage);
|
||||
navigator.serviceWorker.removeEventListener("controllerchange", handleControllerChange);
|
||||
};
|
||||
}, [handleMessage]);
|
||||
|
||||
const clearCacheUpdate = useCallback((url: string) => {
|
||||
setCacheUpdates((prev) => prev.filter((u) => u.url !== url));
|
||||
}, []);
|
||||
|
||||
const clearAllCacheUpdates = useCallback(() => {
|
||||
setCacheUpdates([]);
|
||||
}, []);
|
||||
|
||||
const getCacheStats = useCallback(async (): Promise<CacheStats | null> => {
|
||||
if (!navigator.serviceWorker.controller) return null;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
pendingRequests.current.set("CACHE_STATS", resolve as (value: unknown) => void);
|
||||
navigator.serviceWorker.controller!.postMessage({ type: "GET_CACHE_STATS" });
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.current.has("CACHE_STATS")) {
|
||||
pendingRequests.current.delete("CACHE_STATS");
|
||||
resolve(null);
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getCacheEntries = useCallback(
|
||||
async (cacheType: CacheType): Promise<CacheEntry[] | null> => {
|
||||
if (!navigator.serviceWorker.controller) return null;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
pendingRequests.current.set("CACHE_ENTRIES", resolve as (value: unknown) => void);
|
||||
navigator.serviceWorker.controller!.postMessage({
|
||||
type: "GET_CACHE_ENTRIES",
|
||||
payload: { cacheType },
|
||||
});
|
||||
|
||||
// Timeout after 10 seconds (can be slow for large caches)
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.current.has("CACHE_ENTRIES")) {
|
||||
pendingRequests.current.delete("CACHE_ENTRIES");
|
||||
resolve(null);
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const clearCache = useCallback(async (cacheType: CacheType = "all"): Promise<boolean> => {
|
||||
if (!navigator.serviceWorker.controller) return false;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
pendingRequests.current.set("CACHE_CLEARED", resolve as (value: unknown) => void);
|
||||
navigator.serviceWorker.controller!.postMessage({
|
||||
type: "CLEAR_CACHE",
|
||||
payload: { cacheType },
|
||||
});
|
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.current.has("CACHE_CLEARED")) {
|
||||
pendingRequests.current.delete("CACHE_CLEARED");
|
||||
resolve(false);
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const skipWaiting = useCallback(() => {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({ type: "SKIP_WAITING" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadForUpdate = useCallback(() => {
|
||||
if (waitingWorkerRef.current) {
|
||||
waitingWorkerRef.current.postMessage({ type: "SKIP_WAITING" });
|
||||
setHasNewVersion(false);
|
||||
// Reload will happen automatically when new SW takes control
|
||||
window.location.reload();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ServiceWorkerContext.Provider
|
||||
value={{
|
||||
isSupported,
|
||||
isReady,
|
||||
version,
|
||||
hasNewVersion,
|
||||
cacheUpdates,
|
||||
clearCacheUpdate,
|
||||
clearAllCacheUpdates,
|
||||
getCacheStats,
|
||||
getCacheEntries,
|
||||
clearCache,
|
||||
skipWaiting,
|
||||
reloadForUpdate,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ServiceWorkerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useServiceWorker() {
|
||||
const context = useContext(ServiceWorkerContext);
|
||||
if (!context) {
|
||||
throw new Error("useServiceWorker must be used within a ServiceWorkerProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
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,32 @@
|
||||
"title": "Error saving configuration",
|
||||
"message": "An error occurred while saving the configuration"
|
||||
}
|
||||
},
|
||||
"cache": {
|
||||
"title": "Cache & Storage",
|
||||
"description": "Manage local cache for optimal offline experience.",
|
||||
"notSupported": "Offline cache is not supported by your browser.",
|
||||
"initializing": "Initializing...",
|
||||
"totalStorage": "Total storage",
|
||||
"imagesQuota": "{used}% of images quota used",
|
||||
"static": "Static resources",
|
||||
"staticDesc": "Next.js scripts, styles and assets",
|
||||
"api": "API data",
|
||||
"apiDesc": "Series, books and library metadata",
|
||||
"images": "Images",
|
||||
"imagesDesc": "Covers and thumbnails (100 MB limit)",
|
||||
"books": "Offline books",
|
||||
"booksDesc": "Manually downloaded pages",
|
||||
"clearAll": "Clear all cache",
|
||||
"cleared": "Cache cleared",
|
||||
"clearedDesc": "Cache has been cleared successfully",
|
||||
"clearError": "Error clearing cache",
|
||||
"unavailable": "Cache statistics unavailable",
|
||||
"entry": "entry",
|
||||
"entries": "entries",
|
||||
"loadingEntries": "Loading entries...",
|
||||
"noEntries": "No entries in this cache",
|
||||
"loadError": "Error loading entries"
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
|
||||
@@ -136,6 +136,32 @@
|
||||
"title": "Erreur lors de la sauvegarde de la configuration",
|
||||
"message": "Une erreur est survenue lors de la sauvegarde de la configuration"
|
||||
}
|
||||
},
|
||||
"cache": {
|
||||
"title": "Cache et stockage",
|
||||
"description": "Gérez le cache local pour une expérience hors-ligne optimale.",
|
||||
"notSupported": "Le cache hors-ligne n'est pas supporté par votre navigateur.",
|
||||
"initializing": "Initialisation...",
|
||||
"totalStorage": "Stockage total",
|
||||
"imagesQuota": "{used}% du quota images utilisé",
|
||||
"static": "Ressources statiques",
|
||||
"staticDesc": "Scripts, styles et assets Next.js",
|
||||
"api": "Données API",
|
||||
"apiDesc": "Métadonnées des séries, livres et bibliothèques",
|
||||
"images": "Images",
|
||||
"imagesDesc": "Couvertures et vignettes (limite 100 Mo)",
|
||||
"books": "Livres hors-ligne",
|
||||
"booksDesc": "Pages téléchargées manuellement",
|
||||
"clearAll": "Vider tout le cache",
|
||||
"cleared": "Cache vidé",
|
||||
"clearedDesc": "Le cache a été vidé avec succès",
|
||||
"clearError": "Erreur lors du vidage du cache",
|
||||
"unavailable": "Statistiques du cache non disponibles",
|
||||
"entry": "entrée",
|
||||
"entries": "entrées",
|
||||
"loadingEntries": "Chargement des entrées...",
|
||||
"noEntries": "Aucune entrée dans ce cache",
|
||||
"loadError": "Erreur lors du chargement des entrées"
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
|
||||
@@ -1,14 +1,137 @@
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
export const registerServiceWorker = async () => {
|
||||
interface ServiceWorkerRegistrationOptions {
|
||||
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the service worker with optional callbacks for update and success events
|
||||
*/
|
||||
export const registerServiceWorker = async (
|
||||
options: ServiceWorkerRegistrationOptions = {}
|
||||
): Promise<ServiceWorkerRegistration | null> => {
|
||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const { onUpdate, onSuccess, onError } = options;
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register("/sw.js", {
|
||||
scope: "/",
|
||||
});
|
||||
|
||||
// Check for updates immediately
|
||||
registration.update().catch(() => {
|
||||
// Ignore update check errors
|
||||
});
|
||||
|
||||
// Handle updates
|
||||
registration.addEventListener("updatefound", () => {
|
||||
const newWorker = registration.installing;
|
||||
|
||||
if (!newWorker) return;
|
||||
|
||||
newWorker.addEventListener("statechange", () => {
|
||||
if (newWorker.state === "installed") {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// New service worker available
|
||||
logger.info("New service worker available");
|
||||
onUpdate?.(registration);
|
||||
} else {
|
||||
// First install
|
||||
logger.info("Service worker installed for the first time");
|
||||
onSuccess?.(registration);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// If already active, call success
|
||||
if (registration.active) {
|
||||
onSuccess?.(registration);
|
||||
}
|
||||
|
||||
return registration;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
logger.error({ err }, "Service Worker registration failed");
|
||||
onError?.(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Unregister all service workers
|
||||
*/
|
||||
export const unregisterServiceWorker = async (): Promise<boolean> => {
|
||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.serviceWorker.register("/sw.js");
|
||||
// logger.info("Service Worker registered with scope:", registration.scope);
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(registrations.map((reg) => reg.unregister()));
|
||||
logger.info("All service workers unregistered");
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Service Worker registration failed:");
|
||||
logger.error({ err: error }, "Failed to unregister service workers");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a message to the active service worker
|
||||
*/
|
||||
export const sendMessageToSW = <T = unknown>(message: unknown): Promise<T | null> => {
|
||||
return new Promise((resolve) => {
|
||||
if (!navigator.serviceWorker.controller) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const messageChannel = new MessageChannel();
|
||||
|
||||
messageChannel.port1.onmessage = (event) => {
|
||||
resolve(event.data as T);
|
||||
};
|
||||
|
||||
navigator.serviceWorker.controller.postMessage(message, [messageChannel.port2]);
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
resolve(null);
|
||||
}, 5000);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the app is running as a PWA (standalone mode)
|
||||
*/
|
||||
export const isPWA = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
// iOS Safari
|
||||
("standalone" in window.navigator &&
|
||||
(window.navigator as { standalone?: boolean }).standalone === true)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current service worker registration
|
||||
*/
|
||||
export const getServiceWorkerRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
|
||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await navigator.serviceWorker.ready;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user