feat: add authors page to backoffice with dedicated API endpoint
Add a new GET /authors endpoint that aggregates unique authors from books with book/series counts, pagination and search. Add author filter to GET /books. Backoffice gets a list page with search/sort and a detail page showing the author's series and books. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
156
apps/api/src/authors.rs
Normal file
156
apps/api/src/authors.rs
Normal file
@@ -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<String>, example = "batman")]
|
||||||
|
pub q: Option<String>,
|
||||||
|
#[schema(value_type = Option<i64>, example = 1)]
|
||||||
|
pub page: Option<i64>,
|
||||||
|
#[schema(value_type = Option<i64>, example = 20)]
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
/// Sort order: "name" (default), "books" (most books first)
|
||||||
|
#[schema(value_type = Option<String>, example = "books")]
|
||||||
|
pub sort: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<AuthorItem>,
|
||||||
|
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<String>, Query, description = "Search by author name"),
|
||||||
|
("page" = Option<i64>, Query, description = "Page number (1-based)"),
|
||||||
|
("limit" = Option<i64>, Query, description = "Items per page (max 100)"),
|
||||||
|
("sort" = Option<String>, 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<AppState>,
|
||||||
|
Query(query): Query<ListAuthorsQuery>,
|
||||||
|
) -> Result<Json<AuthorsPageResponse>, 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<AuthorItem> = 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ pub struct ListBooksQuery {
|
|||||||
pub series: Option<String>,
|
pub series: Option<String>,
|
||||||
#[schema(value_type = Option<String>, example = "unread,reading")]
|
#[schema(value_type = Option<String>, example = "unread,reading")]
|
||||||
pub reading_status: Option<String>,
|
pub reading_status: Option<String>,
|
||||||
|
/// Filter by exact author name (matches in authors array or scalar author field)
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub author: Option<String>,
|
||||||
#[schema(value_type = Option<i64>, example = 1)]
|
#[schema(value_type = Option<i64>, example = 1)]
|
||||||
pub page: Option<i64>,
|
pub page: Option<i64>,
|
||||||
#[schema(value_type = Option<i64>, example = 50)]
|
#[schema(value_type = Option<i64>, example = 50)]
|
||||||
@@ -135,6 +138,9 @@ pub async fn list_books(
|
|||||||
let rs_cond = if reading_statuses.is_some() {
|
let rs_cond = if reading_statuses.is_some() {
|
||||||
p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})")
|
p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})")
|
||||||
} else { String::new() };
|
} 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!(
|
let count_sql = format!(
|
||||||
r#"SELECT COUNT(*) FROM books b
|
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 ($2::text IS NULL OR b.kind = $2)
|
||||||
AND ($3::text IS NULL OR b.format = $3)
|
AND ($3::text IS NULL OR b.format = $3)
|
||||||
{series_cond}
|
{series_cond}
|
||||||
{rs_cond}"#
|
{rs_cond}
|
||||||
|
{author_cond}"#
|
||||||
);
|
);
|
||||||
|
|
||||||
let order_clause = if query.sort.as_deref() == Some("latest") {
|
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)
|
AND ($3::text IS NULL OR b.format = $3)
|
||||||
{series_cond}
|
{series_cond}
|
||||||
{rs_cond}
|
{rs_cond}
|
||||||
|
{author_cond}
|
||||||
ORDER BY {order_clause}
|
ORDER BY {order_clause}
|
||||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||||
"#
|
"#
|
||||||
@@ -192,6 +200,10 @@ pub async fn list_books(
|
|||||||
count_builder = count_builder.bind(statuses.clone());
|
count_builder = count_builder.bind(statuses.clone());
|
||||||
data_builder = data_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);
|
data_builder = data_builder.bind(limit).bind(offset);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod auth;
|
mod auth;
|
||||||
|
mod authors;
|
||||||
mod books;
|
mod books;
|
||||||
mod error;
|
mod error;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
@@ -145,6 +146,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/series/statuses", get(books::series_statuses))
|
.route("/series/statuses", get(books::series_statuses))
|
||||||
.route("/series/provider-statuses", get(books::provider_statuses))
|
.route("/series/provider-statuses", get(books::provider_statuses))
|
||||||
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
.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("/stats", get(stats::get_stats))
|
||||||
.route("/search", get(search::search_books))
|
.route("/search", get(search::search_books))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
|
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ use utoipa::OpenApi;
|
|||||||
crate::tokens::create_token,
|
crate::tokens::create_token,
|
||||||
crate::tokens::revoke_token,
|
crate::tokens::revoke_token,
|
||||||
crate::tokens::delete_token,
|
crate::tokens::delete_token,
|
||||||
|
crate::authors::list_authors,
|
||||||
crate::stats::get_stats,
|
crate::stats::get_stats,
|
||||||
crate::settings::get_settings,
|
crate::settings::get_settings,
|
||||||
crate::settings::get_setting,
|
crate::settings::get_setting,
|
||||||
@@ -104,6 +105,9 @@ use utoipa::OpenApi;
|
|||||||
crate::settings::ThumbnailStats,
|
crate::settings::ThumbnailStats,
|
||||||
crate::settings::StatusMappingDto,
|
crate::settings::StatusMappingDto,
|
||||||
crate::settings::UpsertStatusMappingRequest,
|
crate::settings::UpsertStatusMappingRequest,
|
||||||
|
crate::authors::ListAuthorsQuery,
|
||||||
|
crate::authors::AuthorItem,
|
||||||
|
crate::authors::AuthorsPageResponse,
|
||||||
crate::stats::StatsResponse,
|
crate::stats::StatsResponse,
|
||||||
crate::stats::StatsOverview,
|
crate::stats::StatsOverview,
|
||||||
crate::stats::ReadingStatusStats,
|
crate::stats::ReadingStatusStats,
|
||||||
@@ -141,6 +145,7 @@ use utoipa::OpenApi;
|
|||||||
("Bearer" = [])
|
("Bearer" = [])
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
|
(name = "authors", description = "Author browsing and listing"),
|
||||||
(name = "books", description = "Read-only endpoints for browsing and searching books"),
|
(name = "books", description = "Read-only endpoints for browsing and searching books"),
|
||||||
(name = "reading-progress", description = "Reading progress tracking per book"),
|
(name = "reading-progress", description = "Reading progress tracking per book"),
|
||||||
(name = "libraries", description = "Library management endpoints (Admin only)"),
|
(name = "libraries", description = "Library management endpoints (Admin only)"),
|
||||||
|
|||||||
142
apps/backoffice/app/authors/[name]/page.tsx
Normal file
142
apps/backoffice/app/authors/[name]/page.tsx
Normal file
@@ -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 */}
|
||||||
|
<nav className="flex items-center gap-2 text-sm text-muted-foreground mb-6">
|
||||||
|
<Link href="/authors" className="hover:text-foreground transition-colors">
|
||||||
|
{t("authors.title")}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground font-medium">{authorName}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Author Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-accent/50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-2xl font-bold text-accent-foreground">
|
||||||
|
{authorName.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">{authorName}</h1>
|
||||||
|
<div className="flex items-center gap-4 mt-1">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("authors.bookCount", { count: String(booksPage.total), plural: booksPage.total !== 1 ? "s" : "" })}
|
||||||
|
</span>
|
||||||
|
{authorSeries.length > 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("authors.seriesCount", { count: String(authorSeries.length), plural: authorSeries.length !== 1 ? "s" : "" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Series Section */}
|
||||||
|
{authorSeries.length > 0 && (
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||||
|
{t("authors.seriesBy", { name: authorName })}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||||
|
{authorSeries.map((s) => (
|
||||||
|
<Link
|
||||||
|
key={`${s.library_id}-${s.name}`}
|
||||||
|
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md hover:-translate-y-1 transition-all duration-200">
|
||||||
|
<div className="aspect-[2/3] relative bg-muted/50">
|
||||||
|
<Image
|
||||||
|
src={getBookCoverUrl(s.first_book_id)}
|
||||||
|
alt={s.name}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||||
|
{s.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("authors.bookCount", { count: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Books Section */}
|
||||||
|
{booksPage.items.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||||
|
{t("authors.booksBy", { name: authorName })}
|
||||||
|
</h2>
|
||||||
|
<BooksGrid books={booksPage.items} />
|
||||||
|
<OffsetPagination
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
pageSize={limit}
|
||||||
|
totalItems={booksPage.total}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{booksPage.items.length === 0 && authorSeries.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
{t("authors.noResults")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
apps/backoffice/app/authors/page.tsx
Normal file
122
apps/backoffice/app/authors/page.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
|
<svg className="w-8 h-8 text-violet-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
|
||||||
|
</svg>
|
||||||
|
{t("authors.title")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<LiveSearchForm
|
||||||
|
basePath="/authors"
|
||||||
|
fields={[
|
||||||
|
{ name: "q", type: "text", label: t("common.search"), placeholder: t("authors.searchPlaceholder") },
|
||||||
|
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Results count */}
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{authorsPage.total} {t("authors.title").toLowerCase()}
|
||||||
|
{searchQuery && <> {t("authors.matchingQuery")} "{searchQuery}"</>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Authors List */}
|
||||||
|
{authorsPage.items.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{authorsPage.items.map((author) => (
|
||||||
|
<Link
|
||||||
|
key={author.name}
|
||||||
|
href={`/authors/${encodeURIComponent(author.name)}`}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md hover:-translate-y-1 transition-all duration-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-accent/50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-lg font-semibold text-violet-500">
|
||||||
|
{author.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-medium text-foreground truncate text-sm group-hover:text-violet-500 transition-colors" title={author.name}>
|
||||||
|
{author.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("authors.bookCount", { count: String(author.book_count), plural: author.book_count !== 1 ? "s" : "" })}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("authors.seriesCount", { count: String(author.series_count), plural: author.series_count !== 1 ? "s" : "" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OffsetPagination
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
pageSize={limit}
|
||||||
|
totalItems={authorsPage.total}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div className="w-16 h-16 mb-4 text-muted-foreground/30">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
{hasFilters ? t("authors.noResults") : t("authors.noAuthors")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@ import { NavIcon } from "./ui";
|
|||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||||
label: string;
|
label: string;
|
||||||
icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings";
|
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
|
||||||
};
|
};
|
||||||
|
|
||||||
const HamburgerIcon = () => (
|
const HamburgerIcon = () => (
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ type IconName =
|
|||||||
| "spinner"
|
| "spinner"
|
||||||
| "warning"
|
| "warning"
|
||||||
| "tag"
|
| "tag"
|
||||||
| "document";
|
| "document"
|
||||||
|
| "authors";
|
||||||
|
|
||||||
type IconSize = "sm" | "md" | "lg" | "xl";
|
type IconSize = "sm" | "md" | "lg" | "xl";
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@ const icons: Record<IconName, string> = {
|
|||||||
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",
|
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",
|
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",
|
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<Record<IconName, string>> = {
|
const colorClasses: Partial<Record<IconName, string>> = {
|
||||||
@@ -99,6 +101,7 @@ const colorClasses: Partial<Record<IconName, string>> = {
|
|||||||
image: "text-primary",
|
image: "text-primary",
|
||||||
cache: "text-warning",
|
cache: "text-warning",
|
||||||
performance: "text-success",
|
performance: "text-success",
|
||||||
|
authors: "text-violet-500",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Icon({ name, size = "md", className = "" }: IconProps) {
|
export function Icon({ name, size = "md", className = "" }: IconProps) {
|
||||||
|
|||||||
@@ -18,15 +18,16 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||||
labelKey: TranslationKey;
|
labelKey: TranslationKey;
|
||||||
icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings";
|
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
|
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
|
||||||
{ href: "/books", labelKey: "nav.books", icon: "books" },
|
{ href: "/books", labelKey: "nav.books", icon: "books" },
|
||||||
{ href: "/series", labelKey: "nav.series", icon: "series" },
|
{ href: "/series", labelKey: "nav.series", icon: "series" },
|
||||||
|
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
|
||||||
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
|
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
|
||||||
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
|
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
|
||||||
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
|
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
|
||||||
|
|||||||
@@ -284,12 +284,14 @@ export async function fetchBooks(
|
|||||||
limit: number = 50,
|
limit: number = 50,
|
||||||
readingStatus?: string,
|
readingStatus?: string,
|
||||||
sort?: string,
|
sort?: string,
|
||||||
|
author?: string,
|
||||||
): Promise<BooksPageDto> {
|
): Promise<BooksPageDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (libraryId) params.set("library_id", libraryId);
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
if (series) params.set("series", series);
|
if (series) params.set("series", series);
|
||||||
if (readingStatus) params.set("reading_status", readingStatus);
|
if (readingStatus) params.set("reading_status", readingStatus);
|
||||||
if (sort) params.set("sort", sort);
|
if (sort) params.set("sort", sort);
|
||||||
|
if (author) params.set("author", author);
|
||||||
params.set("page", page.toString());
|
params.set("page", page.toString());
|
||||||
params.set("limit", limit.toString());
|
params.set("limit", limit.toString());
|
||||||
|
|
||||||
@@ -552,6 +554,38 @@ export async function fetchStats() {
|
|||||||
return apiFetch<StatsResponse>("/stats");
|
return apiFetch<StatsResponse>("/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<AuthorsPageDto> {
|
||||||
|
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<AuthorsPageDto>(`/authors?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export type UpdateBookRequest = {
|
export type UpdateBookRequest = {
|
||||||
title: string;
|
title: string;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
|
|||||||
@@ -113,6 +113,20 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"series.missingCount": "{{count}} missing",
|
"series.missingCount": "{{count}} missing",
|
||||||
"series.readCount": "{{read}}/{{total}} read",
|
"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 page
|
||||||
"libraries.title": "Libraries",
|
"libraries.title": "Libraries",
|
||||||
"libraries.addLibrary": "Add a library",
|
"libraries.addLibrary": "Add a library",
|
||||||
|
|||||||
@@ -111,6 +111,20 @@ const fr = {
|
|||||||
"series.missingCount": "{{count}} manquant{{plural}}",
|
"series.missingCount": "{{count}} manquant{{plural}}",
|
||||||
"series.readCount": "{{read}}/{{total}} lu{{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 page
|
||||||
"libraries.title": "Bibliothèques",
|
"libraries.title": "Bibliothèques",
|
||||||
"libraries.addLibrary": "Ajouter une bibliothèque",
|
"libraries.addLibrary": "Ajouter une bibliothèque",
|
||||||
|
|||||||
Reference in New Issue
Block a user