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:
2026-03-18 19:39:01 +01:00
parent 055c376222
commit d4f87c4044
43 changed files with 2024 additions and 693 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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}
</>

View File

@@ -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}
</>

View File

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

View File

@@ -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>

View File

@@ -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
? progress.current_file.substring(0, 40) + "..."
: progress.current_file}
{t("jobProgress.currentFile", { file: progress.current_file.length > 40
? progress.current_file.substring(0, 40) + "..."
: 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>
)}

View File

@@ -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>

View File

@@ -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 && (

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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({
library,
title,
icon,
export async function LibrarySubPageHeader({
library,
title,
icon,
iconColor = "text-primary",
filterInfo
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>

View File

@@ -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>

View File

@@ -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);

View File

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

View File

@@ -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 &quot;{seriesName}&quot;...</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>
)}

View File

@@ -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 />}

View File

@@ -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

View File

@@ -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>
);

View File

@@ -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>;
}

View File

@@ -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" />