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);
|
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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
if (refreshLibrary) {
|
||||||
const result = await refreshLibrary(libraryId);
|
const result = await refreshLibrary(libraryId);
|
||||||
|
|
||||||
if (result.success) {
|
if (!result.success) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("library.refresh.success.title"),
|
title: t("library.refresh.success.title"),
|
||||||
description: t("library.refresh.success.description"),
|
description: t("library.refresh.success.description"),
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
throw new Error(result.error);
|
|
||||||
}
|
|
||||||
} 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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user