Files
stripstream-librarian/apps/backoffice/app/components/EditSeriesForm.tsx
Froidefond Julien bc98067871 feat(books): édition des métadonnées livres et séries + champ authors multi-valeurs
- Nouveaux endpoints PATCH /books/:id et PATCH /libraries/:id/series/:name pour éditer les métadonnées
- GET /libraries/:id/series/:name/metadata pour récupérer les métadonnées de série
- Ajout du champ `authors` (Vec<String>) sur les structs Book/BookDetails
- 3 migrations : table series_metadata, colonne authors sur series_metadata et books
- Composants EditBookForm et EditSeriesForm dans le backoffice
- Routes API Next.js correspondantes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 17:21:55 +01:00

362 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { FormField, FormLabel, FormInput } from "./ui/Form";
interface EditSeriesFormProps {
libraryId: string;
seriesName: string;
currentAuthors: string[];
currentPublishers: string[];
currentBookAuthor: string | null;
currentBookLanguage: string | null;
currentDescription: string | null;
currentStartYear: number | null;
}
export function EditSeriesForm({
libraryId,
seriesName,
currentAuthors,
currentPublishers,
currentBookAuthor,
currentBookLanguage,
currentDescription,
currentStartYear,
}: EditSeriesFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isEditing, setIsEditing] = 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() ?? "");
// Propagation aux livres — opt-in via bouton
const [bookAuthor, setBookAuthor] = useState(currentBookAuthor ?? "");
const [bookLanguage, setBookLanguage] = useState(currentBookLanguage ?? "");
const [showApplyToBooks, setShowApplyToBooks] = useState(false);
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 handleCancel = () => {
setNewName(seriesName === "unclassified" ? "" : seriesName);
setAuthors(currentAuthors);
setAuthorInput("");
setPublishers(currentPublishers);
setPublisherInput("");
setDescription(currentDescription ?? "");
setStartYear(currentStartYear?.toString() ?? "");
setShowApplyToBooks(false);
setBookAuthor(currentBookAuthor ?? "");
setBookLanguage(currentBookLanguage ?? "");
setError(null);
setIsEditing(false);
};
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,
};
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;
}
setIsEditing(false);
if (effectiveName !== seriesName) {
router.push(`/libraries/${libraryId}/series/${encodeURIComponent(effectiveName)}` as any);
} else {
router.refresh();
}
} catch {
setError("Erreur réseau");
}
});
};
if (!isEditing) {
return (
<button
onClick={() => setIsEditing(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>
);
}
return (
<form onSubmit={handleSubmit} className="w-full p-4 border border-border rounded-xl bg-muted/30 space-y-5">
<h3 className="text-sm font-semibold text-foreground">Modifier les métadonnées de la série</h3>
<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>
<FormLabel>Année de début</FormLabel>
<FormInput
type="number"
min="1900"
max="2100"
value={startYear}
onChange={(e) => setStartYear(e.target.value)}
disabled={isPending}
placeholder="ex : 1990"
/>
</FormField>
{/* Auteurs — multi-valeur */}
<FormField className="sm:col-span-2">
<FormLabel>Auteur(s)</FormLabel>
<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">
<FormLabel>Éditeur(s)</FormLabel>
<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">
<FormLabel>Description</FormLabel>
<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>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex items-center gap-2">
<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>
<button
type="button"
onClick={handleCancel}
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>
</div>
</form>
);
}