refacto: cover split and simplify

This commit is contained in:
Julien Froidefond
2025-03-01 14:55:52 +01:00
parent ee6e055505
commit 4c8c7cdb9e
10 changed files with 118 additions and 230 deletions

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import { Cover } from "@/components/ui/cover"; import { SeriesCover } from "@/components/ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { KomgaSeries } from "@/types/komga";
interface OptimizedHeroSeries { interface OptimizedHeroSeries {
id: string; id: string;
@@ -31,9 +32,8 @@ export function HeroSection({ series }: HeroSectionProps) {
key={series.id} key={series.id}
className="relative aspect-[2/3] bg-muted rounded-lg overflow-hidden" className="relative aspect-[2/3] bg-muted rounded-lg overflow-hidden"
> >
<Cover <SeriesCover
type="series" series={series as KomgaSeries}
id={series.id}
alt={t("home.hero.coverAlt", { title: series.metadata.title })} alt={t("home.hero.coverAlt", { title: series.metadata.title })}
quality={25} quality={25}
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 16.666vw" sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 16.666vw"

View File

@@ -2,10 +2,10 @@
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Cover } from "@/components/ui/cover";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service"; import { KomgaBook, KomgaSeries } from "@/types/komga";
import { KomgaBook } from "@/types/komga"; import { BookCover } from "../ui/book-cover";
import { SeriesCover } from "../ui/series-cover";
interface BaseItem { interface BaseItem {
id: string; id: string;
@@ -128,24 +128,9 @@ function MediaCard({ item, onClick }: MediaCardProps) {
> >
<div className="relative aspect-[2/3] bg-muted"> <div className="relative aspect-[2/3] bg-muted">
{isSeries ? ( {isSeries ? (
<Cover <SeriesCover series={item as KomgaSeries} alt={`Couverture de ${title}`} quality={100} />
type={isSeries ? "series" : "book"}
id={item.id}
alt={`Couverture de ${title}`}
quality={100}
readBooks={item.booksReadCount}
totalBooks={item.booksCount}
isCompleted={item.booksCount === item.booksReadCount}
/>
) : ( ) : (
<Cover <BookCover book={item as KomgaBook} alt={`Couverture de ${title}`} quality={100} />
type="book"
id={item.id}
alt={`Couverture de ${title}`}
quality={100}
currentPage={ClientOfflineBookService.getCurrentPage(item as KomgaBook)}
totalPages={item.media?.pagesCount}
/>
)} )}
{/* Overlay avec les informations au survol */} {/* 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"> <div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3">

View File

@@ -1,105 +0,0 @@
import { Book } from "lucide-react";
import { Cover } from "@/components/ui/cover";
import { KomgaLibrary } from "@/types/komga";
import { useTranslate } from "@/hooks/useTranslate";
interface LibraryGridProps {
libraries: KomgaLibrary[];
onLibraryClick?: (library: KomgaLibrary) => void;
}
// Utility function to format date safely
const formatDate = (dateString: string, locale: string): string => {
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return "Date unavailable";
}
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
} catch (error) {
console.error("Error formatting date:", error);
return "Date unavailable";
}
};
export function LibraryGrid({ libraries, onLibraryClick }: LibraryGridProps) {
const { t } = useTranslate();
if (!libraries.length) {
return (
<div className="text-center p-8">
<p className="text-muted-foreground">{t("library.empty")}</p>
</div>
);
}
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)} />
))}
</div>
);
}
interface LibraryCardProps {
library: KomgaLibrary;
onClick?: () => void;
}
function LibraryCard({ library, onClick }: LibraryCardProps) {
const { t, i18n } = useTranslate();
return (
<button
onClick={onClick}
className="group relative flex flex-col h-48 rounded-lg border bg-card text-card-foreground shadow-sm hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden"
>
{/* Cover image */}
<div className="absolute inset-0 bg-muted">
<div className="w-full h-full opacity-20 group-hover:opacity-30 transition-opacity">
<Cover
type="series"
id={library.id}
alt={t("library.coverAlt", { name: library.name })}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
quality={25}
readBooks={library.booksReadCount}
totalBooks={library.booksCount}
/>
</div>
</div>
{/* Content */}
<div className="relative h-full flex flex-col p-6">
<div className="flex items-center gap-3 mb-4">
<Book className="h-6 w-6 shrink-0" />
<h3 className="text-lg font-semibold line-clamp-1">{library.name}</h3>
</div>
<div className="mt-auto space-y-2">
<div className="flex items-center justify-between text-sm">
<span
className={`px-2 py-0.5 rounded-full text-xs ${
library.unavailable
? "bg-destructive/10 text-destructive"
: "bg-green-500/10 text-green-500"
}`}
>
{t(library.unavailable ? "library.status.unavailable" : "library.status.available")}
</span>
</div>
<div className="text-xs text-muted-foreground">
{t("library.lastUpdated", {
date: formatDate(library.lastModified, i18n.language === "fr" ? "fr-FR" : "en-US"),
})}
</div>
</div>
</div>
</button>
);
}

View File

@@ -3,7 +3,7 @@
import { KomgaSeries } from "@/types/komga"; import { KomgaSeries } from "@/types/komga";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Cover } from "@/components/ui/cover"; import { SeriesCover } from "@/components/ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
interface SeriesGridProps { interface SeriesGridProps {
@@ -65,13 +65,11 @@ export function SeriesGrid({ series }: SeriesGridProps) {
series.booksCount === series.booksReadCount && "opacity-50" series.booksCount === series.booksReadCount && "opacity-50"
)} )}
> >
<Cover <SeriesCover
type="series" series={series as KomgaSeries}
id={series.id}
alt={t("series.coverAlt", { title: series.metadata.title })} alt={t("series.coverAlt", { title: series.metadata.title })}
isCompleted={series.booksCount === series.booksReadCount} quality={25}
readBooks={series.booksReadCount} sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
totalBooks={series.booksCount}
/> />
<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"> <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> <h3 className="font-medium text-sm text-white line-clamp-2">{series.metadata.title}</h3>

View File

@@ -2,7 +2,7 @@
import { KomgaBook } from "@/types/komga"; import { KomgaBook } from "@/types/komga";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { Cover } from "@/components/ui/cover"; import { BookCover } from "@/components/ui/book-cover";
import { MarkAsReadButton } from "@/components/ui/mark-as-read-button"; import { MarkAsReadButton } from "@/components/ui/mark-as-read-button";
import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button"; import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button";
import { BookOfflineButton } from "@/components/ui/book-offline-button"; import { BookOfflineButton } from "@/components/ui/book-offline-button";
@@ -106,7 +106,6 @@ export function BookGrid({ books, onBookClick }: BookGridProps) {
const statusInfo = getReadingStatusInfo(book, t); const statusInfo = getReadingStatusInfo(book, t);
const isRead = book.readProgress?.completed || false; const isRead = book.readProgress?.completed || false;
const hasReadProgress = book.readProgress !== null; const hasReadProgress = book.readProgress !== null;
const currentPage = ClientOfflineBookService.getCurrentPage(book);
return ( return (
<div <div
@@ -117,15 +116,11 @@ export function BookGrid({ books, onBookClick }: BookGridProps) {
onClick={() => onBookClick(book)} onClick={() => onBookClick(book)}
className="w-full h-full hover:opacity-100 transition-all" className="w-full h-full hover:opacity-100 transition-all"
> >
<Cover <BookCover
type="book" book={book}
id={book.id}
alt={t("books.coverAlt", { alt={t("books.coverAlt", {
title: book.metadata.title || `Tome ${book.metadata.number}`, title: book.metadata.title || `Tome ${book.metadata.number}`,
})} })}
isCompleted={isRead}
currentPage={currentPage}
totalPages={book.media.pagesCount}
/> />
</button> </button>

View File

@@ -5,12 +5,12 @@ import { KomgaSeries } from "@/types/komga";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { Cover } from "@/components/ui/cover";
import { RefreshButton } from "@/components/library/RefreshButton"; import { RefreshButton } from "@/components/library/RefreshButton";
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";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { SeriesCover } from "@/components/ui/series-cover";
interface SeriesHeaderProps { interface SeriesHeaderProps {
series: KomgaSeries; series: KomgaSeries;
@@ -122,9 +122,8 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
<div className="relative min-h-[300px] md:h-[300px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden"> <div className="relative min-h-[300px] md:h-[300px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden">
{/* Image de fond */} {/* Image de fond */}
<div className="absolute inset-0"> <div className="absolute inset-0">
<Cover <SeriesCover
type="series" series={series as KomgaSeries}
id={series.id}
alt={t("series.header.coverAlt", { title: series.metadata.title })} alt={t("series.header.coverAlt", { title: series.metadata.title })}
className="blur-sm scale-105 brightness-50" className="blur-sm scale-105 brightness-50"
quality={60} quality={60}
@@ -136,9 +135,8 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
<div className="flex flex-col md:flex-row gap-6 items-center md:items-start w-full"> <div className="flex flex-col md:flex-row gap-6 items-center md:items-start w-full">
{/* Image principale */} {/* Image principale */}
<div className="relative w-[180px] aspect-[2/3] rounded-lg overflow-hidden shadow-lg bg-muted flex-shrink-0"> <div className="relative w-[180px] aspect-[2/3] rounded-lg overflow-hidden shadow-lg bg-muted flex-shrink-0">
<Cover <SeriesCover
type="series" series={series as KomgaSeries}
id={series.id}
alt={t("series.header.coverAlt", { title: series.metadata.title })} alt={t("series.header.coverAlt", { title: series.metadata.title })}
quality={90} quality={90}
/> />

View File

@@ -0,0 +1,37 @@
"use client";
import { CoverClient } from "./cover-client";
import { ProgressBar } from "./progress-bar";
import { BookCoverProps, getImageUrl } from "./cover-utils";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
export function BookCover({
book,
alt = "Image de couverture",
className,
quality = 80,
sizes = "100vw",
}: BookCoverProps) {
if (!book) return null;
const imageUrl = getImageUrl("book", book.id);
const isCompleted = book.readProgress?.completed || false;
const currentPage = ClientOfflineBookService.getCurrentPage(book);
const totalPages = book.media.pagesCount;
const showProgress = currentPage && totalPages && currentPage > 0 && !isCompleted;
return (
<div className="relative w-full h-full">
<CoverClient
imageUrl={imageUrl}
alt={alt}
className={className}
quality={quality}
sizes={sizes}
isCompleted={isCompleted}
/>
{showProgress && <ProgressBar progress={currentPage} total={totalPages} type="book" />}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { KomgaBook, KomgaSeries } from "@/types/komga";
export interface BaseCoverProps {
alt?: string;
className?: string;
quality?: number;
sizes?: string;
}
export interface BookCoverProps extends BaseCoverProps {
book?: KomgaBook;
}
export interface SeriesCoverProps extends BaseCoverProps {
series: KomgaSeries;
}
export function getImageUrl(type: "series" | "book", id: string) {
if (type === "series") {
return `/api/komga/images/series/${id}/thumbnail`;
}
return `/api/komga/images/books/${id}/thumbnail`;
}

View File

@@ -1,79 +0,0 @@
"use client";
import { CoverClient } from "./cover-client";
import { ProgressBar } from "./progress-bar";
interface BaseCoverProps {
type: "series" | "book";
id: string;
alt?: string;
className?: string;
quality?: number;
sizes?: string;
isCompleted?: boolean;
}
interface BookCoverProps extends BaseCoverProps {
type: "book";
currentPage?: number;
totalPages?: number;
}
interface SeriesCoverProps extends BaseCoverProps {
type: "series";
readBooks?: number;
totalBooks?: number;
}
type CoverProps = BookCoverProps | SeriesCoverProps;
function getImageUrl(type: "series" | "book", id: string) {
if (type === "series") {
return `/api/komga/images/series/${id}/thumbnail`;
}
return `/api/komga/images/books/${id}/thumbnail`;
}
export function Cover(props: CoverProps) {
const {
type,
id,
alt = "Image de couverture",
className,
quality = 80,
sizes = "100vw",
isCompleted = false,
} = props;
const imageUrl = getImageUrl(type, id);
const showProgress = () => {
if (type === "book") {
const { currentPage, totalPages } = props;
return currentPage && totalPages && currentPage > 0 && !isCompleted ? (
<ProgressBar progress={currentPage} total={totalPages} type="book" />
) : null;
}
if (type === "series") {
const { readBooks, totalBooks } = props;
return readBooks && totalBooks && readBooks > 0 && !isCompleted ? (
<ProgressBar progress={readBooks} total={totalBooks} type="series" />
) : null;
}
};
return (
<div className="relative w-full h-full">
<CoverClient
imageUrl={imageUrl}
alt={alt}
className={className}
quality={quality}
sizes={sizes}
isCompleted={isCompleted}
/>
{showProgress()}
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { CoverClient } from "./cover-client";
import { ProgressBar } from "./progress-bar";
import { SeriesCoverProps, getImageUrl } from "./cover-utils";
export function SeriesCover({
series,
alt = "Image de couverture",
className,
quality = 80,
sizes = "100vw",
}: SeriesCoverProps) {
if (!series) return null;
const imageUrl = getImageUrl("series", series.id);
const isCompleted = series.booksCount === series.booksReadCount;
const readBooks = series.booksReadCount;
const totalBooks = series.booksCount;
const showProgress = readBooks && totalBooks && readBooks > 0 && !isCompleted;
return (
<div className="relative w-full h-full">
<CoverClient
imageUrl={imageUrl}
alt={alt}
className={className}
quality={quality}
sizes={sizes}
isCompleted={isCompleted}
/>
{showProgress && <ProgressBar progress={readBooks} total={totalBooks} type="series" />}
</div>
);
}