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")} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { BookDto, ReadingStatus } from "../../lib/api";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
const readingStatusOverlay: Record<ReadingStatus, { label: string; className: string } | null> = {
|
||||
const readingStatusOverlayClasses: Record<ReadingStatus, string | null> = {
|
||||
unread: null,
|
||||
reading: { label: "En cours", className: "bg-amber-500/90 text-white" },
|
||||
read: { label: "Lu", className: "bg-green-600/90 text-white" },
|
||||
reading: "bg-amber-500/90 text-white",
|
||||
read: "bg-green-600/90 text-white",
|
||||
};
|
||||
|
||||
interface BookCardProps {
|
||||
@@ -57,9 +58,15 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
|
||||
}
|
||||
|
||||
export function BookCard({ book, readingStatus }: BookCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
|
||||
const status = readingStatus ?? book.reading_status;
|
||||
const overlay = status ? readingStatusOverlay[status] : null;
|
||||
const overlayClass = status ? readingStatusOverlayClasses[status] : null;
|
||||
const statusLabels: Record<ReadingStatus, string> = {
|
||||
unread: t("status.unread"),
|
||||
reading: t("status.reading"),
|
||||
read: t("status.read"),
|
||||
};
|
||||
|
||||
const isRead = status === "read";
|
||||
|
||||
@@ -71,11 +78,11 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
||||
<div className="relative">
|
||||
<BookImage
|
||||
src={coverUrl}
|
||||
alt={`Couverture de ${book.title}`}
|
||||
alt={t("books.coverOf", { name: book.title })}
|
||||
/>
|
||||
{overlay && (
|
||||
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wide ${overlay.className}`}>
|
||||
{overlay.label}
|
||||
{overlayClass && status && (
|
||||
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wide ${overlayClass}`}>
|
||||
{statusLabels[status]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount: number }) {
|
||||
const { t } = useTranslation();
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
const pages = Array.from({ length: PAGE_SIZE }, (_, i) => offset + i + 1).filter(
|
||||
@@ -16,9 +18,9 @@ export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount:
|
||||
<div className="bg-card rounded-xl border border-border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Aperçu
|
||||
{t("bookPreview.preview")}
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
pages {offset + 1}–{Math.min(offset + PAGE_SIZE, pageCount)} / {pageCount}
|
||||
{t("bookPreview.pages", { start: offset + 1, end: Math.min(offset + PAGE_SIZE, pageCount), total: pageCount })}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
@@ -27,14 +29,14 @@ export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount:
|
||||
disabled={offset === 0}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-muted/50 text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Préc.
|
||||
{t("bookPreview.prev")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOffset((o) => Math.min(o + PAGE_SIZE, pageCount - 1))}
|
||||
disabled={offset + PAGE_SIZE >= pageCount}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-muted/50 text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Suiv. →
|
||||
{t("bookPreview.next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "./ui";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface ConvertButtonProps {
|
||||
bookId: string;
|
||||
@@ -15,6 +16,7 @@ type ConvertState =
|
||||
| { type: "error"; message: string };
|
||||
|
||||
export function ConvertButton({ bookId }: ConvertButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<ConvertState>({ type: "idle" });
|
||||
|
||||
const handleConvert = async () => {
|
||||
@@ -23,22 +25,22 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
|
||||
const res = await fetch(`/api/books/${bookId}/convert`, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
setState({ type: "error", message: body.error || "Échec de la conversion" });
|
||||
setState({ type: "error", message: body.error || t("convert.failed") });
|
||||
return;
|
||||
}
|
||||
const job = await res.json();
|
||||
setState({ type: "success", jobId: job.id });
|
||||
} catch (err) {
|
||||
setState({ type: "error", message: err instanceof Error ? err.message : "Erreur inconnue" });
|
||||
setState({ type: "error", message: err instanceof Error ? err.message : t("convert.unknownError") });
|
||||
}
|
||||
};
|
||||
|
||||
if (state.type === "success") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-success">
|
||||
<span>Conversion lancée.</span>
|
||||
<span>{t("convert.started")}</span>
|
||||
<Link href={`/jobs/${state.jobId}`} className="text-primary hover:underline font-medium">
|
||||
Voir la tâche →
|
||||
{t("convert.viewJob")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -52,7 +54,7 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
|
||||
className="text-xs text-muted-foreground hover:underline text-left"
|
||||
onClick={() => setState({ type: "idle" })}
|
||||
>
|
||||
Fermer
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -65,7 +67,7 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
|
||||
onClick={handleConvert}
|
||||
disabled={state.type === "loading"}
|
||||
>
|
||||
{state.type === "loading" ? "Conversion…" : "Convertir en CBZ"}
|
||||
{state.type === "loading" ? t("convert.converting") : t("convert.convertToCbz")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { BookDto } from "@/lib/api";
|
||||
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
function LockButton({
|
||||
locked,
|
||||
@@ -15,6 +16,7 @@ function LockButton({
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -25,7 +27,7 @@ function LockButton({
|
||||
? "text-amber-500 hover:text-amber-600"
|
||||
: "text-muted-foreground/40 hover:text-muted-foreground"
|
||||
}`}
|
||||
title={locked ? "Champ verrouillé (protégé des synchros)" : "Cliquer pour verrouiller ce champ"}
|
||||
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
|
||||
>
|
||||
{locked ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -45,6 +47,7 @@ interface EditBookFormProps {
|
||||
}
|
||||
|
||||
export function EditBookForm({ book }: EditBookFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -139,13 +142,13 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error ?? "Erreur lors de la sauvegarde");
|
||||
setError(data.error ?? t("editBook.saveError"));
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Erreur réseau");
|
||||
setError(t("common.networkError"));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -163,7 +166,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
||||
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
||||
<h3 className="font-semibold text-foreground">Modifier les métadonnées</h3>
|
||||
<h3 className="font-semibold text-foreground">{t("editBook.editMetadata")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
@@ -181,21 +184,21 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField className="sm:col-span-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel required>Titre</FormLabel>
|
||||
<FormLabel required>{t("editBook.title")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.title} onToggle={() => toggleLock("title")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Titre du livre"
|
||||
placeholder={t("editBook.titlePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Auteurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Auteur(s)</FormLabel>
|
||||
<FormLabel>{t("editBook.authors")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.authors} onToggle={() => toggleLock("authors")} disabled={isPending} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -212,7 +215,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
||||
onClick={() => removeAuthor(i)}
|
||||
disabled={isPending}
|
||||
className="hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={`Supprimer ${a}`}
|
||||
aria-label={t("editBook.removeAuthor", { name: a })}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -227,7 +230,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
||||
onChange={(e) => setAuthorInput(e.target.value)}
|
||||
onKeyDown={handleAuthorKeyDown}
|
||||
disabled={isPending}
|
||||
placeholder="Ajouter un auteur (Entrée pour valider)"
|
||||
placeholder={t("editBook.addAuthor")}
|
||||
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
@@ -244,33 +247,33 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Langue</FormLabel>
|
||||
<FormLabel>{t("editBook.language")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.language} onToggle={() => toggleLock("language")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="ex : fr, en, jp"
|
||||
placeholder={t("editBook.languagePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Série</FormLabel>
|
||||
<FormLabel>{t("editBook.series")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.series} onToggle={() => toggleLock("series")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
value={series}
|
||||
onChange={(e) => setSeries(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Nom de la série"
|
||||
placeholder={t("editBook.seriesPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Volume</FormLabel>
|
||||
<FormLabel>{t("editBook.volume")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.volume} onToggle={() => toggleLock("volume")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
@@ -279,13 +282,13 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Numéro de volume"
|
||||
placeholder={t("editBook.volumePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>ISBN</FormLabel>
|
||||
<FormLabel>{t("editBook.isbn")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.isbn} onToggle={() => toggleLock("isbn")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
@@ -298,27 +301,27 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Date de publication</FormLabel>
|
||||
<FormLabel>{t("editBook.publishDate")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.publish_date} onToggle={() => toggleLock("publish_date")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
value={publishDate}
|
||||
onChange={(e) => setPublishDate(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="ex : 2023-01-15"
|
||||
placeholder={t("editBook.publishDatePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField className="sm:col-span-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormLabel>{t("editBook.description")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.summary} onToggle={() => toggleLock("summary")} disabled={isPending} />
|
||||
</div>
|
||||
<textarea
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Résumé / description du livre"
|
||||
placeholder={t("editBook.descriptionPlaceholder")}
|
||||
rows={4}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
|
||||
/>
|
||||
@@ -331,7 +334,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Les champs verrouillés ne seront pas écrasés par les synchros de métadonnées externes.
|
||||
{t("editBook.lockedFieldsNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -347,14 +350,14 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
||||
disabled={isPending}
|
||||
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Annuler
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || !title.trim()}
|
||||
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isPending ? "Sauvegarde…" : "Sauvegarder"}
|
||||
{isPending ? t("editBook.savingLabel") : t("editBook.saveLabel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -370,7 +373,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
||||
>
|
||||
<span>✏️</span> Modifier
|
||||
<span>✏️</span> {t("editBook.editMetadata")}
|
||||
</button>
|
||||
{modal}
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useTransition, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
function LockButton({
|
||||
locked,
|
||||
@@ -14,6 +15,7 @@ function LockButton({
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -24,7 +26,7 @@ function LockButton({
|
||||
? "text-amber-500 hover:text-amber-600"
|
||||
: "text-muted-foreground/40 hover:text-muted-foreground"
|
||||
}`}
|
||||
title={locked ? "Champ verrouillé (protégé des synchros)" : "Cliquer pour verrouiller ce champ"}
|
||||
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
|
||||
>
|
||||
{locked ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -39,14 +41,7 @@ function LockButton({
|
||||
);
|
||||
}
|
||||
|
||||
const SERIES_STATUSES = [
|
||||
{ value: "", label: "Non défini" },
|
||||
{ value: "ongoing", label: "En cours" },
|
||||
{ value: "ended", label: "Terminée" },
|
||||
{ value: "hiatus", label: "Hiatus" },
|
||||
{ value: "cancelled", label: "Annulée" },
|
||||
{ value: "upcoming", label: "À paraître" },
|
||||
] as const;
|
||||
const SERIES_STATUS_VALUES = ["", "ongoing", "ended", "hiatus", "cancelled", "upcoming"] as const;
|
||||
|
||||
interface EditSeriesFormProps {
|
||||
libraryId: string;
|
||||
@@ -75,6 +70,7 @@ export function EditSeriesForm({
|
||||
currentStatus,
|
||||
currentLockedFields,
|
||||
}: EditSeriesFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -213,7 +209,7 @@ export function EditSeriesForm({
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error ?? "Erreur lors de la sauvegarde");
|
||||
setError(data.error ?? t("editBook.saveError"));
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
@@ -224,7 +220,7 @@ export function EditSeriesForm({
|
||||
router.refresh();
|
||||
}
|
||||
} catch {
|
||||
setError("Erreur réseau");
|
||||
setError(t("common.networkError"));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -242,7 +238,7 @@ export function EditSeriesForm({
|
||||
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
||||
<h3 className="font-semibold text-foreground">Modifier la série</h3>
|
||||
<h3 className="font-semibold text-foreground">{t("editSeries.title")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
@@ -259,18 +255,18 @@ export function EditSeriesForm({
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField>
|
||||
<FormLabel required>Nom</FormLabel>
|
||||
<FormLabel required>{t("editSeries.name")}</FormLabel>
|
||||
<FormInput
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Nom de la série"
|
||||
placeholder={t("editSeries.namePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Année de début</FormLabel>
|
||||
<FormLabel>{t("editSeries.startYear")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.start_year} onToggle={() => toggleLock("start_year")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
@@ -280,13 +276,13 @@ export function EditSeriesForm({
|
||||
value={startYear}
|
||||
onChange={(e) => setStartYear(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="ex : 1990"
|
||||
placeholder={t("editSeries.startYearPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Nombre de volumes</FormLabel>
|
||||
<FormLabel>{t("editSeries.totalVolumes")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.total_volumes} onToggle={() => toggleLock("total_volumes")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
@@ -295,13 +291,13 @@ export function EditSeriesForm({
|
||||
value={totalVolumes}
|
||||
onChange={(e) => setTotalVolumes(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="ex : 12"
|
||||
placeholder="12"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Statut</FormLabel>
|
||||
<FormLabel>{t("editSeries.status")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.status} onToggle={() => toggleLock("status")} disabled={isPending} />
|
||||
</div>
|
||||
<select
|
||||
@@ -310,8 +306,10 @@ export function EditSeriesForm({
|
||||
disabled={isPending}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
>
|
||||
{SERIES_STATUSES.map((s) => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
{SERIES_STATUS_VALUES.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v === "" ? t("seriesStatus.notDefined") : t(`seriesStatus.${v}` as any)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
@@ -319,7 +317,7 @@ export function EditSeriesForm({
|
||||
{/* Auteurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Auteur(s)</FormLabel>
|
||||
<FormLabel>{t("editSeries.authors")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.authors} onToggle={() => toggleLock("authors")} disabled={isPending} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -336,7 +334,7 @@ export function EditSeriesForm({
|
||||
onClick={() => removeAuthor(i)}
|
||||
disabled={isPending}
|
||||
className="hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={`Supprimer ${a}`}
|
||||
aria-label={t("editBook.removeAuthor", { name: a })}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -351,7 +349,7 @@ export function EditSeriesForm({
|
||||
onChange={(e) => setAuthorInput(e.target.value)}
|
||||
onKeyDown={handleAuthorKeyDown}
|
||||
disabled={isPending}
|
||||
placeholder="Ajouter un auteur (Entrée pour valider)"
|
||||
placeholder={t("editBook.addAuthor")}
|
||||
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
@@ -371,9 +369,9 @@ export function EditSeriesForm({
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
title="Appliquer auteur et langue à tous les livres de la série"
|
||||
title={t("editSeries.applyToBooksTitle")}
|
||||
>
|
||||
→ livres
|
||||
{t("editSeries.applyToBooks")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -382,21 +380,21 @@ export function EditSeriesForm({
|
||||
{showApplyToBooks && (
|
||||
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-3 pl-4 border-l-2 border-primary/30">
|
||||
<FormField>
|
||||
<FormLabel>Auteur (livres)</FormLabel>
|
||||
<FormLabel>{t("editSeries.bookAuthor")}</FormLabel>
|
||||
<FormInput
|
||||
value={bookAuthor}
|
||||
onChange={(e) => setBookAuthor(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Écrase le champ auteur de chaque livre"
|
||||
placeholder={t("editSeries.bookAuthorPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<FormLabel>Langue (livres)</FormLabel>
|
||||
<FormLabel>{t("editSeries.bookLanguage")}</FormLabel>
|
||||
<FormInput
|
||||
value={bookLanguage}
|
||||
onChange={(e) => setBookLanguage(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="ex : fr, en, jp"
|
||||
placeholder={t("editBook.languagePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -405,7 +403,7 @@ export function EditSeriesForm({
|
||||
{/* Éditeurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Éditeur(s)</FormLabel>
|
||||
<FormLabel>{t("editSeries.publishers")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.publishers} onToggle={() => toggleLock("publishers")} disabled={isPending} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -422,7 +420,7 @@ export function EditSeriesForm({
|
||||
onClick={() => removePublisher(i)}
|
||||
disabled={isPending}
|
||||
className="hover:text-destructive transition-colors ml-0.5"
|
||||
aria-label={`Supprimer ${p}`}
|
||||
aria-label={t("editBook.removeAuthor", { name: p })}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -437,7 +435,7 @@ export function EditSeriesForm({
|
||||
onChange={(e) => setPublisherInput(e.target.value)}
|
||||
onKeyDown={handlePublisherKeyDown}
|
||||
disabled={isPending}
|
||||
placeholder="Ajouter un éditeur (Entrée pour valider)"
|
||||
placeholder={t("editSeries.addPublisher")}
|
||||
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
@@ -454,7 +452,7 @@ export function EditSeriesForm({
|
||||
|
||||
<FormField className="sm:col-span-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormLabel>{t("editBook.description")}</FormLabel>
|
||||
<LockButton locked={!!lockedFields.description} onToggle={() => toggleLock("description")} disabled={isPending} />
|
||||
</div>
|
||||
<textarea
|
||||
@@ -462,7 +460,7 @@ export function EditSeriesForm({
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isPending}
|
||||
rows={3}
|
||||
placeholder="Synopsis ou description de la série…"
|
||||
placeholder={t("editSeries.descriptionPlaceholder")}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none"
|
||||
/>
|
||||
</FormField>
|
||||
@@ -474,7 +472,7 @@ export function EditSeriesForm({
|
||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Les champs verrouillés ne seront pas écrasés par les synchros de métadonnées externes.
|
||||
{t("editBook.lockedFieldsNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -488,14 +486,14 @@ export function EditSeriesForm({
|
||||
disabled={isPending}
|
||||
className="px-4 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Annuler
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || (!newName.trim() && seriesName !== "unclassified")}
|
||||
className="px-4 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isPending ? "Sauvegarde…" : "Sauvegarder"}
|
||||
{isPending ? t("common.saving") : t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -511,7 +509,7 @@ export function EditSeriesForm({
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
||||
>
|
||||
<span>✏️</span> Modifier la série
|
||||
<span>✏️</span> {t("editSeries.title")}
|
||||
</button>
|
||||
{modal}
|
||||
</>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { FolderItem } from "../../lib/api";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface TreeNode extends FolderItem {
|
||||
children?: TreeNode[];
|
||||
@@ -15,6 +16,7 @@ interface FolderBrowserProps {
|
||||
}
|
||||
|
||||
export function FolderBrowser({ initialFolders, selectedPath, onSelect }: FolderBrowserProps) {
|
||||
const { t } = useTranslation();
|
||||
// Convert initial folders to tree structure
|
||||
const [tree, setTree] = useState<TreeNode[]>(
|
||||
initialFolders.map(f => ({ ...f, children: f.has_children ? [] : undefined }))
|
||||
@@ -173,7 +175,7 @@ export function FolderBrowser({ initialFolders, selectedPath, onSelect }: Folder
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{tree.length === 0 ? (
|
||||
<div className="px-3 py-8 text-sm text-muted-foreground text-center">
|
||||
Aucun dossier trouvé
|
||||
{t("folder.noFolders")}
|
||||
</div>
|
||||
) : (
|
||||
tree.map(node => renderNode(node))
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { FolderBrowser } from "./FolderBrowser";
|
||||
import { FolderItem } from "../../lib/api";
|
||||
import { Button } from "./ui";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface FolderPickerProps {
|
||||
initialFolders: FolderItem[];
|
||||
@@ -13,6 +14,7 @@ interface FolderPickerProps {
|
||||
|
||||
export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderPickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSelect = (path: string) => {
|
||||
onSelect(path);
|
||||
@@ -27,7 +29,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={selectedPath || "Sélectionner un dossier..."}
|
||||
value={selectedPath || t("folder.selectFolder")}
|
||||
className={`
|
||||
w-full px-3 py-2 rounded-lg border bg-card
|
||||
text-sm font-mono
|
||||
@@ -57,7 +59,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
Parcourir
|
||||
{t("common.browse")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +81,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="font-medium">Sélectionner le dossier</span>
|
||||
<span className="font-medium">{t("folder.selectFolderTitle")}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -104,7 +106,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-muted/30">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Cliquez sur un dossier pour le sélectionner
|
||||
{t("folder.clickToSelect")}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -113,7 +115,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Annuler
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import { StatusBadge, Badge, ProgressBar } from "./ui";
|
||||
|
||||
interface ProgressEvent {
|
||||
@@ -24,6 +25,7 @@ interface JobProgressProps {
|
||||
}
|
||||
|
||||
export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
const { t } = useTranslation();
|
||||
const [progress, setProgress] = useState<ProgressEvent | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
@@ -53,25 +55,25 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
onComplete?.();
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Échec de l'analyse des données SSE");
|
||||
setError(t("jobProgress.sseError"));
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.error("SSE error:", err);
|
||||
eventSource.close();
|
||||
setError("Connexion perdue");
|
||||
setError(t("jobProgress.connectionLost"));
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [jobId, onComplete]);
|
||||
}, [jobId, onComplete, t]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-destructive/10 text-error rounded-lg text-sm">
|
||||
Erreur : {error}
|
||||
{t("jobProgress.error", { message: error })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,7 +81,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
if (!progress) {
|
||||
return (
|
||||
<div className="p-4 text-muted-foreground text-sm">
|
||||
Chargement de la progression...
|
||||
{t("jobProgress.loadingProgress")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,14 +90,14 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
const processed = progress.processed_files ?? 0;
|
||||
const total = progress.total_files ?? 0;
|
||||
const isPhase2 = progress.status === "extracting_pages" || progress.status === "generating_thumbnails";
|
||||
const unitLabel = progress.status === "extracting_pages" ? "pages" : progress.status === "generating_thumbnails" ? "miniatures" : "fichiers";
|
||||
const unitLabel = progress.status === "extracting_pages" ? t("jobProgress.pages") : progress.status === "generating_thumbnails" ? t("jobProgress.thumbnails") : t("jobProgress.filesUnit");
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-card rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<StatusBadge status={progress.status} />
|
||||
{isComplete && (
|
||||
<Badge variant="success">Terminé</Badge>
|
||||
<Badge variant="success">{t("jobProgress.done")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -105,20 +107,20 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
<span>{processed} / {total} {unitLabel}</span>
|
||||
{progress.current_file && (
|
||||
<span className="truncate max-w-md" title={progress.current_file}>
|
||||
En cours : {progress.current_file.length > 40
|
||||
{t("jobProgress.currentFile", { file: progress.current_file.length > 40
|
||||
? progress.current_file.substring(0, 40) + "..."
|
||||
: progress.current_file}
|
||||
: progress.current_file })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{progress.stats_json && !isPhase2 && (
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
<Badge variant="primary">Analysés : {progress.stats_json.scanned_files}</Badge>
|
||||
<Badge variant="success">Indexés : {progress.stats_json.indexed_files}</Badge>
|
||||
<Badge variant="warning">Supprimés : {progress.stats_json.removed_files}</Badge>
|
||||
<Badge variant="primary">{t("jobProgress.scanned", { count: progress.stats_json.scanned_files })}</Badge>
|
||||
<Badge variant="success">{t("jobProgress.indexed", { count: progress.stats_json.indexed_files })}</Badge>
|
||||
<Badge variant="warning">{t("jobProgress.removed", { count: progress.stats_json.removed_files })}</Badge>
|
||||
{progress.stats_json.errors > 0 && (
|
||||
<Badge variant="error">Erreurs : {progress.stats_json.errors}</Badge>
|
||||
<Badge variant="error">{t("jobProgress.errors", { count: progress.stats_json.errors })}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import { JobProgress } from "./JobProgress";
|
||||
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar } from "./ui";
|
||||
|
||||
@@ -33,6 +34,7 @@ interface JobRowProps {
|
||||
}
|
||||
|
||||
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const isActive = job.status === "running" || job.status === "pending" || job.status === "extracting_pages" || job.status === "generating_thumbnails";
|
||||
const [showProgress, setShowProgress] = useState(highlighted || isActive);
|
||||
|
||||
@@ -63,12 +65,12 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
||||
? job.total_files != null
|
||||
? `${job.processed_files ?? 0}/${job.total_files}`
|
||||
: scanned > 0
|
||||
? `${scanned} analysés`
|
||||
? t("jobRow.scanned", { count: scanned })
|
||||
: "-"
|
||||
: job.status === "success" && (indexed > 0 || removed > 0 || errors > 0)
|
||||
? null // rendered below as ✓ / − / ⚠
|
||||
: scanned > 0
|
||||
? `${scanned} analysés`
|
||||
? t("jobRow.scanned", { count: scanned })
|
||||
: "—";
|
||||
|
||||
// Thumbnails column (Phase 2: extracting_pages + generating_thumbnails)
|
||||
@@ -113,7 +115,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
||||
className="text-xs text-primary hover:text-primary/80 hover:underline"
|
||||
onClick={() => setShowProgress(!showProgress)}
|
||||
>
|
||||
{showProgress ? "Masquer" : "Afficher"} la progression
|
||||
{showProgress ? t("jobRow.hideProgress") : t("jobRow.showProgress")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -154,7 +156,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
||||
href={`/jobs/${job.id}`}
|
||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Voir
|
||||
{t("jobRow.view")}
|
||||
</Link>
|
||||
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
|
||||
<Button
|
||||
@@ -162,7 +164,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
||||
size="sm"
|
||||
onClick={() => onCancel(job.id)}
|
||||
>
|
||||
Annuler
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import { Badge } from "./ui/Badge";
|
||||
import { ProgressBar } from "./ui/ProgressBar";
|
||||
|
||||
@@ -45,6 +46,7 @@ const ChevronIcon = ({ className }: { className?: string }) => (
|
||||
);
|
||||
|
||||
export function JobsIndicator() {
|
||||
const { t } = useTranslation();
|
||||
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -152,7 +154,7 @@ export function JobsIndicator() {
|
||||
hover:bg-accent
|
||||
transition-colors duration-200
|
||||
"
|
||||
title="Voir toutes les tâches"
|
||||
title={t("jobsIndicator.viewAll")}
|
||||
>
|
||||
<JobsIcon className="w-[18px] h-[18px]" />
|
||||
</Link>
|
||||
@@ -187,11 +189,11 @@ export function JobsIndicator() {
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">📊</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">Tâches actives</h3>
|
||||
<h3 className="font-semibold text-foreground">{t("jobsIndicator.activeTasks")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{runningJobs.length > 0
|
||||
? `${runningJobs.length} en cours, ${pendingJobs.length} en attente`
|
||||
: `${pendingJobs.length} tâche${pendingJobs.length !== 1 ? 's' : ''} en attente`
|
||||
? t("jobsIndicator.runningAndPending", { running: runningJobs.length, pending: pendingJobs.length })
|
||||
: t("jobsIndicator.pendingTasks", { count: pendingJobs.length, plural: pendingJobs.length !== 1 ? "s" : "" })
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@@ -201,7 +203,7 @@ export function JobsIndicator() {
|
||||
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Tout voir →
|
||||
{t("jobsIndicator.viewAllLink")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -209,7 +211,7 @@ export function JobsIndicator() {
|
||||
{runningJobs.length > 0 && (
|
||||
<div className="px-4 py-3 border-b border-border/60">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-muted-foreground">Progression globale</span>
|
||||
<span className="text-muted-foreground">{t("jobsIndicator.overallProgress")}</span>
|
||||
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
|
||||
</div>
|
||||
<ProgressBar value={totalProgress} size="sm" variant="success" />
|
||||
@@ -221,7 +223,7 @@ export function JobsIndicator() {
|
||||
{activeJobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<span className="text-4xl mb-2">✅</span>
|
||||
<p>Aucune tâche active</p>
|
||||
<p>{t("jobsIndicator.noActiveTasks")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
@@ -242,7 +244,7 @@ export function JobsIndicator() {
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<code className="text-xs px-1.5 py-0.5 bg-muted rounded font-mono">{job.id.slice(0, 8)}</code>
|
||||
<Badge variant={job.type === 'rebuild' ? 'primary' : job.type === 'thumbnail_regenerate' ? 'warning' : 'secondary'} className="text-[10px]">
|
||||
{job.type === 'thumbnail_rebuild' ? 'Miniatures' : job.type === 'thumbnail_regenerate' ? 'Regénération' : job.type}
|
||||
{t(`jobType.${job.type}` as any) !== `jobType.${job.type}` ? t(`jobType.${job.type}` as any) : job.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -281,7 +283,7 @@ export function JobsIndicator() {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-border/60 bg-muted/50">
|
||||
<p className="text-xs text-muted-foreground text-center">Actualisation automatique toutes les 2s</p>
|
||||
<p className="text-xs text-muted-foreground text-center">{t("jobsIndicator.autoRefresh")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -304,7 +306,7 @@ export function JobsIndicator() {
|
||||
${isOpen ? 'ring-2 ring-ring ring-offset-2 ring-offset-background' : ''}
|
||||
`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title={`${totalCount} tâche${totalCount !== 1 ? 's' : ''} active${totalCount !== 1 ? 's' : ''}`}
|
||||
title={t("jobsIndicator.taskCount", { count: totalCount, plural: totalCount !== 1 ? "s" : "" })}
|
||||
>
|
||||
{/* Animated spinner for running jobs */}
|
||||
{runningJobs.length > 0 && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import { JobRow } from "./JobRow";
|
||||
|
||||
interface Job {
|
||||
@@ -39,26 +40,36 @@ function formatDuration(start: string, end: string | null): string {
|
||||
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
function getDateParts(dateStr: string): { mins: number; hours: number; useDate: boolean; date: Date } {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 3600000) {
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "À l'instant";
|
||||
return `il y a ${mins}m`;
|
||||
return { mins, hours: 0, useDate: false, date };
|
||||
}
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
return `il y a ${hours}h`;
|
||||
return { mins: 0, hours, useDate: false, date };
|
||||
}
|
||||
return date.toLocaleDateString();
|
||||
return { mins: 0, hours: 0, useDate: true, date };
|
||||
}
|
||||
|
||||
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [jobs, setJobs] = useState(initialJobs);
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const parts = getDateParts(dateStr);
|
||||
if (parts.useDate) {
|
||||
return parts.date.toLocaleDateString();
|
||||
}
|
||||
if (parts.mins < 1) return t("time.justNow");
|
||||
if (parts.hours > 0) return t("time.hoursAgo", { count: parts.hours });
|
||||
return t("time.minutesAgo", { count: parts.mins });
|
||||
};
|
||||
|
||||
// Refresh jobs list via SSE
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource("/api/jobs/stream");
|
||||
@@ -102,15 +113,15 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/50">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Bibliothèque</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Statut</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Fichiers</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Miniatures</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Durée</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Créé</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.id")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.library")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.type")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.status")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.files")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.thumbnails")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.duration")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.created")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useRef, useEffect, useTransition } from "react";
|
||||
import { Button } from "../components/ui";
|
||||
import { ProviderIcon } from "../components/ProviderIcon";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface LibraryActionsProps {
|
||||
libraryId: string;
|
||||
@@ -23,6 +24,7 @@ export function LibraryActions({
|
||||
fallbackMetadataProvider,
|
||||
onUpdate
|
||||
}: LibraryActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
@@ -109,7 +111,7 @@ export function LibraryActions({
|
||||
defaultChecked={monitorEnabled}
|
||||
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
||||
/>
|
||||
Scan auto
|
||||
{t("libraryActions.autoScan")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -122,36 +124,36 @@ export function LibraryActions({
|
||||
defaultChecked={watcherEnabled}
|
||||
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
||||
/>
|
||||
Surveillance fichiers ⚡
|
||||
{t("libraryActions.fileWatch")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">📅 Planification</label>
|
||||
<label className="text-sm font-medium text-foreground">{t("libraryActions.schedule")}</label>
|
||||
<select
|
||||
name="scan_mode"
|
||||
defaultValue={scanMode}
|
||||
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||
>
|
||||
<option value="manual">Manuel</option>
|
||||
<option value="hourly">Toutes les heures</option>
|
||||
<option value="daily">Quotidien</option>
|
||||
<option value="weekly">Hebdomadaire</option>
|
||||
<option value="manual">{t("monitoring.manual")}</option>
|
||||
<option value="hourly">{t("monitoring.hourly")}</option>
|
||||
<option value="daily">{t("monitoring.daily")}</option>
|
||||
<option value="weekly">{t("monitoring.weekly")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||
{metadataProvider && <ProviderIcon provider={metadataProvider} size={16} />}
|
||||
Fournisseur
|
||||
{t("libraryActions.provider")}
|
||||
</label>
|
||||
<select
|
||||
name="metadata_provider"
|
||||
defaultValue={metadataProvider || ""}
|
||||
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||
>
|
||||
<option value="">Par défaut</option>
|
||||
<option value="none">Aucun</option>
|
||||
<option value="">{t("libraryActions.default")}</option>
|
||||
<option value="none">{t("libraryActions.none")}</option>
|
||||
<option value="google_books">Google Books</option>
|
||||
<option value="comicvine">ComicVine</option>
|
||||
<option value="open_library">Open Library</option>
|
||||
@@ -163,14 +165,14 @@ export function LibraryActions({
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||
{fallbackMetadataProvider && fallbackMetadataProvider !== "none" && <ProviderIcon provider={fallbackMetadataProvider} size={16} />}
|
||||
Secours
|
||||
{t("libraryActions.fallback")}
|
||||
</label>
|
||||
<select
|
||||
name="fallback_metadata_provider"
|
||||
defaultValue={fallbackMetadataProvider || ""}
|
||||
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||
>
|
||||
<option value="">Aucun</option>
|
||||
<option value="">{t("libraryActions.none")}</option>
|
||||
<option value="google_books">Google Books</option>
|
||||
<option value="comicvine">ComicVine</option>
|
||||
<option value="open_library">Open Library</option>
|
||||
@@ -191,7 +193,7 @@ export function LibraryActions({
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? "Enregistrement..." : "Enregistrer"}
|
||||
{isPending ? t("libraryActions.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { FolderPicker } from "./FolderPicker";
|
||||
import { FolderItem } from "../../lib/api";
|
||||
import { Button, FormField, FormInput, FormRow } from "./ui";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface LibraryFormProps {
|
||||
initialFolders: FolderItem[];
|
||||
@@ -11,13 +12,14 @@ interface LibraryFormProps {
|
||||
}
|
||||
|
||||
export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPath, setSelectedPath] = useState<string>("");
|
||||
|
||||
return (
|
||||
<form action={action}>
|
||||
<FormRow>
|
||||
<FormField className="flex-1 min-w-48">
|
||||
<FormInput name="name" placeholder="Nom de la bibliothèque" required />
|
||||
<FormInput name="name" placeholder={t("libraries.libraryName")} required />
|
||||
</FormField>
|
||||
<FormField className="flex-1 min-w-64">
|
||||
<input type="hidden" name="root_path" value={selectedPath} />
|
||||
@@ -30,7 +32,7 @@ export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
|
||||
</FormRow>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button type="submit" disabled={!selectedPath}>
|
||||
Ajouter une bibliothèque
|
||||
{t("libraries.addButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Card, Badge } from "./ui";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
|
||||
interface LibrarySubPageHeaderProps {
|
||||
library: {
|
||||
@@ -19,13 +20,14 @@ interface LibrarySubPageHeaderProps {
|
||||
};
|
||||
}
|
||||
|
||||
export function LibrarySubPageHeader({
|
||||
export async function LibrarySubPageHeader({
|
||||
library,
|
||||
title,
|
||||
icon,
|
||||
iconColor = "text-primary",
|
||||
filterInfo
|
||||
}: LibrarySubPageHeaderProps) {
|
||||
const { t } = await getServerTranslations();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header avec breadcrumb intégré */}
|
||||
@@ -38,7 +40,7 @@ export function LibrarySubPageHeader({
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Bibliothèques
|
||||
{t("libraryHeader.libraries")}
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-sm text-foreground font-medium">{library.name}</span>
|
||||
@@ -73,8 +75,7 @@ export function LibrarySubPageHeader({
|
||||
<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>
|
||||
<span className="text-foreground">
|
||||
<span className="font-semibold">{library.book_count}</span>
|
||||
<span className="text-muted-foreground ml-1">livre{library.book_count !== 1 ? 's' : ''}</span>
|
||||
<span className="text-muted-foreground ml-1">{t("libraryHeader.bookCount", { count: library.book_count, plural: library.book_count !== 1 ? "s" : "" })}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +87,7 @@ export function LibrarySubPageHeader({
|
||||
variant={library.enabled ? "success" : "muted"}
|
||||
className="text-xs"
|
||||
>
|
||||
{library.enabled ? "Activée" : "Désactivée"}
|
||||
{library.enabled ? t("libraryHeader.enabled") : t("libraries.disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface FieldDef {
|
||||
name: string;
|
||||
@@ -21,6 +22,7 @@ interface LiveSearchFormProps {
|
||||
export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearchFormProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
@@ -120,7 +122,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
||||
w-full sm:w-auto
|
||||
"
|
||||
>
|
||||
Effacer
|
||||
{t("common.clear")}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "./ui";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface MarkBookReadButtonProps {
|
||||
bookId: string;
|
||||
@@ -10,12 +11,13 @@ interface MarkBookReadButtonProps {
|
||||
}
|
||||
|
||||
export function MarkBookReadButton({ bookId, currentStatus }: MarkBookReadButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const isRead = currentStatus === "read";
|
||||
const targetStatus = isRead ? "unread" : "read";
|
||||
const label = isRead ? "Marquer non lu" : "Marquer comme lu";
|
||||
const label = isRead ? t("markRead.markUnread") : t("markRead.markAsRead");
|
||||
|
||||
const handleClick = async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface MarkSeriesReadButtonProps {
|
||||
seriesName: string;
|
||||
@@ -10,12 +11,13 @@ interface MarkSeriesReadButtonProps {
|
||||
}
|
||||
|
||||
export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }: MarkSeriesReadButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const allRead = booksReadCount >= bookCount;
|
||||
const targetStatus = allRead ? "unread" : "read";
|
||||
const label = allRead ? "Marquer non lu" : "Tout marquer lu";
|
||||
const label = allRead ? t("markRead.markUnread") : t("markRead.markAllRead");
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -6,23 +6,12 @@ import { useRouter } from "next/navigation";
|
||||
import { Icon } from "./ui";
|
||||
import { ProviderIcon, PROVIDERS, providerLabel } from "./ProviderIcon";
|
||||
import type { ExternalMetadataLinkDto, SeriesCandidateDto, MissingBooksDto, SyncReport } from "../../lib/api";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
description: "Description",
|
||||
authors: "Auteurs",
|
||||
publishers: "Éditeurs",
|
||||
start_year: "Année",
|
||||
total_volumes: "Nb volumes",
|
||||
status: "Statut",
|
||||
summary: "Résumé",
|
||||
isbn: "ISBN",
|
||||
publish_date: "Date de publication",
|
||||
language: "Langue",
|
||||
};
|
||||
|
||||
function fieldLabel(field: string): string {
|
||||
return FIELD_LABELS[field] ?? field;
|
||||
}
|
||||
const FIELD_KEYS: string[] = [
|
||||
"description", "authors", "publishers", "start_year",
|
||||
"total_volumes", "status", "summary", "isbn", "publish_date", "language",
|
||||
];
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value == null) return "—";
|
||||
@@ -48,7 +37,15 @@ export function MetadataSearchModal({
|
||||
existingLink,
|
||||
initialMissing,
|
||||
}: MetadataSearchModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const fieldLabel = (field: string): string => {
|
||||
if (FIELD_KEYS.includes(field)) {
|
||||
return t(`field.${field}` as any);
|
||||
}
|
||||
return field;
|
||||
};
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [step, setStep] = useState<ModalStep>("idle");
|
||||
const [candidates, setCandidates] = useState<SeriesCandidateDto[]>([]);
|
||||
@@ -126,7 +123,7 @@ export function MetadataSearchModal({
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
setError(data.error || "Échec de la recherche");
|
||||
setError(data.error || t("metadata.searchFailed"));
|
||||
setStep("results");
|
||||
return;
|
||||
}
|
||||
@@ -138,7 +135,7 @@ export function MetadataSearchModal({
|
||||
}
|
||||
setStep("results");
|
||||
} catch {
|
||||
setError("Erreur réseau");
|
||||
setError(t("common.networkError"));
|
||||
setStep("results");
|
||||
}
|
||||
}
|
||||
@@ -177,7 +174,7 @@ export function MetadataSearchModal({
|
||||
});
|
||||
const matchData = await matchResp.json();
|
||||
if (!matchResp.ok) {
|
||||
setError(matchData.error || "Échec de la création du lien");
|
||||
setError(matchData.error || t("metadata.linkFailed"));
|
||||
setStep("results");
|
||||
return;
|
||||
}
|
||||
@@ -196,7 +193,7 @@ export function MetadataSearchModal({
|
||||
});
|
||||
const approveData = await approveResp.json();
|
||||
if (!approveResp.ok) {
|
||||
setError(approveData.error || "Échec de l'approbation");
|
||||
setError(approveData.error || t("metadata.approveFailed"));
|
||||
setStep("results");
|
||||
return;
|
||||
}
|
||||
@@ -218,7 +215,7 @@ export function MetadataSearchModal({
|
||||
|
||||
setStep("done");
|
||||
} catch {
|
||||
setError("Erreur réseau");
|
||||
setError(t("common.networkError"));
|
||||
setStep("results");
|
||||
}
|
||||
}
|
||||
@@ -262,7 +259,7 @@ export function MetadataSearchModal({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{step === "linked" ? "Lien métadonnées" : "Rechercher les métadonnées externes"}
|
||||
{step === "linked" ? t("metadata.metadataLink") : t("metadata.searchExternal")}
|
||||
</h3>
|
||||
<button type="button" onClick={handleClose}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-muted-foreground hover:text-foreground">
|
||||
@@ -275,7 +272,7 @@ export function MetadataSearchModal({
|
||||
{/* Provider selector — visible during searching & results */}
|
||||
{(step === "searching" || step === "results") && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted-foreground whitespace-nowrap">Fournisseur :</label>
|
||||
<label className="text-sm text-muted-foreground whitespace-nowrap">{t("metadata.provider")}</label>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{visibleProviders.map((p) => (
|
||||
<button
|
||||
@@ -304,7 +301,7 @@ export function MetadataSearchModal({
|
||||
{step === "searching" && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">Recherche de "{seriesName}"...</span>
|
||||
<span className="ml-3 text-muted-foreground">{t("metadata.searching", { name: seriesName })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -319,13 +316,13 @@ export function MetadataSearchModal({
|
||||
{step === "results" && (
|
||||
<>
|
||||
{candidates.length === 0 && !error ? (
|
||||
<p className="text-muted-foreground text-center py-8">Aucun résultat trouvé.</p>
|
||||
<p className="text-muted-foreground text-center py-8">{t("metadata.noResults")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{candidates.length} résultat{candidates.length !== 1 ? "s" : ""} trouvé{candidates.length !== 1 ? "s" : ""}
|
||||
{t("metadata.resultCount", { count: candidates.length, plural: candidates.length !== 1 ? "s" : "" })}
|
||||
{activeProvider && (
|
||||
<span className="ml-1 text-xs inline-flex items-center gap-1">via <ProviderIcon provider={activeProvider} size={12} /> <span className="font-medium">{providerLabel(activeProvider)}</span></span>
|
||||
<span className="ml-1 text-xs inline-flex items-center gap-1">{t("common.via")} <ProviderIcon provider={activeProvider} size={12} /> <span className="font-medium">{providerLabel(activeProvider)}</span></span>
|
||||
)}
|
||||
</p>
|
||||
{candidates.map((c, i) => (
|
||||
@@ -362,7 +359,7 @@ export function MetadataSearchModal({
|
||||
</span>
|
||||
)}
|
||||
{c.metadata_json?.status === "RELEASING" && (
|
||||
<span className="italic text-amber-500">en cours</span>
|
||||
<span className="italic text-amber-500">{t("metadata.inProgress")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,18 +390,18 @@ export function MetadataSearchModal({
|
||||
)}
|
||||
{selectedCandidate.total_volumes != null && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedCandidate.total_volumes} {selectedCandidate.metadata_json?.volume_source === "chapters" ? "chapitres" : "volumes"}
|
||||
{selectedCandidate.metadata_json?.status === "RELEASING" && <span className="italic text-amber-500 ml-1">(en cours)</span>}
|
||||
{selectedCandidate.total_volumes} {selectedCandidate.metadata_json?.volume_source === "chapters" ? t("metadata.chapters") : t("metadata.volumes")}
|
||||
{selectedCandidate.metadata_json?.status === "RELEASING" && <span className="italic text-amber-500 ml-1">({t("metadata.inProgress")})</span>}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1 inline-flex items-center gap-1">
|
||||
via <ProviderIcon provider={selectedCandidate.provider} size={12} /> <span className="font-medium">{providerLabel(selectedCandidate.provider)}</span>
|
||||
{t("common.via")} <ProviderIcon provider={selectedCandidate.provider} size={12} /> <span className="font-medium">{providerLabel(selectedCandidate.provider)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-foreground font-medium">Comment souhaitez-vous synchroniser ?</p>
|
||||
<p className="text-sm text-foreground font-medium">{t("metadata.howToSync")}</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
@@ -412,16 +409,16 @@ export function MetadataSearchModal({
|
||||
onClick={() => handleApprove(true, false)}
|
||||
className="w-full p-3 rounded-lg border border-border bg-card text-left hover:bg-muted/40 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<p className="font-medium text-sm text-foreground">Synchroniser la série uniquement</p>
|
||||
<p className="text-xs text-muted-foreground">Mettre à jour la description, les auteurs, les éditeurs et l'année</p>
|
||||
<p className="font-medium text-sm text-foreground">{t("metadata.syncSeriesOnly")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("metadata.syncSeriesOnlyDesc")}</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApprove(true, true)}
|
||||
className="w-full p-3 rounded-lg border border-primary/50 bg-primary/5 text-left hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
<p className="font-medium text-sm text-foreground">Synchroniser la série + les livres</p>
|
||||
<p className="text-xs text-muted-foreground">Récupérer aussi la liste des livres et afficher les tomes manquants</p>
|
||||
<p className="font-medium text-sm text-foreground">{t("metadata.syncSeriesAndBooks")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("metadata.syncSeriesAndBooksDesc")}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -430,7 +427,7 @@ export function MetadataSearchModal({
|
||||
onClick={() => { setSelectedCandidate(null); setStep("results"); }}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Retour aux résultats
|
||||
{t("metadata.backToResults")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -439,7 +436,7 @@ export function MetadataSearchModal({
|
||||
{step === "syncing" && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">Synchronisation des métadonnées...</span>
|
||||
<span className="ml-3 text-muted-foreground">{t("metadata.syncingMetadata")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -447,7 +444,7 @@ export function MetadataSearchModal({
|
||||
{step === "done" && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/30">
|
||||
<p className="font-medium text-green-600">Métadonnées synchronisées avec succès !</p>
|
||||
<p className="font-medium text-green-600">{t("metadata.syncSuccess")}</p>
|
||||
</div>
|
||||
|
||||
{/* Sync Report */}
|
||||
@@ -456,7 +453,7 @@ export function MetadataSearchModal({
|
||||
{/* Series report */}
|
||||
{syncReport.series && (syncReport.series.fields_updated.length > 0 || syncReport.series.fields_skipped.length > 0) && (
|
||||
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">Série</p>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">{t("metadata.seriesLabel")}</p>
|
||||
{syncReport.series.fields_updated.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{syncReport.series.fields_updated.map((f, i) => (
|
||||
@@ -478,7 +475,7 @@ export function MetadataSearchModal({
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span className="font-medium">{fieldLabel(f.field)}</span>
|
||||
<span className="text-muted-foreground">verrouillé</span>
|
||||
<span className="text-muted-foreground">{t("metadata.locked")}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -497,7 +494,7 @@ export function MetadataSearchModal({
|
||||
{!syncReport.books_message && (syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (
|
||||
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Livres — {syncReport.books_matched} associé{syncReport.books_matched !== 1 ? "s" : ""}{syncReport.books_unmatched > 0 && `, ${syncReport.books_unmatched} non associé${syncReport.books_unmatched !== 1 ? "s" : ""}`}
|
||||
{t("metadata.booksLabel")} — {t("metadata.booksMatched", { matched: syncReport.books_matched, plural: syncReport.books_matched !== 1 ? "s" : "" })}{syncReport.books_unmatched > 0 && `, ${t("metadata.booksUnmatched", { count: syncReport.books_unmatched, plural: syncReport.books_unmatched !== 1 ? "s" : "" })}`}
|
||||
</p>
|
||||
{syncReport.books.length > 0 && (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
@@ -520,7 +517,7 @@ export function MetadataSearchModal({
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span className="font-medium">{fieldLabel(f.field)}</span>
|
||||
<span className="text-muted-foreground">verrouillé</span>
|
||||
<span className="text-muted-foreground">{t("metadata.locked")}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -538,15 +535,15 @@ export function MetadataSearchModal({
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Externe</p>
|
||||
<p className="text-sm text-muted-foreground">{t("metadata.external")}</p>
|
||||
<p className="text-2xl font-semibold">{missing.total_external}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Locaux</p>
|
||||
<p className="text-sm text-muted-foreground">{t("metadata.local")}</p>
|
||||
<p className="text-2xl font-semibold">{missing.total_local}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Manquants</p>
|
||||
<p className="text-sm text-muted-foreground">{t("metadata.missingLabel")}</p>
|
||||
<p className="text-2xl font-semibold text-warning">{missing.missing_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -559,14 +556,14 @@ export function MetadataSearchModal({
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{missing.missing_count} livre{missing.missing_count !== 1 ? "s" : ""} manquant{missing.missing_count !== 1 ? "s" : ""}
|
||||
{t("metadata.missingBooks", { count: missing.missing_count, plural: missing.missing_count !== 1 ? "s" : "" })}
|
||||
</button>
|
||||
{showMissingList && (
|
||||
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
||||
{missing.missing_books.map((b, i) => (
|
||||
<p key={i} className="text-muted-foreground truncate">
|
||||
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
|
||||
{b.title || "Inconnu"}
|
||||
{b.title || t("metadata.unknown")}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -581,7 +578,7 @@ export function MetadataSearchModal({
|
||||
onClick={() => { handleClose(); router.refresh(); }}
|
||||
className="w-full p-2.5 rounded-lg bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Fermer
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -593,7 +590,7 @@ export function MetadataSearchModal({
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-foreground inline-flex items-center gap-1.5">
|
||||
Lié à <ProviderIcon provider={existingLink.provider} size={16} /> {providerLabel(existingLink.provider)}
|
||||
{t("metadata.linkedTo")} <ProviderIcon provider={existingLink.provider} size={16} /> {providerLabel(existingLink.provider)}
|
||||
</p>
|
||||
{existingLink.external_url && (
|
||||
<a
|
||||
@@ -602,7 +599,7 @@ export function MetadataSearchModal({
|
||||
rel="noopener noreferrer"
|
||||
className="block mt-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
Voir sur la source externe
|
||||
{t("metadata.viewExternal")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -613,15 +610,15 @@ export function MetadataSearchModal({
|
||||
{initialMissing && (
|
||||
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">External</p>
|
||||
<p className="text-sm text-muted-foreground">{t("metadata.external")}</p>
|
||||
<p className="text-2xl font-semibold">{initialMissing.total_external}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Local</p>
|
||||
<p className="text-sm text-muted-foreground">{t("metadata.local")}</p>
|
||||
<p className="text-2xl font-semibold">{initialMissing.total_local}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Missing</p>
|
||||
<p className="text-sm text-muted-foreground">{t("metadata.missingLabel")}</p>
|
||||
<p className="text-2xl font-semibold text-warning">{initialMissing.missing_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -635,14 +632,14 @@ export function MetadataSearchModal({
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{initialMissing.missing_count} livre{initialMissing.missing_count !== 1 ? "s" : ""} manquant{initialMissing.missing_count !== 1 ? "s" : ""}
|
||||
{t("metadata.missingBooks", { count: initialMissing.missing_count, plural: initialMissing.missing_count !== 1 ? "s" : "" })}
|
||||
</button>
|
||||
{showMissingList && (
|
||||
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
||||
{initialMissing.missing_books.map((b, i) => (
|
||||
<p key={i} className="text-muted-foreground truncate">
|
||||
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
|
||||
{b.title || "Inconnu"}
|
||||
{b.title || t("metadata.unknown")}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -656,14 +653,14 @@ export function MetadataSearchModal({
|
||||
onClick={() => { doSearch(""); }}
|
||||
className="flex-1 p-2.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
||||
>
|
||||
Rechercher à nouveau
|
||||
{t("metadata.searchAgain")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUnlink}
|
||||
className="p-2.5 rounded-lg border border-destructive/30 bg-destructive/5 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
Dissocier
|
||||
{t("metadata.unlink")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -683,13 +680,13 @@ export function MetadataSearchModal({
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
||||
>
|
||||
<Icon name="search" size="sm" />
|
||||
{existingLink && existingLink.status === "approved" ? "Métadonnées" : "Rechercher les métadonnées"}
|
||||
{existingLink && existingLink.status === "approved" ? t("metadata.metadataButton") : t("metadata.searchButton")}
|
||||
</button>
|
||||
|
||||
{/* Inline badge when linked */}
|
||||
{existingLink && existingLink.status === "approved" && initialMissing && initialMissing.missing_count > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 text-xs border border-yellow-500/30">
|
||||
{initialMissing.missing_count} manquant{initialMissing.missing_count !== 1 ? "s" : ""}
|
||||
{t("series.missingCount", { count: initialMissing.missing_count, plural: initialMissing.missing_count !== 1 ? "s" : "" })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Link from "next/link";
|
||||
import { NavIcon } from "./ui";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
type NavItem = {
|
||||
href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||
@@ -24,6 +25,7 @@ const XIcon = () => (
|
||||
);
|
||||
|
||||
export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -53,7 +55,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
||||
`}
|
||||
>
|
||||
<div className="h-16 border-b border-border/40 flex items-center px-4">
|
||||
<span className="text-sm font-semibold text-muted-foreground tracking-wide uppercase">Navigation</span>
|
||||
<span className="text-sm font-semibold text-muted-foreground tracking-wide uppercase">{t("nav.navigation")}</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-1 p-3 flex-1">
|
||||
@@ -76,7 +78,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<NavIcon name="settings" />
|
||||
<span className="font-medium">Paramètres</span>
|
||||
<span className="font-medium">{t("nav.settings")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -90,7 +92,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
||||
<button
|
||||
className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label={isOpen ? "Fermer le menu" : "Ouvrir le menu"}
|
||||
aria-label={isOpen ? t("nav.closeMenu") : t("nav.openMenu")}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{isOpen ? <XIcon /> : <HamburgerIcon />}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface MonitoringFormProps {
|
||||
libraryId: string;
|
||||
@@ -10,6 +11,7 @@ interface MonitoringFormProps {
|
||||
}
|
||||
|
||||
export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEnabled }: MonitoringFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSubmit = (formData: FormData) => {
|
||||
@@ -51,7 +53,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
||||
disabled={isPending}
|
||||
className="w-3.5 h-3.5 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span>Auto</span>
|
||||
<span>{t("monitoring.auto")}</span>
|
||||
</label>
|
||||
|
||||
<label className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-sm font-medium transition-all cursor-pointer select-none ${
|
||||
@@ -67,7 +69,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
||||
disabled={isPending}
|
||||
className="w-3.5 h-3.5 rounded border-border text-warning focus:ring-warning"
|
||||
/>
|
||||
<span title="Surveillance des fichiers en temps réel">⚡</span>
|
||||
<span title={t("monitoring.fileWatch")}>⚡</span>
|
||||
</label>
|
||||
|
||||
<select
|
||||
@@ -76,10 +78,10 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
||||
disabled={isPending}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
|
||||
>
|
||||
<option value="manual">Manuel</option>
|
||||
<option value="hourly">Toutes les heures</option>
|
||||
<option value="daily">Quotidien</option>
|
||||
<option value="weekly">Hebdomadaire</option>
|
||||
<option value="manual">{t("monitoring.manual")}</option>
|
||||
<option value="hourly">{t("monitoring.hourly")}</option>
|
||||
<option value="daily">{t("monitoring.daily")}</option>
|
||||
<option value="weekly">{t("monitoring.weekly")}</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
|
||||
interface SeriesFiltersProps {
|
||||
basePath: string;
|
||||
@@ -13,6 +14,7 @@ interface SeriesFiltersProps {
|
||||
export function SeriesFilters({ basePath, currentSeriesStatus, currentHasMissing, seriesStatusOptions }: SeriesFiltersProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const updateFilter = useCallback((key: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
@@ -43,8 +45,8 @@ export function SeriesFilters({ basePath, currentSeriesStatus, currentHasMissing
|
||||
onChange={(e) => updateFilter("has_missing", e.target.value)}
|
||||
className="px-3 py-2 rounded-lg border border-border bg-card text-foreground text-sm"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
<option value="true">Livres manquants</option>
|
||||
<option value="">{t("seriesFilters.all")}</option>
|
||||
<option value="true">{t("seriesFilters.missingBooks")}</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "../../../lib/i18n/context";
|
||||
|
||||
type BadgeVariant =
|
||||
| "default"
|
||||
@@ -70,19 +73,19 @@ const statusVariants: Record<string, BadgeVariant> = {
|
||||
unread: "unread",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
extracting_pages: "Extraction des pages",
|
||||
generating_thumbnails: "Miniatures",
|
||||
};
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const key = status.toLowerCase();
|
||||
const variant = statusVariants[key] || "default";
|
||||
const statusLabels: Record<string, string> = {
|
||||
extracting_pages: t("statusBadge.extracting_pages"),
|
||||
generating_thumbnails: t("statusBadge.generating_thumbnails"),
|
||||
};
|
||||
const label = statusLabels[key] ?? status;
|
||||
return <Badge variant={variant} className={className}>{label}</Badge>;
|
||||
}
|
||||
@@ -95,22 +98,23 @@ const jobTypeVariants: Record<string, BadgeVariant> = {
|
||||
thumbnail_regenerate: "warning",
|
||||
};
|
||||
|
||||
const jobTypeLabels: Record<string, string> = {
|
||||
rebuild: "Indexation",
|
||||
full_rebuild: "Indexation complète",
|
||||
thumbnail_rebuild: "Miniatures",
|
||||
thumbnail_regenerate: "Régén. miniatures",
|
||||
cbr_to_cbz: "CBR → CBZ",
|
||||
};
|
||||
|
||||
interface JobTypeBadgeProps {
|
||||
type: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const key = type.toLowerCase();
|
||||
const variant = jobTypeVariants[key] || "default";
|
||||
const jobTypeLabels: Record<string, string> = {
|
||||
rebuild: t("jobType.rebuild"),
|
||||
full_rebuild: t("jobType.full_rebuild"),
|
||||
thumbnail_rebuild: t("jobType.thumbnail_rebuild"),
|
||||
thumbnail_regenerate: t("jobType.thumbnail_regenerate"),
|
||||
cbr_to_cbz: t("jobType.cbr_to_cbz"),
|
||||
metadata_batch: t("jobType.metadata_batch"),
|
||||
};
|
||||
const label = jobTypeLabels[key] ?? type;
|
||||
return <Badge variant={variant} className={className}>{label}</Badge>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./Button";
|
||||
import { useTranslation } from "../../../lib/i18n/context";
|
||||
|
||||
interface CursorPaginationProps {
|
||||
hasNextPage: boolean;
|
||||
@@ -23,6 +24,7 @@ export function CursorPagination({
|
||||
}: CursorPaginationProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const goToNext = () => {
|
||||
if (!nextCursor) return;
|
||||
@@ -48,7 +50,7 @@ export function CursorPagination({
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
|
||||
{/* Page size selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">Afficher</span>
|
||||
<span className="text-sm text-muted-foreground">{t("pagination.show")}</span>
|
||||
<select
|
||||
value={pageSize.toString()}
|
||||
onChange={(e) => changePageSize(Number(e.target.value))}
|
||||
@@ -60,12 +62,12 @@ export function CursorPagination({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm text-muted-foreground">par page</span>
|
||||
<span className="text-sm text-muted-foreground">{t("common.perPage")}</span>
|
||||
</div>
|
||||
|
||||
{/* Count info */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Affichage de {currentCount} éléments
|
||||
{t("pagination.displaying", { count: currentCount.toString() })}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
@@ -79,7 +81,7 @@ export function CursorPagination({
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
Premier
|
||||
{t("common.first")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -88,7 +90,7 @@ export function CursorPagination({
|
||||
onClick={goToNext}
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
Suivant
|
||||
{t("common.next")}
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
@@ -115,6 +117,7 @@ export function OffsetPagination({
|
||||
}: OffsetPaginationProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
@@ -170,7 +173,7 @@ export function OffsetPagination({
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
|
||||
{/* Page size selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">Afficher</span>
|
||||
<span className="text-sm text-muted-foreground">{t("pagination.show")}</span>
|
||||
<select
|
||||
value={pageSize.toString()}
|
||||
onChange={(e) => changePageSize(Number(e.target.value))}
|
||||
@@ -182,12 +185,12 @@ export function OffsetPagination({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm text-muted-foreground">par page</span>
|
||||
<span className="text-sm text-muted-foreground">{t("common.perPage")}</span>
|
||||
</div>
|
||||
|
||||
{/* Page info */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{startItem}-{endItem} sur {totalItems}
|
||||
{t("pagination.range", { start: startItem.toString(), end: endItem.toString(), total: totalItems.toString() })}
|
||||
</div>
|
||||
|
||||
{/* Page navigation */}
|
||||
@@ -196,7 +199,7 @@ export function OffsetPagination({
|
||||
size="sm"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
title="Page précédente"
|
||||
title={t("common.previousPage")}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
@@ -224,7 +227,7 @@ export function OffsetPagination({
|
||||
size="sm"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
title="Page suivante"
|
||||
title={t("common.nextPage")}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
||||
} from "../../components/ui";
|
||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||
|
||||
interface JobDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -42,38 +43,6 @@ interface JobError {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const JOB_TYPE_INFO: Record<string, { label: string; description: string; isThumbnailOnly: boolean }> = {
|
||||
rebuild: {
|
||||
label: "Indexation incrémentale",
|
||||
description: "Scanne les fichiers nouveaux/modifiés, les analyse et génère les miniatures manquantes.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
full_rebuild: {
|
||||
label: "Réindexation complète",
|
||||
description: "Supprime toutes les données existantes puis effectue un scan complet, une ré-analyse et la génération des miniatures.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
thumbnail_rebuild: {
|
||||
label: "Reconstruction des miniatures",
|
||||
description: "Génère les miniatures uniquement pour les livres qui n'en ont pas. Les miniatures existantes sont conservées.",
|
||||
isThumbnailOnly: true,
|
||||
},
|
||||
thumbnail_regenerate: {
|
||||
label: "Regénération des miniatures",
|
||||
description: "Regénère toutes les miniatures depuis zéro, en remplaçant les existantes.",
|
||||
isThumbnailOnly: true,
|
||||
},
|
||||
cbr_to_cbz: {
|
||||
label: "Conversion CBR → CBZ",
|
||||
description: "Convertit une archive CBR au format ouvert CBZ.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
metadata_batch: {
|
||||
label: "Métadonnées en lot",
|
||||
description: "Recherche les métadonnées auprès des fournisseurs externes pour toutes les séries de la bibliothèque et applique automatiquement les correspondances à 100% de confiance.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
async function getJobDetails(jobId: string): Promise<JobDetails | null> {
|
||||
try {
|
||||
@@ -117,6 +86,41 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { t, locale } = await getServerTranslations();
|
||||
|
||||
const JOB_TYPE_INFO: Record<string, { label: string; description: string; isThumbnailOnly: boolean }> = {
|
||||
rebuild: {
|
||||
label: t("jobType.rebuildLabel"),
|
||||
description: t("jobType.rebuildDesc"),
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
full_rebuild: {
|
||||
label: t("jobType.full_rebuildLabel"),
|
||||
description: t("jobType.full_rebuildDesc"),
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
thumbnail_rebuild: {
|
||||
label: t("jobType.thumbnail_rebuildLabel"),
|
||||
description: t("jobType.thumbnail_rebuildDesc"),
|
||||
isThumbnailOnly: true,
|
||||
},
|
||||
thumbnail_regenerate: {
|
||||
label: t("jobType.thumbnail_regenerateLabel"),
|
||||
description: t("jobType.thumbnail_regenerateDesc"),
|
||||
isThumbnailOnly: true,
|
||||
},
|
||||
cbr_to_cbz: {
|
||||
label: t("jobType.cbr_to_cbzLabel"),
|
||||
description: t("jobType.cbr_to_cbzDesc"),
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
metadata_batch: {
|
||||
label: t("jobType.metadata_batchLabel"),
|
||||
description: t("jobType.metadata_batchDesc"),
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
const isMetadataBatch = job.type === "metadata_batch";
|
||||
|
||||
// Fetch batch report & results for metadata_batch jobs
|
||||
@@ -149,24 +153,24 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
|
||||
// Which label to use for the progress card
|
||||
const progressTitle = isMetadataBatch
|
||||
? "Recherche de métadonnées"
|
||||
? t("jobDetail.metadataSearch")
|
||||
: isThumbnailOnly
|
||||
? "Miniatures"
|
||||
? t("jobType.thumbnail_rebuild")
|
||||
: isExtractingPages
|
||||
? "Phase 2 — Extraction des pages"
|
||||
? t("jobDetail.phase2a")
|
||||
: isThumbnailPhase
|
||||
? "Phase 2 — Miniatures"
|
||||
: "Phase 1 — Découverte";
|
||||
? t("jobDetail.phase2b")
|
||||
: t("jobDetail.phase1");
|
||||
|
||||
const progressDescription = isMetadataBatch
|
||||
? "Recherche auprès des fournisseurs externes pour chaque série"
|
||||
? t("jobDetail.metadataSearchDesc")
|
||||
: isThumbnailOnly
|
||||
? undefined
|
||||
: isExtractingPages
|
||||
? "Extraction de la première page de chaque archive (nombre de pages + image brute)"
|
||||
? t("jobDetail.phase2aDesc")
|
||||
: isThumbnailPhase
|
||||
? "Génération des miniatures pour les livres analysés"
|
||||
: "Scan et indexation des fichiers de la bibliothèque";
|
||||
? t("jobDetail.phase2bDesc")
|
||||
: t("jobDetail.phase1Desc");
|
||||
|
||||
// Speed metric: thumbnail count for thumbnail jobs, scanned files for index jobs
|
||||
const speedCount = isThumbnailOnly
|
||||
@@ -187,9 +191,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour aux tâches
|
||||
{t("jobDetail.backToJobs")}
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-foreground mt-2">Détails de la tâche</h1>
|
||||
<h1 className="text-3xl font-bold text-foreground mt-2">{t("jobDetail.title")}</h1>
|
||||
</div>
|
||||
|
||||
{/* Summary banner — completed */}
|
||||
@@ -199,24 +203,24 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-success">
|
||||
<span className="font-semibold">Terminé en {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
<span className="font-semibold">{t("jobDetail.completedIn", { duration: formatDuration(job.started_at, job.finished_at) })}</span>
|
||||
{isMetadataBatch && batchReport && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {batchReport.auto_matched} auto-associées, {batchReport.already_linked} déjà liées, {batchReport.no_results} aucun résultat, {batchReport.errors} erreurs
|
||||
— {batchReport.auto_matched} {t("jobDetail.autoMatched").toLowerCase()}, {batchReport.already_linked} {t("jobDetail.alreadyLinked").toLowerCase()}, {batchReport.no_results} {t("jobDetail.noResults").toLowerCase()}, {batchReport.errors} {t("jobDetail.errors").toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
{!isMetadataBatch && job.stats_json && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {job.stats_json.scanned_files} scannés, {job.stats_json.indexed_files} indexés
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} supprimés`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} avertissements`}
|
||||
{job.stats_json.errors > 0 && `, ${job.stats_json.errors} erreurs`}
|
||||
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} miniatures`}
|
||||
— {job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()}
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`}
|
||||
{job.stats_json.errors > 0 && `, ${job.stats_json.errors} ${t("jobDetail.errors").toLowerCase()}`}
|
||||
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} ${t("jobType.thumbnail_rebuild").toLowerCase()}`}
|
||||
</span>
|
||||
)}
|
||||
{!isMetadataBatch && !job.stats_json && isThumbnailOnly && job.total_files != null && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {job.processed_files ?? job.total_files} miniatures générées
|
||||
— {job.processed_files ?? job.total_files} {t("jobDetail.generated").toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -230,9 +234,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-destructive">
|
||||
<span className="font-semibold">Tâche échouée</span>
|
||||
<span className="font-semibold">{t("jobDetail.jobFailed")}</span>
|
||||
{job.started_at && (
|
||||
<span className="ml-2 text-destructive/80">après {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
<span className="ml-2 text-destructive/80">{t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })}</span>
|
||||
)}
|
||||
{job.error_opt && (
|
||||
<p className="mt-1 text-destructive/70 font-mono text-xs break-all">{job.error_opt}</p>
|
||||
@@ -248,9 +252,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="font-semibold">Annulé</span>
|
||||
<span className="font-semibold">{t("jobDetail.cancelled")}</span>
|
||||
{job.started_at && (
|
||||
<span className="ml-2">après {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
<span className="ml-2">{t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -260,7 +264,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{/* Overview Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aperçu</CardTitle>
|
||||
<CardTitle>{t("jobDetail.overview")}</CardTitle>
|
||||
{typeInfo.description && (
|
||||
<CardDescription>{typeInfo.description}</CardDescription>
|
||||
)}
|
||||
@@ -271,23 +275,23 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<code className="px-2 py-1 bg-muted rounded font-mono text-sm text-foreground">{job.id}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||
<span className="text-sm text-muted-foreground">Type</span>
|
||||
<span className="text-sm text-muted-foreground">{t("jobsList.type")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<JobTypeBadge type={job.type} />
|
||||
<span className="text-sm text-muted-foreground">{typeInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||
<span className="text-sm text-muted-foreground">Statut</span>
|
||||
<span className="text-sm text-muted-foreground">{t("jobsList.status")}</span>
|
||||
<StatusBadge status={job.status} />
|
||||
</div>
|
||||
<div className={`flex items-center justify-between py-2 ${(job.book_id || job.started_at) ? "border-b border-border/60" : ""}`}>
|
||||
<span className="text-sm text-muted-foreground">Bibliothèque</span>
|
||||
<span className="text-sm text-foreground">{job.library_id || "Toutes les bibliothèques"}</span>
|
||||
<span className="text-sm text-muted-foreground">{t("jobDetail.library")}</span>
|
||||
<span className="text-sm text-foreground">{job.library_id || t("jobDetail.allLibraries")}</span>
|
||||
</div>
|
||||
{job.book_id && (
|
||||
<div className={`flex items-center justify-between py-2 ${job.started_at ? "border-b border-border/60" : ""}`}>
|
||||
<span className="text-sm text-muted-foreground">Livre</span>
|
||||
<span className="text-sm text-muted-foreground">{t("jobDetail.book")}</span>
|
||||
<Link
|
||||
href={`/books/${job.book_id}`}
|
||||
className="text-sm text-primary hover:text-primary/80 font-mono hover:underline"
|
||||
@@ -298,7 +302,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
)}
|
||||
{job.started_at && (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-muted-foreground">Durée</span>
|
||||
<span className="text-sm text-muted-foreground">{t("jobsList.duration")}</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatDuration(job.started_at, job.finished_at)}
|
||||
</span>
|
||||
@@ -310,7 +314,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{/* Timeline Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Chronologie</CardTitle>
|
||||
<CardTitle>{t("jobDetail.timeline")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative">
|
||||
@@ -322,8 +326,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-muted border-2 border-border shrink-0 z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Créé</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</p>
|
||||
<span className="text-sm font-medium text-foreground">{t("jobDetail.created")}</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString(locale)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -332,15 +336,15 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-primary shrink-0 z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Phase 1 — Découverte</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
|
||||
<span className="text-sm font-medium text-foreground">{t("jobDetail.phase1")}</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString(locale)}</p>
|
||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||
Durée : {formatDuration(job.started_at, job.phase2_started_at)}
|
||||
{t("jobDetail.duration", { duration: formatDuration(job.started_at, job.phase2_started_at) })}
|
||||
{job.stats_json && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
· {job.stats_json.scanned_files} scannés, {job.stats_json.indexed_files} indexés
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} supprimés`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} avert.`}
|
||||
· {job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()}
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -355,12 +359,12 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
job.generating_thumbnails_started_at || job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Phase 2a — Extraction des pages</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.phase2_started_at).toLocaleString()}</p>
|
||||
<span className="text-sm font-medium text-foreground">{t("jobDetail.phase2a")}</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.phase2_started_at).toLocaleString(locale)}</p>
|
||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||
Durée : {formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null)}
|
||||
{t("jobDetail.duration", { duration: formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null) })}
|
||||
{!job.generating_thumbnails_started_at && !job.finished_at && isExtractingPages && (
|
||||
<span className="text-muted-foreground font-normal ml-1">· en cours</span>
|
||||
<span className="text-muted-foreground font-normal ml-1">· {t("jobDetail.inProgress")}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -375,26 +379,26 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{isThumbnailOnly ? "Miniatures" : "Phase 2b — Génération des miniatures"}
|
||||
{isThumbnailOnly ? t("jobType.thumbnail_rebuild") : t("jobDetail.phase2b")}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(job.generating_thumbnails_started_at ? new Date(job.generating_thumbnails_started_at) : job.phase2_started_at ? new Date(job.phase2_started_at) : null)?.toLocaleString()}
|
||||
{(job.generating_thumbnails_started_at ? new Date(job.generating_thumbnails_started_at) : job.phase2_started_at ? new Date(job.phase2_started_at) : null)?.toLocaleString(locale)}
|
||||
</p>
|
||||
{(job.generating_thumbnails_started_at || job.finished_at) && (
|
||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||
Durée : {formatDuration(
|
||||
{t("jobDetail.duration", { duration: formatDuration(
|
||||
job.generating_thumbnails_started_at ?? job.phase2_started_at!,
|
||||
job.finished_at ?? null
|
||||
)}
|
||||
) })}
|
||||
{job.total_files != null && job.total_files > 0 && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
· {job.processed_files ?? job.total_files} miniatures
|
||||
· {job.processed_files ?? job.total_files} {t("jobType.thumbnail_rebuild").toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{!job.finished_at && isThumbnailPhase && (
|
||||
<span className="text-xs text-muted-foreground">en cours</span>
|
||||
<span className="text-xs text-muted-foreground">{t("jobDetail.inProgress")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -407,8 +411,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Démarré</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
|
||||
<span className="text-sm font-medium text-foreground">{t("jobDetail.started")}</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString(locale)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -418,7 +422,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-warning shrink-0 z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">En attente de démarrage…</span>
|
||||
<span className="text-sm font-medium text-foreground">{t("jobDetail.pendingStart")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -431,9 +435,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{isCompleted ? "Terminé" : isFailed ? "Échoué" : "Annulé"}
|
||||
{isCompleted ? t("jobDetail.finished") : isFailed ? t("jobDetail.failed") : t("jobDetail.cancelled")}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.finished_at).toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.finished_at).toLocaleString(locale)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -456,13 +460,13 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<StatBox
|
||||
value={job.processed_files ?? 0}
|
||||
label={isThumbnailOnly || isPhase2 ? "Générés" : "Traités"}
|
||||
label={isThumbnailOnly || isPhase2 ? t("jobDetail.generated") : t("jobDetail.processed")}
|
||||
variant="primary"
|
||||
/>
|
||||
<StatBox value={job.total_files} label="Total" />
|
||||
<StatBox value={job.total_files} label={t("jobDetail.total")} />
|
||||
<StatBox
|
||||
value={Math.max(0, job.total_files - (job.processed_files ?? 0))}
|
||||
label="Restants"
|
||||
label={t("jobDetail.remaining")}
|
||||
variant={isCompleted ? "default" : "warning"}
|
||||
/>
|
||||
</div>
|
||||
@@ -470,7 +474,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
)}
|
||||
{job.current_file && (
|
||||
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">Fichier en cours</span>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{t("jobDetail.currentFile")}</span>
|
||||
<code className="block mt-1 text-xs font-mono text-foreground break-all">{job.current_file}</code>
|
||||
</div>
|
||||
)}
|
||||
@@ -482,7 +486,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Statistiques d'indexation</CardTitle>
|
||||
<CardTitle>{t("jobDetail.indexStats")}</CardTitle>
|
||||
{job.started_at && (
|
||||
<CardDescription>
|
||||
{formatDuration(job.started_at, job.finished_at)}
|
||||
@@ -492,11 +496,11 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||
<StatBox value={job.stats_json.scanned_files} label="Scannés" variant="success" />
|
||||
<StatBox value={job.stats_json.indexed_files} label="Indexés" variant="primary" />
|
||||
<StatBox value={job.stats_json.removed_files} label="Supprimés" variant="warning" />
|
||||
<StatBox value={job.stats_json.warnings ?? 0} label="Avertissements" variant={(job.stats_json.warnings ?? 0) > 0 ? "warning" : "default"} />
|
||||
<StatBox value={job.stats_json.errors} label="Erreurs" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||
<StatBox value={job.stats_json.scanned_files} label={t("jobDetail.scanned")} variant="success" />
|
||||
<StatBox value={job.stats_json.indexed_files} label={t("jobDetail.indexed")} variant="primary" />
|
||||
<StatBox value={job.stats_json.removed_files} label={t("jobDetail.removed")} variant="warning" />
|
||||
<StatBox value={job.stats_json.warnings ?? 0} label={t("jobDetail.warnings")} variant={(job.stats_json.warnings ?? 0) > 0 ? "warning" : "default"} />
|
||||
<StatBox value={job.stats_json.errors} label={t("jobDetail.errors")} variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -506,7 +510,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{isThumbnailOnly && isCompleted && job.total_files != null && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Statistiques des miniatures</CardTitle>
|
||||
<CardTitle>{t("jobDetail.thumbnailStats")}</CardTitle>
|
||||
{job.started_at && (
|
||||
<CardDescription>
|
||||
{formatDuration(job.started_at, job.finished_at)}
|
||||
@@ -516,8 +520,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatBox value={job.processed_files ?? job.total_files} label="Générés" variant="success" />
|
||||
<StatBox value={job.total_files} label="Total" />
|
||||
<StatBox value={job.processed_files ?? job.total_files} label={t("jobDetail.generated")} variant="success" />
|
||||
<StatBox value={job.total_files} label={t("jobDetail.total")} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -527,17 +531,17 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{isMetadataBatch && batchReport && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rapport du lot</CardTitle>
|
||||
<CardDescription>{batchReport.total_series} séries analysées</CardDescription>
|
||||
<CardTitle>{t("jobDetail.batchReport")}</CardTitle>
|
||||
<CardDescription>{t("jobDetail.seriesAnalyzed", { count: String(batchReport.total_series) })}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<StatBox value={batchReport.auto_matched} label="Auto-associé" variant="success" />
|
||||
<StatBox value={batchReport.already_linked} label="Déjà lié" variant="primary" />
|
||||
<StatBox value={batchReport.no_results} label="Aucun résultat" />
|
||||
<StatBox value={batchReport.too_many_results} label="Trop de résultats" variant="warning" />
|
||||
<StatBox value={batchReport.low_confidence} label="Confiance faible" variant="warning" />
|
||||
<StatBox value={batchReport.errors} label="Erreurs" variant={batchReport.errors > 0 ? "error" : "default"} />
|
||||
<StatBox value={batchReport.auto_matched} label={t("jobDetail.autoMatched")} variant="success" />
|
||||
<StatBox value={batchReport.already_linked} label={t("jobDetail.alreadyLinked")} variant="primary" />
|
||||
<StatBox value={batchReport.no_results} label={t("jobDetail.noResults")} />
|
||||
<StatBox value={batchReport.too_many_results} label={t("jobDetail.tooManyResults")} variant="warning" />
|
||||
<StatBox value={batchReport.low_confidence} label={t("jobDetail.lowConfidence")} variant="warning" />
|
||||
<StatBox value={batchReport.errors} label={t("jobDetail.errors")} variant={batchReport.errors > 0 ? "error" : "default"} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -547,8 +551,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{isMetadataBatch && batchResults.length > 0 && (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Résultats par série</CardTitle>
|
||||
<CardDescription>{batchResults.length} séries traitées</CardDescription>
|
||||
<CardTitle>{t("jobDetail.resultsBySeries")}</CardTitle>
|
||||
<CardDescription>{t("jobDetail.seriesProcessed", { count: String(batchResults.length) })}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{batchResults.map((r) => (
|
||||
@@ -572,29 +576,29 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
r.status === "error" ? "bg-destructive/20 text-destructive" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{r.status === "auto_matched" ? "Auto-associé" :
|
||||
r.status === "already_linked" ? "Déjà lié" :
|
||||
r.status === "no_results" ? "Aucun résultat" :
|
||||
r.status === "too_many_results" ? "Trop de résultats" :
|
||||
r.status === "low_confidence" ? "Confiance faible" :
|
||||
r.status === "error" ? "Erreur" :
|
||||
{r.status === "auto_matched" ? t("jobDetail.autoMatched") :
|
||||
r.status === "already_linked" ? t("jobDetail.alreadyLinked") :
|
||||
r.status === "no_results" ? t("jobDetail.noResults") :
|
||||
r.status === "too_many_results" ? t("jobDetail.tooManyResults") :
|
||||
r.status === "low_confidence" ? t("jobDetail.lowConfidence") :
|
||||
r.status === "error" ? t("common.error") :
|
||||
r.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
{r.provider_used && (
|
||||
<span>{r.provider_used}{r.fallback_used ? " (secours)" : ""}</span>
|
||||
<span>{r.provider_used}{r.fallback_used ? ` ${t("metadata.fallbackUsed")}` : ""}</span>
|
||||
)}
|
||||
{r.candidates_count > 0 && (
|
||||
<span>{r.candidates_count} candidat{r.candidates_count > 1 ? "s" : ""}</span>
|
||||
<span>{r.candidates_count} {t("jobDetail.candidates", { plural: r.candidates_count > 1 ? "s" : "" })}</span>
|
||||
)}
|
||||
{r.best_confidence != null && (
|
||||
<span>{Math.round(r.best_confidence * 100)}% confiance</span>
|
||||
<span>{Math.round(r.best_confidence * 100)}% {t("jobDetail.confidence")}</span>
|
||||
)}
|
||||
</div>
|
||||
{r.best_candidate_json && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Correspondance : {(r.best_candidate_json as { title?: string }).title || r.best_candidate_json.toString()}
|
||||
{t("jobDetail.match", { title: (r.best_candidate_json as { title?: string }).title || r.best_candidate_json.toString() })}
|
||||
</p>
|
||||
)}
|
||||
{r.error_message && (
|
||||
@@ -610,15 +614,15 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{errors.length > 0 && (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Erreurs de fichiers ({errors.length})</CardTitle>
|
||||
<CardDescription>Erreurs rencontrées lors du traitement des fichiers</CardDescription>
|
||||
<CardTitle>{t("jobDetail.fileErrors", { count: String(errors.length) })}</CardTitle>
|
||||
<CardDescription>{t("jobDetail.fileErrorsDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{errors.map((error) => (
|
||||
<div key={error.id} className="p-3 bg-destructive/10 rounded-lg border border-destructive/20">
|
||||
<code className="block text-sm font-mono text-destructive mb-1">{error.file_path}</code>
|
||||
<p className="text-sm text-destructive/80">{error.error_message}</p>
|
||||
<span className="text-xs text-muted-foreground">{new Date(error.created_at).toLocaleString()}</span>
|
||||
<span className="text-xs text-muted-foreground">{new Date(error.created_at).toLocaleString(locale)}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { redirect } from "next/navigation";
|
||||
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, IndexJobDto, LibraryDto } from "../../lib/api";
|
||||
import { JobsList } from "../components/JobsList";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormSelect, FormRow } from "../components/ui";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function JobsPage({ searchParams }: { searchParams: Promise<{ highlight?: string }> }) {
|
||||
const { highlight } = await searchParams;
|
||||
const { t } = await getServerTranslations();
|
||||
const [jobs, libraries] = await Promise.all([
|
||||
listJobs().catch(() => [] as IndexJobDto[]),
|
||||
fetchLibraries().catch(() => [] as LibraryDto[])
|
||||
@@ -63,21 +65,21 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Tâches d'indexation
|
||||
{t("jobs.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Lancer une tâche</CardTitle>
|
||||
<CardDescription>Sélectionnez une bibliothèque (ou toutes) et choisissez l'action à effectuer.</CardDescription>
|
||||
<CardTitle>{t("jobs.startJob")}</CardTitle>
|
||||
<CardDescription>{t("jobs.startJobDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<FormRow>
|
||||
<FormField className="flex-1 max-w-xs">
|
||||
<FormSelect name="library_id" defaultValue="">
|
||||
<option value="">Toutes les bibliothèques</option>
|
||||
<option value="">{t("jobs.allLibraries")}</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>{lib.name}</option>
|
||||
))}
|
||||
@@ -88,31 +90,31 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Reconstruction
|
||||
{t("jobs.rebuild")}
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerFullRebuild} variant="warning">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Reconstruction complète
|
||||
{t("jobs.fullRebuild")}
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerThumbnailsRebuild} variant="secondary">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Générer les miniatures
|
||||
{t("jobs.generateThumbnails")}
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerThumbnailsRegenerate} variant="warning">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Regénérer les miniatures
|
||||
{t("jobs.regenerateThumbnails")}
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerMetadataBatch} variant="secondary">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Métadonnées en lot
|
||||
{t("jobs.batchMetadata")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormRow>
|
||||
@@ -123,7 +125,7 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
{/* Job types legend */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Référence des types de tâches</CardTitle>
|
||||
<CardTitle className="text-base">{t("jobs.referenceTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
@@ -134,10 +136,8 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Reconstruction</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Scan incrémental : détecte les fichiers ajoutés, modifiés ou supprimés depuis le dernier scan, les indexe et génère les miniatures manquantes. Les données existantes non modifiées sont conservées. C’est l’action la plus courante et la plus rapide.
|
||||
</p>
|
||||
<span className="font-medium text-foreground">{t("jobs.rebuild")}</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5" dangerouslySetInnerHTML={{ __html: t("jobs.rebuildDescription") }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -147,10 +147,8 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Reconstruction complète</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Supprime toutes les données indexées (livres, séries, miniatures) puis effectue un scan complet depuis zéro. Utile si la base de données est désynchronisée ou corrompue. Opération longue et destructive : les statuts de lecture et les métadonnées manuelles seront perdus.
|
||||
</p>
|
||||
<span className="font-medium text-foreground">{t("jobs.fullRebuild")}</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5" dangerouslySetInnerHTML={{ __html: t("jobs.fullRebuildDescription") }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -160,10 +158,8 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Générer les miniatures</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Génère les miniatures uniquement pour les livres qui n’en ont pas encore. Les miniatures existantes ne sont pas touchées. Utile après un import ou si certaines miniatures sont manquantes.
|
||||
</p>
|
||||
<span className="font-medium text-foreground">{t("jobs.generateThumbnails")}</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5" dangerouslySetInnerHTML={{ __html: t("jobs.generateThumbnailsDescription") }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -173,10 +169,8 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Regénérer les miniatures</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Regénère toutes les miniatures depuis zéro, en remplaçant les existantes. Utile si la qualité ou la taille des miniatures a changé dans la configuration, ou si des miniatures sont corrompues.
|
||||
</p>
|
||||
<span className="font-medium text-foreground">{t("jobs.regenerateThumbnails")}</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5" dangerouslySetInnerHTML={{ __html: t("jobs.regenerateThumbnailsDescription") }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -186,10 +180,8 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Métadonnées en lot</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Recherche automatiquement les métadonnées de chaque série de la bibliothèque auprès du provider configuré (avec fallback si configuré). Seuls les résultats avec un match unique à 100% de confiance sont appliqués automatiquement. Les séries déjà liées sont ignorées. Un rapport détaillé par série est disponible à la fin du job. <strong>Requiert une bibliothèque spécifique</strong> (ne fonctionne pas sur « Toutes les bibliothèques »).
|
||||
</p>
|
||||
<span className="font-medium text-foreground">{t("jobs.batchMetadata")}</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5" dangerouslySetInnerHTML={{ __html: t("jobs.batchMetadataDescription") }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,9 @@ import { ThemeToggle } from "./theme-toggle";
|
||||
import { JobsIndicator } from "./components/JobsIndicator";
|
||||
import { NavIcon, Icon } from "./components/ui";
|
||||
import { MobileNav } from "./components/MobileNav";
|
||||
import { LocaleProvider } from "../lib/i18n/context";
|
||||
import { getServerLocale, getServerTranslations } from "../lib/i18n/server";
|
||||
import type { TranslationKey } from "../lib/i18n/fr";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "StripStream Backoffice",
|
||||
@@ -16,24 +19,28 @@ export const metadata: Metadata = {
|
||||
|
||||
type NavItem = {
|
||||
href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||
label: string;
|
||||
labelKey: TranslationKey;
|
||||
icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings";
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/", label: "Tableau de bord", icon: "dashboard" },
|
||||
{ href: "/books", label: "Livres", icon: "books" },
|
||||
{ href: "/series", label: "Séries", icon: "series" },
|
||||
{ href: "/libraries", label: "Bibliothèques", icon: "libraries" },
|
||||
{ href: "/jobs", label: "Tâches", icon: "jobs" },
|
||||
{ href: "/tokens", label: "Jetons", icon: "tokens" },
|
||||
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
|
||||
{ href: "/books", labelKey: "nav.books", icon: "books" },
|
||||
{ href: "/series", labelKey: "nav.series", icon: "series" },
|
||||
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
|
||||
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
|
||||
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
|
||||
];
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
const locale = await getServerLocale();
|
||||
const { t } = await getServerTranslations();
|
||||
|
||||
return (
|
||||
<html lang="fr" suppressHydrationWarning>
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
|
||||
<ThemeProvider>
|
||||
<LocaleProvider initialLocale={locale}>
|
||||
{/* Header avec effet glassmorphism */}
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
|
||||
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
@@ -54,7 +61,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
StripStream
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground font-medium hidden md:inline">
|
||||
backoffice
|
||||
{t("common.backoffice")}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -63,9 +70,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.href} href={item.href} title={item.label}>
|
||||
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
|
||||
<NavIcon name={item.icon} />
|
||||
<span className="ml-2 hidden lg:inline">{item.label}</span>
|
||||
<span className="ml-2 hidden lg:inline">{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
@@ -76,12 +83,12 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<Link
|
||||
href="/settings"
|
||||
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
title="Paramètres"
|
||||
title={t("nav.settings")}
|
||||
>
|
||||
<Icon name="settings" size="md" />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<MobileNav navItems={navItems} />
|
||||
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -91,6 +98,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
||||
{children}
|
||||
</main>
|
||||
</LocaleProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BooksGrid, EmptyState } from "../../../components/BookCard";
|
||||
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
||||
import { OffsetPagination } from "../../../components/ui";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getServerTranslations } from "../../../../lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -14,6 +15,7 @@ export default async function LibraryBooksPage({
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { t } = await getServerTranslations();
|
||||
const searchParamsAwaited = await searchParams;
|
||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||
const series = typeof searchParamsAwaited.series === "string" ? searchParamsAwaited.series : undefined;
|
||||
@@ -38,14 +40,14 @@ export default async function LibraryBooksPage({
|
||||
coverUrl: getBookCoverUrl(book.id)
|
||||
}));
|
||||
|
||||
const seriesDisplayName = series === "unclassified" ? "Non classé" : series;
|
||||
const seriesDisplayName = series === "unclassified" ? t("books.unclassified") : (series ?? "");
|
||||
const totalPages = Math.ceil(booksPage.total / limit);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<LibrarySubPageHeader
|
||||
library={library}
|
||||
title={series ? `Livres de "${seriesDisplayName}"` : "Tous les livres"}
|
||||
title={series ? t("libraryBooks.booksOfSeries", { series: seriesDisplayName }) : t("libraryBooks.allBooks")}
|
||||
icon={
|
||||
<svg className="w-8 h-8" 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" />
|
||||
@@ -53,9 +55,9 @@ export default async function LibraryBooksPage({
|
||||
}
|
||||
iconColor="text-success"
|
||||
filterInfo={series ? {
|
||||
label: `Livres de la série "${seriesDisplayName}"`,
|
||||
label: t("libraryBooks.filterLabel", { series: seriesDisplayName }),
|
||||
clearHref: `/libraries/${id}/books`,
|
||||
clearLabel: "Voir tous les livres"
|
||||
clearLabel: t("libraryBooks.viewAll")
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
@@ -71,7 +73,7 @@ export default async function LibraryBooksPage({
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState message={series ? `Aucun livre dans la série "${seriesDisplayName}"` : "Aucun livre dans cette bibliothèque"} />
|
||||
<EmptyState message={series ? t("libraryBooks.noBooksInSeries", { series: seriesDisplayName }) : t("libraryBooks.noBooks")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SafeHtml } from "../../../../components/SafeHtml";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getServerTranslations } from "../../../../../lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -20,6 +21,7 @@ export default async function SeriesDetailPage({
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const { id, name } = await params;
|
||||
const { t } = await getServerTranslations();
|
||||
const searchParamsAwaited = await searchParams;
|
||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 50;
|
||||
@@ -55,7 +57,7 @@ export default async function SeriesDetailPage({
|
||||
|
||||
const totalPages = Math.ceil(booksPage.total / limit);
|
||||
const booksReadCount = booksPage.items.filter((b) => b.reading_status === "read").length;
|
||||
const displayName = seriesName === "unclassified" ? "Non classé" : seriesName;
|
||||
const displayName = seriesName === "unclassified" ? t("books.unclassified") : seriesName;
|
||||
|
||||
// Use first book cover as series cover
|
||||
const coverBookId = booksPage.items[0]?.id;
|
||||
@@ -68,7 +70,7 @@ export default async function SeriesDetailPage({
|
||||
href="/libraries"
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
Bibliothèques
|
||||
{t("nav.libraries")}
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<Link
|
||||
@@ -88,7 +90,7 @@ export default async function SeriesDetailPage({
|
||||
<div className="w-40 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
|
||||
<Image
|
||||
src={getBookCoverUrl(coverBookId)}
|
||||
alt={`Couverture de ${displayName}`}
|
||||
alt={t("books.coverOf", { name: displayName })}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
@@ -112,12 +114,7 @@ export default async function SeriesDetailPage({
|
||||
seriesMeta.status === "cancelled" ? "bg-red-500/15 text-red-600" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{seriesMeta.status === "ongoing" ? "En cours" :
|
||||
seriesMeta.status === "ended" ? "Terminée" :
|
||||
seriesMeta.status === "hiatus" ? "Hiatus" :
|
||||
seriesMeta.status === "cancelled" ? "Annulée" :
|
||||
seriesMeta.status === "upcoming" ? "À paraître" :
|
||||
seriesMeta.status}
|
||||
{t(`seriesStatus.${seriesMeta.status}` as any) || seriesMeta.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,11 +134,11 @@ export default async function SeriesDetailPage({
|
||||
)}
|
||||
{((seriesMeta && seriesMeta.publishers.length > 0) || seriesMeta?.start_year) && <span className="w-px h-4 bg-border" />}
|
||||
<span className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{booksPage.total}</span> livre{booksPage.total !== 1 ? "s" : ""}
|
||||
<span className="font-semibold text-foreground">{booksPage.total}</span> {t("dashboard.books").toLowerCase()}
|
||||
</span>
|
||||
<span className="w-px h-4 bg-border" />
|
||||
<span className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{booksReadCount}</span>/{booksPage.total} lu{booksPage.total !== 1 ? "s" : ""}
|
||||
{t("series.readCount", { read: String(booksReadCount), total: String(booksPage.total) })}
|
||||
</span>
|
||||
|
||||
{/* Progress bar */}
|
||||
@@ -196,7 +193,7 @@ export default async function SeriesDetailPage({
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState message="Aucun livre dans cette série" />
|
||||
<EmptyState message={t("librarySeries.noBooksInSeries")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
||||
import { getServerTranslations } from "../../../../lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -17,6 +18,7 @@ export default async function LibrarySeriesPage({
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { t } = await getServerTranslations();
|
||||
const searchParamsAwaited = await searchParams;
|
||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||
@@ -37,14 +39,14 @@ export default async function LibrarySeriesPage({
|
||||
const totalPages = Math.ceil(seriesPage.total / limit);
|
||||
|
||||
const KNOWN_STATUSES: Record<string, string> = {
|
||||
ongoing: "En cours",
|
||||
ended: "Terminée",
|
||||
hiatus: "Hiatus",
|
||||
cancelled: "Annulée",
|
||||
upcoming: "À paraître",
|
||||
ongoing: t("seriesStatus.ongoing"),
|
||||
ended: t("seriesStatus.ended"),
|
||||
hiatus: t("seriesStatus.hiatus"),
|
||||
cancelled: t("seriesStatus.cancelled"),
|
||||
upcoming: t("seriesStatus.upcoming"),
|
||||
};
|
||||
const seriesStatusOptions = [
|
||||
{ value: "", label: "Tous les statuts" },
|
||||
{ value: "", label: t("seriesStatus.allStatuses") },
|
||||
...dbStatuses.map((s) => ({ value: s, label: KNOWN_STATUSES[s] || s })),
|
||||
];
|
||||
|
||||
@@ -52,7 +54,7 @@ export default async function LibrarySeriesPage({
|
||||
<div className="space-y-6">
|
||||
<LibrarySubPageHeader
|
||||
library={library}
|
||||
title="Séries"
|
||||
title={t("series.title")}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
@@ -81,7 +83,7 @@ export default async function LibrarySeriesPage({
|
||||
<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
|
||||
@@ -89,11 +91,11 @@ export default async function LibrarySeriesPage({
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<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>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.books_read_count}/{s.book_count} lu{s.book_count !== 1 ? 's' : ''}
|
||||
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count) })}
|
||||
</p>
|
||||
<MarkSeriesReadButton
|
||||
seriesName={s.name}
|
||||
@@ -110,17 +112,12 @@ export default async function LibrarySeriesPage({
|
||||
s.series_status === "cancelled" ? "bg-red-500/15 text-red-600" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{s.series_status === "ongoing" ? "En cours" :
|
||||
s.series_status === "ended" ? "Terminée" :
|
||||
s.series_status === "hiatus" ? "Hiatus" :
|
||||
s.series_status === "cancelled" ? "Annulée" :
|
||||
s.series_status === "upcoming" ? "À paraître" :
|
||||
s.series_status}
|
||||
{KNOWN_STATUSES[s.series_status] || s.series_status}
|
||||
</span>
|
||||
)}
|
||||
{s.missing_count != null && s.missing_count > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-yellow-500/15 text-yellow-600">
|
||||
{s.missing_count} manquant{s.missing_count > 1 ? "s" : ""}
|
||||
{t("series.missingCount", { count: String(s.missing_count) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -139,7 +136,7 @@ export default async function LibrarySeriesPage({
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>Aucune série trouvée dans cette bibliothèque</p>
|
||||
<p>{t("librarySeries.noSeries")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import Link from "next/link";
|
||||
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, startMetadataBatch, LibraryDto, FolderItem } from "../../lib/api";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
import { LibraryActions } from "../components/LibraryActions";
|
||||
import { LibraryForm } from "../components/LibraryForm";
|
||||
import {
|
||||
@@ -10,13 +11,13 @@ import {
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatNextScan(nextScanAt: string | null): string {
|
||||
function formatNextScan(nextScanAt: string | null, imminentLabel: string): string {
|
||||
if (!nextScanAt) return "-";
|
||||
const date = new Date(nextScanAt);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 0) return "Imminent";
|
||||
if (diff < 0) return imminentLabel;
|
||||
if (diff < 60000) return "< 1 min";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
|
||||
@@ -24,6 +25,7 @@ function formatNextScan(nextScanAt: string | null): string {
|
||||
}
|
||||
|
||||
export default async function LibrariesPage() {
|
||||
const { t } = await getServerTranslations();
|
||||
const [libraries, folders] = await Promise.all([
|
||||
fetchLibraries().catch(() => [] as LibraryDto[]),
|
||||
listFolders().catch(() => [] as FolderItem[])
|
||||
@@ -90,15 +92,15 @@ export default async function LibrariesPage() {
|
||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
Bibliothèques
|
||||
{t("libraries.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Add Library Form */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Ajouter une bibliothèque</CardTitle>
|
||||
<CardDescription>Créer une nouvelle bibliothèque à partir d'un dossier existant</CardDescription>
|
||||
<CardTitle>{t("libraries.addLibrary")}</CardTitle>
|
||||
<CardDescription>{t("libraries.addLibraryDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LibraryForm initialFolders={folders} action={addLibrary} />
|
||||
@@ -115,7 +117,7 @@ export default async function LibrariesPage() {
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{lib.name}</CardTitle>
|
||||
{!lib.enabled && <Badge variant="muted" className="mt-1">Désactivée</Badge>}
|
||||
{!lib.enabled && <Badge variant="muted" className="mt-1">{t("libraries.disabled")}</Badge>}
|
||||
</div>
|
||||
<LibraryActions
|
||||
libraryId={lib.id}
|
||||
@@ -138,28 +140,28 @@ export default async function LibrariesPage() {
|
||||
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||
>
|
||||
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
|
||||
<span className="text-xs text-muted-foreground">Livres</span>
|
||||
<span className="text-xs text-muted-foreground">{t("libraries.books")}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/libraries/${lib.id}/series`}
|
||||
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||
>
|
||||
<span className="block text-2xl font-bold text-foreground">{seriesCount}</span>
|
||||
<span className="text-xs text-muted-foreground">Séries</span>
|
||||
<span className="text-xs text-muted-foreground">{t("libraries.series")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-3 mb-4 text-sm">
|
||||
<span className={`flex items-center gap-1 ${lib.monitor_enabled ? 'text-success' : 'text-muted-foreground'}`}>
|
||||
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? 'Auto' : 'Manuel'}
|
||||
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? t("libraries.auto") : t("libraries.manual")}
|
||||
</span>
|
||||
{lib.watcher_enabled && (
|
||||
<span className="text-warning" title="Surveillance de fichiers active">⚡</span>
|
||||
)}
|
||||
{lib.monitor_enabled && lib.next_scan_at && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
Prochain : {formatNextScan(lib.next_scan_at)}
|
||||
{t("libraries.nextScan", { time: formatNextScan(lib.next_scan_at, t("libraries.imminent")) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -172,7 +174,7 @@ export default async function LibrariesPage() {
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Indexer
|
||||
{t("libraries.index")}
|
||||
</Button>
|
||||
</form>
|
||||
<form className="flex-1">
|
||||
@@ -181,13 +183,13 @@ export default async function LibrariesPage() {
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Complet
|
||||
{t("libraries.fullIndex")}
|
||||
</Button>
|
||||
</form>
|
||||
{lib.metadata_provider !== "none" && (
|
||||
<form>
|
||||
<input type="hidden" name="id" value={lib.id} />
|
||||
<Button type="submit" variant="secondary" size="sm" formAction={batchMetadataAction} title="Métadonnées en lot">
|
||||
<Button type="submit" variant="secondary" size="sm" formAction={batchMetadataAction} title={t("libraries.batchMetadata")}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from "react";
|
||||
import { fetchStats, StatsResponse } from "../lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
|
||||
import Link from "next/link";
|
||||
import { getServerTranslations } from "../lib/i18n/server";
|
||||
import type { TranslateFunction } from "../lib/i18n/dictionaries";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,14 +15,14 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString("fr-FR");
|
||||
function formatNumber(n: number, locale: string): string {
|
||||
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
|
||||
}
|
||||
|
||||
// Donut chart via SVG
|
||||
function DonutChart({ data, colors }: { data: { label: string; value: number; color: string }[]; colors?: string[] }) {
|
||||
function DonutChart({ data, colors, noDataLabel, locale = "fr" }: { data: { label: string; value: number; color: string }[]; colors?: string[]; noDataLabel?: string; locale?: string }) {
|
||||
const total = data.reduce((sum, d) => sum + d.value, 0);
|
||||
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">Aucune donnée</p>;
|
||||
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||
|
||||
const radius = 40;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
@@ -51,7 +53,7 @@ function DonutChart({ data, colors }: { data: { label: string; value: number; co
|
||||
);
|
||||
})}
|
||||
<text x="50" y="50" textAnchor="middle" dominantBaseline="central" className="fill-foreground text-[10px] font-bold">
|
||||
{formatNumber(total)}
|
||||
{formatNumber(total, locale)}
|
||||
</text>
|
||||
</svg>
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
@@ -68,9 +70,9 @@ function DonutChart({ data, colors }: { data: { label: string; value: number; co
|
||||
}
|
||||
|
||||
// Bar chart via pure CSS
|
||||
function BarChart({ data, color = "var(--color-primary)" }: { data: { label: string; value: number }[]; color?: string }) {
|
||||
function BarChart({ data, color = "var(--color-primary)", noDataLabel }: { data: { label: string; value: number }[]; color?: string; noDataLabel?: string }) {
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">Aucune donnée</p>;
|
||||
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-1.5 h-40">
|
||||
@@ -101,7 +103,7 @@ function HorizontalBar({ label, value, max, subLabel, color = "var(--color-prima
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium text-foreground truncate">{label}</span>
|
||||
<span className="text-muted-foreground shrink-0 ml-2">{subLabel || formatNumber(value)}</span>
|
||||
<span className="text-muted-foreground shrink-0 ml-2">{subLabel || value}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -114,6 +116,8 @@ function HorizontalBar({ label, value, max, subLabel, color = "var(--color-prima
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const { t, locale } = await getServerTranslations();
|
||||
|
||||
let stats: StatsResponse | null = null;
|
||||
try {
|
||||
stats = await fetchStats();
|
||||
@@ -126,9 +130,9 @@ export default async function DashboardPage() {
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-4 text-foreground">StripStream Backoffice</h1>
|
||||
<p className="text-lg text-muted-foreground">Impossible de charger les statistiques. Vérifiez que l'API est en cours d'exécution.</p>
|
||||
<p className="text-lg text-muted-foreground">{t("dashboard.loadError")}</p>
|
||||
</div>
|
||||
<QuickLinks />
|
||||
<QuickLinks t={t} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +147,7 @@ export default async function DashboardPage() {
|
||||
];
|
||||
|
||||
const maxLibBooks = Math.max(...by_library.map((l) => l.book_count), 1);
|
||||
const noDataLabel = t("common.noData");
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
@@ -152,21 +157,21 @@ export default async function DashboardPage() {
|
||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Tableau de bord
|
||||
{t("dashboard.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2 max-w-2xl">
|
||||
Aperçu de votre collection de bandes dessinées. Gérez vos bibliothèques, suivez votre progression de lecture et explorez vos livres et séries.
|
||||
{t("dashboard.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Overview stat cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<StatCard icon="book" label="Livres" value={formatNumber(overview.total_books)} color="success" />
|
||||
<StatCard icon="series" label="Séries" value={formatNumber(overview.total_series)} color="primary" />
|
||||
<StatCard icon="library" label="Bibliothèques" value={formatNumber(overview.total_libraries)} color="warning" />
|
||||
<StatCard icon="pages" label="Pages" value={formatNumber(overview.total_pages)} color="primary" />
|
||||
<StatCard icon="author" label="Auteurs" value={formatNumber(overview.total_authors)} color="success" />
|
||||
<StatCard icon="size" label="Taille totale" value={formatBytes(overview.total_size_bytes)} color="warning" />
|
||||
<StatCard icon="book" label={t("dashboard.books")} value={formatNumber(overview.total_books, locale)} color="success" />
|
||||
<StatCard icon="series" label={t("dashboard.series")} value={formatNumber(overview.total_series, locale)} color="primary" />
|
||||
<StatCard icon="library" label={t("dashboard.libraries")} value={formatNumber(overview.total_libraries, locale)} color="warning" />
|
||||
<StatCard icon="pages" label={t("dashboard.pages")} value={formatNumber(overview.total_pages, locale)} color="primary" />
|
||||
<StatCard icon="author" label={t("dashboard.authors")} value={formatNumber(overview.total_authors, locale)} color="success" />
|
||||
<StatCard icon="size" label={t("dashboard.totalSize")} value={formatBytes(overview.total_size_bytes)} color="warning" />
|
||||
</div>
|
||||
|
||||
{/* Charts row */}
|
||||
@@ -174,14 +179,16 @@ export default async function DashboardPage() {
|
||||
{/* Reading status donut */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Statut de lecture</CardTitle>
|
||||
<CardTitle className="text-base">{t("dashboard.readingStatus")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DonutChart
|
||||
locale={locale}
|
||||
noDataLabel={noDataLabel}
|
||||
data={[
|
||||
{ label: "Non lu", value: reading_status.unread, color: readingColors[0] },
|
||||
{ label: "En cours", value: reading_status.reading, color: readingColors[1] },
|
||||
{ label: "Lu", value: reading_status.read, color: readingColors[2] },
|
||||
{ label: t("status.unread"), value: reading_status.unread, color: readingColors[0] },
|
||||
{ label: t("status.reading"), value: reading_status.reading, color: readingColors[1] },
|
||||
{ label: t("status.read"), value: reading_status.read, color: readingColors[2] },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -190,12 +197,14 @@ export default async function DashboardPage() {
|
||||
{/* By format donut */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Par format</CardTitle>
|
||||
<CardTitle className="text-base">{t("dashboard.byFormat")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DonutChart
|
||||
locale={locale}
|
||||
noDataLabel={noDataLabel}
|
||||
data={by_format.slice(0, 6).map((f, i) => ({
|
||||
label: (f.format || "Inconnu").toUpperCase(),
|
||||
label: (f.format || t("dashboard.unknown")).toUpperCase(),
|
||||
value: f.count,
|
||||
color: formatColors[i % formatColors.length],
|
||||
}))}
|
||||
@@ -206,10 +215,12 @@ export default async function DashboardPage() {
|
||||
{/* By library donut */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Par bibliothèque</CardTitle>
|
||||
<CardTitle className="text-base">{t("dashboard.byLibrary")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DonutChart
|
||||
locale={locale}
|
||||
noDataLabel={noDataLabel}
|
||||
data={by_library.slice(0, 6).map((l, i) => ({
|
||||
label: l.library_name,
|
||||
value: l.book_count,
|
||||
@@ -225,10 +236,11 @@ export default async function DashboardPage() {
|
||||
{/* Monthly additions bar chart */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Livres ajoutés (12 derniers mois)</CardTitle>
|
||||
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={additions_over_time.map((m) => ({
|
||||
label: m.month.slice(5), // "MM" from "YYYY-MM"
|
||||
value: m.books_added,
|
||||
@@ -241,7 +253,7 @@ export default async function DashboardPage() {
|
||||
{/* Top series */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Séries populaires</CardTitle>
|
||||
<CardTitle className="text-base">{t("dashboard.popularSeries")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
@@ -251,12 +263,12 @@ export default async function DashboardPage() {
|
||||
label={s.series}
|
||||
value={s.book_count}
|
||||
max={top_series[0]?.book_count || 1}
|
||||
subLabel={`${s.read_count}/${s.book_count} lu`}
|
||||
subLabel={t("dashboard.readCount", { read: s.read_count, total: s.book_count })}
|
||||
color="hsl(142 60% 45%)"
|
||||
/>
|
||||
))}
|
||||
{top_series.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">Aucune série pour le moment</p>
|
||||
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noSeries")}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -267,7 +279,7 @@ export default async function DashboardPage() {
|
||||
{by_library.length > 0 && (
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Bibliothèques</CardTitle>
|
||||
<CardTitle className="text-base">{t("dashboard.libraries")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
|
||||
@@ -281,23 +293,23 @@ export default async function DashboardPage() {
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${(lib.read_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(142 60% 45%)" }}
|
||||
title={`Lu : ${lib.read_count}`}
|
||||
title={`${t("status.read")} : ${lib.read_count}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${(lib.reading_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(45 93% 47%)" }}
|
||||
title={`En cours : ${lib.reading_count}`}
|
||||
title={`${t("status.reading")} : ${lib.reading_count}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${(lib.unread_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(220 13% 70%)" }}
|
||||
title={`Non lu : ${lib.unread_count}`}
|
||||
title={`${t("status.unread")} : ${lib.unread_count}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{lib.book_count} livres</span>
|
||||
<span className="text-success">{lib.read_count} lu</span>
|
||||
<span className="text-warning">{lib.reading_count} en cours</span>
|
||||
<span>{lib.book_count} {t("dashboard.books").toLowerCase()}</span>
|
||||
<span className="text-success">{lib.read_count} {t("status.read").toLowerCase()}</span>
|
||||
<span className="text-warning">{lib.reading_count} {t("status.reading").toLowerCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -307,7 +319,7 @@ export default async function DashboardPage() {
|
||||
)}
|
||||
|
||||
{/* Quick links */}
|
||||
<QuickLinks />
|
||||
<QuickLinks t={t} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -345,12 +357,12 @@ function StatCard({ icon, label, value, color }: { icon: string; label: string;
|
||||
);
|
||||
}
|
||||
|
||||
function QuickLinks() {
|
||||
function QuickLinks({ t }: { t: TranslateFunction }) {
|
||||
const links = [
|
||||
{ href: "/libraries", label: "Bibliothèques", bg: "bg-primary/10", text: "text-primary", hoverBg: "group-hover:bg-primary", hoverText: "group-hover:text-primary-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> },
|
||||
{ href: "/books", label: "Livres", bg: "bg-success/10", text: "text-success", hoverBg: "group-hover:bg-success", hoverText: "group-hover:text-white", icon: <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" /> },
|
||||
{ href: "/series", label: "Séries", bg: "bg-warning/10", text: "text-warning", hoverBg: "group-hover:bg-warning", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> },
|
||||
{ href: "/jobs", label: "Tâches", bg: "bg-destructive/10", text: "text-destructive", hoverBg: "group-hover:bg-destructive", hoverText: "group-hover:text-destructive-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> },
|
||||
{ href: "/libraries", label: t("nav.libraries"), bg: "bg-primary/10", text: "text-primary", hoverBg: "group-hover:bg-primary", hoverText: "group-hover:text-primary-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> },
|
||||
{ href: "/books", label: t("nav.books"), bg: "bg-success/10", text: "text-success", hoverBg: "group-hover:bg-success", hoverText: "group-hover:text-white", icon: <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" /> },
|
||||
{ href: "/series", label: t("nav.series"), bg: "bg-warning/10", text: "text-warning", hoverBg: "group-hover:bg-warning", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> },
|
||||
{ href: "/jobs", label: t("nav.jobs"), bg: "bg-destructive/10", text: "text-destructive", hoverBg: "group-hover:bg-destructive", hoverText: "group-hover:text-destructive-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton";
|
||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||
@@ -12,6 +13,7 @@ export default async function SeriesPage({
|
||||
}: {
|
||||
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 : "";
|
||||
@@ -33,39 +35,39 @@ export default async function SeriesPage({
|
||||
const series = seriesPage.items;
|
||||
const totalPages = Math.ceil(seriesPage.total / limit);
|
||||
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 || seriesStatus || hasMissing;
|
||||
|
||||
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 KNOWN_STATUSES: Record<string, string> = {
|
||||
ongoing: "En cours",
|
||||
ended: "Terminée",
|
||||
hiatus: "Hiatus",
|
||||
cancelled: "Annulée",
|
||||
upcoming: "À paraître",
|
||||
ongoing: t("seriesStatus.ongoing"),
|
||||
ended: t("seriesStatus.ended"),
|
||||
hiatus: t("seriesStatus.hiatus"),
|
||||
cancelled: t("seriesStatus.cancelled"),
|
||||
upcoming: t("seriesStatus.upcoming"),
|
||||
};
|
||||
const seriesStatusOptions = [
|
||||
{ value: "", label: "Tous les statuts" },
|
||||
{ value: "", label: t("seriesStatus.allStatuses") },
|
||||
...dbStatuses.map((s) => ({ value: s, label: KNOWN_STATUSES[s] || s })),
|
||||
];
|
||||
|
||||
const missingOptions = [
|
||||
{ value: "", label: "Tous" },
|
||||
{ value: "true", label: "Livres manquants" },
|
||||
{ value: "", label: t("common.all") },
|
||||
{ value: "true", label: t("series.missingBooks") },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -75,7 +77,7 @@ export default async function SeriesPage({
|
||||
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
Séries
|
||||
{t("series.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -84,12 +86,12 @@ export default async function SeriesPage({
|
||||
<LiveSearchForm
|
||||
basePath="/series"
|
||||
fields={[
|
||||
{ name: "q", type: "text", label: "Rechercher", placeholder: "Rechercher par nom de 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: "Lecture", options: statusOptions, className: "w-full sm:w-36" },
|
||||
{ name: "series_status", type: "select", label: "Statut", options: seriesStatusOptions, className: "w-full sm:w-36" },
|
||||
{ name: "has_missing", type: "select", label: "Manquant", options: missingOptions, className: "w-full sm:w-36" },
|
||||
{ name: "sort", type: "select", label: "Tri", options: sortOptions, className: "w-full sm:w-36" },
|
||||
{ name: "q", type: "text", label: t("common.search"), placeholder: t("series.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("series.reading"), options: statusOptions, className: "w-full sm:w-36" },
|
||||
{ name: "series_status", type: "select", label: t("editSeries.status"), options: seriesStatusOptions, className: "w-full sm:w-36" },
|
||||
{ name: "has_missing", type: "select", label: t("series.missing"), options: missingOptions, className: "w-full sm:w-36" },
|
||||
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions, className: "w-full sm:w-36" },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -97,8 +99,8 @@ export default async function SeriesPage({
|
||||
|
||||
{/* Results count */}
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{seriesPage.total} séries
|
||||
{searchQuery && <> correspondant à "{searchQuery}"</>}
|
||||
{seriesPage.total} {t("series.title").toLowerCase()}
|
||||
{searchQuery && <> {t("series.matchingQuery")} "{searchQuery}"</>}
|
||||
</p>
|
||||
|
||||
{/* Series Grid */}
|
||||
@@ -119,7 +121,7 @@ export default async function SeriesPage({
|
||||
<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
|
||||
@@ -127,11 +129,11 @@ export default async function SeriesPage({
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<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>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.books_read_count}/{s.book_count} lu{s.book_count !== 1 ? "s" : ""}
|
||||
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||
</p>
|
||||
<MarkSeriesReadButton
|
||||
seriesName={s.name}
|
||||
@@ -148,17 +150,12 @@ export default async function SeriesPage({
|
||||
s.series_status === "cancelled" ? "bg-red-500/15 text-red-600" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{s.series_status === "ongoing" ? "En cours" :
|
||||
s.series_status === "ended" ? "Terminée" :
|
||||
s.series_status === "hiatus" ? "Hiatus" :
|
||||
s.series_status === "cancelled" ? "Annulée" :
|
||||
s.series_status === "upcoming" ? "À paraître" :
|
||||
s.series_status}
|
||||
{KNOWN_STATUSES[s.series_status] || s.series_status}
|
||||
</span>
|
||||
)}
|
||||
{s.missing_count != null && s.missing_count > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-yellow-500/15 text-yellow-600">
|
||||
{s.missing_count} manquant{s.missing_count > 1 ? "s" : ""}
|
||||
{t("series.missingCount", { count: String(s.missing_count), plural: s.missing_count > 1 ? "s" : "" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -183,7 +180,7 @@ export default async function SeriesPage({
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{hasFilters ? "Aucune série trouvée correspondant à vos filtres" : "Aucune série disponible"}
|
||||
{hasFilters ? t("series.noResults") : t("series.noSeries")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
|
||||
import { ProviderIcon } from "../components/ProviderIcon";
|
||||
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary } from "../../lib/api";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import type { Locale } from "../../lib/i18n/types";
|
||||
|
||||
interface SettingsPageProps {
|
||||
initialSettings: Settings;
|
||||
@@ -12,6 +14,7 @@ interface SettingsPageProps {
|
||||
}
|
||||
|
||||
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats }: SettingsPageProps) {
|
||||
const { t, locale, setLocale } = useTranslation();
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
...initialSettings,
|
||||
thumbnail: initialSettings.thumbnail || { enabled: true, width: 300, height: 400, quality: 80, format: "webp", directory: "/data/thumbnails" }
|
||||
@@ -55,13 +58,13 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
body: JSON.stringify({ value })
|
||||
});
|
||||
if (response.ok) {
|
||||
setSaveMessage("Paramètres enregistrés avec succès");
|
||||
setSaveMessage(t("settings.savedSuccess"));
|
||||
setTimeout(() => setSaveMessage(null), 3000);
|
||||
} else {
|
||||
setSaveMessage("Échec de l'enregistrement des paramètres");
|
||||
setSaveMessage(t("settings.savedError"));
|
||||
}
|
||||
} catch (error) {
|
||||
setSaveMessage("Erreur lors de l'enregistrement des paramètres");
|
||||
setSaveMessage(t("settings.saveError"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -81,7 +84,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
setCacheStats(stats);
|
||||
}
|
||||
} catch (error) {
|
||||
setClearResult({ success: false, message: "Échec du vidage du cache" });
|
||||
setClearResult({ success: false, message: t("settings.cacheClearError") });
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
@@ -150,8 +153,8 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
const [activeTab, setActiveTab] = useState<"general" | "integrations">("general");
|
||||
|
||||
const tabs = [
|
||||
{ id: "general" as const, label: "Général", icon: "settings" as const },
|
||||
{ id: "integrations" as const, label: "Intégrations", icon: "refresh" as const },
|
||||
{ id: "general" as const, label: t("settings.general"), icon: "settings" as const },
|
||||
{ id: "integrations" as const, label: t("settings.integrations"), icon: "refresh" as const },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -159,7 +162,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||
<Icon name="settings" size="xl" />
|
||||
Paramètres
|
||||
{t("settings.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -190,20 +193,40 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
)}
|
||||
|
||||
{activeTab === "general" && (<>
|
||||
{/* Language Selector */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="settings" size="md" />
|
||||
{t("settings.language")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("settings.languageDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormSelect
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
>
|
||||
<option value="fr">Français</option>
|
||||
<option value="en">English</option>
|
||||
</FormSelect>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Image Processing Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="image" size="md" />
|
||||
Traitement d'images
|
||||
{t("settings.imageProcessing")}
|
||||
</CardTitle>
|
||||
<CardDescription>Ces paramètres s'appliquent uniquement lorsqu'un client demande explicitement une conversion de format via l'API (ex. <code className="text-xs bg-muted px-1 rounded">?format=webp&width=800</code>). Les pages servies sans paramètres sont livrées telles quelles depuis l'archive, sans traitement.</CardDescription>
|
||||
<CardDescription><span dangerouslySetInnerHTML={{ __html: t("settings.imageProcessingDesc") }} /></CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Format de sortie par défaut</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.defaultFormat")}</label>
|
||||
<FormSelect
|
||||
value={settings.image_processing.format}
|
||||
onChange={(e) => {
|
||||
@@ -218,7 +241,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Qualité par défaut (1-100)</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.defaultQuality")}</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -235,7 +258,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Filtre de redimensionnement par défaut</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.defaultFilter")}</label>
|
||||
<FormSelect
|
||||
value={settings.image_processing.filter}
|
||||
onChange={(e) => {
|
||||
@@ -244,13 +267,13 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
handleUpdateSetting("image_processing", newSettings.image_processing);
|
||||
}}
|
||||
>
|
||||
<option value="lanczos3">Lanczos3 (Meilleure qualité)</option>
|
||||
<option value="triangle">Triangle (Plus rapide)</option>
|
||||
<option value="nearest">Nearest (Le plus rapide)</option>
|
||||
<option value="lanczos3">{t("settings.filterLanczos")}</option>
|
||||
<option value="triangle">{t("settings.filterTriangle")}</option>
|
||||
<option value="nearest">{t("settings.filterNearest")}</option>
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Largeur maximale autorisée (px)</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.maxWidth")}</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
min={100}
|
||||
@@ -274,23 +297,23 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="cache" size="md" />
|
||||
Cache
|
||||
{t("settings.cache")}
|
||||
</CardTitle>
|
||||
<CardDescription>Gérer le cache d'images et le stockage</CardDescription>
|
||||
<CardDescription>{t("settings.cacheDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Taille du cache</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.cacheSize")}</p>
|
||||
<p className="text-2xl font-semibold">{cacheStats.total_size_mb.toFixed(2)} MB</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Files</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.files")}</p>
|
||||
<p className="text-2xl font-semibold">{cacheStats.file_count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Directory</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.directory")}</p>
|
||||
<p className="text-sm font-mono truncate" title={cacheStats.directory}>{cacheStats.directory}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,7 +326,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Cache Directory</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.cacheDirectory")}</label>
|
||||
<FormInput
|
||||
value={settings.cache.directory}
|
||||
onChange={(e) => {
|
||||
@@ -314,7 +337,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
/>
|
||||
</FormField>
|
||||
<FormField className="w-32">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Max Size (MB)</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.maxSizeMb")}</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
value={settings.cache.max_size_mb}
|
||||
@@ -336,12 +359,12 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
{isClearing ? (
|
||||
<>
|
||||
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
|
||||
Clearing...
|
||||
{t("settings.clearing")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="trash" size="sm" className="mr-2" />
|
||||
Clear Cache
|
||||
{t("settings.clearCache")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -354,15 +377,15 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="performance" size="md" />
|
||||
Performance Limits
|
||||
{t("settings.performanceLimits")}
|
||||
</CardTitle>
|
||||
<CardDescription>Configure API performance, rate limiting, and thumbnail generation concurrency</CardDescription>
|
||||
<CardDescription>{t("settings.performanceDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Concurrent Renders</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.concurrentRenders")}</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -376,11 +399,11 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
onBlur={() => handleUpdateSetting("limits", settings.limits)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Maximum number of page renders and thumbnail generations running in parallel
|
||||
{t("settings.concurrentRendersHelp")}
|
||||
</p>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Timeout (seconds)</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.timeoutSeconds")}</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
min={5}
|
||||
@@ -395,7 +418,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
/>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Rate Limit (req/s)</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.rateLimit")}</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
min={10}
|
||||
@@ -411,7 +434,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
</FormField>
|
||||
</FormRow>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Note: Changes to limits require a server restart to take effect. The "Concurrent Renders" setting controls both page rendering and thumbnail generation parallelism.
|
||||
{t("settings.limitsNote")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -422,15 +445,15 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="image" size="md" />
|
||||
Thumbnails
|
||||
{t("settings.thumbnails")}
|
||||
</CardTitle>
|
||||
<CardDescription>Configure thumbnail generation during indexing</CardDescription>
|
||||
<CardDescription>{t("settings.thumbnailsDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Enable Thumbnails</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.enableThumbnails")}</label>
|
||||
<FormSelect
|
||||
value={settings.thumbnail.enabled ? "true" : "false"}
|
||||
onChange={(e) => {
|
||||
@@ -439,12 +462,12 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
handleUpdateSetting("thumbnail", newSettings.thumbnail);
|
||||
}}
|
||||
>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
<option value="true">{t("common.enabled")}</option>
|
||||
<option value="false">{t("common.disabled")}</option>
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Output Format</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.outputFormat")}</label>
|
||||
<FormSelect
|
||||
value={settings.thumbnail.format}
|
||||
onChange={(e) => {
|
||||
@@ -453,21 +476,21 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
handleUpdateSetting("thumbnail", newSettings.thumbnail);
|
||||
}}
|
||||
>
|
||||
<option value="original">Original (No Re-encoding)</option>
|
||||
<option value="original">{t("settings.formatOriginal")}</option>
|
||||
<option value="webp">WebP</option>
|
||||
<option value="jpeg">JPEG</option>
|
||||
<option value="png">PNG</option>
|
||||
</FormSelect>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{settings.thumbnail.format === "original"
|
||||
? "Resizes to target dimensions, keeps source format (JPEG→JPEG). Much faster generation."
|
||||
: "Resizes and re-encodes to selected format."}
|
||||
? t("settings.formatOriginalDesc")
|
||||
: t("settings.formatReencodeDesc")}
|
||||
</p>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Width (px)</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.width")}</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
min={50}
|
||||
@@ -482,7 +505,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
/>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Height (px)</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.height")}</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
min={50}
|
||||
@@ -497,7 +520,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
/>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Quality (1-100)</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.quality")}</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -514,7 +537,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Thumbnail Directory</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.thumbnailDirectory")}</label>
|
||||
<FormInput
|
||||
value={settings.thumbnail.directory}
|
||||
onChange={(e) => {
|
||||
@@ -528,21 +551,21 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Size</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.totalSize")}</p>
|
||||
<p className="text-2xl font-semibold">{thumbnailStats.total_size_mb.toFixed(2)} MB</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Files</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.files")}</p>
|
||||
<p className="text-2xl font-semibold">{thumbnailStats.file_count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Directory</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.directory")}</p>
|
||||
<p className="text-sm font-mono truncate" title={thumbnailStats.directory}>{thumbnailStats.directory}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Note: Thumbnail settings are used during indexing. Existing thumbnails will not be regenerated automatically. The concurrency for thumbnail generation is controlled by the "Concurrent Renders" setting in Performance Limits above.
|
||||
{t("settings.thumbnailsNote")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -559,15 +582,15 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="refresh" size="md" />
|
||||
Komga Sync
|
||||
{t("settings.komgaSync")}
|
||||
</CardTitle>
|
||||
<CardDescription>Import read status from a Komga server. Books are matched by title (case-insensitive). Credentials are not stored.</CardDescription>
|
||||
<CardDescription>{t("settings.komgaDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Komga URL</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.komgaUrl")}</label>
|
||||
<FormInput
|
||||
type="url"
|
||||
placeholder="https://komga.example.com"
|
||||
@@ -578,14 +601,14 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Username</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.username")}</label>
|
||||
<FormInput
|
||||
value={komgaUsername}
|
||||
onChange={(e) => setKomgaUsername(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Password</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.password")}</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
value={komgaPassword}
|
||||
@@ -601,12 +624,12 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
|
||||
Syncing...
|
||||
{t("settings.syncing")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="refresh" size="sm" className="mr-2" />
|
||||
Sync Read Books
|
||||
{t("settings.syncReadBooks")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -621,19 +644,19 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Komga read</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.komgaRead")}</p>
|
||||
<p className="text-2xl font-semibold">{syncResult.total_komga_read}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Matched</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.matched")}</p>
|
||||
<p className="text-2xl font-semibold">{syncResult.matched}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Already read</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.alreadyRead")}</p>
|
||||
<p className="text-2xl font-semibold">{syncResult.already_read}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Newly marked</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.newlyMarked")}</p>
|
||||
<p className="text-2xl font-semibold text-success">{syncResult.newly_marked}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -646,7 +669,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showMatchedBooks ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{syncResult.matched_books.length} matched book{syncResult.matched_books.length !== 1 ? "s" : ""}
|
||||
{t("settings.matchedBooks", { count: syncResult.matched_books.length, plural: syncResult.matched_books.length !== 1 ? "s" : "" })}
|
||||
</button>
|
||||
{showMatchedBooks && (
|
||||
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-success/5 rounded-lg text-sm space-y-1">
|
||||
@@ -671,7 +694,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showUnmatched ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{syncResult.unmatched.length} unmatched book{syncResult.unmatched.length !== 1 ? "s" : ""}
|
||||
{t("settings.unmatchedBooks", { count: syncResult.unmatched.length, plural: syncResult.unmatched.length !== 1 ? "s" : "" })}
|
||||
</button>
|
||||
{showUnmatched && (
|
||||
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
||||
@@ -687,7 +710,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
{/* Past reports */}
|
||||
{reports.length > 0 && (
|
||||
<div className="border-t border-border pt-4">
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">Sync History</h3>
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">{t("settings.syncHistory")}</h3>
|
||||
<div className="space-y-2">
|
||||
{reports.map((r) => (
|
||||
<button
|
||||
@@ -709,11 +732,11 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 mt-1 text-xs text-muted-foreground">
|
||||
<span>{r.total_komga_read} read</span>
|
||||
<span>{r.matched} matched</span>
|
||||
<span className="text-success">{r.newly_marked} new</span>
|
||||
<span>{r.total_komga_read} {t("settings.read")}</span>
|
||||
<span>{r.matched} {t("settings.matched").toLowerCase()}</span>
|
||||
<span className="text-success">{r.newly_marked} {t("settings.new")}</span>
|
||||
{r.unmatched_count > 0 && (
|
||||
<span className="text-warning">{r.unmatched_count} unmatched</span>
|
||||
<span className="text-warning">{r.unmatched_count} {t("settings.unmatched")}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
@@ -725,19 +748,19 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Komga read</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.komgaRead")}</p>
|
||||
<p className="text-2xl font-semibold">{selectedReport.total_komga_read}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Matched</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.matched")}</p>
|
||||
<p className="text-2xl font-semibold">{selectedReport.matched}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Already read</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.alreadyRead")}</p>
|
||||
<p className="text-2xl font-semibold">{selectedReport.already_read}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Newly marked</p>
|
||||
<p className="text-sm text-muted-foreground">{t("settings.newlyMarked")}</p>
|
||||
<p className="text-2xl font-semibold text-success">{selectedReport.newly_marked}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -750,7 +773,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showReportMatchedBooks ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{selectedReport.matched_books.length} matched book{selectedReport.matched_books.length !== 1 ? "s" : ""}
|
||||
{t("settings.matchedBooks", { count: selectedReport.matched_books.length, plural: selectedReport.matched_books.length !== 1 ? "s" : "" })}
|
||||
</button>
|
||||
{showReportMatchedBooks && (
|
||||
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-success/5 rounded-lg text-sm space-y-1">
|
||||
@@ -775,7 +798,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Icon name={showReportUnmatched ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{selectedReport.unmatched.length} unmatched book{selectedReport.unmatched.length !== 1 ? "s" : ""}
|
||||
{t("settings.unmatchedBooks", { count: selectedReport.unmatched.length, plural: selectedReport.unmatched.length !== 1 ? "s" : "" })}
|
||||
</button>
|
||||
{showReportUnmatched && (
|
||||
<div className="mt-2 max-h-60 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
||||
@@ -809,6 +832,7 @@ const METADATA_LANGUAGES = [
|
||||
] as const;
|
||||
|
||||
function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
|
||||
const { t } = useTranslation();
|
||||
const [defaultProvider, setDefaultProvider] = useState("google_books");
|
||||
const [metadataLanguage, setMetadataLanguage] = useState("en");
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||
@@ -843,15 +867,15 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="search" size="md" />
|
||||
Metadata Providers
|
||||
{t("settings.metadataProviders")}
|
||||
</CardTitle>
|
||||
<CardDescription>Configure external metadata providers for series/book enrichment. Each library can override the default provider. All providers are available for quick-search in the metadata modal.</CardDescription>
|
||||
<CardDescription>{t("settings.metadataProvidersDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Default provider */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-2 block">Default Provider</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-2 block">{t("settings.defaultProvider")}</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{([
|
||||
{ value: "google_books", label: "Google Books" },
|
||||
@@ -878,12 +902,12 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Used by default for metadata search. Libraries can override this individually.</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{t("settings.defaultProviderHelp")}</p>
|
||||
</div>
|
||||
|
||||
{/* Metadata language */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-2 block">Metadata Language</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-2 block">{t("settings.metadataLanguage")}</label>
|
||||
<div className="flex gap-2">
|
||||
{METADATA_LANGUAGES.map((l) => (
|
||||
<button
|
||||
@@ -903,41 +927,41 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Preferred language for search results and descriptions. Fallback: English.</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{t("settings.metadataLanguageHelp")}</p>
|
||||
</div>
|
||||
|
||||
{/* Provider API keys — always visible */}
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<h4 className="text-sm font-medium text-foreground mb-3">API Keys</h4>
|
||||
<h4 className="text-sm font-medium text-foreground mb-3">{t("settings.apiKeys")}</h4>
|
||||
<div className="space-y-4">
|
||||
<FormField>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 flex items-center gap-1.5">
|
||||
<ProviderIcon provider="google_books" size={16} />
|
||||
Google Books API Key
|
||||
{t("settings.googleBooksKey")}
|
||||
</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
placeholder="Optional — for higher rate limits"
|
||||
placeholder={t("settings.googleBooksPlaceholder")}
|
||||
value={apiKeys.google_books || ""}
|
||||
onChange={(e) => setApiKeys({ ...apiKeys, google_books: e.target.value })}
|
||||
onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Works without a key but with lower rate limits.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("settings.googleBooksHelp")}</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 flex items-center gap-1.5">
|
||||
<ProviderIcon provider="comicvine" size={16} />
|
||||
ComicVine API Key
|
||||
{t("settings.comicvineKey")}
|
||||
</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
placeholder="Required to use ComicVine"
|
||||
placeholder={t("settings.comicvinePlaceholder")}
|
||||
value={apiKeys.comicvine || ""}
|
||||
onChange={(e) => setApiKeys({ ...apiKeys, comicvine: e.target.value })}
|
||||
onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Get your key at <span className="font-mono text-foreground/70">comicvine.gamespot.com/api</span>.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("settings.comicvineHelp")} <span className="font-mono text-foreground/70">comicvine.gamespot.com/api</span>.</p>
|
||||
</FormField>
|
||||
|
||||
<div className="p-3 rounded-lg bg-muted/30 flex items-center gap-3 flex-wrap">
|
||||
@@ -950,12 +974,12 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
|
||||
<ProviderIcon provider="anilist" size={16} />
|
||||
<span className="text-xs font-medium text-foreground">AniList</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">and</span>
|
||||
<span className="text-xs text-muted-foreground">{t("common.and")}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ProviderIcon provider="bedetheque" size={16} />
|
||||
<span className="text-xs font-medium text-foreground">Bédéthèque</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">are free and require no API key.</span>
|
||||
<span className="text-xs text-muted-foreground">{t("settings.freeProviders")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTokens, createToken, revokeToken, deleteToken, TokenDto } from "../../lib/api";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -10,6 +11,7 @@ export default async function TokensPage({
|
||||
}: {
|
||||
searchParams: Promise<{ created?: string }>;
|
||||
}) {
|
||||
const { t } = await getServerTranslations();
|
||||
const params = await searchParams;
|
||||
const tokens = await listTokens().catch(() => [] as TokenDto[]);
|
||||
|
||||
@@ -45,15 +47,15 @@ export default async function TokensPage({
|
||||
<svg className="w-8 h-8 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
Jetons API
|
||||
{t("tokens.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{params.created ? (
|
||||
<Card className="mb-6 border-success/50 bg-success/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-success">Jeton créé</CardTitle>
|
||||
<CardDescription>Copiez-le maintenant, il ne sera plus affiché</CardDescription>
|
||||
<CardTitle className="text-success">{t("tokens.created")}</CardTitle>
|
||||
<CardDescription>{t("tokens.createdDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="p-4 bg-background rounded-lg text-sm font-mono text-foreground overflow-x-auto border">{params.created}</pre>
|
||||
@@ -63,22 +65,22 @@ export default async function TokensPage({
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Créer un nouveau jeton</CardTitle>
|
||||
<CardDescription>Générer un nouveau jeton API avec la portée souhaitée</CardDescription>
|
||||
<CardTitle>{t("tokens.createNew")}</CardTitle>
|
||||
<CardDescription>{t("tokens.createDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={createTokenAction}>
|
||||
<FormRow>
|
||||
<FormField className="flex-1 min-w-48">
|
||||
<FormInput name="name" placeholder="Nom du jeton" required />
|
||||
<FormInput name="name" placeholder={t("tokens.tokenName")} required />
|
||||
</FormField>
|
||||
<FormField className="w-32">
|
||||
<FormSelect name="scope" defaultValue="read">
|
||||
<option value="read">Lecture</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="read">{t("tokens.scopeRead")}</option>
|
||||
<option value="admin">{t("tokens.scopeAdmin")}</option>
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
<Button type="submit">Créer le jeton</Button>
|
||||
<Button type="submit">{t("tokens.createButton")}</Button>
|
||||
</FormRow>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -89,11 +91,11 @@ export default async function TokensPage({
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/50">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Nom</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Portée</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Préfixe</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Statut</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.name")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.scope")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.prefix")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.status")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
@@ -110,9 +112,9 @@ export default async function TokensPage({
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{token.revoked_at ? (
|
||||
<Badge variant="error">Révoqué</Badge>
|
||||
<Badge variant="error">{t("tokens.revoked")}</Badge>
|
||||
) : (
|
||||
<Badge variant="success">Actif</Badge>
|
||||
<Badge variant="success">{t("tokens.active")}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -123,7 +125,7 @@ export default async function TokensPage({
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Révoquer
|
||||
{t("tokens.revoke")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
@@ -133,7 +135,7 @@ export default async function TokensPage({
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Supprimer
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
60
apps/backoffice/lib/i18n/context.tsx
Normal file
60
apps/backoffice/lib/i18n/context.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useCallback, useState, type ReactNode } from "react";
|
||||
import type { Locale } from "./types";
|
||||
import { LOCALE_COOKIE } from "./types";
|
||||
import { getDictionarySync, createTranslateFunction } from "./dictionaries";
|
||||
import type { TranslateFunction } from "./dictionaries";
|
||||
|
||||
interface LocaleContextValue {
|
||||
locale: Locale;
|
||||
t: TranslateFunction;
|
||||
setLocale: (locale: Locale) => void;
|
||||
}
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
interface LocaleProviderProps {
|
||||
initialLocale: Locale;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function LocaleProvider({ initialLocale, children }: LocaleProviderProps) {
|
||||
const [locale] = useState<Locale>(initialLocale);
|
||||
const dict = getDictionarySync(locale);
|
||||
const t = createTranslateFunction(dict);
|
||||
|
||||
const setLocale = useCallback(async (newLocale: Locale) => {
|
||||
// Set cookie
|
||||
document.cookie = `${LOCALE_COOKIE}=${newLocale};path=/;max-age=${365 * 24 * 60 * 60}`;
|
||||
|
||||
// Save to DB
|
||||
try {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || "http://localhost:7080";
|
||||
await fetch(`${apiBase}/settings`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ language: newLocale }),
|
||||
});
|
||||
} catch {
|
||||
// Best effort — cookie is the primary source for rendering
|
||||
}
|
||||
|
||||
// Reload to apply new locale everywhere (server + client)
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={{ locale, t, setLocale }}>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranslation(): LocaleContextValue {
|
||||
const ctx = useContext(LocaleContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useTranslation must be used within a LocaleProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
35
apps/backoffice/lib/i18n/dictionaries.ts
Normal file
35
apps/backoffice/lib/i18n/dictionaries.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Locale } from "./types";
|
||||
import type { TranslationKey } from "./fr";
|
||||
|
||||
const dictionaries: Record<Locale, () => Promise<Record<TranslationKey, string>>> = {
|
||||
fr: () => import("./fr").then((m) => m.default),
|
||||
en: () => import("./en").then((m) => m.default),
|
||||
};
|
||||
|
||||
export async function getDictionary(locale: Locale): Promise<Record<TranslationKey, string>> {
|
||||
return dictionaries[locale]();
|
||||
}
|
||||
|
||||
// Synchronous versions for client-side use
|
||||
import fr from "./fr";
|
||||
import en from "./en";
|
||||
|
||||
const dictionariesSync: Record<Locale, Record<TranslationKey, string>> = { fr, en };
|
||||
|
||||
export function getDictionarySync(locale: Locale): Record<TranslationKey, string> {
|
||||
return dictionariesSync[locale];
|
||||
}
|
||||
|
||||
export type TranslateFunction = (key: TranslationKey, params?: Record<string, string | number>) => string;
|
||||
|
||||
export function createTranslateFunction(dict: Record<TranslationKey, string>): TranslateFunction {
|
||||
return (key: TranslationKey, params?: Record<string, string | number>): string => {
|
||||
let value: string = dict[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
value = value.replace(new RegExp(`\\{\\{${k}\\}\\}`, "g"), String(v));
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
555
apps/backoffice/lib/i18n/en.ts
Normal file
555
apps/backoffice/lib/i18n/en.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import type { TranslationKey } from "./fr";
|
||||
|
||||
const en: Record<TranslationKey, string> = {
|
||||
// Navigation
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.books": "Books",
|
||||
"nav.series": "Series",
|
||||
"nav.libraries": "Libraries",
|
||||
"nav.jobs": "Jobs",
|
||||
"nav.tokens": "Tokens",
|
||||
"nav.settings": "Settings",
|
||||
"nav.navigation": "Navigation",
|
||||
"nav.closeMenu": "Close menu",
|
||||
"nav.openMenu": "Open menu",
|
||||
|
||||
// Common
|
||||
"common.save": "Save",
|
||||
"common.saving": "Saving...",
|
||||
"common.cancel": "Cancel",
|
||||
"common.close": "Close",
|
||||
"common.delete": "Delete",
|
||||
"common.edit": "Edit",
|
||||
"common.search": "Search",
|
||||
"common.clear": "Clear",
|
||||
"common.view": "View",
|
||||
"common.all": "All",
|
||||
"common.enabled": "Enabled",
|
||||
"common.disabled": "Disabled",
|
||||
"common.browse": "Browse",
|
||||
"common.add": "Add",
|
||||
"common.noData": "No data",
|
||||
"common.loading": "Loading...",
|
||||
"common.error": "Error",
|
||||
"common.networkError": "Network error",
|
||||
"common.show": "Show",
|
||||
"common.perPage": "per page",
|
||||
"common.next": "Next",
|
||||
"common.previous": "Previous",
|
||||
"common.first": "First",
|
||||
"common.previousPage": "Previous page",
|
||||
"common.nextPage": "Next page",
|
||||
"common.backoffice": "backoffice",
|
||||
"common.and": "and",
|
||||
"common.via": "via",
|
||||
|
||||
// Reading status
|
||||
"status.unread": "Unread",
|
||||
"status.reading": "Reading",
|
||||
"status.read": "Read",
|
||||
|
||||
// Series status
|
||||
"seriesStatus.ongoing": "Ongoing",
|
||||
"seriesStatus.ended": "Ended",
|
||||
"seriesStatus.hiatus": "Hiatus",
|
||||
"seriesStatus.cancelled": "Cancelled",
|
||||
"seriesStatus.upcoming": "Upcoming",
|
||||
"seriesStatus.allStatuses": "All statuses",
|
||||
"seriesStatus.notDefined": "Not defined",
|
||||
|
||||
// Dashboard
|
||||
"dashboard.title": "Dashboard",
|
||||
"dashboard.subtitle": "Overview of your comic book collection. Manage your libraries, track your reading progress and explore your books and series.",
|
||||
"dashboard.loadError": "Unable to load statistics. Check that the API is running.",
|
||||
"dashboard.books": "Books",
|
||||
"dashboard.series": "Series",
|
||||
"dashboard.libraries": "Libraries",
|
||||
"dashboard.pages": "Pages",
|
||||
"dashboard.authors": "Authors",
|
||||
"dashboard.totalSize": "Total size",
|
||||
"dashboard.readingStatus": "Reading status",
|
||||
"dashboard.byFormat": "By format",
|
||||
"dashboard.byLibrary": "By library",
|
||||
"dashboard.booksAdded": "Books added (last 12 months)",
|
||||
"dashboard.popularSeries": "Popular series",
|
||||
"dashboard.noSeries": "No series yet",
|
||||
"dashboard.unknown": "Unknown",
|
||||
"dashboard.readCount": "{{read}}/{{total}} read",
|
||||
|
||||
// Books page
|
||||
"books.title": "Books",
|
||||
"books.searchPlaceholder": "Search by title, author, series...",
|
||||
"books.library": "Library",
|
||||
"books.allLibraries": "All libraries",
|
||||
"books.status": "Status",
|
||||
"books.sort": "Sort",
|
||||
"books.sortTitle": "Title",
|
||||
"books.sortLatest": "Latest added",
|
||||
"books.resultCount": "{{count}} result{{plural}}",
|
||||
"books.resultCountFor": "{{count}} result{{plural}} for \"{{query}}\"",
|
||||
"books.bookCount": "{{count}} book{{plural}}",
|
||||
"books.seriesHeading": "Series",
|
||||
"books.unclassified": "Unclassified",
|
||||
"books.noResults": "No books found for \"{{query}}\"",
|
||||
"books.noBooks": "No books available",
|
||||
"books.coverOf": "Cover of {{name}}",
|
||||
|
||||
// Series page
|
||||
"series.title": "Series",
|
||||
"series.searchPlaceholder": "Search by series name...",
|
||||
"series.reading": "Reading",
|
||||
"series.missing": "Missing",
|
||||
"series.missingBooks": "Missing books",
|
||||
"series.matchingQuery": "matching",
|
||||
"series.noResults": "No series found matching your filters",
|
||||
"series.noSeries": "No series available",
|
||||
"series.missingCount": "{{count}} missing",
|
||||
"series.readCount": "{{read}}/{{total}} read",
|
||||
|
||||
// Libraries page
|
||||
"libraries.title": "Libraries",
|
||||
"libraries.addLibrary": "Add a library",
|
||||
"libraries.addLibraryDescription": "Create a new library from an existing folder",
|
||||
"libraries.disabled": "Disabled",
|
||||
"libraries.books": "Books",
|
||||
"libraries.series": "Series",
|
||||
"libraries.auto": "Auto",
|
||||
"libraries.manual": "Manual",
|
||||
"libraries.nextScan": "Next: {{time}}",
|
||||
"libraries.imminent": "Imminent",
|
||||
"libraries.index": "Index",
|
||||
"libraries.fullIndex": "Full",
|
||||
"libraries.batchMetadata": "Batch metadata",
|
||||
"libraries.libraryName": "Library name",
|
||||
"libraries.addButton": "Add library",
|
||||
|
||||
// Library sub-pages
|
||||
"libraryBooks.allBooks": "All books",
|
||||
"libraryBooks.booksOfSeries": "Books from \"{{series}}\"",
|
||||
"libraryBooks.filterLabel": "Books from series \"{{series}}\"",
|
||||
"libraryBooks.viewAll": "View all books",
|
||||
"libraryBooks.noBooks": "No books in this library",
|
||||
"libraryBooks.noBooksInSeries": "No books in series \"{{series}}\"",
|
||||
"librarySeries.noSeries": "No series found in this library",
|
||||
"librarySeries.noBooksInSeries": "No books in this series",
|
||||
|
||||
// Library actions
|
||||
"libraryActions.autoScan": "Auto scan",
|
||||
"libraryActions.fileWatch": "File watch ⚡",
|
||||
"libraryActions.schedule": "📅 Schedule",
|
||||
"libraryActions.provider": "Provider",
|
||||
"libraryActions.fallback": "Fallback",
|
||||
"libraryActions.default": "Default",
|
||||
"libraryActions.none": "None",
|
||||
"libraryActions.saving": "Saving...",
|
||||
|
||||
// Library sub-page header
|
||||
"libraryHeader.libraries": "Libraries",
|
||||
"libraryHeader.bookCount": "{{count}} book{{plural}}",
|
||||
"libraryHeader.enabled": "Enabled",
|
||||
|
||||
// Monitoring
|
||||
"monitoring.auto": "Auto",
|
||||
"monitoring.manual": "Manual",
|
||||
"monitoring.hourly": "Hourly",
|
||||
"monitoring.daily": "Daily",
|
||||
"monitoring.weekly": "Weekly",
|
||||
"monitoring.fileWatch": "Real-time file watching",
|
||||
|
||||
// Jobs page
|
||||
"jobs.title": "Indexing jobs",
|
||||
"jobs.startJob": "Start a job",
|
||||
"jobs.startJobDescription": "Select a library (or all) and choose the action to perform.",
|
||||
"jobs.allLibraries": "All libraries",
|
||||
"jobs.rebuild": "Rebuild",
|
||||
"jobs.fullRebuild": "Full rebuild",
|
||||
"jobs.generateThumbnails": "Generate thumbnails",
|
||||
"jobs.regenerateThumbnails": "Regenerate thumbnails",
|
||||
"jobs.batchMetadata": "Batch metadata",
|
||||
"jobs.referenceTitle": "Job types reference",
|
||||
"jobs.rebuildDescription": "Incremental scan: detects files added, modified, or deleted since the last scan, indexes them, and generates missing thumbnails. Existing unmodified data is preserved. This is the most common and fastest action.",
|
||||
"jobs.fullRebuildDescription": "Deletes all indexed data (books, series, thumbnails) then performs a full scan from scratch. Useful if the database is out of sync or corrupted. Long and destructive operation: reading statuses and manual metadata will be lost.",
|
||||
"jobs.generateThumbnailsDescription": "Generates thumbnails only for books that don't have one yet. Existing thumbnails are not affected. Useful after an import or if some thumbnails are missing.",
|
||||
"jobs.regenerateThumbnailsDescription": "Regenerates all thumbnails from scratch, replacing existing ones. Useful if thumbnail quality or size has changed in the configuration, or if thumbnails are corrupted.",
|
||||
"jobs.batchMetadataDescription": "Automatically searches metadata for each series in the library from the configured provider (with fallback if configured). Only results with a unique 100% confidence match are applied automatically. Already linked series are skipped. A detailed per-series report is available at the end of the job. <strong>Requires a specific library</strong> (does not work on \"All libraries\").",
|
||||
|
||||
// Jobs list
|
||||
"jobsList.id": "ID",
|
||||
"jobsList.library": "Library",
|
||||
"jobsList.type": "Type",
|
||||
"jobsList.status": "Status",
|
||||
"jobsList.files": "Files",
|
||||
"jobsList.thumbnails": "Thumbnails",
|
||||
"jobsList.duration": "Duration",
|
||||
"jobsList.created": "Created",
|
||||
"jobsList.actions": "Actions",
|
||||
|
||||
// Job row
|
||||
"jobRow.showProgress": "Show progress",
|
||||
"jobRow.hideProgress": "Hide progress",
|
||||
"jobRow.scanned": "{{count}} scanned",
|
||||
"jobRow.view": "View",
|
||||
|
||||
// Job progress
|
||||
"jobProgress.loadingProgress": "Loading progress...",
|
||||
"jobProgress.sseError": "Failed to parse SSE data",
|
||||
"jobProgress.connectionLost": "Connection lost",
|
||||
"jobProgress.error": "Error: {{message}}",
|
||||
"jobProgress.done": "Done",
|
||||
"jobProgress.currentFile": "Current: {{file}}",
|
||||
"jobProgress.pages": "pages",
|
||||
"jobProgress.thumbnails": "thumbnails",
|
||||
"jobProgress.filesUnit": "files",
|
||||
"jobProgress.scanned": "Scanned: {{count}}",
|
||||
"jobProgress.indexed": "Indexed: {{count}}",
|
||||
"jobProgress.removed": "Removed: {{count}}",
|
||||
"jobProgress.errors": "Errors: {{count}}",
|
||||
|
||||
// Job detail
|
||||
"jobDetail.backToJobs": "Back to jobs",
|
||||
"jobDetail.title": "Job details",
|
||||
"jobDetail.completedIn": "Completed in {{duration}}",
|
||||
"jobDetail.failedAfter": "after {{duration}}",
|
||||
"jobDetail.jobFailed": "Job failed",
|
||||
"jobDetail.cancelled": "Cancelled",
|
||||
"jobDetail.overview": "Overview",
|
||||
"jobDetail.timeline": "Timeline",
|
||||
"jobDetail.created": "Created",
|
||||
"jobDetail.started": "Started",
|
||||
"jobDetail.pendingStart": "Pending start…",
|
||||
"jobDetail.finished": "Finished",
|
||||
"jobDetail.failed": "Failed",
|
||||
"jobDetail.library": "Library",
|
||||
"jobDetail.book": "Book",
|
||||
"jobDetail.allLibraries": "All libraries",
|
||||
"jobDetail.phase1": "Phase 1 — Discovery",
|
||||
"jobDetail.phase2a": "Phase 2a — Page extraction",
|
||||
"jobDetail.phase2b": "Phase 2b — Thumbnail generation",
|
||||
"jobDetail.metadataSearch": "Metadata search",
|
||||
"jobDetail.metadataSearchDesc": "Searching external providers for each series",
|
||||
"jobDetail.phase1Desc": "Scanning and indexing library files",
|
||||
"jobDetail.phase2aDesc": "Extracting the first page of each archive (page count + raw image)",
|
||||
"jobDetail.phase2bDesc": "Generating thumbnails for scanned books",
|
||||
"jobDetail.inProgress": "in progress",
|
||||
"jobDetail.duration": "Duration: {{duration}}",
|
||||
"jobDetail.currentFile": "Current file",
|
||||
"jobDetail.generated": "Generated",
|
||||
"jobDetail.processed": "Processed",
|
||||
"jobDetail.total": "Total",
|
||||
"jobDetail.remaining": "Remaining",
|
||||
"jobDetail.indexStats": "Index statistics",
|
||||
"jobDetail.scanned": "Scanned",
|
||||
"jobDetail.indexed": "Indexed",
|
||||
"jobDetail.removed": "Removed",
|
||||
"jobDetail.warnings": "Warnings",
|
||||
"jobDetail.errors": "Errors",
|
||||
"jobDetail.thumbnailStats": "Thumbnail statistics",
|
||||
"jobDetail.batchReport": "Batch report",
|
||||
"jobDetail.seriesAnalyzed": "{{count}} series analyzed",
|
||||
"jobDetail.autoMatched": "Auto-matched",
|
||||
"jobDetail.alreadyLinked": "Already linked",
|
||||
"jobDetail.noResults": "No results",
|
||||
"jobDetail.tooManyResults": "Too many results",
|
||||
"jobDetail.lowConfidence": "Low confidence",
|
||||
"jobDetail.resultsBySeries": "Results by series",
|
||||
"jobDetail.seriesProcessed": "{{count}} series processed",
|
||||
"jobDetail.candidates": "candidate{{plural}}",
|
||||
"jobDetail.confidence": "confidence",
|
||||
"jobDetail.match": "Match: {{title}}",
|
||||
"jobDetail.fileErrors": "File errors ({{count}})",
|
||||
"jobDetail.fileErrorsDesc": "Errors encountered while processing files",
|
||||
|
||||
// Job types
|
||||
"jobType.rebuild": "Indexing",
|
||||
"jobType.full_rebuild": "Full indexing",
|
||||
"jobType.thumbnail_rebuild": "Thumbnails",
|
||||
"jobType.thumbnail_regenerate": "Regen. thumbnails",
|
||||
"jobType.cbr_to_cbz": "CBR → CBZ",
|
||||
"jobType.metadata_batch": "Batch metadata",
|
||||
"jobType.rebuildLabel": "Incremental indexing",
|
||||
"jobType.rebuildDesc": "Scans new/modified files, analyzes them, and generates missing thumbnails.",
|
||||
"jobType.full_rebuildLabel": "Full reindexing",
|
||||
"jobType.full_rebuildDesc": "Deletes all existing data then performs a full scan, re-analysis, and thumbnail generation.",
|
||||
"jobType.thumbnail_rebuildLabel": "Thumbnail rebuild",
|
||||
"jobType.thumbnail_rebuildDesc": "Generates thumbnails only for books that don't have one. Existing thumbnails are preserved.",
|
||||
"jobType.thumbnail_regenerateLabel": "Thumbnail regeneration",
|
||||
"jobType.thumbnail_regenerateDesc": "Regenerates all thumbnails from scratch, replacing existing ones.",
|
||||
"jobType.cbr_to_cbzLabel": "CBR → CBZ conversion",
|
||||
"jobType.cbr_to_cbzDesc": "Converts a CBR archive to the open CBZ format.",
|
||||
"jobType.metadata_batchLabel": "Batch metadata",
|
||||
"jobType.metadata_batchDesc": "Searches external metadata providers for all series in the library and automatically applies 100% confidence matches.",
|
||||
|
||||
// Status badges
|
||||
"statusBadge.extracting_pages": "Extracting pages",
|
||||
"statusBadge.generating_thumbnails": "Thumbnails",
|
||||
|
||||
// Jobs indicator
|
||||
"jobsIndicator.viewAll": "View all jobs",
|
||||
"jobsIndicator.activeTasks": "Active jobs",
|
||||
"jobsIndicator.runningAndPending": "{{running}} running, {{pending}} pending",
|
||||
"jobsIndicator.pendingTasks": "{{count}} pending job{{plural}}",
|
||||
"jobsIndicator.overallProgress": "Overall progress",
|
||||
"jobsIndicator.viewAllLink": "View all →",
|
||||
"jobsIndicator.noActiveTasks": "No active jobs",
|
||||
"jobsIndicator.autoRefresh": "Auto-refresh every 2s",
|
||||
"jobsIndicator.taskCount": "{{count}} active job{{plural}}",
|
||||
"jobsIndicator.thumbnails": "Thumbnails",
|
||||
"jobsIndicator.regeneration": "Regeneration",
|
||||
|
||||
// Time
|
||||
"time.justNow": "Just now",
|
||||
"time.minutesAgo": "{{count}}m ago",
|
||||
"time.hoursAgo": "{{count}}h ago",
|
||||
|
||||
// Tokens page
|
||||
"tokens.title": "API Tokens",
|
||||
"tokens.created": "Token created",
|
||||
"tokens.createdDescription": "Copy it now, it won't be shown again",
|
||||
"tokens.createNew": "Create a new token",
|
||||
"tokens.createDescription": "Generate a new API token with the desired scope",
|
||||
"tokens.tokenName": "Token name",
|
||||
"tokens.scopeRead": "Read",
|
||||
"tokens.scopeAdmin": "Admin",
|
||||
"tokens.createButton": "Create token",
|
||||
"tokens.name": "Name",
|
||||
"tokens.scope": "Scope",
|
||||
"tokens.prefix": "Prefix",
|
||||
"tokens.status": "Status",
|
||||
"tokens.actions": "Actions",
|
||||
"tokens.revoked": "Revoked",
|
||||
"tokens.active": "Active",
|
||||
"tokens.revoke": "Revoke",
|
||||
|
||||
// Settings page
|
||||
"settings.title": "Settings",
|
||||
"settings.general": "General",
|
||||
"settings.integrations": "Integrations",
|
||||
"settings.savedSuccess": "Settings saved successfully",
|
||||
"settings.savedError": "Failed to save settings",
|
||||
"settings.saveError": "Error saving settings",
|
||||
"settings.cacheClearError": "Failed to clear cache",
|
||||
|
||||
// Settings - Image Processing
|
||||
"settings.imageProcessing": "Image processing",
|
||||
"settings.imageProcessingDesc": "These settings only apply when a client explicitly requests a format conversion via the API (e.g. <code>?format=webp&width=800</code>). Pages served without parameters are delivered as-is from the archive, without processing.",
|
||||
"settings.defaultFormat": "Default output format",
|
||||
"settings.defaultQuality": "Default quality (1-100)",
|
||||
"settings.defaultFilter": "Default resize filter",
|
||||
"settings.filterLanczos": "Lanczos3 (Best quality)",
|
||||
"settings.filterTriangle": "Triangle (Faster)",
|
||||
"settings.filterNearest": "Nearest (Fastest)",
|
||||
"settings.maxWidth": "Maximum allowed width (px)",
|
||||
|
||||
// Settings - Cache
|
||||
"settings.cache": "Cache",
|
||||
"settings.cacheDesc": "Manage image cache and storage",
|
||||
"settings.cacheSize": "Cache size",
|
||||
"settings.files": "Files",
|
||||
"settings.directory": "Directory",
|
||||
"settings.cacheDirectory": "Cache directory",
|
||||
"settings.maxSizeMb": "Max size (MB)",
|
||||
"settings.clearing": "Clearing...",
|
||||
"settings.clearCache": "Clear cache",
|
||||
|
||||
// Settings - Performance
|
||||
"settings.performanceLimits": "Performance limits",
|
||||
"settings.performanceDesc": "Configure API performance, rate limiting, and thumbnail generation concurrency",
|
||||
"settings.concurrentRenders": "Concurrent renders",
|
||||
"settings.concurrentRendersHelp": "Maximum number of parallel page renders and thumbnail generations",
|
||||
"settings.timeoutSeconds": "Timeout (seconds)",
|
||||
"settings.rateLimit": "Rate limit (req/s)",
|
||||
"settings.limitsNote": "Note: Changes to limits require a server restart to take effect. The \"Concurrent renders\" setting controls both page rendering and thumbnail generation parallelism.",
|
||||
|
||||
// Settings - Thumbnails
|
||||
"settings.thumbnails": "Thumbnails",
|
||||
"settings.thumbnailsDesc": "Configure thumbnail generation during indexing",
|
||||
"settings.enableThumbnails": "Enable thumbnails",
|
||||
"settings.outputFormat": "Output format",
|
||||
"settings.formatOriginal": "Original (No re-encoding)",
|
||||
"settings.formatOriginalDesc": "Resizes to target dimensions, keeps the source format (JPEG→JPEG). Much faster generation.",
|
||||
"settings.formatReencodeDesc": "Resizes and re-encodes to the selected format.",
|
||||
"settings.width": "Width (px)",
|
||||
"settings.height": "Height (px)",
|
||||
"settings.quality": "Quality (1-100)",
|
||||
"settings.thumbnailDirectory": "Thumbnail directory",
|
||||
"settings.totalSize": "Total size",
|
||||
"settings.thumbnailsNote": "Note: Thumbnail settings are used during indexing. Existing thumbnails will not be automatically regenerated. Thumbnail generation concurrency is controlled by the \"Concurrent renders\" setting in Performance limits above.",
|
||||
|
||||
// Settings - Komga
|
||||
"settings.komgaSync": "Komga sync",
|
||||
"settings.komgaDesc": "Import reading status from a Komga server. Books are matched by title (case-insensitive). Credentials are not stored.",
|
||||
"settings.komgaUrl": "Komga URL",
|
||||
"settings.username": "Username",
|
||||
"settings.password": "Password",
|
||||
"settings.syncing": "Syncing...",
|
||||
"settings.syncReadBooks": "Sync read books",
|
||||
"settings.komgaRead": "Read on Komga",
|
||||
"settings.matched": "Matched",
|
||||
"settings.alreadyRead": "Already read",
|
||||
"settings.newlyMarked": "Newly marked",
|
||||
"settings.matchedBooks": "{{count}} matched book{{plural}}",
|
||||
"settings.unmatchedBooks": "{{count}} unmatched book{{plural}}",
|
||||
"settings.syncHistory": "Sync history",
|
||||
"settings.read": "read",
|
||||
"settings.new": "new",
|
||||
"settings.unmatched": "unmatched",
|
||||
|
||||
// Settings - Metadata Providers
|
||||
"settings.metadataProviders": "Metadata providers",
|
||||
"settings.metadataProvidersDesc": "Configure external metadata providers for series/book enrichment. Each library can override the default provider. All providers are available for quick search in the metadata modal.",
|
||||
"settings.defaultProvider": "Default provider",
|
||||
"settings.defaultProviderHelp": "Used by default for metadata search. Libraries can override it individually.",
|
||||
"settings.metadataLanguage": "Metadata language",
|
||||
"settings.metadataLanguageHelp": "Preferred language for search results and descriptions. Fallback: English.",
|
||||
"settings.apiKeys": "API keys",
|
||||
"settings.googleBooksKey": "Google Books API key",
|
||||
"settings.googleBooksPlaceholder": "Optional — for higher rate limits",
|
||||
"settings.googleBooksHelp": "Works without a key but with lower rate limits.",
|
||||
"settings.comicvineKey": "ComicVine API key",
|
||||
"settings.comicvinePlaceholder": "Required to use ComicVine",
|
||||
"settings.comicvineHelp": "Get your key at",
|
||||
"settings.freeProviders": "are free and do not require an API key.",
|
||||
|
||||
// Settings - Language
|
||||
"settings.language": "Language",
|
||||
"settings.languageDesc": "Choose the interface language",
|
||||
|
||||
// Pagination
|
||||
"pagination.show": "Show",
|
||||
"pagination.displaying": "Displaying {{count}} items",
|
||||
"pagination.range": "{{start}}-{{end}} of {{total}}",
|
||||
|
||||
// Book detail
|
||||
"bookDetail.libraries": "Libraries",
|
||||
"bookDetail.coverOf": "Cover of {{title}}",
|
||||
"bookDetail.technicalInfo": "Technical information",
|
||||
"bookDetail.file": "File",
|
||||
"bookDetail.fileFormat": "File format",
|
||||
"bookDetail.parsing": "Parsing",
|
||||
"bookDetail.updatedAt": "Updated",
|
||||
|
||||
// Book preview
|
||||
"bookPreview.preview": "Preview",
|
||||
"bookPreview.pages": "pages {{start}}–{{end}} / {{total}}",
|
||||
"bookPreview.prev": "← Prev",
|
||||
"bookPreview.next": "Next →",
|
||||
|
||||
// Edit book form
|
||||
"editBook.editMetadata": "Edit metadata",
|
||||
"editBook.title": "Title",
|
||||
"editBook.titlePlaceholder": "Book title",
|
||||
"editBook.authors": "Author(s)",
|
||||
"editBook.addAuthor": "Add an author (Enter to confirm)",
|
||||
"editBook.language": "Language",
|
||||
"editBook.languagePlaceholder": "e.g. fr, en, jp",
|
||||
"editBook.series": "Series",
|
||||
"editBook.seriesPlaceholder": "Series name",
|
||||
"editBook.volume": "Volume",
|
||||
"editBook.volumePlaceholder": "Volume number",
|
||||
"editBook.isbn": "ISBN",
|
||||
"editBook.publishDate": "Publish date",
|
||||
"editBook.publishDatePlaceholder": "e.g. 2023-01-15",
|
||||
"editBook.description": "Description",
|
||||
"editBook.descriptionPlaceholder": "Summary / book description",
|
||||
"editBook.lockedField": "Locked field (protected from syncs)",
|
||||
"editBook.clickToLock": "Click to lock this field",
|
||||
"editBook.lockedFieldsNote": "Locked fields will not be overwritten by external metadata syncs.",
|
||||
"editBook.saveError": "Error saving",
|
||||
"editBook.savingLabel": "Saving…",
|
||||
"editBook.saveLabel": "Save",
|
||||
"editBook.removeAuthor": "Remove {{name}}",
|
||||
|
||||
// Edit series form
|
||||
"editSeries.title": "Edit series",
|
||||
"editSeries.name": "Name",
|
||||
"editSeries.namePlaceholder": "Series name",
|
||||
"editSeries.startYear": "Start year",
|
||||
"editSeries.startYearPlaceholder": "e.g. 1990",
|
||||
"editSeries.totalVolumes": "Number of volumes",
|
||||
"editSeries.status": "Status",
|
||||
"editSeries.authors": "Author(s)",
|
||||
"editSeries.applyToBooks": "→ books",
|
||||
"editSeries.applyToBooksTitle": "Apply author and language to all books in the series",
|
||||
"editSeries.bookAuthor": "Author (books)",
|
||||
"editSeries.bookAuthorPlaceholder": "Overwrites the author field of each book",
|
||||
"editSeries.bookLanguage": "Language (books)",
|
||||
"editSeries.publishers": "Publisher(s)",
|
||||
"editSeries.addPublisher": "Add a publisher (Enter to confirm)",
|
||||
"editSeries.descriptionPlaceholder": "Synopsis or series description…",
|
||||
|
||||
// Convert button
|
||||
"convert.convertToCbz": "Convert to CBZ",
|
||||
"convert.converting": "Converting…",
|
||||
"convert.started": "Conversion started.",
|
||||
"convert.viewJob": "View job →",
|
||||
"convert.failed": "Conversion failed",
|
||||
"convert.unknownError": "Unknown error",
|
||||
|
||||
// Mark read buttons
|
||||
"markRead.markUnread": "Mark unread",
|
||||
"markRead.markAllRead": "Mark all read",
|
||||
"markRead.markAsRead": "Mark as read",
|
||||
|
||||
// Metadata search modal
|
||||
"metadata.metadataLink": "Metadata link",
|
||||
"metadata.searchExternal": "Search external metadata",
|
||||
"metadata.provider": "Provider:",
|
||||
"metadata.searching": "Searching \"{{name}}\"...",
|
||||
"metadata.noResults": "No results found.",
|
||||
"metadata.resultCount": "{{count}} result{{plural}} found",
|
||||
"metadata.howToSync": "How would you like to sync?",
|
||||
"metadata.syncSeriesOnly": "Sync series only",
|
||||
"metadata.syncSeriesOnlyDesc": "Update description, authors, publishers, and year",
|
||||
"metadata.syncSeriesAndBooks": "Sync series + books",
|
||||
"metadata.syncSeriesAndBooksDesc": "Also fetch the book list and show missing volumes",
|
||||
"metadata.backToResults": "Back to results",
|
||||
"metadata.syncingMetadata": "Syncing metadata...",
|
||||
"metadata.syncSuccess": "Metadata synced successfully!",
|
||||
"metadata.seriesLabel": "Series",
|
||||
"metadata.booksLabel": "Books",
|
||||
"metadata.booksMatched": "{{matched}} matched",
|
||||
"metadata.booksUnmatched": "{{count}} unmatched",
|
||||
"metadata.external": "External",
|
||||
"metadata.local": "Local",
|
||||
"metadata.missingLabel": "Missing",
|
||||
"metadata.missingBooks": "{{count}} missing book{{plural}}",
|
||||
"metadata.unknown": "Unknown",
|
||||
"metadata.linkedTo": "Linked to",
|
||||
"metadata.viewExternal": "View on external source",
|
||||
"metadata.searchAgain": "Search again",
|
||||
"metadata.unlink": "Unlink",
|
||||
"metadata.searchButton": "Search metadata",
|
||||
"metadata.metadataButton": "Metadata",
|
||||
"metadata.locked": "locked",
|
||||
"metadata.searchFailed": "Search failed",
|
||||
"metadata.linkFailed": "Link creation failed",
|
||||
"metadata.approveFailed": "Approval failed",
|
||||
"metadata.chapters": "chapters",
|
||||
"metadata.volumes": "volumes",
|
||||
"metadata.inProgress": "in progress",
|
||||
"metadata.fallbackUsed": "(fallback)",
|
||||
|
||||
// Field labels
|
||||
"field.description": "Description",
|
||||
"field.authors": "Authors",
|
||||
"field.publishers": "Publishers",
|
||||
"field.start_year": "Year",
|
||||
"field.total_volumes": "Volumes",
|
||||
"field.status": "Status",
|
||||
"field.summary": "Summary",
|
||||
"field.isbn": "ISBN",
|
||||
"field.publish_date": "Publish date",
|
||||
"field.language": "Language",
|
||||
|
||||
// Folder picker/browser
|
||||
"folder.selectFolder": "Select a folder...",
|
||||
"folder.selectFolderTitle": "Select folder",
|
||||
"folder.clickToSelect": "Click a folder to select it",
|
||||
"folder.noFolders": "No folders found",
|
||||
|
||||
// Series filters
|
||||
"seriesFilters.all": "All",
|
||||
"seriesFilters.missingBooks": "Missing books",
|
||||
};
|
||||
|
||||
export default en;
|
||||
554
apps/backoffice/lib/i18n/fr.ts
Normal file
554
apps/backoffice/lib/i18n/fr.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
const fr = {
|
||||
// Navigation
|
||||
"nav.dashboard": "Tableau de bord",
|
||||
"nav.books": "Livres",
|
||||
"nav.series": "Séries",
|
||||
"nav.libraries": "Bibliothèques",
|
||||
"nav.jobs": "Tâches",
|
||||
"nav.tokens": "Jetons",
|
||||
"nav.settings": "Paramètres",
|
||||
"nav.navigation": "Navigation",
|
||||
"nav.closeMenu": "Fermer le menu",
|
||||
"nav.openMenu": "Ouvrir le menu",
|
||||
|
||||
// Common
|
||||
"common.save": "Enregistrer",
|
||||
"common.saving": "Enregistrement...",
|
||||
"common.cancel": "Annuler",
|
||||
"common.close": "Fermer",
|
||||
"common.delete": "Supprimer",
|
||||
"common.edit": "Modifier",
|
||||
"common.search": "Rechercher",
|
||||
"common.clear": "Effacer",
|
||||
"common.view": "Voir",
|
||||
"common.all": "Tous",
|
||||
"common.enabled": "Activé",
|
||||
"common.disabled": "Désactivé",
|
||||
"common.browse": "Parcourir",
|
||||
"common.add": "Ajouter",
|
||||
"common.noData": "Aucune donnée",
|
||||
"common.loading": "Chargement...",
|
||||
"common.error": "Erreur",
|
||||
"common.networkError": "Erreur réseau",
|
||||
"common.show": "Afficher",
|
||||
"common.perPage": "par page",
|
||||
"common.next": "Suivant",
|
||||
"common.previous": "Précédent",
|
||||
"common.first": "Premier",
|
||||
"common.previousPage": "Page précédente",
|
||||
"common.nextPage": "Page suivante",
|
||||
"common.backoffice": "backoffice",
|
||||
"common.and": "et",
|
||||
"common.via": "via",
|
||||
|
||||
// Reading status
|
||||
"status.unread": "Non lu",
|
||||
"status.reading": "En cours",
|
||||
"status.read": "Lu",
|
||||
|
||||
// Series status
|
||||
"seriesStatus.ongoing": "En cours",
|
||||
"seriesStatus.ended": "Terminée",
|
||||
"seriesStatus.hiatus": "Hiatus",
|
||||
"seriesStatus.cancelled": "Annulée",
|
||||
"seriesStatus.upcoming": "À paraître",
|
||||
"seriesStatus.allStatuses": "Tous les statuts",
|
||||
"seriesStatus.notDefined": "Non défini",
|
||||
|
||||
// Dashboard
|
||||
"dashboard.title": "Tableau de bord",
|
||||
"dashboard.subtitle": "Aperçu de votre collection de bandes dessinées. Gérez vos bibliothèques, suivez votre progression de lecture et explorez vos livres et séries.",
|
||||
"dashboard.loadError": "Impossible de charger les statistiques. Vérifiez que l'API est en cours d'exécution.",
|
||||
"dashboard.books": "Livres",
|
||||
"dashboard.series": "Séries",
|
||||
"dashboard.libraries": "Bibliothèques",
|
||||
"dashboard.pages": "Pages",
|
||||
"dashboard.authors": "Auteurs",
|
||||
"dashboard.totalSize": "Taille totale",
|
||||
"dashboard.readingStatus": "Statut de lecture",
|
||||
"dashboard.byFormat": "Par format",
|
||||
"dashboard.byLibrary": "Par bibliothèque",
|
||||
"dashboard.booksAdded": "Livres ajoutés (12 derniers mois)",
|
||||
"dashboard.popularSeries": "Séries populaires",
|
||||
"dashboard.noSeries": "Aucune série pour le moment",
|
||||
"dashboard.unknown": "Inconnu",
|
||||
"dashboard.readCount": "{{read}}/{{total}} lu",
|
||||
|
||||
// Books page
|
||||
"books.title": "Livres",
|
||||
"books.searchPlaceholder": "Rechercher par titre, auteur, série...",
|
||||
"books.library": "Bibliothèque",
|
||||
"books.allLibraries": "Toutes les bibliothèques",
|
||||
"books.status": "Statut",
|
||||
"books.sort": "Tri",
|
||||
"books.sortTitle": "Titre",
|
||||
"books.sortLatest": "Ajout récent",
|
||||
"books.resultCount": "{{count}} résultat{{plural}}",
|
||||
"books.resultCountFor": "{{count}} résultat{{plural}} pour \u00ab {{query}} \u00bb",
|
||||
"books.bookCount": "{{count}} livre{{plural}}",
|
||||
"books.seriesHeading": "Séries",
|
||||
"books.unclassified": "Non classé",
|
||||
"books.noResults": "Aucun livre trouvé pour \"{{query}}\"",
|
||||
"books.noBooks": "Aucun livre disponible",
|
||||
"books.coverOf": "Couverture de {{name}}",
|
||||
|
||||
// Series page
|
||||
"series.title": "Séries",
|
||||
"series.searchPlaceholder": "Rechercher par nom de série...",
|
||||
"series.reading": "Lecture",
|
||||
"series.missing": "Manquant",
|
||||
"series.missingBooks": "Livres manquants",
|
||||
"series.matchingQuery": "correspondant à",
|
||||
"series.noResults": "Aucune série trouvée correspondant à vos filtres",
|
||||
"series.noSeries": "Aucune série disponible",
|
||||
"series.missingCount": "{{count}} manquant{{plural}}",
|
||||
"series.readCount": "{{read}}/{{total}} lu{{plural}}",
|
||||
|
||||
// Libraries page
|
||||
"libraries.title": "Bibliothèques",
|
||||
"libraries.addLibrary": "Ajouter une bibliothèque",
|
||||
"libraries.addLibraryDescription": "Créer une nouvelle bibliothèque à partir d'un dossier existant",
|
||||
"libraries.disabled": "Désactivée",
|
||||
"libraries.books": "Livres",
|
||||
"libraries.series": "Séries",
|
||||
"libraries.auto": "Auto",
|
||||
"libraries.manual": "Manuel",
|
||||
"libraries.nextScan": "Prochain : {{time}}",
|
||||
"libraries.imminent": "Imminent",
|
||||
"libraries.index": "Indexer",
|
||||
"libraries.fullIndex": "Complet",
|
||||
"libraries.batchMetadata": "Métadonnées en lot",
|
||||
"libraries.libraryName": "Nom de la bibliothèque",
|
||||
"libraries.addButton": "Ajouter une bibliothèque",
|
||||
|
||||
// Library sub-pages
|
||||
"libraryBooks.allBooks": "Tous les livres",
|
||||
"libraryBooks.booksOfSeries": "Livres de \"{{series}}\"",
|
||||
"libraryBooks.filterLabel": "Livres de la série \"{{series}}\"",
|
||||
"libraryBooks.viewAll": "Voir tous les livres",
|
||||
"libraryBooks.noBooks": "Aucun livre dans cette bibliothèque",
|
||||
"libraryBooks.noBooksInSeries": "Aucun livre dans la série \"{{series}}\"",
|
||||
"librarySeries.noSeries": "Aucune série trouvée dans cette bibliothèque",
|
||||
"librarySeries.noBooksInSeries": "Aucun livre dans cette série",
|
||||
|
||||
// Library actions
|
||||
"libraryActions.autoScan": "Scan auto",
|
||||
"libraryActions.fileWatch": "Surveillance fichiers ⚡",
|
||||
"libraryActions.schedule": "📅 Planification",
|
||||
"libraryActions.provider": "Fournisseur",
|
||||
"libraryActions.fallback": "Secours",
|
||||
"libraryActions.default": "Par défaut",
|
||||
"libraryActions.none": "Aucun",
|
||||
"libraryActions.saving": "Enregistrement...",
|
||||
|
||||
// Library sub-page header
|
||||
"libraryHeader.libraries": "Bibliothèques",
|
||||
"libraryHeader.bookCount": "{{count}} livre{{plural}}",
|
||||
"libraryHeader.enabled": "Activée",
|
||||
|
||||
// Monitoring
|
||||
"monitoring.auto": "Auto",
|
||||
"monitoring.manual": "Manuel",
|
||||
"monitoring.hourly": "Toutes les heures",
|
||||
"monitoring.daily": "Quotidien",
|
||||
"monitoring.weekly": "Hebdomadaire",
|
||||
"monitoring.fileWatch": "Surveillance des fichiers en temps réel",
|
||||
|
||||
// Jobs page
|
||||
"jobs.title": "Tâches d'indexation",
|
||||
"jobs.startJob": "Lancer une tâche",
|
||||
"jobs.startJobDescription": "Sélectionnez une bibliothèque (ou toutes) et choisissez l'action à effectuer.",
|
||||
"jobs.allLibraries": "Toutes les bibliothèques",
|
||||
"jobs.rebuild": "Reconstruction",
|
||||
"jobs.fullRebuild": "Reconstruction complète",
|
||||
"jobs.generateThumbnails": "Générer les miniatures",
|
||||
"jobs.regenerateThumbnails": "Regénérer les miniatures",
|
||||
"jobs.batchMetadata": "Métadonnées en lot",
|
||||
"jobs.referenceTitle": "Référence des types de tâches",
|
||||
"jobs.rebuildDescription": "Scan incrémental : détecte les fichiers ajoutés, modifiés ou supprimés depuis le dernier scan, les indexe et génère les miniatures manquantes. Les données existantes non modifiées sont conservées. C'est l'action la plus courante et la plus rapide.",
|
||||
"jobs.fullRebuildDescription": "Supprime toutes les données indexées (livres, séries, miniatures) puis effectue un scan complet depuis zéro. Utile si la base de données est désynchronisée ou corrompue. Opération longue et destructive : les statuts de lecture et les métadonnées manuelles seront perdus.",
|
||||
"jobs.generateThumbnailsDescription": "Génère les miniatures uniquement pour les livres qui n'en ont pas encore. Les miniatures existantes ne sont pas touchées. Utile après un import ou si certaines miniatures sont manquantes.",
|
||||
"jobs.regenerateThumbnailsDescription": "Regénère toutes les miniatures depuis zéro, en remplaçant les existantes. Utile si la qualité ou la taille des miniatures a changé dans la configuration, ou si des miniatures sont corrompues.",
|
||||
"jobs.batchMetadataDescription": "Recherche automatiquement les métadonnées de chaque série de la bibliothèque auprès du provider configuré (avec fallback si configuré). Seuls les résultats avec un match unique à 100% de confiance sont appliqués automatiquement. Les séries déjà liées sont ignorées. Un rapport détaillé par série est disponible à la fin du job. <strong>Requiert une bibliothèque spécifique</strong> (ne fonctionne pas sur \u00ab Toutes les bibliothèques \u00bb).",
|
||||
|
||||
// Jobs list
|
||||
"jobsList.id": "ID",
|
||||
"jobsList.library": "Bibliothèque",
|
||||
"jobsList.type": "Type",
|
||||
"jobsList.status": "Statut",
|
||||
"jobsList.files": "Fichiers",
|
||||
"jobsList.thumbnails": "Miniatures",
|
||||
"jobsList.duration": "Durée",
|
||||
"jobsList.created": "Créé",
|
||||
"jobsList.actions": "Actions",
|
||||
|
||||
// Job row
|
||||
"jobRow.showProgress": "Afficher la progression",
|
||||
"jobRow.hideProgress": "Masquer la progression",
|
||||
"jobRow.scanned": "{{count}} analysés",
|
||||
"jobRow.view": "Voir",
|
||||
|
||||
// Job progress
|
||||
"jobProgress.loadingProgress": "Chargement de la progression...",
|
||||
"jobProgress.sseError": "Échec de l'analyse des données SSE",
|
||||
"jobProgress.connectionLost": "Connexion perdue",
|
||||
"jobProgress.error": "Erreur : {{message}}",
|
||||
"jobProgress.done": "Terminé",
|
||||
"jobProgress.currentFile": "En cours : {{file}}",
|
||||
"jobProgress.pages": "pages",
|
||||
"jobProgress.thumbnails": "miniatures",
|
||||
"jobProgress.filesUnit": "fichiers",
|
||||
"jobProgress.scanned": "Analysés : {{count}}",
|
||||
"jobProgress.indexed": "Indexés : {{count}}",
|
||||
"jobProgress.removed": "Supprimés : {{count}}",
|
||||
"jobProgress.errors": "Erreurs : {{count}}",
|
||||
|
||||
// Job detail
|
||||
"jobDetail.backToJobs": "Retour aux tâches",
|
||||
"jobDetail.title": "Détails de la tâche",
|
||||
"jobDetail.completedIn": "Terminé en {{duration}}",
|
||||
"jobDetail.failedAfter": "après {{duration}}",
|
||||
"jobDetail.jobFailed": "Tâche échouée",
|
||||
"jobDetail.cancelled": "Annulé",
|
||||
"jobDetail.overview": "Aperçu",
|
||||
"jobDetail.timeline": "Chronologie",
|
||||
"jobDetail.created": "Créé",
|
||||
"jobDetail.started": "Démarré",
|
||||
"jobDetail.pendingStart": "En attente de démarrage…",
|
||||
"jobDetail.finished": "Terminé",
|
||||
"jobDetail.failed": "Échoué",
|
||||
"jobDetail.library": "Bibliothèque",
|
||||
"jobDetail.book": "Livre",
|
||||
"jobDetail.allLibraries": "Toutes les bibliothèques",
|
||||
"jobDetail.phase1": "Phase 1 — Découverte",
|
||||
"jobDetail.phase2a": "Phase 2a — Extraction des pages",
|
||||
"jobDetail.phase2b": "Phase 2b — Génération des miniatures",
|
||||
"jobDetail.metadataSearch": "Recherche de métadonnées",
|
||||
"jobDetail.metadataSearchDesc": "Recherche auprès des fournisseurs externes pour chaque série",
|
||||
"jobDetail.phase1Desc": "Scan et indexation des fichiers de la bibliothèque",
|
||||
"jobDetail.phase2aDesc": "Extraction de la première page de chaque archive (nombre de pages + image brute)",
|
||||
"jobDetail.phase2bDesc": "Génération des miniatures pour les livres analysés",
|
||||
"jobDetail.inProgress": "en cours",
|
||||
"jobDetail.duration": "Durée : {{duration}}",
|
||||
"jobDetail.currentFile": "Fichier en cours",
|
||||
"jobDetail.generated": "Générés",
|
||||
"jobDetail.processed": "Traités",
|
||||
"jobDetail.total": "Total",
|
||||
"jobDetail.remaining": "Restants",
|
||||
"jobDetail.indexStats": "Statistiques d'indexation",
|
||||
"jobDetail.scanned": "Scannés",
|
||||
"jobDetail.indexed": "Indexés",
|
||||
"jobDetail.removed": "Supprimés",
|
||||
"jobDetail.warnings": "Avertissements",
|
||||
"jobDetail.errors": "Erreurs",
|
||||
"jobDetail.thumbnailStats": "Statistiques des miniatures",
|
||||
"jobDetail.batchReport": "Rapport du lot",
|
||||
"jobDetail.seriesAnalyzed": "{{count}} séries analysées",
|
||||
"jobDetail.autoMatched": "Auto-associé",
|
||||
"jobDetail.alreadyLinked": "Déjà lié",
|
||||
"jobDetail.noResults": "Aucun résultat",
|
||||
"jobDetail.tooManyResults": "Trop de résultats",
|
||||
"jobDetail.lowConfidence": "Confiance faible",
|
||||
"jobDetail.resultsBySeries": "Résultats par série",
|
||||
"jobDetail.seriesProcessed": "{{count}} séries traitées",
|
||||
"jobDetail.candidates": "candidat{{plural}}",
|
||||
"jobDetail.confidence": "confiance",
|
||||
"jobDetail.match": "Correspondance : {{title}}",
|
||||
"jobDetail.fileErrors": "Erreurs de fichiers ({{count}})",
|
||||
"jobDetail.fileErrorsDesc": "Erreurs rencontrées lors du traitement des fichiers",
|
||||
|
||||
// Job types
|
||||
"jobType.rebuild": "Indexation",
|
||||
"jobType.full_rebuild": "Indexation complète",
|
||||
"jobType.thumbnail_rebuild": "Miniatures",
|
||||
"jobType.thumbnail_regenerate": "Régén. miniatures",
|
||||
"jobType.cbr_to_cbz": "CBR → CBZ",
|
||||
"jobType.metadata_batch": "Métadonnées en lot",
|
||||
"jobType.rebuildLabel": "Indexation incrémentale",
|
||||
"jobType.rebuildDesc": "Scanne les fichiers nouveaux/modifiés, les analyse et génère les miniatures manquantes.",
|
||||
"jobType.full_rebuildLabel": "Réindexation complète",
|
||||
"jobType.full_rebuildDesc": "Supprime toutes les données existantes puis effectue un scan complet, une ré-analyse et la génération des miniatures.",
|
||||
"jobType.thumbnail_rebuildLabel": "Reconstruction des miniatures",
|
||||
"jobType.thumbnail_rebuildDesc": "Génère les miniatures uniquement pour les livres qui n'en ont pas. Les miniatures existantes sont conservées.",
|
||||
"jobType.thumbnail_regenerateLabel": "Regénération des miniatures",
|
||||
"jobType.thumbnail_regenerateDesc": "Regénère toutes les miniatures depuis zéro, en remplaçant les existantes.",
|
||||
"jobType.cbr_to_cbzLabel": "Conversion CBR → CBZ",
|
||||
"jobType.cbr_to_cbzDesc": "Convertit une archive CBR au format ouvert CBZ.",
|
||||
"jobType.metadata_batchLabel": "Métadonnées en lot",
|
||||
"jobType.metadata_batchDesc": "Recherche les métadonnées auprès des fournisseurs externes pour toutes les séries de la bibliothèque et applique automatiquement les correspondances à 100% de confiance.",
|
||||
|
||||
// Status badges
|
||||
"statusBadge.extracting_pages": "Extraction des pages",
|
||||
"statusBadge.generating_thumbnails": "Miniatures",
|
||||
|
||||
// Jobs indicator
|
||||
"jobsIndicator.viewAll": "Voir toutes les tâches",
|
||||
"jobsIndicator.activeTasks": "Tâches actives",
|
||||
"jobsIndicator.runningAndPending": "{{running}} en cours, {{pending}} en attente",
|
||||
"jobsIndicator.pendingTasks": "{{count}} tâche{{plural}} en attente",
|
||||
"jobsIndicator.overallProgress": "Progression globale",
|
||||
"jobsIndicator.viewAllLink": "Tout voir →",
|
||||
"jobsIndicator.noActiveTasks": "Aucune tâche active",
|
||||
"jobsIndicator.autoRefresh": "Actualisation automatique toutes les 2s",
|
||||
"jobsIndicator.taskCount": "{{count}} tâche{{plural}} active{{plural}}",
|
||||
"jobsIndicator.thumbnails": "Miniatures",
|
||||
"jobsIndicator.regeneration": "Regénération",
|
||||
|
||||
// Time
|
||||
"time.justNow": "À l'instant",
|
||||
"time.minutesAgo": "il y a {{count}}m",
|
||||
"time.hoursAgo": "il y a {{count}}h",
|
||||
|
||||
// Tokens page
|
||||
"tokens.title": "Jetons API",
|
||||
"tokens.created": "Jeton créé",
|
||||
"tokens.createdDescription": "Copiez-le maintenant, il ne sera plus affiché",
|
||||
"tokens.createNew": "Créer un nouveau jeton",
|
||||
"tokens.createDescription": "Générer un nouveau jeton API avec la portée souhaitée",
|
||||
"tokens.tokenName": "Nom du jeton",
|
||||
"tokens.scopeRead": "Lecture",
|
||||
"tokens.scopeAdmin": "Admin",
|
||||
"tokens.createButton": "Créer le jeton",
|
||||
"tokens.name": "Nom",
|
||||
"tokens.scope": "Portée",
|
||||
"tokens.prefix": "Préfixe",
|
||||
"tokens.status": "Statut",
|
||||
"tokens.actions": "Actions",
|
||||
"tokens.revoked": "Révoqué",
|
||||
"tokens.active": "Actif",
|
||||
"tokens.revoke": "Révoquer",
|
||||
|
||||
// Settings page
|
||||
"settings.title": "Paramètres",
|
||||
"settings.general": "Général",
|
||||
"settings.integrations": "Intégrations",
|
||||
"settings.savedSuccess": "Paramètres enregistrés avec succès",
|
||||
"settings.savedError": "Échec de l'enregistrement des paramètres",
|
||||
"settings.saveError": "Erreur lors de l'enregistrement des paramètres",
|
||||
"settings.cacheClearError": "Échec du vidage du cache",
|
||||
|
||||
// Settings - Image Processing
|
||||
"settings.imageProcessing": "Traitement d'images",
|
||||
"settings.imageProcessingDesc": "Ces paramètres s'appliquent uniquement lorsqu'un client demande explicitement une conversion de format via l'API (ex. <code>?format=webp&width=800</code>). Les pages servies sans paramètres sont livrées telles quelles depuis l'archive, sans traitement.",
|
||||
"settings.defaultFormat": "Format de sortie par défaut",
|
||||
"settings.defaultQuality": "Qualité par défaut (1-100)",
|
||||
"settings.defaultFilter": "Filtre de redimensionnement par défaut",
|
||||
"settings.filterLanczos": "Lanczos3 (Meilleure qualité)",
|
||||
"settings.filterTriangle": "Triangle (Plus rapide)",
|
||||
"settings.filterNearest": "Nearest (Le plus rapide)",
|
||||
"settings.maxWidth": "Largeur maximale autorisée (px)",
|
||||
|
||||
// Settings - Cache
|
||||
"settings.cache": "Cache",
|
||||
"settings.cacheDesc": "Gérer le cache d'images et le stockage",
|
||||
"settings.cacheSize": "Taille du cache",
|
||||
"settings.files": "Fichiers",
|
||||
"settings.directory": "Répertoire",
|
||||
"settings.cacheDirectory": "Répertoire du cache",
|
||||
"settings.maxSizeMb": "Taille max (Mo)",
|
||||
"settings.clearing": "Vidage en cours...",
|
||||
"settings.clearCache": "Vider le cache",
|
||||
|
||||
// Settings - Performance
|
||||
"settings.performanceLimits": "Limites de performance",
|
||||
"settings.performanceDesc": "Configurer les performances de l'API, la limitation de débit et la concurrence de génération des miniatures",
|
||||
"settings.concurrentRenders": "Rendus simultanés",
|
||||
"settings.concurrentRendersHelp": "Nombre maximum de rendus de pages et de générations de miniatures en parallèle",
|
||||
"settings.timeoutSeconds": "Délai d'expiration (secondes)",
|
||||
"settings.rateLimit": "Limite de débit (req/s)",
|
||||
"settings.limitsNote": "Note : Les modifications des limites nécessitent un redémarrage du serveur pour prendre effet. Le paramètre « Rendus simultanés » contrôle à la fois le rendu des pages et le parallélisme de génération des miniatures.",
|
||||
|
||||
// Settings - Thumbnails
|
||||
"settings.thumbnails": "Miniatures",
|
||||
"settings.thumbnailsDesc": "Configurer la génération des miniatures pendant l'indexation",
|
||||
"settings.enableThumbnails": "Activer les miniatures",
|
||||
"settings.outputFormat": "Format de sortie",
|
||||
"settings.formatOriginal": "Original (Sans ré-encodage)",
|
||||
"settings.formatOriginalDesc": "Redimensionne aux dimensions cibles, conserve le format source (JPEG→JPEG). Génération beaucoup plus rapide.",
|
||||
"settings.formatReencodeDesc": "Redimensionne et ré-encode dans le format sélectionné.",
|
||||
"settings.width": "Largeur (px)",
|
||||
"settings.height": "Hauteur (px)",
|
||||
"settings.quality": "Qualité (1-100)",
|
||||
"settings.thumbnailDirectory": "Répertoire des miniatures",
|
||||
"settings.totalSize": "Taille totale",
|
||||
"settings.thumbnailsNote": "Note : Les paramètres des miniatures sont utilisés pendant l'indexation. Les miniatures existantes ne seront pas regénérées automatiquement. La concurrence de génération des miniatures est contrôlée par le paramètre « Rendus simultanés » dans les Limites de performance ci-dessus.",
|
||||
|
||||
// Settings - Komga
|
||||
"settings.komgaSync": "Synchronisation Komga",
|
||||
"settings.komgaDesc": "Importer le statut de lecture depuis un serveur Komga. Les livres sont associés par titre (insensible à la casse). Les identifiants ne sont pas stockés.",
|
||||
"settings.komgaUrl": "URL Komga",
|
||||
"settings.username": "Nom d'utilisateur",
|
||||
"settings.password": "Mot de passe",
|
||||
"settings.syncing": "Synchronisation...",
|
||||
"settings.syncReadBooks": "Synchroniser les livres lus",
|
||||
"settings.komgaRead": "Lus sur Komga",
|
||||
"settings.matched": "Associés",
|
||||
"settings.alreadyRead": "Déjà lus",
|
||||
"settings.newlyMarked": "Nouvellement marqués",
|
||||
"settings.matchedBooks": "{{count}} livre{{plural}} associé{{plural}}",
|
||||
"settings.unmatchedBooks": "{{count}} unmatched book{{plural}}",
|
||||
"settings.syncHistory": "Historique de synchronisation",
|
||||
"settings.read": "lus",
|
||||
"settings.new": "nouveaux",
|
||||
"settings.unmatched": "non associés",
|
||||
|
||||
// Settings - Metadata Providers
|
||||
"settings.metadataProviders": "Fournisseurs de métadonnées",
|
||||
"settings.metadataProvidersDesc": "Configurer les fournisseurs de métadonnées externes pour l'enrichissement des séries/livres. Chaque bibliothèque peut remplacer le fournisseur par défaut. Tous les fournisseurs sont disponibles pour la recherche rapide dans la modale de métadonnées.",
|
||||
"settings.defaultProvider": "Fournisseur par défaut",
|
||||
"settings.defaultProviderHelp": "Utilisé par défaut pour la recherche de métadonnées. Les bibliothèques peuvent le remplacer individuellement.",
|
||||
"settings.metadataLanguage": "Langue des métadonnées",
|
||||
"settings.metadataLanguageHelp": "Langue préférée pour les résultats de recherche et les descriptions. Secours : anglais.",
|
||||
"settings.apiKeys": "Clés API",
|
||||
"settings.googleBooksKey": "Clé API Google Books",
|
||||
"settings.googleBooksPlaceholder": "Optionnel — pour des limites de débit plus élevées",
|
||||
"settings.googleBooksHelp": "Fonctionne sans clé mais avec des limites de débit plus basses.",
|
||||
"settings.comicvineKey": "Clé API ComicVine",
|
||||
"settings.comicvinePlaceholder": "Requise pour utiliser ComicVine",
|
||||
"settings.comicvineHelp": "Obtenez votre clé sur",
|
||||
"settings.freeProviders": "sont gratuits et ne nécessitent pas de clé API.",
|
||||
|
||||
// Settings - Language
|
||||
"settings.language": "Langue",
|
||||
"settings.languageDesc": "Choisir la langue de l'interface",
|
||||
|
||||
// Pagination
|
||||
"pagination.show": "Afficher",
|
||||
"pagination.displaying": "Affichage de {{count}} éléments",
|
||||
"pagination.range": "{{start}}-{{end}} sur {{total}}",
|
||||
|
||||
// Book detail
|
||||
"bookDetail.libraries": "Bibliothèques",
|
||||
"bookDetail.coverOf": "Couverture de {{title}}",
|
||||
"bookDetail.technicalInfo": "Informations techniques",
|
||||
"bookDetail.file": "Fichier",
|
||||
"bookDetail.fileFormat": "Format fichier",
|
||||
"bookDetail.parsing": "Parsing",
|
||||
"bookDetail.updatedAt": "Mis à jour",
|
||||
|
||||
// Book preview
|
||||
"bookPreview.preview": "Aperçu",
|
||||
"bookPreview.pages": "pages {{start}}–{{end}} / {{total}}",
|
||||
"bookPreview.prev": "← Préc.",
|
||||
"bookPreview.next": "Suiv. →",
|
||||
|
||||
// Edit book form
|
||||
"editBook.editMetadata": "Modifier les métadonnées",
|
||||
"editBook.title": "Titre",
|
||||
"editBook.titlePlaceholder": "Titre du livre",
|
||||
"editBook.authors": "Auteur(s)",
|
||||
"editBook.addAuthor": "Ajouter un auteur (Entrée pour valider)",
|
||||
"editBook.language": "Langue",
|
||||
"editBook.languagePlaceholder": "ex : fr, en, jp",
|
||||
"editBook.series": "Série",
|
||||
"editBook.seriesPlaceholder": "Nom de la série",
|
||||
"editBook.volume": "Volume",
|
||||
"editBook.volumePlaceholder": "Numéro de volume",
|
||||
"editBook.isbn": "ISBN",
|
||||
"editBook.publishDate": "Date de publication",
|
||||
"editBook.publishDatePlaceholder": "ex : 2023-01-15",
|
||||
"editBook.description": "Description",
|
||||
"editBook.descriptionPlaceholder": "Résumé / description du livre",
|
||||
"editBook.lockedField": "Champ verrouillé (protégé des synchros)",
|
||||
"editBook.clickToLock": "Cliquer pour verrouiller ce champ",
|
||||
"editBook.lockedFieldsNote": "Les champs verrouillés ne seront pas écrasés par les synchros de métadonnées externes.",
|
||||
"editBook.saveError": "Erreur lors de la sauvegarde",
|
||||
"editBook.savingLabel": "Sauvegarde…",
|
||||
"editBook.saveLabel": "Sauvegarder",
|
||||
"editBook.removeAuthor": "Supprimer {{name}}",
|
||||
|
||||
// Edit series form
|
||||
"editSeries.title": "Modifier la série",
|
||||
"editSeries.name": "Nom",
|
||||
"editSeries.namePlaceholder": "Nom de la série",
|
||||
"editSeries.startYear": "Année de début",
|
||||
"editSeries.startYearPlaceholder": "ex : 1990",
|
||||
"editSeries.totalVolumes": "Nombre de volumes",
|
||||
"editSeries.status": "Statut",
|
||||
"editSeries.authors": "Auteur(s)",
|
||||
"editSeries.applyToBooks": "→ livres",
|
||||
"editSeries.applyToBooksTitle": "Appliquer auteur et langue à tous les livres de la série",
|
||||
"editSeries.bookAuthor": "Auteur (livres)",
|
||||
"editSeries.bookAuthorPlaceholder": "Écrase le champ auteur de chaque livre",
|
||||
"editSeries.bookLanguage": "Langue (livres)",
|
||||
"editSeries.publishers": "Éditeur(s)",
|
||||
"editSeries.addPublisher": "Ajouter un éditeur (Entrée pour valider)",
|
||||
"editSeries.descriptionPlaceholder": "Synopsis ou description de la série…",
|
||||
|
||||
// Convert button
|
||||
"convert.convertToCbz": "Convertir en CBZ",
|
||||
"convert.converting": "Conversion…",
|
||||
"convert.started": "Conversion lancée.",
|
||||
"convert.viewJob": "Voir la tâche →",
|
||||
"convert.failed": "Échec de la conversion",
|
||||
"convert.unknownError": "Erreur inconnue",
|
||||
|
||||
// Mark read buttons
|
||||
"markRead.markUnread": "Marquer non lu",
|
||||
"markRead.markAllRead": "Tout marquer lu",
|
||||
"markRead.markAsRead": "Marquer comme lu",
|
||||
|
||||
// Metadata search modal
|
||||
"metadata.metadataLink": "Lien métadonnées",
|
||||
"metadata.searchExternal": "Rechercher les métadonnées externes",
|
||||
"metadata.provider": "Fournisseur :",
|
||||
"metadata.searching": "Recherche de \"{{name}}\"...",
|
||||
"metadata.noResults": "Aucun résultat trouvé.",
|
||||
"metadata.resultCount": "{{count}} résultat{{plural}} trouvé{{plural}}",
|
||||
"metadata.howToSync": "Comment souhaitez-vous synchroniser ?",
|
||||
"metadata.syncSeriesOnly": "Synchroniser la série uniquement",
|
||||
"metadata.syncSeriesOnlyDesc": "Mettre à jour la description, les auteurs, les éditeurs et l'année",
|
||||
"metadata.syncSeriesAndBooks": "Synchroniser la série + les livres",
|
||||
"metadata.syncSeriesAndBooksDesc": "Récupérer aussi la liste des livres et afficher les tomes manquants",
|
||||
"metadata.backToResults": "Retour aux résultats",
|
||||
"metadata.syncingMetadata": "Synchronisation des métadonnées...",
|
||||
"metadata.syncSuccess": "Métadonnées synchronisées avec succès !",
|
||||
"metadata.seriesLabel": "Série",
|
||||
"metadata.booksLabel": "Livres",
|
||||
"metadata.booksMatched": "{{matched}} associé{{plural}}",
|
||||
"metadata.booksUnmatched": "{{count}} non associé{{plural}}",
|
||||
"metadata.external": "Externe",
|
||||
"metadata.local": "Locaux",
|
||||
"metadata.missingLabel": "Manquants",
|
||||
"metadata.missingBooks": "{{count}} livre{{plural}} manquant{{plural}}",
|
||||
"metadata.unknown": "Inconnu",
|
||||
"metadata.linkedTo": "Lié à",
|
||||
"metadata.viewExternal": "Voir sur la source externe",
|
||||
"metadata.searchAgain": "Rechercher à nouveau",
|
||||
"metadata.unlink": "Dissocier",
|
||||
"metadata.searchButton": "Rechercher les métadonnées",
|
||||
"metadata.metadataButton": "Métadonnées",
|
||||
"metadata.locked": "verrouillé",
|
||||
"metadata.searchFailed": "Échec de la recherche",
|
||||
"metadata.linkFailed": "Échec de la création du lien",
|
||||
"metadata.approveFailed": "Échec de l'approbation",
|
||||
"metadata.chapters": "chapitres",
|
||||
"metadata.volumes": "volumes",
|
||||
"metadata.inProgress": "en cours",
|
||||
"metadata.fallbackUsed": "(secours)",
|
||||
|
||||
// Field labels
|
||||
"field.description": "Description",
|
||||
"field.authors": "Auteurs",
|
||||
"field.publishers": "Éditeurs",
|
||||
"field.start_year": "Année",
|
||||
"field.total_volumes": "Nb volumes",
|
||||
"field.status": "Statut",
|
||||
"field.summary": "Résumé",
|
||||
"field.isbn": "ISBN",
|
||||
"field.publish_date": "Date de publication",
|
||||
"field.language": "Langue",
|
||||
|
||||
// Folder picker/browser
|
||||
"folder.selectFolder": "Sélectionner un dossier...",
|
||||
"folder.selectFolderTitle": "Sélectionner le dossier",
|
||||
"folder.clickToSelect": "Cliquez sur un dossier pour le sélectionner",
|
||||
"folder.noFolders": "Aucun dossier trouvé",
|
||||
|
||||
// Series filters
|
||||
"seriesFilters.all": "Tous",
|
||||
"seriesFilters.missingBooks": "Livres manquants",
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof fr;
|
||||
export default fr;
|
||||
5
apps/backoffice/lib/i18n/index.ts
Normal file
5
apps/backoffice/lib/i18n/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { type Locale, DEFAULT_LOCALE, LOCALE_COOKIE, LOCALES } from "./types";
|
||||
export { getDictionary, getDictionarySync, createTranslateFunction, type TranslateFunction } from "./dictionaries";
|
||||
export { getServerLocale, getServerTranslations } from "./server";
|
||||
export { LocaleProvider, useTranslation } from "./context";
|
||||
export { type TranslationKey } from "./fr";
|
||||
20
apps/backoffice/lib/i18n/server.ts
Normal file
20
apps/backoffice/lib/i18n/server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cookies } from "next/headers";
|
||||
import type { Locale } from "./types";
|
||||
import { DEFAULT_LOCALE, LOCALE_COOKIE, LOCALES } from "./types";
|
||||
import { getDictionarySync, createTranslateFunction } from "./dictionaries";
|
||||
import type { TranslateFunction } from "./dictionaries";
|
||||
|
||||
export async function getServerLocale(): Promise<Locale> {
|
||||
const cookieStore = await cookies();
|
||||
const raw = cookieStore.get(LOCALE_COOKIE)?.value;
|
||||
if (raw && LOCALES.includes(raw as Locale)) {
|
||||
return raw as Locale;
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export async function getServerTranslations(): Promise<{ t: TranslateFunction; locale: Locale }> {
|
||||
const locale = await getServerLocale();
|
||||
const dict = getDictionarySync(locale);
|
||||
return { t: createTranslateFunction(dict), locale };
|
||||
}
|
||||
5
apps/backoffice/lib/i18n/types.ts
Normal file
5
apps/backoffice/lib/i18n/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type Locale = "fr" | "en";
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = "en";
|
||||
export const LOCALE_COOKIE = "locale";
|
||||
export const LOCALES: Locale[] = ["fr", "en"];
|
||||
Reference in New Issue
Block a user