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>
This commit is contained in:
2026-03-16 17:21:55 +01:00
parent a085924f8a
commit bc98067871
15 changed files with 1061 additions and 8 deletions

View File

@@ -38,6 +38,7 @@ pub struct BookItem {
pub format: Option<String>, pub format: Option<String>,
pub title: String, pub title: String,
pub author: Option<String>, pub author: Option<String>,
pub authors: Vec<String>,
pub series: Option<String>, pub series: Option<String>,
pub volume: Option<i32>, pub volume: Option<i32>,
pub language: Option<String>, pub language: Option<String>,
@@ -69,6 +70,7 @@ pub struct BookDetails {
pub kind: String, pub kind: String,
pub title: String, pub title: String,
pub author: Option<String>, pub author: Option<String>,
pub authors: Vec<String>,
pub series: Option<String>, pub series: Option<String>,
pub volume: Option<i32>, pub volume: Option<i32>,
pub language: Option<String>, pub language: Option<String>,
@@ -149,7 +151,7 @@ pub async fn list_books(
let offset_p = p + 2; let offset_p = p + 2;
let data_sql = format!( let data_sql = format!(
r#" 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, COALESCE(brp.status, 'unread') AS reading_status,
brp.current_page AS reading_current_page, brp.current_page AS reading_current_page,
brp.last_read_at AS reading_last_read_at brp.last_read_at AS reading_last_read_at
@@ -204,6 +206,7 @@ pub async fn list_books(
format: row.get("format"), format: row.get("format"),
title: row.get("title"), title: row.get("title"),
author: row.get("author"), author: row.get("author"),
authors: row.get::<Vec<String>, _>("authors"),
series: row.get("series"), series: row.get("series"),
volume: row.get("volume"), volume: row.get("volume"),
language: row.get("language"), language: row.get("language"),
@@ -246,7 +249,7 @@ pub async fn get_book(
) -> Result<Json<BookDetails>, ApiError> { ) -> Result<Json<BookDetails>, ApiError> {
let row = sqlx::query( let row = sqlx::query(
r#" 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, bf.abs_path, bf.format, bf.parse_status,
COALESCE(brp.status, 'unread') AS reading_status, COALESCE(brp.status, 'unread') AS reading_status,
brp.current_page AS reading_current_page, brp.current_page AS reading_current_page,
@@ -275,6 +278,7 @@ pub async fn get_book(
kind: row.get("kind"), kind: row.get("kind"),
title: row.get("title"), title: row.get("title"),
author: row.get("author"), author: row.get("author"),
authors: row.get::<Vec<String>, _>("authors"),
series: row.get("series"), series: row.get("series"),
volume: row.get("volume"), volume: row.get("volume"),
language: row.get("language"), language: row.get("language"),
@@ -784,7 +788,7 @@ pub async fn ongoing_books(
), ),
next_books AS ( next_books AS (
SELECT 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, b.language, b.page_count, b.thumbnail_path, b.updated_at,
COALESCE(brp.status, 'unread') AS reading_status, COALESCE(brp.status, 'unread') AS reading_status,
brp.current_page AS reading_current_page, 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 LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
WHERE COALESCE(brp.status, 'unread') != 'read' 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 thumbnail_path, updated_at, reading_status, reading_current_page, reading_last_read_at
FROM next_books FROM next_books
WHERE rn = 1 WHERE rn = 1
@@ -822,6 +826,7 @@ pub async fn ongoing_books(
format: row.get("format"), format: row.get("format"),
title: row.get("title"), title: row.get("title"),
author: row.get("author"), author: row.get("author"),
authors: row.get::<Vec<String>, _>("authors"),
series: row.get("series"), series: row.get("series"),
volume: row.get("volume"), volume: row.get("volume"),
language: row.get("language"), language: row.get("language"),
@@ -945,6 +950,305 @@ pub async fn convert_book(
Ok(Json(crate::index_jobs::map_row(job_row))) Ok(Json(crate::index_jobs::map_row(job_row)))
} }
// ─── Metadata editing ─────────────────────────────────────────────────────────
#[derive(Deserialize, ToSchema)]
pub struct UpdateBookRequest {
pub title: String,
pub author: Option<String>,
#[serde(default)]
pub authors: Vec<String>,
pub series: Option<String>,
pub volume: Option<i32>,
pub language: Option<String>,
}
/// 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<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateBookRequest>,
) -> Result<Json<BookDetails>, 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<String> = 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<String> = 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::<Vec<String>, _>("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<String>,
pub description: Option<String>,
pub publishers: Vec<String>,
pub start_year: Option<i32>,
/// Convenience: author from first book (for pre-filling the per-book apply section)
pub book_author: Option<String>,
pub book_language: Option<String>,
}
/// 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<AppState>,
Path((library_id, name)): Path<(Uuid, String)>,
) -> Result<Json<SeriesMetadata>, 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::<Vec<String>, _>("authors")).unwrap_or_default(),
description: meta_row.as_ref().and_then(|r| r.get("description")),
publishers: meta_row.as_ref().map(|r| r.get::<Vec<String>, _>("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<String>,
/// Per-book author propagation: absent = keep books unchanged, present = overwrite all books
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<Option<String>>,
/// Per-book language propagation: absent = keep books unchanged, present = overwrite all books
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<Option<String>>,
pub description: Option<String>,
#[serde(default)]
pub publishers: Vec<String>,
pub start_year: Option<i32>,
}
#[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<AppState>,
Path((library_id, name)): Path<(Uuid, String)>,
Json(body): Json<UpdateSeriesRequest>,
) -> Result<Json<UpdateSeriesResponse>, 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<String> = body.publishers.iter()
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect();
let new_series_value: Option<String> = 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<String> = 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::{ use axum::{
body::Body, body::Body,
http::{header, HeaderMap, HeaderValue, StatusCode}, http::{header, HeaderMap, HeaderValue, StatusCode},

View File

@@ -84,7 +84,9 @@ async fn main() -> anyhow::Result<()> {
.route("/libraries/:id", delete(libraries::delete_library)) .route("/libraries/:id", delete(libraries::delete_library))
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library)) .route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring)) .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("/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/rebuild", axum::routing::post(index_jobs::enqueue_rebuild))
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild)) .route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
.route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate)) .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/pages/:n", get(pages::get_page))
.route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress)) .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", 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", get(books::list_all_series))
.route("/series/ongoing", get(books::ongoing_series)) .route("/series/ongoing", get(books::ongoing_series))
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read)) .route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))

View File

@@ -15,6 +15,9 @@ use utoipa::OpenApi;
crate::books::ongoing_series, crate::books::ongoing_series,
crate::books::ongoing_books, crate::books::ongoing_books,
crate::books::convert_book, crate::books::convert_book,
crate::books::update_book,
crate::books::get_series_metadata,
crate::books::update_series,
crate::pages::get_page, crate::pages::get_page,
crate::search::search_books, crate::search::search_books,
crate::index_jobs::enqueue_rebuild, crate::index_jobs::enqueue_rebuild,
@@ -58,6 +61,10 @@ use utoipa::OpenApi;
crate::books::SeriesPage, crate::books::SeriesPage,
crate::books::ListAllSeriesQuery, crate::books::ListAllSeriesQuery,
crate::books::OngoingQuery, crate::books::OngoingQuery,
crate::books::UpdateBookRequest,
crate::books::SeriesMetadata,
crate::books::UpdateSeriesRequest,
crate::books::UpdateSeriesResponse,
crate::pages::PageQuery, crate::pages::PageQuery,
crate::search::SearchQuery, crate::search::SearchQuery,
crate::search::SearchResponse, crate::search::SearchResponse,

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -2,6 +2,7 @@ import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } fro
import { BookPreview } from "../../components/BookPreview"; import { BookPreview } from "../../components/BookPreview";
import { ConvertButton } from "../../components/ConvertButton"; import { ConvertButton } from "../../components/ConvertButton";
import { MarkBookReadButton } from "../../components/MarkBookReadButton"; import { MarkBookReadButton } from "../../components/MarkBookReadButton";
import { EditBookForm } from "../../components/EditBookForm";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
@@ -89,7 +90,10 @@ export default async function BookDetailPage({
<div className="flex-1"> <div className="flex-1">
<div className="bg-card rounded-xl shadow-sm border border-border p-6"> <div className="bg-card rounded-xl shadow-sm border border-border p-6">
<h1 className="text-3xl font-bold text-foreground mb-2">{book.title}</h1> <div className="flex items-start justify-between gap-4 mb-2">
<h1 className="text-3xl font-bold text-foreground">{book.title}</h1>
<EditBookForm book={book} />
</div>
{book.author && ( {book.author && (
<p className="text-lg text-muted-foreground mb-4">by {book.author}</p> <p className="text-lg text-muted-foreground mb-4">by {book.author}</p>

View File

@@ -40,6 +40,7 @@ export default async function BooksPage({
kind: hit.kind, kind: hit.kind,
title: hit.title, title: hit.title,
author: hit.author, author: hit.author,
authors: [],
series: hit.series, series: hit.series,
volume: hit.volume, volume: hit.volume,
language: hit.language, language: hit.language,

View File

@@ -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<string | null>(null);
const [title, setTitle] = useState(book.title);
const [authors, setAuthors] = useState<string[]>(book.authors ?? []);
const [authorInput, setAuthorInput] = useState("");
const [authorInputEl, setAuthorInputEl] = useState<HTMLInputElement | null>(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 (
<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
</button>
);
}
return (
<form onSubmit={handleSubmit} className="mt-4 p-4 border border-border rounded-xl bg-muted/30 space-y-4">
<h3 className="text-sm font-semibold text-foreground">Modifier les métadonnées</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField className="sm:col-span-2">
<FormLabel required>Titre</FormLabel>
<FormInput
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isPending}
placeholder="Titre du livre"
/>
</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>
</div>
</div>
</FormField>
<FormField>
<FormLabel>Langue</FormLabel>
<FormInput
value={language}
onChange={(e) => setLanguage(e.target.value)}
disabled={isPending}
placeholder="ex : fr, en, jp"
/>
</FormField>
<FormField>
<FormLabel>Série</FormLabel>
<FormInput
value={series}
onChange={(e) => setSeries(e.target.value)}
disabled={isPending}
placeholder="Nom de la série"
/>
</FormField>
<FormField>
<FormLabel>Volume</FormLabel>
<FormInput
type="number"
min="1"
value={volume}
onChange={(e) => setVolume(e.target.value)}
disabled={isPending}
placeholder="Numéro de volume"
/>
</FormField>
</div>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
<div className="flex items-center gap-2">
<button
type="submit"
disabled={isPending || !title.trim()}
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>
);
}

View File

@@ -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<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>
);
}

View File

@@ -1,7 +1,8 @@
import { fetchLibraries, fetchBooks, getBookCoverUrl, BookDto } from "../../../../../lib/api"; import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, BookDto, SeriesMetadataDto } from "../../../../../lib/api";
import { BooksGrid, EmptyState } from "../../../../components/BookCard"; import { BooksGrid, EmptyState } from "../../../../components/BookCard";
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton"; import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton"; import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
import { OffsetPagination } from "../../../../components/ui"; import { OffsetPagination } from "../../../../components/ui";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -23,7 +24,7 @@ export default async function SeriesDetailPage({
const seriesName = decodeURIComponent(name); const seriesName = decodeURIComponent(name);
const [library, booksPage] = await Promise.all([ const [library, booksPage, seriesMeta] = await Promise.all([
fetchLibraries().then((libs) => libs.find((l) => l.id === id)), fetchLibraries().then((libs) => libs.find((l) => l.id === id)),
fetchBooks(id, seriesName, page, limit).catch(() => ({ fetchBooks(id, seriesName, page, limit).catch(() => ({
items: [] as BookDto[], items: [] as BookDto[],
@@ -31,6 +32,7 @@ export default async function SeriesDetailPage({
page: 1, page: 1,
limit, limit,
})), })),
fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null),
]); ]);
if (!library) { if (!library) {
@@ -89,7 +91,24 @@ export default async function SeriesDetailPage({
<div className="flex-1 space-y-4"> <div className="flex-1 space-y-4">
<h1 className="text-3xl font-bold text-foreground">{displayName}</h1> <h1 className="text-3xl font-bold text-foreground">{displayName}</h1>
{seriesMeta && seriesMeta.authors.length > 0 && (
<p className="text-base text-muted-foreground">{seriesMeta.authors.join(", ")}</p>
)}
{seriesMeta?.description && (
<p className="text-sm text-muted-foreground leading-relaxed">{seriesMeta.description}</p>
)}
<div className="flex flex-wrap items-center gap-4 text-sm"> <div className="flex flex-wrap items-center gap-4 text-sm">
{seriesMeta && seriesMeta.publishers.length > 0 && (
<span className="text-muted-foreground">
<span className="font-semibold text-foreground">{seriesMeta.publishers.join(", ")}</span>
</span>
)}
{seriesMeta?.start_year && (
<span className="text-muted-foreground">{seriesMeta.start_year}</span>
)}
{((seriesMeta && seriesMeta.publishers.length > 0) || seriesMeta?.start_year) && <span className="w-px h-4 bg-border" />}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
<span className="font-semibold text-foreground">{booksPage.total}</span> livre{booksPage.total !== 1 ? "s" : ""} <span className="font-semibold text-foreground">{booksPage.total}</span> livre{booksPage.total !== 1 ? "s" : ""}
</span> </span>
@@ -109,12 +128,22 @@ export default async function SeriesDetailPage({
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<MarkSeriesReadButton <MarkSeriesReadButton
seriesName={seriesName} seriesName={seriesName}
bookCount={booksPage.total} bookCount={booksPage.total}
booksReadCount={booksReadCount} booksReadCount={booksReadCount}
/> />
<EditSeriesForm
libraryId={id}
seriesName={seriesName}
currentAuthors={seriesMeta?.authors ?? []}
currentPublishers={seriesMeta?.publishers ?? []}
currentBookAuthor={seriesMeta?.book_author ?? booksPage.items[0]?.author ?? null}
currentBookLanguage={seriesMeta?.book_language ?? booksPage.items[0]?.language ?? null}
currentDescription={seriesMeta?.description ?? null}
currentStartYear={seriesMeta?.start_year ?? null}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -62,6 +62,7 @@ export type BookDto = {
format: string | null; format: string | null;
title: string; title: string;
author: string | null; author: string | null;
authors: string[];
series: string | null; series: string | null;
volume: number | null; volume: number | null;
language: string | null; language: string | null;
@@ -484,6 +485,54 @@ export async function fetchStats() {
return apiFetch<StatsResponse>("/stats"); return apiFetch<StatsResponse>("/stats");
} }
export type UpdateBookRequest = {
title: string;
author: string | null;
authors: string[];
series: string | null;
volume: number | null;
language: string | null;
};
export async function updateBook(bookId: string, data: UpdateBookRequest) {
return apiFetch<BookDto>(`/books/${bookId}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
export type SeriesMetadataDto = {
authors: string[];
description: string | null;
publishers: string[];
start_year: number | null;
book_author: string | null;
book_language: string | null;
};
export async function fetchSeriesMetadata(libraryId: string, seriesName: string) {
return apiFetch<SeriesMetadataDto>(
`/libraries/${libraryId}/series/${encodeURIComponent(seriesName)}/metadata`
);
}
export type UpdateSeriesRequest = {
new_name: string;
authors: string[];
author?: string | null;
language?: string | null;
description: string | null;
publishers: string[];
start_year: number | null;
};
export async function updateSeries(libraryId: string, seriesName: string, data: UpdateSeriesRequest) {
return apiFetch<{ updated: number }>(`/libraries/${libraryId}/series/${encodeURIComponent(seriesName)}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
export async function markSeriesRead(seriesName: string, status: "read" | "unread" = "read") { export async function markSeriesRead(seriesName: string, status: "read" | "unread" = "read") {
return apiFetch<{ updated: number }>("/series/mark-read", { return apiFetch<{ updated: number }>("/series/mark-read", {
method: "POST", method: "POST",

View File

@@ -0,0 +1,10 @@
CREATE TABLE series_metadata (
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
publisher TEXT,
start_year INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (library_id, name)
);

View File

@@ -0,0 +1,8 @@
ALTER TABLE series_metadata
ADD COLUMN authors TEXT[] NOT NULL DEFAULT '{}',
ADD COLUMN publishers TEXT[] NOT NULL DEFAULT '{}';
-- Migrate existing scalar publisher → publishers array
UPDATE series_metadata SET publishers = ARRAY[publisher] WHERE publisher IS NOT NULL AND publisher != '';
ALTER TABLE series_metadata DROP COLUMN publisher;

View File

@@ -0,0 +1,5 @@
ALTER TABLE books
ADD COLUMN authors TEXT[] NOT NULL DEFAULT '{}';
-- Migrate existing scalar author → authors array
UPDATE books SET authors = ARRAY[author] WHERE author IS NOT NULL AND author != '';