diff --git a/src/app/actions/read-progress.ts b/src/app/actions/read-progress.ts index 95996c8..68b34fa 100644 --- a/src/app/actions/read-progress.ts +++ b/src/app/actions/read-progress.ts @@ -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) { diff --git a/src/app/libraries/[libraryId]/LibraryClientWrapper.tsx b/src/app/libraries/[libraryId]/LibraryClientWrapper.tsx index d2d3d9d..fd29446 100644 --- a/src/app/libraries/[libraryId]/LibraryClientWrapper.tsx +++ b/src/app/libraries/[libraryId]/LibraryClientWrapper.tsx @@ -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} /> - {children} + {children} ); } diff --git a/src/app/libraries/[libraryId]/LibraryContent.tsx b/src/app/libraries/[libraryId]/LibraryContent.tsx index 25e7e24..6598eb0 100644 --- a/src/app/libraries/[libraryId]/LibraryContent.tsx +++ b/src/app/libraries/[libraryId]/LibraryContent.tsx @@ -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 ( <> ({ success: false }))} /> diff --git a/src/components/common/CompactModeButton.tsx b/src/components/common/CompactModeButton.tsx index ca645de..5e96ddd 100644 --- a/src/components/common/CompactModeButton.tsx +++ b/src/components/common/CompactModeButton.tsx @@ -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; +}) { 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) { ); } + +function CompactModeButtonUncontrolled({ onToggle }: Pick) { + const { isCompact, handleCompactToggle } = useDisplayPreferences(); + + const handleToggle = async (nextCompactMode: boolean) => { + await handleCompactToggle(nextCompactMode); + onToggle?.(nextCompactMode); + }; + + return ; +} + +export function CompactModeButton({ onToggle, isCompact }: CompactModeButtonProps) { + const isControlled = typeof isCompact === "boolean" && typeof onToggle === "function"; + + if (isControlled) { + return ; + } + + return ; +} diff --git a/src/components/common/PageSizeSelect.tsx b/src/components/common/PageSizeSelect.tsx index f16262c..3f090a7 100644 --- a/src/components/common/PageSizeSelect.tsx +++ b/src/components/common/PageSizeSelect.tsx @@ -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; +}) { + const handleChange = async (rawValue: string) => { + const size = parseInt(rawValue); + await onChange(size); }; return ( - @@ -35,3 +39,24 @@ export function PageSizeSelect({ onSizeChange }: PageSizeSelectProps) { ); } + +function PageSizeSelectUncontrolled({ onSizeChange }: Pick) { + const { itemsPerPage, handlePageSizeChange } = useDisplayPreferences(); + + const onChange = async (size: number) => { + await handlePageSizeChange(size); + onSizeChange?.(size); + }; + + return ; +} + +export function PageSizeSelect({ onSizeChange, pageSize }: PageSizeSelectProps) { + const isControlled = typeof pageSize === "number" && typeof onSizeChange === "function"; + + if (isControlled) { + return ; + } + + return ; +} diff --git a/src/components/common/ViewModeButton.tsx b/src/components/common/ViewModeButton.tsx index e568832..9a98820 100644 --- a/src/components/common/ViewModeButton.tsx +++ b/src/components/common/ViewModeButton.tsx @@ -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; +}) { 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) { ); } + +function ViewModeButtonUncontrolled({ onToggle }: Pick) { + const { viewMode, handleViewModeToggle } = useDisplayPreferences(); + + const handleToggle = async (nextViewMode: "grid" | "list") => { + await handleViewModeToggle(nextViewMode); + onToggle?.(nextViewMode); + }; + + return ; +} + +export function ViewModeButton({ onToggle, viewMode }: ViewModeButtonProps) { + const isControlled = typeof viewMode === "string" && typeof onToggle === "function"; + + if (isControlled) { + return ; + } + + return ; +} diff --git a/src/components/library/LibraryHeader.tsx b/src/components/library/LibraryHeader.tsx index 7b18463..e1d929c 100644 --- a/src/components/library/LibraryHeader.tsx +++ b/src/components/library/LibraryHeader.tsx @@ -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 (
- {/* Image de fond avec une série aléatoire */}
- {backgroundSeries ? ( + {background ? ( - {/* Contenu */}
- {/* Cover centrale avec icône overlay */}
- {randomSeries ? ( + {featured ? (
@@ -79,27 +73,22 @@ export const LibraryHeader = ({ )}
- {/* Informations */}

{library.name}

- {seriesCount === 1 - ? t("library.header.series", { count: seriesCount }) - : t("library.header.series_plural", { count: seriesCount })} + {seriesLabel} - +
- {library.unavailable && ( -

{t("library.header.unavailable")}

- )} + {library.unavailable &&

Bibliotheque indisponible

}
); -}; +} diff --git a/src/components/library/PaginatedSeriesGrid.tsx b/src/components/library/PaginatedSeriesGrid.tsx index 372ee75..41d3e58 100644 --- a/src/components/library/PaginatedSeriesGrid.tsx +++ b/src/components/library/PaginatedSeriesGrid.tsx @@ -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[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, 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({
- - - + + +
diff --git a/src/components/library/RefreshButton.tsx b/src/components/library/RefreshButton.tsx index f747308..543dd1d 100644 --- a/src/components/library/RefreshButton.tsx +++ b/src/components/library/RefreshButton.tsx @@ -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 { - const result = await refreshLibrary(libraryId); + if (refreshLibrary) { + const result = await refreshLibrary(libraryId); - if (result.success) { - toast({ - title: t("library.refresh.success.title"), - description: t("library.refresh.success.description"), - }); + if (!result.success) { + throw new Error(result.error); + } } else { - throw new Error(result.error); + router.refresh(); } + + toast({ + title: t("library.refresh.success.title"), + description: t("library.refresh.success.description"), + }); } 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} > diff --git a/src/components/ui/book-cover.tsx b/src/components/ui/book-cover.tsx index 341b3a3..ee50b6d 100644 --- a/src/components/ui/book-cover.tsx +++ b/src/components/ui/book-cover.tsx @@ -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 ( <>
- {showProgress && } {/* Badge hors ligne si non accessible */} diff --git a/src/components/ui/cover-client.tsx b/src/components/ui/cover-client.tsx deleted file mode 100644 index 619a858..0000000 --- a/src/components/ui/cover-client.tsx +++ /dev/null @@ -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(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const img = imgRef.current; - if (img?.complete && img.naturalWidth > 0) { - setIsLoading(false); - } - }, []); - - return ( -
- - {alt} setIsLoading(false)} - onError={() => setIsLoading(false)} - /> -
- ); -}; diff --git a/src/components/ui/series-cover.tsx b/src/components/ui/series-cover.tsx index 5757f37..d797707 100644 --- a/src/components/ui/series-cover.tsx +++ b/src/components/ui/series-cover.tsx @@ -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 (
- - {showProgress && } + {alt} + {showProgress ? : null}
); } diff --git a/src/contexts/ServiceWorkerContext.tsx b/src/contexts/ServiceWorkerContext.tsx index dc5956c..08af324 100644 --- a/src/contexts/ServiceWorkerContext.tsx +++ b/src/contexts/ServiceWorkerContext.tsx @@ -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 diff --git a/src/lib/services/library.service.ts b/src/lib/services/library.service.ts index b3c9982..9c22802 100644 --- a/src/lib/services/library.service.ts +++ b/src/lib/services/library.service.ts @@ -15,6 +15,22 @@ interface KomgaLibraryRaw { type KomgaCondition = Record; +const sortSeriesDeterministically = ( + 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);