feat: download button and page
This commit is contained in:
7473
package-lock.json
generated
Normal file
7473
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "1.0.5",
|
"@radix-ui/react-dialog": "1.0.5",
|
||||||
"@radix-ui/react-dropdown-menu": "2.0.6",
|
"@radix-ui/react-dropdown-menu": "2.0.6",
|
||||||
|
"@radix-ui/react-progress": "^1.1.2",
|
||||||
"@radix-ui/react-slot": "1.0.2",
|
"@radix-ui/react-slot": "1.0.2",
|
||||||
"@radix-ui/react-toast": "1.1.5",
|
"@radix-ui/react-toast": "1.1.5",
|
||||||
"@types/mongoose": "5.11.97",
|
"@types/mongoose": "5.11.97",
|
||||||
|
|||||||
30
public/sw.js
30
public/sw.js
@@ -1,4 +1,5 @@
|
|||||||
const CACHE_NAME = "stripstream-cache-v1";
|
const CACHE_NAME = "stripstream-cache-v1";
|
||||||
|
const BOOKS_CACHE_NAME = "stripstream-books";
|
||||||
const OFFLINE_PAGE = "/offline.html";
|
const OFFLINE_PAGE = "/offline.html";
|
||||||
|
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
@@ -11,7 +12,12 @@ const STATIC_ASSETS = [
|
|||||||
|
|
||||||
// Installation du service worker
|
// Installation du service worker
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)));
|
event.waitUntil(
|
||||||
|
Promise.all([
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)),
|
||||||
|
caches.open(BOOKS_CACHE_NAME),
|
||||||
|
])
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activation et nettoyage des anciens caches
|
// Activation et nettoyage des anciens caches
|
||||||
@@ -19,7 +25,9 @@ self.addEventListener("activate", (event) => {
|
|||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) => {
|
caches.keys().then((cacheNames) => {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
cacheNames.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name))
|
cacheNames
|
||||||
|
.filter((name) => name !== CACHE_NAME && name !== BOOKS_CACHE_NAME)
|
||||||
|
.map((name) => caches.delete(name))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -41,6 +49,11 @@ const isNextStaticResource = (url) => {
|
|||||||
return url.includes("/_next/static") && !isWebpackResource(url);
|
return url.includes("/_next/static") && !isWebpackResource(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fonction pour vérifier si c'est une ressource de livre
|
||||||
|
const isBookResource = (url) => {
|
||||||
|
return url.includes("/api/v1/books/") && (url.includes("/pages") || url.includes("/thumbnail"));
|
||||||
|
};
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
// Ignorer les requêtes non GET
|
// Ignorer les requêtes non GET
|
||||||
if (event.request.method !== "GET") return;
|
if (event.request.method !== "GET") return;
|
||||||
@@ -48,6 +61,19 @@ self.addEventListener("fetch", (event) => {
|
|||||||
// Ignorer les ressources webpack
|
// Ignorer les ressources webpack
|
||||||
if (isWebpackResource(event.request.url)) return;
|
if (isWebpackResource(event.request.url)) return;
|
||||||
|
|
||||||
|
// Pour les ressources de livre
|
||||||
|
if (isBookResource(event.request.url)) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then((response) => {
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return fetch(event.request);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Pour les ressources statiques de Next.js et les autres requêtes
|
// Pour les ressources statiques de Next.js et les autres requêtes
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(event.request)
|
fetch(event.request)
|
||||||
|
|||||||
11
src/app/downloads/page.tsx
Normal file
11
src/app/downloads/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { PageHeader } from "@/components/layout/PageHeader";
|
||||||
|
import { DownloadManager } from "@/components/downloads/DownloadManager";
|
||||||
|
|
||||||
|
export default function DownloadsPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Téléchargements" description="Gérez vos livres disponibles hors ligne" />
|
||||||
|
<DownloadManager />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
348
src/components/downloads/DownloadManager.tsx
Normal file
348
src/components/downloads/DownloadManager.tsx
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { KomgaBook } from "@/types/komga";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Download, Loader2, Check, Trash2, AlertCircle } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type BookStatus = "idle" | "downloading" | "available" | "error";
|
||||||
|
|
||||||
|
interface BookDownloadStatus {
|
||||||
|
status: BookStatus;
|
||||||
|
progress: number;
|
||||||
|
timestamp: number;
|
||||||
|
lastDownloadedPage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadedBook {
|
||||||
|
book: KomgaBook;
|
||||||
|
status: BookDownloadStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadManager() {
|
||||||
|
const [downloadedBooks, setDownloadedBooks] = useState<DownloadedBook[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const getStorageKey = (bookId: string) => `book-status-${bookId}`;
|
||||||
|
|
||||||
|
const loadDownloadedBooks = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Récupère tous les livres du localStorage
|
||||||
|
const books: DownloadedBook[] = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key?.startsWith("book-status-")) {
|
||||||
|
const bookId = key.replace("book-status-", "");
|
||||||
|
const status = JSON.parse(localStorage.getItem(key) || "");
|
||||||
|
if (status.status !== "idle") {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/komga/books/${bookId}`);
|
||||||
|
if (!response.ok) throw new Error("Livre non trouvé");
|
||||||
|
const bookData = await response.json();
|
||||||
|
books.push({
|
||||||
|
book: bookData.book,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur lors de la récupération du livre ${bookId}:`, error);
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDownloadedBooks(books);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors du chargement des livres:", error);
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Impossible de charger les livres téléchargés",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBookStatuses = () => {
|
||||||
|
setDownloadedBooks((prevBooks) => {
|
||||||
|
return prevBooks.map((downloadedBook) => {
|
||||||
|
const status = JSON.parse(
|
||||||
|
localStorage.getItem(getStorageKey(downloadedBook.book.id)) || "{}"
|
||||||
|
);
|
||||||
|
if (!status || status.status === "idle") {
|
||||||
|
return downloadedBook;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...downloadedBook,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDownloadedBooks();
|
||||||
|
|
||||||
|
// Écoute les changements de statut des livres
|
||||||
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
|
if (e.key?.startsWith("book-status-")) {
|
||||||
|
updateBookStatuses();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Écoute les changements dans d'autres onglets
|
||||||
|
window.addEventListener("storage", handleStorageChange);
|
||||||
|
|
||||||
|
// Écoute les changements dans l'onglet courant
|
||||||
|
const interval = setInterval(updateBookStatuses, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", handleStorageChange);
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteBook = async (book: KomgaBook) => {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open("stripstream-books");
|
||||||
|
await cache.delete(`/api/komga/books/${book.id}/pages`);
|
||||||
|
for (let i = 1; i <= book.media.pagesCount; i++) {
|
||||||
|
await cache.delete(`/api/komga/books/${book.id}/pages/${i}`);
|
||||||
|
}
|
||||||
|
localStorage.removeItem(getStorageKey(book.id));
|
||||||
|
setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id));
|
||||||
|
toast({
|
||||||
|
title: "Livre supprimé",
|
||||||
|
description: "Le livre n'est plus disponible hors ligne",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la suppression du livre:", error);
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Une erreur est survenue lors de la suppression",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetryDownload = async (book: KomgaBook) => {
|
||||||
|
// Réinitialise le statut et laisse le composant BookOfflineButton gérer le téléchargement
|
||||||
|
localStorage.removeItem(getStorageKey(book.id));
|
||||||
|
setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id));
|
||||||
|
toast({
|
||||||
|
title: "Téléchargement relancé",
|
||||||
|
description: "Le téléchargement va reprendre depuis le début",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="all" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">Tous ({downloadedBooks.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="downloading">
|
||||||
|
En cours ({downloadedBooks.filter((b) => b.status.status === "downloading").length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="available">
|
||||||
|
Disponibles ({downloadedBooks.filter((b) => b.status.status === "available").length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="error">
|
||||||
|
Erreurs ({downloadedBooks.filter((b) => b.status.status === "error").length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="all" className="space-y-4">
|
||||||
|
{downloadedBooks.map(({ book, status }) => (
|
||||||
|
<BookDownloadCard
|
||||||
|
key={book.id}
|
||||||
|
book={book}
|
||||||
|
status={status}
|
||||||
|
onDelete={() => handleDeleteBook(book)}
|
||||||
|
onRetry={() => handleRetryDownload(book)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{downloadedBooks.length === 0 && (
|
||||||
|
<p className="text-center text-muted-foreground p-8">Aucun livre téléchargé</p>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="downloading" className="space-y-4">
|
||||||
|
{downloadedBooks
|
||||||
|
.filter((b) => b.status.status === "downloading")
|
||||||
|
.map(({ book, status }) => (
|
||||||
|
<BookDownloadCard
|
||||||
|
key={book.id}
|
||||||
|
book={book}
|
||||||
|
status={status}
|
||||||
|
onDelete={() => handleDeleteBook(book)}
|
||||||
|
onRetry={() => handleRetryDownload(book)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{downloadedBooks.filter((b) => b.status.status === "downloading").length === 0 && (
|
||||||
|
<p className="text-center text-muted-foreground p-8">Aucun téléchargement en cours</p>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="available" className="space-y-4">
|
||||||
|
{downloadedBooks
|
||||||
|
.filter((b) => b.status.status === "available")
|
||||||
|
.map(({ book, status }) => (
|
||||||
|
<BookDownloadCard
|
||||||
|
key={book.id}
|
||||||
|
book={book}
|
||||||
|
status={status}
|
||||||
|
onDelete={() => handleDeleteBook(book)}
|
||||||
|
onRetry={() => handleRetryDownload(book)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{downloadedBooks.filter((b) => b.status.status === "available").length === 0 && (
|
||||||
|
<p className="text-center text-muted-foreground p-8">Aucun livre disponible hors ligne</p>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="error" className="space-y-4">
|
||||||
|
{downloadedBooks
|
||||||
|
.filter((b) => b.status.status === "error")
|
||||||
|
.map(({ book, status }) => (
|
||||||
|
<BookDownloadCard
|
||||||
|
key={book.id}
|
||||||
|
book={book}
|
||||||
|
status={status}
|
||||||
|
onDelete={() => handleDeleteBook(book)}
|
||||||
|
onRetry={() => handleRetryDownload(book)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{downloadedBooks.filter((b) => b.status.status === "error").length === 0 && (
|
||||||
|
<p className="text-center text-muted-foreground p-8">Aucune erreur</p>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookDownloadCardProps {
|
||||||
|
book: KomgaBook;
|
||||||
|
status: BookDownloadStatus;
|
||||||
|
onDelete: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardProps) {
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
const mb = bytes / (1024 * 1024);
|
||||||
|
return `${mb.toFixed(1)} Mo`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: BookStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case "downloading":
|
||||||
|
return <Loader2 className="h-4 w-4 animate-spin" />;
|
||||||
|
case "available":
|
||||||
|
return <Check className="h-4 w-4" />;
|
||||||
|
case "error":
|
||||||
|
return <AlertCircle className="h-4 w-4 text-destructive" />;
|
||||||
|
default:
|
||||||
|
return <Download className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: BookStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case "downloading":
|
||||||
|
return "En cours de téléchargement";
|
||||||
|
case "available":
|
||||||
|
return "Disponible hors ligne";
|
||||||
|
case "error":
|
||||||
|
return "Erreur de téléchargement";
|
||||||
|
default:
|
||||||
|
return "Non téléchargé";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative w-12 aspect-[2/3] bg-muted rounded overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={`/api/komga/images/books/${book.id}/thumbnail`}
|
||||||
|
alt={`Couverture de ${book.metadata?.title}`}
|
||||||
|
className="object-cover"
|
||||||
|
fill
|
||||||
|
sizes="48px"
|
||||||
|
priority={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Link
|
||||||
|
href={`/books/${book.id}`}
|
||||||
|
className="hover:underline hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium truncate">
|
||||||
|
{book.metadata?.title || `Tome ${book.metadata?.number}`}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{formatSize(book.sizeBytes)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{status.status === "downloading"
|
||||||
|
? `${Math.floor((status.progress * book.media.pagesCount) / 100)}/${
|
||||||
|
book.media.pagesCount
|
||||||
|
} pages`
|
||||||
|
: `${book.media.pagesCount} pages`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
{getStatusIcon(status.status)}
|
||||||
|
<span>{getStatusText(status.status)}</span>
|
||||||
|
</div>
|
||||||
|
{status.status === "downloading" && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Progress value={status.progress} className="flex-1" />
|
||||||
|
<span className="text-xs text-muted-foreground w-12 text-right">
|
||||||
|
{Math.round(status.progress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{status.status === "error" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onRetry}
|
||||||
|
title="Réessayer"
|
||||||
|
className="h-8 w-8 p-0 rounded-br-lg rounded-tl-lg"
|
||||||
|
>
|
||||||
|
<Download className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onDelete}
|
||||||
|
title="Supprimer"
|
||||||
|
className="h-8 w-8 p-0 rounded-br-lg rounded-tl-lg"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/components/layout/PageHeader.tsx
Normal file
13
src/components/layout/PageHeader.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ title, description }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 mb-8">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
|
||||||
|
{description && <p className="text-lg text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Home, Library, Settings, LogOut, RefreshCw, Star } from "lucide-react";
|
import { Home, Library, Settings, LogOut, RefreshCw, Star, Download } from "lucide-react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { authService } from "@/lib/services/auth.service";
|
import { authService } from "@/lib/services/auth.service";
|
||||||
@@ -117,12 +117,17 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
|||||||
[pathname, router, onClose]
|
[pathname, router, onClose]
|
||||||
);
|
);
|
||||||
|
|
||||||
const navigation = [
|
const mainNavItems = [
|
||||||
{
|
{
|
||||||
name: "Accueil",
|
title: "Accueil",
|
||||||
href: "/",
|
href: "/",
|
||||||
icon: Home,
|
icon: Home,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Téléchargements",
|
||||||
|
href: "/downloads",
|
||||||
|
icon: Download,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -140,7 +145,7 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
|||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">Navigation</h2>
|
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">Navigation</h2>
|
||||||
{navigation.map((item) => (
|
{mainNavItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.href}
|
key={item.href}
|
||||||
onClick={() => handleLinkClick(item.href)}
|
onClick={() => handleLinkClick(item.href)}
|
||||||
@@ -150,7 +155,7 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="mr-2 h-4 w-4" />
|
<item.icon className="mr-2 h-4 w-4" />
|
||||||
{item.name}
|
{item.title}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { KomgaBook } from "@/types/komga";
|
|||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import { Cover } from "@/components/ui/cover";
|
import { Cover } from "@/components/ui/cover";
|
||||||
import { MarkAsReadButton } from "@/components/ui/mark-as-read-button";
|
import { MarkAsReadButton } from "@/components/ui/mark-as-read-button";
|
||||||
|
import { BookOfflineButton } from "@/components/ui/book-offline-button";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
interface BookGridProps {
|
interface BookGridProps {
|
||||||
@@ -102,9 +103,9 @@ export function BookGrid({ books, onBookClick }: BookGridProps) {
|
|||||||
|
|
||||||
{/* Overlay avec les contrôles */}
|
{/* Overlay avec les contrôles */}
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
{/* Bouton Marquer comme lu en haut à droite avec un petit décalage */}
|
{/* Boutons en haut à droite avec un petit décalage */}
|
||||||
{!isRead && (
|
<div className="absolute top-2 right-2 pointer-events-auto flex gap-1">
|
||||||
<div className="absolute top-2 right-2 pointer-events-auto">
|
{!isRead && (
|
||||||
<MarkAsReadButton
|
<MarkAsReadButton
|
||||||
bookId={book.id}
|
bookId={book.id}
|
||||||
pagesCount={book.media.pagesCount}
|
pagesCount={book.media.pagesCount}
|
||||||
@@ -112,8 +113,12 @@ export function BookGrid({ books, onBookClick }: BookGridProps) {
|
|||||||
onSuccess={() => handleMarkAsRead(book.id)}
|
onSuccess={() => handleMarkAsRead(book.id)}
|
||||||
className="bg-white/90 hover:bg-white text-black shadow-sm"
|
className="bg-white/90 hover:bg-white text-black shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
<BookOfflineButton
|
||||||
|
book={book}
|
||||||
|
className="bg-white/90 hover:bg-white text-black shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Informations en bas - visible au survol uniquement */}
|
{/* Informations en bas - visible au survol uniquement */}
|
||||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 space-y-2 translate-y-full group-hover:translate-y-0 transition-transform duration-200">
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 space-y-2 translate-y-full group-hover:translate-y-0 transition-transform duration-200">
|
||||||
|
|||||||
256
src/components/ui/book-offline-button.tsx
Normal file
256
src/components/ui/book-offline-button.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Download, Check, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "./button";
|
||||||
|
import { useToast } from "./use-toast";
|
||||||
|
import { KomgaBook } from "@/types/komga";
|
||||||
|
|
||||||
|
interface BookOfflineButtonProps {
|
||||||
|
book: KomgaBook;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statuts possibles pour un livre
|
||||||
|
type BookStatus = "idle" | "downloading" | "available" | "error";
|
||||||
|
|
||||||
|
interface BookDownloadStatus {
|
||||||
|
status: BookStatus;
|
||||||
|
progress: number;
|
||||||
|
timestamp: number;
|
||||||
|
lastDownloadedPage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
|
||||||
|
const [isAvailableOffline, setIsAvailableOffline] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const getStorageKey = (bookId: string) => `book-status-${bookId}`;
|
||||||
|
|
||||||
|
const getBookStatus = (bookId: string): BookDownloadStatus => {
|
||||||
|
try {
|
||||||
|
const status = localStorage.getItem(getStorageKey(bookId));
|
||||||
|
return status ? JSON.parse(status) : { status: "idle", progress: 0, timestamp: 0 };
|
||||||
|
} catch {
|
||||||
|
return { status: "idle", progress: 0, timestamp: 0 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBookStatus = (bookId: string, status: BookDownloadStatus) => {
|
||||||
|
localStorage.setItem(getStorageKey(bookId), JSON.stringify(status));
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadBook = async (startFromPage: number = 1) => {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open("stripstream-books");
|
||||||
|
|
||||||
|
// Marque le début du téléchargement
|
||||||
|
setBookStatus(book.id, {
|
||||||
|
status: "downloading",
|
||||||
|
progress: ((startFromPage - 1) / book.media.pagesCount) * 100,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
lastDownloadedPage: startFromPage - 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ajoute le livre au cache si on commence depuis le début
|
||||||
|
if (startFromPage === 1) {
|
||||||
|
const pagesResponse = await fetch(`/api/komga/books/${book.id}/pages`);
|
||||||
|
await cache.put(`/api/komga/books/${book.id}/pages`, pagesResponse.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache chaque page
|
||||||
|
let failedPages = 0;
|
||||||
|
for (let i = startFromPage; i <= book.media.pagesCount; i++) {
|
||||||
|
try {
|
||||||
|
const pageResponse = await fetch(`/api/komga/books/${book.id}/pages/${i}`);
|
||||||
|
if (!pageResponse.ok) {
|
||||||
|
failedPages++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await cache.put(`/api/komga/books/${book.id}/pages/${i}`, pageResponse.clone());
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur lors du téléchargement de la page ${i}:`, error);
|
||||||
|
failedPages++;
|
||||||
|
}
|
||||||
|
const progress = (i / book.media.pagesCount) * 100;
|
||||||
|
setDownloadProgress(progress);
|
||||||
|
setBookStatus(book.id, {
|
||||||
|
status: "downloading",
|
||||||
|
progress,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
lastDownloadedPage: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedPages > 0) {
|
||||||
|
// Si des pages ont échoué, on supprime tout le cache pour ce livre
|
||||||
|
await cache.delete(`/api/komga/books/${book.id}/pages`);
|
||||||
|
for (let i = 1; i <= book.media.pagesCount; i++) {
|
||||||
|
await cache.delete(`/api/komga/books/${book.id}/pages/${i}`);
|
||||||
|
}
|
||||||
|
setIsAvailableOffline(false);
|
||||||
|
setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() });
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: `${failedPages} page(s) n'ont pas pu être téléchargées. Le livre ne sera pas disponible hors ligne.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setIsAvailableOffline(true);
|
||||||
|
setBookStatus(book.id, { status: "available", progress: 100, timestamp: Date.now() });
|
||||||
|
toast({
|
||||||
|
title: "Livre téléchargé",
|
||||||
|
description: "Le livre est maintenant disponible hors ligne",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors du téléchargement:", error);
|
||||||
|
setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() });
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Une erreur est survenue lors du téléchargement",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vérifie si le livre est déjà disponible hors ligne
|
||||||
|
useEffect(() => {
|
||||||
|
const checkStatus = async () => {
|
||||||
|
const storedStatus = getBookStatus(book.id);
|
||||||
|
|
||||||
|
// Si le livre est marqué comme en cours de téléchargement
|
||||||
|
if (storedStatus.status === "downloading") {
|
||||||
|
// Si le téléchargement a commencé il y a plus de 5 minutes, on considère qu'il a échoué
|
||||||
|
if (Date.now() - storedStatus.timestamp > 5 * 60 * 1000) {
|
||||||
|
setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() });
|
||||||
|
setIsLoading(false);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
} else {
|
||||||
|
// On reprend le téléchargement là où il s'était arrêté
|
||||||
|
setIsLoading(true);
|
||||||
|
setDownloadProgress(storedStatus.progress);
|
||||||
|
const startFromPage = (storedStatus.lastDownloadedPage || 0) + 1;
|
||||||
|
downloadBook(startFromPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkOfflineAvailability();
|
||||||
|
};
|
||||||
|
|
||||||
|
checkStatus();
|
||||||
|
}, [book.id]);
|
||||||
|
|
||||||
|
const checkOfflineAvailability = async () => {
|
||||||
|
if (!("caches" in window)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cache = await caches.open("stripstream-books");
|
||||||
|
// On vérifie que toutes les pages sont dans le cache
|
||||||
|
const bookPages = await cache.match(`/api/komga/books/${book.id}/pages`);
|
||||||
|
if (!bookPages) {
|
||||||
|
setIsAvailableOffline(false);
|
||||||
|
setBookStatus(book.id, { status: "idle", progress: 0, timestamp: Date.now() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifie que toutes les pages sont dans le cache
|
||||||
|
let allPagesAvailable = true;
|
||||||
|
for (let i = 1; i <= book.media.pagesCount; i++) {
|
||||||
|
const page = await cache.match(`/api/komga/books/${book.id}/pages/${i}`);
|
||||||
|
if (!page) {
|
||||||
|
allPagesAvailable = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAvailableOffline(allPagesAvailable);
|
||||||
|
setBookStatus(book.id, {
|
||||||
|
status: allPagesAvailable ? "available" : "idle",
|
||||||
|
progress: allPagesAvailable ? 100 : 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la vérification du cache:", error);
|
||||||
|
setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleOffline = async () => {
|
||||||
|
if (!("caches" in window)) {
|
||||||
|
toast({
|
||||||
|
title: "Non supporté",
|
||||||
|
description: "Votre navigateur ne supporte pas le stockage hors ligne",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cache = await caches.open("stripstream-books");
|
||||||
|
|
||||||
|
if (isAvailableOffline) {
|
||||||
|
setBookStatus(book.id, { status: "idle", progress: 0, timestamp: Date.now() });
|
||||||
|
// Supprime le livre du cache
|
||||||
|
await cache.delete(`/api/komga/books/${book.id}/pages`);
|
||||||
|
for (let i = 1; i <= book.media.pagesCount; i++) {
|
||||||
|
await cache.delete(`/api/komga/books/${book.id}/pages/${i}`);
|
||||||
|
const progress = (i / book.media.pagesCount) * 100;
|
||||||
|
setDownloadProgress(progress);
|
||||||
|
}
|
||||||
|
setIsAvailableOffline(false);
|
||||||
|
toast({
|
||||||
|
title: "Livre supprimé",
|
||||||
|
description: "Le livre n'est plus disponible hors ligne",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await downloadBook();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la gestion du cache:", error);
|
||||||
|
setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() });
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Une erreur est survenue lors de la gestion du stockage hors ligne",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
setIsAvailableOffline(false);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonTitle = isLoading
|
||||||
|
? `Téléchargement en cours (${Math.round(downloadProgress)}%)`
|
||||||
|
: isAvailableOffline
|
||||||
|
? "Supprimer hors ligne"
|
||||||
|
: "Disponible hors ligne";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleToggleOffline}
|
||||||
|
className={`h-8 w-8 p-0 rounded-br-lg rounded-tl-lg ${className}`}
|
||||||
|
disabled={isLoading}
|
||||||
|
title={buttonTitle}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : isAvailableOffline ? (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/ui/card.tsx
Normal file
56
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
25
src/components/ui/progress.tsx
Normal file
25
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
));
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
@@ -5,7 +5,6 @@ import { PreferencesService } from "./preferences.service";
|
|||||||
|
|
||||||
export class BookService extends BaseApiService {
|
export class BookService extends BaseApiService {
|
||||||
static async getBook(bookId: string): Promise<{ book: KomgaBook; pages: number[] }> {
|
static async getBook(bookId: string): Promise<{ book: KomgaBook; pages: number[] }> {
|
||||||
console.log("dzadaz");
|
|
||||||
try {
|
try {
|
||||||
const config = await this.getKomgaConfig();
|
const config = await this.getKomgaConfig();
|
||||||
const headers = this.getAuthHeaders(config);
|
const headers = this.getAuthHeaders(config);
|
||||||
|
|||||||
Reference in New Issue
Block a user