feat: enhance service worker caching strategies and implement offline accessibility checks for books
This commit is contained in:
408
public/sw.js
408
public/sw.js
@@ -1,230 +1,240 @@
|
|||||||
const CACHE_NAME = "stripstream-cache-v3";
|
// StripStream Service Worker - Version 1
|
||||||
const IMAGES_CACHE_NAME = "stripstream-images-v3";
|
// Architecture: Cache-as-you-go with Stale-While-Revalidate for data
|
||||||
|
|
||||||
|
const VERSION = "v1";
|
||||||
|
const STATIC_CACHE = `stripstream-static-${VERSION}`;
|
||||||
|
const IMAGES_CACHE = `stripstream-images-${VERSION}`;
|
||||||
|
const DATA_CACHE = `stripstream-data-${VERSION}`;
|
||||||
|
const RSC_CACHE = `stripstream-rsc-${VERSION}`;
|
||||||
|
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 STATIC_ASSETS = [
|
// ============================================================================
|
||||||
"/offline.html",
|
// Utility Functions - Request Detection
|
||||||
"/manifest.json",
|
// ============================================================================
|
||||||
"/favicon.svg",
|
|
||||||
"/images/icons/icon-192x192.png",
|
|
||||||
"/images/icons/icon-512x512.png",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Fonction pour obtenir l'URL de base sans les query params
|
function isNextStaticResource(url) {
|
||||||
const getBaseUrl = (url) => {
|
return url.includes("/_next/static/");
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
return urlObj.origin + urlObj.pathname;
|
|
||||||
} catch {
|
|
||||||
return url;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Installation du service worker
|
function isImageRequest(url) {
|
||||||
self.addEventListener("install", (event) => {
|
return url.includes("/api/komga/images/");
|
||||||
event.waitUntil(
|
}
|
||||||
Promise.all([
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)),
|
|
||||||
caches.open(IMAGES_CACHE_NAME),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fonction pour nettoyer les doublons dans un cache
|
function isApiDataRequest(url) {
|
||||||
const cleanDuplicatesInCache = async (cacheName) => {
|
return url.includes("/api/komga/") && !isImageRequest(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNextRSCRequest(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldCacheApiData(url) {
|
||||||
|
// Exclude dynamic/auth endpoints that should always be fresh
|
||||||
|
return !url.includes("/api/auth/session") && !url.includes("/api/preferences");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cache Strategies
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache-First: Serve from cache, fallback to network
|
||||||
|
* Used for: Images, Next.js static resources
|
||||||
|
*/
|
||||||
|
async function cacheFirstStrategy(request, cacheName, options = {}) {
|
||||||
const cache = await caches.open(cacheName);
|
const cache = await caches.open(cacheName);
|
||||||
const keys = await cache.keys();
|
const cached = await cache.match(request, options);
|
||||||
|
|
||||||
// Grouper par URL de base
|
if (cached) {
|
||||||
const grouped = {};
|
return cached;
|
||||||
for (const key of keys) {
|
|
||||||
const baseUrl = getBaseUrl(key.url);
|
|
||||||
if (!grouped[baseUrl]) {
|
|
||||||
grouped[baseUrl] = [];
|
|
||||||
}
|
|
||||||
grouped[baseUrl].push(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pour chaque groupe, garder seulement la version la plus récente
|
|
||||||
const deletePromises = [];
|
|
||||||
for (const baseUrl in grouped) {
|
|
||||||
const versions = grouped[baseUrl];
|
|
||||||
if (versions.length > 1) {
|
|
||||||
// Trier par query params (version) décroissant
|
|
||||||
versions.sort((a, b) => {
|
|
||||||
const aVersion = new URL(a.url).searchParams.get('v') || '0';
|
|
||||||
const bVersion = new URL(b.url).searchParams.get('v') || '0';
|
|
||||||
return Number(bVersion) - Number(aVersion);
|
|
||||||
});
|
|
||||||
// Supprimer toutes sauf la première (plus récente)
|
|
||||||
for (let i = 1; i < versions.length; i++) {
|
|
||||||
deletePromises.push(cache.delete(versions[i]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(deletePromises);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Activation et nettoyage des anciens caches
|
|
||||||
self.addEventListener("activate", (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
Promise.all([
|
|
||||||
// Supprimer les anciens caches
|
|
||||||
caches.keys().then((cacheNames) => {
|
|
||||||
return Promise.all(
|
|
||||||
cacheNames
|
|
||||||
.filter((name) => name !== CACHE_NAME && name !== IMAGES_CACHE_NAME)
|
|
||||||
.map((name) => caches.delete(name))
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
// Nettoyer les doublons dans les caches actuels
|
|
||||||
cleanDuplicatesInCache(CACHE_NAME),
|
|
||||||
cleanDuplicatesInCache(IMAGES_CACHE_NAME),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fonction pour vérifier si c'est une ressource webpack
|
|
||||||
const isWebpackResource = (url) => {
|
|
||||||
return (
|
|
||||||
url.includes("/_next/webpack") ||
|
|
||||||
url.includes("webpack-hmr") ||
|
|
||||||
url.includes("webpack.js") ||
|
|
||||||
url.includes("webpack-runtime") ||
|
|
||||||
url.includes("hot-update")
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fonction pour vérifier si c'est une ressource statique de Next.js
|
|
||||||
const isNextStaticResource = (url) => {
|
|
||||||
return url.includes("/_next/static") && !isWebpackResource(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fonction pour vérifier si c'est une image (couvertures ou pages de livres)
|
|
||||||
const isImageResource = (url) => {
|
|
||||||
return (
|
|
||||||
(url.includes("/api/v1/books/") && (url.includes("/pages") || url.includes("/thumbnail") || url.includes("/cover"))) ||
|
|
||||||
(url.includes("/api/komga/images/") && (url.includes("/series/") || url.includes("/books/")) && url.includes("/thumbnail"))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fonction pour nettoyer les anciennes versions d'un fichier
|
|
||||||
const cleanOldVersions = async (cacheName, request) => {
|
|
||||||
const cache = await caches.open(cacheName);
|
|
||||||
const baseUrl = getBaseUrl(request.url);
|
|
||||||
|
|
||||||
// Récupérer toutes les requêtes en cache
|
|
||||||
const keys = await cache.keys();
|
|
||||||
|
|
||||||
// Supprimer toutes les requêtes qui ont la même URL de base
|
|
||||||
const deletePromises = keys
|
|
||||||
.filter(key => getBaseUrl(key.url) === baseUrl)
|
|
||||||
.map(key => cache.delete(key));
|
|
||||||
|
|
||||||
await Promise.all(deletePromises);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stratégie Cache-First pour les images
|
|
||||||
const imageCacheStrategy = async (request) => {
|
|
||||||
const cache = await caches.open(IMAGES_CACHE_NAME);
|
|
||||||
const cachedResponse = await cache.match(request);
|
|
||||||
|
|
||||||
if (cachedResponse) {
|
|
||||||
return cachedResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(request);
|
const response = await fetch(request);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
await cache.put(request, response.clone());
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
// Network failed - try cache without ignoreSearch as fallback
|
||||||
|
if (options.ignoreSearch) {
|
||||||
|
const fallback = await cache.match(request, { ignoreSearch: false });
|
||||||
|
if (fallback) return fallback;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stale-While-Revalidate: Serve from cache immediately, update in background
|
||||||
|
* Used for: API data, RSC payloads
|
||||||
|
*/
|
||||||
|
async function staleWhileRevalidateStrategy(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
|
||||||
|
// Start network request (don't await)
|
||||||
|
const fetchPromise = fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
// Return cached version immediately if available
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise wait for network
|
||||||
|
const response = await fetchPromise;
|
||||||
|
if (response) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
// Si 404, retourner une réponse vide sans throw (pas d'erreur console)
|
|
||||||
if (response.status === 404) {
|
throw new Error("Network failed and no cache available");
|
||||||
return new Response("", {
|
|
||||||
status: 404,
|
|
||||||
statusText: "Not Found",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// Pour les autres erreurs, throw
|
|
||||||
throw new Error(`Network response error: ${response.status}`);
|
/**
|
||||||
|
* Navigation Strategy: Network-First with SPA fallback
|
||||||
|
* Used for: Page navigations
|
||||||
|
*/
|
||||||
|
async function navigationStrategy(request) {
|
||||||
|
const cache = await caches.open(STATIC_CACHE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try network first
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) {
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Erreurs réseau (offline, timeout, etc.)
|
// Network failed - try cache
|
||||||
console.warn("Image fetch failed:", error);
|
const cached = await cache.match(request);
|
||||||
return new Response("", {
|
if (cached) {
|
||||||
status: 503,
|
return cached;
|
||||||
statusText: "Service Unavailable",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
// Try to serve root page for SPA client-side routing
|
||||||
|
const rootPage = await cache.match("/");
|
||||||
|
if (rootPage) {
|
||||||
|
return rootPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: offline page
|
||||||
|
const offlinePage = await cache.match(OFFLINE_PAGE);
|
||||||
|
if (offlinePage) {
|
||||||
|
return offlinePage;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Service Worker Lifecycle
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[SW] Installing version", VERSION);
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(STATIC_CACHE);
|
||||||
|
try {
|
||||||
|
await cache.addAll(PRECACHE_ASSETS);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[SW] Precached assets");
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[SW] Precache failed:", error);
|
||||||
|
}
|
||||||
|
await self.skipWaiting();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[SW] Activating version", VERSION);
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
// Clean up old caches, but preserve BOOKS_CACHE
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
const cachesToDelete = cacheNames.filter(
|
||||||
|
(name) =>
|
||||||
|
name.startsWith("stripstream-") &&
|
||||||
|
name !== BOOKS_CACHE &&
|
||||||
|
!name.endsWith(`-${VERSION}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(cachesToDelete.map((name) => caches.delete(name)));
|
||||||
|
|
||||||
|
if (cachesToDelete.length > 0) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[SW] Deleted old caches:", cachesToDelete);
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.clients.claim();
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[SW] Activated and claimed clients");
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Fetch Handler - Request Routing
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
// Ignorer les requêtes non GET
|
const { request } = event;
|
||||||
if (event.request.method !== "GET") return;
|
const { method } = request;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
// Ignorer les ressources webpack
|
// Only handle GET requests
|
||||||
if (isWebpackResource(event.request.url)) return;
|
if (method !== "GET") {
|
||||||
|
|
||||||
// Gérer les images avec Cache-First
|
|
||||||
if (isImageResource(event.request.url)) {
|
|
||||||
event.respondWith(imageCacheStrategy(event.request));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pour les ressources statiques de Next.js et les autres requêtes : Network-First
|
// Route 1: Images → Cache-First with ignoreSearch
|
||||||
event.respondWith(
|
if (isImageRequest(url.href)) {
|
||||||
fetch(event.request)
|
event.respondWith(cacheFirstStrategy(request, IMAGES_CACHE, { ignoreSearch: true }));
|
||||||
.then(async (response) => {
|
return;
|
||||||
// Mettre en cache les ressources statiques de Next.js et les pages
|
|
||||||
if (
|
|
||||||
response.ok &&
|
|
||||||
(isNextStaticResource(event.request.url) || event.request.mode === "navigate")
|
|
||||||
) {
|
|
||||||
const responseToCache = response.clone();
|
|
||||||
const cache = await caches.open(CACHE_NAME);
|
|
||||||
|
|
||||||
// Nettoyer les anciennes versions avant de mettre en cache la nouvelle
|
|
||||||
if (isNextStaticResource(event.request.url)) {
|
|
||||||
try {
|
|
||||||
await cleanOldVersions(CACHE_NAME, event.request);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Error cleaning old versions:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mettre en cache la nouvelle version
|
// Route 2: Next.js RSC payloads → Stale-While-Revalidate
|
||||||
try {
|
if (isNextRSCRequest(request)) {
|
||||||
await cache.put(event.request, responseToCache);
|
event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE));
|
||||||
} catch (error) {
|
return;
|
||||||
console.warn("Error caching response:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
.catch(async () => {
|
|
||||||
const cache = await caches.open(CACHE_NAME);
|
|
||||||
const cachedResponse = await cache.match(event.request);
|
|
||||||
|
|
||||||
if (cachedResponse) {
|
|
||||||
return cachedResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si c'est une navigation, renvoyer la page hors ligne
|
// Route 3: API data → Stale-While-Revalidate (if cacheable)
|
||||||
if (event.request.mode === "navigate") {
|
if (isApiDataRequest(url.href) && shouldCacheApiData(url.href)) {
|
||||||
return cache.match(OFFLINE_PAGE);
|
event.respondWith(staleWhileRevalidateStrategy(request, DATA_CACHE));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({ error: "Hors ligne" }), {
|
// Route 4: Next.js static resources → Cache-First with ignoreSearch
|
||||||
status: 503,
|
if (isNextStaticResource(url.href)) {
|
||||||
headers: { "Content-Type": "application/json" },
|
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true }));
|
||||||
});
|
return;
|
||||||
})
|
}
|
||||||
);
|
|
||||||
|
// Route 5: Navigation → Network-First with SPA fallback
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
event.respondWith(navigationStrategy(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route 6: Everything else → Network only (no caching)
|
||||||
|
// This includes: API auth, preferences, and other dynamic content
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ 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 type { LucideIcon } from "lucide-react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface BaseItem {
|
interface BaseItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -76,6 +78,8 @@ interface MediaCardProps {
|
|||||||
function MediaCard({ item, onClick }: MediaCardProps) {
|
function MediaCard({ item, onClick }: MediaCardProps) {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const isSeries = "booksCount" in item;
|
const isSeries = "booksCount" in item;
|
||||||
|
const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id);
|
||||||
|
|
||||||
const title = isSeries
|
const title = isSeries
|
||||||
? item.metadata.title
|
? item.metadata.title
|
||||||
: item.metadata.title ||
|
: item.metadata.title ||
|
||||||
@@ -83,10 +87,21 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
|||||||
? t("navigation.volume", { number: item.metadata.number })
|
? t("navigation.volume", { number: item.metadata.number })
|
||||||
: "");
|
: "");
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
// Pour les séries, toujours autoriser le clic
|
||||||
|
// Pour les livres, vérifier si accessible
|
||||||
|
if (isSeries || isAccessible) {
|
||||||
|
onClick?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
onClick={onClick}
|
onClick={handleClick}
|
||||||
className="flex-shrink-0 w-[200px] relative flex flex-col hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden cursor-pointer"
|
className={cn(
|
||||||
|
"flex-shrink-0 w-[200px] relative flex flex-col hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden",
|
||||||
|
(!isSeries && !isAccessible) ? "cursor-not-allowed" : "cursor-pointer"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative aspect-[2/3] bg-muted">
|
<div className="relative aspect-[2/3] bg-muted">
|
||||||
{isSeries ? (
|
{isSeries ? (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { BookCover } from "@/components/ui/book-cover";
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||||
|
|
||||||
interface BookGridProps {
|
interface BookGridProps {
|
||||||
books: KomgaBook[];
|
books: KomgaBook[];
|
||||||
@@ -12,6 +13,53 @@ interface BookGridProps {
|
|||||||
isCompact?: boolean;
|
isCompact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BookCardProps {
|
||||||
|
book: KomgaBook;
|
||||||
|
onBookClick: (book: KomgaBook) => void;
|
||||||
|
onSuccess: (book: KomgaBook, action: "read" | "unread") => void;
|
||||||
|
isCompact: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookCard({ book, onBookClick, onSuccess, isCompact }: BookCardProps) {
|
||||||
|
const { t } = useTranslate();
|
||||||
|
const { isAccessible } = useBookOfflineStatus(book.id);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
// Ne pas permettre le clic si le livre n'est pas accessible
|
||||||
|
if (!isAccessible) return;
|
||||||
|
onBookClick(book);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative aspect-[2/3] overflow-hidden rounded-lg bg-muted",
|
||||||
|
isCompact ? "hover:scale-105 transition-transform" : "",
|
||||||
|
!isAccessible ? "cursor-not-allowed" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
"w-full h-full hover:opacity-100 transition-all",
|
||||||
|
isAccessible ? "cursor-pointer" : "cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BookCover
|
||||||
|
book={book}
|
||||||
|
alt={t("books.coverAlt", {
|
||||||
|
title: book.metadata.title ||
|
||||||
|
(book.metadata.number
|
||||||
|
? t("navigation.volume", { number: book.metadata.number })
|
||||||
|
: ""),
|
||||||
|
})}
|
||||||
|
onSuccess={(book, action) => onSuccess(book, action)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function BookGrid({ books, onBookClick, isCompact = false }: BookGridProps) {
|
export function BookGrid({ books, onBookClick, isCompact = false }: BookGridProps) {
|
||||||
const [localBooks, setLocalBooks] = useState(books);
|
const [localBooks, setLocalBooks] = useState(books);
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
@@ -69,33 +117,15 @@ export function BookGrid({ books, onBookClick, isCompact = false }: BookGridProp
|
|||||||
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
|
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{localBooks.map((book) => {
|
{localBooks.map((book) => (
|
||||||
return (
|
<BookCard
|
||||||
<div
|
|
||||||
key={book.id}
|
key={book.id}
|
||||||
className={cn(
|
|
||||||
"group relative aspect-[2/3] overflow-hidden rounded-lg bg-muted",
|
|
||||||
isCompact ? "hover:scale-105 transition-transform" : ""
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={() => onBookClick(book)}
|
|
||||||
className="w-full h-full hover:opacity-100 transition-all cursor-pointer"
|
|
||||||
>
|
|
||||||
<BookCover
|
|
||||||
book={book}
|
book={book}
|
||||||
alt={t("books.coverAlt", {
|
onBookClick={onBookClick}
|
||||||
title: book.metadata.title ||
|
onSuccess={handleOnSuccess}
|
||||||
(book.metadata.number
|
isCompact={isCompact}
|
||||||
? t("navigation.volume", { number: book.metadata.number })
|
|
||||||
: ""),
|
|
||||||
})}
|
|
||||||
onSuccess={(book, action) => handleOnSuccess(book, action)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
const [isServiceWorkerClearing, setIsServiceWorkerClearing] = useState(false);
|
const [isServiceWorkerClearing, setIsServiceWorkerClearing] = useState(false);
|
||||||
const [serverCacheSize, setServerCacheSize] = useState<CacheSizeInfo | null>(null);
|
const [serverCacheSize, setServerCacheSize] = useState<CacheSizeInfo | null>(null);
|
||||||
const [swCacheSize, setSwCacheSize] = useState<number | null>(null);
|
const [swCacheSize, setSwCacheSize] = useState<number | null>(null);
|
||||||
|
const [apiCacheSize, setApiCacheSize] = useState<number | null>(null);
|
||||||
const [isLoadingCacheSize, setIsLoadingCacheSize] = useState(true);
|
const [isLoadingCacheSize, setIsLoadingCacheSize] = useState(true);
|
||||||
const [cacheEntries, setCacheEntries] = useState<CacheEntry[]>([]);
|
const [cacheEntries, setCacheEntries] = useState<CacheEntry[]>([]);
|
||||||
const [isLoadingEntries, setIsLoadingEntries] = useState(false);
|
const [isLoadingEntries, setIsLoadingEntries] = useState(false);
|
||||||
@@ -116,6 +117,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
if ("caches" in window) {
|
if ("caches" in window) {
|
||||||
const cacheNames = await caches.keys();
|
const cacheNames = await caches.keys();
|
||||||
let totalSize = 0;
|
let totalSize = 0;
|
||||||
|
let apiSize = 0;
|
||||||
|
|
||||||
for (const cacheName of cacheNames) {
|
for (const cacheName of cacheNames) {
|
||||||
const cache = await caches.open(cacheName);
|
const cache = await caches.open(cacheName);
|
||||||
@@ -126,11 +128,17 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
if (response) {
|
if (response) {
|
||||||
const blob = await response.clone().blob();
|
const blob = await response.clone().blob();
|
||||||
totalSize += blob.size;
|
totalSize += blob.size;
|
||||||
|
|
||||||
|
// Calculer la taille du cache API séparément
|
||||||
|
if (cacheName.includes("api")) {
|
||||||
|
apiSize += blob.size;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSwCacheSize(totalSize);
|
setSwCacheSize(totalSize);
|
||||||
|
setApiCacheSize(apiSize);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur lors de la récupération de la taille du cache:", error);
|
console.error("Erreur lors de la récupération de la taille du cache:", error);
|
||||||
@@ -468,7 +476,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
{isLoadingCacheSize ? (
|
{isLoadingCacheSize ? (
|
||||||
<div className="text-sm text-muted-foreground">{t("settings.cache.size.loading")}</div>
|
<div className="text-sm text-muted-foreground">{t("settings.cache.size.loading")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium">{t("settings.cache.size.server")}</div>
|
<div className="text-sm font-medium">{t("settings.cache.size.server")}</div>
|
||||||
{serverCacheSize ? (
|
{serverCacheSize ? (
|
||||||
@@ -491,6 +499,15 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
|||||||
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div>
|
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium">{t("settings.cache.size.api")}</div>
|
||||||
|
{apiCacheSize !== null ? (
|
||||||
|
<div className="text-sm text-muted-foreground">{formatBytes(apiCacheSize)}</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { BookOfflineButton } from "./book-offline-button";
|
|||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import type { KomgaBook } from "@/types/komga";
|
import type { KomgaBook } from "@/types/komga";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||||
|
import { WifiOff } from "lucide-react";
|
||||||
|
|
||||||
// Fonction utilitaire pour obtenir les informations de statut de lecture
|
// Fonction utilitaire pour obtenir les informations de statut de lecture
|
||||||
const getReadingStatusInfo = (book: KomgaBook, t: (key: string, options?: any) => string) => {
|
const getReadingStatusInfo = (book: KomgaBook, t: (key: string, options?: any) => string) => {
|
||||||
@@ -59,6 +61,7 @@ export function BookCover({
|
|||||||
overlayVariant = "default",
|
overlayVariant = "default",
|
||||||
}: BookCoverProps) {
|
}: BookCoverProps) {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
const { isAccessible } = useBookOfflineStatus(book.id);
|
||||||
|
|
||||||
const baseUrl = getImageUrl("book", book.id);
|
const baseUrl = getImageUrl("book", book.id);
|
||||||
const imageUrl = useImageUrl(baseUrl);
|
const imageUrl = useImageUrl(baseUrl);
|
||||||
@@ -73,6 +76,9 @@ export function BookCover({
|
|||||||
const isRead = book.readProgress?.completed || false;
|
const isRead = book.readProgress?.completed || false;
|
||||||
const hasReadProgress = book.readProgress !== null || currentPage > 0;
|
const hasReadProgress = book.readProgress !== null || currentPage > 0;
|
||||||
|
|
||||||
|
// Détermine si le livre doit être grisé (non accessible hors ligne)
|
||||||
|
const isUnavailable = !isAccessible;
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
onSuccess?.(book, "read");
|
onSuccess?.(book, "read");
|
||||||
};
|
};
|
||||||
@@ -83,7 +89,7 @@ export function BookCover({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative w-full h-full">
|
<div className={`relative w-full h-full ${isUnavailable ? "opacity-40 grayscale" : ""}`}>
|
||||||
<CoverClient
|
<CoverClient
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
alt={alt || t("books.defaultCoverAlt")}
|
alt={alt || t("books.defaultCoverAlt")}
|
||||||
@@ -91,6 +97,15 @@ export function BookCover({
|
|||||||
isCompleted={isCompleted}
|
isCompleted={isCompleted}
|
||||||
/>
|
/>
|
||||||
{showProgress && <ProgressBar progress={currentPage} total={totalPages} type="book" />}
|
{showProgress && <ProgressBar progress={currentPage} total={totalPages} type="book" />}
|
||||||
|
{/* Badge hors ligne si non accessible */}
|
||||||
|
{isUnavailable && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="bg-destructive/90 backdrop-blur-md text-destructive-foreground px-3 py-1.5 rounded-full flex items-center gap-2 text-xs font-medium shadow-lg">
|
||||||
|
<WifiOff className="h-3 w-3" />
|
||||||
|
<span>{t("books.status.offline")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Overlay avec les contrôles */}
|
{/* Overlay avec les contrôles */}
|
||||||
{(showControls || showOverlay) && (
|
{(showControls || showOverlay) && (
|
||||||
|
|||||||
86
src/hooks/useBookOfflineStatus.ts
Normal file
86
src/hooks/useBookOfflineStatus.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useNetworkStatus } from "./useNetworkStatus";
|
||||||
|
|
||||||
|
type BookStatus = "idle" | "downloading" | "available" | "error";
|
||||||
|
|
||||||
|
interface BookDownloadStatus {
|
||||||
|
status: BookStatus;
|
||||||
|
progress: number;
|
||||||
|
timestamp: number;
|
||||||
|
lastDownloadedPage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook pour vérifier si un livre est disponible hors ligne
|
||||||
|
*/
|
||||||
|
export function useBookOfflineStatus(bookId: string) {
|
||||||
|
const [isAvailableOffline, setIsAvailableOffline] = useState(false);
|
||||||
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
|
const isOnline = useNetworkStatus();
|
||||||
|
|
||||||
|
const getStorageKey = useCallback((id: string) => `book-status-${id}`, []);
|
||||||
|
|
||||||
|
const checkOfflineAvailability = useCallback(async () => {
|
||||||
|
if (typeof window === "undefined" || !("caches" in window)) {
|
||||||
|
setIsAvailableOffline(false);
|
||||||
|
setIsChecking(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsChecking(true);
|
||||||
|
try {
|
||||||
|
// Vérifier le localStorage d'abord (plus rapide)
|
||||||
|
const statusStr = localStorage.getItem(getStorageKey(bookId));
|
||||||
|
if (statusStr) {
|
||||||
|
const status: BookDownloadStatus = JSON.parse(statusStr);
|
||||||
|
if (status.status === "available") {
|
||||||
|
setIsAvailableOffline(true);
|
||||||
|
setIsChecking(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon vérifier le cache
|
||||||
|
const cache = await caches.open("stripstream-books");
|
||||||
|
const bookPages = await cache.match(`/api/komga/images/books/${bookId}/pages`);
|
||||||
|
setIsAvailableOffline(!!bookPages);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la vérification du cache:", error);
|
||||||
|
setIsAvailableOffline(false);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
}, [bookId, getStorageKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkOfflineAvailability();
|
||||||
|
|
||||||
|
// Écouter les changements de localStorage
|
||||||
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
|
if (e.key === getStorageKey(bookId)) {
|
||||||
|
checkOfflineAvailability();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("storage", handleStorageChange);
|
||||||
|
|
||||||
|
// Rafraîchir périodiquement (pour les changements dans le même onglet)
|
||||||
|
const interval = setInterval(checkOfflineAvailability, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", handleStorageChange);
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [bookId, checkOfflineAvailability, getStorageKey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAvailableOffline,
|
||||||
|
isChecking,
|
||||||
|
isOnline,
|
||||||
|
// Le livre est "accessible" s'il est disponible hors ligne OU si on est en ligne
|
||||||
|
isAccessible: isAvailableOffline || isOnline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -139,7 +139,8 @@
|
|||||||
"size": {
|
"size": {
|
||||||
"title": "Cache size",
|
"title": "Cache size",
|
||||||
"server": "Server cache",
|
"server": "Server cache",
|
||||||
"serviceWorker": "Service worker cache",
|
"serviceWorker": "SW cache (total)",
|
||||||
|
"api": "API cache (data)",
|
||||||
"items": "{count} item(s)",
|
"items": "{count} item(s)",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"error": "Error loading"
|
"error": "Error loading"
|
||||||
@@ -290,7 +291,8 @@
|
|||||||
"unread": "Unread",
|
"unread": "Unread",
|
||||||
"read": "Read",
|
"read": "Read",
|
||||||
"readDate": "Read on {{date}}",
|
"readDate": "Read on {{date}}",
|
||||||
"progress": "Page {{current}}/{{total}}"
|
"progress": "Page {{current}}/{{total}}",
|
||||||
|
"offline": "Unavailable offline"
|
||||||
},
|
},
|
||||||
"display": {
|
"display": {
|
||||||
"showing": "Showing books {start} to {end} of {total}",
|
"showing": "Showing books {start} to {end} of {total}",
|
||||||
|
|||||||
@@ -139,7 +139,8 @@
|
|||||||
"size": {
|
"size": {
|
||||||
"title": "Taille du cache",
|
"title": "Taille du cache",
|
||||||
"server": "Cache serveur",
|
"server": "Cache serveur",
|
||||||
"serviceWorker": "Cache service worker",
|
"serviceWorker": "Cache SW (total)",
|
||||||
|
"api": "Cache API (données)",
|
||||||
"items": "{count} élément(s)",
|
"items": "{count} élément(s)",
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
"error": "Erreur lors du chargement"
|
"error": "Erreur lors du chargement"
|
||||||
@@ -288,7 +289,8 @@
|
|||||||
"unread": "Non lu",
|
"unread": "Non lu",
|
||||||
"read": "Lu",
|
"read": "Lu",
|
||||||
"readDate": "Lu le {{date}}",
|
"readDate": "Lu le {{date}}",
|
||||||
"progress": "Page {{current}}/{{total}}"
|
"progress": "Page {{current}}/{{total}}",
|
||||||
|
"offline": "Indisponible hors ligne"
|
||||||
},
|
},
|
||||||
"display": {
|
"display": {
|
||||||
"showing": "Affichage des tomes {start} à {end} sur {total}",
|
"showing": "Affichage des tomes {start} à {end} sur {total}",
|
||||||
|
|||||||
Reference in New Issue
Block a user