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,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
|
||||
? 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>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user