refactor: make library rendering server-first and deterministic
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m7s
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:
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user