diff --git a/apps/api/src/authors.rs b/apps/api/src/authors.rs new file mode 100644 index 0000000..44b6fed --- /dev/null +++ b/apps/api/src/authors.rs @@ -0,0 +1,156 @@ +use axum::{extract::{Query, State}, Json}; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use utoipa::ToSchema; + +use crate::{error::ApiError, state::AppState}; + +#[derive(Deserialize, ToSchema)] +pub struct ListAuthorsQuery { + #[schema(value_type = Option, example = "batman")] + pub q: Option, + #[schema(value_type = Option, example = 1)] + pub page: Option, + #[schema(value_type = Option, example = 20)] + pub limit: Option, + /// Sort order: "name" (default), "books" (most books first) + #[schema(value_type = Option, example = "books")] + pub sort: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct AuthorItem { + pub name: String, + pub book_count: i64, + pub series_count: i64, +} + +#[derive(Serialize, ToSchema)] +pub struct AuthorsPageResponse { + pub items: Vec, + pub total: i64, + pub page: i64, + pub limit: i64, +} + +/// List all unique authors with book/series counts +#[utoipa::path( + get, + path = "/authors", + tag = "authors", + params( + ("q" = Option, Query, description = "Search by author name"), + ("page" = Option, Query, description = "Page number (1-based)"), + ("limit" = Option, Query, description = "Items per page (max 100)"), + ("sort" = Option, Query, description = "Sort: name (default) or books"), + ), + responses( + (status = 200, body = AuthorsPageResponse), + (status = 401, description = "Unauthorized"), + ), + security(("Bearer" = [])) +)] +pub async fn list_authors( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let page = query.page.unwrap_or(1).max(1); + let limit = query.limit.unwrap_or(20).clamp(1, 100); + let offset = (page - 1) * limit; + let sort = query.sort.as_deref().unwrap_or("name"); + + let order_clause = match sort { + "books" => "book_count DESC, name ASC", + _ => "name ASC", + }; + + let q_pattern = query.q.as_deref() + .filter(|s| !s.trim().is_empty()) + .map(|s| format!("%{s}%")); + + // Aggregate unique authors from books.authors + books.author + let sql = format!( + r#" + WITH all_authors AS ( + SELECT DISTINCT UNNEST( + COALESCE( + NULLIF(authors, '{{}}'), + CASE WHEN author IS NOT NULL AND author != '' THEN ARRAY[author] ELSE ARRAY[]::text[] END + ) + ) AS name + FROM books + ), + filtered AS ( + SELECT name FROM all_authors + WHERE ($1::text IS NULL OR name ILIKE $1) + ), + counted AS ( + SELECT + f.name, + COUNT(DISTINCT b.id) AS book_count, + COUNT(DISTINCT NULLIF(b.series, '')) AS series_count + FROM filtered f + JOIN books b ON ( + f.name = ANY( + COALESCE( + NULLIF(b.authors, '{{}}'), + CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END + ) + ) + ) + GROUP BY f.name + ) + SELECT name, book_count, series_count + FROM counted + ORDER BY {order_clause} + LIMIT $2 OFFSET $3 + "# + ); + + let count_sql = r#" + WITH all_authors AS ( + SELECT DISTINCT UNNEST( + COALESCE( + NULLIF(authors, '{}'), + CASE WHEN author IS NOT NULL AND author != '' THEN ARRAY[author] ELSE ARRAY[]::text[] END + ) + ) AS name + FROM books + ) + SELECT COUNT(*) AS total + FROM all_authors + WHERE ($1::text IS NULL OR name ILIKE $1) + "#; + + let (rows, count_row) = tokio::join!( + sqlx::query(&sql) + .bind(q_pattern.as_deref()) + .bind(limit) + .bind(offset) + .fetch_all(&state.pool), + sqlx::query(count_sql) + .bind(q_pattern.as_deref()) + .fetch_one(&state.pool) + ); + + let rows = rows.map_err(|e| ApiError::internal(format!("authors query failed: {e}")))?; + let total: i64 = count_row + .map_err(|e| ApiError::internal(format!("authors count failed: {e}")))? + .get("total"); + + let items: Vec = rows + .iter() + .map(|r| AuthorItem { + name: r.get("name"), + book_count: r.get("book_count"), + series_count: r.get("series_count"), + }) + .collect(); + + Ok(Json(AuthorsPageResponse { + items, + total, + page, + limit, + })) +} diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index 04d3072..0d9206c 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -19,6 +19,9 @@ pub struct ListBooksQuery { pub series: Option, #[schema(value_type = Option, example = "unread,reading")] pub reading_status: Option, + /// Filter by exact author name (matches in authors array or scalar author field) + #[schema(value_type = Option)] + pub author: Option, #[schema(value_type = Option, example = 1)] pub page: Option, #[schema(value_type = Option, example = 50)] @@ -135,6 +138,9 @@ pub async fn list_books( let rs_cond = if reading_statuses.is_some() { p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})") } else { String::new() }; + let author_cond = if query.author.is_some() { + p += 1; format!("AND (${p} = ANY(COALESCE(NULLIF(b.authors, '{{}}'), CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END)))") + } else { String::new() }; let count_sql = format!( r#"SELECT COUNT(*) FROM books b @@ -143,7 +149,8 @@ pub async fn list_books( AND ($2::text IS NULL OR b.kind = $2) AND ($3::text IS NULL OR b.format = $3) {series_cond} - {rs_cond}"# + {rs_cond} + {author_cond}"# ); let order_clause = if query.sort.as_deref() == Some("latest") { @@ -168,6 +175,7 @@ pub async fn list_books( AND ($3::text IS NULL OR b.format = $3) {series_cond} {rs_cond} + {author_cond} ORDER BY {order_clause} LIMIT ${limit_p} OFFSET ${offset_p} "# @@ -192,6 +200,10 @@ pub async fn list_books( count_builder = count_builder.bind(statuses.clone()); data_builder = data_builder.bind(statuses.clone()); } + if let Some(ref author) = query.author { + count_builder = count_builder.bind(author.clone()); + data_builder = data_builder.bind(author.clone()); + } data_builder = data_builder.bind(limit).bind(offset); diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 2a1feae..cf09a6d 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -1,4 +1,5 @@ mod auth; +mod authors; mod books; mod error; mod handlers; @@ -145,6 +146,7 @@ async fn main() -> anyhow::Result<()> { .route("/series/statuses", get(books::series_statuses)) .route("/series/provider-statuses", get(books::provider_statuses)) .route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read)) + .route("/authors", get(authors::list_authors)) .route("/stats", get(stats::get_stats)) .route("/search", get(search::search_books)) .route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit)) diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index 7979876..5f65a8f 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -39,6 +39,7 @@ use utoipa::OpenApi; crate::tokens::create_token, crate::tokens::revoke_token, crate::tokens::delete_token, + crate::authors::list_authors, crate::stats::get_stats, crate::settings::get_settings, crate::settings::get_setting, @@ -104,6 +105,9 @@ use utoipa::OpenApi; crate::settings::ThumbnailStats, crate::settings::StatusMappingDto, crate::settings::UpsertStatusMappingRequest, + crate::authors::ListAuthorsQuery, + crate::authors::AuthorItem, + crate::authors::AuthorsPageResponse, crate::stats::StatsResponse, crate::stats::StatsOverview, crate::stats::ReadingStatusStats, @@ -141,6 +145,7 @@ use utoipa::OpenApi; ("Bearer" = []) ), tags( + (name = "authors", description = "Author browsing and listing"), (name = "books", description = "Read-only endpoints for browsing and searching books"), (name = "reading-progress", description = "Reading progress tracking per book"), (name = "libraries", description = "Library management endpoints (Admin only)"), diff --git a/apps/backoffice/app/authors/[name]/page.tsx b/apps/backoffice/app/authors/[name]/page.tsx new file mode 100644 index 0000000..b04e7d6 --- /dev/null +++ b/apps/backoffice/app/authors/[name]/page.tsx @@ -0,0 +1,142 @@ +import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "../../../lib/api"; +import { getServerTranslations } from "../../../lib/i18n/server"; +import { BooksGrid } from "../../components/BookCard"; +import { OffsetPagination } from "../../components/ui"; +import Image from "next/image"; +import Link from "next/link"; + +export const dynamic = "force-dynamic"; + +export default async function AuthorDetailPage({ + params, + searchParams, +}: { + params: Promise<{ name: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const { t } = await getServerTranslations(); + const { name: encodedName } = await params; + const authorName = decodeURIComponent(encodedName); + const searchParamsAwaited = await searchParams; + const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1; + const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20; + + // Fetch books by this author (server-side filtering via API) and series + const [booksPage, seriesPage] = await Promise.all([ + fetchBooks(undefined, undefined, page, limit, undefined, undefined, authorName).catch( + () => ({ items: [], total: 0, page: 1, limit }) as BooksPageDto + ), + fetchAllSeries(undefined, undefined, undefined, 1, 200).catch( + () => ({ items: [], total: 0, page: 1, limit: 200 }) as SeriesPageDto + ), + ]); + + const totalPages = Math.ceil(booksPage.total / limit); + + // Extract unique series names from this author's books + const authorSeriesNames = new Set( + booksPage.items + .map((b) => b.series) + .filter((s): s is string => s != null && s !== "") + ); + + const authorSeries = seriesPage.items.filter((s) => authorSeriesNames.has(s.name)); + + return ( + <> + {/* Breadcrumb */} + + + {/* Author Header */} +
+
+ + {authorName.charAt(0).toUpperCase()} + +
+
+

{authorName}

+
+ + {t("authors.bookCount", { count: String(booksPage.total), plural: booksPage.total !== 1 ? "s" : "" })} + + {authorSeries.length > 0 && ( + + {t("authors.seriesCount", { count: String(authorSeries.length), plural: authorSeries.length !== 1 ? "s" : "" })} + + )} +
+
+
+ + {/* Series Section */} + {authorSeries.length > 0 && ( +
+

+ {t("authors.seriesBy", { name: authorName })} +

+
+ {authorSeries.map((s) => ( + +
+
+ {s.name} +
+
+

+ {s.name} +

+

+ {t("authors.bookCount", { count: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })} +

+
+
+ + ))} +
+
+ )} + + {/* Books Section */} + {booksPage.items.length > 0 && ( +
+

+ {t("authors.booksBy", { name: authorName })} +

+ + +
+ )} + + {/* Empty State */} + {booksPage.items.length === 0 && authorSeries.length === 0 && ( +
+

+ {t("authors.noResults")} +

+
+ )} + + ); +} diff --git a/apps/backoffice/app/authors/page.tsx b/apps/backoffice/app/authors/page.tsx new file mode 100644 index 0000000..ecbf26c --- /dev/null +++ b/apps/backoffice/app/authors/page.tsx @@ -0,0 +1,122 @@ +import { fetchAuthors, AuthorsPageDto } from "../../lib/api"; +import { getServerTranslations } from "../../lib/i18n/server"; +import { LiveSearchForm } from "../components/LiveSearchForm"; +import { Card, CardContent, OffsetPagination } from "../components/ui"; +import Link from "next/link"; + +export const dynamic = "force-dynamic"; + +export default async function AuthorsPage({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const { t } = await getServerTranslations(); + const searchParamsAwaited = await searchParams; + const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : ""; + const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined; + const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1; + const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20; + + const authorsPage = await fetchAuthors( + searchQuery || undefined, + page, + limit, + sort, + ).catch(() => ({ items: [], total: 0, page: 1, limit }) as AuthorsPageDto); + + const totalPages = Math.ceil(authorsPage.total / limit); + const hasFilters = searchQuery || sort; + + const sortOptions = [ + { value: "", label: t("authors.sortName") }, + { value: "books", label: t("authors.sortBooks") }, + ]; + + return ( + <> +
+

+ + + + {t("authors.title")} +

+
+ + + + + + + + {/* Results count */} +

+ {authorsPage.total} {t("authors.title").toLowerCase()} + {searchQuery && <> {t("authors.matchingQuery")} "{searchQuery}"} +

+ + {/* Authors List */} + {authorsPage.items.length > 0 ? ( + <> +
+ {authorsPage.items.map((author) => ( + +
+
+
+ + {author.name.charAt(0).toUpperCase()} + +
+
+

+ {author.name} +

+
+ + {t("authors.bookCount", { count: String(author.book_count), plural: author.book_count !== 1 ? "s" : "" })} + + + {t("authors.seriesCount", { count: String(author.series_count), plural: author.series_count !== 1 ? "s" : "" })} + +
+
+
+
+ + ))} +
+ + + + ) : ( +
+
+ + + +
+

+ {hasFilters ? t("authors.noResults") : t("authors.noAuthors")} +

+
+ )} + + ); +} diff --git a/apps/backoffice/app/components/MobileNav.tsx b/apps/backoffice/app/components/MobileNav.tsx index 757b026..5d8c547 100644 --- a/apps/backoffice/app/components/MobileNav.tsx +++ b/apps/backoffice/app/components/MobileNav.tsx @@ -7,9 +7,9 @@ import { NavIcon } from "./ui"; import { useTranslation } from "../../lib/i18n/context"; type NavItem = { - href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings"; + href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings"; label: string; - icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings"; + icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings"; }; const HamburgerIcon = () => ( diff --git a/apps/backoffice/app/components/ui/Icon.tsx b/apps/backoffice/app/components/ui/Icon.tsx index cd55296..38b1bcc 100644 --- a/apps/backoffice/app/components/ui/Icon.tsx +++ b/apps/backoffice/app/components/ui/Icon.tsx @@ -33,7 +33,8 @@ type IconName = | "spinner" | "warning" | "tag" - | "document"; + | "document" + | "authors"; type IconSize = "sm" | "md" | "lg" | "xl"; @@ -86,6 +87,7 @@ const icons: Record = { warning: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z", tag: "M7 7h.01M7 3h5a1.99 1.99 0 011.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z", document: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", + authors: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z", }; const colorClasses: Partial> = { @@ -99,6 +101,7 @@ const colorClasses: Partial> = { image: "text-primary", cache: "text-warning", performance: "text-success", + authors: "text-violet-500", }; export function Icon({ name, size = "md", className = "" }: IconProps) { diff --git a/apps/backoffice/app/layout.tsx b/apps/backoffice/app/layout.tsx index cc6be0c..91c7d8a 100644 --- a/apps/backoffice/app/layout.tsx +++ b/apps/backoffice/app/layout.tsx @@ -18,15 +18,16 @@ export const metadata: Metadata = { }; type NavItem = { - href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings"; + href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings"; labelKey: TranslationKey; - icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings"; + icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings"; }; const navItems: NavItem[] = [ { href: "/", labelKey: "nav.dashboard", icon: "dashboard" }, { href: "/books", labelKey: "nav.books", icon: "books" }, { href: "/series", labelKey: "nav.series", icon: "series" }, + { href: "/authors", labelKey: "nav.authors", icon: "authors" }, { href: "/libraries", labelKey: "nav.libraries", icon: "libraries" }, { href: "/jobs", labelKey: "nav.jobs", icon: "jobs" }, { href: "/tokens", labelKey: "nav.tokens", icon: "tokens" }, diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index 4bb6eea..c77c5ab 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -284,12 +284,14 @@ export async function fetchBooks( limit: number = 50, readingStatus?: string, sort?: string, + author?: string, ): Promise { const params = new URLSearchParams(); if (libraryId) params.set("library_id", libraryId); if (series) params.set("series", series); if (readingStatus) params.set("reading_status", readingStatus); if (sort) params.set("sort", sort); + if (author) params.set("author", author); params.set("page", page.toString()); params.set("limit", limit.toString()); @@ -552,6 +554,38 @@ export async function fetchStats() { return apiFetch("/stats"); } +// --------------------------------------------------------------------------- +// Authors +// --------------------------------------------------------------------------- + +export type AuthorDto = { + name: string; + book_count: number; + series_count: number; +}; + +export type AuthorsPageDto = { + items: AuthorDto[]; + total: number; + page: number; + limit: number; +}; + +export async function fetchAuthors( + q?: string, + page: number = 1, + limit: number = 20, + sort?: string, +): Promise { + const params = new URLSearchParams(); + if (q) params.set("q", q); + if (sort) params.set("sort", sort); + params.set("page", page.toString()); + params.set("limit", limit.toString()); + + return apiFetch(`/authors?${params.toString()}`); +} + export type UpdateBookRequest = { title: string; author: string | null; diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index 404ed6a..aaf3759 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -113,6 +113,20 @@ const en: Record = { "series.missingCount": "{{count}} missing", "series.readCount": "{{read}}/{{total}} read", + // Authors page + "nav.authors": "Authors", + "authors.title": "Authors", + "authors.searchPlaceholder": "Search by author name...", + "authors.bookCount": "{{count}} book{{plural}}", + "authors.seriesCount": "{{count}} serie{{plural}}", + "authors.noResults": "No authors found matching your filters", + "authors.noAuthors": "No authors available", + "authors.matchingQuery": "matching", + "authors.sortName": "Name", + "authors.sortBooks": "Book count", + "authors.booksBy": "Books by {{name}}", + "authors.seriesBy": "Series by {{name}}", + // Libraries page "libraries.title": "Libraries", "libraries.addLibrary": "Add a library", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index b3d7089..3355053 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -111,6 +111,20 @@ const fr = { "series.missingCount": "{{count}} manquant{{plural}}", "series.readCount": "{{read}}/{{total}} lu{{plural}}", + // Authors page + "nav.authors": "Auteurs", + "authors.title": "Auteurs", + "authors.searchPlaceholder": "Rechercher par nom d'auteur...", + "authors.bookCount": "{{count}} livre{{plural}}", + "authors.seriesCount": "{{count}} série{{plural}}", + "authors.noResults": "Aucun auteur trouvé correspondant à vos filtres", + "authors.noAuthors": "Aucun auteur disponible", + "authors.matchingQuery": "correspondant à", + "authors.sortName": "Nom", + "authors.sortBooks": "Nombre de livres", + "authors.booksBy": "Livres de {{name}}", + "authors.seriesBy": "Séries de {{name}}", + // Libraries page "libraries.title": "Bibliothèques", "libraries.addLibrary": "Ajouter une bibliothèque",