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:
@@ -38,6 +38,7 @@ pub struct BookItem {
|
||||
pub format: Option<String>,
|
||||
pub title: String,
|
||||
pub author: Option<String>,
|
||||
pub authors: Vec<String>,
|
||||
pub series: Option<String>,
|
||||
pub volume: Option<i32>,
|
||||
pub language: Option<String>,
|
||||
@@ -69,6 +70,7 @@ pub struct BookDetails {
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub author: Option<String>,
|
||||
pub authors: Vec<String>,
|
||||
pub series: Option<String>,
|
||||
pub volume: Option<i32>,
|
||||
pub language: Option<String>,
|
||||
@@ -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::<Vec<String>, _>("authors"),
|
||||
series: row.get("series"),
|
||||
volume: row.get("volume"),
|
||||
language: row.get("language"),
|
||||
@@ -246,7 +249,7 @@ pub async fn get_book(
|
||||
) -> Result<Json<BookDetails>, 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::<Vec<String>, _>("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::<Vec<String>, _>("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<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::{
|
||||
body::Body,
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
17
apps/backoffice/app/api/books/[bookId]/route.ts
Normal file
17
apps/backoffice/app/api/books/[bookId]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
<div className="flex-1">
|
||||
<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 && (
|
||||
<p className="text-lg text-muted-foreground mb-4">by {book.author}</p>
|
||||
|
||||
@@ -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,
|
||||
|
||||
222
apps/backoffice/app/components/EditBookForm.tsx
Normal file
222
apps/backoffice/app/components/EditBookForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
361
apps/backoffice/app/components/EditSeriesForm.tsx
Normal file
361
apps/backoffice/app/components/EditSeriesForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
|
||||
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
||||
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
|
||||
import { OffsetPagination } from "../../../../components/ui";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -23,7 +24,7 @@ export default async function SeriesDetailPage({
|
||||
|
||||
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)),
|
||||
fetchBooks(id, seriesName, page, limit).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
@@ -31,6 +32,7 @@ export default async function SeriesDetailPage({
|
||||
page: 1,
|
||||
limit,
|
||||
})),
|
||||
fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null),
|
||||
]);
|
||||
|
||||
if (!library) {
|
||||
@@ -89,7 +91,24 @@ export default async function SeriesDetailPage({
|
||||
<div className="flex-1 space-y-4">
|
||||
<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">
|
||||
{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="font-semibold text-foreground">{booksPage.total}</span> livre{booksPage.total !== 1 ? "s" : ""}
|
||||
</span>
|
||||
@@ -109,12 +128,22 @@ export default async function SeriesDetailPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<MarkSeriesReadButton
|
||||
seriesName={seriesName}
|
||||
bookCount={booksPage.total}
|
||||
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>
|
||||
|
||||
@@ -62,6 +62,7 @@ export type BookDto = {
|
||||
format: string | null;
|
||||
title: string;
|
||||
author: string | null;
|
||||
authors: string[];
|
||||
series: string | null;
|
||||
volume: number | null;
|
||||
language: string | null;
|
||||
@@ -484,6 +485,54 @@ export async function fetchStats() {
|
||||
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") {
|
||||
return apiFetch<{ updated: number }>("/series/mark-read", {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user