feat: add multi-provider support (Komga + Stripstream Librarian)
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:
2026-03-11 11:48:17 +01:00
parent a1a95775db
commit 7d0f1c4457
77 changed files with 2695 additions and 1705 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -26,7 +26,7 @@ interface BookDownloadStatus {
}
interface DownloadedBook {
book: KomgaBook;
book: NormalizedBook;
status: BookDownloadStatus;
}
@@ -112,11 +112,11 @@ export function DownloadManager() {
};
}, [loadDownloadedBooks, updateBookStatuses]);
const handleDeleteBook = async (book: KomgaBook) => {
const handleDeleteBook = async (book: NormalizedBook) => {
try {
const cache = await caches.open("stripstream-books");
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
for (let i = 1; i <= book.media.pagesCount; i++) {
for (let i = 1; i <= book.pageCount; i++) {
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
}
localStorage.removeItem(getStorageKey(book.id));
@@ -135,7 +135,7 @@ export function DownloadManager() {
}
};
const handleRetryDownload = async (book: KomgaBook) => {
const handleRetryDownload = async (book: NormalizedBook) => {
localStorage.removeItem(getStorageKey(book.id));
setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id));
toast({
@@ -279,7 +279,7 @@ export function DownloadManager() {
}
interface BookDownloadCardProps {
book: KomgaBook;
book: NormalizedBook;
status: BookDownloadStatus;
onDelete: () => void;
onRetry: () => void;
@@ -315,8 +315,8 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
<div className="flex items-center gap-4">
<div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
<Image
src={`/api/komga/images/books/${book.id}/thumbnail`}
alt={t("books.coverAlt", { title: book.metadata?.title })}
src={book.thumbnailUrl}
alt={t("books.coverAlt", { title: book.title })}
className="object-cover"
fill
sizes="64px"
@@ -330,19 +330,17 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
className="hover:underline hover:text-primary transition-colors"
>
<h3 className="font-medium truncate">
{book.metadata?.title || t("books.title", { number: book.metadata?.number })}
{book.title || t("books.title", { number: book.number ?? "" })}
</h3>
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatSize(book.sizeBytes)}</span>
<span></span>
<span>
{status.status === "downloading"
? t("downloads.info.pages", {
current: Math.floor((status.progress * book.media.pagesCount) / 100),
total: book.media.pagesCount,
current: Math.floor((status.progress * book.pageCount) / 100),
total: book.pageCount,
})
: t("downloads.info.totalPages", { count: book.media.pagesCount })}
: t("downloads.info.totalPages", { count: book.pageCount })}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">

View File

@@ -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>

View File

@@ -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"
/>
)}

View File

@@ -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"

View File

@@ -10,7 +10,7 @@ import { usePathname } from "next/navigation";
import { NetworkStatus } from "../ui/NetworkStatus";
import { usePreferences } from "@/contexts/PreferencesContext";
import { ServiceWorkerProvider } from "@/contexts/ServiceWorkerContext";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
import { defaultPreferences } from "@/types/preferences";
import logger from "@/lib/logger";
import { getRandomBookFromLibraries } from "@/app/actions/library";
@@ -20,8 +20,8 @@ const publicRoutes = ["/login", "/register"];
interface ClientLayoutProps {
children: React.ReactNode;
initialLibraries: KomgaLibrary[];
initialFavorites: KomgaSeries[];
initialLibraries: NormalizedLibrary[];
initialFavorites: NormalizedSeries[];
userIsAdmin?: boolean;
}

View File

@@ -6,26 +6,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useTranslate } from "@/hooks/useTranslate";
import { getImageUrl } from "@/lib/utils/image-url";
interface SearchSeriesResult {
id: string;
title: string;
href: string;
booksCount: number;
}
interface SearchBookResult {
id: string;
title: string;
seriesTitle: string;
href: string;
}
interface SearchResponse {
series: SearchSeriesResult[];
books: SearchBookResult[];
}
import type { NormalizedSearchResult } from "@/lib/providers/types";
const MIN_QUERY_LENGTH = 2;
@@ -38,21 +19,15 @@ export function GlobalSearch() {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [results, setResults] = useState<SearchResponse>({ series: [], books: [] });
const [results, setResults] = useState<NormalizedSearchResult[]>([]);
const hasResults = results.series.length > 0 || results.books.length > 0;
const seriesResults = results.filter((r) => r.type === "series");
const bookResults = results.filter((r) => r.type === "book");
const hasResults = results.length > 0;
const firstResultHref = useMemo(() => {
if (results.series.length > 0) {
return results.series[0].href;
}
if (results.books.length > 0) {
return results.books[0].href;
}
return null;
}, [results.books, results.series]);
return results[0]?.href ?? null;
}, [results]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -77,7 +52,7 @@ export function GlobalSearch() {
const trimmedQuery = query.trim();
if (trimmedQuery.length < MIN_QUERY_LENGTH) {
setResults({ series: [], books: [] });
setResults([]);
setIsLoading(false);
return;
}
@@ -90,7 +65,7 @@ export function GlobalSearch() {
setIsLoading(true);
const response = await fetch(`/api/komga/search?q=${encodeURIComponent(trimmedQuery)}`, {
const response = await fetch(`/api/provider/search?q=${encodeURIComponent(trimmedQuery)}`, {
method: "GET",
signal: controller.signal,
cache: "no-store",
@@ -100,12 +75,12 @@ export function GlobalSearch() {
throw new Error("Search request failed");
}
const data = (await response.json()) as SearchResponse;
setResults(data);
const data = (await response.json()) as NormalizedSearchResult[];
setResults(Array.isArray(data) ? data : []);
setIsOpen(true);
} catch (error) {
if ((error as Error).name !== "AbortError") {
setResults({ series: [], books: [] });
setResults([]);
}
} finally {
setIsLoading(false);
@@ -158,12 +133,12 @@ export function GlobalSearch() {
{isOpen && query.trim().length >= MIN_QUERY_LENGTH && (
<div className="absolute left-0 right-0 top-[calc(100%+0.5rem)] z-50 overflow-hidden rounded-2xl border border-border/70 bg-background/95 shadow-xl backdrop-blur-xl">
<div className="max-h-[26rem] overflow-y-auto p-2">
{results.series.length > 0 && (
{seriesResults.length > 0 && (
<div className="mb-2">
<div className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("header.search.series")}
</div>
{results.series.map((item) => (
{seriesResults.map((item) => (
<Link
key={item.id}
href={item.href}
@@ -172,16 +147,17 @@ export function GlobalSearch() {
aria-label={t("header.search.openSeries", { title: item.title })}
>
<img
src={getImageUrl("series", item.id)}
src={item.coverUrl}
alt={item.title}
loading="lazy"
className="h-14 w-10 rounded object-cover bg-muted"
className="h-14 w-10 shrink-0 rounded object-cover bg-muted"
onError={(e) => { e.currentTarget.style.display = "none"; }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-base font-medium">{item.title}</p>
<p className="mt-0.5 flex items-center gap-1 text-sm text-muted-foreground">
<Library className="h-3 w-3" />
{t("series.books", { count: item.booksCount })}
{item.bookCount !== undefined && t("series.books", { count: item.bookCount })}
</p>
</div>
</Link>
@@ -189,12 +165,12 @@ export function GlobalSearch() {
</div>
)}
{results.books.length > 0 && (
{bookResults.length > 0 && (
<div>
<div className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("header.search.books")}
</div>
{results.books.map((item) => (
{bookResults.map((item) => (
<Link
key={item.id}
href={item.href}
@@ -203,10 +179,11 @@ export function GlobalSearch() {
aria-label={t("header.search.openBook", { title: item.title })}
>
<img
src={getImageUrl("book", item.id)}
src={item.coverUrl}
alt={item.title}
loading="lazy"
className="h-14 w-10 rounded object-cover bg-muted"
className="h-14 w-10 shrink-0 rounded object-cover bg-muted"
onError={(e) => { e.currentTarget.style.display = "none"; }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-base font-medium">{item.title}</p>

View File

@@ -15,7 +15,7 @@ import { usePathname, useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { signOut } from "next-auth/react";
import { useEffect, useState, useCallback } from "react";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
import { useToast } from "@/components/ui/use-toast";
import { useTranslate } from "@/hooks/useTranslate";
import { NavButton } from "@/components/ui/nav-button";
@@ -25,8 +25,8 @@ import logger from "@/lib/logger";
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
initialLibraries: KomgaLibrary[];
initialFavorites: KomgaSeries[];
initialLibraries: NormalizedLibrary[];
initialFavorites: NormalizedSeries[];
userIsAdmin?: boolean;
}
@@ -40,8 +40,8 @@ export function Sidebar({
const { t } = useTranslate();
const pathname = usePathname();
const router = useRouter();
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []);
const [libraries, setLibraries] = useState<NormalizedLibrary[]>(initialLibraries || []);
const [favorites, setFavorites] = useState<NormalizedSeries[]>(initialFavorites || []);
const [isRefreshing, setIsRefreshing] = useState(false);
const { toast } = useToast();
@@ -60,7 +60,7 @@ export function Sidebar({
const customEvent = event as CustomEvent<{
seriesId?: string;
action?: "add" | "remove";
series?: KomgaSeries;
series?: NormalizedSeries;
}>;
// Si on a les détails de l'action, faire une mise à jour optimiste locale
@@ -207,7 +207,7 @@ export function Sidebar({
<NavButton
key={series.id}
icon={Star}
label={series.metadata.title}
label={series.name}
active={pathname === `/series/${series.id}`}
onClick={() => handleLinkClick(`/series/${series.id}`)}
className="[&_svg]:fill-yellow-400 [&_svg]:text-yellow-400"

View File

@@ -1,17 +1,17 @@
import { Library } from "lucide-react";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
import { RefreshButton } from "./RefreshButton";
import { ScanButton } from "./ScanButton";
import { StatusBadge } from "@/components/ui/status-badge";
import { SeriesCover } from "@/components/ui/series-cover";
interface LibraryHeaderProps {
library: KomgaLibrary;
library: NormalizedLibrary;
seriesCount: number;
series: KomgaSeries[];
series: NormalizedSeries[];
}
const getHeaderSeries = (series: KomgaSeries[]) => {
const getHeaderSeries = (series: NormalizedSeries[]) => {
if (series.length === 0) {
return { featured: null, background: null };
}
@@ -84,8 +84,6 @@ export function LibraryHeader({
<RefreshButton libraryId={library.id} />
<ScanButton libraryId={library.id} />
</div>
{library.unavailable && <p className="text-sm text-destructive mt-2">Bibliotheque indisponible</p>}
</div>
</div>
</div>

View File

@@ -5,7 +5,7 @@ import { SeriesList } from "./SeriesList";
import { Pagination } from "@/components/ui/Pagination";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react";
import type { KomgaSeries } from "@/types/komga";
import type { NormalizedSeries } from "@/lib/providers/types";
import { SearchInput } from "./SearchInput";
import { useTranslate } from "@/hooks/useTranslate";
import { PageSizeSelect } from "@/components/common/PageSizeSelect";
@@ -15,7 +15,7 @@ import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
import { updatePreferences as updatePreferencesAction } from "@/app/actions/preferences";
interface PaginatedSeriesGridProps {
series: KomgaSeries[];
series: NormalizedSeries[];
currentPage: number;
totalPages: number;
totalElements: number;
@@ -108,19 +108,13 @@ export function PaginatedSeriesGrid({
const handleUnreadFilter = async () => {
const newUnreadState = !showOnlyUnread;
setShowOnlyUnread(newUnreadState);
await updateUrlParams({
page: "1",
unread: newUnreadState ? "true" : "false",
});
await updateUrlParams({ page: "1", unread: newUnreadState ? "true" : "false" });
await persistPreferences({ showOnlyUnread: newUnreadState });
};
const handlePageSizeChange = async (size: number) => {
setCurrentPageSize(size);
await updateUrlParams({
page: "1",
size: size.toString(),
});
await updateUrlParams({ page: "1", size: size.toString() });
await persistPreferences({
displayMode: {

View File

@@ -1,29 +1,29 @@
"use client";
import type { KomgaSeries } from "@/types/komga";
import type { NormalizedSeries } from "@/lib/providers/types";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { SeriesCover } from "@/components/ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate";
interface SeriesGridProps {
series: KomgaSeries[];
series: NormalizedSeries[];
isCompact?: boolean;
}
// Utility function to get reading status info
const getReadingStatusInfo = (
series: KomgaSeries,
series: NormalizedSeries,
t: (key: string, options?: { [key: string]: string | number }) => string
) => {
if (series.booksCount === 0) {
if (series.bookCount === 0) {
return {
label: t("series.status.noBooks"),
className: "bg-yellow-500/10 text-yellow-500",
};
}
if (series.booksCount === series.booksReadCount) {
if (series.bookCount === series.booksReadCount) {
return {
label: t("series.status.read"),
className: "bg-green-500/10 text-green-500",
@@ -34,7 +34,7 @@ const getReadingStatusInfo = (
return {
label: t("series.status.progress", {
read: series.booksReadCount,
total: series.booksCount,
total: series.bookCount,
}),
className: "bg-primary/15 text-primary",
};
@@ -67,32 +67,32 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
)}
>
{series.map((series) => (
{series.map((seriesItem) => (
<button
key={series.id}
onClick={() => router.push(`/series/${series.id}`)}
key={seriesItem.id}
onClick={() => router.push(`/series/${seriesItem.id}`)}
className={cn(
"group relative aspect-[2/3] overflow-hidden rounded-xl border border-border/60 bg-card/80 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md",
series.booksCount === series.booksReadCount && "opacity-50",
seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50",
isCompact && "aspect-[3/4]"
)}
>
<SeriesCover
series={series as KomgaSeries}
alt={t("series.coverAlt", { title: series.metadata.title })}
series={seriesItem}
alt={t("series.coverAlt", { title: seriesItem.name })}
/>
<div className="absolute inset-x-0 bottom-0 translate-y-full space-y-2 bg-gradient-to-t from-black/75 via-black/25 to-transparent p-4 transition-transform duration-200 group-hover:translate-y-0">
<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">{seriesItem.name}</h3>
<div className="flex items-center gap-2">
<span
className={`px-2 py-0.5 rounded-full text-xs ${
getReadingStatusInfo(series, t).className
getReadingStatusInfo(seriesItem, t).className
}`}
>
{getReadingStatusInfo(series, t).label}
{getReadingStatusInfo(seriesItem, t).label}
</span>
<span className="text-xs text-white/80">
{t("series.books", { count: series.booksCount })}
{t("series.books", { count: seriesItem.bookCount })}
</span>
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import type { KomgaSeries } from "@/types/komga";
import type { NormalizedSeries } from "@/lib/providers/types";
import { SeriesCover } from "@/components/ui/series-cover";
import { useRouter } from "next/navigation";
import { useTranslate } from "@/hooks/useTranslate";
@@ -10,28 +10,28 @@ import { BookOpen, Calendar, Tag, User } from "lucide-react";
import { formatDate } from "@/lib/utils";
interface SeriesListProps {
series: KomgaSeries[];
series: NormalizedSeries[];
isCompact?: boolean;
}
interface SeriesListItemProps {
series: KomgaSeries;
series: NormalizedSeries;
isCompact?: boolean;
}
// Utility function to get reading status info
const getReadingStatusInfo = (
series: KomgaSeries,
series: NormalizedSeries,
t: (key: string, options?: { [key: string]: string | number }) => string
) => {
if (series.booksCount === 0) {
if (series.bookCount === 0) {
return {
label: t("series.status.noBooks"),
className: "bg-yellow-500/10 text-yellow-500",
};
}
if (series.booksCount === series.booksReadCount) {
if (series.bookCount === series.booksReadCount) {
return {
label: t("series.status.read"),
className: "bg-green-500/10 text-green-500",
@@ -42,7 +42,7 @@ const getReadingStatusInfo = (
return {
label: t("series.status.progress", {
read: series.booksReadCount,
total: series.booksCount,
total: series.bookCount,
}),
className: "bg-primary/15 text-primary",
};
@@ -62,9 +62,9 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
router.push(`/series/${series.id}`);
};
const isCompleted = series.booksCount === series.booksReadCount;
const isCompleted = series.bookCount === series.booksReadCount;
const progressPercentage =
series.booksCount > 0 ? (series.booksReadCount / series.booksCount) * 100 : 0;
series.bookCount > 0 ? (series.booksReadCount / series.bookCount) * 100 : 0;
const statusInfo = getReadingStatusInfo(series, t);
@@ -81,7 +81,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="relative w-12 h-16 sm:w-14 sm:h-20 flex-shrink-0 rounded overflow-hidden bg-muted">
<SeriesCover
series={series}
alt={t("series.coverAlt", { title: series.metadata.title })}
alt={t("series.coverAlt", { title: series.name })}
className="w-full h-full"
/>
</div>
@@ -91,7 +91,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
{/* Titre et statut */}
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-sm sm:text-base line-clamp-1 hover:text-primary transition-colors flex-1 min-w-0">
{series.metadata.title}
{series.name}
</h3>
<span
className={cn(
@@ -108,15 +108,15 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-center gap-1">
<BookOpen className="h-3 w-3" />
<span>
{series.booksCount === 1
{series.bookCount === 1
? t("series.book", { count: 1 })
: t("series.books", { count: series.booksCount })}
: t("series.books", { count: series.bookCount })}
</span>
</div>
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
{series.authors && series.authors.length > 0 && (
<div className="flex items-center gap-1 hidden sm:flex">
<User className="h-3 w-3" />
<span className="line-clamp-1">{series.booksMetadata.authors[0].name}</span>
<span className="line-clamp-1">{series.authors[0].name}</span>
</div>
)}
</div>
@@ -137,7 +137,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="relative w-20 h-28 sm:w-24 sm:h-36 flex-shrink-0 rounded overflow-hidden bg-muted">
<SeriesCover
series={series}
alt={t("series.coverAlt", { title: series.metadata.title })}
alt={t("series.coverAlt", { title: series.name })}
className="w-full h-full"
/>
</div>
@@ -148,7 +148,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-base sm:text-lg line-clamp-2 hover:text-primary transition-colors">
{series.metadata.title}
{series.name}
</h3>
</div>
@@ -164,9 +164,9 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
</div>
{/* Résumé */}
{series.metadata.summary && (
{series.summary && (
<p className="text-sm text-muted-foreground line-clamp-2 hidden sm:block">
{series.metadata.summary}
{series.summary}
</p>
)}
@@ -176,55 +176,55 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-center gap-1">
<BookOpen className="h-3 w-3" />
<span>
{series.booksCount === 1
{series.bookCount === 1
? t("series.book", { count: 1 })
: t("series.books", { count: series.booksCount })}
: t("series.books", { count: series.bookCount })}
</span>
</div>
{/* Auteurs */}
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
{series.authors && series.authors.length > 0 && (
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{series.booksMetadata.authors.map((a) => a.name).join(", ")}
{series.authors.map((a) => a.name).join(", ")}
</span>
</div>
)}
{/* Date de création */}
{series.created && (
{series.createdAt && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>{formatDate(series.created)}</span>
<span>{formatDate(series.createdAt)}</span>
</div>
)}
{/* Genres */}
{series.metadata.genres && series.metadata.genres.length > 0 && (
{series.genres && series.genres.length > 0 && (
<div className="flex items-center gap-1">
<Tag className="h-3 w-3" />
<span className="line-clamp-1">
{series.metadata.genres.slice(0, 3).join(", ")}
{series.metadata.genres.length > 3 && ` +${series.metadata.genres.length - 3}`}
{series.genres.slice(0, 3).join(", ")}
{series.genres.length > 3 && ` +${series.genres.length - 3}`}
</span>
</div>
)}
{/* Tags */}
{series.metadata.tags && series.metadata.tags.length > 0 && (
{series.tags && series.tags.length > 0 && (
<div className="flex items-center gap-1">
<Tag className="h-3 w-3" />
<span className="line-clamp-1">
{series.metadata.tags.slice(0, 3).join(", ")}
{series.metadata.tags.length > 3 && ` +${series.metadata.tags.length - 3}`}
{series.tags.slice(0, 3).join(", ")}
{series.tags.length > 3 && ` +${series.tags.length - 3}`}
</span>
</div>
)}
</div>
{/* Barre de progression */}
{series.booksCount > 0 && !isCompleted && series.booksReadCount > 0 && (
{series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && (
<div className="space-y-1">
<Progress value={progressPercentage} className="h-2" />
<p className="text-xs text-muted-foreground">

View File

@@ -5,16 +5,16 @@ import { ClientBookWrapper } from "./ClientBookWrapper";
import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import logger from "@/lib/logger";
import { getBookData } from "@/app/actions/books";
interface ClientBookPageProps {
bookId: string;
initialData?: {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
nextBook: KomgaBook | null;
nextBook: NormalizedBook | null;
};
initialError?: string;
}
@@ -23,9 +23,9 @@ export function ClientBookPage({ bookId, initialData, initialError }: ClientBook
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<{
book: KomgaBook;
book: NormalizedBook;
pages: number[];
nextBook: KomgaBook | null;
nextBook: NormalizedBook | null;
} | null>(null);
// Use SSR data if available

View File

@@ -2,12 +2,12 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import { PhotoswipeReader } from "./PhotoswipeReader";
import { Button } from "@/components/ui/button";
interface ClientBookReaderProps {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
}

View File

@@ -1,32 +1,26 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import { PhotoswipeReader } from "./PhotoswipeReader";
import { useRouter } from "next/navigation";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
interface ClientBookWrapperProps {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
nextBook: KomgaBook | null;
nextBook: NormalizedBook | null;
}
export function ClientBookWrapper({ book, pages, nextBook }: ClientBookWrapperProps) {
const router = useRouter();
const [isClosing, setIsClosing] = useState(false);
const [targetPath, setTargetPath] = useState<string | null>(null);
useEffect(() => {
if (!isClosing || !targetPath) return;
router.push(targetPath);
}, [isClosing, targetPath, router]);
const handleCloseReader = (currentPage: number) => {
ClientOfflineBookService.setCurrentPage(book, currentPage);
setTargetPath(`/series/${book.seriesId}`);
setIsClosing(true);
router.back();
};
if (isClosing) {

View File

@@ -24,6 +24,17 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
const lastClickTimeRef = useRef<number>(0);
const clickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Derive page URL builder from book.thumbnailUrl (provider-agnostic)
const bookPageUrlBuilder = useCallback(
(pageNum: number) => book.thumbnailUrl.replace("/thumbnail", `/pages/${pageNum}`),
[book.thumbnailUrl]
);
const nextBookPageUrlBuilder = useCallback(
(pageNum: number) =>
nextBook ? nextBook.thumbnailUrl.replace("/thumbnail", `/pages/${pageNum}`) : "",
[nextBook]
);
// Hooks
const { direction, toggleDirection, isRTL } = useReadingDirection();
const { isFullscreen, toggleFullscreen } = useFullscreen();
@@ -38,10 +49,10 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
getPageUrl,
prefetchCount,
} = useImageLoader({
bookId: book.id,
pageUrlBuilder: bookPageUrlBuilder,
pages,
prefetchCount: preferences.readerPrefetchCount,
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null,
nextBook: nextBook ? { getPageUrl: nextBookPageUrlBuilder, pages: [] } : null,
});
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } =
usePageNavigation({

View File

@@ -9,14 +9,14 @@ interface ImageDimensions {
type ImageKey = number | string; // Support both numeric pages and prefixed keys like "next-1"
interface UseImageLoaderProps {
bookId: string;
pageUrlBuilder: (pageNum: number) => string;
pages: number[];
prefetchCount?: number; // Nombre de pages à précharger (défaut: 5)
nextBook?: { id: string; pages: number[] } | null; // Livre suivant pour prefetch
nextBook?: { getPageUrl: (pageNum: number) => string; pages: number[] } | null; // Livre suivant pour prefetch
}
export function useImageLoader({
bookId,
pageUrlBuilder,
pages: _pages,
prefetchCount = 5,
nextBook,
@@ -73,8 +73,8 @@ export function useImageLoader({
);
const getPageUrl = useCallback(
(pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`,
[bookId]
(pageNum: number) => pageUrlBuilder(pageNum),
[pageUrlBuilder]
);
// Prefetch image and store dimensions
@@ -216,7 +216,7 @@ export function useImageLoader({
abortControllersRef.current.set(nextBookPageKey, controller);
try {
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, {
const response = await fetch(nextBook.getPageUrl(pageNum), {
cache: "default", // Respect Cache-Control headers from server
signal: controller.signal,
});

View File

@@ -1,17 +1,17 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import logger from "@/lib/logger";
import { updateReadProgress } from "@/app/actions/read-progress";
interface UsePageNavigationProps {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
isDoublePage: boolean;
shouldShowDoublePage: (page: number) => boolean;
onClose?: (currentPage: number) => void;
nextBook?: KomgaBook | null;
nextBook?: NormalizedBook | null;
}
export function usePageNavigation({

View File

@@ -1,8 +1,8 @@
import { useState, useCallback, useEffect } from "react";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
interface UseThumbnailsProps {
book: KomgaBook;
book: NormalizedBook;
currentPage: number;
}
@@ -16,9 +16,13 @@ export const useThumbnails = ({ book, currentPage }: UseThumbnailsProps) => {
const getThumbnailUrl = useCallback(
(pageNumber: number) => {
// Derive page URL from the book's thumbnailUrl provider pattern
if (book.thumbnailUrl.startsWith("/api/stripstream/")) {
return `/api/stripstream/images/books/${book.id}/pages/${pageNumber}`;
}
return `/api/komga/images/books/${book.id}/pages/${pageNumber}/thumbnail?zero_based=true`;
},
[book.id]
[book.id, book.thumbnailUrl]
);
// Mettre à jour les thumbnails visibles autour de la page courante

View File

@@ -1,4 +1,4 @@
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
export interface PageCache {
[pageNumber: number]: {
@@ -10,10 +10,10 @@ export interface PageCache {
}
export interface BookReaderProps {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
onClose?: (currentPage: number) => void;
nextBook?: KomgaBook | null;
nextBook?: NormalizedBook | null;
}
export interface ThumbnailProps {
@@ -32,7 +32,7 @@ export interface NavigationBarProps {
onPageChange: (page: number) => void;
showControls: boolean;
showThumbnails: boolean;
book: KomgaBook;
book: NormalizedBook;
}
export interface ControlButtonsProps {
@@ -57,7 +57,7 @@ export interface ControlButtonsProps {
}
export interface UsePageNavigationProps {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
isDoublePage: boolean;
onClose?: () => void;

View File

@@ -1,6 +1,6 @@
"use client";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import { BookCover } from "@/components/ui/book-cover";
import { useState, useEffect, useRef } from "react";
import { useTranslate } from "@/hooks/useTranslate";
@@ -8,16 +8,16 @@ import { cn } from "@/lib/utils";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
interface BookGridProps {
books: KomgaBook[];
onBookClick: (book: KomgaBook) => void;
books: NormalizedBook[];
onBookClick: (book: NormalizedBook) => void;
isCompact?: boolean;
onRefresh?: () => void;
}
interface BookCardProps {
book: KomgaBook;
onBookClick: (book: KomgaBook) => void;
onSuccess: (book: KomgaBook, action: "read" | "unread") => void;
book: NormalizedBook;
onBookClick: (book: NormalizedBook) => void;
onSuccess: (book: NormalizedBook, action: "read" | "unread") => void;
isCompact: boolean;
}
@@ -50,9 +50,9 @@ function BookCard({ book, onBookClick, onSuccess, isCompact }: BookCardProps) {
book={book}
alt={t("books.coverAlt", {
title:
book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
book.title ||
(book.number
? t("navigation.volume", { number: book.number })
: ""),
})}
onSuccess={(book, action) => onSuccess(book, action)}
@@ -84,7 +84,7 @@ export function BookGrid({ books, onBookClick, isCompact = false, onRefresh }: B
);
}
const handleOnSuccess = (book: KomgaBook, action: "read" | "unread") => {
const handleOnSuccess = (book: NormalizedBook, action: "read" | "unread") => {
if (action === "read") {
setLocalBooks(
localBooks.map((previousBook) =>
@@ -93,10 +93,8 @@ export function BookGrid({ books, onBookClick, isCompact = false, onRefresh }: B
...previousBook,
readProgress: {
completed: true,
page: previousBook.media.pagesCount,
readDate: new Date().toISOString(),
created: new Date().toISOString(),
lastModified: new Date().toISOString(),
page: previousBook.pageCount,
lastReadAt: new Date().toISOString(),
},
}
: previousBook

View File

@@ -1,6 +1,6 @@
"use client";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import { BookCover } from "@/components/ui/book-cover";
import { useState, useEffect, useRef } from "react";
import { useTranslate } from "@/hooks/useTranslate";
@@ -9,22 +9,22 @@ import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { formatDate } from "@/lib/utils";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import { Progress } from "@/components/ui/progress";
import { Calendar, FileText, User, Tag } from "lucide-react";
import { FileText } from "lucide-react";
import { MarkAsReadButton } from "@/components/ui/mark-as-read-button";
import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button";
import { BookOfflineButton } from "@/components/ui/book-offline-button";
interface BookListProps {
books: KomgaBook[];
onBookClick: (book: KomgaBook) => void;
books: NormalizedBook[];
onBookClick: (book: NormalizedBook) => void;
isCompact?: boolean;
onRefresh?: () => void;
}
interface BookListItemProps {
book: KomgaBook;
onBookClick: (book: KomgaBook) => void;
onSuccess: (book: KomgaBook, action: "read" | "unread") => void;
book: NormalizedBook;
onBookClick: (book: NormalizedBook) => void;
onSuccess: (book: NormalizedBook, action: "read" | "unread") => void;
isCompact?: boolean;
}
@@ -40,7 +40,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
const isRead = book.readProgress?.completed || false;
const hasReadProgress = book.readProgress !== null;
const currentPage = ClientOfflineBookService.getCurrentPage(book);
const totalPages = book.media.pagesCount;
const totalPages = book.pageCount;
const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
const getStatusInfo = () => {
@@ -52,7 +52,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
}
if (book.readProgress.completed) {
const readDate = book.readProgress.readDate ? formatDate(book.readProgress.readDate) : null;
const readDate = book.readProgress.lastReadAt ? formatDate(book.readProgress.lastReadAt) : null;
return {
label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"),
className: "bg-green-500/10 text-green-500",
@@ -77,8 +77,8 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
const statusInfo = getStatusInfo();
const title =
book.metadata.title ||
(book.metadata.number ? t("navigation.volume", { number: book.metadata.number }) : book.name);
book.title ||
(book.number ? t("navigation.volume", { number: book.number }) : "");
if (isCompact) {
return (
@@ -130,8 +130,8 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{/* Métadonnées minimales */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{book.metadata.number && (
<span>{t("navigation.volume", { number: book.metadata.number })}</span>
{book.number && (
<span>{t("navigation.volume", { number: book.number })}</span>
)}
<div className="flex items-center gap-1">
<FileText className="h-3 w-3" />
@@ -139,12 +139,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
</span>
</div>
{book.metadata.authors && book.metadata.authors.length > 0 && (
<div className="flex items-center gap-1 hidden sm:flex">
<User className="h-3 w-3" />
<span className="line-clamp-1">{book.metadata.authors[0].name}</span>
</div>
)}
</div>
</div>
</div>
@@ -189,9 +183,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
>
{title}
</h3>
{book.metadata.number && (
{book.number && (
<p className="text-sm text-muted-foreground mt-1">
{t("navigation.volume", { number: book.metadata.number })}
{t("navigation.volume", { number: book.number })}
</p>
)}
</div>
@@ -207,13 +201,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
</span>
</div>
{/* Résumé */}
{book.metadata.summary && (
<p className="text-sm text-muted-foreground line-clamp-2 hidden sm:block">
{book.metadata.summary}
</p>
)}
{/* Métadonnées */}
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
{/* Pages */}
@@ -223,35 +210,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
</span>
</div>
{/* Auteurs */}
{book.metadata.authors && book.metadata.authors.length > 0 && (
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.authors.map((a) => a.name).join(", ")}
</span>
</div>
)}
{/* Date de sortie */}
{book.metadata.releaseDate && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>{formatDate(book.metadata.releaseDate)}</span>
</div>
)}
{/* Tags */}
{book.metadata.tags && book.metadata.tags.length > 0 && (
<div className="flex items-center gap-1">
<Tag className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.tags.slice(0, 3).join(", ")}
{book.metadata.tags.length > 3 && ` +${book.metadata.tags.length - 3}`}
</span>
</div>
)}
</div>
{/* Barre de progression */}
@@ -269,7 +227,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{!isRead && (
<MarkAsReadButton
bookId={book.id}
pagesCount={book.media.pagesCount}
pagesCount={book.pageCount}
isRead={isRead}
onSuccess={() => onSuccess(book, "read")}
className="text-xs"
@@ -311,7 +269,7 @@ export function BookList({ books, onBookClick, isCompact = false, onRefresh }: B
);
}
const handleOnSuccess = (book: KomgaBook, action: "read" | "unread") => {
const handleOnSuccess = (book: NormalizedBook, action: "read" | "unread") => {
if (action === "read") {
setLocalBooks(
localBooks.map((previousBook) =>
@@ -320,10 +278,8 @@ export function BookList({ books, onBookClick, isCompact = false, onRefresh }: B
...previousBook,
readProgress: {
completed: true,
page: previousBook.media.pagesCount,
readDate: new Date().toISOString(),
created: new Date().toISOString(),
lastModified: new Date().toISOString(),
page: previousBook.pageCount,
lastReadAt: new Date().toISOString(),
},
}
: previousBook

View File

@@ -5,7 +5,7 @@ import { BookList } from "./BookList";
import { Pagination } from "@/components/ui/Pagination";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import { useTranslate } from "@/hooks/useTranslate";
import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
import { usePreferences } from "@/contexts/PreferencesContext";
@@ -15,7 +15,7 @@ import { ViewModeButton } from "@/components/common/ViewModeButton";
import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
interface PaginatedBookGridProps {
books: KomgaBook[];
books: NormalizedBook[];
currentPage: number;
totalPages: number;
totalElements: number;
@@ -95,13 +95,10 @@ export function PaginatedBookGrid({
};
const handlePageSizeChange = async (size: number) => {
await updateUrlParams({
page: "1",
size: size.toString(),
});
await updateUrlParams({ page: "1", size: size.toString() });
};
const handleBookClick = (book: KomgaBook) => {
const handleBookClick = (book: NormalizedBook) => {
router.push(`/books/${book.id}`);
};

View File

@@ -1,7 +1,7 @@
"use client";
import { Book, BookOpen, BookMarked, Star, StarOff } from "lucide-react";
import type { KomgaSeries } from "@/types/komga";
import type { NormalizedSeries } from "@/lib/providers/types";
import { useState, useEffect } from "react";
import { useToast } from "@/components/ui/use-toast";
import { RefreshButton } from "@/components/library/RefreshButton";
@@ -16,7 +16,7 @@ import logger from "@/lib/logger";
import { addToFavorites, removeFromFavorites } from "@/app/actions/favorites";
interface SeriesHeaderProps {
series: KomgaSeries;
series: NormalizedSeries;
refreshSeries: (seriesId: string) => Promise<{ success: boolean; error?: string }>;
initialIsFavorite: boolean;
}
@@ -48,7 +48,7 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
window.dispatchEvent(event);
toast({
title: t(isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add"),
description: series.metadata.title,
description: series.name,
});
} else {
throw new AppError(
@@ -69,10 +69,11 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
};
const getReadingStatusInfo = () => {
const { booksCount, booksReadCount, booksUnreadCount } = series;
const booksInProgressCount = booksCount - (booksReadCount + booksUnreadCount);
const { bookCount, booksReadCount } = series;
const booksUnreadCount = bookCount - booksReadCount;
const booksInProgressCount = bookCount - (booksReadCount + booksUnreadCount);
if (booksReadCount === booksCount) {
if (booksReadCount === bookCount) {
return {
label: t("series.header.status.read"),
status: "success" as const,
@@ -80,11 +81,11 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
};
}
if (booksInProgressCount > 0 || (booksReadCount > 0 && booksReadCount < booksCount)) {
if (booksInProgressCount > 0 || (booksReadCount > 0 && booksReadCount < bookCount)) {
return {
label: t("series.header.status.progress", {
read: booksReadCount,
total: booksCount,
total: bookCount,
}),
status: "reading" as const,
icon: BookOpen,
@@ -105,8 +106,8 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
{/* Image de fond */}
<div className="absolute inset-0">
<SeriesCover
series={series as KomgaSeries}
alt={t("series.header.coverAlt", { title: series.metadata.title })}
series={series}
alt={t("series.header.coverAlt", { title: series.name })}
className="blur-sm scale-105 brightness-50"
showProgressUi={false}
/>
@@ -118,18 +119,18 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
{/* Image principale */}
<div className="relative w-[180px] aspect-[2/3] rounded-lg overflow-hidden shadow-lg bg-muted/80 backdrop-blur-md flex-shrink-0">
<SeriesCover
series={series as KomgaSeries}
alt={t("series.header.coverAlt", { title: series.metadata.title })}
series={series}
alt={t("series.header.coverAlt", { title: series.name })}
showProgressUi={false}
/>
</div>
{/* Informations */}
<div className="flex-1 text-white space-y-2 text-center md:text-left">
<h1 className="text-2xl md:text-3xl font-bold">{series.metadata.title}</h1>
{series.metadata.summary && (
<h1 className="text-2xl md:text-3xl font-bold">{series.name}</h1>
{series.summary && (
<p className="text-white/80 line-clamp-3 text-sm md:text-base">
{series.metadata.summary}
{series.summary}
</p>
)}
<div className="flex items-center gap-4 mt-4 justify-center md:justify-start flex-wrap">
@@ -137,9 +138,9 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
{statusInfo.label}
</StatusBadge>
<span className="text-sm text-white/80">
{series.booksCount === 1
? t("series.header.books", { count: series.booksCount })
: t("series.header.books_plural", { count: series.booksCount })}
{series.bookCount === 1
? t("series.header.books", { count: series.bookCount })
: t("series.header.books_plural", { count: series.bookCount })}
</span>
<IconButton
variant="ghost"

View File

@@ -14,11 +14,11 @@ import { Check } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { SliderControl } from "@/components/ui/slider-control";
import type { KomgaLibrary } from "@/types/komga";
import type { NormalizedLibrary } from "@/lib/providers/types";
import logger from "@/lib/logger";
interface BackgroundSettingsProps {
initialLibraries: KomgaLibrary[];
initialLibraries: NormalizedLibrary[];
}
export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps) {
@@ -27,7 +27,7 @@ export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps
const { preferences, updatePreferences } = usePreferences();
const [customImageUrl, setCustomImageUrl] = useState(preferences.background.imageUrl || "");
const [komgaConfigValid, setKomgaConfigValid] = useState(false);
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
const [libraries, setLibraries] = useState<NormalizedLibrary[]>(initialLibraries || []);
const [selectedLibraries, setSelectedLibraries] = useState<string[]>(
preferences.background.komgaLibraries || []
);
@@ -278,7 +278,7 @@ export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps
htmlFor={`lib-${library.id}`}
className="cursor-pointer font-normal text-sm"
>
{library.name} ({library.booksCount} livres)
{library.name} ({library.bookCount} livres)
</Label>
</div>
))}

View File

@@ -2,10 +2,12 @@
import { useEffect, useState } from "react";
import type { KomgaConfig } from "@/types/komga";
import type { KomgaLibrary } from "@/types/komga";
import type { NormalizedLibrary, ProviderType } from "@/lib/providers/types";
import { useTranslate } from "@/hooks/useTranslate";
import { DisplaySettings } from "./DisplaySettings";
import { KomgaSettings } from "./KomgaSettings";
import { StripstreamSettings } from "./StripstreamSettings";
import { ProviderSelector } from "./ProviderSelector";
import { BackgroundSettings } from "./BackgroundSettings";
import { AdvancedSettings } from "./AdvancedSettings";
import { CacheSettings } from "./CacheSettings";
@@ -14,12 +16,23 @@ import { Monitor, Network } from "lucide-react";
interface ClientSettingsProps {
initialConfig: KomgaConfig | null;
initialLibraries: KomgaLibrary[];
initialLibraries: NormalizedLibrary[];
stripstreamConfig?: { url?: string; hasToken: boolean } | null;
providersStatus?: {
komgaConfigured: boolean;
stripstreamConfigured: boolean;
activeProvider: ProviderType;
};
}
const SETTINGS_TAB_STORAGE_KEY = "stripstream:settings-active-tab";
export function ClientSettings({ initialConfig, initialLibraries }: ClientSettingsProps) {
export function ClientSettings({
initialConfig,
initialLibraries,
stripstreamConfig,
providersStatus,
}: ClientSettingsProps) {
const { t } = useTranslate();
const [activeTab, setActiveTab] = useState<"display" | "connection">("display");
@@ -63,7 +76,18 @@ export function ClientSettings({ initialConfig, initialLibraries }: ClientSettin
</TabsContent>
<TabsContent value="connection" className="mt-6 space-y-6">
{providersStatus && (
<ProviderSelector
activeProvider={providersStatus.activeProvider}
komgaConfigured={providersStatus.komgaConfigured}
stripstreamConfigured={providersStatus.stripstreamConfigured}
/>
)}
<KomgaSettings initialConfig={initialConfig} />
<StripstreamSettings
initialUrl={stripstreamConfig?.url}
hasToken={stripstreamConfig?.hasToken}
/>
<AdvancedSettings />
<CacheSettings />
</TabsContent>

View File

@@ -0,0 +1,113 @@
"use client";
import { useState } from "react";
import { useToast } from "@/components/ui/use-toast";
import { CheckCircle, Circle } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { setActiveProvider } from "@/app/actions/stripstream-config";
import type { ProviderType } from "@/lib/providers/types";
interface ProviderSelectorProps {
activeProvider: ProviderType;
komgaConfigured: boolean;
stripstreamConfigured: boolean;
}
const providers: { id: ProviderType; label: string; description: string }[] = [
{
id: "komga",
label: "Komga",
description: "Serveur de gestion de BD / manga (Basic Auth)",
},
{
id: "stripstream",
label: "Stripstream Librarian",
description: "Serveur de gestion de BD / manga (Bearer Token)",
},
];
export function ProviderSelector({
activeProvider,
komgaConfigured,
stripstreamConfigured,
}: ProviderSelectorProps) {
const { toast } = useToast();
const [current, setCurrent] = useState<ProviderType>(activeProvider);
const [isChanging, setIsChanging] = useState(false);
const isConfigured = (id: ProviderType) =>
id === "komga" ? komgaConfigured : stripstreamConfigured;
const handleSelect = async (provider: ProviderType) => {
if (provider === current) return;
setIsChanging(true);
try {
const result = await setActiveProvider(provider);
if (!result.success) {
throw new Error(result.message);
}
setCurrent(provider);
toast({ title: "Provider actif", description: result.message });
window.location.reload();
} catch (error) {
toast({
variant: "destructive",
title: "Erreur",
description: error instanceof Error ? error.message : "Changement de provider échoué",
});
} finally {
setIsChanging(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Provider actif</CardTitle>
<CardDescription>
Choisissez le serveur que l&apos;application doit utiliser. Les deux configurations peuvent coexister.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
{providers.map((provider) => {
const active = current === provider.id;
const configured = isConfigured(provider.id);
return (
<button
key={provider.id}
type="button"
disabled={isChanging || !configured}
onClick={() => handleSelect(provider.id)}
className={cn(
"relative flex flex-col gap-1.5 rounded-xl border p-4 text-left transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted/50",
(!configured || isChanging) && "cursor-not-allowed opacity-60"
)}
>
<div className="flex items-center justify-between">
<span className="font-medium">{provider.label}</span>
{active ? (
<CheckCircle className="h-4 w-4 text-primary" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" />
)}
</div>
<p className="text-sm text-muted-foreground">{provider.description}</p>
{configured ? (
<span className="text-xs text-green-600 dark:text-green-400"> Configuré</span>
) : (
<span className="text-xs text-muted-foreground">Non configuré</span>
)}
</button>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import { useState } from "react";
import { useToast } from "@/components/ui/use-toast";
import { Network, Loader2 } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import logger from "@/lib/logger";
import { saveStripstreamConfig, testStripstreamConnection } from "@/app/actions/stripstream-config";
interface StripstreamSettingsProps {
initialUrl?: string;
hasToken?: boolean;
}
export function StripstreamSettings({ initialUrl, hasToken }: StripstreamSettingsProps) {
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [url, setUrl] = useState(initialUrl ?? "");
const [token, setToken] = useState("");
const [isEditing, setIsEditing] = useState(!initialUrl);
const isConfigured = !!initialUrl;
const shouldShowForm = !isConfigured || isEditing;
const handleTest = async () => {
if (!url) return;
setIsLoading(true);
try {
const result = await testStripstreamConnection(url.trim(), token);
if (!result.success) {
throw new Error(result.message);
}
toast({ title: "Stripstream Librarian", description: result.message });
} catch (error) {
logger.error({ err: error }, "Erreur test Stripstream:");
toast({
variant: "destructive",
title: "Erreur de connexion",
description: error instanceof Error ? error.message : "Connexion échouée",
});
} finally {
setIsLoading(false);
}
};
const handleSave = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSaving(true);
try {
const result = await saveStripstreamConfig(url.trim(), token);
if (!result.success) {
throw new Error(result.message);
}
setIsEditing(false);
setToken("");
toast({ title: "Stripstream Librarian", description: result.message });
window.location.reload();
} catch (error) {
logger.error({ err: error }, "Erreur sauvegarde Stripstream:");
toast({
variant: "destructive",
title: "Erreur de sauvegarde",
description: error instanceof Error ? error.message : "Erreur lors de la sauvegarde",
});
} finally {
setIsSaving(false);
}
};
const inputClass =
"flex h-9 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-1 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
const btnSecondary =
"flex-1 inline-flex items-center justify-center rounded-md bg-secondary px-3 py-2 text-sm font-medium text-secondary-foreground ring-offset-background transition-colors hover:bg-secondary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
const btnPrimary =
"flex-1 inline-flex items-center justify-center rounded-md bg-primary/90 backdrop-blur-md px-3 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Network className="h-5 w-5" />
Stripstream Librarian
</CardTitle>
<CardDescription>
Connectez votre instance Stripstream Librarian via token API (format{" "}
<code className="text-xs">stl_...</code>).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!shouldShowForm ? (
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium">URL du serveur</label>
<p className="text-sm text-muted-foreground">{url}</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Token API</label>
<p className="text-sm text-muted-foreground">{hasToken ? "••••••••" : "Non configuré"}</p>
</div>
</div>
<button
type="button"
onClick={() => setIsEditing(true)}
className="inline-flex items-center justify-center rounded-md bg-secondary/80 backdrop-blur-md px-3 py-2 text-sm font-medium text-secondary-foreground ring-offset-background transition-colors hover:bg-secondary/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
Modifier
</button>
</div>
) : (
<form onSubmit={handleSave} className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<label htmlFor="stripstream-url" className="text-sm font-medium">
URL du serveur
</label>
<input
type="url"
id="stripstream-url"
name="url"
required
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://librarian.example.com"
className={inputClass}
/>
</div>
<div className="space-y-2">
<label htmlFor="stripstream-token" className="text-sm font-medium">
Token API
{isConfigured && (
<span className="ml-2 text-xs text-muted-foreground">(laisser vide pour conserver l&apos;actuel)</span>
)}
</label>
<input
type="password"
id="stripstream-token"
name="token"
required={!isConfigured}
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={isConfigured ? "••••••••" : "stl_xxxx_xxxxxxxx"}
className={inputClass}
/>
</div>
</div>
<div className="flex gap-3">
<button type="submit" disabled={isSaving} className={btnPrimary}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sauvegarde...
</>
) : (
"Sauvegarder"
)}
</button>
<button
type="button"
onClick={handleTest}
disabled={isLoading || !url}
className={btnSecondary}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Test...
</>
) : (
"Tester"
)}
</button>
{isConfigured && (
<button
type="button"
onClick={() => {
setIsEditing(false);
setToken("");
}}
className={btnSecondary}
>
Annuler
</button>
)}
</div>
</form>
)}
</CardContent>
</Card>
);
}

View File

@@ -2,20 +2,18 @@
import { ProgressBar } from "./progress-bar";
import type { BookCoverProps } from "./cover-utils";
import { getImageUrl } from "@/lib/utils/image-url";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import { MarkAsReadButton } from "./mark-as-read-button";
import { MarkAsUnreadButton } from "./mark-as-unread-button";
import { BookOfflineButton } from "./book-offline-button";
import { useTranslate } from "@/hooks/useTranslate";
import type { KomgaBook } from "@/types/komga";
import { formatDate } from "@/lib/utils";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { WifiOff } from "lucide-react";
// Fonction utilitaire pour obtenir les informations de statut de lecture
const getReadingStatusInfo = (
book: KomgaBook,
book: BookCoverProps["book"],
t: (key: string, options?: { [key: string]: string | number }) => string
) => {
if (!book.readProgress) {
@@ -26,7 +24,7 @@ const getReadingStatusInfo = (
}
if (book.readProgress.completed) {
const readDate = book.readProgress.readDate ? formatDate(book.readProgress.readDate) : null;
const readDate = book.readProgress.lastReadAt ? formatDate(book.readProgress.lastReadAt) : null;
return {
label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"),
className: "bg-green-500/10 text-green-500",
@@ -39,7 +37,7 @@ const getReadingStatusInfo = (
return {
label: t("books.status.progress", {
current: currentPage,
total: book.media.pagesCount,
total: book.pageCount,
}),
className: "bg-blue-500/10 text-blue-500",
};
@@ -64,11 +62,10 @@ export function BookCover({
const { t } = useTranslate();
const { isAccessible } = useBookOfflineStatus(book.id);
const imageUrl = getImageUrl("book", book.id);
const isCompleted = book.readProgress?.completed || false;
const currentPage = ClientOfflineBookService.getCurrentPage(book);
const totalPages = book.media.pagesCount;
const totalPages = book.pageCount;
const showProgress = Boolean(showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted);
const statusInfo = getReadingStatusInfo(book, t);
@@ -90,7 +87,7 @@ export function BookCover({
<>
<div className={`relative w-full h-full ${isUnavailable ? "opacity-40 grayscale" : ""}`}>
<img
src={imageUrl.trim()}
src={book.thumbnailUrl.trim()}
alt={alt || t("books.defaultCoverAlt")}
loading="lazy"
className={[
@@ -121,7 +118,7 @@ export function BookCover({
{!isRead && (
<MarkAsReadButton
bookId={book.id}
pagesCount={book.media.pagesCount}
pagesCount={book.pageCount}
isRead={isRead}
onSuccess={() => handleMarkAsRead()}
className="bg-white/90 hover:bg-white text-black shadow-sm"
@@ -143,9 +140,9 @@ export function BookCover({
{showOverlay && overlayVariant === "default" && (
<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 ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
{book.title ||
(book.number
? t("navigation.volume", { number: book.number })
: "")}
</p>
<div className="flex items-center gap-2">
@@ -160,15 +157,15 @@ export function BookCover({
{showOverlay && overlayVariant === "home" && (
<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">
{book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
{book.title ||
(book.number
? t("navigation.volume", { number: book.number })
: "")}
</h3>
<p className="text-xs text-white/80 mt-1">
{t("books.status.progress", {
current: currentPage,
total: book.media.pagesCount,
total: book.pageCount,
})}
</p>
</div>

View File

@@ -4,11 +4,11 @@ import { useState, useEffect, useCallback } from "react";
import { Download, Check, Loader2 } from "lucide-react";
import { Button } from "./button";
import { useToast } from "./use-toast";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import logger from "@/lib/logger";
interface BookOfflineButtonProps {
book: KomgaBook;
book: NormalizedBook;
className?: string;
}
@@ -57,7 +57,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
// Marque le début du téléchargement
setBookStatus(book.id, {
status: "downloading",
progress: ((startFromPage - 1) / book.media.pagesCount) * 100,
progress: ((startFromPage - 1) / book.pageCount) * 100,
timestamp: Date.now(),
lastDownloadedPage: startFromPage - 1,
});
@@ -71,7 +71,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
// Cache chaque page avec retry
let failedPages = 0;
for (let i = startFromPage; i <= book.media.pagesCount; i++) {
for (let i = startFromPage; i <= book.pageCount; i++) {
let retryCount = 0;
const maxRetries = 3;
@@ -105,7 +105,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
}
// Mise à jour du statut
const progress = (i / book.media.pagesCount) * 100;
const progress = (i / book.pageCount) * 100;
setDownloadProgress(progress);
setBookStatus(book.id, {
status: "downloading",
@@ -125,7 +125,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
if (failedPages > 0) {
// Si des pages ont échoué, on supprime tout le cache pour ce livre
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
for (let i = 1; i <= book.media.pagesCount; i++) {
for (let i = 1; i <= book.pageCount; i++) {
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
}
setIsAvailableOffline(false);
@@ -159,7 +159,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
setDownloadProgress(0);
}
},
[book.id, book.media.pagesCount, getBookStatus, setBookStatus, toast]
[book.id, book.pageCount, getBookStatus, setBookStatus, toast]
);
const checkOfflineAvailability = useCallback(async () => {
@@ -177,7 +177,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
// Vérifie que toutes les pages sont dans le cache
let allPagesAvailable = true;
for (let i = 1; i <= book.media.pagesCount; i++) {
for (let i = 1; i <= book.pageCount; i++) {
const page = await cache.match(`/api/komga/images/books/${book.id}/pages/${i}`);
if (!page) {
allPagesAvailable = false;
@@ -195,7 +195,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
logger.error({ err: error }, "Erreur lors de la vérification du cache:");
setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() });
}
}, [book.id, book.media.pagesCount, setBookStatus]);
}, [book.id, book.pageCount, setBookStatus]);
useEffect(() => {
const checkStatus = async () => {
@@ -242,9 +242,9 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
setBookStatus(book.id, { status: "idle", progress: 0, timestamp: Date.now() });
// Supprime le livre du cache
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
for (let i = 1; i <= book.media.pagesCount; i++) {
for (let i = 1; i <= book.pageCount; i++) {
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
const progress = (i / book.media.pagesCount) * 100;
const progress = (i / book.pageCount) * 100;
setDownloadProgress(progress);
}
setIsAvailableOffline(false);

View File

@@ -1,4 +1,4 @@
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
export interface BaseCoverProps {
alt?: string;
@@ -9,13 +9,13 @@ export interface BaseCoverProps {
}
export interface BookCoverProps extends BaseCoverProps {
book: KomgaBook;
onSuccess?: (book: KomgaBook, action: "read" | "unread") => void;
book: NormalizedBook;
onSuccess?: (book: NormalizedBook, action: "read" | "unread") => void;
showControls?: boolean;
showOverlay?: boolean;
overlayVariant?: "default" | "home";
}
export interface SeriesCoverProps extends BaseCoverProps {
series: KomgaSeries;
series: NormalizedSeries;
}

View File

@@ -1,6 +1,5 @@
import { ProgressBar } from "./progress-bar";
import type { SeriesCoverProps } from "./cover-utils";
import { getImageUrl } from "@/lib/utils/image-url";
export function SeriesCover({
series,
@@ -8,17 +7,16 @@ export function SeriesCover({
className,
showProgressUi = true,
}: SeriesCoverProps) {
const imageUrl = getImageUrl("series", series.id);
const isCompleted = series.booksCount === series.booksReadCount;
const isCompleted = series.bookCount === series.booksReadCount;
const readBooks = series.booksReadCount;
const totalBooks = series.booksCount;
const totalBooks = series.bookCount;
const showProgress = Boolean(showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
return (
<div className="relative w-full h-full">
<img
src={imageUrl}
src={series.thumbnailUrl}
alt={alt}
loading="lazy"
className={[