feat: enhance service worker caching strategies and implement offline accessibility checks for books

This commit is contained in:
Julien Froidefond
2025-10-19 20:23:37 +02:00
parent d3860ce7cc
commit bc3da12fbb
8 changed files with 417 additions and 240 deletions

View File

@@ -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
const grouped = {};
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 if (cached) {
self.addEventListener("activate", (event) => { return cached;
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;
} }
// Si 404, retourner une réponse vide sans throw (pas d'erreur console) return response;
if (response.status === 404) {
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}`);
} catch (error) { } catch (error) {
// Erreurs réseau (offline, timeout, etc.) // Network failed - try cache without ignoreSearch as fallback
console.warn("Image fetch failed:", error); if (options.ignoreSearch) {
return new Response("", { const fallback = await cache.match(request, { ignoreSearch: false });
status: 503, if (fallback) return fallback;
statusText: "Service Unavailable", }
headers: { throw error;
"Content-Type": "text/plain",
},
});
} }
}; }
/**
* 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;
}
throw new Error("Network failed and no cache available");
}
/**
* 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) {
// Network failed - try cache
const cached = await cache.match(request);
if (cached) {
return cached;
}
// 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
try {
await cache.put(event.request, responseToCache);
} catch (error) {
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) { // Route 2: Next.js RSC payloads → Stale-While-Revalidate
return cachedResponse; if (isNextRSCRequest(request)) {
} event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE));
return;
}
// 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
}); });

View File

@@ -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 ? (

View File

@@ -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} book={book}
className={cn( onBookClick={onBookClick}
"group relative aspect-[2/3] overflow-hidden rounded-lg bg-muted", onSuccess={handleOnSuccess}
isCompact ? "hover:scale-105 transition-transform" : "" isCompact={isCompact}
)} />
> ))}
<div
onClick={() => onBookClick(book)}
className="w-full h-full hover:opacity-100 transition-all cursor-pointer"
>
<BookCover
book={book}
alt={t("books.coverAlt", {
title: book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
: ""),
})}
onSuccess={(book, action) => handleOnSuccess(book, action)}
/>
</div>
</div>
);
})}
</div> </div>
); );
} }

View File

@@ -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>

View File

@@ -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) && (

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

View File

@@ -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}",

View File

@@ -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}",