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:
@@ -5,6 +5,7 @@ import {
|
||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
||||
} from "../../components/ui";
|
||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||
|
||||
interface JobDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -42,38 +43,6 @@ interface JobError {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const JOB_TYPE_INFO: Record<string, { label: string; description: string; isThumbnailOnly: boolean }> = {
|
||||
rebuild: {
|
||||
label: "Indexation incrémentale",
|
||||
description: "Scanne les fichiers nouveaux/modifiés, les analyse et génère les miniatures manquantes.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
full_rebuild: {
|
||||
label: "Réindexation complète",
|
||||
description: "Supprime toutes les données existantes puis effectue un scan complet, une ré-analyse et la génération des miniatures.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
thumbnail_rebuild: {
|
||||
label: "Reconstruction des miniatures",
|
||||
description: "Génère les miniatures uniquement pour les livres qui n'en ont pas. Les miniatures existantes sont conservées.",
|
||||
isThumbnailOnly: true,
|
||||
},
|
||||
thumbnail_regenerate: {
|
||||
label: "Regénération des miniatures",
|
||||
description: "Regénère toutes les miniatures depuis zéro, en remplaçant les existantes.",
|
||||
isThumbnailOnly: true,
|
||||
},
|
||||
cbr_to_cbz: {
|
||||
label: "Conversion CBR → CBZ",
|
||||
description: "Convertit une archive CBR au format ouvert CBZ.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
metadata_batch: {
|
||||
label: "Métadonnées en lot",
|
||||
description: "Recherche les métadonnées auprès des fournisseurs externes pour toutes les séries de la bibliothèque et applique automatiquement les correspondances à 100% de confiance.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
async function getJobDetails(jobId: string): Promise<JobDetails | null> {
|
||||
try {
|
||||
@@ -117,6 +86,41 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { t, locale } = await getServerTranslations();
|
||||
|
||||
const JOB_TYPE_INFO: Record<string, { label: string; description: string; isThumbnailOnly: boolean }> = {
|
||||
rebuild: {
|
||||
label: t("jobType.rebuildLabel"),
|
||||
description: t("jobType.rebuildDesc"),
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
full_rebuild: {
|
||||
label: t("jobType.full_rebuildLabel"),
|
||||
description: t("jobType.full_rebuildDesc"),
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
thumbnail_rebuild: {
|
||||
label: t("jobType.thumbnail_rebuildLabel"),
|
||||
description: t("jobType.thumbnail_rebuildDesc"),
|
||||
isThumbnailOnly: true,
|
||||
},
|
||||
thumbnail_regenerate: {
|
||||
label: t("jobType.thumbnail_regenerateLabel"),
|
||||
description: t("jobType.thumbnail_regenerateDesc"),
|
||||
isThumbnailOnly: true,
|
||||
},
|
||||
cbr_to_cbz: {
|
||||
label: t("jobType.cbr_to_cbzLabel"),
|
||||
description: t("jobType.cbr_to_cbzDesc"),
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
metadata_batch: {
|
||||
label: t("jobType.metadata_batchLabel"),
|
||||
description: t("jobType.metadata_batchDesc"),
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
const isMetadataBatch = job.type === "metadata_batch";
|
||||
|
||||
// Fetch batch report & results for metadata_batch jobs
|
||||
@@ -149,24 +153,24 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
|
||||
// Which label to use for the progress card
|
||||
const progressTitle = isMetadataBatch
|
||||
? "Recherche de métadonnées"
|
||||
? t("jobDetail.metadataSearch")
|
||||
: isThumbnailOnly
|
||||
? "Miniatures"
|
||||
? t("jobType.thumbnail_rebuild")
|
||||
: isExtractingPages
|
||||
? "Phase 2 — Extraction des pages"
|
||||
? t("jobDetail.phase2a")
|
||||
: isThumbnailPhase
|
||||
? "Phase 2 — Miniatures"
|
||||
: "Phase 1 — Découverte";
|
||||
? t("jobDetail.phase2b")
|
||||
: t("jobDetail.phase1");
|
||||
|
||||
const progressDescription = isMetadataBatch
|
||||
? "Recherche auprès des fournisseurs externes pour chaque série"
|
||||
? t("jobDetail.metadataSearchDesc")
|
||||
: isThumbnailOnly
|
||||
? undefined
|
||||
: isExtractingPages
|
||||
? "Extraction de la première page de chaque archive (nombre de pages + image brute)"
|
||||
? t("jobDetail.phase2aDesc")
|
||||
: isThumbnailPhase
|
||||
? "Génération des miniatures pour les livres analysés"
|
||||
: "Scan et indexation des fichiers de la bibliothèque";
|
||||
? t("jobDetail.phase2bDesc")
|
||||
: t("jobDetail.phase1Desc");
|
||||
|
||||
// Speed metric: thumbnail count for thumbnail jobs, scanned files for index jobs
|
||||
const speedCount = isThumbnailOnly
|
||||
@@ -187,9 +191,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour aux tâches
|
||||
{t("jobDetail.backToJobs")}
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-foreground mt-2">Détails de la tâche</h1>
|
||||
<h1 className="text-3xl font-bold text-foreground mt-2">{t("jobDetail.title")}</h1>
|
||||
</div>
|
||||
|
||||
{/* Summary banner — completed */}
|
||||
@@ -199,24 +203,24 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-success">
|
||||
<span className="font-semibold">Terminé en {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
<span className="font-semibold">{t("jobDetail.completedIn", { duration: formatDuration(job.started_at, job.finished_at) })}</span>
|
||||
{isMetadataBatch && batchReport && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {batchReport.auto_matched} auto-associées, {batchReport.already_linked} déjà liées, {batchReport.no_results} aucun résultat, {batchReport.errors} erreurs
|
||||
— {batchReport.auto_matched} {t("jobDetail.autoMatched").toLowerCase()}, {batchReport.already_linked} {t("jobDetail.alreadyLinked").toLowerCase()}, {batchReport.no_results} {t("jobDetail.noResults").toLowerCase()}, {batchReport.errors} {t("jobDetail.errors").toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
{!isMetadataBatch && job.stats_json && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {job.stats_json.scanned_files} scannés, {job.stats_json.indexed_files} indexés
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} supprimés`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} avertissements`}
|
||||
{job.stats_json.errors > 0 && `, ${job.stats_json.errors} erreurs`}
|
||||
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} miniatures`}
|
||||
— {job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()}
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`}
|
||||
{job.stats_json.errors > 0 && `, ${job.stats_json.errors} ${t("jobDetail.errors").toLowerCase()}`}
|
||||
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} ${t("jobType.thumbnail_rebuild").toLowerCase()}`}
|
||||
</span>
|
||||
)}
|
||||
{!isMetadataBatch && !job.stats_json && isThumbnailOnly && job.total_files != null && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {job.processed_files ?? job.total_files} miniatures générées
|
||||
— {job.processed_files ?? job.total_files} {t("jobDetail.generated").toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -230,9 +234,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-destructive">
|
||||
<span className="font-semibold">Tâche échouée</span>
|
||||
<span className="font-semibold">{t("jobDetail.jobFailed")}</span>
|
||||
{job.started_at && (
|
||||
<span className="ml-2 text-destructive/80">après {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
<span className="ml-2 text-destructive/80">{t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })}</span>
|
||||
)}
|
||||
{job.error_opt && (
|
||||
<p className="mt-1 text-destructive/70 font-mono text-xs break-all">{job.error_opt}</p>
|
||||
@@ -248,9 +252,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="font-semibold">Annulé</span>
|
||||
<span className="font-semibold">{t("jobDetail.cancelled")}</span>
|
||||
{job.started_at && (
|
||||
<span className="ml-2">après {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
<span className="ml-2">{t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -260,7 +264,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{/* Overview Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aperçu</CardTitle>
|
||||
<CardTitle>{t("jobDetail.overview")}</CardTitle>
|
||||
{typeInfo.description && (
|
||||
<CardDescription>{typeInfo.description}</CardDescription>
|
||||
)}
|
||||
@@ -271,23 +275,23 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<code className="px-2 py-1 bg-muted rounded font-mono text-sm text-foreground">{job.id}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||
<span className="text-sm text-muted-foreground">Type</span>
|
||||
<span className="text-sm text-muted-foreground">{t("jobsList.type")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<JobTypeBadge type={job.type} />
|
||||
<span className="text-sm text-muted-foreground">{typeInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||
<span className="text-sm text-muted-foreground">Statut</span>
|
||||
<span className="text-sm text-muted-foreground">{t("jobsList.status")}</span>
|
||||
<StatusBadge status={job.status} />
|
||||
</div>
|
||||
<div className={`flex items-center justify-between py-2 ${(job.book_id || job.started_at) ? "border-b border-border/60" : ""}`}>
|
||||
<span className="text-sm text-muted-foreground">Bibliothèque</span>
|
||||
<span className="text-sm text-foreground">{job.library_id || "Toutes les bibliothèques"}</span>
|
||||
<span className="text-sm text-muted-foreground">{t("jobDetail.library")}</span>
|
||||
<span className="text-sm text-foreground">{job.library_id || t("jobDetail.allLibraries")}</span>
|
||||
</div>
|
||||
{job.book_id && (
|
||||
<div className={`flex items-center justify-between py-2 ${job.started_at ? "border-b border-border/60" : ""}`}>
|
||||
<span className="text-sm text-muted-foreground">Livre</span>
|
||||
<span className="text-sm text-muted-foreground">{t("jobDetail.book")}</span>
|
||||
<Link
|
||||
href={`/books/${job.book_id}`}
|
||||
className="text-sm text-primary hover:text-primary/80 font-mono hover:underline"
|
||||
@@ -298,7 +302,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
)}
|
||||
{job.started_at && (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-muted-foreground">Durée</span>
|
||||
<span className="text-sm text-muted-foreground">{t("jobsList.duration")}</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatDuration(job.started_at, job.finished_at)}
|
||||
</span>
|
||||
@@ -310,7 +314,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{/* Timeline Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Chronologie</CardTitle>
|
||||
<CardTitle>{t("jobDetail.timeline")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative">
|
||||
@@ -322,8 +326,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-muted border-2 border-border shrink-0 z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Créé</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</p>
|
||||
<span className="text-sm font-medium text-foreground">{t("jobDetail.created")}</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString(locale)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -332,15 +336,15 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-primary shrink-0 z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Phase 1 — Découverte</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
|
||||
<span className="text-sm font-medium text-foreground">{t("jobDetail.phase1")}</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString(locale)}</p>
|
||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||
Durée : {formatDuration(job.started_at, job.phase2_started_at)}
|
||||
{t("jobDetail.duration", { duration: formatDuration(job.started_at, job.phase2_started_at) })}
|
||||
{job.stats_json && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
· {job.stats_json.scanned_files} scannés, {job.stats_json.indexed_files} indexés
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} supprimés`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} avert.`}
|
||||
· {job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()}
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -355,12 +359,12 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
job.generating_thumbnails_started_at || job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Phase 2a — Extraction des pages</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.phase2_started_at).toLocaleString()}</p>
|
||||
<span className="text-sm font-medium text-foreground">{t("jobDetail.phase2a")}</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.phase2_started_at).toLocaleString(locale)}</p>
|
||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||
Durée : {formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null)}
|
||||
{t("jobDetail.duration", { duration: formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null) })}
|
||||
{!job.generating_thumbnails_started_at && !job.finished_at && isExtractingPages && (
|
||||
<span className="text-muted-foreground font-normal ml-1">· en cours</span>
|
||||
<span className="text-muted-foreground font-normal ml-1">· {t("jobDetail.inProgress")}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -375,26 +379,26 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{isThumbnailOnly ? "Miniatures" : "Phase 2b — Génération des miniatures"}
|
||||
{isThumbnailOnly ? t("jobType.thumbnail_rebuild") : t("jobDetail.phase2b")}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(job.generating_thumbnails_started_at ? new Date(job.generating_thumbnails_started_at) : job.phase2_started_at ? new Date(job.phase2_started_at) : null)?.toLocaleString()}
|
||||
{(job.generating_thumbnails_started_at ? new Date(job.generating_thumbnails_started_at) : job.phase2_started_at ? new Date(job.phase2_started_at) : null)?.toLocaleString(locale)}
|
||||
</p>
|
||||
{(job.generating_thumbnails_started_at || job.finished_at) && (
|
||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||
Durée : {formatDuration(
|
||||
{t("jobDetail.duration", { duration: formatDuration(
|
||||
job.generating_thumbnails_started_at ?? job.phase2_started_at!,
|
||||
job.finished_at ?? null
|
||||
)}
|
||||
) })}
|
||||
{job.total_files != null && job.total_files > 0 && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
· {job.processed_files ?? job.total_files} miniatures
|
||||
· {job.processed_files ?? job.total_files} {t("jobType.thumbnail_rebuild").toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{!job.finished_at && isThumbnailPhase && (
|
||||
<span className="text-xs text-muted-foreground">en cours</span>
|
||||
<span className="text-xs text-muted-foreground">{t("jobDetail.inProgress")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -407,8 +411,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Démarré</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
|
||||
<span className="text-sm font-medium text-foreground">{t("jobDetail.started")}</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString(locale)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -418,7 +422,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-warning shrink-0 z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">En attente de démarrage…</span>
|
||||
<span className="text-sm font-medium text-foreground">{t("jobDetail.pendingStart")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -431,9 +435,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{isCompleted ? "Terminé" : isFailed ? "Échoué" : "Annulé"}
|
||||
{isCompleted ? t("jobDetail.finished") : isFailed ? t("jobDetail.failed") : t("jobDetail.cancelled")}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.finished_at).toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.finished_at).toLocaleString(locale)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -456,13 +460,13 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<StatBox
|
||||
value={job.processed_files ?? 0}
|
||||
label={isThumbnailOnly || isPhase2 ? "Générés" : "Traités"}
|
||||
label={isThumbnailOnly || isPhase2 ? t("jobDetail.generated") : t("jobDetail.processed")}
|
||||
variant="primary"
|
||||
/>
|
||||
<StatBox value={job.total_files} label="Total" />
|
||||
<StatBox value={job.total_files} label={t("jobDetail.total")} />
|
||||
<StatBox
|
||||
value={Math.max(0, job.total_files - (job.processed_files ?? 0))}
|
||||
label="Restants"
|
||||
label={t("jobDetail.remaining")}
|
||||
variant={isCompleted ? "default" : "warning"}
|
||||
/>
|
||||
</div>
|
||||
@@ -470,7 +474,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
)}
|
||||
{job.current_file && (
|
||||
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">Fichier en cours</span>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{t("jobDetail.currentFile")}</span>
|
||||
<code className="block mt-1 text-xs font-mono text-foreground break-all">{job.current_file}</code>
|
||||
</div>
|
||||
)}
|
||||
@@ -482,7 +486,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Statistiques d'indexation</CardTitle>
|
||||
<CardTitle>{t("jobDetail.indexStats")}</CardTitle>
|
||||
{job.started_at && (
|
||||
<CardDescription>
|
||||
{formatDuration(job.started_at, job.finished_at)}
|
||||
@@ -492,11 +496,11 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||
<StatBox value={job.stats_json.scanned_files} label="Scannés" variant="success" />
|
||||
<StatBox value={job.stats_json.indexed_files} label="Indexés" variant="primary" />
|
||||
<StatBox value={job.stats_json.removed_files} label="Supprimés" variant="warning" />
|
||||
<StatBox value={job.stats_json.warnings ?? 0} label="Avertissements" variant={(job.stats_json.warnings ?? 0) > 0 ? "warning" : "default"} />
|
||||
<StatBox value={job.stats_json.errors} label="Erreurs" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||
<StatBox value={job.stats_json.scanned_files} label={t("jobDetail.scanned")} variant="success" />
|
||||
<StatBox value={job.stats_json.indexed_files} label={t("jobDetail.indexed")} variant="primary" />
|
||||
<StatBox value={job.stats_json.removed_files} label={t("jobDetail.removed")} variant="warning" />
|
||||
<StatBox value={job.stats_json.warnings ?? 0} label={t("jobDetail.warnings")} variant={(job.stats_json.warnings ?? 0) > 0 ? "warning" : "default"} />
|
||||
<StatBox value={job.stats_json.errors} label={t("jobDetail.errors")} variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -506,7 +510,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{isThumbnailOnly && isCompleted && job.total_files != null && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Statistiques des miniatures</CardTitle>
|
||||
<CardTitle>{t("jobDetail.thumbnailStats")}</CardTitle>
|
||||
{job.started_at && (
|
||||
<CardDescription>
|
||||
{formatDuration(job.started_at, job.finished_at)}
|
||||
@@ -516,8 +520,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatBox value={job.processed_files ?? job.total_files} label="Générés" variant="success" />
|
||||
<StatBox value={job.total_files} label="Total" />
|
||||
<StatBox value={job.processed_files ?? job.total_files} label={t("jobDetail.generated")} variant="success" />
|
||||
<StatBox value={job.total_files} label={t("jobDetail.total")} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -527,17 +531,17 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{isMetadataBatch && batchReport && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rapport du lot</CardTitle>
|
||||
<CardDescription>{batchReport.total_series} séries analysées</CardDescription>
|
||||
<CardTitle>{t("jobDetail.batchReport")}</CardTitle>
|
||||
<CardDescription>{t("jobDetail.seriesAnalyzed", { count: String(batchReport.total_series) })}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<StatBox value={batchReport.auto_matched} label="Auto-associé" variant="success" />
|
||||
<StatBox value={batchReport.already_linked} label="Déjà lié" variant="primary" />
|
||||
<StatBox value={batchReport.no_results} label="Aucun résultat" />
|
||||
<StatBox value={batchReport.too_many_results} label="Trop de résultats" variant="warning" />
|
||||
<StatBox value={batchReport.low_confidence} label="Confiance faible" variant="warning" />
|
||||
<StatBox value={batchReport.errors} label="Erreurs" variant={batchReport.errors > 0 ? "error" : "default"} />
|
||||
<StatBox value={batchReport.auto_matched} label={t("jobDetail.autoMatched")} variant="success" />
|
||||
<StatBox value={batchReport.already_linked} label={t("jobDetail.alreadyLinked")} variant="primary" />
|
||||
<StatBox value={batchReport.no_results} label={t("jobDetail.noResults")} />
|
||||
<StatBox value={batchReport.too_many_results} label={t("jobDetail.tooManyResults")} variant="warning" />
|
||||
<StatBox value={batchReport.low_confidence} label={t("jobDetail.lowConfidence")} variant="warning" />
|
||||
<StatBox value={batchReport.errors} label={t("jobDetail.errors")} variant={batchReport.errors > 0 ? "error" : "default"} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -547,8 +551,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{isMetadataBatch && batchResults.length > 0 && (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Résultats par série</CardTitle>
|
||||
<CardDescription>{batchResults.length} séries traitées</CardDescription>
|
||||
<CardTitle>{t("jobDetail.resultsBySeries")}</CardTitle>
|
||||
<CardDescription>{t("jobDetail.seriesProcessed", { count: String(batchResults.length) })}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{batchResults.map((r) => (
|
||||
@@ -572,29 +576,29 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
r.status === "error" ? "bg-destructive/20 text-destructive" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{r.status === "auto_matched" ? "Auto-associé" :
|
||||
r.status === "already_linked" ? "Déjà lié" :
|
||||
r.status === "no_results" ? "Aucun résultat" :
|
||||
r.status === "too_many_results" ? "Trop de résultats" :
|
||||
r.status === "low_confidence" ? "Confiance faible" :
|
||||
r.status === "error" ? "Erreur" :
|
||||
{r.status === "auto_matched" ? t("jobDetail.autoMatched") :
|
||||
r.status === "already_linked" ? t("jobDetail.alreadyLinked") :
|
||||
r.status === "no_results" ? t("jobDetail.noResults") :
|
||||
r.status === "too_many_results" ? t("jobDetail.tooManyResults") :
|
||||
r.status === "low_confidence" ? t("jobDetail.lowConfidence") :
|
||||
r.status === "error" ? t("common.error") :
|
||||
r.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
{r.provider_used && (
|
||||
<span>{r.provider_used}{r.fallback_used ? " (secours)" : ""}</span>
|
||||
<span>{r.provider_used}{r.fallback_used ? ` ${t("metadata.fallbackUsed")}` : ""}</span>
|
||||
)}
|
||||
{r.candidates_count > 0 && (
|
||||
<span>{r.candidates_count} candidat{r.candidates_count > 1 ? "s" : ""}</span>
|
||||
<span>{r.candidates_count} {t("jobDetail.candidates", { plural: r.candidates_count > 1 ? "s" : "" })}</span>
|
||||
)}
|
||||
{r.best_confidence != null && (
|
||||
<span>{Math.round(r.best_confidence * 100)}% confiance</span>
|
||||
<span>{Math.round(r.best_confidence * 100)}% {t("jobDetail.confidence")}</span>
|
||||
)}
|
||||
</div>
|
||||
{r.best_candidate_json && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Correspondance : {(r.best_candidate_json as { title?: string }).title || r.best_candidate_json.toString()}
|
||||
{t("jobDetail.match", { title: (r.best_candidate_json as { title?: string }).title || r.best_candidate_json.toString() })}
|
||||
</p>
|
||||
)}
|
||||
{r.error_message && (
|
||||
@@ -610,15 +614,15 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{errors.length > 0 && (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Erreurs de fichiers ({errors.length})</CardTitle>
|
||||
<CardDescription>Erreurs rencontrées lors du traitement des fichiers</CardDescription>
|
||||
<CardTitle>{t("jobDetail.fileErrors", { count: String(errors.length) })}</CardTitle>
|
||||
<CardDescription>{t("jobDetail.fileErrorsDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{errors.map((error) => (
|
||||
<div key={error.id} className="p-3 bg-destructive/10 rounded-lg border border-destructive/20">
|
||||
<code className="block text-sm font-mono text-destructive mb-1">{error.file_path}</code>
|
||||
<p className="text-sm text-destructive/80">{error.error_message}</p>
|
||||
<span className="text-xs text-muted-foreground">{new Date(error.created_at).toLocaleString()}</span>
|
||||
<span className="text-xs text-muted-foreground">{new Date(error.created_at).toLocaleString(locale)}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { redirect } from "next/navigation";
|
||||
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, IndexJobDto, LibraryDto } from "../../lib/api";
|
||||
import { JobsList } from "../components/JobsList";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormSelect, FormRow } from "../components/ui";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function JobsPage({ searchParams }: { searchParams: Promise<{ highlight?: string }> }) {
|
||||
const { highlight } = await searchParams;
|
||||
const { t } = await getServerTranslations();
|
||||
const [jobs, libraries] = await Promise.all([
|
||||
listJobs().catch(() => [] as IndexJobDto[]),
|
||||
fetchLibraries().catch(() => [] as LibraryDto[])
|
||||
@@ -63,21 +65,21 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Tâches d'indexation
|
||||
{t("jobs.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Lancer une tâche</CardTitle>
|
||||
<CardDescription>Sélectionnez une bibliothèque (ou toutes) et choisissez l'action à effectuer.</CardDescription>
|
||||
<CardTitle>{t("jobs.startJob")}</CardTitle>
|
||||
<CardDescription>{t("jobs.startJobDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<FormRow>
|
||||
<FormField className="flex-1 max-w-xs">
|
||||
<FormSelect name="library_id" defaultValue="">
|
||||
<option value="">Toutes les bibliothèques</option>
|
||||
<option value="">{t("jobs.allLibraries")}</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>{lib.name}</option>
|
||||
))}
|
||||
@@ -88,31 +90,31 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Reconstruction
|
||||
{t("jobs.rebuild")}
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerFullRebuild} variant="warning">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Reconstruction complète
|
||||
{t("jobs.fullRebuild")}
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerThumbnailsRebuild} variant="secondary">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Générer les miniatures
|
||||
{t("jobs.generateThumbnails")}
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerThumbnailsRegenerate} variant="warning">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Regénérer les miniatures
|
||||
{t("jobs.regenerateThumbnails")}
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerMetadataBatch} variant="secondary">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Métadonnées en lot
|
||||
{t("jobs.batchMetadata")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormRow>
|
||||
@@ -123,7 +125,7 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
{/* Job types legend */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Référence des types de tâches</CardTitle>
|
||||
<CardTitle className="text-base">{t("jobs.referenceTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
@@ -134,10 +136,8 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Reconstruction</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Scan incrémental : détecte les fichiers ajoutés, modifiés ou supprimés depuis le dernier scan, les indexe et génère les miniatures manquantes. Les données existantes non modifiées sont conservées. C’est l’action la plus courante et la plus rapide.
|
||||
</p>
|
||||
<span className="font-medium text-foreground">{t("jobs.rebuild")}</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5" dangerouslySetInnerHTML={{ __html: t("jobs.rebuildDescription") }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -147,10 +147,8 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Reconstruction complète</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Supprime toutes les données indexées (livres, séries, miniatures) puis effectue un scan complet depuis zéro. Utile si la base de données est désynchronisée ou corrompue. Opération longue et destructive : les statuts de lecture et les métadonnées manuelles seront perdus.
|
||||
</p>
|
||||
<span className="font-medium text-foreground">{t("jobs.fullRebuild")}</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5" dangerouslySetInnerHTML={{ __html: t("jobs.fullRebuildDescription") }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -160,10 +158,8 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Générer les miniatures</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Génère les miniatures uniquement pour les livres qui n’en ont pas encore. Les miniatures existantes ne sont pas touchées. Utile après un import ou si certaines miniatures sont manquantes.
|
||||
</p>
|
||||
<span className="font-medium text-foreground">{t("jobs.generateThumbnails")}</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5" dangerouslySetInnerHTML={{ __html: t("jobs.generateThumbnailsDescription") }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -173,10 +169,8 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Regénérer les miniatures</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Regénère toutes les miniatures depuis zéro, en remplaçant les existantes. Utile si la qualité ou la taille des miniatures a changé dans la configuration, ou si des miniatures sont corrompues.
|
||||
</p>
|
||||
<span className="font-medium text-foreground">{t("jobs.regenerateThumbnails")}</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5" dangerouslySetInnerHTML={{ __html: t("jobs.regenerateThumbnailsDescription") }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -186,10 +180,8 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Métadonnées en lot</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Recherche automatiquement les métadonnées de chaque série de la bibliothèque auprès du provider configuré (avec fallback si configuré). Seuls les résultats avec un match unique à 100% de confiance sont appliqués automatiquement. Les séries déjà liées sont ignorées. Un rapport détaillé par série est disponible à la fin du job. <strong>Requiert une bibliothèque spécifique</strong> (ne fonctionne pas sur « Toutes les bibliothèques »).
|
||||
</p>
|
||||
<span className="font-medium text-foreground">{t("jobs.batchMetadata")}</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5" dangerouslySetInnerHTML={{ __html: t("jobs.batchMetadataDescription") }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user