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:
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
113
src/components/settings/ProviderSelector.tsx
Normal file
113
src/components/settings/ProviderSelector.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
192
src/components/settings/StripstreamSettings.tsx
Normal file
192
src/components/settings/StripstreamSettings.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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={[
|
||||
|
||||
Reference in New Issue
Block a user