feat: add external metadata sync system with multiple providers

Add a complete metadata synchronization system allowing users to search
and sync series/book metadata from external providers (Google Books,
Open Library, ComicVine, AniList, Bédéthèque). Each library can use a
different provider. Matching requires manual approval with detailed sync
reports showing what was updated or skipped (locked fields protection).

Key changes:
- DB migrations: external_metadata_links, external_book_metadata tables,
  library metadata_provider column, locked_fields, total_volumes, book
  metadata fields (summary, isbn, publish_date)
- Rust API: MetadataProvider trait + 5 provider implementations,
  7 metadata endpoints (search, match, approve, reject, links, missing,
  delete), sync report system, provider language preference support
- Backoffice: MetadataSearchModal, ProviderIcon, SafeHtml components,
  settings UI for provider/language config, enriched book detail page,
  edit forms with locked fields support, API proxy routes
- OpenAPI/Swagger documentation for all new endpoints and schemas

Closes #3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 14:59:24 +01:00
parent a99bfb5a91
commit c9ccf5cd90
42 changed files with 5492 additions and 198 deletions

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch, LibraryDto } from "@/lib/api";
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const body = await request.json();
const data = await apiFetch<LibraryDto>(`/libraries/${id}/metadata-provider`, {
method: "PATCH",
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to update metadata provider";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { id, ...rest } = body;
const data = await apiFetch<{ status: string; books_synced: number }>(`/metadata/approve/${id}`, {
method: "POST",
body: JSON.stringify(rest),
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to approve metadata";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch, ExternalMetadataLinkDto } from "@/lib/api";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const libraryId = searchParams.get("library_id") || "";
const seriesName = searchParams.get("series_name") || "";
const params = new URLSearchParams();
if (libraryId) params.set("library_id", libraryId);
if (seriesName) params.set("series_name", seriesName);
const data = await apiFetch<ExternalMetadataLinkDto[]>(`/metadata/links?${params.toString()}`);
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to fetch metadata links";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "id is required" }, { status: 400 });
}
const data = await apiFetch<{ deleted: boolean }>(`/metadata/links/${id}`, {
method: "DELETE",
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete metadata link";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch, ExternalMetadataLinkDto } from "@/lib/api";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = await apiFetch<ExternalMetadataLinkDto>("/metadata/match", {
method: "POST",
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create metadata match";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch, MissingBooksDto } from "@/lib/api";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "id is required" }, { status: 400 });
}
const data = await apiFetch<MissingBooksDto>(`/metadata/missing/${id}`);
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to fetch missing books";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = await apiFetch<{ status: string }>(`/metadata/reject/${body.id}`, {
method: "POST",
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to reject metadata";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch, SeriesCandidateDto } from "@/lib/api";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = await apiFetch<SeriesCandidateDto[]>("/metadata/search", {
method: "POST",
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to search metadata";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -3,6 +3,7 @@ import { BookPreview } from "../../components/BookPreview";
import { ConvertButton } from "../../components/ConvertButton";
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
import { EditBookForm } from "../../components/EditBookForm";
import { SafeHtml } from "../../components/SafeHtml";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
@@ -15,31 +16,6 @@ const readingStatusConfig: Record<ReadingStatus, { label: string; className: str
read: { label: "Lu", className: "bg-green-500/15 text-green-600 dark:text-green-400 border border-green-500/30" },
};
function ReadingStatusBadge({
status,
currentPage,
lastReadAt,
}: {
status: ReadingStatus;
currentPage: number | null;
lastReadAt: string | null;
}) {
const { label, className } = readingStatusConfig[status];
return (
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${className}`}>
{label}
{status === "reading" && currentPage != null && ` · p. ${currentPage}`}
</span>
{lastReadAt && (
<span className="text-xs text-muted-foreground">
{new Date(lastReadAt).toLocaleDateString()}
</span>
)}
</div>
);
}
async function fetchBook(bookId: string): Promise<BookDto | null> {
try {
return await apiFetch<BookDto>(`/books/${bookId}`);
@@ -64,163 +40,195 @@ export default async function BookDetailPage({
}
const library = libraries.find(l => l.id === book.library_id);
const formatBadge = (book.format ?? book.kind).toUpperCase();
const formatColor =
formatBadge === "CBZ" ? "bg-success/10 text-success border-success/30" :
formatBadge === "CBR" ? "bg-warning/10 text-warning border-warning/30" :
formatBadge === "PDF" ? "bg-destructive/10 text-destructive border-destructive/30" :
"bg-muted/50 text-muted-foreground border-border";
const { label: statusLabel, className: statusClassName } = readingStatusConfig[book.reading_status];
return (
<>
<div className="mb-6">
<Link href="/books" className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors">
Back to books
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm">
<Link href="/libraries" className="text-muted-foreground hover:text-primary transition-colors">
Libraries
</Link>
<span className="text-muted-foreground">/</span>
{library && (
<>
<Link
href={`/libraries/${book.library_id}/series`}
className="text-muted-foreground hover:text-primary transition-colors"
>
{library.name}
</Link>
<span className="text-muted-foreground">/</span>
</>
)}
{book.series && (
<>
<Link
href={`/libraries/${book.library_id}/series/${encodeURIComponent(book.series)}`}
className="text-muted-foreground hover:text-primary transition-colors"
>
{book.series}
</Link>
<span className="text-muted-foreground">/</span>
</>
)}
<span className="text-foreground font-medium truncate">{book.title}</span>
</div>
<div className="flex flex-col lg:flex-row gap-8">
{/* Hero */}
<div className="flex flex-col sm:flex-row gap-6">
{/* Cover */}
<div className="flex-shrink-0">
<div className="bg-card rounded-xl shadow-card border border-border p-4 inline-block">
<div className="w-48 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
<Image
src={getBookCoverUrl(book.id)}
alt={`Cover of ${book.title}`}
width={300}
height={440}
className="w-auto h-auto max-w-[300px] rounded-lg"
fill
className="object-cover"
unoptimized
loading="lazy"
/>
</div>
</div>
<div className="flex-1">
<div className="bg-card rounded-xl shadow-sm border border-border p-6">
<div className="flex items-start justify-between gap-4 mb-2">
{/* Info */}
<div className="flex-1 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-foreground">{book.title}</h1>
<EditBookForm book={book} />
</div>
{book.author && (
<p className="text-lg text-muted-foreground mb-4">by {book.author}</p>
)}
{book.series && (
<p className="text-sm text-muted-foreground mb-6">
{book.series}
{book.volume && <span className="ml-2 px-2 py-1 bg-primary/10 text-primary rounded text-xs">Volume {book.volume}</span>}
</p>
)}
<div className="space-y-3">
{book.reading_status && (
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Lecture :</span>
<div className="flex items-center gap-3">
<ReadingStatusBadge
status={book.reading_status}
currentPage={book.reading_current_page ?? null}
lastReadAt={book.reading_last_read_at ?? null}
/>
<MarkBookReadButton bookId={book.id} currentStatus={book.reading_status} />
</div>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Format:</span>
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
(book.format ?? book.kind) === 'cbz' ? 'bg-success/10 text-success' :
(book.format ?? book.kind) === 'cbr' ? 'bg-warning/10 text-warning' :
(book.format ?? book.kind) === 'pdf' ? 'bg-destructive/10 text-destructive' :
'bg-muted/50 text-muted-foreground'
}`}>
{(book.format ?? book.kind).toUpperCase()}
</span>
</div>
{book.volume && (
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Volume:</span>
<span className="text-sm text-foreground">{book.volume}</span>
</div>
)}
{book.language && (
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Language:</span>
<span className="text-sm text-foreground">{book.language.toUpperCase()}</span>
</div>
)}
{book.page_count && (
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Pages:</span>
<span className="text-sm text-foreground">{book.page_count}</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Library:</span>
<span className="text-sm text-foreground">{library?.name || book.library_id}</span>
</div>
{book.series && (
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Series:</span>
<span className="text-sm text-foreground">{book.series}</span>
</div>
)}
{book.file_format && (
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">File Format:</span>
<div className="flex items-center gap-3">
<span className="text-sm text-foreground">{book.file_format.toUpperCase()}</span>
{book.file_format === "cbr" && <ConvertButton bookId={book.id} />}
</div>
</div>
)}
{book.file_parse_status && (
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Parse Status:</span>
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
book.file_parse_status === 'success' ? 'bg-success/10 text-success' :
book.file_parse_status === 'failed' ? 'bg-destructive/10 text-error' : 'bg-muted/50 text-muted-foreground'
}`}>
{book.file_parse_status}
</span>
</div>
)}
{book.file_path && (
<div className="flex flex-col py-2 border-b border-border">
<span className="text-sm text-muted-foreground mb-1">File Path:</span>
<code className="text-xs font-mono text-foreground break-all">{book.file_path}</code>
</div>
)}
<div className="flex flex-col py-2 border-b border-border">
<span className="text-sm text-muted-foreground mb-1">Book ID:</span>
<code className="text-xs font-mono text-foreground break-all">{book.id}</code>
</div>
<div className="flex flex-col py-2 border-b border-border">
<span className="text-sm text-muted-foreground mb-1">Library ID:</span>
<code className="text-xs font-mono text-foreground break-all">{book.library_id}</code>
</div>
{book.updated_at && (
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">Updated:</span>
<span className="text-sm text-foreground">{new Date(book.updated_at).toLocaleString()}</span>
</div>
{book.author && (
<p className="text-base text-muted-foreground mt-1">{book.author}</p>
)}
</div>
<EditBookForm book={book} />
</div>
{/* Series + Volume link */}
{book.series && (
<div className="flex items-center gap-2 text-sm">
<Link
href={`/libraries/${book.library_id}/series/${encodeURIComponent(book.series)}`}
className="text-primary hover:text-primary/80 transition-colors font-medium"
>
{book.series}
</Link>
{book.volume != null && (
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-md text-xs font-semibold">
Vol. {book.volume}
</span>
)}
</div>
)}
{/* Reading status + actions */}
<div className="flex flex-wrap items-center gap-3">
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold ${statusClassName}`}>
{statusLabel}
{book.reading_status === "reading" && book.reading_current_page != null && ` · p. ${book.reading_current_page}`}
</span>
{book.reading_last_read_at && (
<span className="text-xs text-muted-foreground">
{new Date(book.reading_last_read_at).toLocaleDateString()}
</span>
)}
<MarkBookReadButton bookId={book.id} currentStatus={book.reading_status} />
{book.file_format === "cbr" && <ConvertButton bookId={book.id} />}
</div>
{/* Metadata pills */}
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold border ${formatColor}`}>
{formatBadge}
</span>
{book.page_count && (
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
{book.page_count} pages
</span>
)}
{book.language && (
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
{book.language.toUpperCase()}
</span>
)}
{book.isbn && (
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-mono font-medium bg-muted/50 text-muted-foreground border border-border">
ISBN {book.isbn}
</span>
)}
{book.publish_date && (
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
{book.publish_date}
</span>
)}
</div>
{/* Description */}
{book.summary && (
<SafeHtml html={book.summary} className="text-sm text-muted-foreground leading-relaxed" />
)}
</div>
</div>
{book.page_count && book.page_count > 0 && (
<div className="mt-8">
<BookPreview bookId={book.id} pageCount={book.page_count} />
{/* Technical info (collapsible) */}
<details className="group">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors select-none flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
Informations techniques
</summary>
<div className="mt-3 p-4 rounded-lg bg-muted/30 border border-border/50 space-y-2 text-xs">
{book.file_path && (
<div className="flex flex-col gap-0.5">
<span className="text-muted-foreground">Fichier</span>
<code className="font-mono text-foreground break-all">{book.file_path}</code>
</div>
)}
{book.file_format && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Format fichier</span>
<span className="text-foreground">{book.file_format.toUpperCase()}</span>
</div>
)}
{book.file_parse_status && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Parsing</span>
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${
book.file_parse_status === "success" ? "bg-success/10 text-success" :
book.file_parse_status === "failed" ? "bg-destructive/10 text-destructive" :
"bg-muted/50 text-muted-foreground"
}`}>
{book.file_parse_status}
</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Book ID</span>
<code className="font-mono text-foreground">{book.id}</code>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Library ID</span>
<code className="font-mono text-foreground">{book.library_id}</code>
</div>
{book.updated_at && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Mis à jour</span>
<span className="text-foreground">{new Date(book.updated_at).toLocaleString()}</span>
</div>
)}
</div>
</details>
{/* Book Preview */}
{book.page_count && book.page_count > 0 && (
<BookPreview bookId={book.id} pageCount={book.page_count} />
)}
</>
</div>
);
}

View File

@@ -53,6 +53,9 @@ export default async function BooksPage({
reading_status: "unread" as const,
reading_current_page: null,
reading_last_read_at: null,
summary: null,
isbn: null,
publish_date: null,
}));
totalHits = searchResponse.estimated_total_hits;
}

View File

@@ -6,6 +6,40 @@ import { useRouter } from "next/navigation";
import { BookDto } from "@/lib/api";
import { FormField, FormLabel, FormInput } from "./ui/Form";
function LockButton({
locked,
onToggle,
disabled,
}: {
locked: boolean;
onToggle: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onToggle}
disabled={disabled}
className={`p-1 rounded transition-colors ${
locked
? "text-amber-500 hover:text-amber-600"
: "text-muted-foreground/40 hover:text-muted-foreground"
}`}
title={locked ? "Champ verrouillé (protégé des synchros)" : "Cliquer pour verrouiller ce champ"}
>
{locked ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
interface EditBookFormProps {
book: BookDto;
}
@@ -23,6 +57,14 @@ export function EditBookForm({ book }: EditBookFormProps) {
const [series, setSeries] = useState(book.series ?? "");
const [volume, setVolume] = useState(book.volume?.toString() ?? "");
const [language, setLanguage] = useState(book.language ?? "");
const [summary, setSummary] = useState(book.summary ?? "");
const [isbn, setIsbn] = useState(book.isbn ?? "");
const [publishDate, setPublishDate] = useState(book.publish_date ?? "");
const [lockedFields, setLockedFields] = useState<Record<string, boolean>>(book.locked_fields ?? {});
const toggleLock = (field: string) => {
setLockedFields((prev) => ({ ...prev, [field]: !prev[field] }));
};
const addAuthor = () => {
const v = authorInput.trim();
@@ -51,6 +93,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
setSeries(book.series ?? "");
setVolume(book.volume?.toString() ?? "");
setLanguage(book.language ?? "");
setSummary(book.summary ?? "");
setIsbn(book.isbn ?? "");
setPublishDate(book.publish_date ?? "");
setLockedFields(book.locked_fields ?? {});
setError(null);
setIsOpen(false);
}, [book]);
@@ -85,6 +131,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
series: series.trim() || null,
volume: volume.trim() ? parseInt(volume.trim(), 10) : null,
language: language.trim() || null,
summary: summary.trim() || null,
isbn: isbn.trim() || null,
publish_date: publishDate.trim() || null,
locked_fields: lockedFields,
}),
});
if (!res.ok) {
@@ -130,7 +180,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
<form onSubmit={handleSubmit} className="p-5 space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField className="sm:col-span-2">
<FormLabel required>Titre</FormLabel>
<div className="flex items-center gap-1">
<FormLabel required>Titre</FormLabel>
<LockButton locked={!!lockedFields.title} onToggle={() => toggleLock("title")} disabled={isPending} />
</div>
<FormInput
value={title}
onChange={(e) => setTitle(e.target.value)}
@@ -141,7 +194,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
{/* Auteurs — multi-valeur */}
<FormField className="sm:col-span-2">
<FormLabel>Auteur(s)</FormLabel>
<div className="flex items-center gap-1">
<FormLabel>Auteur(s)</FormLabel>
<LockButton locked={!!lockedFields.authors} onToggle={() => toggleLock("authors")} disabled={isPending} />
</div>
<div className="space-y-2">
{authors.length > 0 && (
<div className="flex flex-wrap gap-1.5">
@@ -187,7 +243,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
</FormField>
<FormField>
<FormLabel>Langue</FormLabel>
<div className="flex items-center gap-1">
<FormLabel>Langue</FormLabel>
<LockButton locked={!!lockedFields.language} onToggle={() => toggleLock("language")} disabled={isPending} />
</div>
<FormInput
value={language}
onChange={(e) => setLanguage(e.target.value)}
@@ -197,7 +256,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
</FormField>
<FormField>
<FormLabel>Série</FormLabel>
<div className="flex items-center gap-1">
<FormLabel>Série</FormLabel>
<LockButton locked={!!lockedFields.series} onToggle={() => toggleLock("series")} disabled={isPending} />
</div>
<FormInput
value={series}
onChange={(e) => setSeries(e.target.value)}
@@ -207,7 +269,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
</FormField>
<FormField>
<FormLabel>Volume</FormLabel>
<div className="flex items-center gap-1">
<FormLabel>Volume</FormLabel>
<LockButton locked={!!lockedFields.volume} onToggle={() => toggleLock("volume")} disabled={isPending} />
</div>
<FormInput
type="number"
min="1"
@@ -217,8 +282,59 @@ export function EditBookForm({ book }: EditBookFormProps) {
placeholder="Numéro de volume"
/>
</FormField>
<FormField>
<div className="flex items-center gap-1">
<FormLabel>ISBN</FormLabel>
<LockButton locked={!!lockedFields.isbn} onToggle={() => toggleLock("isbn")} disabled={isPending} />
</div>
<FormInput
value={isbn}
onChange={(e) => setIsbn(e.target.value)}
disabled={isPending}
placeholder="ISBN"
/>
</FormField>
<FormField>
<div className="flex items-center gap-1">
<FormLabel>Date de publication</FormLabel>
<LockButton locked={!!lockedFields.publish_date} onToggle={() => toggleLock("publish_date")} disabled={isPending} />
</div>
<FormInput
value={publishDate}
onChange={(e) => setPublishDate(e.target.value)}
disabled={isPending}
placeholder="ex : 2023-01-15"
/>
</FormField>
<FormField className="sm:col-span-2">
<div className="flex items-center gap-1">
<FormLabel>Description</FormLabel>
<LockButton locked={!!lockedFields.summary} onToggle={() => toggleLock("summary")} disabled={isPending} />
</div>
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
disabled={isPending}
placeholder="Résumé / description du livre"
rows={4}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
/>
</FormField>
</div>
{/* Lock legend */}
{Object.values(lockedFields).some(Boolean) && (
<p className="text-xs text-amber-500 flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Les champs verrouillés ne seront pas écrasés par les synchros de métadonnées externes.
</p>
)}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}

View File

@@ -5,6 +5,40 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { FormField, FormLabel, FormInput } from "./ui/Form";
function LockButton({
locked,
onToggle,
disabled,
}: {
locked: boolean;
onToggle: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onToggle}
disabled={disabled}
className={`p-1 rounded transition-colors ${
locked
? "text-amber-500 hover:text-amber-600"
: "text-muted-foreground/40 hover:text-muted-foreground"
}`}
title={locked ? "Champ verrouillé (protégé des synchros)" : "Cliquer pour verrouiller ce champ"}
>
{locked ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
interface EditSeriesFormProps {
libraryId: string;
seriesName: string;
@@ -14,6 +48,8 @@ interface EditSeriesFormProps {
currentBookLanguage: string | null;
currentDescription: string | null;
currentStartYear: number | null;
currentTotalVolumes: number | null;
currentLockedFields: Record<string, boolean>;
}
export function EditSeriesForm({
@@ -25,6 +61,8 @@ export function EditSeriesForm({
currentBookLanguage,
currentDescription,
currentStartYear,
currentTotalVolumes,
currentLockedFields,
}: EditSeriesFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
@@ -41,12 +79,20 @@ export function EditSeriesForm({
const [publisherInputEl, setPublisherInputEl] = useState<HTMLInputElement | null>(null);
const [description, setDescription] = useState(currentDescription ?? "");
const [startYear, setStartYear] = useState(currentStartYear?.toString() ?? "");
const [totalVolumes, setTotalVolumes] = useState(currentTotalVolumes?.toString() ?? "");
// Lock states
const [lockedFields, setLockedFields] = useState<Record<string, boolean>>(currentLockedFields);
// Propagation aux livres — opt-in via bouton
const [bookAuthor, setBookAuthor] = useState(currentBookAuthor ?? "");
const [bookLanguage, setBookLanguage] = useState(currentBookLanguage ?? "");
const [showApplyToBooks, setShowApplyToBooks] = useState(false);
const toggleLock = (field: string) => {
setLockedFields((prev) => ({ ...prev, [field]: !prev[field] }));
};
const addAuthor = () => {
const v = authorInput.trim();
if (v && !authors.includes(v)) {
@@ -95,12 +141,14 @@ export function EditSeriesForm({
setPublisherInput("");
setDescription(currentDescription ?? "");
setStartYear(currentStartYear?.toString() ?? "");
setTotalVolumes(currentTotalVolumes?.toString() ?? "");
setLockedFields(currentLockedFields);
setShowApplyToBooks(false);
setBookAuthor(currentBookAuthor ?? "");
setBookLanguage(currentBookLanguage ?? "");
setError(null);
setIsOpen(false);
}, [seriesName, currentAuthors, currentPublishers, currentDescription, currentStartYear, currentBookAuthor, currentBookLanguage]);
}, [seriesName, currentAuthors, currentPublishers, currentDescription, currentStartYear, currentTotalVolumes, currentBookAuthor, currentBookLanguage, currentLockedFields]);
useEffect(() => {
if (!isOpen) return;
@@ -133,6 +181,8 @@ export function EditSeriesForm({
publishers: finalPublishers,
description: description.trim() || null,
start_year: startYear.trim() ? parseInt(startYear.trim(), 10) : null,
total_volumes: totalVolumes.trim() ? parseInt(totalVolumes.trim(), 10) : null,
locked_fields: lockedFields,
};
if (showApplyToBooks) {
body.author = bookAuthor.trim() || null;
@@ -205,7 +255,10 @@ export function EditSeriesForm({
</FormField>
<FormField>
<FormLabel>Année de début</FormLabel>
<div className="flex items-center gap-1">
<FormLabel>Année de début</FormLabel>
<LockButton locked={!!lockedFields.start_year} onToggle={() => toggleLock("start_year")} disabled={isPending} />
</div>
<FormInput
type="number"
min="1900"
@@ -217,9 +270,27 @@ export function EditSeriesForm({
/>
</FormField>
<FormField>
<div className="flex items-center gap-1">
<FormLabel>Nombre de volumes</FormLabel>
<LockButton locked={!!lockedFields.total_volumes} onToggle={() => toggleLock("total_volumes")} disabled={isPending} />
</div>
<FormInput
type="number"
min="1"
value={totalVolumes}
onChange={(e) => setTotalVolumes(e.target.value)}
disabled={isPending}
placeholder="ex : 12"
/>
</FormField>
{/* Auteurs — multi-valeur */}
<FormField className="sm:col-span-2">
<FormLabel>Auteur(s)</FormLabel>
<div className="flex items-center gap-1">
<FormLabel>Auteur(s)</FormLabel>
<LockButton locked={!!lockedFields.authors} onToggle={() => toggleLock("authors")} disabled={isPending} />
</div>
<div className="space-y-2">
{authors.length > 0 && (
<div className="flex flex-wrap gap-1.5">
@@ -302,7 +373,10 @@ export function EditSeriesForm({
{/* Éditeurs — multi-valeur */}
<FormField className="sm:col-span-2">
<FormLabel>Éditeur(s)</FormLabel>
<div className="flex items-center gap-1">
<FormLabel>Éditeur(s)</FormLabel>
<LockButton locked={!!lockedFields.publishers} onToggle={() => toggleLock("publishers")} disabled={isPending} />
</div>
<div className="space-y-2">
{publishers.length > 0 && (
<div className="flex flex-wrap gap-1.5">
@@ -348,7 +422,10 @@ export function EditSeriesForm({
</FormField>
<FormField className="sm:col-span-2">
<FormLabel>Description</FormLabel>
<div className="flex items-center gap-1">
<FormLabel>Description</FormLabel>
<LockButton locked={!!lockedFields.description} onToggle={() => toggleLock("description")} disabled={isPending} />
</div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
@@ -360,6 +437,16 @@ export function EditSeriesForm({
</FormField>
</div>
{/* Lock legend */}
{Object.values(lockedFields).some(Boolean) && (
<p className="text-xs text-amber-500 flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Les champs verrouillés ne seront pas écrasés par les synchros de métadonnées externes.
</p>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{/* Footer */}

View File

@@ -2,21 +2,24 @@
import { useState, useRef, useEffect, useTransition } from "react";
import { Button } from "../components/ui";
import { ProviderIcon } from "../components/ProviderIcon";
interface LibraryActionsProps {
libraryId: string;
monitorEnabled: boolean;
scanMode: string;
watcherEnabled: boolean;
metadataProvider: string | null;
onUpdate?: () => void;
}
export function LibraryActions({
libraryId,
monitorEnabled,
scanMode,
export function LibraryActions({
libraryId,
monitorEnabled,
scanMode,
watcherEnabled,
onUpdate
metadataProvider,
onUpdate
}: LibraryActionsProps) {
const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition();
@@ -39,17 +42,25 @@ export function LibraryActions({
const monitorEnabled = formData.get("monitor_enabled") === "true";
const watcherEnabled = formData.get("watcher_enabled") === "true";
const scanMode = formData.get("scan_mode") as string;
const newMetadataProvider = (formData.get("metadata_provider") as string) || null;
try {
const response = await fetch(`/api/libraries/${libraryId}/monitoring`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
monitor_enabled: monitorEnabled,
scan_mode: scanMode,
watcher_enabled: watcherEnabled,
const [response] = await Promise.all([
fetch(`/api/libraries/${libraryId}/monitoring`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
monitor_enabled: monitorEnabled,
scan_mode: scanMode,
watcher_enabled: watcherEnabled,
}),
}),
});
fetch(`/api/libraries/${libraryId}/metadata-provider`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ metadata_provider: newMetadataProvider }),
}),
]);
if (response.ok) {
setIsOpen(false);
@@ -126,6 +137,25 @@ export function LibraryActions({
</select>
</div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
{metadataProvider && <ProviderIcon provider={metadataProvider} size={16} />}
Metadata Provider
</label>
<select
name="metadata_provider"
defaultValue={metadataProvider || ""}
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
>
<option value="">Default</option>
<option value="google_books">Google Books</option>
<option value="comicvine">ComicVine</option>
<option value="open_library">Open Library</option>
<option value="anilist">AniList</option>
<option value="bedetheque">Bédéthèque</option>
</select>
</div>
{saveError && (
<p className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded-lg break-all">
{saveError}

View File

@@ -0,0 +1,671 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { Icon } from "./ui";
import { ProviderIcon, PROVIDERS, providerLabel } from "./ProviderIcon";
import type { ExternalMetadataLinkDto, SeriesCandidateDto, MissingBooksDto, SyncReport } from "../../lib/api";
const FIELD_LABELS: Record<string, string> = {
description: "Description",
authors: "Auteurs",
publishers: "Éditeurs",
start_year: "Année",
total_volumes: "Nb volumes",
summary: "Résumé",
isbn: "ISBN",
publish_date: "Date de publication",
language: "Langue",
};
function fieldLabel(field: string): string {
return FIELD_LABELS[field] ?? field;
}
function formatValue(value: unknown): string {
if (value == null) return "—";
if (Array.isArray(value)) return value.join(", ");
if (typeof value === "string") {
return value.length > 80 ? value.slice(0, 80) + "…" : value;
}
return String(value);
}
interface MetadataSearchModalProps {
libraryId: string;
seriesName: string;
existingLink: ExternalMetadataLinkDto | null;
initialMissing: MissingBooksDto | null;
}
type ModalStep = "idle" | "searching" | "results" | "confirm" | "syncing" | "done" | "linked";
export function MetadataSearchModal({
libraryId,
seriesName,
existingLink,
initialMissing,
}: MetadataSearchModalProps) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [step, setStep] = useState<ModalStep>("idle");
const [candidates, setCandidates] = useState<SeriesCandidateDto[]>([]);
const [selectedCandidate, setSelectedCandidate] = useState<SeriesCandidateDto | null>(null);
const [error, setError] = useState<string | null>(null);
const [linkId, setLinkId] = useState<string | null>(existingLink?.id ?? null);
const [missing, setMissing] = useState<MissingBooksDto | null>(initialMissing);
const [showMissingList, setShowMissingList] = useState(false);
const [syncReport, setSyncReport] = useState<SyncReport | null>(null);
// Provider selector: empty string = library default
const [searchProvider, setSearchProvider] = useState("");
const [activeProvider, setActiveProvider] = useState("");
const handleOpen = useCallback(() => {
setIsOpen(true);
if (existingLink && existingLink.status === "approved") {
setStep("linked");
} else {
doSearch("");
}
}, [existingLink]);
const handleClose = useCallback(() => {
setIsOpen(false);
setStep("idle");
setError(null);
setCandidates([]);
setSelectedCandidate(null);
setShowMissingList(false);
setSyncReport(null);
}, []);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") handleClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleClose]);
async function doSearch(provider: string) {
setStep("searching");
setError(null);
setActiveProvider(provider);
try {
const body: Record<string, string> = {
library_id: libraryId,
series_name: seriesName,
};
if (provider) body.provider = provider;
const resp = await fetch("/api/metadata/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await resp.json();
if (!resp.ok) {
setError(data.error || "Search failed");
setStep("results");
return;
}
setCandidates(data);
// Update activeProvider from first result (the API returns the actual provider used)
if (data.length > 0 && data[0].provider) {
setActiveProvider(data[0].provider);
if (!provider) setSearchProvider(data[0].provider);
}
setStep("results");
} catch {
setError("Network error");
setStep("results");
}
}
async function handleSelectCandidate(candidate: SeriesCandidateDto) {
setSelectedCandidate(candidate);
setStep("confirm");
}
async function handleApprove(syncSeries: boolean, syncBooks: boolean) {
if (!selectedCandidate) return;
setStep("syncing");
setError(null);
try {
// Create match — use the provider from the candidate
const matchResp = await fetch("/api/metadata/match", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
library_id: libraryId,
series_name: seriesName,
provider: selectedCandidate.provider,
external_id: selectedCandidate.external_id,
external_url: selectedCandidate.external_url,
confidence: selectedCandidate.confidence,
title: selectedCandidate.title,
metadata_json: {
...selectedCandidate.metadata_json,
description: selectedCandidate.description,
authors: selectedCandidate.authors,
publishers: selectedCandidate.publishers,
start_year: selectedCandidate.start_year,
},
total_volumes: selectedCandidate.total_volumes,
}),
});
const matchData = await matchResp.json();
if (!matchResp.ok) {
setError(matchData.error || "Failed to create match");
setStep("results");
return;
}
const newLinkId = matchData.id;
setLinkId(newLinkId);
// Approve
const approveResp = await fetch("/api/metadata/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: newLinkId,
sync_series: syncSeries,
sync_books: syncBooks,
}),
});
const approveData = await approveResp.json();
if (!approveResp.ok) {
setError(approveData.error || "Failed to approve");
setStep("results");
return;
}
// Store sync report
if (approveData.report) {
setSyncReport(approveData.report);
}
// Fetch missing books info
if (syncBooks) {
try {
const missingResp = await fetch(`/api/metadata/missing?id=${newLinkId}`);
if (missingResp.ok) {
setMissing(await missingResp.json());
}
} catch { /* ignore */ }
}
setStep("done");
} catch {
setError("Network error");
setStep("results");
}
}
async function handleUnlink() {
if (!linkId) return;
try {
const resp = await fetch(`/api/metadata/links?id=${linkId}`, { method: "DELETE" });
if (resp.ok) {
setLinkId(null);
setMissing(null);
handleClose();
router.refresh();
}
} catch { /* ignore */ }
}
function confidenceBadge(confidence: number) {
const color =
confidence >= 0.8
? "bg-green-500/10 text-green-600 border-green-500/30"
: confidence >= 0.5
? "bg-yellow-500/10 text-yellow-600 border-yellow-500/30"
: "bg-red-500/10 text-red-600 border-red-500/30";
return (
<span className={`text-xs px-2 py-0.5 rounded-full border ${color}`}>
{Math.round(confidence * 100)}%
</span>
);
}
const modal = isOpen
? createPortal(
<>
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
onClick={handleClose}
/>
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
<h3 className="font-semibold text-foreground">
{step === "linked" ? "Metadata Link" : "Search External Metadata"}
</h3>
<button type="button" onClick={handleClose}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-muted-foreground hover:text-foreground">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="p-5 space-y-4">
{/* Provider selector — visible during searching & results */}
{(step === "searching" || step === "results") && (
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground whitespace-nowrap">Provider :</label>
<div className="flex gap-1 flex-wrap">
{PROVIDERS.map((p) => (
<button
key={p.value}
type="button"
disabled={step === "searching"}
onClick={() => {
setSearchProvider(p.value);
doSearch(p.value);
}}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${
(activeProvider || searchProvider) === p.value
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50"
}`}
>
<ProviderIcon provider={p.value} size={14} />
{p.label}
</button>
))}
</div>
</div>
)}
{/* SEARCHING */}
{step === "searching" && (
<div className="flex items-center justify-center py-12">
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
<span className="ml-3 text-muted-foreground">Searching for &quot;{seriesName}&quot;...</span>
</div>
)}
{/* ERROR */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* RESULTS */}
{step === "results" && (
<>
{candidates.length === 0 && !error ? (
<p className="text-muted-foreground text-center py-8">No results found.</p>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground mb-2">
{candidates.length} result{candidates.length !== 1 ? "s" : ""} found
{activeProvider && (
<span className="ml-1 text-xs inline-flex items-center gap-1">via <ProviderIcon provider={activeProvider} size={12} /> <span className="font-medium">{providerLabel(activeProvider)}</span></span>
)}
</p>
{candidates.map((c, i) => (
<button
key={i}
type="button"
onClick={() => handleSelectCandidate(c)}
className="w-full text-left px-3 py-2.5 rounded-lg border border-border/60 bg-muted/20 hover:bg-muted/40 hover:border-primary/50 transition-colors"
>
<div className="flex gap-3 items-start">
<div className="w-10 h-14 flex-shrink-0 rounded bg-muted/50 overflow-hidden">
{c.cover_url ? (
<img src={c.cover_url} alt={c.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground/40">
<Icon name="image" size="sm" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground truncate">{c.title}</span>
{confidenceBadge(c.confidence)}
</div>
{c.authors.length > 0 && (
<p className="text-xs text-muted-foreground truncate">{c.authors.join(", ")}</p>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{c.publishers.length > 0 && <span>{c.publishers[0]}</span>}
{c.start_year != null && <span>{c.start_year}</span>}
{c.total_volumes != null && <span>{c.total_volumes} vol.</span>}
</div>
</div>
</div>
</button>
))}
</div>
)}
</>
)}
{/* CONFIRM */}
{step === "confirm" && selectedCandidate && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-muted/30 border border-border/50">
<div className="flex gap-3">
{selectedCandidate.cover_url && (
<img
src={selectedCandidate.cover_url}
alt={selectedCandidate.title}
className="w-16 h-22 object-cover rounded"
/>
)}
<div>
<h4 className="font-medium text-foreground">{selectedCandidate.title}</h4>
{selectedCandidate.authors.length > 0 && (
<p className="text-sm text-muted-foreground">{selectedCandidate.authors.join(", ")}</p>
)}
{selectedCandidate.total_volumes && (
<p className="text-sm text-muted-foreground">{selectedCandidate.total_volumes} volumes</p>
)}
<p className="text-xs text-muted-foreground mt-1 inline-flex items-center gap-1">
via <ProviderIcon provider={selectedCandidate.provider} size={12} /> <span className="font-medium">{providerLabel(selectedCandidate.provider)}</span>
</p>
</div>
</div>
</div>
<p className="text-sm text-foreground font-medium">How would you like to sync?</p>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => handleApprove(true, false)}
className="w-full p-3 rounded-lg border border-border bg-card text-left hover:bg-muted/40 hover:border-primary/50 transition-colors"
>
<p className="font-medium text-sm text-foreground">Sync series metadata only</p>
<p className="text-xs text-muted-foreground">Update description, authors, publishers, and year</p>
</button>
<button
type="button"
onClick={() => handleApprove(true, true)}
className="w-full p-3 rounded-lg border border-primary/50 bg-primary/5 text-left hover:bg-primary/10 transition-colors"
>
<p className="font-medium text-sm text-foreground">Sync series + books</p>
<p className="text-xs text-muted-foreground">Also fetch book list and show missing volumes</p>
</button>
</div>
<button
type="button"
onClick={() => { setSelectedCandidate(null); setStep("results"); }}
className="text-sm text-muted-foreground hover:text-foreground"
>
Back to results
</button>
</div>
)}
{/* SYNCING */}
{step === "syncing" && (
<div className="flex items-center justify-center py-12">
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
<span className="ml-3 text-muted-foreground">Syncing metadata...</span>
</div>
)}
{/* DONE */}
{step === "done" && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/30">
<p className="font-medium text-green-600">Metadata synced successfully!</p>
</div>
{/* Sync Report */}
{syncReport && (
<div className="space-y-3">
{/* Series report */}
{syncReport.series && (syncReport.series.fields_updated.length > 0 || syncReport.series.fields_skipped.length > 0) && (
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">Série</p>
{syncReport.series.fields_updated.length > 0 && (
<div className="space-y-1">
{syncReport.series.fields_updated.map((f, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="inline-flex items-center gap-1 text-green-600">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
</span>
<span className="font-medium text-foreground">{fieldLabel(f.field)}</span>
<span className="text-muted-foreground truncate max-w-[200px]">{formatValue(f.new_value)}</span>
</div>
))}
</div>
)}
{syncReport.series.fields_skipped.length > 0 && (
<div className="space-y-1 mt-1">
{syncReport.series.fields_skipped.map((f, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-amber-500">
<svg className="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="font-medium">{fieldLabel(f.field)}</span>
<span className="text-muted-foreground">locked</span>
</div>
))}
</div>
)}
</div>
)}
{/* Books report */}
{(syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Livres {syncReport.books_matched} matched{syncReport.books_unmatched > 0 && `, ${syncReport.books_unmatched} unmatched`}
</p>
{syncReport.books.length > 0 && (
<div className="space-y-2 max-h-48 overflow-y-auto">
{syncReport.books.map((b, i) => (
<div key={i} className="text-xs">
<p className="font-medium text-foreground">
{b.volume != null && <span className="font-mono text-muted-foreground mr-1.5">#{b.volume}</span>}
{b.title}
</p>
<div className="ml-4 space-y-0.5 mt-0.5">
{b.fields_updated.map((f, j) => (
<p key={j} className="flex items-center gap-1.5 text-green-600">
<svg className="w-2.5 h-2.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
<span className="font-medium">{fieldLabel(f.field)}</span>
</p>
))}
{b.fields_skipped.map((f, j) => (
<p key={`s${j}`} className="flex items-center gap-1.5 text-amber-500">
<svg className="w-2.5 h-2.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="font-medium">{fieldLabel(f.field)}</span>
<span className="text-muted-foreground">locked</span>
</p>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{/* Missing books */}
{missing && (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
<div>
<p className="text-sm text-muted-foreground">External</p>
<p className="text-2xl font-semibold">{missing.total_external}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Local</p>
<p className="text-2xl font-semibold">{missing.total_local}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Missing</p>
<p className="text-2xl font-semibold text-warning">{missing.missing_count}</p>
</div>
</div>
{missing.missing_books.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowMissingList(!showMissingList)}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
{missing.missing_count} missing book{missing.missing_count !== 1 ? "s" : ""}
</button>
{showMissingList && (
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
{missing.missing_books.map((b, i) => (
<p key={i} className="text-muted-foreground truncate">
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
{b.title || "Unknown"}
</p>
))}
</div>
)}
</div>
)}
</div>
)}
<button
type="button"
onClick={() => { handleClose(); router.refresh(); }}
className="w-full p-2.5 rounded-lg bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 transition-colors"
>
Close
</button>
</div>
)}
{/* LINKED (already approved) */}
{step === "linked" && existingLink && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-primary/5 border border-primary/30">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground inline-flex items-center gap-1.5">
Linked to <ProviderIcon provider={existingLink.provider} size={16} /> {providerLabel(existingLink.provider)}
</p>
{existingLink.external_url && (
<a
href={existingLink.external_url}
target="_blank"
rel="noopener noreferrer"
className="block mt-1 text-xs text-primary hover:underline"
>
View on external source
</a>
)}
</div>
{existingLink.confidence != null && confidenceBadge(existingLink.confidence)}
</div>
</div>
{initialMissing && (
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
<div>
<p className="text-sm text-muted-foreground">External</p>
<p className="text-2xl font-semibold">{initialMissing.total_external}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Local</p>
<p className="text-2xl font-semibold">{initialMissing.total_local}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Missing</p>
<p className="text-2xl font-semibold text-warning">{initialMissing.missing_count}</p>
</div>
</div>
)}
{initialMissing && initialMissing.missing_books.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowMissingList(!showMissingList)}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
{initialMissing.missing_count} missing book{initialMissing.missing_count !== 1 ? "s" : ""}
</button>
{showMissingList && (
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
{initialMissing.missing_books.map((b, i) => (
<p key={i} className="text-muted-foreground truncate">
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
{b.title || "Unknown"}
</p>
))}
</div>
)}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => { doSearch(""); }}
className="flex-1 p-2.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
>
Search again
</button>
<button
type="button"
onClick={handleUnlink}
className="p-2.5 rounded-lg border border-destructive/30 bg-destructive/5 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors"
>
Unlink
</button>
</div>
</div>
)}
</div>
</div>
</div>
</>,
document.body,
)
: null;
return (
<>
<button
onClick={handleOpen}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
>
<Icon name="search" size="sm" />
{existingLink && existingLink.status === "approved" ? "Metadata" : "Search metadata"}
</button>
{/* Inline badge when linked */}
{existingLink && existingLink.status === "approved" && initialMissing && initialMissing.missing_count > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 text-xs border border-yellow-500/30">
{initialMissing.missing_count} missing
</span>
)}
{existingLink && existingLink.status === "approved" && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/30">
<ProviderIcon provider={existingLink.provider} size={12} />
<span>{providerLabel(existingLink.provider)}</span>
</span>
)}
{modal}
</>
);
}

View File

@@ -0,0 +1,120 @@
/** Inline SVG icons for metadata providers */
interface ProviderIconProps {
provider: string;
size?: number;
className?: string;
}
export function ProviderIcon({ provider, size = 16, className = "" }: ProviderIconProps) {
const style = { width: size, height: size, flexShrink: 0 };
switch (provider) {
case "google_books":
// Stylized book (Google Books)
return (
<svg viewBox="0 0 24 24" style={style} className={className}>
<path
d="M21 4H3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1z"
fill="#4285F4"
opacity="0.15"
/>
<path
d="M12 4v16M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"
fill="none"
stroke="#4285F4"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M7 8h3M7 11h3M14 8h3M14 11h3" stroke="#4285F4" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
case "open_library":
// Open book (Open Library)
return (
<svg viewBox="0 0 24 24" style={style} className={className}>
<path
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
fill="none"
stroke="#E8590C"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
case "comicvine":
// Explosion / star burst (ComicVine)
return (
<svg viewBox="0 0 24 24" style={style} className={className}>
<path
d="M12 2l2.09 6.26L20.18 9l-4.91 4.09L16.54 20 12 16.27 7.46 20l1.27-6.91L3.82 9l6.09-.74z"
fill="#E7272D"
opacity="0.15"
/>
<path
d="M12 2l2.09 6.26L20.18 9l-4.91 4.09L16.54 20 12 16.27 7.46 20l1.27-6.91L3.82 9l6.09-.74z"
fill="none"
stroke="#E7272D"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
);
case "anilist":
// Stylized play / triangle (AniList)
return (
<svg viewBox="0 0 24 24" style={style} className={className}>
<rect x="3" y="3" width="18" height="18" rx="3" fill="#02A9FF" opacity="0.15" />
<path
d="M8 6h2.5l4 12H12l-.75-2.5H7.75L7 18H4.5L8 6zm-.25 7.5h3.5L9.5 8.25 7.75 13.5z"
fill="#02A9FF"
/>
<path d="M16 10h2.5v8H16z" fill="#02A9FF" />
</svg>
);
case "bedetheque":
// French flag-inspired book (Bédéthèque)
return (
<svg viewBox="0 0 24 24" style={style} className={className}>
<rect x="3" y="4" width="6" height="16" rx="1" fill="#002395" opacity="0.2" />
<rect x="9" y="4" width="6" height="16" fill="#FFFFFF" opacity="0.1" />
<rect x="15" y="4" width="6" height="16" rx="1" fill="#ED2939" opacity="0.2" />
<path
d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"
fill="none"
stroke="#002395"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path d="M8 9h8M8 12h6M8 15h4" stroke="#002395" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
default:
// Generic globe
return (
<svg viewBox="0 0 24 24" style={style} className={className} fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="9" />
<path d="M3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18" strokeLinecap="round" />
</svg>
);
}
}
export const PROVIDERS = [
{ value: "google_books", label: "Google Books" },
{ value: "open_library", label: "Open Library" },
{ value: "comicvine", label: "ComicVine" },
{ value: "anilist", label: "AniList" },
{ value: "bedetheque", label: "Bédéthèque" },
] as const;
export function providerLabel(value: string) {
return PROVIDERS.find((p) => p.value === value)?.label ?? value.replace("_", " ");
}

View File

@@ -0,0 +1,29 @@
import sanitizeHtml from "sanitize-html";
import React from "react";
interface SafeHtmlProps {
html: string;
className?: string;
as?: "div" | "p" | "span";
}
const sanitizeOptions: sanitizeHtml.IOptions = {
allowedTags: [
"b", "i", "em", "strong", "a", "p", "br", "ul", "ol", "li",
"h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "span",
],
allowedAttributes: {
a: ["href", "target", "rel"],
span: ["class"],
},
transformTags: {
a: sanitizeHtml.simpleTransform("a", { target: "_blank", rel: "noopener noreferrer" }),
},
};
export function SafeHtml({ html, className, as: tag = "div" }: SafeHtmlProps) {
return React.createElement(tag, {
className,
dangerouslySetInnerHTML: { __html: sanitizeHtml(html, sanitizeOptions) },
});
}

View File

@@ -1,9 +1,11 @@
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, BookDto, SeriesMetadataDto } from "../../../../../lib/api";
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "../../../../../lib/api";
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
import { MetadataSearchModal } from "../../../../components/MetadataSearchModal";
import { OffsetPagination } from "../../../../components/ui";
import { SafeHtml } from "../../../../components/SafeHtml";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
@@ -24,7 +26,7 @@ export default async function SeriesDetailPage({
const seriesName = decodeURIComponent(name);
const [library, booksPage, seriesMeta] = await Promise.all([
const [library, booksPage, seriesMeta, metadataLinks] = await Promise.all([
fetchLibraries().then((libs) => libs.find((l) => l.id === id)),
fetchBooks(id, seriesName, page, limit).catch(() => ({
items: [] as BookDto[],
@@ -33,8 +35,15 @@ export default async function SeriesDetailPage({
limit,
})),
fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null),
getMetadataLink(id, seriesName).catch(() => [] as ExternalMetadataLinkDto[]),
]);
const existingLink = metadataLinks.find((l) => l.status === "approved") ?? metadataLinks[0] ?? null;
let missingData: MissingBooksDto | null = null;
if (existingLink && existingLink.status === "approved") {
missingData = await getMissingBooks(existingLink.id).catch(() => null);
}
if (!library) {
notFound();
}
@@ -96,7 +105,7 @@ export default async function SeriesDetailPage({
)}
{seriesMeta?.description && (
<p className="text-sm text-muted-foreground leading-relaxed">{seriesMeta.description}</p>
<SafeHtml html={seriesMeta.description} className="text-sm text-muted-foreground leading-relaxed" />
)}
<div className="flex flex-wrap items-center gap-4 text-sm">
@@ -143,6 +152,14 @@ export default async function SeriesDetailPage({
currentBookLanguage={seriesMeta?.book_language ?? booksPage.items[0]?.language ?? null}
currentDescription={seriesMeta?.description ?? null}
currentStartYear={seriesMeta?.start_year ?? null}
currentTotalVolumes={seriesMeta?.total_volumes ?? null}
currentLockedFields={seriesMeta?.locked_fields ?? {}}
/>
<MetadataSearchModal
libraryId={id}
seriesName={seriesName}
existingLink={existingLink}
initialMissing={missingData}
/>
</div>
</div>

View File

@@ -114,6 +114,7 @@ export default async function LibrariesPage() {
monitorEnabled={lib.monitor_enabled}
scanMode={lib.scan_mode}
watcherEnabled={lib.watcher_enabled}
metadataProvider={lib.metadata_provider}
/>
</div>
</CardHeader>

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
import { ProviderIcon } from "../components/ProviderIcon";
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary } from "../../lib/api";
interface SettingsPageProps {
@@ -550,6 +551,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</>)}
{activeTab === "integrations" && (<>
{/* Metadata Providers */}
<MetadataProvidersCard handleUpdateSetting={handleUpdateSetting} />
{/* Komga Sync */}
<Card className="mb-6">
<CardHeader>
@@ -793,3 +797,170 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</>
);
}
// ---------------------------------------------------------------------------
// Metadata Providers sub-component
// ---------------------------------------------------------------------------
const METADATA_LANGUAGES = [
{ value: "en", label: "English" },
{ value: "fr", label: "Français" },
{ value: "es", label: "Español" },
] as const;
function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
const [defaultProvider, setDefaultProvider] = useState("google_books");
const [metadataLanguage, setMetadataLanguage] = useState("en");
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
useEffect(() => {
fetch("/api/settings/metadata_providers")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data) {
if (data.default_provider) setDefaultProvider(data.default_provider);
if (data.metadata_language) setMetadataLanguage(data.metadata_language);
if (data.comicvine?.api_key) setApiKeys((prev) => ({ ...prev, comicvine: data.comicvine.api_key }));
if (data.google_books?.api_key) setApiKeys((prev) => ({ ...prev, google_books: data.google_books.api_key }));
}
})
.catch(() => {});
}, []);
function save(provider: string, lang: string, keys: Record<string, string>) {
const value: Record<string, unknown> = {
default_provider: provider,
metadata_language: lang,
};
for (const [k, v] of Object.entries(keys)) {
if (v) value[k] = { api_key: v };
}
handleUpdateSetting("metadata_providers", value);
}
return (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="search" size="md" />
Metadata Providers
</CardTitle>
<CardDescription>Configure external metadata providers for series/book enrichment. Each library can override the default provider. All providers are available for quick-search in the metadata modal.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Default provider */}
<div>
<label className="text-sm font-medium text-muted-foreground mb-2 block">Default Provider</label>
<div className="flex gap-2 flex-wrap">
{([
{ value: "google_books", label: "Google Books" },
{ value: "open_library", label: "Open Library" },
{ value: "comicvine", label: "ComicVine" },
{ value: "anilist", label: "AniList" },
{ value: "bedetheque", label: "Bédéthèque" },
] as const).map((p) => (
<button
key={p.value}
type="button"
onClick={() => {
setDefaultProvider(p.value);
save(p.value, metadataLanguage, apiKeys);
}}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
defaultProvider === p.value
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50"
}`}
>
<ProviderIcon provider={p.value} size={18} />
{p.label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">Used by default for metadata search. Libraries can override this individually.</p>
</div>
{/* Metadata language */}
<div>
<label className="text-sm font-medium text-muted-foreground mb-2 block">Metadata Language</label>
<div className="flex gap-2">
{METADATA_LANGUAGES.map((l) => (
<button
key={l.value}
type="button"
onClick={() => {
setMetadataLanguage(l.value);
save(defaultProvider, l.value, apiKeys);
}}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
metadataLanguage === l.value
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50"
}`}
>
{l.label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">Preferred language for search results and descriptions. Fallback: English.</p>
</div>
{/* Provider API keys — always visible */}
<div className="border-t border-border/50 pt-4">
<h4 className="text-sm font-medium text-foreground mb-3">API Keys</h4>
<div className="space-y-4">
<FormField>
<label className="text-sm font-medium text-muted-foreground mb-1 flex items-center gap-1.5">
<ProviderIcon provider="google_books" size={16} />
Google Books API Key
</label>
<FormInput
type="password"
placeholder="Optional — for higher rate limits"
value={apiKeys.google_books || ""}
onChange={(e) => setApiKeys({ ...apiKeys, google_books: e.target.value })}
onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)}
/>
<p className="text-xs text-muted-foreground mt-1">Works without a key but with lower rate limits.</p>
</FormField>
<FormField>
<label className="text-sm font-medium text-muted-foreground mb-1 flex items-center gap-1.5">
<ProviderIcon provider="comicvine" size={16} />
ComicVine API Key
</label>
<FormInput
type="password"
placeholder="Required to use ComicVine"
value={apiKeys.comicvine || ""}
onChange={(e) => setApiKeys({ ...apiKeys, comicvine: e.target.value })}
onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)}
/>
<p className="text-xs text-muted-foreground mt-1">Get your key at <span className="font-mono text-foreground/70">comicvine.gamespot.com/api</span>.</p>
</FormField>
<div className="p-3 rounded-lg bg-muted/30 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5">
<ProviderIcon provider="open_library" size={16} />
<span className="text-xs font-medium text-foreground">Open Library</span>
</div>
<span className="text-xs text-muted-foreground">,</span>
<div className="flex items-center gap-1.5">
<ProviderIcon provider="anilist" size={16} />
<span className="text-xs font-medium text-foreground">AniList</span>
</div>
<span className="text-xs text-muted-foreground">and</span>
<div className="flex items-center gap-1.5">
<ProviderIcon provider="bedetheque" size={16} />
<span className="text-xs font-medium text-foreground">Bédéthèque</span>
</div>
<span className="text-xs text-muted-foreground">are free and require no API key.</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -8,6 +8,7 @@ export type LibraryDto = {
scan_mode: string;
next_scan_at: string | null;
watcher_enabled: boolean;
metadata_provider: string | null;
};
export type IndexJobDto = {
@@ -74,6 +75,10 @@ export type BookDto = {
reading_status: ReadingStatus;
reading_current_page: number | null;
reading_last_read_at: string | null;
summary: string | null;
isbn: string | null;
publish_date: string | null;
locked_fields?: Record<string, boolean>;
};
export type BooksPageDto = {
@@ -492,6 +497,10 @@ export type UpdateBookRequest = {
series: string | null;
volume: number | null;
language: string | null;
summary: string | null;
isbn: string | null;
publish_date: string | null;
locked_fields?: Record<string, boolean>;
};
export async function updateBook(bookId: string, data: UpdateBookRequest) {
@@ -506,8 +515,10 @@ export type SeriesMetadataDto = {
description: string | null;
publishers: string[];
start_year: number | null;
total_volumes: number | null;
book_author: string | null;
book_language: string | null;
locked_fields: Record<string, boolean>;
};
export async function fetchSeriesMetadata(libraryId: string, seriesName: string) {
@@ -524,6 +535,8 @@ export type UpdateSeriesRequest = {
description: string | null;
publishers: string[];
start_year: number | null;
total_volumes: number | null;
locked_fields?: Record<string, boolean>;
};
export async function updateSeries(libraryId: string, seriesName: string, data: UpdateSeriesRequest) {
@@ -584,3 +597,136 @@ export async function listKomgaReports() {
export async function getKomgaReport(id: string) {
return apiFetch<KomgaSyncResponse>(`/komga/reports/${id}`);
}
// ---------------------------------------------------------------------------
// External Metadata
// ---------------------------------------------------------------------------
export type SeriesCandidateDto = {
provider: string;
external_id: string;
title: string;
authors: string[];
description: string | null;
publishers: string[];
start_year: number | null;
total_volumes: number | null;
cover_url: string | null;
external_url: string | null;
confidence: number;
metadata_json: Record<string, unknown>;
};
export type ExternalMetadataLinkDto = {
id: string;
library_id: string;
series_name: string;
provider: string;
external_id: string;
external_url: string | null;
status: string;
confidence: number | null;
metadata_json: Record<string, unknown>;
total_volumes_external: number | null;
matched_at: string;
approved_at: string | null;
synced_at: string | null;
};
export type FieldChange = {
field: string;
old_value?: unknown;
new_value?: unknown;
};
export type SeriesSyncReport = {
fields_updated: FieldChange[];
fields_skipped: FieldChange[];
};
export type BookSyncReport = {
book_id: string;
title: string;
volume: number | null;
fields_updated: FieldChange[];
fields_skipped: FieldChange[];
};
export type SyncReport = {
series: SeriesSyncReport | null;
books: BookSyncReport[];
books_matched: number;
books_unmatched: number;
};
export type MissingBooksDto = {
total_external: number;
total_local: number;
missing_count: number;
missing_books: {
title: string | null;
volume_number: number | null;
external_book_id: string | null;
}[];
};
export async function searchMetadata(libraryId: string, seriesName: string, provider?: string) {
return apiFetch<SeriesCandidateDto[]>("/metadata/search", {
method: "POST",
body: JSON.stringify({ library_id: libraryId, series_name: seriesName, provider: provider || undefined }),
});
}
export async function createMetadataMatch(data: {
library_id: string;
series_name: string;
provider: string;
external_id: string;
external_url?: string | null;
confidence?: number | null;
title: string;
metadata_json: Record<string, unknown>;
total_volumes?: number | null;
}) {
return apiFetch<ExternalMetadataLinkDto>("/metadata/match", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function approveMetadataMatch(id: string, syncSeries: boolean, syncBooks: boolean) {
return apiFetch<{ status: string; report: SyncReport }>(`/metadata/approve/${id}`, {
method: "POST",
body: JSON.stringify({ sync_series: syncSeries, sync_books: syncBooks }),
});
}
export async function rejectMetadataMatch(id: string) {
return apiFetch<{ status: string }>(`/metadata/reject/${id}`, {
method: "POST",
});
}
export async function getMetadataLink(libraryId: string, seriesName: string) {
const params = new URLSearchParams();
params.set("library_id", libraryId);
params.set("series_name", seriesName);
return apiFetch<ExternalMetadataLinkDto[]>(`/metadata/links?${params.toString()}`);
}
export async function getMissingBooks(linkId: string) {
return apiFetch<MissingBooksDto>(`/metadata/missing/${linkId}`);
}
export async function deleteMetadataLink(id: string) {
return apiFetch<{ deleted: boolean }>(`/metadata/links/${id}`, {
method: "DELETE",
});
}
export async function updateLibraryMetadataProvider(libraryId: string, provider: string | null) {
return apiFetch<LibraryDto>(`/libraries/${libraryId}/metadata-provider`, {
method: "PATCH",
body: JSON.stringify({ metadata_provider: provider }),
});
}

View File

@@ -1,23 +1,25 @@
{
"name": "stripstream-backoffice",
"version": "0.1.0",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stripstream-backoffice",
"version": "0.1.0",
"version": "1.4.0",
"dependencies": {
"next": "^16.1.6",
"next-themes": "^0.4.6",
"react": "19.0.0",
"react-dom": "19.0.0"
"react-dom": "19.0.0",
"sanitize-html": "^2.17.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "22.13.14",
"@types/react": "19.0.12",
"@types/react-dom": "19.0.5",
"@types/sanitize-html": "^2.16.1",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
@@ -1079,6 +1081,49 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/sanitize-html": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^10.1"
}
},
"node_modules/@types/sanitize-html/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/autoprefixer": {
"version": "10.4.27",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
@@ -1195,6 +1240,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1205,6 +1259,61 @@
"node": ">=8"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.307",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
@@ -1226,6 +1335,18 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1236,6 +1357,18 @@
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -1257,6 +1390,34 @@
"dev": true,
"license": "ISC"
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1666,6 +1827,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1676,7 +1843,6 @@
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -1729,6 +1895,20 @@
"react": "^19.0.0"
}
},
"node_modules/sanitize-html": {
"version": "2.17.1",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.1.tgz",
"integrity": "sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",

View File

@@ -11,13 +11,15 @@
"next": "^16.1.6",
"next-themes": "^0.4.6",
"react": "19.0.0",
"react-dom": "19.0.0"
"react-dom": "19.0.0",
"sanitize-html": "^2.17.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "22.13.14",
"@types/react": "19.0.12",
"@types/react-dom": "19.0.5",
"@types/sanitize-html": "^2.16.1",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",