feat: add backoffice authentication with login page
- Add login page with logo background, glassmorphism card - Add session management via JWT (jose) with httpOnly cookie - Add Next.js proxy middleware to protect all routes - Add logout button in nav - Restructure app into (app) route group to isolate login layout - Add ADMIN_USERNAME, ADMIN_PASSWORD, SESSION_SECRET env vars Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
242
apps/backoffice/app/(app)/books/[id]/page.tsx
Normal file
242
apps/backoffice/app/(app)/books/[id]/page.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "@/lib/api";
|
||||
import { BookPreview } from "@/app/components/BookPreview";
|
||||
import { ConvertButton } from "@/app/components/ConvertButton";
|
||||
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
|
||||
import nextDynamic from "next/dynamic";
|
||||
import { SafeHtml } from "@/app/components/SafeHtml";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
const EditBookForm = nextDynamic(
|
||||
() => import("@/app/components/EditBookForm").then(m => m.EditBookForm)
|
||||
);
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const readingStatusClassNames: Record<ReadingStatus, string> = {
|
||||
unread: "bg-muted/60 text-muted-foreground border border-border",
|
||||
reading: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30",
|
||||
read: "bg-green-500/15 text-green-600 dark:text-green-400 border border-green-500/30",
|
||||
};
|
||||
|
||||
async function fetchBook(bookId: string): Promise<BookDto | null> {
|
||||
try {
|
||||
return await apiFetch<BookDto>(`/books/${bookId}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BookDetailPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const [book, libraries] = await Promise.all([
|
||||
fetchBook(id),
|
||||
fetchLibraries().catch(() => [] as { id: string; name: string }[])
|
||||
]);
|
||||
|
||||
if (!book) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { t, locale } = await getServerTranslations();
|
||||
|
||||
const library = libraries.find(l => l.id === book.library_id);
|
||||
const formatBadge = (book.format ?? book.kind).toUpperCase();
|
||||
const formatColor =
|
||||
formatBadge === "CBZ" ? "bg-success/10 text-success border-success/30" :
|
||||
formatBadge === "CBR" ? "bg-warning/10 text-warning border-warning/30" :
|
||||
formatBadge === "PDF" ? "bg-destructive/10 text-destructive border-destructive/30" :
|
||||
"bg-muted/50 text-muted-foreground border-border";
|
||||
const statusLabel = t(`status.${book.reading_status}` as "status.unread" | "status.reading" | "status.read");
|
||||
const statusClassName = readingStatusClassNames[book.reading_status];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link href="/libraries" className="text-muted-foreground hover:text-primary transition-colors">
|
||||
{t("bookDetail.libraries")}
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
{library && (
|
||||
<>
|
||||
<Link
|
||||
href={`/libraries/${book.library_id}/series`}
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{library.name}
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
</>
|
||||
)}
|
||||
{book.series && (
|
||||
<>
|
||||
<Link
|
||||
href={`/libraries/${book.library_id}/series/${encodeURIComponent(book.series)}`}
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{book.series}
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-foreground font-medium truncate">{book.title}</span>
|
||||
</div>
|
||||
|
||||
{/* Hero */}
|
||||
<div className="flex flex-col sm:flex-row gap-6">
|
||||
{/* Cover */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-48 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.id)}
|
||||
alt={t("bookDetail.coverOf", { title: book.title })}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="192px"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">{book.title}</h1>
|
||||
{book.author && (
|
||||
<p className="text-base text-muted-foreground mt-1">{book.author}</p>
|
||||
)}
|
||||
</div>
|
||||
<EditBookForm book={book} />
|
||||
</div>
|
||||
|
||||
{/* Series + Volume link */}
|
||||
{book.series && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href={`/libraries/${book.library_id}/series/${encodeURIComponent(book.series)}`}
|
||||
className="text-primary hover:text-primary/80 transition-colors font-medium"
|
||||
>
|
||||
{book.series}
|
||||
</Link>
|
||||
{book.volume != null && (
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-md text-xs font-semibold">
|
||||
Vol. {book.volume}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reading status + actions */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold ${statusClassName}`}>
|
||||
{statusLabel}
|
||||
{book.reading_status === "reading" && book.reading_current_page != null && ` · p. ${book.reading_current_page}`}
|
||||
</span>
|
||||
{book.reading_last_read_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(book.reading_last_read_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
)}
|
||||
<MarkBookReadButton bookId={book.id} currentStatus={book.reading_status} />
|
||||
{book.file_format === "cbr" && <ConvertButton bookId={book.id} />}
|
||||
</div>
|
||||
|
||||
{/* Metadata pills */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold border ${formatColor}`}>
|
||||
{formatBadge}
|
||||
</span>
|
||||
{book.page_count && (
|
||||
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{book.page_count} {t("dashboard.pages").toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
{book.language && (
|
||||
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{book.language.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{book.isbn && (
|
||||
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-mono font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
ISBN {book.isbn}
|
||||
</span>
|
||||
)}
|
||||
{book.publish_date && (
|
||||
<span className="inline-flex px-2.5 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{book.publish_date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{book.summary && (
|
||||
<SafeHtml html={book.summary} className="text-sm text-muted-foreground leading-relaxed" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical info (collapsible) */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors select-none flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{t("bookDetail.technicalInfo")}
|
||||
</summary>
|
||||
<div className="mt-3 p-4 rounded-lg bg-muted/30 border border-border/50 space-y-2 text-xs">
|
||||
{book.file_path && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-muted-foreground">{t("bookDetail.file")}</span>
|
||||
<code className="font-mono text-foreground break-all">{book.file_path}</code>
|
||||
</div>
|
||||
)}
|
||||
{book.file_format && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("bookDetail.fileFormat")}</span>
|
||||
<span className="text-foreground">{book.file_format.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
{book.file_parse_status && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("bookDetail.parsing")}</span>
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
book.file_parse_status === "success" ? "bg-success/10 text-success" :
|
||||
book.file_parse_status === "failed" ? "bg-destructive/10 text-destructive" :
|
||||
"bg-muted/50 text-muted-foreground"
|
||||
}`}>
|
||||
{book.file_parse_status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Book ID</span>
|
||||
<code className="font-mono text-foreground">{book.id}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Library ID</span>
|
||||
<code className="font-mono text-foreground">{book.library_id}</code>
|
||||
</div>
|
||||
{book.updated_at && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("bookDetail.updatedAt")}</span>
|
||||
<span className="text-foreground">{new Date(book.updated_at).toLocaleString(locale)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Book Preview */}
|
||||
{book.page_count && book.page_count > 0 && (
|
||||
<BookPreview bookId={book.id} pageCount={book.page_count} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
apps/backoffice/app/(app)/books/page.tsx
Normal file
211
apps/backoffice/app/(app)/books/page.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "@/lib/api";
|
||||
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
|
||||
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BooksPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const { t } = await getServerTranslations();
|
||||
const searchParamsAwaited = await searchParams;
|
||||
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
||||
const format = typeof searchParamsAwaited.format === "string" ? searchParamsAwaited.format : undefined;
|
||||
const metadataProvider = typeof searchParamsAwaited.metadata === "string" ? searchParamsAwaited.metadata : undefined;
|
||||
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 [libraries] = await Promise.all([
|
||||
fetchLibraries().catch(() => [] as LibraryDto[])
|
||||
]);
|
||||
|
||||
let books: BookDto[] = [];
|
||||
let total = 0;
|
||||
let searchResults: BookDto[] | null = null;
|
||||
let seriesHits: SeriesHitDto[] = [];
|
||||
let totalHits: number | null = null;
|
||||
|
||||
if (searchQuery) {
|
||||
const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null);
|
||||
if (searchResponse) {
|
||||
seriesHits = searchResponse.series_hits ?? [];
|
||||
searchResults = searchResponse.hits.map(hit => ({
|
||||
id: hit.id,
|
||||
library_id: hit.library_id,
|
||||
kind: hit.kind,
|
||||
title: hit.title,
|
||||
author: hit.authors?.[0] ?? null,
|
||||
authors: hit.authors ?? [],
|
||||
series: hit.series,
|
||||
volume: hit.volume,
|
||||
language: hit.language,
|
||||
page_count: null,
|
||||
format: null,
|
||||
file_path: null,
|
||||
file_format: null,
|
||||
file_parse_status: null,
|
||||
updated_at: "",
|
||||
reading_status: "unread" as const,
|
||||
reading_current_page: null,
|
||||
reading_last_read_at: null,
|
||||
summary: null,
|
||||
isbn: null,
|
||||
publish_date: null,
|
||||
}));
|
||||
totalHits = searchResponse.estimated_total_hits;
|
||||
}
|
||||
} else {
|
||||
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort, undefined, format, metadataProvider).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit,
|
||||
}));
|
||||
books = booksPage.items;
|
||||
total = booksPage.total;
|
||||
}
|
||||
|
||||
const displayBooks = (searchResults || books).map(book => ({
|
||||
...book,
|
||||
coverUrl: getBookCoverUrl(book.id)
|
||||
}));
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const libraryOptions = [
|
||||
{ value: "", label: t("books.allLibraries") },
|
||||
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "", label: t("common.all") },
|
||||
{ value: "unread", label: t("status.unread") },
|
||||
{ value: "reading", label: t("status.reading") },
|
||||
{ value: "read", label: t("status.read") },
|
||||
];
|
||||
|
||||
const formatOptions = [
|
||||
{ value: "", label: t("books.allFormats") },
|
||||
{ value: "cbz", label: "CBZ" },
|
||||
{ value: "cbr", label: "CBR" },
|
||||
{ value: "pdf", label: "PDF" },
|
||||
{ value: "epub", label: "EPUB" },
|
||||
];
|
||||
|
||||
const metadataOptions = [
|
||||
{ value: "", label: t("series.metadataAll") },
|
||||
{ value: "linked", label: t("series.metadataLinked") },
|
||||
{ value: "unlinked", label: t("series.metadataUnlinked") },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "", label: t("books.sortTitle") },
|
||||
{ value: "latest", label: t("books.sortLatest") },
|
||||
];
|
||||
|
||||
const hasFilters = searchQuery || libraryId || readingStatus || format || metadataProvider || sort;
|
||||
|
||||
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-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
{t("books.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<LiveSearchForm
|
||||
basePath="/books"
|
||||
fields={[
|
||||
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder") },
|
||||
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
|
||||
{ name: "status", type: "select", label: t("books.status"), options: statusOptions },
|
||||
{ name: "format", type: "select", label: t("books.format"), options: formatOptions },
|
||||
{ name: "metadata", type: "select", label: t("series.metadata"), options: metadataOptions },
|
||||
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Résultats */}
|
||||
{searchQuery && totalHits !== null ? (
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("books.resultCountFor", { count: String(totalHits), plural: totalHits !== 1 ? "s" : "", query: searchQuery })}
|
||||
</p>
|
||||
) : !searchQuery && (
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("books.resultCount", { count: String(total), plural: total !== 1 ? "s" : "" })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Séries matchantes */}
|
||||
{seriesHits.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-3">{t("books.seriesHeading")}</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{seriesHits.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 transition-shadow duration-200">
|
||||
<div className="aspect-[2/3] relative bg-muted/50">
|
||||
<Image
|
||||
src={getBookCoverUrl(s.first_book_id)}
|
||||
alt={t("books.coverOf", { name: s.name })}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||
{s.name === "unclassified" ? t("books.unclassified") : s.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("books.bookCount", { count: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grille de livres */}
|
||||
{displayBooks.length > 0 ? (
|
||||
<>
|
||||
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">{t("books.title")}</h2>}
|
||||
<BooksGrid books={displayBooks} />
|
||||
|
||||
{!searchQuery && (
|
||||
<OffsetPagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
pageSize={limit}
|
||||
totalItems={total}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState message={searchQuery ? t("books.noResults", { query: searchQuery }) : t("books.noBooks")} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user