diff --git a/src/app/api/komga/books/[bookId]/pages/[pageNumber]/route.ts b/src/app/api/komga/books/[bookId]/pages/[pageNumber]/route.ts index c9bf9c8..72fdba3 100644 --- a/src/app/api/komga/books/[bookId]/pages/[pageNumber]/route.ts +++ b/src/app/api/komga/books/[bookId]/pages/[pageNumber]/route.ts @@ -1,60 +1,28 @@ import { NextResponse } from "next/server"; -import { cookies } from "next/headers"; +import { BookService } from "@/lib/services/book.service"; + +export const dynamic = "force-dynamic"; export async function GET( request: Request, { params }: { params: { bookId: string; pageNumber: 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 }); - } + const response = await BookService.getPage(params.bookId, parseInt(params.pageNumber)); + const buffer = await response.arrayBuffer(); + const headers = new Headers(); + headers.set("Content-Type", response.headers.get("Content-Type") || "image/jpeg"); + headers.set("Cache-Control", "public, max-age=31536000"); // Cache for 1 year - 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 }); - } - - // Appel à l'API Komga - const response = await fetch( - `${config.serverUrl}/api/v1/books/${params.bookId}/pages/${params.pageNumber}`, - { - headers: { - Authorization: `Basic ${Buffer.from( - `${config.credentials.username}:${config.credentials.password}` - ).toString("base64")}`, - }, - } - ); - - if (!response.ok) { - return NextResponse.json( - { error: "Erreur lors de la récupération de la page" }, - { status: response.status } - ); - } - - // Récupérer le type MIME de l'image - const contentType = response.headers.get("content-type"); - const imageBuffer = await response.arrayBuffer(); - - // Retourner l'image avec le bon type MIME - return new NextResponse(imageBuffer, { - headers: { - "Content-Type": contentType || "image/jpeg", - "Cache-Control": "public, max-age=31536000, immutable", - }, + return new NextResponse(buffer, { + status: 200, + headers, }); } catch (error) { - console.error("Erreur lors de la récupération de la page:", error); - return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + console.error("API Book Page - Erreur:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération de la page" }, + { status: 500 } + ); } } diff --git a/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/route.ts b/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/route.ts new file mode 100644 index 0000000..c6704bc --- /dev/null +++ b/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { BookService } from "@/lib/services/book.service"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: { bookId: string; pageNumber: string } } +) { + try { + // Convertir le numéro de page en nombre + const pageNumber = parseInt(params.pageNumber); + if (isNaN(pageNumber) || pageNumber < 1) { + return NextResponse.json({ error: "Numéro de page invalide" }, { status: 400 }); + } + + const response = await BookService.getPage(params.bookId, pageNumber); + const buffer = await response.arrayBuffer(); + const headers = new Headers(); + headers.set("Content-Type", response.headers.get("Content-Type") || "image/jpeg"); + headers.set("Cache-Control", "public, max-age=31536000"); // Cache for 1 year + + return new NextResponse(buffer, { + status: 200, + headers, + }); + } catch (error) { + console.error("API Book Page - Erreur:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération de la page" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/thumbnail/route.ts b/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/thumbnail/route.ts new file mode 100644 index 0000000..88031e6 --- /dev/null +++ b/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/thumbnail/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { BookService } from "@/lib/services/book.service"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: { bookId: string; pageNumber: string } } +) { + try { + // Convertir le numéro de page en nombre + const pageNumber = parseInt(params.pageNumber); + if (isNaN(pageNumber) || pageNumber < 1) { + return NextResponse.json({ error: "Numéro de page invalide" }, { status: 400 }); + } + + const response = await BookService.getPageThumbnail(params.bookId, pageNumber); + const buffer = await response.arrayBuffer(); + const headers = new Headers(); + headers.set("Content-Type", response.headers.get("Content-Type") || "image/jpeg"); + headers.set("Cache-Control", "public, max-age=31536000"); // Cache for 1 year + + return new NextResponse(buffer, { + status: 200, + headers, + }); + } catch (error) { + console.error("API Book Page Thumbnail - Erreur:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération de la miniature" }, + { status: 500 } + ); + } +} diff --git a/src/components/reader/BookReader.tsx b/src/components/reader/BookReader.tsx index 4b22a2f..c3aa616 100644 --- a/src/components/reader/BookReader.tsx +++ b/src/components/reader/BookReader.tsx @@ -8,6 +8,7 @@ import { Loader2, LayoutTemplate, SplitSquareVertical, + ChevronUp, } from "lucide-react"; import Image from "next/image"; import { useEffect, useState, useCallback, useRef } from "react"; @@ -31,8 +32,17 @@ export function BookReader({ book, pages, onClose }: BookReaderProps) { const [isLoading, setIsLoading] = useState(true); const [imageError, setImageError] = useState(false); const [isDoublePage, setIsDoublePage] = useState(false); + const [showNavigation, setShowNavigation] = useState(false); const pageCache = useRef({}); + // Ajout d'un état pour le chargement des miniatures + const [loadedThumbnails, setLoadedThumbnails] = useState<{ [key: number]: boolean }>({}); + + // Ajout d'un état pour les miniatures visibles + const [visibleThumbnails, setVisibleThumbnails] = useState([]); + const thumbnailObserver = useRef(null); + const thumbnailRefs = useRef<{ [key: number]: HTMLButtonElement | null }>({}); + // Effet pour synchroniser la progression initiale useEffect(() => { if (book.readProgress?.page) { @@ -145,12 +155,18 @@ export function BookReader({ book, pages, onClose }: BookReaderProps) { [pages.length] ); - // Fonction pour obtenir l'URL d'une page (depuis le cache ou générée) + // Fonction pour obtenir l'URL d'une page const getPageUrl = useCallback( (pageNumber: number) => { - const cached = pageCache.current[pageNumber]; - if (cached) return cached.url; - return `/api/komga/books/${book.id}/pages/${pageNumber}`; + return `/api/komga/images/books/${book.id}/pages/${pageNumber}`; + }, + [book.id] + ); + + // Fonction pour obtenir l'URL d'une miniature + const getThumbnailUrl = useCallback( + (pageNumber: number) => { + return `/api/komga/images/books/${book.id}/pages/${pageNumber}/thumbnail`; }, [book.id] ); @@ -176,121 +192,321 @@ export function BookReader({ book, pages, onClose }: BookReaderProps) { return () => window.removeEventListener("keydown", handleKeyDown); }, [handlePreviousPage, handleNextPage, onClose]); + // Fonction pour marquer une miniature comme chargée + const handleThumbnailLoad = (pageNumber: number) => { + setLoadedThumbnails((prev) => ({ ...prev, [pageNumber]: true })); + }; + + // Fonction pour scroller jusqu'à la miniature active + const scrollToActiveThumbnail = useCallback(() => { + const container = document.getElementById("thumbnails-container"); + const activeThumbnail = document.getElementById(`thumbnail-${currentPage}`); + if (container && activeThumbnail) { + const containerWidth = container.clientWidth; + const thumbnailLeft = activeThumbnail.offsetLeft; + const thumbnailWidth = activeThumbnail.clientWidth; + + // Centrer la miniature dans le conteneur + container.scrollLeft = thumbnailLeft - containerWidth / 2 + thumbnailWidth / 2; + } + }, [currentPage]); + + // Effet pour scroller jusqu'à la miniature active au chargement et au changement de page + useEffect(() => { + if (showNavigation) { + scrollToActiveThumbnail(); + } + }, [currentPage, showNavigation, scrollToActiveThumbnail]); + + // Effet pour scroller jusqu'à la miniature active quand la navigation devient visible + useEffect(() => { + if (showNavigation) { + // Petit délai pour laisser le temps à la barre de s'afficher + const timer = setTimeout(() => { + scrollToActiveThumbnail(); + }, 100); + return () => clearTimeout(timer); + } + }, [showNavigation, scrollToActiveThumbnail]); + + // Fonction pour calculer les miniatures à afficher autour de la page courante + const updateVisibleThumbnails = useCallback(() => { + const windowSize = 20; // Nombre de miniatures à charger de chaque côté + const start = Math.max(1, currentPage - windowSize); + const end = Math.min(pages.length, currentPage + windowSize); + const visibleRange = Array.from({ length: end - start + 1 }, (_, i) => start + i); + setVisibleThumbnails(visibleRange); + }, [currentPage, pages.length]); + + // Effet pour mettre à jour les miniatures visibles lors du changement de page + useEffect(() => { + updateVisibleThumbnails(); + }, [currentPage, updateVisibleThumbnails]); + + // Fonction pour observer les miniatures + const observeThumbnail = useCallback( + (pageNumber: number) => { + if (!thumbnailRefs.current[pageNumber]) return; + + if (!thumbnailObserver.current) { + thumbnailObserver.current = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const pageNumber = parseInt(entry.target.getAttribute("data-page") || "0"); + if (entry.isIntersecting && !loadedThumbnails[pageNumber]) { + // Charger la miniature uniquement si elle devient visible + setLoadedThumbnails((prev) => ({ ...prev, [pageNumber]: false })); + } + }); + }, + { + root: document.getElementById("thumbnails-container"), + rootMargin: "50px", + threshold: 0.1, + } + ); + } + + thumbnailObserver.current.observe(thumbnailRefs.current[pageNumber]); + }, + [loadedThumbnails] + ); + + // Nettoyer l'observer + useEffect(() => { + return () => { + if (thumbnailObserver.current) { + thumbnailObserver.current.disconnect(); + } + }; + }, []); + return (
-
- {/* Bouton mode double page */} - - - {/* Bouton précédent */} - {currentPage > 1 && ( - - )} - - {/* Pages */} -
- {/* Page courante */} -
- {isLoading && ( -
- -
- )} - {!imageError ? ( - {`Page setIsLoading(false)} - onError={() => { - setIsLoading(false); - setImageError(true); - }} +
+ {/* Contenu principal */} +
+ {/* Boutons en haut */} +
+ + +
+ + {/* Bouton précédent */} + {currentPage > 1 && ( + + )} + + {/* Pages */} +
+ {/* Page courante */} +
+ {isLoading && ( +
+ +
+ )} + {!imageError ? ( + {`Page setIsLoading(false)} + onError={() => { + setIsLoading(false); + setImageError(true); + }} + /> + ) : ( +
+ +
+ )} +
+ + {/* Deuxième page en mode double page */} + {shouldShowDoublePage(currentPage) && ( +
+ {`Page setImageError(true)} + />
)}
- {/* Deuxième page en mode double page */} - {shouldShowDoublePage(currentPage) && ( -
- {`Page setImageError(true)} - /> -
+ {/* Bouton suivant */} + {currentPage < pages.length && ( + + )} + + {/* Bouton fermer */} + {onClose && ( + )}
- {/* Bouton suivant */} - {currentPage < pages.length && ( - - )} + {/* Barre de navigation des pages */} +
+ {showNavigation && ( + <> +
+ +
- {/* Indicateur de page */} -
- Page {currentPage} - {shouldShowDoublePage(currentPage) ? `-${currentPage + 1}` : ""} / {pages.length} +
+ {pages.map((_, index) => { + const pageNumber = index + 1; + const isVisible = visibleThumbnails.includes(pageNumber); + + return ( + + ); + })} +
+ +
+ +
+ + {/* Indicateur de page */} +
+ Page {currentPage} + {shouldShowDoublePage(currentPage) ? `-${currentPage + 1}` : ""} / {pages.length} +
+ + )}
- - {/* Bouton fermer */} - {onClose && ( - - )}
); diff --git a/src/components/series/BookGrid.tsx b/src/components/series/BookGrid.tsx index 6476c50..8e7487b 100644 --- a/src/components/series/BookGrid.tsx +++ b/src/components/series/BookGrid.tsx @@ -4,6 +4,7 @@ import { KomgaBook } from "@/types/komga"; import { ImageOff } from "lucide-react"; import Image from "next/image"; import { useState } from "react"; +import { formatDate } from "@/lib/utils"; interface BookGridProps { books: KomgaBook[]; @@ -21,9 +22,7 @@ const getReadingStatusInfo = (book: KomgaBook) => { } if (book.readProgress.completed) { - const readDate = book.readProgress.readDate - ? new Date(book.readProgress.readDate).toLocaleDateString() - : null; + const readDate = book.readProgress.readDate ? formatDate(book.readProgress.readDate) : null; return { label: readDate ? `Lu le ${readDate}` : "Lu", className: "bg-green-500/10 text-green-500", @@ -104,9 +103,7 @@ function BookCard({ book, onClick, getBookThumbnailUrl }: BookCardProps) { } if (book.readProgress.completed) { - const readDate = book.readProgress.readDate - ? new Date(book.readProgress.readDate).toLocaleDateString() - : null; + const readDate = book.readProgress.readDate ? formatDate(book.readProgress.readDate) : null; return { label: readDate ? `Lu le ${readDate}` : "Lu", className: "bg-green-500/10 text-green-500", diff --git a/src/lib/services/book.service.ts b/src/lib/services/book.service.ts index 8b480ff..e9f546a 100644 --- a/src/lib/services/book.service.ts +++ b/src/lib/services/book.service.ts @@ -1,5 +1,6 @@ import { BaseApiService } from "./base-api.service"; import { KomgaBook } from "@/types/komga"; +import { ImageService } from "./image.service"; export class BookService extends BaseApiService { static async getBook(bookId: string): Promise<{ book: KomgaBook; pages: number[] }> { @@ -63,11 +64,49 @@ export class BookService extends BaseApiService { } } + static async getPage(bookId: string, pageNumber: number): Promise { + try { + // Ajuster le numéro de page pour l'API Komga (zero-based) + const adjustedPageNumber = pageNumber - 1; + const response = await ImageService.getImage( + `books/${bookId}/pages/${adjustedPageNumber}?zero_based=true` + ); + return new Response(response.buffer, { + headers: { + "Content-Type": response.contentType || "image/jpeg", + }, + }); + } catch (error) { + throw this.handleError(error, "Impossible de récupérer la page"); + } + } + + static async getPageThumbnail(bookId: string, pageNumber: number): Promise { + try { + // Ajuster le numéro de page pour l'API Komga (zero-based) + const adjustedPageNumber = pageNumber - 1; + const response = await ImageService.getImage( + `books/${bookId}/pages/${adjustedPageNumber}/thumbnail?zero_based=true` + ); + return new Response(response.buffer, { + headers: { + "Content-Type": response.contentType || "image/jpeg", + }, + }); + } catch (error) { + throw this.handleError(error, "Impossible de récupérer la miniature"); + } + } + static getPageUrl(bookId: string, pageNumber: number): string { - return `/api/komga/books/${bookId}/pages/${pageNumber}`; + return `/api/komga/images/books/${bookId}/pages/${pageNumber}`; + } + + static getPageThumbnailUrl(bookId: string, pageNumber: number): string { + return `/api/komga/images/books/${bookId}/pages/${pageNumber}/thumbnail`; } static getThumbnailUrl(bookId: string): string { - return `/api/komga/images/books/${bookId}/thumbnail`; + return ImageService.getBookThumbnailUrl(bookId); } } diff --git a/src/lib/services/image.service.ts b/src/lib/services/image.service.ts index 6242887..e4abdcb 100644 --- a/src/lib/services/image.service.ts +++ b/src/lib/services/image.service.ts @@ -47,6 +47,10 @@ export class ImageService extends BaseApiService { } static getBookPageUrl(bookId: string, pageNumber: number): string { - return `/api/komga/books/${bookId}/pages/${pageNumber}`; + return `/api/komga/images/books/${bookId}/pages/${pageNumber}`; + } + + static getBookPageThumbnailUrl(bookId: string, pageNumber: number): string { + return `/api/komga/images/books/${bookId}/pages/${pageNumber}/thumbnail`; } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 365058c..5ecd27d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,12 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function formatDate(date: string | Date): string { + const d = new Date(date); + return d.toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} diff --git a/src/styles/globals.css b/src/styles/globals.css index 8abdb15..9111bbd 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -74,3 +74,13 @@ @apply bg-background text-foreground; } } + +@layer utilities { + .hide-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + .hide-scrollbar::-webkit-scrollbar { + display: none; /* Chrome, Safari and Opera */ + } +}