feat: enhance service worker caching strategies and implement offline accessibility checks for books
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
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": {
|
||||
"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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user