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

@@ -78,20 +78,20 @@ export default async function BooksPage({
const totalPages = Math.ceil(total / limit);
const libraryOptions = [
{ value: "", label: "All libraries" },
{ value: "", label: "Toutes les bibliothèques" },
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
];
const statusOptions = [
{ value: "", label: "All" },
{ value: "unread", label: "Unread" },
{ value: "reading", label: "In progress" },
{ value: "read", label: "Read" },
{ value: "", label: "Tous" },
{ value: "unread", label: "Non lu" },
{ value: "reading", label: "En cours" },
{ value: "read", label: "Lu" },
];
const sortOptions = [
{ value: "", label: "Title" },
{ value: "latest", label: "Latest added" },
{ value: "", label: "Titre" },
{ value: "latest", label: "Ajout récent" },
];
const hasFilters = searchQuery || libraryId || readingStatus || sort;
@@ -103,7 +103,7 @@ export default async function BooksPage({
<svg className="w-8 h-8 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Books
Livres
</h1>
</div>
@@ -112,10 +112,10 @@ export default async function BooksPage({
<LiveSearchForm
basePath="/books"
fields={[
{ name: "q", type: "text", label: "Search", placeholder: "Search by title, author, series...", className: "flex-1 w-full" },
{ name: "library", type: "select", label: "Library", options: libraryOptions, className: "w-full sm:w-48" },
{ name: "status", type: "select", label: "Status", options: statusOptions, className: "w-full sm:w-40" },
{ name: "sort", type: "select", label: "Sort", options: sortOptions, className: "w-full sm:w-40" },
{ name: "q", type: "text", label: "Rechercher", placeholder: "Rechercher par titre, auteur, série...", className: "flex-1 w-full" },
{ name: "library", type: "select", label: "Bibliothèque", options: libraryOptions, className: "w-full sm:w-48" },
{ name: "status", type: "select", label: "Statut", options: statusOptions, className: "w-full sm:w-40" },
{ name: "sort", type: "select", label: "Tri", options: sortOptions, className: "w-full sm:w-40" },
]}
/>
</CardContent>
@@ -124,18 +124,18 @@ export default async function BooksPage({
{/* Résultats */}
{searchQuery && totalHits !== null ? (
<p className="text-sm text-muted-foreground mb-4">
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for &quot;{searchQuery}&quot;
{totalHits} résultat{totalHits !== 1 ? 's' : ''} pour &laquo; {searchQuery} &raquo;
</p>
) : !searchQuery && (
<p className="text-sm text-muted-foreground mb-4">
{total} book{total !== 1 ? 's' : ''}
{total} livre{total !== 1 ? 's' : ''}
</p>
)}
{/* Séries matchantes */}
{seriesHits.length > 0 && (
<div className="mb-8">
<h2 className="text-lg font-semibold text-foreground mb-3">Series</h2>
<h2 className="text-lg font-semibold text-foreground mb-3">Séries</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{seriesHits.map((s) => (
<Link
@@ -147,7 +147,7 @@ export default async function BooksPage({
<div className="aspect-[2/3] relative bg-muted/50">
<Image
src={getBookCoverUrl(s.first_book_id)}
alt={`Cover of ${s.name}`}
alt={`Couverture de ${s.name}`}
fill
className="object-cover"
unoptimized
@@ -155,10 +155,10 @@ export default async function BooksPage({
</div>
<div className="p-2">
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
{s.name === "unclassified" ? "Unclassified" : s.name}
{s.name === "unclassified" ? "Non classé" : s.name}
</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
{s.book_count} livre{s.book_count !== 1 ? 's' : ''}
</p>
</div>
</div>
@@ -171,7 +171,7 @@ export default async function BooksPage({
{/* Grille de livres */}
{displayBooks.length > 0 ? (
<>
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">Books</h2>}
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">Livres</h2>}
<BooksGrid books={displayBooks} />
{!searchQuery && (
@@ -184,7 +184,7 @@ export default async function BooksPage({
)}
</>
) : (
<EmptyState message={searchQuery ? `No books found for "${searchQuery}"` : "No books available"} />
<EmptyState message={searchQuery ? `Aucun livre trouvé pour "${searchQuery}"` : "Aucun livre disponible"} />
)}
</>
);