From e4a663b6d4e15d3c03617d1bf880721c1645a304 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Tue, 11 Feb 2025 21:43:09 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20ajoute=20la=20pagination=20et=20le=20fi?= =?UTF-8?q?ltrage=20des=20tomes=20dans=20la=20page=20s=C3=A9rie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../libraries/[libraryId]/series/route.ts | 30 +- .../komga/series/[seriesId]/books/route.ts | 78 +++++ src/app/libraries/[libraryId]/page.tsx | 19 +- src/app/series/[seriesId]/page.tsx | 315 ++++-------------- .../library/PaginatedSeriesGrid.tsx | 28 +- src/components/series/SeriesHeader.tsx | 91 +++++ 6 files changed, 300 insertions(+), 261 deletions(-) create mode 100644 src/app/api/komga/series/[seriesId]/books/route.ts create mode 100644 src/components/series/SeriesHeader.tsx diff --git a/src/app/api/komga/libraries/[libraryId]/series/route.ts b/src/app/api/komga/libraries/[libraryId]/series/route.ts index 33b47ce..495bfbe 100644 --- a/src/app/api/komga/libraries/[libraryId]/series/route.ts +++ b/src/app/api/komga/libraries/[libraryId]/series/route.ts @@ -21,26 +21,32 @@ export async function GET(request: Request, { params }: { params: { libraryId: s return NextResponse.json({ error: "Credentials Komga manquants" }, { status: 401 }); } - // Récupérer les paramètres de pagination depuis l'URL + // Récupérer les paramètres de pagination et de filtre depuis l'URL const { searchParams } = new URL(request.url); const page = searchParams.get("page") || "0"; const size = searchParams.get("size") || "20"; + const unreadOnly = searchParams.get("unread") === "true"; // Clé de cache unique pour cette page de séries - const cacheKey = `library-${params.libraryId}-series-${page}-${size}`; + const cacheKey = `library-${params.libraryId}-series-${page}-${size}-${unreadOnly}`; // Fonction pour récupérer les séries const fetchSeries = async () => { - const response = await fetch( - `${config.serverUrl}/api/v1/series?library_id=${params.libraryId}&page=${page}&size=${size}`, - { - headers: { - Authorization: `Basic ${Buffer.from( - `${config.credentials.username}:${config.credentials.password}` - ).toString("base64")}`, - }, - } - ); + // Construire l'URL avec les paramètres + let url = `${config.serverUrl}/api/v1/series?library_id=${params.libraryId}&page=${page}&size=${size}`; + + // Ajouter le filtre pour les séries non lues et en cours si nécessaire + if (unreadOnly) { + url += "&read_status=UNREAD&read_status=IN_PROGRESS"; + } + + const response = await fetch(url, { + headers: { + Authorization: `Basic ${Buffer.from( + `${config.credentials.username}:${config.credentials.password}` + ).toString("base64")}`, + }, + }); if (!response.ok) { const errorData = await response.json().catch(() => null); diff --git a/src/app/api/komga/series/[seriesId]/books/route.ts b/src/app/api/komga/series/[seriesId]/books/route.ts new file mode 100644 index 0000000..b0d035c --- /dev/null +++ b/src/app/api/komga/series/[seriesId]/books/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { serverCacheService } from "@/lib/services/server-cache.service"; + +export async function GET(request: Request, { params }: { params: { seriesId: string } }) { + try { + // Récupérer les credentials Komga depuis le cookie + const configCookie = cookies().get("komgaCredentials"); + if (!configCookie) { + return NextResponse.json({ error: "Configuration Komga manquante" }, { status: 401 }); + } + + let config; + try { + config = JSON.parse(atob(configCookie.value)); + } catch (error) { + return NextResponse.json({ error: "Configuration Komga invalide" }, { status: 401 }); + } + + if (!config.credentials?.username || !config.credentials?.password) { + return NextResponse.json({ error: "Credentials Komga manquants" }, { status: 401 }); + } + + // Récupérer les paramètres de pagination et de filtre depuis l'URL + const { searchParams } = new URL(request.url); + const page = searchParams.get("page") || "0"; + const size = searchParams.get("size") || "24"; + const unreadOnly = searchParams.get("unread") === "true"; + + // Clé de cache unique pour cette page de tomes + const cacheKey = `series-${params.seriesId}-books-${page}-${size}-${unreadOnly}`; + + // Fonction pour récupérer les tomes + const fetchBooks = async () => { + // Construire l'URL avec les paramètres + let url = `${config.serverUrl}/api/v1/series/${params.seriesId}/books?page=${page}&size=${size}&sort=metadata.numberSort,asc`; + + // Ajouter le filtre pour les tomes non lus et en cours si nécessaire + if (unreadOnly) { + url += "&read_status=UNREAD&read_status=IN_PROGRESS"; + } + + const response = await fetch(url, { + headers: { + Authorization: `Basic ${Buffer.from( + `${config.credentials.username}:${config.credentials.password}` + ).toString("base64")}`, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error( + JSON.stringify({ + error: "Erreur lors de la récupération des tomes", + details: errorData, + }) + ); + } + + return response.json(); + }; + + // Récupérer les données du cache ou faire l'appel API + const data = await serverCacheService.getOrSet(cacheKey, fetchBooks, 5 * 60); // Cache de 5 minutes + + return NextResponse.json(data); + } catch (error) { + console.error("Erreur lors de la récupération des tomes:", error); + return NextResponse.json( + { + error: "Erreur serveur", + details: error instanceof Error ? error.message : "Erreur inconnue", + }, + { status: 500 } + ); + } +} diff --git a/src/app/libraries/[libraryId]/page.tsx b/src/app/libraries/[libraryId]/page.tsx index d53a07f..42c805c 100644 --- a/src/app/libraries/[libraryId]/page.tsx +++ b/src/app/libraries/[libraryId]/page.tsx @@ -3,12 +3,12 @@ import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid"; interface PageProps { params: { libraryId: string }; - searchParams: { page?: string }; + searchParams: { page?: string; unread?: string }; } const PAGE_SIZE = 20; -async function getLibrarySeries(libraryId: string, page: number = 1) { +async function getLibrarySeries(libraryId: string, page: number = 1, unreadOnly: boolean = false) { const configCookie = cookies().get("komgaCredentials"); if (!configCookie) { @@ -25,7 +25,13 @@ async function getLibrarySeries(libraryId: string, page: number = 1) { // Paramètres de pagination const pageIndex = page - 1; // L'API Komga utilise un index base 0 - const url = `${config.serverUrl}/api/v1/series?library_id=${libraryId}&page=${pageIndex}&size=${PAGE_SIZE}`; + // Construire l'URL avec les paramètres + let url = `${config.serverUrl}/api/v1/series?library_id=${libraryId}&page=${pageIndex}&size=${PAGE_SIZE}`; + + // Ajouter le filtre pour les séries non lues et en cours si nécessaire + if (unreadOnly) { + url += "&read_status=UNREAD&read_status=IN_PROGRESS"; + } const credentials = `${config.credentials.username}:${config.credentials.password}`; const auth = Buffer.from(credentials).toString("base64"); @@ -51,9 +57,14 @@ async function getLibrarySeries(libraryId: string, page: number = 1) { export default async function LibraryPage({ params, searchParams }: PageProps) { const currentPage = searchParams.page ? parseInt(searchParams.page) : 1; + const unreadOnly = searchParams.unread === "true"; try { - const { data: series, serverUrl } = await getLibrarySeries(params.libraryId, currentPage); + const { data: series, serverUrl } = await getLibrarySeries( + params.libraryId, + currentPage, + unreadOnly + ); return (
diff --git a/src/app/series/[seriesId]/page.tsx b/src/app/series/[seriesId]/page.tsx index 8d1b29d..43b2465 100644 --- a/src/app/series/[seriesId]/page.tsx +++ b/src/app/series/[seriesId]/page.tsx @@ -1,265 +1,92 @@ -"use client"; - -import { useRouter } from "next/navigation"; +import { cookies } from "next/headers"; +import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid"; +import { SeriesHeader } from "@/components/series/SeriesHeader"; import { KomgaSeries, KomgaBook } from "@/types/komga"; -import { useEffect, useState } from "react"; -import { BookGrid } from "@/components/series/BookGrid"; -import { ImageOff, Loader2 } from "lucide-react"; -import Image from "next/image"; -import { Pagination } from "@/components/ui/Pagination"; -import { useSearchParams, usePathname } from "next/navigation"; -import { cn } from "@/lib/utils"; -interface SeriesData { - series: KomgaSeries; - books: { - content: KomgaBook[]; - totalElements: number; - totalPages: number; - number: number; - size: number; - }; +interface PageProps { + params: { seriesId: string }; + searchParams: { page?: string; unread?: string }; } -const PAGE_SIZE = 24; // 6 colonnes x 4 lignes pour un affichage optimal +const PAGE_SIZE = 24; -export default function SeriesPage({ params }: { params: { seriesId: string } }) { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const currentPage = searchParams.get("page") ? parseInt(searchParams.get("page")!) : 1; +export default async function SeriesPage({ params, searchParams }: PageProps) { + const currentPage = searchParams.page ? parseInt(searchParams.page) : 1; + const unreadOnly = searchParams.unread === "true"; - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isChangingPage, setIsChangingPage] = useState(false); - const [error, setError] = useState(null); - const [imageError, setImageError] = useState(false); + const configCookie = cookies().get("komgaCredentials"); + if (!configCookie) { + throw new Error("Configuration Komga manquante"); + } - useEffect(() => { - const fetchSeriesData = async () => { - try { - setIsChangingPage(true); - const response = await fetch( - `/api/komga/series/${params.seriesId}?page=${currentPage - 1}&size=${PAGE_SIZE}` - ); - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || "Erreur lors de la récupération de la série"); + try { + const config = JSON.parse(atob(configCookie.value)); + if (!config.serverUrl || !config.credentials?.username || !config.credentials?.password) { + throw new Error("Configuration Komga invalide ou incomplète"); + } + + const credentials = `${config.credentials.username}:${config.credentials.password}`; + const auth = Buffer.from(credentials).toString("base64"); + + // Paramètres de pagination + const pageIndex = currentPage - 1; // L'API Komga utilise un index base 0 + + // Appels API parallèles pour les détails de la série et les tomes + const [seriesResponse, booksResponse] = await Promise.all([ + // Détails de la série + fetch(`${config.serverUrl}/api/v1/series/${params.seriesId}`, { + headers: { + Authorization: `Basic ${auth}`, + Accept: "application/json", + }, + next: { revalidate: 300 }, + }), + // Liste des tomes avec pagination et filtre + fetch( + `${config.serverUrl}/api/v1/series/${ + params.seriesId + }/books?page=${pageIndex}&size=${PAGE_SIZE}&sort=metadata.numberSort,asc${ + unreadOnly ? "&read_status=UNREAD&read_status=IN_PROGRESS" : "" + }`, + { + headers: { + Authorization: `Basic ${auth}`, + Accept: "application/json", + }, + next: { revalidate: 300 }, } - const data = await response.json(); - setData(data); - } catch (error) { - console.error("Erreur:", error); - setError(error instanceof Error ? error.message : "Une erreur est survenue"); - } finally { - setIsLoading(false); - setIsChangingPage(false); - } - }; + ), + ]); - fetchSeriesData(); - }, [params.seriesId, currentPage]); + if (!seriesResponse.ok || !booksResponse.ok) { + throw new Error("Erreur lors de la récupération des données"); + } - const handleBookClick = (book: KomgaBook) => { - router.push(`/books/${book.id}`); - }; + const [series, books] = await Promise.all([seriesResponse.json(), booksResponse.json()]); - const handlePageChange = (page: number) => { - setIsChangingPage(true); - router.push(`/series/${params.seriesId}?page=${page}`); - }; - - const getBookThumbnailUrl = (bookId: string) => { - return `/api/komga/images/books/${bookId}/thumbnail`; - }; - - if (isLoading) { return ( -
-
-
-
-
-
-
-
-
-
-
-
- {[...Array(6)].map((_, i) => ( -
-
-
-
-
-
-
- ))} -
-
+
+ +
); - } - - if (error || !data) { + } catch (error) { return ( -
+
+

Série

-

{error || "Données non disponibles"}

+

+ {error instanceof Error ? error.message : "Erreur lors de la récupération de la série"} +

); } - - const { series, books } = data; - const startIndex = (currentPage - 1) * PAGE_SIZE + 1; - const endIndex = Math.min(currentPage * PAGE_SIZE, books.totalElements); - - return ( -
- {/* En-tête de la série */} -
- {/* Couverture */} -
-
- {!imageError ? ( - {`Couverture setImageError(true)} - /> - ) : ( -
- -
- )} -
-
- - {/* Informations */} -
-
-

{series.metadata.title}

- {series.metadata.status && ( - - {series.metadata.status === "ENDED" - ? "Terminé" - : series.metadata.status === "ONGOING" - ? "En cours" - : series.metadata.status === "ABANDONED" - ? "Abandonné" - : "En pause"} - - )} -
- - {series.metadata.summary && ( -

{series.metadata.summary}

- )} - -
- {series.metadata.publisher && ( -
- Éditeur : {series.metadata.publisher} -
- )} - {series.metadata.genres?.length > 0 && ( -
- Genres : {series.metadata.genres.join(", ")} -
- )} - {series.metadata.tags?.length > 0 && ( -
- Tags : {series.metadata.tags.join(", ")} -
- )} - {series.metadata.language && ( -
- Langue :{" "} - {new Intl.DisplayNames([navigator.language], { type: "language" }).of( - series.metadata.language - )} -
- )} - {series.metadata.ageRating && ( -
- Âge recommandé : {series.metadata.ageRating}+ -
- )} -
-
-
- - {/* Grille des tomes */} -
-
-

- Tomes ({books.totalElements}) -

-

- {books.totalElements > 0 ? ( - <> - Affichage des tomes {startIndex} à{" "} - {endIndex} sur{" "} - {books.totalElements} - - ) : ( - "Aucun tome disponible" - )} -

-
- -
- {/* Indicateur de chargement */} - {isChangingPage && ( -
-
- - Chargement... -
-
- )} - - {/* Grille avec animation de transition */} -
- -
-
- -
-

- Page {currentPage} sur {books.totalPages} -

- -
-
-
- ); } diff --git a/src/components/library/PaginatedSeriesGrid.tsx b/src/components/library/PaginatedSeriesGrid.tsx index dab3b0b..389773f 100644 --- a/src/components/library/PaginatedSeriesGrid.tsx +++ b/src/components/library/PaginatedSeriesGrid.tsx @@ -4,7 +4,7 @@ import { SeriesGrid } from "./SeriesGrid"; import { Pagination } from "@/components/ui/Pagination"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { useState, useEffect } from "react"; -import { Loader2 } from "lucide-react"; +import { Loader2, Filter } from "lucide-react"; import { cn } from "@/lib/utils"; interface PaginatedSeriesGridProps { @@ -28,6 +28,7 @@ export function PaginatedSeriesGrid({ const pathname = usePathname(); const searchParams = useSearchParams(); const [isChangingPage, setIsChangingPage] = useState(false); + const [showOnlyUnread, setShowOnlyUnread] = useState(searchParams.get("unread") === "true"); // Réinitialiser l'état de chargement quand les séries changent useEffect(() => { @@ -39,11 +40,29 @@ export function PaginatedSeriesGrid({ // Créer un nouvel objet URLSearchParams pour manipuler les paramètres const params = new URLSearchParams(searchParams); params.set("page", page.toString()); + if (showOnlyUnread) { + params.set("unread", "true"); + } // Rediriger vers la nouvelle URL avec les paramètres mis à jour router.push(`${pathname}?${params.toString()}`); }; + const handleUnreadFilter = () => { + setIsChangingPage(true); + const params = new URLSearchParams(searchParams); + params.set("page", "1"); // Retourner à la première page lors du changement de filtre + + if (!showOnlyUnread) { + params.set("unread", "true"); + } else { + params.delete("unread"); + } + + setShowOnlyUnread(!showOnlyUnread); + router.push(`${pathname}?${params.toString()}`); + }; + // Calcul des indices de début et de fin pour l'affichage const startIndex = (currentPage - 1) * pageSize + 1; const endIndex = Math.min(currentPage * pageSize, totalElements); @@ -62,6 +81,13 @@ export function PaginatedSeriesGrid({ "Aucune série trouvée" )}

+
diff --git a/src/components/series/SeriesHeader.tsx b/src/components/series/SeriesHeader.tsx new file mode 100644 index 0000000..a57f95d --- /dev/null +++ b/src/components/series/SeriesHeader.tsx @@ -0,0 +1,91 @@ +"use client"; + +import Image from "next/image"; +import { ImageOff } from "lucide-react"; +import { KomgaSeries } from "@/types/komga"; + +interface SeriesHeaderProps { + series: KomgaSeries; + serverUrl: string; +} + +export function SeriesHeader({ series, serverUrl }: SeriesHeaderProps) { + return ( +
+ {/* Couverture */} +
+
+ {`Couverture +
+
+ + {/* Informations */} +
+
+

{series.metadata.title}

+ {series.metadata.status && ( + + {series.metadata.status === "ENDED" + ? "Terminé" + : series.metadata.status === "ONGOING" + ? "En cours" + : series.metadata.status === "ABANDONED" + ? "Abandonné" + : "En pause"} + + )} +
+ + {series.metadata.summary && ( +

{series.metadata.summary}

+ )} + +
+ {series.metadata.publisher && ( +
+ Éditeur : {series.metadata.publisher} +
+ )} + {series.metadata.genres?.length > 0 && ( +
+ Genres : {series.metadata.genres.join(", ")} +
+ )} + {series.metadata.tags?.length > 0 && ( +
+ Tags : {series.metadata.tags.join(", ")} +
+ )} + {series.metadata.language && ( +
+ Langue :{" "} + {new Intl.DisplayNames([navigator.language], { type: "language" }).of( + series.metadata.language + )} +
+ )} + {series.metadata.ageRating && ( +
+ Âge recommandé : {series.metadata.ageRating}+ +
+ )} +
+
+
+ ); +}