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:
2026-03-20 11:43:22 +01:00
parent fe5de3d5c1
commit 4ad6d57271
12 changed files with 511 additions and 6 deletions

View 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>
)}
</>
);
}

View 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")} &quot;{searchQuery}&quot;</>}
</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>
)}
</>
);
}

View File

@@ -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 = () => (

View File

@@ -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<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",
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<Record<IconName, string>> = {
@@ -99,6 +101,7 @@ const colorClasses: Partial<Record<IconName, string>> = {
image: "text-primary",
cache: "text-warning",
performance: "text-success",
authors: "text-violet-500",
};
export function Icon({ name, size = "md", className = "" }: IconProps) {

View File

@@ -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" },