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 cookieStore = await cookies();
|
||||||
const locale = cookieStore.get("NEXT_LOCALE")?.value || "fr";
|
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 preferences: UserPreferences = defaultPreferences;
|
||||||
let userIsAdmin = false;
|
let userIsAdmin = false;
|
||||||
|
let libraries: any[] = [];
|
||||||
|
let favorites: any[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [preferencesData, isAdminCheck] = await Promise.allSettled([
|
const [preferencesData, isAdminCheck, librariesData, favoritesData] = await Promise.allSettled([
|
||||||
PreferencesService.getPreferences(),
|
PreferencesService.getPreferences(),
|
||||||
import("@/lib/auth-utils").then((m) => m.isAdmin()),
|
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") {
|
if (preferencesData.status === "fulfilled") {
|
||||||
@@ -88,8 +91,16 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
if (isAdminCheck.status === "fulfilled") {
|
if (isAdminCheck.status === "fulfilled") {
|
||||||
userIsAdmin = isAdminCheck.value;
|
userIsAdmin = isAdminCheck.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (librariesData.status === "fulfilled") {
|
||||||
|
libraries = librariesData.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (favoritesData.status === "fulfilled") {
|
||||||
|
favorites = favoritesData.value;
|
||||||
|
}
|
||||||
} catch (error) {
|
} 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 (
|
return (
|
||||||
@@ -155,7 +166,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<I18nProvider locale={locale}>
|
<I18nProvider locale={locale}>
|
||||||
<PreferencesProvider initialPreferences={preferences}>
|
<PreferencesProvider initialPreferences={preferences}>
|
||||||
<ClientLayout initialLibraries={[]} initialFavorites={[]} userIsAdmin={userIsAdmin}>
|
<ClientLayout initialLibraries={libraries} initialFavorites={favorites} userIsAdmin={userIsAdmin}>
|
||||||
{children}
|
{children}
|
||||||
</ClientLayout>
|
</ClientLayout>
|
||||||
</PreferencesProvider>
|
</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 { 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";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -7,6 +12,8 @@ interface PageProps {
|
|||||||
searchParams: Promise<{ page?: string; unread?: string; search?: string; size?: string }>;
|
searchParams: Promise<{ page?: string; unread?: string; search?: string; size?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
export default async function LibraryPage({ params, searchParams }: PageProps) {
|
export default async function LibraryPage({ params, searchParams }: PageProps) {
|
||||||
const libraryId = (await params).libraryId;
|
const libraryId = (await params).libraryId;
|
||||||
const unread = (await searchParams).unread;
|
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
|
// 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 unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
|
||||||
|
const effectivePageSize = size
|
||||||
|
? parseInt(size)
|
||||||
|
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [series, library] = await Promise.all([
|
||||||
|
LibraryService.getLibrarySeries(
|
||||||
|
libraryId,
|
||||||
|
currentPage - 1,
|
||||||
|
effectivePageSize,
|
||||||
|
unreadOnly,
|
||||||
|
search
|
||||||
|
),
|
||||||
|
LibraryService.getLibrary(libraryId),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientLibraryPage
|
<LibraryClientWrapper
|
||||||
currentPage={currentPage}
|
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
|
currentPage={currentPage}
|
||||||
|
unreadOnly={unreadOnly}
|
||||||
|
search={search}
|
||||||
|
pageSize={effectivePageSize}
|
||||||
|
preferences={preferences}
|
||||||
|
>
|
||||||
|
<LibraryContent
|
||||||
|
library={library}
|
||||||
|
series={series}
|
||||||
|
currentPage={currentPage}
|
||||||
preferences={preferences}
|
preferences={preferences}
|
||||||
unreadOnly={unreadOnly}
|
unreadOnly={unreadOnly}
|
||||||
search={search}
|
search={search}
|
||||||
pageSize={size ? parseInt(size) : undefined}
|
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() {
|
export default async function HomePage() {
|
||||||
return <ClientHomePage />;
|
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 { 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";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -7,6 +12,8 @@ interface PageProps {
|
|||||||
searchParams: Promise<{ page?: string; unread?: string; size?: string }>;
|
searchParams: Promise<{ page?: string; unread?: string; size?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
export default async function SeriesPage({ params, searchParams }: PageProps) {
|
export default async function SeriesPage({ params, searchParams }: PageProps) {
|
||||||
const seriesId = (await params).seriesId;
|
const seriesId = (await params).seriesId;
|
||||||
const page = (await searchParams).page;
|
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
|
// 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 unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
|
||||||
|
const effectivePageSize = size ? parseInt(size) : preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [books, series] = await Promise.all([
|
||||||
|
SeriesService.getSeriesBooks(seriesId, currentPage - 1, effectivePageSize, unreadOnly),
|
||||||
|
SeriesService.getSeries(seriesId),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientSeriesPage
|
<SeriesClientWrapper
|
||||||
seriesId={seriesId}
|
seriesId={seriesId}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
|
unreadOnly={unreadOnly}
|
||||||
|
pageSize={effectivePageSize}
|
||||||
|
preferences={preferences}
|
||||||
|
>
|
||||||
|
<SeriesContent
|
||||||
|
series={series}
|
||||||
|
books={books}
|
||||||
|
currentPage={currentPage}
|
||||||
preferences={preferences}
|
preferences={preferences}
|
||||||
unreadOnly={unreadOnly}
|
unreadOnly={unreadOnly}
|
||||||
pageSize={size ? parseInt(size) : undefined}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<HomeData | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(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 <HomePageSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<main className="container mx-auto px-4 py-8">
|
|
||||||
<ErrorMessage errorCode={error} onRetry={handleRetry} />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return (
|
|
||||||
<main className="container mx-auto px-4 py-8">
|
|
||||||
<ErrorMessage errorCode={ERROR_CODES.KOMGA.SERVER_UNREACHABLE} onRetry={handleRetry} />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PullToRefreshIndicator
|
|
||||||
isPulling={pullToRefresh.isPulling}
|
|
||||||
isRefreshing={pullToRefresh.isRefreshing}
|
|
||||||
progress={pullToRefresh.progress}
|
|
||||||
canRefresh={pullToRefresh.canRefresh}
|
|
||||||
isHiding={pullToRefresh.isHiding}
|
|
||||||
/>
|
|
||||||
<HomeContent data={data} refreshHome={handleRefresh} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
60
src/components/home/HomeClientWrapper.tsx
Normal file
60
src/components/home/HomeClientWrapper.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<PullToRefreshIndicator
|
||||||
|
isPulling={pullToRefresh.isPulling}
|
||||||
|
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
|
||||||
|
progress={pullToRefresh.progress}
|
||||||
|
canRefresh={pullToRefresh.canRefresh}
|
||||||
|
isHiding={pullToRefresh.isHiding}
|
||||||
|
/>
|
||||||
|
<main className="container mx-auto px-4 py-8 space-y-12">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-bold">{t("home.title")}</h1>
|
||||||
|
<RefreshButton libraryId="home" refreshLibrary={handleRefresh} />
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,39 +1,11 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { HeroSection } from "./HeroSection";
|
|
||||||
import { MediaRow } from "./MediaRow";
|
import { MediaRow } from "./MediaRow";
|
||||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||||
import type { HomeData } from "@/types/home";
|
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 {
|
interface HomeContentProps {
|
||||||
data: HomeData;
|
data: HomeData;
|
||||||
refreshHome: () => Promise<{ success: boolean; error?: string }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeContent({ data, refreshHome }: HomeContentProps) {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
const [showHero, setShowHero] = useState(false);
|
|
||||||
|
|
||||||
// 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[]) => {
|
const optimizeSeriesData = (series: KomgaSeries[]) => {
|
||||||
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
|
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
|
||||||
id,
|
id,
|
||||||
@@ -43,15 +15,6 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const optimizeHeroSeriesData = (series: KomgaSeries[]) => {
|
|
||||||
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
|
|
||||||
id,
|
|
||||||
metadata: { title: metadata.title },
|
|
||||||
booksCount,
|
|
||||||
booksReadCount,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const optimizeBookData = (books: KomgaBook[]) => {
|
const optimizeBookData = (books: KomgaBook[]) => {
|
||||||
return books.map(({ id, metadata, readProgress, media }) => ({
|
return books.map(({ id, metadata, readProgress, media }) => ({
|
||||||
id,
|
id,
|
||||||
@@ -64,59 +27,48 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function HomeContent({ data }: HomeContentProps) {
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8 space-y-12">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h1 className="text-3xl font-bold">{t("home.title")}</h1>
|
|
||||||
<RefreshButton libraryId="home" refreshLibrary={refreshHome} />
|
|
||||||
</div>
|
|
||||||
{/* 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 && (
|
|
||||||
<HeroSection series={optimizeHeroSeriesData(data.ongoing)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sections de contenu */}
|
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
{data.ongoing && data.ongoing.length > 0 && (
|
{data.ongoing && data.ongoing.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
title={t("home.sections.continue_series")}
|
titleKey="home.sections.continue_series"
|
||||||
items={optimizeSeriesData(data.ongoing)}
|
items={optimizeSeriesData(data.ongoing)}
|
||||||
icon={LibraryBig}
|
iconName="LibraryBig"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
title={t("home.sections.continue_reading")}
|
titleKey="home.sections.continue_reading"
|
||||||
items={optimizeBookData(data.ongoingBooks)}
|
items={optimizeBookData(data.ongoingBooks)}
|
||||||
icon={BookOpen}
|
iconName="BookOpen"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.onDeck && data.onDeck.length > 0 && (
|
{data.onDeck && data.onDeck.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
title={t("home.sections.up_next")}
|
titleKey="home.sections.up_next"
|
||||||
items={optimizeBookData(data.onDeck)}
|
items={optimizeBookData(data.onDeck)}
|
||||||
icon={Clock}
|
iconName="Clock"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.latestSeries && data.latestSeries.length > 0 && (
|
{data.latestSeries && data.latestSeries.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
title={t("home.sections.latest_series")}
|
titleKey="home.sections.latest_series"
|
||||||
items={optimizeSeriesData(data.latestSeries)}
|
items={optimizeSeriesData(data.latestSeries)}
|
||||||
icon={Sparkles}
|
iconName="Sparkles"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.recentlyRead && data.recentlyRead.length > 0 && (
|
{data.recentlyRead && data.recentlyRead.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
title={t("home.sections.recently_added")}
|
titleKey="home.sections.recently_added"
|
||||||
items={optimizeBookData(data.recentlyRead)}
|
items={optimizeBookData(data.recentlyRead)}
|
||||||
icon={History}
|
iconName="History"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { SeriesCover } from "../ui/series-cover";
|
|||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { ScrollContainer } from "@/components/ui/scroll-container";
|
import { ScrollContainer } from "@/components/ui/scroll-container";
|
||||||
import { Section } from "@/components/ui/section";
|
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 { Card } from "@/components/ui/card";
|
||||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -38,14 +38,23 @@ interface OptimizedBook extends BaseItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MediaRowProps {
|
interface MediaRowProps {
|
||||||
title: string;
|
titleKey: string;
|
||||||
items: (OptimizedSeries | OptimizedBook)[];
|
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 router = useRouter();
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined;
|
||||||
|
|
||||||
const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
|
const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
|
||||||
const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`;
|
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;
|
if (!items.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={title} icon={icon}>
|
<Section title={t(titleKey)} icon={icon}>
|
||||||
<ScrollContainer
|
<ScrollContainer
|
||||||
showArrows={true}
|
showArrows={true}
|
||||||
scrollAmount={400}
|
scrollAmount={400}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { cn } from "@/lib/utils";
|
|||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import { getErrorMessage } from "@/utils/errors";
|
import { getErrorMessage } from "@/utils/errors";
|
||||||
@@ -26,10 +25,6 @@ import { NavButton } from "@/components/ui/nav-button";
|
|||||||
import { IconButton } from "@/components/ui/icon-button";
|
import { IconButton } from "@/components/ui/icon-button";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
// Module-level flags to prevent duplicate fetches (survives StrictMode remounts)
|
|
||||||
let sidebarInitialFetchDone = false;
|
|
||||||
let sidebarFetchInProgress = false;
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -48,37 +43,12 @@ export function Sidebar({
|
|||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { preferences } = usePreferences();
|
|
||||||
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
|
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
|
||||||
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []);
|
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const { toast } = useToast();
|
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 () => {
|
const refreshFavorites = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const favoritesResponse = await fetch("/api/komga/favorites");
|
const favoritesResponse = await fetch("/api/komga/favorites");
|
||||||
@@ -115,22 +85,6 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
}, [toast]);
|
}, [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
|
// Mettre à jour les favoris quand ils changent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFavoritesChange = () => {
|
const handleFavoritesChange = () => {
|
||||||
@@ -146,15 +100,14 @@ export function Sidebar({
|
|||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setIsRefreshing(true);
|
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 () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
// Reset module-level flags to allow refetch on next login
|
|
||||||
sidebarInitialFetchDone = false;
|
|
||||||
sidebarFetchInProgress = false;
|
|
||||||
|
|
||||||
await signOut({ callbackUrl: "/login" });
|
await signOut({ callbackUrl: "/login" });
|
||||||
setLibraries([]);
|
setLibraries([]);
|
||||||
setFavorites([]);
|
setFavorites([]);
|
||||||
|
|||||||
31
src/contexts/RefreshContext.tsx
Normal file
31
src/contexts/RefreshContext.tsx
Normal file
@@ -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<RefreshContextType>({});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<RefreshContext.Provider value={{ refreshLibrary, refreshSeries }}>
|
||||||
|
{children}
|
||||||
|
</RefreshContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRefresh() {
|
||||||
|
return useContext(RefreshContext);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,12 +2,19 @@ import type { KomgaBook } from "@/types/komga";
|
|||||||
|
|
||||||
export class ClientOfflineBookService {
|
export class ClientOfflineBookService {
|
||||||
static setCurrentPage(book: KomgaBook, page: number) {
|
static setCurrentPage(book: KomgaBook, page: number) {
|
||||||
|
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.setItem) {
|
||||||
|
try {
|
||||||
localStorage.setItem(`${book.id}-page`, page.toString());
|
localStorage.setItem(`${book.id}-page`, page.toString());
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors in SSR
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static getCurrentPage(book: KomgaBook) {
|
static getCurrentPage(book: KomgaBook) {
|
||||||
const readProgressPage = book.readProgress?.page || 0;
|
const readProgressPage = book.readProgress?.page || 0;
|
||||||
if (typeof localStorage !== "undefined") {
|
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem) {
|
||||||
|
try {
|
||||||
const cPageLS = localStorage.getItem(`${book.id}-page`) || "0";
|
const cPageLS = localStorage.getItem(`${book.id}-page`) || "0";
|
||||||
const currentPage = parseInt(cPageLS);
|
const currentPage = parseInt(cPageLS);
|
||||||
|
|
||||||
@@ -16,16 +23,31 @@ export class ClientOfflineBookService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return currentPage;
|
return currentPage;
|
||||||
|
} catch {
|
||||||
|
return readProgressPage;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return readProgressPage;
|
return readProgressPage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeCurrentPage(book: KomgaBook) {
|
static removeCurrentPage(book: KomgaBook) {
|
||||||
|
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.removeItem) {
|
||||||
|
try {
|
||||||
localStorage.removeItem(`${book.id}-page`);
|
localStorage.removeItem(`${book.id}-page`);
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors in SSR
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeCurrentPageById(bookId: string) {
|
static removeCurrentPageById(bookId: string) {
|
||||||
|
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.removeItem) {
|
||||||
|
try {
|
||||||
localStorage.removeItem(`${bookId}-page`);
|
localStorage.removeItem(`${bookId}-page`);
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors in SSR
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/lib/services/favorites.service.ts
Normal file
39
src/lib/services/favorites.service.ts
Normal file
@@ -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<KomgaSeries[]> {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
import tailwindcssAnimate from "tailwindcss-animate";
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
@@ -77,7 +78,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [tailwindcssAnimate],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
Reference in New Issue
Block a user