refacto(images): component cover dans refacto services and routes

This commit is contained in:
Julien Froidefond
2025-02-17 09:14:57 +01:00
parent 5c1138f287
commit 7ee99ac31a
13 changed files with 270 additions and 511 deletions

View File

@@ -15,15 +15,7 @@ export async function GET(
}
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,
});
return response;
} catch (error) {
console.error("API Book Page Thumbnail - Erreur:", error);
return NextResponse.json(

View File

@@ -3,7 +3,7 @@ import { BookService } from "@/lib/services/book.service";
export async function GET(request: NextRequest, { params }: { params: { bookId: string } }) {
try {
const response = await BookService.getThumbnail(params.bookId);
const response = await BookService.getCover(params.bookId);
return response;
} catch (error) {
console.error("Erreur lors de la récupération de la miniature du livre:", error);

View File

@@ -5,10 +5,10 @@ export const dynamic = "force-dynamic";
export async function GET(request: NextRequest, { params }: { params: { seriesId: string } }) {
try {
const response = await SeriesService.getFirstPage(params.seriesId);
const response = await SeriesService.getCover(params.seriesId);
return response;
} catch (error) {
console.error("Erreur lors de la récupération de la première page de la série:", error);
return new NextResponse("Erreur lors de la récupération de l'image", { status: 500 });
console.error("Erreur lors de la récupération de la couverture de la série:", error);
return new NextResponse("Erreur lors de la récupération de la couverture", { status: 500 });
}
}

View File

@@ -3,7 +3,7 @@ import { SeriesService } from "@/lib/services/series.service";
export async function GET(request: NextRequest, { params }: { params: { seriesId: string } }) {
try {
const response = await SeriesService.getThumbnail(params.seriesId);
const response = await SeriesService.getCover(params.seriesId);
return response;
} catch (error) {
console.error("Erreur lors de la récupération de la miniature de la série:", error);

View File

@@ -1,11 +1,7 @@
"use client";
import { KomgaSeries } from "@/types/komga";
import Image from "next/image";
import { useState } from "react";
import { ImageOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { ImageLoader } from "@/components/ui/image-loader";
import { Cover } from "@/components/ui/cover";
interface HeroSectionProps {
series: KomgaSeries[];
@@ -22,7 +18,18 @@ export function HeroSection({ series }: HeroSectionProps) {
{/* Grille de couvertures en arrière-plan */}
<div className="absolute inset-0 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 opacity-10">
{series?.map((series) => (
<CoverImage key={series.id} series={series} />
<div
key={series.id}
className="relative aspect-[2/3] bg-muted rounded-lg overflow-hidden"
>
<Cover
type="series"
id={series.id}
alt={`Couverture de ${series.metadata.title}`}
quality={25}
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 16.666vw"
/>
</div>
))}
</div>
@@ -41,40 +48,3 @@ export function HeroSection({ series }: HeroSectionProps) {
</div>
);
}
interface CoverImageProps {
series: KomgaSeries;
}
function CoverImage({ series }: CoverImageProps) {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
return (
<div className="relative aspect-[2/3] bg-muted rounded-lg overflow-hidden">
{!imageError ? (
<>
<ImageLoader isLoading={imageLoading} />
<Image
src={`/api/komga/images/series/${series.id}/thumbnail`}
alt={`Couverture de ${series.metadata.title}`}
fill
className={cn(
"object-cover transition-opacity duration-300",
imageLoading ? "opacity-0" : "opacity-100"
)}
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 16.666vw"
loading="lazy"
quality={25}
onError={() => setImageError(true)}
onLoad={() => setImageLoading(false)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageOff className="w-8 h-8" />
</div>
)}
</div>
);
}

View File

@@ -1,11 +1,9 @@
"use client";
import { KomgaBook, KomgaSeries } from "@/types/komga";
import { ChevronLeft, ChevronRight, ImageOff } from "lucide-react";
import Image from "next/image";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { ImageLoader } from "@/components/ui/image-loader";
import { Cover } from "@/components/ui/cover";
interface MediaRowProps {
title: string;
@@ -82,9 +80,6 @@ interface MediaCardProps {
}
function MediaCard({ item, onClick }: MediaCardProps) {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
// Déterminer si c'est une série ou un livre
const isSeries = "booksCount" in item;
const title = isSeries
@@ -107,32 +102,13 @@ function MediaCard({ item, onClick }: MediaCardProps) {
>
{/* Image de couverture */}
<div className="relative aspect-[2/3] bg-muted">
{!imageError ? (
<>
<ImageLoader isLoading={imageLoading} />
<Image
src={`/api/komga/images/${isSeries ? "series" : "books"}/${item.id}/${
isSeries ? "first-page" : "pages/1"
}`}
<Cover
type={isSeries ? "series" : "book"}
id={item.id}
alt={`Couverture de ${title}`}
fill
className={cn(
"object-cover transition-opacity duration-300",
imageLoading ? "opacity-0" : "opacity-100"
)}
sizes="200px"
loading="lazy"
quality={100}
onError={() => setImageError(true)}
onLoad={() => setImageLoading(false)}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageOff className="w-12 h-12" />
</div>
)}
{/* Overlay avec les informations au survol */}
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3">
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>

View File

@@ -1,12 +1,10 @@
import { KomgaLibrary } from "@/types/komga";
import { Book, ImageOff } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { Book } from "lucide-react";
import { Cover } from "@/components/ui/cover";
interface LibraryGridProps {
libraries: KomgaLibrary[];
onLibraryClick?: (library: KomgaLibrary) => void;
getLibraryThumbnailUrl: (libraryId: string) => string;
}
// Fonction utilitaire pour formater la date de manière sécurisée
@@ -27,11 +25,7 @@ const formatDate = (dateString: string): string => {
}
};
export function LibraryGrid({
libraries,
onLibraryClick,
getLibraryThumbnailUrl,
}: LibraryGridProps) {
export function LibraryGrid({ libraries, onLibraryClick }: LibraryGridProps) {
if (!libraries.length) {
return (
<div className="text-center p-8">
@@ -43,12 +37,7 @@ export function LibraryGrid({
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{libraries.map((library) => (
<LibraryCard
key={library.id}
library={library}
onClick={() => onLibraryClick?.(library)}
getLibraryThumbnailUrl={getLibraryThumbnailUrl}
/>
<LibraryCard key={library.id} library={library} onClick={() => onLibraryClick?.(library)} />
))}
</div>
);
@@ -57,12 +46,9 @@ export function LibraryGrid({
interface LibraryCardProps {
library: KomgaLibrary;
onClick?: () => void;
getLibraryThumbnailUrl: (libraryId: string) => string;
}
function LibraryCard({ library, onClick, getLibraryThumbnailUrl }: LibraryCardProps) {
const [imageError, setImageError] = useState(false);
function LibraryCard({ library, onClick }: LibraryCardProps) {
return (
<button
onClick={onClick}
@@ -70,20 +56,15 @@ function LibraryCard({ library, onClick, getLibraryThumbnailUrl }: LibraryCardPr
>
{/* Image de couverture */}
<div className="absolute inset-0 bg-muted">
{!imageError ? (
<Image
src={getLibraryThumbnailUrl(library.id)}
<div className="w-full h-full opacity-20 group-hover:opacity-30 transition-opacity">
<Cover
type="series"
id={library.id}
alt={`Couverture de ${library.name}`}
fill
className="object-cover opacity-20 group-hover:opacity-30 transition-opacity"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
onError={() => setImageError(true)}
quality={25}
/>
) : (
<div className="w-full h-full flex items-center justify-center opacity-20">
<ImageOff className="w-12 h-12" />
</div>
)}
</div>
{/* Contenu */}

View File

@@ -1,32 +1,33 @@
"use client";
import { KomgaSeries } from "@/types/komga";
import { Book, ImageOff, Loader2 } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ImageLoader } from "@/components/ui/image-loader";
import { cn } from "@/lib/utils";
import { Cover } from "@/components/ui/cover";
interface SeriesGridProps {
series: KomgaSeries[];
}
// Fonction utilitaire pour obtenir les informations de lecture d'une série
// Fonction utilitaire pour obtenir les informations de statut de lecture
const getReadingStatusInfo = (series: KomgaSeries) => {
const { booksCount, booksReadCount, booksUnreadCount } = series;
const booksInProgressCount = booksCount - (booksReadCount + booksUnreadCount);
if (series.booksCount === 0) {
return {
label: "Pas de tomes",
className: "bg-yellow-500/10 text-yellow-500",
};
}
if (booksReadCount === booksCount) {
if (series.booksCount === series.booksReadCount) {
return {
label: "Lu",
className: "bg-green-500/10 text-green-500",
};
}
if (booksInProgressCount > 0 || (booksReadCount > 0 && booksReadCount < booksCount)) {
if (series.booksReadCount > 0) {
return {
label: `${booksReadCount}/${booksCount}`,
label: `${series.booksReadCount}/${series.booksCount}`,
className: "bg-blue-500/10 text-blue-500",
};
}
@@ -51,68 +52,29 @@ export function SeriesGrid({ series }: SeriesGridProps) {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{series.map((series) => (
<SeriesCard
key={series.id}
series={series}
onClick={() => router.push(`/series/${series.id}`)}
/>
))}
</div>
);
}
interface SeriesCardProps {
series: KomgaSeries;
onClick?: () => void;
}
function SeriesCard({ series, onClick }: SeriesCardProps) {
const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const statusInfo = getReadingStatusInfo(series);
const isCompleted = series.booksCount === series.booksReadCount;
return (
<button
onClick={onClick}
key={series.id}
onClick={() => router.push(`/series/${series.id}`)}
className={cn(
"group relative aspect-[2/3] overflow-hidden rounded-lg bg-muted",
isCompleted && "opacity-50"
series.booksCount === series.booksReadCount && "opacity-50"
)}
>
{!imageError ? (
<>
<ImageLoader isLoading={isLoading} />
<Image
src={`/api/komga/images/series/${series.id}/first-page`}
<Cover
type="series"
id={series.id}
alt={`Couverture de ${series.metadata.title}`}
fill
className={cn(
"object-cover transition-opacity duration-300",
isLoading ? "opacity-0" : "opacity-100"
)}
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 20vw, 20vw"
onError={() => setImageError(true)}
onLoad={() => setIsLoading(false)}
loading="lazy"
quality={80}
unoptimized
priority={false}
fetchPriority="low"
isCompleted={series.booksCount === series.booksReadCount}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageOff className="w-12 h-12 text-muted-foreground" />
</div>
)}
{/* Overlay avec les informations au survol */}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 space-y-2 translate-y-full group-hover:translate-y-0 transition-transform duration-200">
<h3 className="font-medium text-sm text-white line-clamp-2">{series.metadata.title}</h3>
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-full text-xs ${statusInfo.className}`}>
{statusInfo.label}
<span
className={`px-2 py-0.5 rounded-full text-xs ${
getReadingStatusInfo(series).className
}`}
>
{getReadingStatusInfo(series).label}
</span>
<span className="text-xs text-white/80">
{series.booksCount} tome{series.booksCount > 1 ? "s" : ""}
@@ -120,5 +82,7 @@ function SeriesCard({ series, onClick }: SeriesCardProps) {
</div>
</div>
</button>
))}
</div>
);
}

View File

@@ -1,12 +1,8 @@
"use client";
import { KomgaBook } from "@/types/komga";
import { ImageOff, Loader2 } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { formatDate } from "@/lib/utils";
import { ImageLoader } from "@/components/ui/image-loader";
import { cn } from "@/lib/utils";
import { Cover } from "@/components/ui/cover";
interface BookGridProps {
books: KomgaBook[];
@@ -62,7 +58,12 @@ export function BookGrid({ books, onBookClick }: BookGridProps) {
onClick={() => onBookClick(book)}
className="group relative aspect-[2/3] overflow-hidden rounded-lg bg-muted hover:opacity-100 transition-all"
>
<BookImage book={book} isCompleted={book.readProgress?.completed} />
<Cover
type="book"
id={book.id}
alt={`Couverture de ${book.metadata.title || `Tome ${book.metadata.number}`}`}
isCompleted={book.readProgress?.completed}
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 space-y-2 translate-y-full group-hover:translate-y-0 transition-transform duration-200">
<p className="text-sm font-medium text-white text-left line-clamp-2">
{book.metadata.title || `Tome ${book.metadata.number}`}
@@ -79,131 +80,3 @@ export function BookGrid({ books, onBookClick }: BookGridProps) {
</div>
);
}
interface BookImageProps {
book: KomgaBook;
isCompleted?: boolean;
}
function BookImage({ book, isCompleted }: BookImageProps) {
const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
if (imageError) {
return (
<div className="w-full h-full flex items-center justify-center">
<ImageOff className="w-12 h-12 text-muted-foreground" />
</div>
);
}
return (
<>
<ImageLoader isLoading={isLoading} />
<Image
src={`/api/komga/images/books/${book.id}/pages/1`}
alt={`Couverture de ${book.metadata.title || `Tome ${book.metadata.number}`}`}
fill
className={cn(
"object-cover transition-opacity duration-300",
isLoading ? "opacity-0" : "opacity-100",
isCompleted && "opacity-50"
)}
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 20vw, 20vw"
onError={() => setImageError(true)}
onLoad={() => setIsLoading(false)}
loading="lazy"
quality={80}
unoptimized
priority={false}
fetchPriority="low"
/>
</>
);
}
interface BookCardProps {
book: KomgaBook;
onClick?: () => void;
getBookThumbnailUrl: (bookId: string) => string;
}
function BookCard({ book, onClick, getBookThumbnailUrl }: BookCardProps) {
const [imageError, setImageError] = useState(false);
const getReadingStatusInfo = () => {
if (!book.readProgress) {
return {
label: "Non lu",
className: "bg-yellow-500/10 text-yellow-500",
};
}
if (book.readProgress.completed) {
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",
};
}
if (book.readProgress.page > 0) {
return {
label: `Page ${book.readProgress.page}/${book.media.pagesCount}`,
className: "bg-blue-500/10 text-blue-500",
};
}
return {
label: "Non lu",
className: "bg-yellow-500/10 text-yellow-500",
};
};
const statusInfo = getReadingStatusInfo();
return (
<button
onClick={onClick}
className="group relative flex flex-col rounded-lg border bg-card text-card-foreground shadow-sm hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden"
>
{/* Image de couverture */}
<div className="relative aspect-[2/3] bg-muted">
{!imageError ? (
<Image
src={getBookThumbnailUrl(book.id)}
alt={`Couverture de ${book.metadata.title}`}
fill
className="object-cover"
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 16.666vw, 16.666vw"
onError={() => setImageError(true)}
loading="lazy"
quality={100}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageOff className="w-12 w-12" />
</div>
)}
</div>
{/* Contenu */}
<div className="flex flex-col p-2">
<h3 className="font-medium line-clamp-2 text-sm">
{book.metadata.title || `Tome ${book.metadata.number}`}
</h3>
<div className="mt-1 text-xs text-muted-foreground space-y-1">
{book.metadata.releaseDate && (
<div>{new Date(book.metadata.releaseDate).toLocaleDateString()}</div>
)}
<div className="flex items-center">
<span className={`px-1.5 py-0.5 rounded-full text-[10px] ${statusInfo.className}`}>
{statusInfo.label}
</span>
</div>
{book.size && <div className="text-[10px]">{book.size}</div>}
</div>
</div>
</button>
);
}

View File

@@ -1,27 +1,69 @@
"use client";
import Image from "next/image";
import { ImageOff, Book, BookOpen, BookMarked, Star, StarOff } from "lucide-react";
import { Book, BookOpen, BookMarked, Star, StarOff } from "lucide-react";
import { KomgaSeries } from "@/types/komga";
import { useState, useEffect } from "react";
import { Button } from "../ui/button";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { ImageLoader } from "@/components/ui/image-loader";
import { Cover } from "@/components/ui/cover";
interface SeriesHeaderProps {
series: KomgaSeries;
onSeriesUpdate?: (series: KomgaSeries) => void;
}
interface ReadingStatusInfo {
label: string;
className: string;
icon: React.ElementType;
}
export const SeriesHeader = ({ series, onSeriesUpdate }: SeriesHeaderProps) => {
const { toast } = useToast();
const [isFavorite, setIsFavorite] = useState(false);
// Fonction utilitaire pour obtenir les informations de lecture d'une série
const getReadingStatusInfo = (series: KomgaSeries): ReadingStatusInfo => {
useEffect(() => {
// Vérifier si la série est dans les favoris
const checkFavorite = async () => {
try {
const response = await fetch(`/api/komga/series/${series.id}/favorite`);
if (response.ok) {
const data = await response.json();
setIsFavorite(data.favorite);
}
} catch (error) {
console.error("Erreur lors de la vérification des favoris:", error);
}
};
checkFavorite();
}, [series.id]);
const handleToggleFavorite = async () => {
try {
const response = await fetch(`/api/komga/series/${series.id}/favorite`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ favorite: !isFavorite }),
});
if (response.ok) {
setIsFavorite(!isFavorite);
toast({
title: !isFavorite ? "Ajouté aux favoris" : "Retiré des favoris",
description: series.metadata.title,
});
} else {
throw new Error("Erreur lors de la modification des favoris");
}
} catch (error) {
console.error("Erreur lors de la modification des favoris:", error);
toast({
title: "Erreur",
description: "Impossible de modifier les favoris",
variant: "destructive",
});
}
};
const getReadingStatusInfo = () => {
const { booksCount, booksReadCount, booksUnreadCount } = series;
const booksInProgressCount = booksCount - (booksReadCount + booksUnreadCount);
@@ -29,7 +71,7 @@ const getReadingStatusInfo = (series: KomgaSeries): ReadingStatusInfo => {
return {
label: "Lu",
className: "bg-green-500/10 text-green-500",
icon: BookOpen,
icon: BookMarked,
};
}
@@ -37,7 +79,7 @@ const getReadingStatusInfo = (series: KomgaSeries): ReadingStatusInfo => {
return {
label: `${booksReadCount}/${booksCount}`,
className: "bg-blue-500/10 text-blue-500",
icon: BookMarked,
icon: BookOpen,
};
}
@@ -48,141 +90,36 @@ const getReadingStatusInfo = (series: KomgaSeries): ReadingStatusInfo => {
};
};
export const SeriesHeader = ({ series, onSeriesUpdate }: SeriesHeaderProps) => {
const { toast } = useToast();
const [languageDisplay, setLanguageDisplay] = useState<string>(series.metadata.language);
const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isFavorite, setIsFavorite] = useState(false);
const [mounted, setMounted] = useState(false);
const statusInfo = getReadingStatusInfo(series);
// Vérifier si la série est dans les favoris au chargement
useEffect(() => {
const checkFavorite = async () => {
try {
const response = await fetch("/api/komga/favorites");
if (response.ok) {
const favoriteIds = await response.json();
setIsFavorite(favoriteIds.includes(series.id));
}
} catch (error) {
console.error("Erreur lors de la vérification des favoris:", error);
}
};
checkFavorite();
setMounted(true);
}, [series.id]);
useEffect(() => {
try {
if (series.metadata.language) {
const displayNames = new Intl.DisplayNames([navigator.language || "fr-FR"], {
type: "language",
});
setLanguageDisplay(displayNames.of(series.metadata.language) || series.metadata.language);
}
} catch (error) {
console.error("Erreur lors de la traduction de la langue:", error);
setLanguageDisplay(series.metadata.language);
}
}, [series.metadata.language]);
const handleToggleFavorite = async () => {
try {
setIsLoading(true);
const response = await fetch("/api/komga/favorites", {
method: isFavorite ? "DELETE" : "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ seriesId: series.id }),
});
if (!response.ok) {
throw new Error("Erreur lors de la modification des favoris");
}
setIsFavorite(!isFavorite);
if (onSeriesUpdate) {
onSeriesUpdate({ ...series, favorite: !isFavorite });
}
// Dispatch l'événement pour notifier les autres composants
window.dispatchEvent(new Event("favoritesChanged"));
toast({
title: isFavorite ? "Retiré des favoris" : "Ajouté aux favoris",
variant: "default",
});
} catch (error) {
toast({
title: "Une erreur est survenue",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const statusInfo = getReadingStatusInfo();
return (
<div className="relative min-h-[300px] md:h-[300px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden">
{/* Image de fond */}
{!imageError ? (
<>
<ImageLoader isLoading={isLoading} />
<Image
src={`/api/komga/images/series/${series.id}/first-page`}
<div className="absolute inset-0">
<Cover
type="series"
id={series.id}
alt={`Couverture de ${series.metadata.title}`}
fill
className={cn(
"object-cover blur-sm scale-105 brightness-50 transition-opacity duration-300",
isLoading ? "opacity-0" : "opacity-100"
)}
className="blur-sm scale-105 brightness-50"
sizes="100vw"
onError={() => setImageError(true)}
onLoad={() => setIsLoading(false)}
quality={60}
priority
unoptimized
/>
</>
) : (
<div className="absolute inset-0 bg-muted flex items-center justify-center">
<ImageOff className="w-12 h-12 text-muted-foreground" />
</div>
)}
{/* Contenu */}
<div className="relative container mx-auto px-4 py-8">
<div className="flex flex-col md:flex-row gap-6 items-center md:items-start w-full">
{/* Image principale */}
<div className="relative w-[180px] aspect-[2/3] rounded-lg overflow-hidden shadow-lg bg-muted flex-shrink-0">
{!imageError ? (
<>
<ImageLoader isLoading={isLoading} />
<Image
src={`/api/komga/images/series/${series.id}/first-page`}
<Cover
type="series"
id={series.id}
alt={`Couverture de ${series.metadata.title}`}
fill
className={cn(
"object-cover transition-opacity duration-300",
isLoading ? "opacity-0" : "opacity-100"
)}
sizes="180px"
onError={() => setImageError(true)}
onLoad={() => setIsLoading(false)}
quality={90}
priority
unoptimized
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageOff className="w-12 h-12 text-muted-foreground" />
</div>
)}
</div>
{/* Informations */}

View File

@@ -0,0 +1,70 @@
"use client";
import { ImageOff } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { ImageLoader } from "@/components/ui/image-loader";
interface CoverProps {
type: "series" | "book";
id: string;
alt?: string;
className?: string;
priority?: boolean;
quality?: number;
sizes?: string;
isCompleted?: boolean;
}
export function Cover({
type,
id,
alt = "Image de couverture",
className,
priority = false,
quality = 80,
sizes = "(max-width: 640px) 33vw, (max-width: 1024px) 20vw, 20vw",
isCompleted = false,
}: CoverProps) {
const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const getImageUrl = () => {
if (type === "series") {
return `/api/komga/images/series/${id}/thumbnail`;
}
return `/api/komga/images/books/${id}/thumbnail`;
};
if (imageError) {
return (
<div className="w-full h-full flex items-center justify-center bg-muted rounded-lg">
<ImageOff className="w-12 h-12 text-muted-foreground" />
</div>
);
}
return (
<div className="relative w-full h-full">
<ImageLoader isLoading={isLoading} />
<Image
src={getImageUrl()}
alt={alt}
fill
className={cn(
"object-cover transition-opacity duration-300 rounded-lg",
isLoading ? "opacity-0" : "opacity-100",
isCompleted && "opacity-50",
className
)}
sizes={sizes}
onError={() => setImageError(true)}
onLoad={() => setIsLoading(false)}
loading={priority ? "eager" : "lazy"}
quality={quality}
priority={priority}
/>
</div>
);
}

View File

@@ -67,14 +67,6 @@ export class BookService extends BaseApiService {
static async getPage(bookId: string, pageNumber: number): Promise<Response> {
try {
// Récupérer les préférences de l'utilisateur
const preferences = await PreferencesService.getPreferences();
// Si l'utilisateur préfère les vignettes, utiliser getPageThumbnail
if (preferences.showThumbnails) {
return this.getPageThumbnail(bookId, pageNumber);
}
// Ajuster le numéro de page pour l'API Komga (zero-based)
const adjustedPageNumber = pageNumber - 1;
const response = await ImageService.getImage(
@@ -83,6 +75,7 @@ export class BookService extends BaseApiService {
return new Response(response.buffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
@@ -90,26 +83,13 @@ export class BookService extends BaseApiService {
}
}
static async getPageThumbnail(bookId: string, pageNumber: number): Promise<Response> {
static async getCover(bookId: string): Promise<Response> {
try {
// Ajuster le numéro de page pour l'API Komga (zero-based)
const adjustedPageNumber = pageNumber;
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",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
throw this.handleError(error, "Impossible de récupérer la miniature");
}
}
// Récupérer les préférences de l'utilisateur
const preferences = await PreferencesService.getPreferences();
static async getThumbnail(bookId: string): Promise<Response> {
try {
// Si l'utilisateur préfère les vignettes, utiliser la miniature
if (preferences.showThumbnails) {
const response = await ImageService.getImage(`books/${bookId}/thumbnail`);
return new Response(response.buffer, {
headers: {
@@ -117,8 +97,12 @@ export class BookService extends BaseApiService {
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
// Sinon, récupérer la première page
return this.getPage(bookId, 1);
} catch (error) {
throw this.handleError(error, "Impossible de récupérer la miniature du livre");
throw this.handleError(error, "Impossible de récupérer la couverture");
}
}
@@ -130,7 +114,23 @@ export class BookService extends BaseApiService {
return `/api/komga/images/books/${bookId}/pages/${pageNumber}/thumbnail`;
}
static getThumbnailUrl(bookId: string): string {
return ImageService.getBookThumbnailUrl(bookId);
static async getPageThumbnail(bookId: string, pageNumber: number): Promise<Response> {
try {
const response = await ImageService.getImage(
`books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true`
);
return new Response(response.buffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
throw this.handleError(error, "Impossible de récupérer la miniature de la page");
}
}
static getCoverUrl(bookId: string): string {
return `/api/komga/images/books/${bookId}/thumbnail`;
}
}

View File

@@ -77,27 +77,13 @@ export class SeriesService extends BaseApiService {
}
}
static async getFirstPage(seriesId: string): Promise<Response> {
static async getCover(seriesId: string): Promise<Response> {
try {
// Récupérer les préférences de l'utilisateur
const preferences = await PreferencesService.getPreferences();
// Si l'utilisateur préfère les vignettes, utiliser getThumbnail
// Si l'utilisateur préfère les vignettes, utiliser la miniature
if (preferences.showThumbnails) {
return this.getThumbnail(seriesId);
}
// Sinon, récupérer la première page
const firstBookId = await this.getFirstBook(seriesId);
const response = await BookService.getPage(firstBookId, 1);
return response;
} catch (error) {
throw this.handleError(error, "Impossible de récupérer la première page");
}
}
static async getThumbnail(seriesId: string): Promise<Response> {
try {
const response = await ImageService.getImage(`series/${seriesId}/thumbnail`);
return new Response(response.buffer, {
headers: {
@@ -105,8 +91,18 @@ export class SeriesService extends BaseApiService {
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
// Sinon, récupérer la première page
const firstBookId = await this.getFirstBook(seriesId);
const response = await BookService.getPage(firstBookId, 1);
return response;
} catch (error) {
throw this.handleError(error, "Impossible de récupérer la miniature de la série");
throw this.handleError(error, "Impossible de récupérer la couverture");
}
}
static getCoverUrl(seriesId: string): string {
return `/api/komga/images/series/${seriesId}/thumbnail`;
}
}