feat: add multi-provider support (Komga + Stripstream Librarian)
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
- Introduce provider abstraction layer (IMediaProvider, KomgaProvider, StripstreamProvider) - Add Stripstream Librarian as second media provider with full feature parity - Migrate all pages and components from direct Komga services to provider factory - Remove dead service code (BaseApiService, HomeService, LibraryService, SearchService, TestService) - Fix library/series page-based pagination for both providers (Komga 0-indexed, Stripstream 1-indexed) - Fix unread filter and search on library page for both providers - Fix read progress display for Stripstream (reading_status mapping) - Fix series read status (books_read_count) for Stripstream - Add global search with series results for Stripstream (series_hits from Meilisearch) - Fix thumbnail proxy to return 404 gracefully instead of JSON on upstream error - Replace duration-based cache debug detection with x-nextjs-cache header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,39 +2,27 @@
|
||||
|
||||
import { SeriesCover } from "@/components/ui/series-cover";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import type { KomgaSeries } from "@/types/komga";
|
||||
|
||||
interface OptimizedHeroSeries {
|
||||
id: string;
|
||||
metadata: {
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||
|
||||
interface HeroSectionProps {
|
||||
series: OptimizedHeroSeries[];
|
||||
series: NormalizedSeries[];
|
||||
}
|
||||
|
||||
export function HeroSection({ series }: HeroSectionProps) {
|
||||
const { t } = useTranslate();
|
||||
|
||||
// logger.info("HeroSection - Séries reçues:", {
|
||||
// count: series?.length || 0,
|
||||
// firstSeries: series?.[0],
|
||||
// });
|
||||
|
||||
return (
|
||||
<div className="relative h-[300px] sm:h-[400px] lg:h-[500px] -mx-4 sm:-mx-8 overflow-hidden">
|
||||
{/* 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-2 sm:gap-4 p-4 opacity-10">
|
||||
{series?.map((series) => (
|
||||
{series?.map((s) => (
|
||||
<div
|
||||
key={series.id}
|
||||
key={s.id}
|
||||
className="relative aspect-[2/3] bg-muted/80 backdrop-blur-md rounded-lg overflow-hidden"
|
||||
>
|
||||
<SeriesCover
|
||||
series={series as KomgaSeries}
|
||||
alt={t("home.hero.coverAlt", { title: series.metadata.title })}
|
||||
series={s}
|
||||
alt={t("home.hero.coverAlt", { title: s.name })}
|
||||
showProgressUi={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,17 @@
|
||||
import { MediaRow } from "./MediaRow";
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import type { HomeData } from "@/types/home";
|
||||
|
||||
interface HomeContentProps {
|
||||
data: HomeData;
|
||||
}
|
||||
|
||||
const optimizeSeriesData = (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,
|
||||
}));
|
||||
};
|
||||
|
||||
export function HomeContent({ data }: HomeContentProps) {
|
||||
return (
|
||||
<div className="space-y-10 pb-2">
|
||||
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.continue_reading"
|
||||
items={optimizeBookData(data.ongoingBooks)}
|
||||
items={data.ongoingBooks}
|
||||
iconName="BookOpen"
|
||||
featuredHeader
|
||||
/>
|
||||
@@ -42,7 +20,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
||||
{data.ongoing && data.ongoing.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.continue_series"
|
||||
items={optimizeSeriesData(data.ongoing)}
|
||||
items={data.ongoing}
|
||||
iconName="LibraryBig"
|
||||
/>
|
||||
)}
|
||||
@@ -50,7 +28,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
||||
{data.onDeck && data.onDeck.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.up_next"
|
||||
items={optimizeBookData(data.onDeck)}
|
||||
items={data.onDeck}
|
||||
iconName="Clock"
|
||||
/>
|
||||
)}
|
||||
@@ -58,7 +36,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
||||
{data.latestSeries && data.latestSeries.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.latest_series"
|
||||
items={optimizeSeriesData(data.latestSeries)}
|
||||
items={data.latestSeries}
|
||||
iconName="Sparkles"
|
||||
/>
|
||||
)}
|
||||
@@ -66,7 +44,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
||||
{data.recentlyRead && data.recentlyRead.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.recently_added"
|
||||
items={optimizeBookData(data.recentlyRead)}
|
||||
items={data.recentlyRead}
|
||||
iconName="History"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
|
||||
import { BookCover } from "../ui/book-cover";
|
||||
import { SeriesCover } from "../ui/series-cover";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
@@ -12,34 +12,9 @@ import { Card } from "@/components/ui/card";
|
||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BaseItem {
|
||||
id: string;
|
||||
metadata: {
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface OptimizedSeries extends BaseItem {
|
||||
booksCount: number;
|
||||
booksReadCount: number;
|
||||
}
|
||||
|
||||
interface OptimizedBook extends BaseItem {
|
||||
readProgress: {
|
||||
page: number;
|
||||
};
|
||||
media: {
|
||||
pagesCount: number;
|
||||
};
|
||||
metadata: {
|
||||
title: string;
|
||||
number?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface MediaRowProps {
|
||||
titleKey: string;
|
||||
items: (OptimizedSeries | OptimizedBook)[];
|
||||
items: (NormalizedSeries | NormalizedBook)[];
|
||||
iconName?: string;
|
||||
featuredHeader?: boolean;
|
||||
}
|
||||
@@ -52,13 +27,17 @@ const iconMap = {
|
||||
History,
|
||||
};
|
||||
|
||||
function isSeries(item: NormalizedSeries | NormalizedBook): item is NormalizedSeries {
|
||||
return "bookCount" in item;
|
||||
}
|
||||
|
||||
export function MediaRow({ titleKey, items, iconName, featuredHeader = false }: 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}`;
|
||||
const onItemClick = (item: NormalizedSeries | NormalizedBook) => {
|
||||
const path = isSeries(item) ? `/series/${item.id}` : `/books/${item.id}`;
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
@@ -92,24 +71,24 @@ export function MediaRow({ titleKey, items, iconName, featuredHeader = false }:
|
||||
}
|
||||
|
||||
interface MediaCardProps {
|
||||
item: OptimizedSeries | OptimizedBook;
|
||||
item: NormalizedSeries | NormalizedBook;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
const { t } = useTranslate();
|
||||
const isSeries = "booksCount" in item;
|
||||
const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id);
|
||||
const isSeriesItem = isSeries(item);
|
||||
const { isAccessible } = useBookOfflineStatus(isSeriesItem ? "" : item.id);
|
||||
|
||||
const title = isSeries
|
||||
? item.metadata.title
|
||||
: item.metadata.title ||
|
||||
(item.metadata.number ? t("navigation.volume", { number: item.metadata.number }) : "");
|
||||
const title = isSeriesItem
|
||||
? item.name
|
||||
: item.title ||
|
||||
(item.number ? t("navigation.volume", { number: item.number }) : "");
|
||||
|
||||
const handleClick = () => {
|
||||
// Pour les séries, toujours autoriser le clic
|
||||
// Pour les livres, vérifier si accessible
|
||||
if (isSeries || isAccessible) {
|
||||
if (isSeriesItem || isAccessible) {
|
||||
onClick?.();
|
||||
}
|
||||
};
|
||||
@@ -119,24 +98,24 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"relative flex w-[188px] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border/60 bg-card/85 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:bg-card hover:shadow-md sm:w-[200px]",
|
||||
!isSeries && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
|
||||
!isSeriesItem && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<div className="relative aspect-[2/3] bg-muted">
|
||||
{isSeries ? (
|
||||
{isSeriesItem ? (
|
||||
<>
|
||||
<SeriesCover series={item as KomgaSeries} alt={`Couverture de ${title}`} />
|
||||
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/75 via-black/30 to-transparent p-3 opacity-0 transition-opacity duration-200 hover:opacity-100">
|
||||
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
|
||||
<p className="text-xs text-white/80 mt-1">
|
||||
{t("series.books", { count: item.booksCount })}
|
||||
</p>
|
||||
<SeriesCover series={item} alt={`Couverture de ${title}`} />
|
||||
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/75 via-black/30 to-transparent p-3 opacity-0 transition-opacity duration-200 hover:opacity-100">
|
||||
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
|
||||
<p className="text-xs text-white/80 mt-1">
|
||||
{t("series.books", { count: item.bookCount })}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookCover
|
||||
book={item as KomgaBook}
|
||||
book={item}
|
||||
alt={`Couverture de ${title}`}
|
||||
showControls={false}
|
||||
overlayVariant="home"
|
||||
|
||||
Reference in New Issue
Block a user