feat: enhance home and library pages by integrating new data fetching methods, improving error handling, and refactoring components for better structure
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m17s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m17s
This commit is contained in:
@@ -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
|
||||
<AuthProvider>
|
||||
<I18nProvider locale={locale}>
|
||||
<PreferencesProvider initialPreferences={preferences}>
|
||||
<ClientLayout initialLibraries={[]} initialFavorites={[]} userIsAdmin={userIsAdmin}>
|
||||
<ClientLayout initialLibraries={libraries} initialFavorites={favorites} userIsAdmin={userIsAdmin}>
|
||||
{children}
|
||||
</ClientLayout>
|
||||
</PreferencesProvider>
|
||||
|
||||
@@ -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<KomgaLibrary | null>(null);
|
||||
const [series, setSeries] = useState<LibraryResponse<KomgaSeries> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 */}
|
||||
<div className="relative min-h-[200px] md:h-[200px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden mb-8">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-primary/10 to-background" />
|
||||
<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">
|
||||
<OptimizedSkeleton className="w-[120px] h-[120px] rounded-lg" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<OptimizedSkeleton className="h-10 w-64" />
|
||||
<div className="flex gap-4">
|
||||
<OptimizedSkeleton className="h-8 w-32" />
|
||||
<OptimizedSkeleton className="h-8 w-32" />
|
||||
<OptimizedSkeleton className="h-10 w-10 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Container>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-4 mb-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="w-full">
|
||||
<OptimizedSkeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<OptimizedSkeleton className="h-10 w-24" />
|
||||
<OptimizedSkeleton className="h-10 w-10 rounded" />
|
||||
<OptimizedSkeleton className="h-10 w-10 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||
{Array.from({ length: effectivePageSize }).map((_, i) => (
|
||||
<OptimizedSkeleton key={i} className="aspect-[2/3] w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
||||
<OptimizedSkeleton className="h-5 w-32 order-2 sm:order-1" />
|
||||
<OptimizedSkeleton className="h-10 w-64 order-1 sm:order-2" />
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container>
|
||||
<Section
|
||||
title={library?.name || t("series.empty")}
|
||||
actions={<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />}
|
||||
/>
|
||||
<ErrorMessage errorCode={error} onRetry={handleRetry} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!library || !series) {
|
||||
return (
|
||||
<Container>
|
||||
<ErrorMessage errorCode="SERIES_FETCH_ERROR" onRetry={handleRetry} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefresh.isPulling}
|
||||
isRefreshing={pullToRefresh.isRefreshing}
|
||||
progress={pullToRefresh.progress}
|
||||
canRefresh={pullToRefresh.canRefresh}
|
||||
isHiding={pullToRefresh.isHiding}
|
||||
/>
|
||||
<LibraryHeader
|
||||
library={library}
|
||||
seriesCount={series.totalElements}
|
||||
series={series.content || []}
|
||||
refreshLibrary={handleRefresh}
|
||||
/>
|
||||
<Container>
|
||||
<PaginatedSeriesGrid
|
||||
series={series.content || []}
|
||||
currentPage={currentPage}
|
||||
totalPages={series.totalPages}
|
||||
totalElements={series.totalElements}
|
||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||
showOnlyUnread={unreadOnly}
|
||||
pageSize={effectivePageSize}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
57
src/app/libraries/[libraryId]/LibraryClientWrapper.tsx
Normal file
57
src/app/libraries/[libraryId]/LibraryClientWrapper.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefresh.isPulling}
|
||||
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
|
||||
progress={pullToRefresh.progress}
|
||||
canRefresh={pullToRefresh.canRefresh}
|
||||
isHiding={pullToRefresh.isHiding}
|
||||
/>
|
||||
<RefreshProvider refreshLibrary={handleRefresh}>{children}</RefreshProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
src/app/libraries/[libraryId]/LibraryContent.tsx
Normal file
53
src/app/libraries/[libraryId]/LibraryContent.tsx
Normal file
@@ -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<Series>;
|
||||
currentPage: number;
|
||||
preferences: UserPreferences;
|
||||
unreadOnly: boolean;
|
||||
search?: string;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export function LibraryContent({
|
||||
library,
|
||||
series,
|
||||
currentPage,
|
||||
preferences,
|
||||
unreadOnly,
|
||||
pageSize,
|
||||
}: LibraryContentProps) {
|
||||
const { refreshLibrary } = useRefresh();
|
||||
|
||||
return (
|
||||
<>
|
||||
<LibraryHeader
|
||||
library={library}
|
||||
seriesCount={series.totalElements}
|
||||
series={series.content || []}
|
||||
refreshLibrary={refreshLibrary || (async () => ({ success: false }))}
|
||||
/>
|
||||
<Container>
|
||||
<PaginatedSeriesGrid
|
||||
series={series.content || []}
|
||||
currentPage={currentPage}
|
||||
totalPages={series.totalPages}
|
||||
totalElements={series.totalElements}
|
||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||
showOnlyUnread={unreadOnly}
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ClientLibraryPage
|
||||
currentPage={currentPage}
|
||||
libraryId={libraryId}
|
||||
preferences={preferences}
|
||||
unreadOnly={unreadOnly}
|
||||
search={search}
|
||||
pageSize={size ? parseInt(size) : undefined}
|
||||
/>
|
||||
);
|
||||
try {
|
||||
const [series, library] = await Promise.all([
|
||||
LibraryService.getLibrarySeries(
|
||||
libraryId,
|
||||
currentPage - 1,
|
||||
effectivePageSize,
|
||||
unreadOnly,
|
||||
search
|
||||
),
|
||||
LibraryService.getLibrary(libraryId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<LibraryClientWrapper
|
||||
libraryId={libraryId}
|
||||
currentPage={currentPage}
|
||||
unreadOnly={unreadOnly}
|
||||
search={search}
|
||||
pageSize={effectivePageSize}
|
||||
preferences={preferences}
|
||||
>
|
||||
<LibraryContent
|
||||
library={library}
|
||||
series={series}
|
||||
currentPage={currentPage}
|
||||
preferences={preferences}
|
||||
unreadOnly={unreadOnly}
|
||||
search={search}
|
||||
pageSize={effectivePageSize}
|
||||
/>
|
||||
</LibraryClientWrapper>
|
||||
);
|
||||
} catch (error) {
|
||||
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.SERIES.FETCH_ERROR;
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<ErrorMessage errorCode={errorCode} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <ClientHomePage />;
|
||||
export default async function HomePage() {
|
||||
try {
|
||||
const data = await HomeService.getHomeData();
|
||||
|
||||
return (
|
||||
<HomeClientWrapper>
|
||||
<HomeContent data={data} />
|
||||
</HomeClientWrapper>
|
||||
);
|
||||
} 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 (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<ErrorMessage errorCode={errorCode} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<KomgaSeries | null>(null);
|
||||
const [books, setBooks] = useState<LibraryResponse<KomgaBook> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="container py-8 space-y-8">
|
||||
<div className="space-y-4">
|
||||
<OptimizedSkeleton className="h-64 w-full rounded" />
|
||||
<OptimizedSkeleton className="h-10 w-64" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{Array.from({ length: effectivePageSize }).map((_, i) => (
|
||||
<OptimizedSkeleton key={i} className="aspect-[3/4] w-full rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container py-8 space-y-8">
|
||||
<h1 className="text-3xl font-bold">Série</h1>
|
||||
<ErrorMessage errorCode={error} onRetry={handleRetry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!series || !books) {
|
||||
return (
|
||||
<div className="container py-8 space-y-8">
|
||||
<h1 className="text-3xl font-bold">Série</h1>
|
||||
<ErrorMessage errorCode={ERROR_CODES.SERIES.FETCH_ERROR} onRetry={handleRetry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefresh.isPulling}
|
||||
isRefreshing={pullToRefresh.isRefreshing}
|
||||
progress={pullToRefresh.progress}
|
||||
canRefresh={pullToRefresh.canRefresh}
|
||||
isHiding={pullToRefresh.isHiding}
|
||||
/>
|
||||
<div className="container">
|
||||
<SeriesHeader series={series} refreshSeries={handleRefresh} />
|
||||
<PaginatedBookGrid
|
||||
books={books.content || []}
|
||||
currentPage={currentPage}
|
||||
totalPages={books.totalPages}
|
||||
totalElements={books.totalElements}
|
||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||
showOnlyUnread={unreadOnly}
|
||||
onRefresh={() => handleRefresh(seriesId)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
src/app/series/[seriesId]/SeriesClientWrapper.tsx
Normal file
61
src/app/series/[seriesId]/SeriesClientWrapper.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefresh.isPulling}
|
||||
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
|
||||
progress={pullToRefresh.progress}
|
||||
canRefresh={pullToRefresh.canRefresh}
|
||||
isHiding={pullToRefresh.isHiding}
|
||||
/>
|
||||
<RefreshProvider refreshSeries={handleRefresh}>
|
||||
{children}
|
||||
</RefreshProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
48
src/app/series/[seriesId]/SeriesContent.tsx
Normal file
48
src/app/series/[seriesId]/SeriesContent.tsx
Normal file
@@ -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<KomgaBook>;
|
||||
currentPage: number;
|
||||
preferences: UserPreferences;
|
||||
unreadOnly: boolean;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export function SeriesContent({
|
||||
series,
|
||||
books,
|
||||
currentPage,
|
||||
preferences,
|
||||
unreadOnly,
|
||||
}: SeriesContentProps) {
|
||||
const { refreshSeries } = useRefresh();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeriesHeader
|
||||
series={series}
|
||||
refreshSeries={refreshSeries || (async () => ({ success: false }))}
|
||||
/>
|
||||
<Container>
|
||||
<PaginatedBookGrid
|
||||
books={books.content || []}
|
||||
currentPage={currentPage}
|
||||
totalPages={books.totalPages}
|
||||
totalElements={books.totalElements}
|
||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||
showOnlyUnread={unreadOnly}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<ClientSeriesPage
|
||||
seriesId={seriesId}
|
||||
currentPage={currentPage}
|
||||
preferences={preferences}
|
||||
unreadOnly={unreadOnly}
|
||||
pageSize={size ? parseInt(size) : undefined}
|
||||
/>
|
||||
);
|
||||
try {
|
||||
const [books, series] = await Promise.all([
|
||||
SeriesService.getSeriesBooks(seriesId, currentPage - 1, effectivePageSize, unreadOnly),
|
||||
SeriesService.getSeries(seriesId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<SeriesClientWrapper
|
||||
seriesId={seriesId}
|
||||
currentPage={currentPage}
|
||||
unreadOnly={unreadOnly}
|
||||
pageSize={effectivePageSize}
|
||||
preferences={preferences}
|
||||
>
|
||||
<SeriesContent
|
||||
series={series}
|
||||
books={books}
|
||||
currentPage={currentPage}
|
||||
preferences={preferences}
|
||||
unreadOnly={unreadOnly}
|
||||
pageSize={effectivePageSize}
|
||||
/>
|
||||
</SeriesClientWrapper>
|
||||
);
|
||||
} catch (error) {
|
||||
const errorCode = error instanceof AppError
|
||||
? error.code
|
||||
: ERROR_CODES.BOOK.PAGES_FETCH_ERROR;
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<ErrorMessage errorCode={errorCode} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user