From b497746cfa4164d0fdebcf89d4edfa244e1a8657 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 4 Jan 2026 06:19:45 +0100 Subject: [PATCH] feat: enhance home and library pages by integrating new data fetching methods, improving error handling, and refactoring components for better structure --- src/app/layout.tsx | 19 +- .../[libraryId]/ClientLibraryPage.tsx | 271 ------------------ .../[libraryId]/LibraryClientWrapper.tsx | 57 ++++ .../libraries/[libraryId]/LibraryContent.tsx | 53 ++++ src/app/libraries/[libraryId]/page.tsx | 63 +++- src/app/page.tsx | 34 ++- .../series/[seriesId]/ClientSeriesPage.tsx | 209 -------------- .../series/[seriesId]/SeriesClientWrapper.tsx | 61 ++++ src/app/series/[seriesId]/SeriesContent.tsx | 48 ++++ src/app/series/[seriesId]/page.tsx | 54 +++- src/components/home/ClientHomePage.tsx | 156 ---------- src/components/home/HomeClientWrapper.tsx | 60 ++++ src/components/home/HomeContent.tsx | 160 ++++------- src/components/home/MediaRow.tsx | 19 +- src/components/layout/Sidebar.tsx | 55 +--- src/contexts/RefreshContext.tsx | 31 ++ .../services/client-offlinebook.service.ts | 40 ++- src/lib/services/favorites.service.ts | 39 +++ tailwind.config.ts | 3 +- 19 files changed, 598 insertions(+), 834 deletions(-) delete mode 100644 src/app/libraries/[libraryId]/ClientLibraryPage.tsx create mode 100644 src/app/libraries/[libraryId]/LibraryClientWrapper.tsx create mode 100644 src/app/libraries/[libraryId]/LibraryContent.tsx delete mode 100644 src/app/series/[seriesId]/ClientSeriesPage.tsx create mode 100644 src/app/series/[seriesId]/SeriesClientWrapper.tsx create mode 100644 src/app/series/[seriesId]/SeriesContent.tsx delete mode 100644 src/components/home/ClientHomePage.tsx create mode 100644 src/components/home/HomeClientWrapper.tsx create mode 100644 src/contexts/RefreshContext.tsx create mode 100644 src/lib/services/favorites.service.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fbac5d2..49f5a7f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -71,14 +71,17 @@ export default async function RootLayout({ children }: { children: React.ReactNo const cookieStore = await cookies(); const locale = cookieStore.get("NEXT_LOCALE")?.value || "fr"; - // Les libraries et favorites sont chargés côté client par la Sidebar let preferences: UserPreferences = defaultPreferences; let userIsAdmin = false; + let libraries: any[] = []; + let favorites: any[] = []; try { - const [preferencesData, isAdminCheck] = await Promise.allSettled([ + const [preferencesData, isAdminCheck, librariesData, favoritesData] = await Promise.allSettled([ PreferencesService.getPreferences(), import("@/lib/auth-utils").then((m) => m.isAdmin()), + import("@/lib/services/library.service").then((m) => m.LibraryService.getLibraries()), + import("@/lib/services/favorites.service").then((m) => m.FavoritesService.getFavorites()), ]); if (preferencesData.status === "fulfilled") { @@ -88,8 +91,16 @@ export default async function RootLayout({ children }: { children: React.ReactNo if (isAdminCheck.status === "fulfilled") { userIsAdmin = isAdminCheck.value; } + + if (librariesData.status === "fulfilled") { + libraries = librariesData.value; + } + + if (favoritesData.status === "fulfilled") { + favorites = favoritesData.value; + } } catch (error) { - logger.error({ err: error }, "Erreur lors du chargement des préférences:"); + logger.error({ err: error }, "Erreur lors du chargement des données initiales:"); } return ( @@ -155,7 +166,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo - + {children} diff --git a/src/app/libraries/[libraryId]/ClientLibraryPage.tsx b/src/app/libraries/[libraryId]/ClientLibraryPage.tsx deleted file mode 100644 index 61a04ba..0000000 --- a/src/app/libraries/[libraryId]/ClientLibraryPage.tsx +++ /dev/null @@ -1,271 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid"; -import { RefreshButton } from "@/components/library/RefreshButton"; -import { LibraryHeader } from "@/components/library/LibraryHeader"; -import { ErrorMessage } from "@/components/ui/ErrorMessage"; -import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator"; -import { usePullToRefresh } from "@/hooks/usePullToRefresh"; -import { useTranslate } from "@/hooks/useTranslate"; -import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons"; -import type { LibraryResponse } from "@/types/library"; -import type { KomgaSeries, KomgaLibrary } from "@/types/komga"; -import type { UserPreferences } from "@/types/preferences"; -import { Container } from "@/components/ui/container"; -import { Section } from "@/components/ui/section"; -import logger from "@/lib/logger"; - -interface ClientLibraryPageProps { - currentPage: number; - libraryId: string; - preferences: UserPreferences; - unreadOnly: boolean; - search?: string; - pageSize?: number; -} - -const DEFAULT_PAGE_SIZE = 20; - -export function ClientLibraryPage({ - currentPage, - libraryId, - preferences, - unreadOnly, - search, - pageSize, -}: ClientLibraryPageProps) { - const { t } = useTranslate(); - const [library, setLibrary] = useState(null); - const [series, setSeries] = useState | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE; - - useEffect(() => { - const abortController = new AbortController(); - - const fetchData = async () => { - setLoading(true); - setError(null); - - try { - const params = new URLSearchParams({ - page: String(currentPage - 1), - size: String(effectivePageSize), - unread: String(unreadOnly), - }); - - if (search) { - params.append("search", search); - } - - const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, { - signal: abortController.signal, - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR"); - } - - const data = await response.json(); - setLibrary(data.library); - setSeries(data.series); - } catch (err) { - // Ignore abort errors (caused by StrictMode cleanup) - if (err instanceof Error && err.name === "AbortError") { - return; - } - logger.error({ err }, "Error fetching library series"); - setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR"); - } finally { - if (!abortController.signal.aborted) { - setLoading(false); - } - } - }; - - fetchData(); - - return () => { - abortController.abort(); - }; - }, [libraryId, currentPage, unreadOnly, search, effectivePageSize]); - - const handleRefresh = async (libraryId: string) => { - try { - const params = new URLSearchParams({ - page: String(currentPage - 1), - size: String(effectivePageSize), - unread: String(unreadOnly), - }); - - if (search) { - params.append("search", search); - } - - const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, { - cache: "reload", - }); - - if (!response.ok) { - throw new Error("Error refreshing library"); - } - - const data = await response.json(); - setLibrary(data.library); - setSeries(data.series); - - return { success: true }; - } catch (error) { - logger.error({ err: error }, "Error during refresh:"); - return { success: false, error: "Error refreshing library" }; - } - }; - - const handleRetry = async () => { - setError(null); - setLoading(true); - - try { - const params = new URLSearchParams({ - page: String(currentPage - 1), - size: String(effectivePageSize), - unread: String(unreadOnly), - }); - - if (search) { - params.append("search", search); - } - - const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, { - cache: "reload", - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR"); - } - - const data = await response.json(); - setLibrary(data.library); - setSeries(data.series); - } catch (err) { - logger.error({ err }, "Error fetching library series"); - setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR"); - } finally { - setLoading(false); - } - }; - - const pullToRefresh = usePullToRefresh({ - onRefresh: async () => { - await handleRefresh(libraryId); - }, - enabled: !loading && !error && !!library && !!series, - }); - - if (loading) { - return ( - <> - {/* Header skeleton */} -
-
-
-
- -
- -
- - - -
-
-
-
-
- - - {/* Filters */} -
-
-
- -
-
- - - -
-
-
- - {/* Grid */} -
- {Array.from({ length: effectivePageSize }).map((_, i) => ( - - ))} -
- - {/* Pagination */} -
- - -
-
- - ); - } - - if (error) { - return ( - -
} - /> - - - ); - } - - if (!library || !series) { - return ( - - - - ); - } - - return ( - <> - - - - - - - ); -} diff --git a/src/app/libraries/[libraryId]/LibraryClientWrapper.tsx b/src/app/libraries/[libraryId]/LibraryClientWrapper.tsx new file mode 100644 index 0000000..76ec334 --- /dev/null +++ b/src/app/libraries/[libraryId]/LibraryClientWrapper.tsx @@ -0,0 +1,57 @@ +"use client"; + +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"; +import type { UserPreferences } from "@/types/preferences"; + +interface LibraryClientWrapperProps { + children: ReactNode; + libraryId: string; + currentPage: number; + unreadOnly: boolean; + search?: string; + pageSize: number; + preferences: UserPreferences; +} + +export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) { + const router = useRouter(); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async () => { + try { + setIsRefreshing(true); + // Revalider la page côté serveur + router.refresh(); + return { success: true }; + } catch { + return { success: false, error: "Error refreshing library" }; + } finally { + // Petit délai pour laisser le temps au serveur de revalider + setTimeout(() => setIsRefreshing(false), 500); + } + }; + + const pullToRefresh = usePullToRefresh({ + onRefresh: async () => { + await handleRefresh(); + }, + enabled: !isRefreshing, + }); + + return ( + <> + + {children} + + ); +} diff --git a/src/app/libraries/[libraryId]/LibraryContent.tsx b/src/app/libraries/[libraryId]/LibraryContent.tsx new file mode 100644 index 0000000..25e7e24 --- /dev/null +++ b/src/app/libraries/[libraryId]/LibraryContent.tsx @@ -0,0 +1,53 @@ +"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"; +import type { UserPreferences } from "@/types/preferences"; + +interface LibraryContentProps { + library: KomgaLibrary; + series: LibraryResponse; + currentPage: number; + preferences: UserPreferences; + unreadOnly: boolean; + search?: string; + pageSize: number; +} + +export function LibraryContent({ + library, + series, + currentPage, + preferences, + unreadOnly, + pageSize, +}: LibraryContentProps) { + const { refreshLibrary } = useRefresh(); + + return ( + <> + ({ success: false }))} + /> + + + + + ); +} diff --git a/src/app/libraries/[libraryId]/page.tsx b/src/app/libraries/[libraryId]/page.tsx index 2159072..e32daff 100644 --- a/src/app/libraries/[libraryId]/page.tsx +++ b/src/app/libraries/[libraryId]/page.tsx @@ -1,5 +1,10 @@ import { PreferencesService } from "@/lib/services/preferences.service"; -import { ClientLibraryPage } from "./ClientLibraryPage"; +import { LibraryService } from "@/lib/services/library.service"; +import { LibraryClientWrapper } from "./LibraryClientWrapper"; +import { LibraryContent } from "./LibraryContent"; +import { ErrorMessage } from "@/components/ui/ErrorMessage"; +import { AppError } from "@/utils/errors"; +import { ERROR_CODES } from "@/constants/errorCodes"; import type { UserPreferences } from "@/types/preferences"; interface PageProps { @@ -7,6 +12,8 @@ interface PageProps { searchParams: Promise<{ page?: string; unread?: string; search?: string; size?: string }>; } +const DEFAULT_PAGE_SIZE = 20; + export default async function LibraryPage({ params, searchParams }: PageProps) { const libraryId = (await params).libraryId; const unread = (await searchParams).unread; @@ -19,15 +26,49 @@ export default async function LibraryPage({ params, searchParams }: PageProps) { // Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread; + const effectivePageSize = size + ? parseInt(size) + : preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE; - return ( - - ); + try { + const [series, library] = await Promise.all([ + LibraryService.getLibrarySeries( + libraryId, + currentPage - 1, + effectivePageSize, + unreadOnly, + search + ), + LibraryService.getLibrary(libraryId), + ]); + + return ( + + + + ); + } catch (error) { + const errorCode = error instanceof AppError ? error.code : ERROR_CODES.SERIES.FETCH_ERROR; + + return ( +
+ +
+ ); + } } diff --git a/src/app/page.tsx b/src/app/page.tsx index 7472c46..930446e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,33 @@ -import { ClientHomePage } from "@/components/home/ClientHomePage"; +import { HomeService } from "@/lib/services/home.service"; +import { HomeContent } from "@/components/home/HomeContent"; +import { HomeClientWrapper } from "@/components/home/HomeClientWrapper"; +import { ErrorMessage } from "@/components/ui/ErrorMessage"; +import { ERROR_CODES } from "@/constants/errorCodes"; +import { AppError } from "@/utils/errors"; +import { redirect } from "next/navigation"; -export default function HomePage() { - return ; +export default async function HomePage() { + try { + const data = await HomeService.getHomeData(); + + return ( + + + + ); + } catch (error) { + // Si la config Komga est manquante, rediriger vers les settings + if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) { + redirect("/settings"); + } + + // Afficher une erreur pour les autres cas + const errorCode = error instanceof AppError ? error.code : ERROR_CODES.KOMGA.SERVER_UNREACHABLE; + + return ( +
+ +
+ ); + } } diff --git a/src/app/series/[seriesId]/ClientSeriesPage.tsx b/src/app/series/[seriesId]/ClientSeriesPage.tsx deleted file mode 100644 index 7afec2d..0000000 --- a/src/app/series/[seriesId]/ClientSeriesPage.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid"; -import { SeriesHeader } from "@/components/series/SeriesHeader"; -import { ErrorMessage } from "@/components/ui/ErrorMessage"; -import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator"; -import { usePullToRefresh } from "@/hooks/usePullToRefresh"; -import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons"; -import type { LibraryResponse } from "@/types/library"; -import type { KomgaBook, KomgaSeries } from "@/types/komga"; -import type { UserPreferences } from "@/types/preferences"; -import { ERROR_CODES } from "@/constants/errorCodes"; -import logger from "@/lib/logger"; - -interface ClientSeriesPageProps { - seriesId: string; - currentPage: number; - preferences: UserPreferences; - unreadOnly: boolean; - pageSize?: number; -} - -const DEFAULT_PAGE_SIZE = 20; - -export function ClientSeriesPage({ - seriesId, - currentPage, - preferences, - unreadOnly, - pageSize, -}: ClientSeriesPageProps) { - const [series, setSeries] = useState(null); - const [books, setBooks] = useState | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE; - - useEffect(() => { - const abortController = new AbortController(); - - const fetchData = async () => { - setLoading(true); - setError(null); - - try { - const params = new URLSearchParams({ - page: String(currentPage - 1), - size: String(effectivePageSize), - unread: String(unreadOnly), - }); - - const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, { - signal: abortController.signal, - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR); - } - - const data = await response.json(); - setSeries(data.series); - setBooks(data.books); - } catch (err) { - // Ignore abort errors (caused by StrictMode cleanup) - if (err instanceof Error && err.name === "AbortError") { - return; - } - logger.error({ err }, "Error fetching series books"); - setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR); - } finally { - if (!abortController.signal.aborted) { - setLoading(false); - } - } - }; - - fetchData(); - - return () => { - abortController.abort(); - }; - }, [seriesId, currentPage, unreadOnly, effectivePageSize]); - - const handleRefresh = async (seriesId: string) => { - try { - const params = new URLSearchParams({ - page: String(currentPage - 1), - size: String(effectivePageSize), - unread: String(unreadOnly), - }); - - const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, { - cache: "reload", - }); - - if (!response.ok) { - throw new Error("Erreur lors du rafraîchissement de la série"); - } - - const data = await response.json(); - setSeries(data.series); - setBooks(data.books); - - return { success: true }; - } catch (error) { - logger.error({ err: error }, "Erreur lors du rafraîchissement:"); - return { success: false, error: "Erreur lors du rafraîchissement de la série" }; - } - }; - - const handleRetry = async () => { - setError(null); - setLoading(true); - - try { - const params = new URLSearchParams({ - page: String(currentPage - 1), - size: String(effectivePageSize), - unread: String(unreadOnly), - }); - - const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, { - cache: "reload", - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR); - } - - const data = await response.json(); - setSeries(data.series); - setBooks(data.books); - } catch (err) { - logger.error({ err }, "Error fetching series books"); - setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR); - } finally { - setLoading(false); - } - }; - - const pullToRefresh = usePullToRefresh({ - onRefresh: async () => { - await handleRefresh(seriesId); - }, - enabled: !loading && !error && !!series && !!books, - }); - - if (loading) { - return ( -
-
- - -
-
- {Array.from({ length: effectivePageSize }).map((_, i) => ( - - ))} -
-
- ); - } - - if (error) { - return ( -
-

Série

- -
- ); - } - - if (!series || !books) { - return ( -
-

Série

- -
- ); - } - - return ( - <> - -
- - handleRefresh(seriesId)} - /> -
- - ); -} diff --git a/src/app/series/[seriesId]/SeriesClientWrapper.tsx b/src/app/series/[seriesId]/SeriesClientWrapper.tsx new file mode 100644 index 0000000..332e6ff --- /dev/null +++ b/src/app/series/[seriesId]/SeriesClientWrapper.tsx @@ -0,0 +1,61 @@ +"use client"; + +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"; +import type { UserPreferences } from "@/types/preferences"; + +interface SeriesClientWrapperProps { + children: ReactNode; + seriesId: string; + currentPage: number; + unreadOnly: boolean; + pageSize: number; + preferences: UserPreferences; +} + +export function SeriesClientWrapper({ + children, +}: SeriesClientWrapperProps) { + const router = useRouter(); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async () => { + try { + setIsRefreshing(true); + // Revalider la page côté serveur + router.refresh(); + return { success: true }; + } catch { + return { success: false, error: "Error refreshing series" }; + } finally { + // Petit délai pour laisser le temps au serveur de revalider + setTimeout(() => setIsRefreshing(false), 500); + } + }; + + const pullToRefresh = usePullToRefresh({ + onRefresh: async () => { + await handleRefresh(); + }, + enabled: !isRefreshing, + }); + + return ( + <> + + + {children} + + + ); +} + diff --git a/src/app/series/[seriesId]/SeriesContent.tsx b/src/app/series/[seriesId]/SeriesContent.tsx new file mode 100644 index 0000000..51ef8fd --- /dev/null +++ b/src/app/series/[seriesId]/SeriesContent.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid"; +import { SeriesHeader } from "@/components/series/SeriesHeader"; +import { Container } from "@/components/ui/container"; +import { useRefresh } from "@/contexts/RefreshContext"; +import type { LibraryResponse } from "@/types/library"; +import type { KomgaBook, KomgaSeries } from "@/types/komga"; +import type { UserPreferences } from "@/types/preferences"; + +interface SeriesContentProps { + series: KomgaSeries; + books: LibraryResponse; + currentPage: number; + preferences: UserPreferences; + unreadOnly: boolean; + pageSize: number; +} + +export function SeriesContent({ + series, + books, + currentPage, + preferences, + unreadOnly, +}: SeriesContentProps) { + const { refreshSeries } = useRefresh(); + + return ( + <> + ({ success: false }))} + /> + + + + + ); +} + diff --git a/src/app/series/[seriesId]/page.tsx b/src/app/series/[seriesId]/page.tsx index 28a819c..217b005 100644 --- a/src/app/series/[seriesId]/page.tsx +++ b/src/app/series/[seriesId]/page.tsx @@ -1,5 +1,10 @@ import { PreferencesService } from "@/lib/services/preferences.service"; -import { ClientSeriesPage } from "./ClientSeriesPage"; +import { SeriesService } from "@/lib/services/series.service"; +import { SeriesClientWrapper } from "./SeriesClientWrapper"; +import { SeriesContent } from "./SeriesContent"; +import { ErrorMessage } from "@/components/ui/ErrorMessage"; +import { AppError } from "@/utils/errors"; +import { ERROR_CODES } from "@/constants/errorCodes"; import type { UserPreferences } from "@/types/preferences"; interface PageProps { @@ -7,6 +12,8 @@ interface PageProps { searchParams: Promise<{ page?: string; unread?: string; size?: string }>; } +const DEFAULT_PAGE_SIZE = 20; + export default async function SeriesPage({ params, searchParams }: PageProps) { const seriesId = (await params).seriesId; const page = (await searchParams).page; @@ -18,14 +25,41 @@ export default async function SeriesPage({ params, searchParams }: PageProps) { // Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread; + const effectivePageSize = size ? parseInt(size) : preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE; - return ( - - ); + try { + const [books, series] = await Promise.all([ + SeriesService.getSeriesBooks(seriesId, currentPage - 1, effectivePageSize, unreadOnly), + SeriesService.getSeries(seriesId), + ]); + + return ( + + + + ); + } catch (error) { + const errorCode = error instanceof AppError + ? error.code + : ERROR_CODES.BOOK.PAGES_FETCH_ERROR; + + return ( +
+ +
+ ); + } } diff --git a/src/components/home/ClientHomePage.tsx b/src/components/home/ClientHomePage.tsx deleted file mode 100644 index ac2e66e..0000000 --- a/src/components/home/ClientHomePage.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { HomeContent } from "./HomeContent"; -import { ErrorMessage } from "@/components/ui/ErrorMessage"; -import { HomePageSkeleton } from "@/components/skeletons/OptimizedSkeletons"; -import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator"; -import { usePullToRefresh } from "@/hooks/usePullToRefresh"; -import { ERROR_CODES } from "@/constants/errorCodes"; -import type { HomeData } from "@/types/home"; -import logger from "@/lib/logger"; - -export function ClientHomePage() { - const router = useRouter(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const abortController = new AbortController(); - - const fetchData = async () => { - setLoading(true); - setError(null); - - try { - const response = await fetch("/api/komga/home", { - signal: abortController.signal, - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE; - - // Si la config Komga est manquante, rediriger vers les settings - if (errorCode === ERROR_CODES.KOMGA.MISSING_CONFIG) { - router.push("/settings"); - return; - } - - throw new Error(errorCode); - } - - const homeData = await response.json(); - setData(homeData); - } catch (err) { - // Ignore abort errors (caused by StrictMode cleanup) - if (err instanceof Error && err.name === "AbortError") { - return; - } - logger.error({ err }, "Error fetching home data"); - setError(err instanceof Error ? err.message : ERROR_CODES.KOMGA.SERVER_UNREACHABLE); - } finally { - if (!abortController.signal.aborted) { - setLoading(false); - } - } - }; - - fetchData(); - - return () => { - abortController.abort(); - }; - }, [router]); - - const handleRefresh = async () => { - try { - const response = await fetch("/api/komga/home", { - cache: "reload", - }); - - if (!response.ok) { - throw new Error("Erreur lors du rafraîchissement de la page d'accueil"); - } - - const homeData = await response.json(); - setData(homeData); - - return { success: true }; - } catch (error) { - logger.error({ err: error }, "Erreur lors du rafraîchissement:"); - return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" }; - } - }; - - const pullToRefresh = usePullToRefresh({ - onRefresh: async () => { - await handleRefresh(); - }, - enabled: !loading && !error && !!data, - }); - - const handleRetry = async () => { - setLoading(true); - setError(null); - - try { - const response = await fetch("/api/komga/home"); - - if (!response.ok) { - const errorData = await response.json(); - const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE; - - if (errorCode === ERROR_CODES.KOMGA.MISSING_CONFIG) { - router.push("/settings"); - return; - } - - throw new Error(errorCode); - } - - const homeData = await response.json(); - setData(homeData); - } catch (err) { - logger.error({ err }, "Error fetching home data"); - setError(err instanceof Error ? err.message : ERROR_CODES.KOMGA.SERVER_UNREACHABLE); - } finally { - setLoading(false); - } - }; - - if (loading) { - return ; - } - - if (error) { - return ( -
- -
- ); - } - - if (!data) { - return ( -
- -
- ); - } - - return ( - <> - - - - ); -} diff --git a/src/components/home/HomeClientWrapper.tsx b/src/components/home/HomeClientWrapper.tsx new file mode 100644 index 0000000..9a5ff9e --- /dev/null +++ b/src/components/home/HomeClientWrapper.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { RefreshButton } from "@/components/library/RefreshButton"; +import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator"; +import { usePullToRefresh } from "@/hooks/usePullToRefresh"; +import { useTranslate } from "@/hooks/useTranslate"; +import logger from "@/lib/logger"; + +interface HomeClientWrapperProps { + children: ReactNode; +} + +export function HomeClientWrapper({ children }: HomeClientWrapperProps) { + const router = useRouter(); + const { t } = useTranslate(); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async () => { + try { + setIsRefreshing(true); + // Revalider la page côté serveur + router.refresh(); + return { success: true }; + } catch (error) { + logger.error({ err: error }, "Erreur lors du rafraîchissement:"); + return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" }; + } finally { + // Petit délai pour laisser le temps au serveur de revalider + setTimeout(() => setIsRefreshing(false), 500); + } + }; + + const pullToRefresh = usePullToRefresh({ + onRefresh: async () => { + await handleRefresh(); + }, + enabled: !isRefreshing, + }); + + return ( + <> + +
+
+

{t("home.title")}

+ +
+ {children} +
+ + ); +} diff --git a/src/components/home/HomeContent.tsx b/src/components/home/HomeContent.tsx index e112c57..65a4290 100644 --- a/src/components/home/HomeContent.tsx +++ b/src/components/home/HomeContent.tsx @@ -1,122 +1,74 @@ -"use client"; - -import { HeroSection } from "./HeroSection"; import { MediaRow } from "./MediaRow"; import type { KomgaBook, KomgaSeries } from "@/types/komga"; import type { HomeData } from "@/types/home"; -import { RefreshButton } from "@/components/library/RefreshButton"; -import { History, Sparkles, Clock, LibraryBig, BookOpen } from "lucide-react"; -import { useTranslate } from "@/hooks/useTranslate"; -import { useEffect, useState } from "react"; interface HomeContentProps { data: HomeData; - refreshHome: () => Promise<{ success: boolean; error?: string }>; } -export function HomeContent({ data, refreshHome }: HomeContentProps) { - const { t } = useTranslate(); - const [showHero, setShowHero] = useState(false); +const optimizeSeriesData = (series: KomgaSeries[]) => { + return series.map(({ id, metadata, booksCount, booksReadCount }) => ({ + id, + metadata: { title: metadata.title }, + booksCount, + booksReadCount, + })); +}; - // Vérifier si la HeroSection a déjà été affichée - useEffect(() => { - const heroShown = localStorage.getItem("heroSectionShown"); - if (!heroShown && data.ongoing && data.ongoing.length > 0) { - setShowHero(true); - localStorage.setItem("heroSectionShown", "true"); - } - }, [data.ongoing]); - - // Vérification des données pour le debug - // logger.info("HomeContent - Données reçues:", { - // ongoingCount: data.ongoing?.length || 0, - // recentlyReadCount: data.recentlyRead?.length || 0, - // onDeckCount: data.onDeck?.length || 0, - // }); - - const optimizeSeriesData = (series: KomgaSeries[]) => { - return series.map(({ id, metadata, booksCount, booksReadCount }) => ({ - id, - metadata: { title: metadata.title }, - booksCount, - booksReadCount, - })); - }; - - const optimizeHeroSeriesData = (series: KomgaSeries[]) => { - return series.map(({ id, metadata, booksCount, booksReadCount }) => ({ - id, - metadata: { title: metadata.title }, - booksCount, - booksReadCount, - })); - }; - - const optimizeBookData = (books: KomgaBook[]) => { - return books.map(({ id, metadata, readProgress, media }) => ({ - id, - metadata: { - title: metadata.title, - number: metadata.number, - }, - readProgress: readProgress || { page: 0 }, - media, - })); - }; +const optimizeBookData = (books: KomgaBook[]) => { + return books.map(({ id, metadata, readProgress, media }) => ({ + id, + metadata: { + title: metadata.title, + number: metadata.number, + }, + readProgress: readProgress || { page: 0 }, + media, + })); +}; +export function HomeContent({ data }: HomeContentProps) { return ( -
-
-

{t("home.title")}

- -
- {/* Hero Section - Afficher uniquement si nous avons des séries en cours et si elle n'a jamais été affichée */} - {showHero && data.ongoing && data.ongoing.length > 0 && ( - +
+ {data.ongoing && data.ongoing.length > 0 && ( + )} - {/* Sections de contenu */} -
- {data.ongoing && data.ongoing.length > 0 && ( - - )} + {data.ongoingBooks && data.ongoingBooks.length > 0 && ( + + )} - {data.ongoingBooks && data.ongoingBooks.length > 0 && ( - - )} + {data.onDeck && data.onDeck.length > 0 && ( + + )} - {data.onDeck && data.onDeck.length > 0 && ( - - )} + {data.latestSeries && data.latestSeries.length > 0 && ( + + )} - {data.latestSeries && data.latestSeries.length > 0 && ( - - )} - - {data.recentlyRead && data.recentlyRead.length > 0 && ( - - )} -
-
+ {data.recentlyRead && data.recentlyRead.length > 0 && ( + + )} +
); } diff --git a/src/components/home/MediaRow.tsx b/src/components/home/MediaRow.tsx index 1fea6a5..3911f5f 100644 --- a/src/components/home/MediaRow.tsx +++ b/src/components/home/MediaRow.tsx @@ -7,7 +7,7 @@ import { SeriesCover } from "../ui/series-cover"; import { useTranslate } from "@/hooks/useTranslate"; import { ScrollContainer } from "@/components/ui/scroll-container"; import { Section } from "@/components/ui/section"; -import type { LucideIcon } from "lucide-react"; +import { History, Sparkles, Clock, LibraryBig, BookOpen } from "lucide-react"; import { Card } from "@/components/ui/card"; import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus"; import { cn } from "@/lib/utils"; @@ -38,14 +38,23 @@ interface OptimizedBook extends BaseItem { } interface MediaRowProps { - title: string; + titleKey: string; items: (OptimizedSeries | OptimizedBook)[]; - icon?: LucideIcon; + iconName?: string; } -export function MediaRow({ title, items, icon }: MediaRowProps) { +const iconMap = { + LibraryBig, + BookOpen, + Clock, + Sparkles, + History, +}; + +export function MediaRow({ titleKey, items, iconName }: MediaRowProps) { const router = useRouter(); const { t } = useTranslate(); + const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined; const onItemClick = (item: OptimizedSeries | OptimizedBook) => { const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`; @@ -55,7 +64,7 @@ export function MediaRow({ title, items, icon }: MediaRowProps) { if (!items.length) return null; return ( -
+
void; @@ -48,37 +43,12 @@ export function Sidebar({ const { t } = useTranslate(); const pathname = usePathname(); const router = useRouter(); - const { preferences } = usePreferences(); const [libraries, setLibraries] = useState(initialLibraries || []); const [favorites, setFavorites] = useState(initialFavorites || []); const [isRefreshing, setIsRefreshing] = useState(false); const { toast } = useToast(); - const refreshLibraries = useCallback(async () => { - setIsRefreshing(true); - try { - const response = await fetch("/api/komga/libraries"); - if (!response.ok) { - throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR); - } - const data = await response.json(); - setLibraries(data); - } catch (error) { - logger.error({ err: error }, "Erreur de chargement des bibliothèques:"); - toast({ - title: "Erreur", - description: - error instanceof AppError - ? error.message - : getErrorMessage(ERROR_CODES.LIBRARY.FETCH_ERROR), - variant: "destructive", - }); - } finally { - setIsRefreshing(false); - } - }, [toast]); - const refreshFavorites = useCallback(async () => { try { const favoritesResponse = await fetch("/api/komga/favorites"); @@ -115,22 +85,6 @@ export function Sidebar({ } }, [toast]); - useEffect(() => { - // Only load once when preferences become available (module-level flag survives StrictMode) - if ( - !sidebarInitialFetchDone && - !sidebarFetchInProgress && - Object.keys(preferences).length > 0 - ) { - sidebarFetchInProgress = true; - sidebarInitialFetchDone = true; - - Promise.all([refreshLibraries(), refreshFavorites()]).finally(() => { - sidebarFetchInProgress = false; - }); - } - }, [preferences, refreshLibraries, refreshFavorites]); - // Mettre à jour les favoris quand ils changent useEffect(() => { const handleFavoritesChange = () => { @@ -146,15 +100,14 @@ export function Sidebar({ const handleRefresh = async () => { setIsRefreshing(true); - await Promise.all([refreshLibraries(), refreshFavorites()]); + // Revalider côté serveur via router.refresh() + router.refresh(); + // Petit délai pour laisser le temps au serveur + setTimeout(() => setIsRefreshing(false), 500); }; const handleLogout = async () => { try { - // Reset module-level flags to allow refetch on next login - sidebarInitialFetchDone = false; - sidebarFetchInProgress = false; - await signOut({ callbackUrl: "/login" }); setLibraries([]); setFavorites([]); diff --git a/src/contexts/RefreshContext.tsx b/src/contexts/RefreshContext.tsx new file mode 100644 index 0000000..6e799d2 --- /dev/null +++ b/src/contexts/RefreshContext.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; + +interface RefreshContextType { + refreshLibrary?: (libraryId: string) => Promise<{ success: boolean; error?: string }>; + refreshSeries?: (seriesId: string) => Promise<{ success: boolean; error?: string }>; +} + +const RefreshContext = createContext({}); + +export function RefreshProvider({ + children, + refreshLibrary, + refreshSeries, +}: { + children: ReactNode; + refreshLibrary?: (libraryId: string) => Promise<{ success: boolean; error?: string }>; + refreshSeries?: (seriesId: string) => Promise<{ success: boolean; error?: string }>; +}) { + return ( + + {children} + + ); +} + +export function useRefresh() { + return useContext(RefreshContext); +} + diff --git a/src/lib/services/client-offlinebook.service.ts b/src/lib/services/client-offlinebook.service.ts index 9c586c2..22b03b4 100644 --- a/src/lib/services/client-offlinebook.service.ts +++ b/src/lib/services/client-offlinebook.service.ts @@ -2,30 +2,52 @@ import type { KomgaBook } from "@/types/komga"; export class ClientOfflineBookService { static setCurrentPage(book: KomgaBook, page: number) { - localStorage.setItem(`${book.id}-page`, page.toString()); + if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.setItem) { + try { + localStorage.setItem(`${book.id}-page`, page.toString()); + } catch { + // Ignore localStorage errors in SSR + } + } } static getCurrentPage(book: KomgaBook) { const readProgressPage = book.readProgress?.page || 0; - if (typeof localStorage !== "undefined") { - const cPageLS = localStorage.getItem(`${book.id}-page`) || "0"; - const currentPage = parseInt(cPageLS); + if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem) { + try { + const cPageLS = localStorage.getItem(`${book.id}-page`) || "0"; + const currentPage = parseInt(cPageLS); - if (currentPage < readProgressPage) { + if (currentPage < readProgressPage) { + return readProgressPage; + } + + return currentPage; + } catch { return readProgressPage; } - - return currentPage; } else { return readProgressPage; } } static removeCurrentPage(book: KomgaBook) { - localStorage.removeItem(`${book.id}-page`); + if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.removeItem) { + try { + localStorage.removeItem(`${book.id}-page`); + } catch { + // Ignore localStorage errors in SSR + } + } } static removeCurrentPageById(bookId: string) { - localStorage.removeItem(`${bookId}-page`); + if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.removeItem) { + try { + localStorage.removeItem(`${bookId}-page`); + } catch { + // Ignore localStorage errors in SSR + } + } } } diff --git a/src/lib/services/favorites.service.ts b/src/lib/services/favorites.service.ts new file mode 100644 index 0000000..2fc3912 --- /dev/null +++ b/src/lib/services/favorites.service.ts @@ -0,0 +1,39 @@ +import { FavoriteService } from "./favorite.service"; +import { SeriesService } from "./series.service"; +import type { KomgaSeries } from "@/types/komga"; +import logger from "@/lib/logger"; + +export class FavoritesService { + static async getFavorites(): Promise { + try { + const favoriteIds = await FavoriteService.getAllFavoriteIds(); + + if (favoriteIds.length === 0) { + return []; + } + + // Fetch toutes les séries en parallèle + const promises = favoriteIds.map(async (id: string) => { + try { + return await SeriesService.getSeries(id); + } catch (error) { + logger.error({ err: error, seriesId: id }, "Error fetching favorite series"); + // Si la série n'existe plus, la retirer des favoris + try { + await FavoriteService.removeFromFavorites(id); + } catch { + // Ignore cleanup errors + } + return null; + } + }); + + const results = await Promise.all(promises); + return results.filter((series): series is KomgaSeries => series !== null); + } catch (error) { + logger.error({ err: error }, "Error fetching favorites"); + return []; + } + } +} + diff --git a/tailwind.config.ts b/tailwind.config.ts index 18d2741..697fe47 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,4 +1,5 @@ import type { Config } from "tailwindcss"; +import tailwindcssAnimate from "tailwindcss-animate"; const config = { darkMode: ["class"], @@ -77,7 +78,7 @@ const config = { }, }, }, - plugins: [require("tailwindcss-animate")], + plugins: [tailwindcssAnimate], } satisfies Config; export default config;