From bc9806787175f3cf884aedb8d7e2d285884c8e78 Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Mon, 16 Mar 2026 17:21:55 +0100 Subject: [PATCH] =?UTF-8?q?feat(books):=20=C3=A9dition=20des=20m=C3=A9tado?= =?UTF-8?q?nn=C3=A9es=20livres=20et=20s=C3=A9ries=20+=20champ=20authors=20?= =?UTF-8?q?multi-valeurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) 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 --- apps/api/src/books.rs | 312 ++++++++++++++- apps/api/src/main.rs | 3 + apps/api/src/openapi.rs | 7 + .../app/api/books/[bookId]/route.ts | 17 + .../[id]/series/[name]/metadata/route.ts | 16 + .../api/libraries/[id]/series/[name]/route.ts | 17 + apps/backoffice/app/books/[id]/page.tsx | 6 +- apps/backoffice/app/books/page.tsx | 1 + .../app/components/EditBookForm.tsx | 222 +++++++++++ .../app/components/EditSeriesForm.tsx | 361 ++++++++++++++++++ .../app/libraries/[id]/series/[name]/page.tsx | 35 +- apps/backoffice/lib/api.ts | 49 +++ infra/migrations/0021_add_series_metadata.sql | 10 + .../0022_add_authors_to_series_metadata.sql | 8 + .../migrations/0023_add_authors_to_books.sql | 5 + 15 files changed, 1061 insertions(+), 8 deletions(-) create mode 100644 apps/backoffice/app/api/books/[bookId]/route.ts create mode 100644 apps/backoffice/app/api/libraries/[id]/series/[name]/metadata/route.ts create mode 100644 apps/backoffice/app/api/libraries/[id]/series/[name]/route.ts create mode 100644 apps/backoffice/app/components/EditBookForm.tsx create mode 100644 apps/backoffice/app/components/EditSeriesForm.tsx create mode 100644 infra/migrations/0021_add_series_metadata.sql create mode 100644 infra/migrations/0022_add_authors_to_series_metadata.sql create mode 100644 infra/migrations/0023_add_authors_to_books.sql diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index 43c5dbe..0e87b56 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -38,6 +38,7 @@ pub struct BookItem { pub format: Option, pub title: String, pub author: Option, + pub authors: Vec, pub series: Option, pub volume: Option, pub language: Option, @@ -69,6 +70,7 @@ pub struct BookDetails { pub kind: String, pub title: String, pub author: Option, + pub authors: Vec, pub series: Option, pub volume: Option, pub language: Option, @@ -149,7 +151,7 @@ pub async fn list_books( let offset_p = p + 2; let data_sql = format!( r#" - SELECT b.id, b.library_id, b.kind, b.format, b.title, b.author, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at, + SELECT b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at, COALESCE(brp.status, 'unread') AS reading_status, brp.current_page AS reading_current_page, brp.last_read_at AS reading_last_read_at @@ -204,6 +206,7 @@ pub async fn list_books( format: row.get("format"), title: row.get("title"), author: row.get("author"), + authors: row.get::, _>("authors"), series: row.get("series"), volume: row.get("volume"), language: row.get("language"), @@ -246,7 +249,7 @@ pub async fn get_book( ) -> Result, ApiError> { let row = sqlx::query( r#" - SELECT b.id, b.library_id, b.kind, b.title, b.author, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, + SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, bf.abs_path, bf.format, bf.parse_status, COALESCE(brp.status, 'unread') AS reading_status, brp.current_page AS reading_current_page, @@ -275,6 +278,7 @@ pub async fn get_book( kind: row.get("kind"), title: row.get("title"), author: row.get("author"), + authors: row.get::, _>("authors"), series: row.get("series"), volume: row.get("volume"), language: row.get("language"), @@ -784,7 +788,7 @@ pub async fn ongoing_books( ), next_books AS ( SELECT - b.id, b.library_id, b.kind, b.format, b.title, b.author, b.series, b.volume, + b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at, COALESCE(brp.status, 'unread') AS reading_status, brp.current_page AS reading_current_page, @@ -799,7 +803,7 @@ pub async fn ongoing_books( LEFT JOIN book_reading_progress brp ON brp.book_id = b.id WHERE COALESCE(brp.status, 'unread') != 'read' ) - SELECT id, library_id, kind, format, title, author, series, volume, language, page_count, + SELECT id, library_id, kind, format, title, author, authors, series, volume, language, page_count, thumbnail_path, updated_at, reading_status, reading_current_page, reading_last_read_at FROM next_books WHERE rn = 1 @@ -822,6 +826,7 @@ pub async fn ongoing_books( format: row.get("format"), title: row.get("title"), author: row.get("author"), + authors: row.get::, _>("authors"), series: row.get("series"), volume: row.get("volume"), language: row.get("language"), @@ -945,6 +950,305 @@ pub async fn convert_book( Ok(Json(crate::index_jobs::map_row(job_row))) } +// ─── Metadata editing ───────────────────────────────────────────────────────── + +#[derive(Deserialize, ToSchema)] +pub struct UpdateBookRequest { + pub title: String, + pub author: Option, + #[serde(default)] + pub authors: Vec, + pub series: Option, + pub volume: Option, + pub language: Option, +} + +/// Update metadata for a specific book +#[utoipa::path( + patch, + path = "/books/{id}", + tag = "books", + params(("id" = String, Path, description = "Book UUID")), + request_body = UpdateBookRequest, + responses( + (status = 200, body = BookDetails), + (status = 400, description = "Invalid request"), + (status = 404, description = "Book not found"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin scope required"), + ), + security(("Bearer" = [])) +)] +pub async fn update_book( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + let title = body.title.trim().to_string(); + if title.is_empty() { + return Err(ApiError::bad_request("title cannot be empty")); + } + let author = body.author.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); + let authors: Vec = body.authors.iter() + .map(|a| a.trim().to_string()) + .filter(|a| !a.is_empty()) + .collect(); + let series = body.series.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); + let language = body.language.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); + + let row = sqlx::query( + r#" + UPDATE books + SET title = $2, author = $3, authors = $4, series = $5, volume = $6, language = $7, updated_at = NOW() + WHERE id = $1 + RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path, + COALESCE((SELECT status FROM book_reading_progress WHERE book_id = $1), 'unread') AS reading_status, + (SELECT current_page FROM book_reading_progress WHERE book_id = $1) AS reading_current_page, + (SELECT last_read_at FROM book_reading_progress WHERE book_id = $1) AS reading_last_read_at + "#, + ) + .bind(id) + .bind(&title) + .bind(&author) + .bind(&authors) + .bind(&series) + .bind(body.volume) + .bind(&language) + .fetch_optional(&state.pool) + .await?; + + let row = row.ok_or_else(|| ApiError::not_found("book not found"))?; + let thumbnail_path: Option = row.get("thumbnail_path"); + + Ok(Json(BookDetails { + id: row.get("id"), + library_id: row.get("library_id"), + kind: row.get("kind"), + title: row.get("title"), + author: row.get("author"), + authors: row.get::, _>("authors"), + series: row.get("series"), + volume: row.get("volume"), + language: row.get("language"), + page_count: row.get("page_count"), + thumbnail_url: thumbnail_path.map(|_| format!("/books/{}/thumbnail", id)), + file_path: None, + file_format: None, + file_parse_status: None, + reading_status: row.get("reading_status"), + reading_current_page: row.get("reading_current_page"), + reading_last_read_at: row.get("reading_last_read_at"), + })) +} + +#[derive(Serialize, ToSchema)] +pub struct SeriesMetadata { + /// Authors of the series (series-level metadata, distinct from per-book author field) + pub authors: Vec, + pub description: Option, + pub publishers: Vec, + pub start_year: Option, + /// Convenience: author from first book (for pre-filling the per-book apply section) + pub book_author: Option, + pub book_language: Option, +} + +/// Get metadata for a specific series +#[utoipa::path( + get, + path = "/libraries/{library_id}/series/{name}/metadata", + tag = "books", + params( + ("library_id" = String, Path, description = "Library UUID"), + ("name" = String, Path, description = "Series name"), + ), + responses( + (status = 200, body = SeriesMetadata), + (status = 401, description = "Unauthorized"), + ), + security(("Bearer" = [])) +)] +pub async fn get_series_metadata( + State(state): State, + Path((library_id, name)): Path<(Uuid, String)>, +) -> Result, ApiError> { + // author/language from first book of series + let books_row = if name == "unclassified" { + sqlx::query("SELECT author, language FROM books WHERE library_id = $1 AND (series IS NULL OR series = '') LIMIT 1") + .bind(library_id) + .fetch_optional(&state.pool) + .await? + } else { + sqlx::query("SELECT author, language FROM books WHERE library_id = $1 AND series = $2 LIMIT 1") + .bind(library_id) + .bind(&name) + .fetch_optional(&state.pool) + .await? + }; + + let meta_row = sqlx::query( + "SELECT authors, description, publishers, start_year FROM series_metadata WHERE library_id = $1 AND name = $2" + ) + .bind(library_id) + .bind(&name) + .fetch_optional(&state.pool) + .await?; + + Ok(Json(SeriesMetadata { + authors: meta_row.as_ref().map(|r| r.get::, _>("authors")).unwrap_or_default(), + description: meta_row.as_ref().and_then(|r| r.get("description")), + publishers: meta_row.as_ref().map(|r| r.get::, _>("publishers")).unwrap_or_default(), + start_year: meta_row.as_ref().and_then(|r| r.get("start_year")), + book_author: books_row.as_ref().and_then(|r| r.get("author")), + book_language: books_row.as_ref().and_then(|r| r.get("language")), + })) +} + +/// `author` and `language` are wrapped in an extra Option so we can distinguish +/// "absent from JSON" (keep books unchanged) from "present as null" (clear the field). +#[derive(Deserialize, ToSchema)] +pub struct UpdateSeriesRequest { + pub new_name: String, + /// Series-level authors list (stored in series_metadata) + #[serde(default)] + pub authors: Vec, + /// Per-book author propagation: absent = keep books unchanged, present = overwrite all books + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option>, + /// Per-book language propagation: absent = keep books unchanged, present = overwrite all books + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option>, + pub description: Option, + #[serde(default)] + pub publishers: Vec, + pub start_year: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct UpdateSeriesResponse { + pub updated: u64, +} + +/// Update metadata for all books in a series +#[utoipa::path( + patch, + path = "/libraries/{library_id}/series/{name}", + tag = "books", + params( + ("library_id" = String, Path, description = "Library UUID"), + ("name" = String, Path, description = "Series name (use 'unclassified' for books without series)"), + ), + request_body = UpdateSeriesRequest, + responses( + (status = 200, body = UpdateSeriesResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin scope required"), + ), + security(("Bearer" = [])) +)] +pub async fn update_series( + State(state): State, + Path((library_id, name)): Path<(Uuid, String)>, + Json(body): Json, +) -> Result, ApiError> { + let new_name = body.new_name.trim().to_string(); + if new_name.is_empty() { + return Err(ApiError::bad_request("series name cannot be empty")); + } + // author/language: None = absent (keep books unchanged), Some(v) = apply to all books + let apply_author = body.author.is_some(); + let author_value = body.author.flatten().as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); + let apply_language = body.language.is_some(); + let language_value = body.language.flatten().as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); + let description = body.description.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); + let publishers: Vec = body.publishers.iter() + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect(); + let new_series_value: Option = if new_name == "unclassified" { None } else { Some(new_name.clone()) }; + + // 1. Update books: always update series name; author/language only if opted-in + // $1=library_id, $2=new_series_value, $3=apply_author, $4=author_value, + // $5=apply_language, $6=language_value, [$7=old_name] + let result = if name == "unclassified" { + sqlx::query( + "UPDATE books \ + SET series = $2, \ + author = CASE WHEN $3 THEN $4 ELSE author END, \ + language = CASE WHEN $5 THEN $6 ELSE language END, \ + updated_at = NOW() \ + WHERE library_id = $1 AND (series IS NULL OR series = '')" + ) + .bind(library_id) + .bind(&new_series_value) + .bind(apply_author) + .bind(&author_value) + .bind(apply_language) + .bind(&language_value) + .execute(&state.pool) + .await? + } else { + sqlx::query( + "UPDATE books \ + SET series = $2, \ + author = CASE WHEN $3 THEN $4 ELSE author END, \ + language = CASE WHEN $5 THEN $6 ELSE language END, \ + updated_at = NOW() \ + WHERE library_id = $1 AND series = $7" + ) + .bind(library_id) + .bind(&new_series_value) + .bind(apply_author) + .bind(&author_value) + .bind(apply_language) + .bind(&language_value) + .bind(&name) + .execute(&state.pool) + .await? + }; + + // 2. Upsert series_metadata (keyed by new_name) + let meta_name = new_series_value.as_deref().unwrap_or("unclassified"); + let authors: Vec = body.authors.iter() + .map(|a| a.trim().to_string()) + .filter(|a| !a.is_empty()) + .collect(); + sqlx::query( + r#" + INSERT INTO series_metadata (library_id, name, authors, description, publishers, start_year, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + ON CONFLICT (library_id, name) DO UPDATE + SET authors = EXCLUDED.authors, + description = EXCLUDED.description, + publishers = EXCLUDED.publishers, + start_year = EXCLUDED.start_year, + updated_at = NOW() + "# + ) + .bind(library_id) + .bind(meta_name) + .bind(&authors) + .bind(&description) + .bind(&publishers) + .bind(body.start_year) + .execute(&state.pool) + .await?; + + // 3. If renamed, move series_metadata from old name to new name + if name != "unclassified" && new_name != name { + sqlx::query( + "DELETE FROM series_metadata WHERE library_id = $1 AND name = $2" + ) + .bind(library_id) + .bind(&name) + .execute(&state.pool) + .await?; + } + + Ok(Json(UpdateSeriesResponse { updated: result.rows_affected() })) +} + use axum::{ body::Body, http::{header, HeaderMap, HeaderValue, StatusCode}, diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 9892451..9cf816c 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -84,7 +84,9 @@ async fn main() -> anyhow::Result<()> { .route("/libraries/:id", delete(libraries::delete_library)) .route("/libraries/:id/scan", axum::routing::post(libraries::scan_library)) .route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring)) + .route("/books/:id", axum::routing::patch(books::update_book)) .route("/books/:id/convert", axum::routing::post(books::convert_book)) + .route("/libraries/:library_id/series/:name", axum::routing::patch(books::update_series)) .route("/index/rebuild", axum::routing::post(index_jobs::enqueue_rebuild)) .route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild)) .route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate)) @@ -112,6 +114,7 @@ async fn main() -> anyhow::Result<()> { .route("/books/:id/pages/:n", get(pages::get_page)) .route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress)) .route("/libraries/:library_id/series", get(books::list_series)) + .route("/libraries/:library_id/series/:name/metadata", get(books::get_series_metadata)) .route("/series", get(books::list_all_series)) .route("/series/ongoing", get(books::ongoing_series)) .route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read)) diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index b090549..5efde5f 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -15,6 +15,9 @@ use utoipa::OpenApi; crate::books::ongoing_series, crate::books::ongoing_books, crate::books::convert_book, + crate::books::update_book, + crate::books::get_series_metadata, + crate::books::update_series, crate::pages::get_page, crate::search::search_books, crate::index_jobs::enqueue_rebuild, @@ -58,6 +61,10 @@ use utoipa::OpenApi; crate::books::SeriesPage, crate::books::ListAllSeriesQuery, crate::books::OngoingQuery, + crate::books::UpdateBookRequest, + crate::books::SeriesMetadata, + crate::books::UpdateSeriesRequest, + crate::books::UpdateSeriesResponse, crate::pages::PageQuery, crate::search::SearchQuery, crate::search::SearchResponse, diff --git a/apps/backoffice/app/api/books/[bookId]/route.ts b/apps/backoffice/app/api/books/[bookId]/route.ts new file mode 100644 index 0000000..f6160fb --- /dev/null +++ b/apps/backoffice/app/api/books/[bookId]/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; +import { updateBook } from "@/lib/api"; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ bookId: string }> } +) { + const { bookId } = await params; + try { + const body = await request.json(); + const data = await updateBook(bookId, body); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to update book"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/api/libraries/[id]/series/[name]/metadata/route.ts b/apps/backoffice/app/api/libraries/[id]/series/[name]/metadata/route.ts new file mode 100644 index 0000000..d46fe37 --- /dev/null +++ b/apps/backoffice/app/api/libraries/[id]/series/[name]/metadata/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { fetchSeriesMetadata } from "@/lib/api"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string; name: string }> } +) { + const { id, name } = await params; + try { + const data = await fetchSeriesMetadata(id, name); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch series metadata"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/api/libraries/[id]/series/[name]/route.ts b/apps/backoffice/app/api/libraries/[id]/series/[name]/route.ts new file mode 100644 index 0000000..616949e --- /dev/null +++ b/apps/backoffice/app/api/libraries/[id]/series/[name]/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; +import { updateSeries } from "@/lib/api"; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string; name: string }> } +) { + const { id, name } = await params; + try { + const body = await request.json(); + const data = await updateSeries(id, name, body); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to update series"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/books/[id]/page.tsx b/apps/backoffice/app/books/[id]/page.tsx index 0b7286b..bd78ad1 100644 --- a/apps/backoffice/app/books/[id]/page.tsx +++ b/apps/backoffice/app/books/[id]/page.tsx @@ -2,6 +2,7 @@ import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } fro import { BookPreview } from "../../components/BookPreview"; import { ConvertButton } from "../../components/ConvertButton"; import { MarkBookReadButton } from "../../components/MarkBookReadButton"; +import { EditBookForm } from "../../components/EditBookForm"; import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; @@ -89,7 +90,10 @@ export default async function BookDetailPage({
-

{book.title}

+
+

{book.title}

+ +
{book.author && (

by {book.author}

diff --git a/apps/backoffice/app/books/page.tsx b/apps/backoffice/app/books/page.tsx index 0da44f8..27d320e 100644 --- a/apps/backoffice/app/books/page.tsx +++ b/apps/backoffice/app/books/page.tsx @@ -40,6 +40,7 @@ export default async function BooksPage({ kind: hit.kind, title: hit.title, author: hit.author, + authors: [], series: hit.series, volume: hit.volume, language: hit.language, diff --git a/apps/backoffice/app/components/EditBookForm.tsx b/apps/backoffice/app/components/EditBookForm.tsx new file mode 100644 index 0000000..1f71b02 --- /dev/null +++ b/apps/backoffice/app/components/EditBookForm.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { BookDto } from "@/lib/api"; +import { FormField, FormLabel, FormInput } from "./ui/Form"; + +interface EditBookFormProps { + book: BookDto; +} + +export function EditBookForm({ book }: EditBookFormProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [isEditing, setIsEditing] = useState(false); + const [error, setError] = useState(null); + + const [title, setTitle] = useState(book.title); + const [authors, setAuthors] = useState(book.authors ?? []); + const [authorInput, setAuthorInput] = useState(""); + const [authorInputEl, setAuthorInputEl] = useState(null); + const [series, setSeries] = useState(book.series ?? ""); + const [volume, setVolume] = useState(book.volume?.toString() ?? ""); + const [language, setLanguage] = useState(book.language ?? ""); + + 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 handleCancel = () => { + setTitle(book.title); + setAuthors(book.authors ?? []); + setAuthorInput(""); + setSeries(book.series ?? ""); + setVolume(book.volume?.toString() ?? ""); + setLanguage(book.language ?? ""); + setError(null); + setIsEditing(false); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) return; + setError(null); + + const finalAuthors = authorInput.trim() + ? [...new Set([...authors, authorInput.trim()])] + : authors; + + startTransition(async () => { + try { + const res = await fetch(`/api/books/${book.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: title.trim(), + author: finalAuthors[0] ?? null, + authors: finalAuthors, + series: series.trim() || null, + volume: volume.trim() ? parseInt(volume.trim(), 10) : null, + language: language.trim() || null, + }), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.error ?? "Erreur lors de la sauvegarde"); + return; + } + setIsEditing(false); + router.refresh(); + } catch { + setError("Erreur réseau"); + } + }); + }; + + if (!isEditing) { + return ( + + ); + } + + return ( +
+

Modifier les métadonnées

+ +
+ + Titre + setTitle(e.target.value)} + disabled={isPending} + placeholder="Titre du livre" + /> + + + {/* Auteurs — multi-valeur */} + + Auteur(s) +
+ {authors.length > 0 && ( +
+ {authors.map((a, i) => ( + + {a} + + + ))} +
+ )} +
+ 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" + /> + +
+
+
+ + + Langue + setLanguage(e.target.value)} + disabled={isPending} + placeholder="ex : fr, en, jp" + /> + + + + Série + setSeries(e.target.value)} + disabled={isPending} + placeholder="Nom de la série" + /> + + + + Volume + setVolume(e.target.value)} + disabled={isPending} + placeholder="Numéro de volume" + /> + +
+ + {error && ( +

{error}

+ )} + +
+ + +
+
+ ); +} diff --git a/apps/backoffice/app/components/EditSeriesForm.tsx b/apps/backoffice/app/components/EditSeriesForm.tsx new file mode 100644 index 0000000..bd3f4de --- /dev/null +++ b/apps/backoffice/app/components/EditSeriesForm.tsx @@ -0,0 +1,361 @@ +"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(null); + + // Champs propres à la série + const [newName, setNewName] = useState(seriesName === "unclassified" ? "" : seriesName); + const [authors, setAuthors] = useState(currentAuthors); + const [authorInput, setAuthorInput] = useState(""); + const [authorInputEl, setAuthorInputEl] = useState(null); + const [publishers, setPublishers] = useState(currentPublishers); + const [publisherInput, setPublisherInput] = useState(""); + const [publisherInputEl, setPublisherInputEl] = useState(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 = { + 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 ( + + ); + } + + return ( +
+

Modifier les métadonnées de la série

+ +
+ + Nom + setNewName(e.target.value)} + disabled={isPending} + placeholder="Nom de la série" + /> + + + + Année de début + setStartYear(e.target.value)} + disabled={isPending} + placeholder="ex : 1990" + /> + + + {/* Auteurs — multi-valeur */} + + Auteur(s) +
+ {authors.length > 0 && ( +
+ {authors.map((a, i) => ( + + {a} + + + ))} +
+ )} +
+ 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" + /> + + +
+
+
+ + {showApplyToBooks && ( +
+ + Auteur (livres) + setBookAuthor(e.target.value)} + disabled={isPending} + placeholder="Écrase le champ auteur de chaque livre" + /> + + + Langue (livres) + setBookLanguage(e.target.value)} + disabled={isPending} + placeholder="ex : fr, en, jp" + /> + +
+ )} + + {/* Éditeurs — multi-valeur */} + + Éditeur(s) +
+ {publishers.length > 0 && ( +
+ {publishers.map((p, i) => ( + + {p} + + + ))} +
+ )} +
+ 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" + /> + +
+
+
+ + + Description +