refactor: streamline LibraryPage component by integrating ClientLibraryPage for improved structure and error handling

This commit is contained in:
Julien Froidefond
2025-10-16 13:25:51 +02:00
parent f6c702d787
commit 4139d8a059
9 changed files with 167 additions and 62 deletions

View File

@@ -0,0 +1,83 @@
"use client";
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
import { RefreshButton } from "@/components/library/RefreshButton";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { useTranslate } from "@/hooks/useTranslate";
import type { LibraryResponse } from "@/types/library";
import type { KomgaSeries, KomgaLibrary } from "@/types/komga";
import type { UserPreferences } from "@/types/preferences";
interface ClientLibraryPageProps {
library: KomgaLibrary | null;
series: LibraryResponse<KomgaSeries> | null;
currentPage: number;
libraryId: string;
refreshLibrary: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
preferences: UserPreferences;
unreadOnly: boolean;
errorCode?: string;
}
export function ClientLibraryPage({
library,
series,
currentPage,
libraryId,
refreshLibrary,
preferences,
unreadOnly,
errorCode,
}: ClientLibraryPageProps) {
const { t } = useTranslate();
if (errorCode) {
return (
<div className="container py-8 space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">
{library?.name || t("series.empty")}
</h1>
<RefreshButton libraryId={libraryId} refreshLibrary={refreshLibrary} />
</div>
<ErrorMessage errorCode={errorCode} />
</div>
);
}
if (!library || !series) {
return (
<div className="container py-8 space-y-8">
<ErrorMessage errorCode="SERIES_FETCH_ERROR" />
</div>
);
}
return (
<div className="container py-8 space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">{library.name}</h1>
<div className="flex items-center gap-2">
{series.totalElements > 0 && (
<p className="text-sm text-muted-foreground">
{t("series.display.showing", {
start: ((currentPage - 1) * (preferences.displayMode?.itemsPerPage || 20)) + 1,
end: Math.min(currentPage * (preferences.displayMode?.itemsPerPage || 20), series.totalElements),
total: series.totalElements,
})}
</p>
)}
<RefreshButton libraryId={libraryId} refreshLibrary={refreshLibrary} />
</div>
</div>
<PaginatedSeriesGrid
series={series.content || []}
currentPage={currentPage}
totalPages={series.totalPages}
totalElements={series.totalElements}
defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly}
/>
</div>
);
}

View File

@@ -1,15 +1,13 @@
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
import { LibraryService } from "@/lib/services/library.service"; import { LibraryService } from "@/lib/services/library.service";
import { PreferencesService } from "@/lib/services/preferences.service"; import { PreferencesService } from "@/lib/services/preferences.service";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { RefreshButton } from "@/components/library/RefreshButton";
import { withPageTiming } from "@/lib/hoc/withPageTiming"; import { withPageTiming } from "@/lib/hoc/withPageTiming";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import type { LibraryResponse } from "@/types/library"; import type { LibraryResponse } from "@/types/library";
import type { KomgaSeries, KomgaLibrary } from "@/types/komga"; import type { KomgaSeries, KomgaLibrary } from "@/types/komga";
import type { UserPreferences } from "@/types/preferences"; import type { UserPreferences } from "@/types/preferences";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import { ClientLibraryPage } from "./ClientLibraryPage";
interface PageProps { interface PageProps {
params: { libraryId: string }; params: { libraryId: string };
@@ -27,8 +25,8 @@ async function refreshLibrary(libraryId: string) {
revalidatePath(`/libraries/${libraryId}`); revalidatePath(`/libraries/${libraryId}`);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Erreur lors du rafraîchissement:", error); console.error("Error during refresh:", error);
return { success: false, error: "Erreur lors du rafraîchissement de la bibliothèque" }; return { success: false, error: "Error refreshing library" };
} }
} }
@@ -75,44 +73,42 @@ async function LibraryPage({ params, searchParams }: PageProps) {
await getLibrarySeries(libraryId, currentPage, unreadOnly, search, pageSize); await getLibrarySeries(libraryId, currentPage, unreadOnly, search, pageSize);
return ( return (
<div className="container py-8 space-y-8"> <ClientLibraryPage
<div className="flex items-center justify-between"> library={library}
<h1 className="text-3xl font-bold">{library.name}</h1> series={series}
<div className="flex items-center gap-2"> currentPage={currentPage}
{series.totalElements > 0 && ( libraryId={libraryId}
<p className="text-sm text-muted-foreground"> refreshLibrary={refreshLibrary}
{series.totalElements} série{series.totalElements > 1 ? "s" : ""} preferences={preferences}
</p> unreadOnly={unreadOnly}
)} />
<RefreshButton libraryId={libraryId} refreshLibrary={refreshLibrary} />
</div>
</div>
<PaginatedSeriesGrid
series={series.content || []}
currentPage={currentPage}
totalPages={series.totalPages}
totalElements={series.totalElements}
defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly}
/>
</div>
); );
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
return ( return (
<div className="container py-8 space-y-8"> <ClientLibraryPage
<div className="flex items-center justify-between"> library={null}
<h1 className="text-3xl font-bold">Séries</h1> series={null}
<RefreshButton libraryId={libraryId} refreshLibrary={refreshLibrary} /> currentPage={currentPage}
</div> libraryId={libraryId}
<ErrorMessage errorCode={error.code} /> refreshLibrary={refreshLibrary}
</div> preferences={preferences}
unreadOnly={unreadOnly}
errorCode={error.code}
/>
); );
} }
return ( return (
<div className="container py-8 space-y-8"> <ClientLibraryPage
<ErrorMessage errorCode={ERROR_CODES.SERIES.FETCH_ERROR} /> library={null}
</div> series={null}
currentPage={currentPage}
libraryId={libraryId}
refreshLibrary={refreshLibrary}
preferences={preferences}
unreadOnly={unreadOnly}
errorCode={ERROR_CODES.SERIES.FETCH_ERROR}
/>
); );
} }
} }

View File

@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import type { KomgaBook, KomgaSeries } from "@/types/komga"; import type { KomgaBook, KomgaSeries } from "@/types/komga";
import { BookCover } from "../ui/book-cover"; import { BookCover } from "../ui/book-cover";
import { SeriesCover } from "../ui/series-cover"; import { SeriesCover } from "../ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate";
interface BaseItem { interface BaseItem {
id: string; id: string;
@@ -43,6 +44,7 @@ export function MediaRow({ title, items, icon }: MediaRowProps) {
const [showLeftArrow, setShowLeftArrow] = useState(false); const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(true); const [showRightArrow, setShowRightArrow] = useState(true);
const router = useRouter(); const router = useRouter();
const { t } = useTranslate();
const onItemClick = (item: OptimizedSeries | OptimizedBook) => { const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`; const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`;
@@ -78,7 +80,7 @@ export function MediaRow({ title, items, icon }: MediaRowProps) {
<button <button
onClick={() => scroll("left")} onClick={() => scroll("left")}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-background/90 shadow-md border transition-opacity" className="absolute left-0 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-background/90 shadow-md border transition-opacity"
aria-label="Défiler vers la gauche" aria-label={t("navigation.scrollLeft")}
> >
<ChevronLeft className="h-6 w-6" /> <ChevronLeft className="h-6 w-6" />
</button> </button>
@@ -100,7 +102,7 @@ export function MediaRow({ title, items, icon }: MediaRowProps) {
<button <button
onClick={() => scroll("right")} onClick={() => scroll("right")}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-background/90 shadow-md border transition-opacity" className="absolute right-0 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-background/90 shadow-md border transition-opacity"
aria-label="Défiler vers la droite" aria-label={t("navigation.scrollRight")}
> >
<ChevronRight className="h-6 w-6" /> <ChevronRight className="h-6 w-6" />
</button> </button>
@@ -116,10 +118,14 @@ interface MediaCardProps {
} }
function MediaCard({ item, onClick }: MediaCardProps) { function MediaCard({ item, onClick }: MediaCardProps) {
const { t } = useTranslate();
const isSeries = "booksCount" in item; const isSeries = "booksCount" in item;
const title = isSeries const title = isSeries
? item.metadata.title ? item.metadata.title
: item.metadata.title || `Tome ${item.metadata.number}`; : item.metadata.title ||
(item.metadata.number
? t("navigation.volume", { number: item.metadata.number })
: "");
return ( return (
<div <div
@@ -133,7 +139,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3"> <div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3">
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3> <h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
<p className="text-xs text-white/80 mt-1"> <p className="text-xs text-white/80 mt-1">
{item.booksCount} tome{item.booksCount > 1 ? "s" : ""} {t("series.books", { count: item.booksCount })}
</p> </p>
</div> </div>
</> </>

View File

@@ -105,24 +105,9 @@ export function PaginatedSeriesGrid({
}); });
}; };
// Calculate start and end indices for display
const startIndex = (currentPage - 1) * itemsPerPage + 1;
const endIndex = Math.min(currentPage * itemsPerPage, totalElements);
const getShowingText = () => {
if (!totalElements) return t("series.empty");
return t("series.display.showing", {
start: startIndex,
end: endIndex,
total: totalElements,
});
};
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground text-right">{getShowingText()}</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-4"> <div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="w-full"> <div className="w-full">
<SearchInput placeholder={t("series.filters.search")} /> <SearchInput placeholder={t("series.filters.search")} />

View File

@@ -85,7 +85,10 @@ export function BookGrid({ books, onBookClick, isCompact = false }: BookGridProp
<BookCover <BookCover
book={book} book={book}
alt={t("books.coverAlt", { alt={t("books.coverAlt", {
title: book.metadata.title || `Tome ${book.metadata.number}`, title: book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
: ""),
})} })}
onSuccess={(book, action) => handleOnSuccess(book, action)} onSuccess={(book, action) => handleOnSuccess(book, action)}
/> />

View File

@@ -158,7 +158,10 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
{statusInfo.label} {statusInfo.label}
</span> </span>
<span className="text-sm text-white/80"> <span className="text-sm text-white/80">
{t("series.header.books", { count: series.booksCount })} {series.booksCount === 1
? t("series.header.books", { count: series.booksCount })
: t("series.header.books_plural", { count: series.booksCount })
}
</span> </span>
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -49,7 +49,7 @@ const getReadingStatusInfo = (book: KomgaBook, t: (key: string, options?: any) =
export function BookCover({ export function BookCover({
book, book,
alt = "Image de couverture", alt,
className, className,
showProgressUi = true, showProgressUi = true,
onSuccess, onSuccess,
@@ -84,7 +84,7 @@ export function BookCover({
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<CoverClient <CoverClient
imageUrl={imageUrl} imageUrl={imageUrl}
alt={alt} alt={alt || t("books.defaultCoverAlt")}
className={className} className={className}
isCompleted={isCompleted} isCompleted={isCompleted}
/> />
@@ -121,7 +121,10 @@ export function BookCover({
{showOverlay && overlayVariant === "default" && ( {showOverlay && overlayVariant === "default" && (
<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">
<p className="text-sm font-medium text-white text-left line-clamp-2"> <p className="text-sm font-medium text-white text-left line-clamp-2">
{book.metadata.title || `Tome ${book.metadata.number}`} {book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
: "")}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-full text-xs ${statusInfo.className}`}> <span className={`px-2 py-0.5 rounded-full text-xs ${statusInfo.className}`}>
@@ -135,10 +138,16 @@ export function BookCover({
{showOverlay && overlayVariant === "home" && ( {showOverlay && overlayVariant === "home" && (
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3"> <div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3">
<h3 className="font-medium text-sm text-white line-clamp-2"> <h3 className="font-medium text-sm text-white line-clamp-2">
{book.metadata.title || `Tome ${book.metadata.number}`} {book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
: "")}
</h3> </h3>
<p className="text-xs text-white/80 mt-1"> <p className="text-xs text-white/80 mt-1">
{currentPage} / {book.media.pagesCount} {t("books.status.progress", {
current: currentPage,
total: book.media.pagesCount,
})}
</p> </p>
</div> </div>
)} )}

View File

@@ -201,6 +201,7 @@
"books": { "books": {
"empty": "No books available", "empty": "No books available",
"coverAlt": "Cover of {{title}}", "coverAlt": "Cover of {{title}}",
"defaultCoverAlt": "Cover image",
"status": { "status": {
"unread": "Unread", "unread": "Unread",
"read": "Read", "read": "Read",
@@ -380,6 +381,10 @@
"enter": "Enter fullscreen", "enter": "Enter fullscreen",
"exit": "Exit fullscreen" "exit": "Exit fullscreen"
}, },
"thumbnails": {
"show": "Show thumbnails",
"hide": "Hide thumbnails"
},
"close": "Close", "close": "Close",
"previousPage": "Previous page", "previousPage": "Previous page",
"nextPage": "Next page" "nextPage": "Next page"
@@ -388,6 +393,11 @@
"endOfSeriesMessage": "You have finished all the books in this series!", "endOfSeriesMessage": "You have finished all the books in this series!",
"backToSeries": "Back to series" "backToSeries": "Back to series"
}, },
"navigation": {
"scrollLeft": "Scroll left",
"scrollRight": "Scroll right",
"volume": "Volume {number}"
},
"debug": { "debug": {
"title": "DEBUG", "title": "DEBUG",
"entries": "entry", "entries": "entry",

View File

@@ -203,6 +203,7 @@
"books": { "books": {
"empty": "Aucun tome disponible", "empty": "Aucun tome disponible",
"coverAlt": "Couverture de {{title}}", "coverAlt": "Couverture de {{title}}",
"defaultCoverAlt": "Image de couverture",
"status": { "status": {
"unread": "Non lu", "unread": "Non lu",
"read": "Lu", "read": "Lu",
@@ -382,6 +383,10 @@
"enter": "Plein écran", "enter": "Plein écran",
"exit": "Quitter le plein écran" "exit": "Quitter le plein écran"
}, },
"thumbnails": {
"show": "Afficher les vignettes",
"hide": "Masquer les vignettes"
},
"close": "Fermer", "close": "Fermer",
"previousPage": "Page précédente", "previousPage": "Page précédente",
"nextPage": "Page suivante" "nextPage": "Page suivante"
@@ -394,6 +399,11 @@
"toggleSidebar": "Afficher/masquer le menu latéral", "toggleSidebar": "Afficher/masquer le menu latéral",
"toggleTheme": "Changer le thème" "toggleTheme": "Changer le thème"
}, },
"navigation": {
"scrollLeft": "Défiler vers la gauche",
"scrollRight": "Défiler vers la droite",
"volume": "Tome {number}"
},
"debug": { "debug": {
"title": "DEBUG", "title": "DEBUG",
"entries": "entrée", "entries": "entrée",