feat(i18n): download page

This commit is contained in:
Julien Froidefond
2025-02-27 12:45:03 +01:00
parent 0d1d969e53
commit 148bfd71e1
5 changed files with 216 additions and 128 deletions

View File

@@ -1,11 +1,9 @@
import { PageHeader } from "@/components/layout/PageHeader";
import { DownloadManager } from "@/components/downloads/DownloadManager"; import { DownloadManager } from "@/components/downloads/DownloadManager";
import { withPageTiming } from "@/lib/hoc/withPageTiming"; import { withPageTiming } from "@/lib/hoc/withPageTiming";
function DownloadsPage() { function DownloadsPage() {
return ( return (
<> <>
<PageHeader title="Téléchargements" description="Gérez vos livres disponibles hors ligne" />
<DownloadManager /> <DownloadManager />
</> </>
); );

View File

@@ -11,6 +11,7 @@ import { Progress } from "@/components/ui/progress";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { BookOfflineButton } from "@/components/ui/book-offline-button"; import { BookOfflineButton } from "@/components/ui/book-offline-button";
import { useTranslate } from "@/hooks/useTranslate";
type BookStatus = "idle" | "downloading" | "available" | "error"; type BookStatus = "idle" | "downloading" | "available" | "error";
@@ -30,6 +31,7 @@ export function DownloadManager() {
const [downloadedBooks, setDownloadedBooks] = useState<DownloadedBook[]>([]); const [downloadedBooks, setDownloadedBooks] = useState<DownloadedBook[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslate();
const getStorageKey = useCallback((bookId: string) => `book-status-${bookId}`, []); const getStorageKey = useCallback((bookId: string) => `book-status-${bookId}`, []);
@@ -116,26 +118,25 @@ export function DownloadManager() {
localStorage.removeItem(getStorageKey(book.id)); localStorage.removeItem(getStorageKey(book.id));
setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id)); setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id));
toast({ toast({
title: "Livre supprimé", title: t("downloads.toast.deleted"),
description: "Le livre n'est plus disponible hors ligne", description: t("downloads.toast.deletedDesc"),
}); });
} catch (error) { } catch (error) {
console.error("Erreur lors de la suppression du livre:", error); console.error("Erreur lors de la suppression du livre:", error);
toast({ toast({
title: "Erreur", title: t("downloads.toast.error"),
description: "Une erreur est survenue lors de la suppression", description: t("downloads.toast.errorDesc"),
variant: "destructive", variant: "destructive",
}); });
} }
}; };
const handleRetryDownload = async (book: KomgaBook) => { 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)); localStorage.removeItem(getStorageKey(book.id));
setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id)); setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id));
toast({ toast({
title: "Téléchargement relancé", title: t("downloads.toast.retry"),
description: "Le téléchargement va reprendre depuis le début", description: t("downloads.toast.retryDesc"),
}); });
}; };
@@ -148,59 +149,57 @@ export function DownloadManager() {
} }
return ( return (
<Tabs defaultValue="all" className="space-y-4"> <>
<div className="flex items-center justify-between"> <div className="flex flex-col gap-1 mb-8">
<TabsList> <h1 className="text-3xl font-bold tracking-tight">{t("downloads.page.title")}</h1>
<TabsTrigger value="all">Tous ({downloadedBooks.length})</TabsTrigger> {t("downloads.page.description") && (
<TabsTrigger value="downloading"> <p className="text-lg text-muted-foreground">{t("downloads.page.description")}</p>
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>
{downloadedBooks.some((b) => b.status.status === "error") && (
<Button
variant="outline"
size="sm"
onClick={() => {
const errorBooks = downloadedBooks.filter((b) => b.status.status === "error");
errorBooks.forEach((book) => handleRetryDownload(book.book));
toast({
title: "Relance des téléchargements",
description: `${errorBooks.length} téléchargement(s) relancé(s)`,
});
}}
className="gap-2"
>
<Download className="h-4 w-4" />
Tout relancer
</Button>
)} )}
</div> </div>
<Tabs defaultValue="all" className="space-y-4">
<div className="flex items-center justify-between">
<TabsList>
<TabsTrigger value="all">
{t("downloads.tabs.all", { count: downloadedBooks.length })}
</TabsTrigger>
<TabsTrigger value="downloading">
{t("downloads.tabs.downloading", {
count: downloadedBooks.filter((b) => b.status.status === "downloading").length,
})}
</TabsTrigger>
<TabsTrigger value="available">
{t("downloads.tabs.available", {
count: downloadedBooks.filter((b) => b.status.status === "available").length,
})}
</TabsTrigger>
<TabsTrigger value="error">
{t("downloads.tabs.error", {
count: downloadedBooks.filter((b) => b.status.status === "error").length,
})}
</TabsTrigger>
</TabsList>
{downloadedBooks.some((b) => b.status.status === "error") && (
<Button
variant="outline"
size="sm"
onClick={() => {
const errorBooks = downloadedBooks.filter((b) => b.status.status === "error");
errorBooks.forEach((book) => handleRetryDownload(book.book));
toast({
title: t("downloads.toast.retryAll"),
description: t("downloads.toast.retryAllDesc", { count: errorBooks.length }),
});
}}
className="gap-2"
>
<Download className="h-4 w-4" />
{t("downloads.actions.retryAll")}
</Button>
)}
</div>
<TabsContent value="all" className="space-y-4"> <TabsContent value="all" className="space-y-4">
{downloadedBooks.map(({ book, status }) => ( {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 <BookDownloadCard
key={book.id} key={book.id}
book={book} book={book}
@@ -209,45 +208,67 @@ export function DownloadManager() {
onRetry={() => handleRetryDownload(book)} onRetry={() => handleRetryDownload(book)}
/> />
))} ))}
{downloadedBooks.filter((b) => b.status.status === "downloading").length === 0 && ( {downloadedBooks.length === 0 && (
<p className="text-center text-muted-foreground p-8">Aucun téléchargement en cours</p> <p className="text-center text-muted-foreground p-8">{t("downloads.empty.all")}</p>
)} )}
</TabsContent> </TabsContent>
<TabsContent value="available" className="space-y-4"> <TabsContent value="downloading" className="space-y-4">
{downloadedBooks {downloadedBooks
.filter((b) => b.status.status === "available") .filter((b) => b.status.status === "downloading")
.map(({ book, status }) => ( .map(({ book, status }) => (
<BookDownloadCard <BookDownloadCard
key={book.id} key={book.id}
book={book} book={book}
status={status} status={status}
onDelete={() => handleDeleteBook(book)} onDelete={() => handleDeleteBook(book)}
onRetry={() => handleRetryDownload(book)} onRetry={() => handleRetryDownload(book)}
/> />
))} ))}
{downloadedBooks.filter((b) => b.status.status === "available").length === 0 && ( {downloadedBooks.filter((b) => b.status.status === "downloading").length === 0 && (
<p className="text-center text-muted-foreground p-8">Aucun livre disponible hors ligne</p> <p className="text-center text-muted-foreground p-8">
)} {t("downloads.empty.downloading")}
</TabsContent> </p>
)}
</TabsContent>
<TabsContent value="error" className="space-y-4"> <TabsContent value="available" className="space-y-4">
{downloadedBooks {downloadedBooks
.filter((b) => b.status.status === "error") .filter((b) => b.status.status === "available")
.map(({ book, status }) => ( .map(({ book, status }) => (
<BookDownloadCard <BookDownloadCard
key={book.id} key={book.id}
book={book} book={book}
status={status} status={status}
onDelete={() => handleDeleteBook(book)} onDelete={() => handleDeleteBook(book)}
onRetry={() => handleRetryDownload(book)} onRetry={() => handleRetryDownload(book)}
/> />
))} ))}
{downloadedBooks.filter((b) => b.status.status === "error").length === 0 && ( {downloadedBooks.filter((b) => b.status.status === "available").length === 0 && (
<p className="text-center text-muted-foreground p-8">Aucune erreur</p> <p className="text-center text-muted-foreground p-8">
)} {t("downloads.empty.available")}
</TabsContent> </p>
</Tabs> )}
</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">{t("downloads.empty.error")}</p>
)}
</TabsContent>
</Tabs>
</>
); );
} }
@@ -259,9 +280,11 @@ interface BookDownloadCardProps {
} }
function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardProps) { function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardProps) {
const { t } = useTranslate();
const formatSize = (bytes: number) => { const formatSize = (bytes: number) => {
const mb = bytes / (1024 * 1024); const mb = bytes / (1024 * 1024);
return `${mb.toFixed(1)} Mo`; return t("downloads.info.size", { size: mb.toFixed(1) });
}; };
const getStatusIcon = (status: BookStatus) => { const getStatusIcon = (status: BookStatus) => {
@@ -278,16 +301,7 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
}; };
const getStatusText = (status: BookStatus) => { const getStatusText = (status: BookStatus) => {
switch (status) { return t(`downloads.status.${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 ( return (
@@ -296,7 +310,7 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
<div className="relative w-12 aspect-[2/3] bg-muted rounded overflow-hidden"> <div className="relative w-12 aspect-[2/3] bg-muted rounded overflow-hidden">
<Image <Image
src={`/api/komga/images/books/${book.id}/thumbnail`} src={`/api/komga/images/books/${book.id}/thumbnail`}
alt={`Couverture de ${book.metadata?.title}`} alt={t("books.coverAlt", { title: book.metadata?.title })}
className="object-cover" className="object-cover"
fill fill
sizes="48px" sizes="48px"
@@ -309,7 +323,7 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
className="hover:underline hover:text-primary transition-colors" className="hover:underline hover:text-primary transition-colors"
> >
<h3 className="font-medium truncate"> <h3 className="font-medium truncate">
{book.metadata?.title || `Tome ${book.metadata?.number}`} {book.metadata?.title || t("books.title", { number: book.metadata?.number })}
</h3> </h3>
</Link> </Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -317,10 +331,11 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
<span></span> <span></span>
<span> <span>
{status.status === "downloading" {status.status === "downloading"
? `${Math.floor((status.progress * book.media.pagesCount) / 100)}/${ ? t("downloads.info.pages", {
book.media.pagesCount current: Math.floor((status.progress * book.media.pagesCount) / 100),
} pages` total: book.media.pagesCount,
: `${book.media.pagesCount} pages`} })
: t("downloads.info.totalPages", { count: book.media.pagesCount })}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -342,7 +357,7 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onRetry} onClick={onRetry}
title="Réessayer" title={t("downloads.actions.retry")}
className="h-8 w-8 p-0 rounded-br-lg rounded-tl-lg" className="h-8 w-8 p-0 rounded-br-lg rounded-tl-lg"
> >
<Download className="h-5 w-5" /> <Download className="h-5 w-5" />
@@ -354,7 +369,7 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onDelete} onClick={onDelete}
title="Supprimer" title={t("downloads.actions.delete")}
className="h-8 w-8 p-0 rounded-br-lg rounded-tl-lg" className="h-8 w-8 p-0 rounded-br-lg rounded-tl-lg"
> >
<Trash2 className="h-5 w-5" /> <Trash2 className="h-5 w-5" />

View File

@@ -1,13 +0,0 @@
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>
);
}

View File

@@ -174,5 +174,49 @@
"unread": "Unread" "unread": "Unread"
}, },
"loading": "Loading..." "loading": "Loading..."
},
"downloads": {
"page": {
"title": "Downloads",
"description": "Manage your offline available books"
},
"tabs": {
"all": "All ({count})",
"downloading": "Downloading ({count})",
"available": "Available ({count})",
"error": "Errors ({count})"
},
"empty": {
"all": "No downloaded books",
"downloading": "No downloads in progress",
"available": "No books available offline",
"error": "No errors"
},
"actions": {
"retryAll": "Retry all",
"retry": "Retry",
"delete": "Delete"
},
"status": {
"downloading": "Downloading",
"available": "Available offline",
"error": "Download error",
"idle": "Not downloaded"
},
"toast": {
"deleted": "Book deleted",
"deletedDesc": "The book is no longer available offline",
"error": "Error",
"errorDesc": "An error occurred while deleting",
"retryAll": "Retrying downloads",
"retryAllDesc": "{count} download(s) restarted",
"retryDesc": "Download will start from the beginning",
"retry": "Download restarted"
},
"info": {
"size": "{size} MB",
"pages": "{current}/{total} pages",
"totalPages": "{count} pages"
}
} }
} }

View File

@@ -174,5 +174,49 @@
"unread": "À lire" "unread": "À lire"
}, },
"loading": "Chargement..." "loading": "Chargement..."
},
"downloads": {
"page": {
"title": "Téléchargements",
"description": "Gérez vos livres disponibles hors ligne"
},
"tabs": {
"all": "Tous ({count})",
"downloading": "En cours ({count})",
"available": "Disponibles ({count})",
"error": "Erreurs ({count})"
},
"empty": {
"all": "Aucun livre téléchargé",
"downloading": "Aucun téléchargement en cours",
"available": "Aucun livre disponible hors ligne",
"error": "Aucune erreur"
},
"actions": {
"retryAll": "Tout relancer",
"retry": "Réessayer",
"delete": "Supprimer"
},
"status": {
"downloading": "En cours de téléchargement",
"available": "Disponible hors ligne",
"error": "Erreur de téléchargement",
"idle": "Non téléchargé"
},
"toast": {
"deleted": "Livre supprimé",
"deletedDesc": "Le livre n'est plus disponible hors ligne",
"error": "Erreur",
"errorDesc": "Une erreur est survenue lors de la suppression",
"retryAll": "Relance des téléchargements",
"retryAllDesc": "{count} téléchargement(s) relancé(s)",
"retryDesc": "Le téléchargement va reprendre depuis le début",
"retry": "Téléchargement relancé"
},
"info": {
"size": "{size} Mo",
"pages": "{current}/{total} pages",
"totalPages": "{count} pages"
}
} }
} }