feat: add i18n support (FR/EN) to backoffice with English as default
Implement full internationalization for the Next.js backoffice: - i18n infrastructure: type-safe dictionaries (fr.ts/en.ts), cookie-based locale detection, React Context for client components, server-side translation helper - Language selector in Settings page (General tab) with cookie + DB persistence - All ~35 pages and components translated via t() / useTranslation() - Default locale set to English, French available via settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,16 +4,17 @@ import { ConvertButton } from "../../components/ConvertButton";
|
||||
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
|
||||
import { EditBookForm } from "../../components/EditBookForm";
|
||||
import { SafeHtml } from "../../components/SafeHtml";
|
||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const readingStatusConfig: Record<ReadingStatus, { label: string; className: string }> = {
|
||||
unread: { label: "Non lu", className: "bg-muted/60 text-muted-foreground border border-border" },
|
||||
reading: { label: "En cours", className: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30" },
|
||||
read: { label: "Lu", className: "bg-green-500/15 text-green-600 dark:text-green-400 border border-green-500/30" },
|
||||
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> {
|
||||
@@ -39,6 +40,8 @@ export default async function BookDetailPage({
|
||||
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 =
|
||||
@@ -46,14 +49,15 @@ export default async function BookDetailPage({
|
||||
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 { label: statusLabel, className: statusClassName } = readingStatusConfig[book.reading_status];
|
||||
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">
|
||||
Bibliothèques
|
||||
{t("bookDetail.libraries")}
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
{library && (
|
||||
@@ -88,7 +92,7 @@ export default async function BookDetailPage({
|
||||
<div className="w-48 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.id)}
|
||||
alt={`Couverture de ${book.title}`}
|
||||
alt={t("bookDetail.coverOf", { title: book.title })}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
@@ -134,7 +138,7 @@ export default async function BookDetailPage({
|
||||
</span>
|
||||
{book.reading_last_read_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(book.reading_last_read_at).toLocaleDateString()}
|
||||
{new Date(book.reading_last_read_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
)}
|
||||
<MarkBookReadButton bookId={book.id} currentStatus={book.reading_status} />
|
||||
@@ -148,7 +152,7 @@ export default async function BookDetailPage({
|
||||
</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} pages
|
||||
{book.page_count} {t("dashboard.pages").toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
{book.language && (
|
||||
@@ -181,24 +185,24 @@ export default async function BookDetailPage({
|
||||
<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>
|
||||
Informations techniques
|
||||
{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">Fichier</span>
|
||||
<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">Format fichier</span>
|
||||
<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">Parsing</span>
|
||||
<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" :
|
||||
@@ -218,8 +222,8 @@ export default async function BookDetailPage({
|
||||
</div>
|
||||
{book.updated_at && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Mis à jour</span>
|
||||
<span className="text-foreground">{new Date(book.updated_at).toLocaleString()}</span>
|
||||
<span className="text-muted-foreground">{t("bookDetail.updatedAt")}</span>
|
||||
<span className="text-foreground">{new Date(book.updated_at).toLocaleString(locale)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -12,6 +13,7 @@ export default async function BooksPage({
|
||||
}: {
|
||||
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 : "";
|
||||
@@ -78,20 +80,20 @@ export default async function BooksPage({
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const libraryOptions = [
|
||||
{ value: "", label: "Toutes les bibliothèques" },
|
||||
{ value: "", label: t("books.allLibraries") },
|
||||
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "", label: "Tous" },
|
||||
{ value: "unread", label: "Non lu" },
|
||||
{ value: "reading", label: "En cours" },
|
||||
{ value: "read", label: "Lu" },
|
||||
{ value: "", label: t("common.all") },
|
||||
{ value: "unread", label: t("status.unread") },
|
||||
{ value: "reading", label: t("status.reading") },
|
||||
{ value: "read", label: t("status.read") },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "", label: "Titre" },
|
||||
{ value: "latest", label: "Ajout récent" },
|
||||
{ value: "", label: t("books.sortTitle") },
|
||||
{ value: "latest", label: t("books.sortLatest") },
|
||||
];
|
||||
|
||||
const hasFilters = searchQuery || libraryId || readingStatus || sort;
|
||||
@@ -103,7 +105,7 @@ export default async function BooksPage({
|
||||
<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>
|
||||
Livres
|
||||
{t("books.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -112,10 +114,10 @@ export default async function BooksPage({
|
||||
<LiveSearchForm
|
||||
basePath="/books"
|
||||
fields={[
|
||||
{ name: "q", type: "text", label: "Rechercher", placeholder: "Rechercher par titre, auteur, série...", className: "flex-1 w-full" },
|
||||
{ name: "library", type: "select", label: "Bibliothèque", options: libraryOptions, className: "w-full sm:w-48" },
|
||||
{ name: "status", type: "select", label: "Statut", options: statusOptions, className: "w-full sm:w-40" },
|
||||
{ name: "sort", type: "select", label: "Tri", options: sortOptions, className: "w-full sm:w-40" },
|
||||
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder"), className: "flex-1 w-full" },
|
||||
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions, className: "w-full sm:w-48" },
|
||||
{ name: "status", type: "select", label: t("books.status"), options: statusOptions, className: "w-full sm:w-40" },
|
||||
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions, className: "w-full sm:w-40" },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -124,18 +126,18 @@ export default async function BooksPage({
|
||||
{/* Résultats */}
|
||||
{searchQuery && totalHits !== null ? (
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{totalHits} résultat{totalHits !== 1 ? 's' : ''} pour « {searchQuery} »
|
||||
{t("books.resultCountFor", { count: String(totalHits), plural: totalHits !== 1 ? "s" : "", query: searchQuery })}
|
||||
</p>
|
||||
) : !searchQuery && (
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{total} livre{total !== 1 ? 's' : ''}
|
||||
{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">Séries</h2>
|
||||
<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
|
||||
@@ -147,7 +149,7 @@ export default async function BooksPage({
|
||||
<div className="aspect-[2/3] relative bg-muted/50">
|
||||
<Image
|
||||
src={getBookCoverUrl(s.first_book_id)}
|
||||
alt={`Couverture de ${s.name}`}
|
||||
alt={t("books.coverOf", { name: s.name })}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
@@ -155,10 +157,10 @@ export default async function BooksPage({
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||
{s.name === "unclassified" ? "Non classé" : s.name}
|
||||
{s.name === "unclassified" ? t("books.unclassified") : s.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{s.book_count} livre{s.book_count !== 1 ? 's' : ''}
|
||||
{t("books.bookCount", { count: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,7 +173,7 @@ export default async function BooksPage({
|
||||
{/* Grille de livres */}
|
||||
{displayBooks.length > 0 ? (
|
||||
<>
|
||||
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">Livres</h2>}
|
||||
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">{t("books.title")}</h2>}
|
||||
<BooksGrid books={displayBooks} />
|
||||
|
||||
{!searchQuery && (
|
||||
@@ -184,7 +186,7 @@ export default async function BooksPage({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState message={searchQuery ? `Aucun livre trouvé pour "${searchQuery}"` : "Aucun livre disponible"} />
|
||||
<EmptyState message={searchQuery ? t("books.noResults", { query: searchQuery }) : t("books.noBooks")} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user