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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
671
apps/backoffice/app/components/MetadataSearchModal.tsx
Normal file
671
apps/backoffice/app/components/MetadataSearchModal.tsx
Normal 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 "{seriesName}"...</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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
120
apps/backoffice/app/components/ProviderIcon.tsx
Normal file
120
apps/backoffice/app/components/ProviderIcon.tsx
Normal 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("_", " ");
|
||||
}
|
||||
29
apps/backoffice/app/components/SafeHtml.tsx
Normal file
29
apps/backoffice/app/components/SafeHtml.tsx
Normal 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) },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user