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

@@ -9,6 +9,8 @@ import { ScrollContainer } from "@/components/ui/scroll-container";
import { Section } from "@/components/ui/section";
import type { LucideIcon } from "lucide-react";
import { Card } from "@/components/ui/card";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { cn } from "@/lib/utils";
interface BaseItem {
id: string;
@@ -76,6 +78,8 @@ interface MediaCardProps {
function MediaCard({ item, onClick }: MediaCardProps) {
const { t } = useTranslate();
const isSeries = "booksCount" in item;
const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id);
const title = isSeries
? item.metadata.title
: item.metadata.title ||
@@ -83,10 +87,21 @@ function MediaCard({ item, onClick }: MediaCardProps) {
? 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 (
<Card
onClick={onClick}
className="flex-shrink-0 w-[200px] relative flex flex-col hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden cursor-pointer"
onClick={handleClick}
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">
{isSeries ? (

View File

@@ -5,6 +5,7 @@ import { BookCover } from "@/components/ui/book-cover";
import { useState, useEffect } from "react";
import { useTranslate } from "@/hooks/useTranslate";
import { cn } from "@/lib/utils";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
interface BookGridProps {
books: KomgaBook[];
@@ -12,6 +13,53 @@ interface BookGridProps {
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) {
const [localBooks, setLocalBooks] = useState(books);
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"
)}
>
{localBooks.map((book) => {
return (
<div
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}
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>
);
})}
{localBooks.map((book) => (
<BookCard
key={book.id}
book={book}
onBookClick={onBookClick}
onSuccess={handleOnSuccess}
isCompact={isCompact}
/>
))}
</div>
);
}

View File

@@ -41,6 +41,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
const [isServiceWorkerClearing, setIsServiceWorkerClearing] = useState(false);
const [serverCacheSize, setServerCacheSize] = useState<CacheSizeInfo | null>(null);
const [swCacheSize, setSwCacheSize] = useState<number | null>(null);
const [apiCacheSize, setApiCacheSize] = useState<number | null>(null);
const [isLoadingCacheSize, setIsLoadingCacheSize] = useState(true);
const [cacheEntries, setCacheEntries] = useState<CacheEntry[]>([]);
const [isLoadingEntries, setIsLoadingEntries] = useState(false);
@@ -116,6 +117,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
if ("caches" in window) {
const cacheNames = await caches.keys();
let totalSize = 0;
let apiSize = 0;
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
@@ -126,11 +128,17 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
if (response) {
const blob = await response.clone().blob();
totalSize += blob.size;
// Calculer la taille du cache API séparément
if (cacheName.includes("api")) {
apiSize += blob.size;
}
}
}
}
setSwCacheSize(totalSize);
setApiCacheSize(apiSize);
}
} catch (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 ? (
<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="text-sm font-medium">{t("settings.cache.size.server")}</div>
{serverCacheSize ? (
@@ -491,6 +499,15 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</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>

View File

@@ -12,6 +12,8 @@ import { BookOfflineButton } from "./book-offline-button";
import { useTranslate } from "@/hooks/useTranslate";
import type { KomgaBook } from "@/types/komga";
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
const getReadingStatusInfo = (book: KomgaBook, t: (key: string, options?: any) => string) => {
@@ -59,6 +61,7 @@ export function BookCover({
overlayVariant = "default",
}: BookCoverProps) {
const { t } = useTranslate();
const { isAccessible } = useBookOfflineStatus(book.id);
const baseUrl = getImageUrl("book", book.id);
const imageUrl = useImageUrl(baseUrl);
@@ -73,6 +76,9 @@ export function BookCover({
const isRead = book.readProgress?.completed || false;
const hasReadProgress = book.readProgress !== null || currentPage > 0;
// Détermine si le livre doit être grisé (non accessible hors ligne)
const isUnavailable = !isAccessible;
const handleMarkAsRead = () => {
onSuccess?.(book, "read");
};
@@ -83,7 +89,7 @@ export function BookCover({
return (
<>
<div className="relative w-full h-full">
<div className={`relative w-full h-full ${isUnavailable ? "opacity-40 grayscale" : ""}`}>
<CoverClient
imageUrl={imageUrl}
alt={alt || t("books.defaultCoverAlt")}
@@ -91,6 +97,15 @@ export function BookCover({
isCompleted={isCompleted}
/>
{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>
{/* Overlay avec les contrôles */}
{(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": {
"title": "Cache size",
"server": "Server cache",
"serviceWorker": "Service worker cache",
"serviceWorker": "SW cache (total)",
"api": "API cache (data)",
"items": "{count} item(s)",
"loading": "Loading...",
"error": "Error loading"
@@ -290,7 +291,8 @@
"unread": "Unread",
"read": "Read",
"readDate": "Read on {{date}}",
"progress": "Page {{current}}/{{total}}"
"progress": "Page {{current}}/{{total}}",
"offline": "Unavailable offline"
},
"display": {
"showing": "Showing books {start} to {end} of {total}",

View File

@@ -139,7 +139,8 @@
"size": {
"title": "Taille du cache",
"server": "Cache serveur",
"serviceWorker": "Cache service worker",
"serviceWorker": "Cache SW (total)",
"api": "Cache API (données)",
"items": "{count} élément(s)",
"loading": "Chargement...",
"error": "Erreur lors du chargement"
@@ -288,7 +289,8 @@
"unread": "Non lu",
"read": "Lu",
"readDate": "Lu le {{date}}",
"progress": "Page {{current}}/{{total}}"
"progress": "Page {{current}}/{{total}}",
"offline": "Indisponible hors ligne"
},
"display": {
"showing": "Affichage des tomes {start} à {end} sur {total}",