refactor: make library rendering server-first and deterministic
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m7s

Move library header/covers to deterministic server-side rendering, split preference controls into controlled/uncontrolled modes, and remove client cover wrapper to eliminate hydration mismatches and provider coupling on library pages.
This commit is contained in:
2026-02-28 14:06:27 +01:00
parent 26021ea907
commit 01951c806d
14 changed files with 264 additions and 154 deletions

View File

@@ -19,7 +19,7 @@ export async function updateReadProgress(
await BookService.updateReadProgress(bookId, page, completed); await BookService.updateReadProgress(bookId, page, completed);
// Invalider le cache de la home (sans refresh auto) // Invalider le cache de la home (sans refresh auto)
revalidateTag(HOME_CACHE_TAG, "min"); revalidateTag(HOME_CACHE_TAG, "max");
return { success: true, message: "Progression mise à jour" }; return { success: true, message: "Progression mise à jour" };
} catch (error) { } catch (error) {
@@ -40,7 +40,7 @@ export async function deleteReadProgress(
await BookService.deleteReadProgress(bookId); await BookService.deleteReadProgress(bookId);
// Invalider le cache de la home (sans refresh auto) // Invalider le cache de la home (sans refresh auto)
revalidateTag(HOME_CACHE_TAG, "min"); revalidateTag(HOME_CACHE_TAG, "max");
return { success: true, message: "Progression supprimée" }; return { success: true, message: "Progression supprimée" };
} catch (error) { } catch (error) {

View File

@@ -4,7 +4,6 @@ import { useState, type ReactNode } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator"; import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh"; import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { RefreshProvider } from "@/contexts/RefreshContext";
interface LibraryClientWrapperProps { interface LibraryClientWrapperProps {
children: ReactNode; children: ReactNode;
@@ -42,7 +41,7 @@ export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) {
canRefresh={pullToRefresh.canRefresh} canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding} isHiding={pullToRefresh.isHiding}
/> />
<RefreshProvider refreshLibrary={handleRefresh}>{children}</RefreshProvider> {children}
</> </>
); );
} }

View File

@@ -1,9 +1,6 @@
"use client";
import { LibraryHeader } from "@/components/library/LibraryHeader"; import { LibraryHeader } from "@/components/library/LibraryHeader";
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid"; import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
import { Container } from "@/components/ui/container"; import { Container } from "@/components/ui/container";
import { useRefresh } from "@/contexts/RefreshContext";
import type { KomgaLibrary } from "@/types/komga"; import type { KomgaLibrary } from "@/types/komga";
import type { LibraryResponse } from "@/types/library"; import type { LibraryResponse } from "@/types/library";
import type { Series } from "@/types/series"; import type { Series } from "@/types/series";
@@ -27,15 +24,12 @@ export function LibraryContent({
unreadOnly, unreadOnly,
pageSize, pageSize,
}: LibraryContentProps) { }: LibraryContentProps) {
const { refreshLibrary } = useRefresh();
return ( return (
<> <>
<LibraryHeader <LibraryHeader
library={library} library={library}
seriesCount={series.totalElements} seriesCount={series.totalElements}
series={series.content || []} series={series.content || []}
refreshLibrary={refreshLibrary || (async () => ({ success: false }))}
/> />
<Container> <Container>
<PaginatedSeriesGrid <PaginatedSeriesGrid
@@ -46,6 +40,8 @@ export function LibraryContent({
defaultShowOnlyUnread={preferences.showOnlyUnread} defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly} showOnlyUnread={unreadOnly}
pageSize={pageSize} pageSize={pageSize}
initialCompact={preferences.displayMode.compact}
initialViewMode={preferences.displayMode.viewMode || "grid"}
/> />
</Container> </Container>
</> </>

View File

@@ -5,16 +5,21 @@ import { Button } from "@/components/ui/button";
interface CompactModeButtonProps { interface CompactModeButtonProps {
onToggle?: (isCompact: boolean) => void; onToggle?: (isCompact: boolean) => void;
isCompact?: boolean;
} }
export function CompactModeButton({ onToggle }: CompactModeButtonProps) { function CompactModeButtonBase({
const { isCompact, handleCompactToggle } = useDisplayPreferences(); isCompact,
onToggle,
}: {
isCompact: boolean;
onToggle: (isCompact: boolean) => Promise<void> | void;
}) {
const { t } = useTranslate(); const { t } = useTranslate();
const handleClick = async () => { const handleClick = async () => {
const newCompactState = !isCompact; const newCompactState = !isCompact;
await handleCompactToggle(newCompactState); await onToggle(newCompactState);
onToggle?.(newCompactState);
}; };
const Icon = isCompact ? LayoutTemplate : LayoutGrid; const Icon = isCompact ? LayoutTemplate : LayoutGrid;
@@ -33,3 +38,24 @@ export function CompactModeButton({ onToggle }: CompactModeButtonProps) {
</Button> </Button>
); );
} }
function CompactModeButtonUncontrolled({ onToggle }: Pick<CompactModeButtonProps, "onToggle">) {
const { isCompact, handleCompactToggle } = useDisplayPreferences();
const handleToggle = async (nextCompactMode: boolean) => {
await handleCompactToggle(nextCompactMode);
onToggle?.(nextCompactMode);
};
return <CompactModeButtonBase isCompact={isCompact} onToggle={handleToggle} />;
}
export function CompactModeButton({ onToggle, isCompact }: CompactModeButtonProps) {
const isControlled = typeof isCompact === "boolean" && typeof onToggle === "function";
if (isControlled) {
return <CompactModeButtonBase isCompact={isCompact} onToggle={onToggle} />;
}
return <CompactModeButtonUncontrolled onToggle={onToggle} />;
}

View File

@@ -10,19 +10,23 @@ import {
interface PageSizeSelectProps { interface PageSizeSelectProps {
onSizeChange?: (size: number) => void; onSizeChange?: (size: number) => void;
pageSize?: number;
} }
export function PageSizeSelect({ onSizeChange }: PageSizeSelectProps) { function PageSizeSelectBase({
const { itemsPerPage, handlePageSizeChange } = useDisplayPreferences(); value,
onChange,
const handleChange = async (value: string) => { }: {
const size = parseInt(value); value: number;
await handlePageSizeChange(size); onChange: (size: number) => Promise<void> | void;
onSizeChange?.(size); }) {
const handleChange = async (rawValue: string) => {
const size = parseInt(rawValue);
await onChange(size);
}; };
return ( return (
<Select value={itemsPerPage.toString()} onValueChange={handleChange}> <Select value={value.toString()} onValueChange={handleChange}>
<SelectTrigger className="w-[80px]"> <SelectTrigger className="w-[80px]">
<LayoutList className="h-4 w-4" /> <LayoutList className="h-4 w-4" />
<SelectValue className="ml-2" /> <SelectValue className="ml-2" />
@@ -35,3 +39,24 @@ export function PageSizeSelect({ onSizeChange }: PageSizeSelectProps) {
</Select> </Select>
); );
} }
function PageSizeSelectUncontrolled({ onSizeChange }: Pick<PageSizeSelectProps, "onSizeChange">) {
const { itemsPerPage, handlePageSizeChange } = useDisplayPreferences();
const onChange = async (size: number) => {
await handlePageSizeChange(size);
onSizeChange?.(size);
};
return <PageSizeSelectBase value={itemsPerPage} onChange={onChange} />;
}
export function PageSizeSelect({ onSizeChange, pageSize }: PageSizeSelectProps) {
const isControlled = typeof pageSize === "number" && typeof onSizeChange === "function";
if (isControlled) {
return <PageSizeSelectBase value={pageSize} onChange={onSizeChange} />;
}
return <PageSizeSelectUncontrolled onSizeChange={onSizeChange} />;
}

View File

@@ -5,16 +5,21 @@ import { Button } from "@/components/ui/button";
interface ViewModeButtonProps { interface ViewModeButtonProps {
onToggle?: (viewMode: "grid" | "list") => void; onToggle?: (viewMode: "grid" | "list") => void;
viewMode?: "grid" | "list";
} }
export function ViewModeButton({ onToggle }: ViewModeButtonProps) { function ViewModeButtonBase({
const { viewMode, handleViewModeToggle } = useDisplayPreferences(); viewMode,
onToggle,
}: {
viewMode: "grid" | "list";
onToggle: (viewMode: "grid" | "list") => Promise<void> | void;
}) {
const { t } = useTranslate(); const { t } = useTranslate();
const handleClick = async () => { const handleClick = async () => {
const newViewMode = viewMode === "grid" ? "list" : "grid"; const newViewMode = viewMode === "grid" ? "list" : "grid";
await handleViewModeToggle(newViewMode); await onToggle(newViewMode);
onToggle?.(newViewMode);
}; };
const Icon = viewMode === "grid" ? List : LayoutGrid; const Icon = viewMode === "grid" ? List : LayoutGrid;
@@ -33,3 +38,24 @@ export function ViewModeButton({ onToggle }: ViewModeButtonProps) {
</Button> </Button>
); );
} }
function ViewModeButtonUncontrolled({ onToggle }: Pick<ViewModeButtonProps, "onToggle">) {
const { viewMode, handleViewModeToggle } = useDisplayPreferences();
const handleToggle = async (nextViewMode: "grid" | "list") => {
await handleViewModeToggle(nextViewMode);
onToggle?.(nextViewMode);
};
return <ViewModeButtonBase viewMode={viewMode} onToggle={handleToggle} />;
}
export function ViewModeButton({ onToggle, viewMode }: ViewModeButtonProps) {
const isControlled = typeof viewMode === "string" && typeof onToggle === "function";
if (isControlled) {
return <ViewModeButtonBase viewMode={viewMode} onToggle={onToggle} />;
}
return <ViewModeButtonUncontrolled onToggle={onToggle} />;
}

View File

@@ -1,11 +1,7 @@
"use client";
import { useMemo } from "react";
import { Library } from "lucide-react"; import { Library } from "lucide-react";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga"; import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
import { RefreshButton } from "./RefreshButton"; import { RefreshButton } from "./RefreshButton";
import { ScanButton } from "./ScanButton"; import { ScanButton } from "./ScanButton";
import { useTranslate } from "@/hooks/useTranslate";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { SeriesCover } from "@/components/ui/series-cover"; import { SeriesCover } from "@/components/ui/series-cover";
@@ -13,39 +9,39 @@ interface LibraryHeaderProps {
library: KomgaLibrary; library: KomgaLibrary;
seriesCount: number; seriesCount: number;
series: KomgaSeries[]; series: KomgaSeries[];
refreshLibrary: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
} }
export const LibraryHeader = ({ const getHeaderSeries = (series: KomgaSeries[]) => {
if (series.length === 0) {
return { featured: null, background: null };
}
const featured = series[0] ?? null;
if (!featured) {
return { featured: null, background: null };
}
const background = series[1] ?? featured;
return { featured, background };
};
export function LibraryHeader({
library, library,
seriesCount, seriesCount,
series, series,
refreshLibrary, }: LibraryHeaderProps) {
}: LibraryHeaderProps) => { const { featured, background } = getHeaderSeries(series);
const { t } = useTranslate(); const seriesLabel = `${seriesCount} ${seriesCount > 1 ? "series" : "serie"}`;
// Mémoriser la sélection des séries pour éviter les rerenders inutiles
const { randomSeries, backgroundSeries } = useMemo(() => {
// Sélectionner une série aléatoire pour l'image centrale
const random = 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 background =
series.length > 1
? series.filter((s) => s.id !== random?.id)[Math.floor(Math.random() * (series.length - 1))]
: random;
return { randomSeries: random, backgroundSeries: background };
}, [series]);
return ( return (
<div className="relative min-h-[200px] md:h-[200px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden"> <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">
<div className="absolute inset-0 bg-black/40" /> <div className="absolute inset-0 bg-black/40" />
{backgroundSeries ? ( {background ? (
<SeriesCover <SeriesCover
series={backgroundSeries} series={background}
alt="" alt=""
className="blur-sm scale-105 brightness-50" className="blur-sm scale-105 brightness-50"
showProgressUi={false} showProgressUi={false}
@@ -55,16 +51,14 @@ export const LibraryHeader = ({
)} )}
</div> </div>
{/* Contenu */}
<div className="relative container mx-auto px-4 py-8 h-full"> <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"> <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"> <div className="relative w-[120px] h-[120px] rounded-lg overflow-hidden shadow-lg flex-shrink-0">
{randomSeries ? ( {featured ? (
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<SeriesCover <SeriesCover
series={randomSeries} series={featured}
alt={t("library.header.coverAlt", { name: library.name })} alt={`Couverture de ${library.name}`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
showProgressUi={false} showProgressUi={false}
/> />
@@ -79,27 +73,22 @@ export const LibraryHeader = ({
)} )}
</div> </div>
{/* Informations */}
<div className="flex-1 space-y-3 text-center md:text-left"> <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> <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"> <div className="flex items-center gap-4 justify-center md:justify-start flex-wrap">
<StatusBadge status="unread" icon={Library}> <StatusBadge status="unread" icon={Library}>
{seriesCount === 1 {seriesLabel}
? t("library.header.series", { count: seriesCount })
: t("library.header.series_plural", { count: seriesCount })}
</StatusBadge> </StatusBadge>
<RefreshButton libraryId={library.id} refreshLibrary={refreshLibrary} /> <RefreshButton libraryId={library.id} />
<ScanButton libraryId={library.id} /> <ScanButton libraryId={library.id} />
</div> </div>
{library.unavailable && ( {library.unavailable && <p className="text-sm text-destructive mt-2">Bibliotheque indisponible</p>}
<p className="text-sm text-destructive mt-2">{t("library.header.unavailable")}</p>
)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
}; }

View File

@@ -8,12 +8,11 @@ import { useState, useEffect, useCallback } from "react";
import type { KomgaSeries } from "@/types/komga"; import type { KomgaSeries } from "@/types/komga";
import { SearchInput } from "./SearchInput"; import { SearchInput } from "./SearchInput";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
import { usePreferences } from "@/contexts/PreferencesContext";
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 { ViewModeButton } from "@/components/common/ViewModeButton"; import { ViewModeButton } from "@/components/common/ViewModeButton";
import { UnreadFilterButton } from "@/components/common/UnreadFilterButton"; import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
import { updatePreferences as updatePreferencesAction } from "@/app/actions/preferences";
interface PaginatedSeriesGridProps { interface PaginatedSeriesGridProps {
series: KomgaSeries[]; series: KomgaSeries[];
@@ -23,6 +22,8 @@ interface PaginatedSeriesGridProps {
defaultShowOnlyUnread: boolean; defaultShowOnlyUnread: boolean;
showOnlyUnread: boolean; showOnlyUnread: boolean;
pageSize?: number; pageSize?: number;
initialCompact: boolean;
initialViewMode: "grid" | "list";
} }
export function PaginatedSeriesGrid({ export function PaginatedSeriesGrid({
@@ -33,18 +34,28 @@ export function PaginatedSeriesGrid({
defaultShowOnlyUnread, defaultShowOnlyUnread,
showOnlyUnread: initialShowOnlyUnread, showOnlyUnread: initialShowOnlyUnread,
pageSize, pageSize,
initialCompact,
initialViewMode,
}: 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: displayItemsPerPage, viewMode } = useDisplayPreferences(); const [isCompact, setIsCompact] = useState(initialCompact);
const { updatePreferences } = usePreferences(); const [viewMode, setViewMode] = useState<"grid" | "list">(initialViewMode);
const [currentPageSize, setCurrentPageSize] = useState(pageSize || 20);
// Utiliser la taille de page effective (depuis l'URL ou les préférences) const effectivePageSize = pageSize || currentPageSize;
const effectivePageSize = pageSize || displayItemsPerPage;
const { t } = useTranslate(); const { t } = useTranslate();
const persistPreferences = useCallback(async (payload: Parameters<typeof updatePreferencesAction>[0]) => {
try {
await updatePreferencesAction(payload);
} catch (error) {
console.error("Erreur lors de la sauvegarde des préférences:", error);
}
}, []);
const updateUrlParams = useCallback( const updateUrlParams = useCallback(
async (updates: Record<string, string | null>, replace: boolean = false) => { async (updates: Record<string, string | null>, replace: boolean = false) => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
@@ -71,6 +82,18 @@ export function PaginatedSeriesGrid({
setShowOnlyUnread(initialShowOnlyUnread); setShowOnlyUnread(initialShowOnlyUnread);
}, [initialShowOnlyUnread]); }, [initialShowOnlyUnread]);
useEffect(() => {
setIsCompact(initialCompact);
}, [initialCompact]);
useEffect(() => {
setViewMode(initialViewMode);
}, [initialViewMode]);
useEffect(() => {
setCurrentPageSize(pageSize || 20);
}, [pageSize]);
// Apply default filter on initial load // Apply default filter on initial load
useEffect(() => { useEffect(() => {
if (defaultShowOnlyUnread && !searchParams.has("unread")) { if (defaultShowOnlyUnread && !searchParams.has("unread")) {
@@ -89,20 +112,47 @@ export function PaginatedSeriesGrid({
page: "1", page: "1",
unread: newUnreadState ? "true" : "false", unread: newUnreadState ? "true" : "false",
}); });
// Sauvegarder la préférence dans la base de données await persistPreferences({ showOnlyUnread: newUnreadState });
try {
await updatePreferences({ showOnlyUnread: newUnreadState });
} catch (error) {
// Log l'erreur mais ne bloque pas l'utilisateur
console.error("Erreur lors de la sauvegarde de la préférence:", error);
}
}; };
const handlePageSizeChange = async (size: number) => { const handlePageSizeChange = async (size: number) => {
setCurrentPageSize(size);
await updateUrlParams({ await updateUrlParams({
page: "1", page: "1",
size: size.toString(), size: size.toString(),
}); });
await persistPreferences({
displayMode: {
compact: isCompact,
itemsPerPage: size,
viewMode,
},
});
};
const handleCompactModeToggle = async (nextCompactMode: boolean) => {
setIsCompact(nextCompactMode);
await persistPreferences({
displayMode: {
compact: nextCompactMode,
itemsPerPage: effectivePageSize,
viewMode,
},
});
};
const handleViewModeToggle = async (nextViewMode: "grid" | "list") => {
setViewMode(nextViewMode);
await persistPreferences({
displayMode: {
compact: isCompact,
itemsPerPage: effectivePageSize,
viewMode: nextViewMode,
},
});
}; };
// Calculate start and end indices for display // Calculate start and end indices for display
@@ -128,9 +178,9 @@ export function PaginatedSeriesGrid({
<SearchInput placeholder={t("series.filters.search")} /> <SearchInput placeholder={t("series.filters.search")} />
</div> </div>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<PageSizeSelect onSizeChange={handlePageSizeChange} /> <PageSizeSelect pageSize={effectivePageSize} onSizeChange={handlePageSizeChange} />
<ViewModeButton /> <ViewModeButton viewMode={viewMode} onToggle={handleViewModeToggle} />
<CompactModeButton /> <CompactModeButton isCompact={isCompact} onToggle={handleCompactModeToggle} />
<UnreadFilterButton showOnlyUnread={showOnlyUnread} onToggle={handleUnreadFilter} /> <UnreadFilterButton showOnlyUnread={showOnlyUnread} onToggle={handleUnreadFilter} />
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -9,27 +10,32 @@ import { useTranslation } from "react-i18next";
interface RefreshButtonProps { interface RefreshButtonProps {
libraryId: string; libraryId: string;
refreshLibrary: (libraryId: string) => Promise<{ success: boolean; error?: string }>; refreshLibrary?: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
} }
export function RefreshButton({ libraryId, refreshLibrary }: RefreshButtonProps) { export function RefreshButton({ libraryId, refreshLibrary }: RefreshButtonProps) {
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslation(); const { t } = useTranslation();
const handleRefresh = async () => { const handleRefresh = async () => {
setIsRefreshing(true); setIsRefreshing(true);
try { try {
const result = await refreshLibrary(libraryId); if (refreshLibrary) {
const result = await refreshLibrary(libraryId);
if (result.success) { if (!result.success) {
toast({ throw new Error(result.error);
title: t("library.refresh.success.title"), }
description: t("library.refresh.success.description"),
});
} else { } else {
throw new Error(result.error); router.refresh();
} }
toast({
title: t("library.refresh.success.title"),
description: t("library.refresh.success.description"),
});
} catch (error) { } catch (error) {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -50,6 +56,7 @@ export function RefreshButton({ libraryId, refreshLibrary }: RefreshButtonProps)
disabled={isRefreshing} disabled={isRefreshing}
className="ml-2" className="ml-2"
aria-label={t("library.refresh.button")} aria-label={t("library.refresh.button")}
data-library-id={libraryId}
> >
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} /> <RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
</Button> </Button>

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { CoverClient } from "./cover-client";
import { ProgressBar } from "./progress-bar"; import { ProgressBar } from "./progress-bar";
import type { BookCoverProps } from "./cover-utils"; import type { BookCoverProps } from "./cover-utils";
import { getImageUrl } from "@/lib/utils/image-url"; import { getImageUrl } from "@/lib/utils/image-url";
@@ -70,8 +69,7 @@ export function BookCover({
const currentPage = ClientOfflineBookService.getCurrentPage(book); const currentPage = ClientOfflineBookService.getCurrentPage(book);
const totalPages = book.media.pagesCount; const totalPages = book.media.pagesCount;
const showProgress = const showProgress = Boolean(showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted);
showProgressUi && currentPage && totalPages && currentPage > 0 && !isCompleted;
const statusInfo = getReadingStatusInfo(book, t); const statusInfo = getReadingStatusInfo(book, t);
const isRead = book.readProgress?.completed || false; const isRead = book.readProgress?.completed || false;
@@ -91,11 +89,17 @@ export function BookCover({
return ( return (
<> <>
<div className={`relative w-full h-full ${isUnavailable ? "opacity-40 grayscale" : ""}`}> <div className={`relative w-full h-full ${isUnavailable ? "opacity-40 grayscale" : ""}`}>
<CoverClient <img
imageUrl={imageUrl} src={imageUrl.trim()}
alt={alt || t("books.defaultCoverAlt")} alt={alt || t("books.defaultCoverAlt")}
className={className} loading="lazy"
isCompleted={isCompleted} className={[
"absolute inset-0 w-full h-full object-cover rounded-lg",
isCompleted ? "opacity-50" : "",
className || "",
]
.filter(Boolean)
.join(" ")}
/> />
{showProgress && <ProgressBar progress={currentPage} total={totalPages} type="book" />} {showProgress && <ProgressBar progress={currentPage} total={totalPages} type="book" />}
{/* Badge hors ligne si non accessible */} {/* Badge hors ligne si non accessible */}

View File

@@ -1,48 +0,0 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { cn } from "@/lib/utils";
import { ImageLoader } from "@/components/ui/image-loader";
interface CoverClientProps {
imageUrl: string;
alt: string;
className?: string;
isCompleted?: boolean;
}
export const CoverClient = ({
imageUrl,
alt,
className,
isCompleted = false,
}: CoverClientProps) => {
const imgRef = useRef<HTMLImageElement>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const img = imgRef.current;
if (img?.complete && img.naturalWidth > 0) {
setIsLoading(false);
}
}, []);
return (
<div className="relative w-full h-full">
<ImageLoader isLoading={isLoading} />
<img
ref={imgRef}
src={imageUrl}
alt={alt}
loading="lazy"
className={cn(
"absolute inset-0 w-full h-full object-cover rounded-lg",
isCompleted && "opacity-50",
className
)}
onLoad={() => setIsLoading(false)}
onError={() => setIsLoading(false)}
/>
</div>
);
};

View File

@@ -1,6 +1,3 @@
"use client";
import { CoverClient } from "./cover-client";
import { ProgressBar } from "./progress-bar"; import { ProgressBar } from "./progress-bar";
import type { SeriesCoverProps } from "./cover-utils"; import type { SeriesCoverProps } from "./cover-utils";
import { getImageUrl } from "@/lib/utils/image-url"; import { getImageUrl } from "@/lib/utils/image-url";
@@ -16,12 +13,23 @@ export function SeriesCover({
const readBooks = series.booksReadCount; const readBooks = series.booksReadCount;
const totalBooks = series.booksCount; const totalBooks = series.booksCount;
const showProgress = showProgressUi && readBooks && totalBooks && readBooks > 0 && !isCompleted; const showProgress = Boolean(showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
return ( return (
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<CoverClient imageUrl={imageUrl} alt={alt} className={className} isCompleted={isCompleted} /> <img
{showProgress && <ProgressBar progress={readBooks} total={totalBooks} type="series" />} src={imageUrl}
alt={alt}
loading="lazy"
className={[
"absolute inset-0 w-full h-full object-cover rounded-lg",
isCompleted ? "opacity-50" : "",
className || "",
]
.filter(Boolean)
.join(" ")}
/>
{showProgress ? <ProgressBar progress={readBooks} total={totalBooks} type="series" /> : null}
</div> </div>
); );
} }

View File

@@ -172,6 +172,18 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
return; return;
} }
if (process.env.NODE_ENV === "development") {
setIsSupported(false);
setIsReady(false);
setVersion(null);
unregisterServiceWorker().catch(() => {
// Ignore cleanup failures in development
});
return;
}
setIsSupported(true); setIsSupported(true);
// Register service worker // Register service worker

View File

@@ -15,6 +15,22 @@ interface KomgaLibraryRaw {
type KomgaCondition = Record<string, unknown>; type KomgaCondition = Record<string, unknown>;
const sortSeriesDeterministically = <T extends { id: string; metadata?: { titleSort?: string } }>(
items: T[]
): T[] => {
return [...items].sort((a, b) => {
const titleA = a.metadata?.titleSort ?? "";
const titleB = b.metadata?.titleSort ?? "";
const titleComparison = titleA.localeCompare(titleB);
if (titleComparison !== 0) {
return titleComparison;
}
return a.id.localeCompare(b.id);
});
};
export class LibraryService extends BaseApiService { export class LibraryService extends BaseApiService {
private static readonly CACHE_TTL = 300; // 5 minutes private static readonly CACHE_TTL = 300; // 5 minutes
@@ -121,13 +137,13 @@ export class LibraryService extends BaseApiService {
{ method: "POST", body: JSON.stringify(searchBody), revalidate: this.CACHE_TTL } { method: "POST", body: JSON.stringify(searchBody), revalidate: this.CACHE_TTL }
); );
// Filtrer uniquement les séries supprimées
const filteredContent = response.content.filter((series) => !series.deleted); const filteredContent = response.content.filter((series) => !series.deleted);
const sortedContent = sortSeriesDeterministically(filteredContent);
return { return {
...response, ...response,
content: filteredContent, content: sortedContent,
numberOfElements: filteredContent.length, numberOfElements: sortedContent.length,
}; };
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);