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>
489 lines
20 KiB
TypeScript
489 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useTransition, useEffect, useCallback } from "react";
|
||
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;
|
||
currentAuthors: string[];
|
||
currentPublishers: string[];
|
||
currentBookAuthor: string | null;
|
||
currentBookLanguage: string | null;
|
||
currentDescription: string | null;
|
||
currentStartYear: number | null;
|
||
currentTotalVolumes: number | null;
|
||
currentLockedFields: Record<string, boolean>;
|
||
}
|
||
|
||
export function EditSeriesForm({
|
||
libraryId,
|
||
seriesName,
|
||
currentAuthors,
|
||
currentPublishers,
|
||
currentBookAuthor,
|
||
currentBookLanguage,
|
||
currentDescription,
|
||
currentStartYear,
|
||
currentTotalVolumes,
|
||
currentLockedFields,
|
||
}: EditSeriesFormProps) {
|
||
const router = useRouter();
|
||
const [isPending, startTransition] = useTransition();
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Champs propres à la série
|
||
const [newName, setNewName] = useState(seriesName === "unclassified" ? "" : seriesName);
|
||
const [authors, setAuthors] = useState<string[]>(currentAuthors);
|
||
const [authorInput, setAuthorInput] = useState("");
|
||
const [authorInputEl, setAuthorInputEl] = useState<HTMLInputElement | null>(null);
|
||
const [publishers, setPublishers] = useState<string[]>(currentPublishers);
|
||
const [publisherInput, setPublisherInput] = useState("");
|
||
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)) {
|
||
setAuthors([...authors, v]);
|
||
}
|
||
setAuthorInput("");
|
||
authorInputEl?.focus();
|
||
};
|
||
|
||
const removeAuthor = (idx: number) => {
|
||
setAuthors(authors.filter((_, i) => i !== idx));
|
||
};
|
||
|
||
const handleAuthorKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
addAuthor();
|
||
}
|
||
};
|
||
|
||
const addPublisher = () => {
|
||
const v = publisherInput.trim();
|
||
if (v && !publishers.includes(v)) {
|
||
setPublishers([...publishers, v]);
|
||
}
|
||
setPublisherInput("");
|
||
publisherInputEl?.focus();
|
||
};
|
||
|
||
const removePublisher = (idx: number) => {
|
||
setPublishers(publishers.filter((_, i) => i !== idx));
|
||
};
|
||
|
||
const handlePublisherKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
addPublisher();
|
||
}
|
||
};
|
||
|
||
const handleClose = useCallback(() => {
|
||
setNewName(seriesName === "unclassified" ? "" : seriesName);
|
||
setAuthors(currentAuthors);
|
||
setAuthorInput("");
|
||
setPublishers(currentPublishers);
|
||
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, currentTotalVolumes, currentBookAuthor, currentBookLanguage, currentLockedFields]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape" && !isPending) handleClose();
|
||
};
|
||
document.addEventListener("keydown", handleKeyDown);
|
||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||
}, [isOpen, isPending, handleClose]);
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!newName.trim() && seriesName !== "unclassified") return;
|
||
setError(null);
|
||
|
||
const finalAuthors = authorInput.trim()
|
||
? [...new Set([...authors, authorInput.trim()])]
|
||
: authors;
|
||
|
||
const finalPublishers = publisherInput.trim()
|
||
? [...new Set([...publishers, publisherInput.trim()])]
|
||
: publishers;
|
||
|
||
startTransition(async () => {
|
||
try {
|
||
const effectiveName = newName.trim() || "unclassified";
|
||
const body: Record<string, unknown> = {
|
||
new_name: effectiveName,
|
||
authors: finalAuthors,
|
||
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;
|
||
body.language = bookLanguage.trim() || null;
|
||
}
|
||
|
||
const res = await fetch(
|
||
`/api/libraries/${libraryId}/series/${encodeURIComponent(seriesName)}`,
|
||
{
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
}
|
||
);
|
||
if (!res.ok) {
|
||
const data = await res.json();
|
||
setError(data.error ?? "Erreur lors de la sauvegarde");
|
||
return;
|
||
}
|
||
setIsOpen(false);
|
||
|
||
if (effectiveName !== seriesName) {
|
||
router.push(`/libraries/${libraryId}/series/${encodeURIComponent(effectiveName)}` as any);
|
||
} else {
|
||
router.refresh();
|
||
}
|
||
} catch {
|
||
setError("Erreur réseau");
|
||
}
|
||
});
|
||
};
|
||
|
||
const modal = isOpen ? createPortal(
|
||
<>
|
||
{/* Backdrop */}
|
||
<div
|
||
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||
onClick={() => !isPending && handleClose()}
|
||
/>
|
||
|
||
{/* Modal */}
|
||
<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">Modifier la série</h3>
|
||
<button
|
||
type="button"
|
||
onClick={handleClose}
|
||
disabled={isPending}
|
||
className="text-muted-foreground hover:text-foreground transition-colors p-1 hover:bg-accent rounded"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<FormField>
|
||
<FormLabel required>Nom</FormLabel>
|
||
<FormInput
|
||
value={newName}
|
||
onChange={(e) => setNewName(e.target.value)}
|
||
disabled={isPending}
|
||
placeholder="Nom de la série"
|
||
/>
|
||
</FormField>
|
||
|
||
<FormField>
|
||
<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"
|
||
max="2100"
|
||
value={startYear}
|
||
onChange={(e) => setStartYear(e.target.value)}
|
||
disabled={isPending}
|
||
placeholder="ex : 1990"
|
||
/>
|
||
</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">
|
||
<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">
|
||
{authors.map((a, i) => (
|
||
<span
|
||
key={i}
|
||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium"
|
||
>
|
||
{a}
|
||
<button
|
||
type="button"
|
||
onClick={() => removeAuthor(i)}
|
||
disabled={isPending}
|
||
className="hover:text-destructive transition-colors ml-0.5"
|
||
aria-label={`Supprimer ${a}`}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="flex gap-2">
|
||
<input
|
||
ref={setAuthorInputEl}
|
||
value={authorInput}
|
||
onChange={(e) => setAuthorInput(e.target.value)}
|
||
onKeyDown={handleAuthorKeyDown}
|
||
disabled={isPending}
|
||
placeholder="Ajouter un auteur (Entrée pour valider)"
|
||
className="flex h-10 flex-1 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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={addAuthor}
|
||
disabled={isPending || !authorInput.trim()}
|
||
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowApplyToBooks(!showApplyToBooks)}
|
||
disabled={isPending}
|
||
className={`px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors ${
|
||
showApplyToBooks
|
||
? "border-primary bg-primary/10 text-primary"
|
||
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
title="Appliquer auteur et langue à tous les livres de la série"
|
||
>
|
||
→ livres
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</FormField>
|
||
|
||
{showApplyToBooks && (
|
||
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-3 pl-4 border-l-2 border-primary/30">
|
||
<FormField>
|
||
<FormLabel>Auteur (livres)</FormLabel>
|
||
<FormInput
|
||
value={bookAuthor}
|
||
onChange={(e) => setBookAuthor(e.target.value)}
|
||
disabled={isPending}
|
||
placeholder="Écrase le champ auteur de chaque livre"
|
||
/>
|
||
</FormField>
|
||
<FormField>
|
||
<FormLabel>Langue (livres)</FormLabel>
|
||
<FormInput
|
||
value={bookLanguage}
|
||
onChange={(e) => setBookLanguage(e.target.value)}
|
||
disabled={isPending}
|
||
placeholder="ex : fr, en, jp"
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
)}
|
||
|
||
{/* Éditeurs — multi-valeur */}
|
||
<FormField className="sm:col-span-2">
|
||
<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">
|
||
{publishers.map((p, i) => (
|
||
<span
|
||
key={i}
|
||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-secondary/50 text-secondary-foreground text-xs font-medium"
|
||
>
|
||
{p}
|
||
<button
|
||
type="button"
|
||
onClick={() => removePublisher(i)}
|
||
disabled={isPending}
|
||
className="hover:text-destructive transition-colors ml-0.5"
|
||
aria-label={`Supprimer ${p}`}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="flex gap-2">
|
||
<input
|
||
ref={setPublisherInputEl}
|
||
value={publisherInput}
|
||
onChange={(e) => setPublisherInput(e.target.value)}
|
||
onKeyDown={handlePublisherKeyDown}
|
||
disabled={isPending}
|
||
placeholder="Ajouter un éditeur (Entrée pour valider)"
|
||
className="flex h-10 flex-1 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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={addPublisher}
|
||
disabled={isPending || !publisherInput.trim()}
|
||
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</FormField>
|
||
|
||
<FormField className="sm:col-span-2">
|
||
<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)}
|
||
disabled={isPending}
|
||
rows={3}
|
||
placeholder="Synopsis ou description de la série…"
|
||
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-none"
|
||
/>
|
||
</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 */}
|
||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border/50">
|
||
<button
|
||
type="button"
|
||
onClick={handleClose}
|
||
disabled={isPending}
|
||
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
Annuler
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={isPending || (!newName.trim() && seriesName !== "unclassified")}
|
||
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{isPending ? "Sauvegarde…" : "Sauvegarder"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body
|
||
) : null;
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
onClick={() => setIsOpen(true)}
|
||
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"
|
||
>
|
||
<span>✏️</span> Modifier la série
|
||
</button>
|
||
{modal}
|
||
</>
|
||
);
|
||
}
|