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:
@@ -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,122 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { HeroSection } from "./HeroSection";
|
||||
import { MediaRow } from "./MediaRow";
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import type { HomeData } from "@/types/home";
|
||||
import { RefreshButton } from "@/components/library/RefreshButton";
|
||||
import { History, Sparkles, Clock, LibraryBig, BookOpen } from "lucide-react";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface HomeContentProps {
|
||||
data: HomeData;
|
||||
refreshHome: () => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
export function HomeContent({ data, refreshHome }: HomeContentProps) {
|
||||
const { t } = useTranslate();
|
||||
const [showHero, setShowHero] = useState(false);
|
||||
const optimizeSeriesData = (series: KomgaSeries[]) => {
|
||||
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
|
||||
id,
|
||||
metadata: { title: metadata.title },
|
||||
booksCount,
|
||||
booksReadCount,
|
||||
}));
|
||||
};
|
||||
|
||||
// Vérifier si la HeroSection a déjà été affichée
|
||||
useEffect(() => {
|
||||
const heroShown = localStorage.getItem("heroSectionShown");
|
||||
if (!heroShown && data.ongoing && data.ongoing.length > 0) {
|
||||
setShowHero(true);
|
||||
localStorage.setItem("heroSectionShown", "true");
|
||||
}
|
||||
}, [data.ongoing]);
|
||||
|
||||
// Vérification des données pour le debug
|
||||
// logger.info("HomeContent - Données reçues:", {
|
||||
// ongoingCount: data.ongoing?.length || 0,
|
||||
// recentlyReadCount: data.recentlyRead?.length || 0,
|
||||
// onDeckCount: data.onDeck?.length || 0,
|
||||
// });
|
||||
|
||||
const optimizeSeriesData = (series: KomgaSeries[]) => {
|
||||
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
|
||||
id,
|
||||
metadata: { title: metadata.title },
|
||||
booksCount,
|
||||
booksReadCount,
|
||||
}));
|
||||
};
|
||||
|
||||
const optimizeHeroSeriesData = (series: KomgaSeries[]) => {
|
||||
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
|
||||
id,
|
||||
metadata: { title: metadata.title },
|
||||
booksCount,
|
||||
booksReadCount,
|
||||
}));
|
||||
};
|
||||
|
||||
const optimizeBookData = (books: KomgaBook[]) => {
|
||||
return books.map(({ id, metadata, readProgress, media }) => ({
|
||||
id,
|
||||
metadata: {
|
||||
title: metadata.title,
|
||||
number: metadata.number,
|
||||
},
|
||||
readProgress: readProgress || { page: 0 },
|
||||
media,
|
||||
}));
|
||||
};
|
||||
const optimizeBookData = (books: KomgaBook[]) => {
|
||||
return books.map(({ id, metadata, readProgress, media }) => ({
|
||||
id,
|
||||
metadata: {
|
||||
title: metadata.title,
|
||||
number: metadata.number,
|
||||
},
|
||||
readProgress: readProgress || { page: 0 },
|
||||
media,
|
||||
}));
|
||||
};
|
||||
|
||||
export function HomeContent({ data }: HomeContentProps) {
|
||||
return (
|
||||
<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)} />
|
||||
<div className="space-y-12">
|
||||
{data.ongoing && data.ongoing.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.continue_series"
|
||||
items={optimizeSeriesData(data.ongoing)}
|
||||
iconName="LibraryBig"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sections de contenu */}
|
||||
<div className="space-y-12">
|
||||
{data.ongoing && data.ongoing.length > 0 && (
|
||||
<MediaRow
|
||||
title={t("home.sections.continue_series")}
|
||||
items={optimizeSeriesData(data.ongoing)}
|
||||
icon={LibraryBig}
|
||||
/>
|
||||
)}
|
||||
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.continue_reading"
|
||||
items={optimizeBookData(data.ongoingBooks)}
|
||||
iconName="BookOpen"
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
||||
<MediaRow
|
||||
title={t("home.sections.continue_reading")}
|
||||
items={optimizeBookData(data.ongoingBooks)}
|
||||
icon={BookOpen}
|
||||
/>
|
||||
)}
|
||||
{data.onDeck && data.onDeck.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.up_next"
|
||||
items={optimizeBookData(data.onDeck)}
|
||||
iconName="Clock"
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.onDeck && data.onDeck.length > 0 && (
|
||||
<MediaRow
|
||||
title={t("home.sections.up_next")}
|
||||
items={optimizeBookData(data.onDeck)}
|
||||
icon={Clock}
|
||||
/>
|
||||
)}
|
||||
{data.latestSeries && data.latestSeries.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.latest_series"
|
||||
items={optimizeSeriesData(data.latestSeries)}
|
||||
iconName="Sparkles"
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.latestSeries && data.latestSeries.length > 0 && (
|
||||
<MediaRow
|
||||
title={t("home.sections.latest_series")}
|
||||
items={optimizeSeriesData(data.latestSeries)}
|
||||
icon={Sparkles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.recentlyRead && data.recentlyRead.length > 0 && (
|
||||
<MediaRow
|
||||
title={t("home.sections.recently_added")}
|
||||
items={optimizeBookData(data.recentlyRead)}
|
||||
icon={History}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
{data.recentlyRead && data.recentlyRead.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.recently_added"
|
||||
items={optimizeBookData(data.recentlyRead)}
|
||||
iconName="History"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SeriesCover } from "../ui/series-cover";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { ScrollContainer } from "@/components/ui/scroll-container";
|
||||
import { Section } from "@/components/ui/section";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { History, Sparkles, Clock, LibraryBig, BookOpen } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -38,14 +38,23 @@ interface OptimizedBook extends BaseItem {
|
||||
}
|
||||
|
||||
interface MediaRowProps {
|
||||
title: string;
|
||||
titleKey: string;
|
||||
items: (OptimizedSeries | OptimizedBook)[];
|
||||
icon?: LucideIcon;
|
||||
iconName?: string;
|
||||
}
|
||||
|
||||
export function MediaRow({ title, items, icon }: MediaRowProps) {
|
||||
const iconMap = {
|
||||
LibraryBig,
|
||||
BookOpen,
|
||||
Clock,
|
||||
Sparkles,
|
||||
History,
|
||||
};
|
||||
|
||||
export function MediaRow({ titleKey, items, iconName }: MediaRowProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined;
|
||||
|
||||
const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
|
||||
const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`;
|
||||
@@ -55,7 +64,7 @@ export function MediaRow({ title, items, icon }: MediaRowProps) {
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<Section title={title} icon={icon}>
|
||||
<Section title={t(titleKey)} icon={icon}>
|
||||
<ScrollContainer
|
||||
showArrows={true}
|
||||
scrollAmount={400}
|
||||
|
||||
@@ -16,7 +16,6 @@ import { cn } from "@/lib/utils";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { getErrorMessage } from "@/utils/errors";
|
||||
@@ -26,10 +25,6 @@ import { NavButton } from "@/components/ui/nav-button";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
// Module-level flags to prevent duplicate fetches (survives StrictMode remounts)
|
||||
let sidebarInitialFetchDone = false;
|
||||
let sidebarFetchInProgress = false;
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -48,37 +43,12 @@ export function Sidebar({
|
||||
const { t } = useTranslate();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { preferences } = usePreferences();
|
||||
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
|
||||
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const refreshLibraries = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const response = await fetch("/api/komga/libraries");
|
||||
if (!response.ok) {
|
||||
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR);
|
||||
}
|
||||
const data = await response.json();
|
||||
setLibraries(data);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur de chargement des bibliothèques:");
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description:
|
||||
error instanceof AppError
|
||||
? error.message
|
||||
: getErrorMessage(ERROR_CODES.LIBRARY.FETCH_ERROR),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const refreshFavorites = useCallback(async () => {
|
||||
try {
|
||||
const favoritesResponse = await fetch("/api/komga/favorites");
|
||||
@@ -115,22 +85,6 @@ export function Sidebar({
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only load once when preferences become available (module-level flag survives StrictMode)
|
||||
if (
|
||||
!sidebarInitialFetchDone &&
|
||||
!sidebarFetchInProgress &&
|
||||
Object.keys(preferences).length > 0
|
||||
) {
|
||||
sidebarFetchInProgress = true;
|
||||
sidebarInitialFetchDone = true;
|
||||
|
||||
Promise.all([refreshLibraries(), refreshFavorites()]).finally(() => {
|
||||
sidebarFetchInProgress = false;
|
||||
});
|
||||
}
|
||||
}, [preferences, refreshLibraries, refreshFavorites]);
|
||||
|
||||
// Mettre à jour les favoris quand ils changent
|
||||
useEffect(() => {
|
||||
const handleFavoritesChange = () => {
|
||||
@@ -146,15 +100,14 @@ export function Sidebar({
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await Promise.all([refreshLibraries(), refreshFavorites()]);
|
||||
// Revalider côté serveur via router.refresh()
|
||||
router.refresh();
|
||||
// Petit délai pour laisser le temps au serveur
|
||||
setTimeout(() => setIsRefreshing(false), 500);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// Reset module-level flags to allow refetch on next login
|
||||
sidebarInitialFetchDone = false;
|
||||
sidebarFetchInProgress = false;
|
||||
|
||||
await signOut({ callbackUrl: "/login" });
|
||||
setLibraries([]);
|
||||
setFavorites([]);
|
||||
|
||||
Reference in New Issue
Block a user