feat: enhance ClientLibraryPage with LibraryHeader component and improve loading skeleton structure for better user experience
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
|
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
|
||||||
import { RefreshButton } from "@/components/library/RefreshButton";
|
import { RefreshButton } from "@/components/library/RefreshButton";
|
||||||
|
import { LibraryHeader } from "@/components/library/LibraryHeader";
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons";
|
import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons";
|
||||||
@@ -156,45 +157,54 @@ export function ClientLibraryPage({
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Container>
|
<>
|
||||||
{/* Section header avec titre + actions */}
|
{/* Header skeleton */}
|
||||||
<div className="space-y-4 mb-8">
|
<div className="relative min-h-[200px] md:h-[200px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden mb-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-primary/10 to-background" />
|
||||||
<OptimizedSkeleton className="h-8 w-64" />
|
<div className="relative container mx-auto px-4 py-8 h-full">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col md:flex-row gap-6 items-center md:items-start h-full">
|
||||||
<OptimizedSkeleton className="h-5 w-48" />
|
<OptimizedSkeleton className="w-[120px] h-[120px] rounded-lg" />
|
||||||
<OptimizedSkeleton className="h-10 w-10 rounded-full" />
|
<div className="flex-1 space-y-3">
|
||||||
|
<OptimizedSkeleton className="h-10 w-64" />
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<OptimizedSkeleton className="h-8 w-32" />
|
||||||
|
<OptimizedSkeleton className="h-8 w-32" />
|
||||||
|
<OptimizedSkeleton className="h-10 w-10 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
<Container>
|
||||||
<div className="flex flex-col gap-4 mb-8">
|
{/* Filters */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
<div className="flex flex-col gap-4 mb-8">
|
||||||
<div className="w-full">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||||
<OptimizedSkeleton className="h-10 w-full" />
|
<div className="w-full">
|
||||||
</div>
|
<OptimizedSkeleton className="h-10 w-full" />
|
||||||
<div className="flex items-center justify-end gap-2">
|
</div>
|
||||||
<OptimizedSkeleton className="h-10 w-24" />
|
<div className="flex items-center justify-end gap-2">
|
||||||
<OptimizedSkeleton className="h-10 w-10 rounded" />
|
<OptimizedSkeleton className="h-10 w-24" />
|
||||||
<OptimizedSkeleton className="h-10 w-10 rounded" />
|
<OptimizedSkeleton className="h-10 w-10 rounded" />
|
||||||
|
<OptimizedSkeleton className="h-10 w-10 rounded" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||||
{Array.from({ length: effectivePageSize }).map((_, i) => (
|
{Array.from({ length: effectivePageSize }).map((_, i) => (
|
||||||
<OptimizedSkeleton key={i} className="aspect-[2/3] w-full rounded-lg" />
|
<OptimizedSkeleton key={i} className="aspect-[2/3] w-full rounded-lg" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
||||||
<OptimizedSkeleton className="h-5 w-32 order-2 sm:order-1" />
|
<OptimizedSkeleton className="h-5 w-32 order-2 sm:order-1" />
|
||||||
<OptimizedSkeleton className="h-10 w-64 order-1 sm:order-2" />
|
<OptimizedSkeleton className="h-10 w-64 order-1 sm:order-2" />
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,32 +229,24 @@ export function ClientLibraryPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<>
|
||||||
<Section
|
<LibraryHeader
|
||||||
title={library.name}
|
library={library}
|
||||||
actions={
|
seriesCount={series.totalElements}
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{series.totalElements > 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("series.display.showing", {
|
|
||||||
start: ((currentPage - 1) * effectivePageSize) + 1,
|
|
||||||
end: Math.min(currentPage * effectivePageSize, series.totalElements),
|
|
||||||
total: series.totalElements,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PaginatedSeriesGrid
|
|
||||||
series={series.content || []}
|
series={series.content || []}
|
||||||
currentPage={currentPage}
|
refreshLibrary={handleRefresh}
|
||||||
totalPages={series.totalPages}
|
|
||||||
totalElements={series.totalElements}
|
|
||||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
|
||||||
showOnlyUnread={unreadOnly}
|
|
||||||
/>
|
/>
|
||||||
</Container>
|
<Container>
|
||||||
|
<PaginatedSeriesGrid
|
||||||
|
series={series.content || []}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={series.totalPages}
|
||||||
|
totalElements={series.totalElements}
|
||||||
|
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||||
|
showOnlyUnread={unreadOnly}
|
||||||
|
pageSize={effectivePageSize}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
102
src/components/library/LibraryHeader.tsx
Normal file
102
src/components/library/LibraryHeader.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Library, BookOpen } from "lucide-react";
|
||||||
|
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||||
|
import { RefreshButton } from "./RefreshButton";
|
||||||
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
|
import { SeriesCover } from "@/components/ui/series-cover";
|
||||||
|
|
||||||
|
interface LibraryHeaderProps {
|
||||||
|
library: KomgaLibrary;
|
||||||
|
seriesCount: number;
|
||||||
|
series: KomgaSeries[];
|
||||||
|
refreshLibrary: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }: LibraryHeaderProps) => {
|
||||||
|
const { t } = useTranslate();
|
||||||
|
|
||||||
|
// Sélectionner une série aléatoire pour l'image centrale
|
||||||
|
const randomSeries = series.length > 0 ? series[Math.floor(Math.random() * series.length)] : null;
|
||||||
|
|
||||||
|
// Sélectionner une autre série aléatoire pour le fond (différente de celle du centre)
|
||||||
|
const backgroundSeries = series.length > 1
|
||||||
|
? series.filter(s => s.id !== randomSeries?.id)[Math.floor(Math.random() * (series.length - 1))]
|
||||||
|
: randomSeries;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-[200px] md:h-[200px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden">
|
||||||
|
{/* Image de fond avec une série aléatoire */}
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<div className="absolute inset-0 bg-black/40" />
|
||||||
|
{backgroundSeries ? (
|
||||||
|
<SeriesCover
|
||||||
|
series={backgroundSeries}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover scale-105 blur-sm opacity-30"
|
||||||
|
showProgressUi={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gradient-to-br from-primary/20 via-primary/10 to-background" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="relative container mx-auto px-4 py-8 h-full">
|
||||||
|
<div className="flex flex-col md:flex-row gap-6 items-center md:items-start h-full">
|
||||||
|
{/* Cover centrale avec icône overlay */}
|
||||||
|
<div className="relative w-[120px] h-[120px] rounded-lg overflow-hidden shadow-lg flex-shrink-0">
|
||||||
|
{randomSeries ? (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
<SeriesCover
|
||||||
|
series={randomSeries}
|
||||||
|
alt={t("library.header.coverAlt", { name: library.name })}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
showProgressUi={false}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
|
||||||
|
<Library className="w-10 h-10 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-primary/10 backdrop-blur-md flex items-center justify-center">
|
||||||
|
<Library className="w-16 h-16 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Informations */}
|
||||||
|
<div className="flex-1 space-y-3 text-center md:text-left">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-foreground">{library.name}</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 justify-center md:justify-start flex-wrap">
|
||||||
|
<StatusBadge status="unread" icon={Library}>
|
||||||
|
{seriesCount === 1
|
||||||
|
? t("library.header.series", { count: seriesCount })
|
||||||
|
: t("library.header.series_plural", { count: seriesCount })
|
||||||
|
}
|
||||||
|
</StatusBadge>
|
||||||
|
|
||||||
|
<StatusBadge status="reading" icon={BookOpen}>
|
||||||
|
{library.booksCount === 1
|
||||||
|
? t("library.header.books", { count: library.booksCount })
|
||||||
|
: t("library.header.books_plural", { count: library.booksCount })
|
||||||
|
}
|
||||||
|
</StatusBadge>
|
||||||
|
|
||||||
|
<RefreshButton libraryId={library.id} refreshLibrary={refreshLibrary} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{library.unavailable && (
|
||||||
|
<p className="text-sm text-destructive mt-2">
|
||||||
|
{t("library.header.unavailable")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -11,7 +11,6 @@ import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
|
|||||||
import { PageSizeSelect } from "@/components/common/PageSizeSelect";
|
import { PageSizeSelect } from "@/components/common/PageSizeSelect";
|
||||||
import { CompactModeButton } from "@/components/common/CompactModeButton";
|
import { CompactModeButton } from "@/components/common/CompactModeButton";
|
||||||
import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
|
import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
|
||||||
import { Container } from "@/components/ui/container";
|
|
||||||
|
|
||||||
interface PaginatedSeriesGridProps {
|
interface PaginatedSeriesGridProps {
|
||||||
series: KomgaSeries[];
|
series: KomgaSeries[];
|
||||||
@@ -20,6 +19,7 @@ interface PaginatedSeriesGridProps {
|
|||||||
totalElements: number;
|
totalElements: number;
|
||||||
defaultShowOnlyUnread: boolean;
|
defaultShowOnlyUnread: boolean;
|
||||||
showOnlyUnread: boolean;
|
showOnlyUnread: boolean;
|
||||||
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaginatedSeriesGrid({
|
export function PaginatedSeriesGrid({
|
||||||
@@ -29,12 +29,16 @@ export function PaginatedSeriesGrid({
|
|||||||
totalElements: _totalElements,
|
totalElements: _totalElements,
|
||||||
defaultShowOnlyUnread,
|
defaultShowOnlyUnread,
|
||||||
showOnlyUnread: initialShowOnlyUnread,
|
showOnlyUnread: initialShowOnlyUnread,
|
||||||
|
pageSize,
|
||||||
}: PaginatedSeriesGridProps) {
|
}: PaginatedSeriesGridProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread);
|
const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread);
|
||||||
const { isCompact, itemsPerPage: _itemsPerPage } = useDisplayPreferences();
|
const { isCompact, itemsPerPage: displayItemsPerPage } = useDisplayPreferences();
|
||||||
|
|
||||||
|
// Utiliser la taille de page effective (depuis l'URL ou les préférences)
|
||||||
|
const effectivePageSize = pageSize || displayItemsPerPage;
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
const updateUrlParams = useCallback(async (
|
const updateUrlParams = useCallback(async (
|
||||||
@@ -97,9 +101,24 @@ export function PaginatedSeriesGrid({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate start and end indices for display
|
||||||
|
const startIndex = (currentPage - 1) * effectivePageSize + 1;
|
||||||
|
const endIndex = Math.min(currentPage * effectivePageSize, _totalElements);
|
||||||
|
|
||||||
|
const getShowingText = () => {
|
||||||
|
if (!_totalElements) return t("series.empty");
|
||||||
|
|
||||||
|
return t("books.display.showing", {
|
||||||
|
start: startIndex,
|
||||||
|
end: endIndex,
|
||||||
|
total: _totalElements,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container spacing="none" 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")} />
|
||||||
@@ -125,6 +144,6 @@ export function PaginatedSeriesGrid({
|
|||||||
className="order-1 sm:order-2"
|
className="order-1 sm:order-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const SearchInput = ({ placeholder }: SearchInputProps) => {
|
|||||||
<Input
|
<Input
|
||||||
type={isPending ? "text" : "search"}
|
type={isPending ? "text" : "search"}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="pl-9"
|
className="pl-3"
|
||||||
defaultValue={searchParams.get("search") ?? ""}
|
defaultValue={searchParams.get("search") ?? ""}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
aria-label={placeholder}
|
aria-label={placeholder}
|
||||||
|
|||||||
@@ -197,6 +197,14 @@
|
|||||||
"title": "Error",
|
"title": "Error",
|
||||||
"description": "An error occurred"
|
"description": "An error occurred"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"series": "{{count}} series",
|
||||||
|
"series_plural": "{{count}} series",
|
||||||
|
"books": "{{count}} book",
|
||||||
|
"books_plural": "{{count}} books",
|
||||||
|
"unavailable": "This library is currently unavailable",
|
||||||
|
"coverAlt": "Cover of {{name}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"series": {
|
"series": {
|
||||||
|
|||||||
@@ -197,6 +197,14 @@
|
|||||||
"title": "Erreur",
|
"title": "Erreur",
|
||||||
"description": "Une erreur est survenue"
|
"description": "Une erreur est survenue"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"series": "{{count}} série",
|
||||||
|
"series_plural": "{{count}} séries",
|
||||||
|
"books": "{{count}} livre",
|
||||||
|
"books_plural": "{{count}} livres",
|
||||||
|
"unavailable": "Cette bibliothèque est actuellement indisponible",
|
||||||
|
"coverAlt": "Couverture de {{name}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"series": {
|
"series": {
|
||||||
|
|||||||
Reference in New Issue
Block a user