feat: add batch metadata jobs, series filters, and translate backoffice to French

- Add metadata_batch job type with background processing via tokio::spawn
- Auto-apply metadata only when single result at 100% confidence
- Support primary + fallback provider per library, "none" to opt out
- Add batch report/results API endpoints and job detail UI
- Add series_status and has_missing filters to both series listing pages
- Add GET /series/statuses endpoint for dynamic filter options
- Normalize series_metadata status values (migration 0036)
- Hide ComicVine provider tab when no API key configured
- Translate entire backoffice UI from English to French

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 18:26:44 +01:00
parent 9a8c1577af
commit b955c2697c
46 changed files with 2161 additions and 379 deletions

View File

@@ -20,7 +20,7 @@ function formatNumber(n: number): string {
// Donut chart via SVG
function DonutChart({ data, colors }: { data: { label: string; value: number; color: string }[]; colors?: string[] }) {
const total = data.reduce((sum, d) => sum + d.value, 0);
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">No data</p>;
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">Aucune donnée</p>;
const radius = 40;
const circumference = 2 * Math.PI * radius;
@@ -70,7 +70,7 @@ function DonutChart({ data, colors }: { data: { label: string; value: number; co
// Bar chart via pure CSS
function BarChart({ data, color = "var(--color-primary)" }: { data: { label: string; value: number }[]; color?: string }) {
const max = Math.max(...data.map((d) => d.value), 1);
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">No data</p>;
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">Aucune donnée</p>;
return (
<div className="flex items-end gap-1.5 h-40">
@@ -126,7 +126,7 @@ export default async function DashboardPage() {
<div className="max-w-5xl mx-auto">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold tracking-tight mb-4 text-foreground">StripStream Backoffice</h1>
<p className="text-lg text-muted-foreground">Unable to load statistics. Make sure the API is running.</p>
<p className="text-lg text-muted-foreground">Impossible de charger les statistiques. Vérifiez que l'API est en cours d'exécution.</p>
</div>
<QuickLinks />
</div>
@@ -152,21 +152,21 @@ export default async function DashboardPage() {
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Dashboard
Tableau de bord
</h1>
<p className="text-muted-foreground mt-2 max-w-2xl">
Overview of your comic collection. Manage your libraries, track your reading progress, and explore your books and series.
Aperçu de votre collection de bandes dessinées. Gérez vos bibliothèques, suivez votre progression de lecture et explorez vos livres et séries.
</p>
</div>
{/* Overview stat cards */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<StatCard icon="book" label="Books" value={formatNumber(overview.total_books)} color="success" />
<StatCard icon="series" label="Series" value={formatNumber(overview.total_series)} color="primary" />
<StatCard icon="library" label="Libraries" value={formatNumber(overview.total_libraries)} color="warning" />
<StatCard icon="book" label="Livres" value={formatNumber(overview.total_books)} color="success" />
<StatCard icon="series" label="Séries" value={formatNumber(overview.total_series)} color="primary" />
<StatCard icon="library" label="Bibliothèques" value={formatNumber(overview.total_libraries)} color="warning" />
<StatCard icon="pages" label="Pages" value={formatNumber(overview.total_pages)} color="primary" />
<StatCard icon="author" label="Authors" value={formatNumber(overview.total_authors)} color="success" />
<StatCard icon="size" label="Total Size" value={formatBytes(overview.total_size_bytes)} color="warning" />
<StatCard icon="author" label="Auteurs" value={formatNumber(overview.total_authors)} color="success" />
<StatCard icon="size" label="Taille totale" value={formatBytes(overview.total_size_bytes)} color="warning" />
</div>
{/* Charts row */}
@@ -174,14 +174,14 @@ export default async function DashboardPage() {
{/* Reading status donut */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">Reading Status</CardTitle>
<CardTitle className="text-base">Statut de lecture</CardTitle>
</CardHeader>
<CardContent>
<DonutChart
data={[
{ label: "Unread", value: reading_status.unread, color: readingColors[0] },
{ label: "In Progress", value: reading_status.reading, color: readingColors[1] },
{ label: "Read", value: reading_status.read, color: readingColors[2] },
{ label: "Non lu", value: reading_status.unread, color: readingColors[0] },
{ label: "En cours", value: reading_status.reading, color: readingColors[1] },
{ label: "Lu", value: reading_status.read, color: readingColors[2] },
]}
/>
</CardContent>
@@ -190,12 +190,12 @@ export default async function DashboardPage() {
{/* By format donut */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">By Format</CardTitle>
<CardTitle className="text-base">Par format</CardTitle>
</CardHeader>
<CardContent>
<DonutChart
data={by_format.slice(0, 6).map((f, i) => ({
label: (f.format || "Unknown").toUpperCase(),
label: (f.format || "Inconnu").toUpperCase(),
value: f.count,
color: formatColors[i % formatColors.length],
}))}
@@ -206,7 +206,7 @@ export default async function DashboardPage() {
{/* By library donut */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">By Library</CardTitle>
<CardTitle className="text-base">Par bibliothèque</CardTitle>
</CardHeader>
<CardContent>
<DonutChart
@@ -225,7 +225,7 @@ export default async function DashboardPage() {
{/* Monthly additions bar chart */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">Books Added (Last 12 Months)</CardTitle>
<CardTitle className="text-base">Livres ajoutés (12 derniers mois)</CardTitle>
</CardHeader>
<CardContent>
<BarChart
@@ -241,7 +241,7 @@ export default async function DashboardPage() {
{/* Top series */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">Top Series</CardTitle>
<CardTitle className="text-base">Séries populaires</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
@@ -251,12 +251,12 @@ export default async function DashboardPage() {
label={s.series}
value={s.book_count}
max={top_series[0]?.book_count || 1}
subLabel={`${s.read_count}/${s.book_count} read`}
subLabel={`${s.read_count}/${s.book_count} lu`}
color="hsl(142 60% 45%)"
/>
))}
{top_series.length === 0 && (
<p className="text-muted-foreground text-sm text-center py-4">No series yet</p>
<p className="text-muted-foreground text-sm text-center py-4">Aucune série pour le moment</p>
)}
</div>
</CardContent>
@@ -267,7 +267,7 @@ export default async function DashboardPage() {
{by_library.length > 0 && (
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">Libraries</CardTitle>
<CardTitle className="text-base">Bibliothèques</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
@@ -281,23 +281,23 @@ export default async function DashboardPage() {
<div
className="h-full transition-all duration-500"
style={{ width: `${(lib.read_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(142 60% 45%)" }}
title={`Read: ${lib.read_count}`}
title={`Lu : ${lib.read_count}`}
/>
<div
className="h-full transition-all duration-500"
style={{ width: `${(lib.reading_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(45 93% 47%)" }}
title={`In progress: ${lib.reading_count}`}
title={`En cours : ${lib.reading_count}`}
/>
<div
className="h-full transition-all duration-500"
style={{ width: `${(lib.unread_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(220 13% 70%)" }}
title={`Unread: ${lib.unread_count}`}
title={`Non lu : ${lib.unread_count}`}
/>
</div>
<div className="flex gap-3 text-[11px] text-muted-foreground">
<span>{lib.book_count} books</span>
<span className="text-success">{lib.read_count} read</span>
<span className="text-warning">{lib.reading_count} in progress</span>
<span>{lib.book_count} livres</span>
<span className="text-success">{lib.read_count} lu</span>
<span className="text-warning">{lib.reading_count} en cours</span>
</div>
</div>
))}
@@ -347,10 +347,10 @@ function StatCard({ icon, label, value, color }: { icon: string; label: string;
function QuickLinks() {
const links = [
{ href: "/libraries", label: "Libraries", bg: "bg-primary/10", text: "text-primary", hoverBg: "group-hover:bg-primary", hoverText: "group-hover:text-primary-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> },
{ href: "/books", label: "Books", bg: "bg-success/10", text: "text-success", hoverBg: "group-hover:bg-success", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> },
{ href: "/series", label: "Series", bg: "bg-warning/10", text: "text-warning", hoverBg: "group-hover:bg-warning", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> },
{ href: "/jobs", label: "Jobs", bg: "bg-destructive/10", text: "text-destructive", hoverBg: "group-hover:bg-destructive", hoverText: "group-hover:text-destructive-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> },
{ href: "/libraries", label: "Bibliothèques", bg: "bg-primary/10", text: "text-primary", hoverBg: "group-hover:bg-primary", hoverText: "group-hover:text-primary-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> },
{ href: "/books", label: "Livres", bg: "bg-success/10", text: "text-success", hoverBg: "group-hover:bg-success", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> },
{ href: "/series", label: "Séries", bg: "bg-warning/10", text: "text-warning", hoverBg: "group-hover:bg-warning", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> },
{ href: "/jobs", label: "Tâches", bg: "bg-destructive/10", text: "text-destructive", hoverBg: "group-hover:bg-destructive", hoverText: "group-hover:text-destructive-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> },
];
return (