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);
// 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" };
} catch (error) {
@@ -40,7 +40,7 @@ export async function deleteReadProgress(
await BookService.deleteReadProgress(bookId);
// 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" };
} catch (error) {

View File

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

View File

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

View File

@@ -5,16 +5,21 @@ import { Button } from "@/components/ui/button";
interface CompactModeButtonProps {
onToggle?: (isCompact: boolean) => void;
isCompact?: boolean;
}
export function CompactModeButton({ onToggle }: CompactModeButtonProps) {
const { isCompact, handleCompactToggle } = useDisplayPreferences();
function CompactModeButtonBase({
isCompact,
onToggle,
}: {
isCompact: boolean;
onToggle: (isCompact: boolean) => Promise<void> | void;
}) {
const { t } = useTranslate();
const handleClick = async () => {
const newCompactState = !isCompact;
await handleCompactToggle(newCompactState);
onToggle?.(newCompactState);
await onToggle(newCompactState);
};
const Icon = isCompact ? LayoutTemplate : LayoutGrid;
@@ -33,3 +38,24 @@ export function CompactModeButton({ onToggle }: CompactModeButtonProps) {
</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 {
onSizeChange?: (size: number) => void;
pageSize?: number;
}
export function PageSizeSelect({ onSizeChange }: PageSizeSelectProps) {
const { itemsPerPage, handlePageSizeChange } = useDisplayPreferences();
const handleChange = async (value: string) => {
const size = parseInt(value);
await handlePageSizeChange(size);
onSizeChange?.(size);
function PageSizeSelectBase({
value,
onChange,
}: {
value: number;
onChange: (size: number) => Promise<void> | void;
}) {
const handleChange = async (rawValue: string) => {
const size = parseInt(rawValue);
await onChange(size);
};
return (
<Select value={itemsPerPage.toString()} onValueChange={handleChange}>
<Select value={value.toString()} onValueChange={handleChange}>
<SelectTrigger className="w-[80px]">
<LayoutList className="h-4 w-4" />
<SelectValue className="ml-2" />
@@ -35,3 +39,24 @@ export function PageSizeSelect({ onSizeChange }: PageSizeSelectProps) {
</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 {
onToggle?: (viewMode: "grid" | "list") => void;
viewMode?: "grid" | "list";
}
export function ViewModeButton({ onToggle }: ViewModeButtonProps) {
const { viewMode, handleViewModeToggle } = useDisplayPreferences();
function ViewModeButtonBase({
viewMode,
onToggle,
}: {
viewMode: "grid" | "list";
onToggle: (viewMode: "grid" | "list") => Promise<void> | void;
}) {
const { t } = useTranslate();
const handleClick = async () => {
const newViewMode = viewMode === "grid" ? "list" : "grid";
await handleViewModeToggle(newViewMode);
onToggle?.(newViewMode);
await onToggle(newViewMode);
};
const Icon = viewMode === "grid" ? List : LayoutGrid;
@@ -33,3 +38,24 @@ export function ViewModeButton({ onToggle }: ViewModeButtonProps) {
</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 type { KomgaLibrary, KomgaSeries } from "@/types/komga";
import { RefreshButton } from "./RefreshButton";
import { ScanButton } from "./ScanButton";
import { useTranslate } from "@/hooks/useTranslate";
import { StatusBadge } from "@/components/ui/status-badge";
import { SeriesCover } from "@/components/ui/series-cover";
@@ -13,39 +9,39 @@ interface LibraryHeaderProps {
library: KomgaLibrary;
seriesCount: number;
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,
seriesCount,
series,
refreshLibrary,
}: LibraryHeaderProps) => {
const { t } = useTranslate();
// 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]);
}: LibraryHeaderProps) {
const { featured, background } = getHeaderSeries(series);
const seriesLabel = `${seriesCount} ${seriesCount > 1 ? "series" : "serie"}`;
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 ? (
{background ? (
<SeriesCover
series={backgroundSeries}
series={background}
alt=""
className="blur-sm scale-105 brightness-50"
showProgressUi={false}
@@ -55,16 +51,14 @@ export const LibraryHeader = ({
)}
</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 ? (
{featured ? (
<div className="relative w-full h-full">
<SeriesCover
series={randomSeries}
alt={t("library.header.coverAlt", { name: library.name })}
series={featured}
alt={`Couverture de ${library.name}`}
className="w-full h-full object-cover"
showProgressUi={false}
/>
@@ -79,27 +73,22 @@ export const LibraryHeader = ({
)}
</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 })}
{seriesLabel}
</StatusBadge>
<RefreshButton libraryId={library.id} refreshLibrary={refreshLibrary} />
<RefreshButton libraryId={library.id} />
<ScanButton libraryId={library.id} />
</div>
{library.unavailable && (
<p className="text-sm text-destructive mt-2">{t("library.header.unavailable")}</p>
)}
{library.unavailable && <p className="text-sm text-destructive mt-2">Bibliotheque indisponible</p>}
</div>
</div>
</div>
</div>
);
};
}

View File

@@ -8,12 +8,11 @@ import { useState, useEffect, useCallback } from "react";
import type { KomgaSeries } from "@/types/komga";
import { SearchInput } from "./SearchInput";
import { useTranslate } from "@/hooks/useTranslate";
import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
import { usePreferences } from "@/contexts/PreferencesContext";
import { PageSizeSelect } from "@/components/common/PageSizeSelect";
import { CompactModeButton } from "@/components/common/CompactModeButton";
import { ViewModeButton } from "@/components/common/ViewModeButton";
import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
import { updatePreferences as updatePreferencesAction } from "@/app/actions/preferences";
interface PaginatedSeriesGridProps {
series: KomgaSeries[];
@@ -23,6 +22,8 @@ interface PaginatedSeriesGridProps {
defaultShowOnlyUnread: boolean;
showOnlyUnread: boolean;
pageSize?: number;
initialCompact: boolean;
initialViewMode: "grid" | "list";
}
export function PaginatedSeriesGrid({
@@ -33,18 +34,28 @@ export function PaginatedSeriesGrid({
defaultShowOnlyUnread,
showOnlyUnread: initialShowOnlyUnread,
pageSize,
initialCompact,
initialViewMode,
}: PaginatedSeriesGridProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread);
const { isCompact, itemsPerPage: displayItemsPerPage, viewMode } = useDisplayPreferences();
const { updatePreferences } = usePreferences();
const [isCompact, setIsCompact] = useState(initialCompact);
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 || displayItemsPerPage;
const effectivePageSize = pageSize || currentPageSize;
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(
async (updates: Record<string, string | null>, replace: boolean = false) => {
const params = new URLSearchParams(searchParams.toString());
@@ -71,6 +82,18 @@ export function PaginatedSeriesGrid({
setShowOnlyUnread(initialShowOnlyUnread);
}, [initialShowOnlyUnread]);
useEffect(() => {
setIsCompact(initialCompact);
}, [initialCompact]);
useEffect(() => {
setViewMode(initialViewMode);
}, [initialViewMode]);
useEffect(() => {
setCurrentPageSize(pageSize || 20);
}, [pageSize]);
// Apply default filter on initial load
useEffect(() => {
if (defaultShowOnlyUnread && !searchParams.has("unread")) {
@@ -89,20 +112,47 @@ export function PaginatedSeriesGrid({
page: "1",
unread: newUnreadState ? "true" : "false",
});
// Sauvegarder la préférence dans la base de données
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);
}
await persistPreferences({ showOnlyUnread: newUnreadState });
};
const handlePageSizeChange = async (size: number) => {
setCurrentPageSize(size);
await updateUrlParams({
page: "1",
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
@@ -128,9 +178,9 @@ export function PaginatedSeriesGrid({
<SearchInput placeholder={t("series.filters.search")} />
</div>
<div className="flex items-center justify-end gap-2">
<PageSizeSelect onSizeChange={handlePageSizeChange} />
<ViewModeButton />
<CompactModeButton />
<PageSizeSelect pageSize={effectivePageSize} onSizeChange={handlePageSizeChange} />
<ViewModeButton viewMode={viewMode} onToggle={handleViewModeToggle} />
<CompactModeButton isCompact={isCompact} onToggle={handleCompactModeToggle} />
<UnreadFilterButton showOnlyUnread={showOnlyUnread} onToggle={handleUnreadFilter} />
</div>
</div>

View File

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

View File

@@ -1,6 +1,5 @@
"use client";
import { CoverClient } from "./cover-client";
import { ProgressBar } from "./progress-bar";
import type { BookCoverProps } from "./cover-utils";
import { getImageUrl } from "@/lib/utils/image-url";
@@ -70,8 +69,7 @@ export function BookCover({
const currentPage = ClientOfflineBookService.getCurrentPage(book);
const totalPages = book.media.pagesCount;
const showProgress =
showProgressUi && currentPage && totalPages && currentPage > 0 && !isCompleted;
const showProgress = Boolean(showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted);
const statusInfo = getReadingStatusInfo(book, t);
const isRead = book.readProgress?.completed || false;
@@ -91,11 +89,17 @@ export function BookCover({
return (
<>
<div className={`relative w-full h-full ${isUnavailable ? "opacity-40 grayscale" : ""}`}>
<CoverClient
imageUrl={imageUrl}
<img
src={imageUrl.trim()}
alt={alt || t("books.defaultCoverAlt")}
className={className}
isCompleted={isCompleted}
loading="lazy"
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" />}
{/* 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 type { SeriesCoverProps } from "./cover-utils";
import { getImageUrl } from "@/lib/utils/image-url";
@@ -16,12 +13,23 @@ export function SeriesCover({
const readBooks = series.booksReadCount;
const totalBooks = series.booksCount;
const showProgress = showProgressUi && readBooks && totalBooks && readBooks > 0 && !isCompleted;
const showProgress = Boolean(showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
return (
<div className="relative w-full h-full">
<CoverClient imageUrl={imageUrl} alt={alt} className={className} isCompleted={isCompleted} />
{showProgress && <ProgressBar progress={readBooks} total={totalBooks} type="series" />}
<img
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>
);
}

View File

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

View File

@@ -15,6 +15,22 @@ interface KomgaLibraryRaw {
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 {
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 }
);
// Filtrer uniquement les séries supprimées
const filteredContent = response.content.filter((series) => !series.deleted);
const sortedContent = sortSeriesDeterministically(filteredContent);
return {
...response,
content: filteredContent,
numberOfElements: filteredContent.length,
content: sortedContent,
numberOfElements: sortedContent.length,
};
} catch (error) {
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);