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

@@ -71,7 +71,7 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
<div className="relative">
<BookImage
src={coverUrl}
alt={`Cover of ${book.title}`}
alt={`Couverture de ${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}`}>

View File

@@ -16,7 +16,7 @@ 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">
Preview
Aperçu
<span className="ml-2 text-sm font-normal text-muted-foreground">
pages {offset + 1}{Math.min(offset + PAGE_SIZE, pageCount)} / {pageCount}
</span>
@@ -27,14 +27,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"
>
Prev
Préc.
</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"
>
Next
Suiv.
</button>
</div>
</div>

View File

@@ -23,22 +23,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 || "Conversion failed" });
setState({ type: "error", message: body.error || "Échec de la conversion" });
return;
}
const job = await res.json();
setState({ type: "success", jobId: job.id });
} catch (err) {
setState({ type: "error", message: err instanceof Error ? err.message : "Unknown error" });
setState({ type: "error", message: err instanceof Error ? err.message : "Erreur inconnue" });
}
};
if (state.type === "success") {
return (
<div className="flex items-center gap-2 text-sm text-success">
<span>Conversion started.</span>
<span>Conversion lancée.</span>
<Link href={`/jobs/${state.jobId}`} className="text-primary hover:underline font-medium">
View job
Voir la tâche
</Link>
</div>
);
@@ -52,7 +52,7 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
className="text-xs text-muted-foreground hover:underline text-left"
onClick={() => setState({ type: "idle" })}
>
Dismiss
Fermer
</button>
</div>
);
@@ -65,7 +65,7 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
onClick={handleConvert}
disabled={state.type === "loading"}
>
{state.type === "loading" ? "Converting…" : "Convert to CBZ"}
{state.type === "loading" ? "Conversion…" : "Convertir en CBZ"}
</Button>
);
}

View File

@@ -173,7 +173,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">
No folders found
Aucun dossier trouvé
</div>
) : (
tree.map(node => renderNode(node))

View File

@@ -27,7 +27,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
<input
type="text"
readOnly
value={selectedPath || "Select a folder..."}
value={selectedPath || "Sélectionner un dossier..."}
className={`
w-full px-3 py-2 rounded-lg border bg-card
text-sm font-mono
@@ -57,7 +57,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>
Browse
Parcourir
</Button>
</div>
@@ -79,7 +79,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">Select Folder</span>
<span className="font-medium">Sélectionner le dossier</span>
</div>
<button
type="button"
@@ -104,7 +104,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">
Click a folder to select it
Cliquez sur un dossier pour le sélectionner
</span>
<div className="flex gap-2">
<Button
@@ -113,7 +113,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
size="sm"
onClick={() => setIsOpen(false)}
>
Cancel
Annuler
</Button>
</div>
</div>

View File

@@ -53,14 +53,14 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
onComplete?.();
}
} catch (err) {
setError("Failed to parse SSE data");
setError("Échec de l'analyse des données SSE");
}
};
eventSource.onerror = (err) => {
console.error("SSE error:", err);
eventSource.close();
setError("Connection lost");
setError("Connexion perdue");
};
return () => {
@@ -71,7 +71,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
if (error) {
return (
<div className="p-4 bg-destructive/10 text-error rounded-lg text-sm">
Error: {error}
Erreur : {error}
</div>
);
}
@@ -79,7 +79,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
if (!progress) {
return (
<div className="p-4 text-muted-foreground text-sm">
Loading progress...
Chargement de la progression...
</div>
);
}
@@ -88,14 +88,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" ? "thumbnails" : "files";
const unitLabel = progress.status === "extracting_pages" ? "pages" : progress.status === "generating_thumbnails" ? "miniatures" : "fichiers";
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">Complete</Badge>
<Badge variant="success">Terminé</Badge>
)}
</div>
@@ -105,7 +105,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
<span>{processed} / {total} {unitLabel}</span>
{progress.current_file && (
<span className="truncate max-w-md" title={progress.current_file}>
Current: {progress.current_file.length > 40
En cours : {progress.current_file.length > 40
? progress.current_file.substring(0, 40) + "..."
: progress.current_file}
</span>
@@ -114,11 +114,11 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
{progress.stats_json && !isPhase2 && (
<div className="flex flex-wrap gap-3 text-xs">
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>
<Badge variant="warning">Removed: {progress.stats_json.removed_files}</Badge>
<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>
{progress.stats_json.errors > 0 && (
<Badge variant="error">Errors: {progress.stats_json.errors}</Badge>
<Badge variant="error">Erreurs : {progress.stats_json.errors}</Badge>
)}
</div>
)}

View File

@@ -63,12 +63,12 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
? job.total_files != null
? `${job.processed_files ?? 0}/${job.total_files}`
: scanned > 0
? `${scanned} scanned`
? `${scanned} analysés`
: "-"
: job.status === "success" && (indexed > 0 || removed > 0 || errors > 0)
? null // rendered below as ✓ / / ⚠
: scanned > 0
? `${scanned} scanned`
? `${scanned} analysés`
: "—";
// Thumbnails column (Phase 2: extracting_pages + generating_thumbnails)
@@ -113,7 +113,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 ? "Hide" : "Show"} progress
{showProgress ? "Masquer" : "Afficher"} la progression
</button>
)}
</div>
@@ -154,7 +154,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"
>
View
Voir
</Link>
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
<Button
@@ -162,7 +162,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
size="sm"
onClick={() => onCancel(job.id)}
>
Cancel
Annuler
</Button>
)}
</div>

View File

@@ -152,7 +152,7 @@ export function JobsIndicator() {
hover:bg-accent
transition-colors duration-200
"
title="View all jobs"
title="Voir toutes les tâches"
>
<JobsIcon className="w-[18px] h-[18px]" />
</Link>
@@ -187,11 +187,11 @@ export function JobsIndicator() {
<div className="flex items-center gap-3">
<span className="text-xl">📊</span>
<div>
<h3 className="font-semibold text-foreground">Active Jobs</h3>
<h3 className="font-semibold text-foreground">Tâches actives</h3>
<p className="text-xs text-muted-foreground">
{runningJobs.length > 0
? `${runningJobs.length} running, ${pendingJobs.length} pending`
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
? `${runningJobs.length} en cours, ${pendingJobs.length} en attente`
: `${pendingJobs.length} tâche${pendingJobs.length !== 1 ? 's' : ''} en attente`
}
</p>
</div>
@@ -201,7 +201,7 @@ export function JobsIndicator() {
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
onClick={() => setIsOpen(false)}
>
View All
Tout voir
</Link>
</div>
@@ -209,7 +209,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">Overall Progress</span>
<span className="text-muted-foreground">Progression globale</span>
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
</div>
<ProgressBar value={totalProgress} size="sm" variant="success" />
@@ -221,7 +221,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>No active jobs</p>
<p>Aucune tâche active</p>
</div>
) : (
<ul className="divide-y divide-border/60">
@@ -242,7 +242,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' ? 'Thumbnails' : job.type === 'thumbnail_regenerate' ? 'Regenerate' : job.type}
{job.type === 'thumbnail_rebuild' ? 'Miniatures' : job.type === 'thumbnail_regenerate' ? 'Regénération' : job.type}
</Badge>
</div>
@@ -281,7 +281,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">Auto-refreshing every 2s</p>
<p className="text-xs text-muted-foreground text-center">Actualisation automatique toutes les 2s</p>
</div>
</div>
</>
@@ -304,7 +304,7 @@ export function JobsIndicator() {
${isOpen ? 'ring-2 ring-ring ring-offset-2 ring-offset-background' : ''}
`}
onClick={() => setIsOpen(!isOpen)}
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
title={`${totalCount} tâche${totalCount !== 1 ? 's' : ''} active${totalCount !== 1 ? 's' : ''}`}
>
{/* Animated spinner for running jobs */}
{runningJobs.length > 0 && (

View File

@@ -46,12 +46,12 @@ function formatDate(dateStr: string): string {
if (diff < 3600000) {
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
return `${mins}m ago`;
if (mins < 1) return "À l'instant";
return `il y a ${mins}m`;
}
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours}h ago`;
return `il y a ${hours}h`;
}
return date.toLocaleDateString();
}
@@ -103,13 +103,13 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
<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">Library</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">Status</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Files</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Thumbnails</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Duration</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Created</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>
</tr>
</thead>

View File

@@ -10,6 +10,7 @@ interface LibraryActionsProps {
scanMode: string;
watcherEnabled: boolean;
metadataProvider: string | null;
fallbackMetadataProvider: string | null;
onUpdate?: () => void;
}
@@ -19,6 +20,7 @@ export function LibraryActions({
scanMode,
watcherEnabled,
metadataProvider,
fallbackMetadataProvider,
onUpdate
}: LibraryActionsProps) {
const [isOpen, setIsOpen] = useState(false);
@@ -43,6 +45,7 @@ export function LibraryActions({
const watcherEnabled = formData.get("watcher_enabled") === "true";
const scanMode = formData.get("scan_mode") as string;
const newMetadataProvider = (formData.get("metadata_provider") as string) || null;
const newFallbackProvider = (formData.get("fallback_metadata_provider") as string) || null;
try {
const [response] = await Promise.all([
@@ -58,7 +61,7 @@ export function LibraryActions({
fetch(`/api/libraries/${libraryId}/metadata-provider`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ metadata_provider: newMetadataProvider }),
body: JSON.stringify({ metadata_provider: newMetadataProvider, fallback_metadata_provider: newFallbackProvider }),
}),
]);
@@ -106,7 +109,7 @@ export function LibraryActions({
defaultChecked={monitorEnabled}
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
/>
Auto Scan
Scan auto
</label>
</div>
@@ -119,35 +122,55 @@ export function LibraryActions({
defaultChecked={watcherEnabled}
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
/>
File Watcher
Surveillance fichiers
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">📅 Schedule</label>
<label className="text-sm font-medium text-foreground">📅 Planification</label>
<select
name="scan_mode"
defaultValue={scanMode}
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
>
<option value="manual">Manual</option>
<option value="hourly">Hourly</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="manual">Manuel</option>
<option value="hourly">Toutes les heures</option>
<option value="daily">Quotidien</option>
<option value="weekly">Hebdomadaire</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} />}
Metadata Provider
Fournisseur
</label>
<select
name="metadata_provider"
defaultValue={metadataProvider || ""}
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
>
<option value="">Default</option>
<option value="">Par faut</option>
<option value="none">Aucun</option>
<option value="google_books">Google Books</option>
<option value="comicvine">ComicVine</option>
<option value="open_library">Open Library</option>
<option value="anilist">AniList</option>
<option value="bedetheque">Bédéthèque</option>
</select>
</div>
<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
</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="google_books">Google Books</option>
<option value="comicvine">ComicVine</option>
<option value="open_library">Open Library</option>
@@ -168,7 +191,7 @@ export function LibraryActions({
className="w-full"
disabled={isPending}
>
{isPending ? "Saving..." : "Save Settings"}
{isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</div>
</form>

View File

@@ -17,7 +17,7 @@ export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
<form action={action}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="name" placeholder="Library name" required />
<FormInput name="name" placeholder="Nom de la bibliothèque" required />
</FormField>
<FormField className="flex-1 min-w-64">
<input type="hidden" name="root_path" value={selectedPath} />
@@ -30,7 +30,7 @@ export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
</FormRow>
<div className="mt-4 flex justify-end">
<Button type="submit" disabled={!selectedPath}>
Add Library
Ajouter une bibliothèque
</Button>
</div>
</form>

View File

@@ -38,7 +38,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>
Libraries
Bibliothèques
</Link>
<span className="text-muted-foreground">/</span>
<span className="text-sm text-foreground font-medium">{library.name}</span>
@@ -74,7 +74,7 @@ export function LibrarySubPageHeader({
</svg>
<span className="text-foreground">
<span className="font-semibold">{library.book_count}</span>
<span className="text-muted-foreground ml-1">book{library.book_count !== 1 ? 's' : ''}</span>
<span className="text-muted-foreground ml-1">livre{library.book_count !== 1 ? 's' : ''}</span>
</span>
</div>
@@ -86,7 +86,7 @@ export function LibrarySubPageHeader({
variant={library.enabled ? "success" : "muted"}
className="text-xs"
>
{library.enabled ? "Enabled" : "Disabled"}
{library.enabled ? "Activée" : "Désactivée"}
</Badge>
</div>
</div>

View File

@@ -120,7 +120,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
w-full sm:w-auto
"
>
Clear
Effacer
</button>
)}
</form>

View File

@@ -62,6 +62,23 @@ export function MetadataSearchModal({
// Provider selector: empty string = library default
const [searchProvider, setSearchProvider] = useState("");
const [activeProvider, setActiveProvider] = useState("");
const [hiddenProviders, setHiddenProviders] = useState<Set<string>>(new Set());
// Fetch metadata provider settings to hide providers without required API keys
useEffect(() => {
fetch("/api/settings/metadata_providers")
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!data) return;
const hidden = new Set<string>();
// ComicVine requires an API key
if (!data.comicvine?.api_key) hidden.add("comicvine");
setHiddenProviders(hidden);
})
.catch(() => {});
}, []);
const visibleProviders = PROVIDERS.filter((p) => !hiddenProviders.has(p.value));
const handleOpen = useCallback(() => {
setIsOpen(true);
@@ -109,7 +126,7 @@ export function MetadataSearchModal({
});
const data = await resp.json();
if (!resp.ok) {
setError(data.error || "Search failed");
setError(data.error || "Échec de la recherche");
setStep("results");
return;
}
@@ -121,7 +138,7 @@ export function MetadataSearchModal({
}
setStep("results");
} catch {
setError("Network error");
setError("Erreur réseau");
setStep("results");
}
}
@@ -160,7 +177,7 @@ export function MetadataSearchModal({
});
const matchData = await matchResp.json();
if (!matchResp.ok) {
setError(matchData.error || "Failed to create match");
setError(matchData.error || "Échec de la création du lien");
setStep("results");
return;
}
@@ -179,7 +196,7 @@ export function MetadataSearchModal({
});
const approveData = await approveResp.json();
if (!approveResp.ok) {
setError(approveData.error || "Failed to approve");
setError(approveData.error || "Échec de l'approbation");
setStep("results");
return;
}
@@ -201,7 +218,7 @@ export function MetadataSearchModal({
setStep("done");
} catch {
setError("Network error");
setError("Erreur réseau");
setStep("results");
}
}
@@ -245,7 +262,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" ? "Metadata Link" : "Search External Metadata"}
{step === "linked" ? "Lien métadonnées" : "Rechercher les métadonnées externes"}
</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">
@@ -258,9 +275,9 @@ 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">Provider :</label>
<label className="text-sm text-muted-foreground whitespace-nowrap">Fournisseur :</label>
<div className="flex gap-1 flex-wrap">
{PROVIDERS.map((p) => (
{visibleProviders.map((p) => (
<button
key={p.value}
type="button"
@@ -287,7 +304,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">Searching for &quot;{seriesName}&quot;...</span>
<span className="ml-3 text-muted-foreground">Recherche de &quot;{seriesName}&quot;...</span>
</div>
)}
@@ -302,11 +319,11 @@ export function MetadataSearchModal({
{step === "results" && (
<>
{candidates.length === 0 && !error ? (
<p className="text-muted-foreground text-center py-8">No results found.</p>
<p className="text-muted-foreground text-center py-8">Aucun résultat trouvé.</p>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground mb-2">
{candidates.length} result{candidates.length !== 1 ? "s" : ""} found
{candidates.length} résultat{candidates.length !== 1 ? "s" : ""} trouvé{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>
)}
@@ -387,7 +404,7 @@ export function MetadataSearchModal({
</div>
</div>
<p className="text-sm text-foreground font-medium">How would you like to sync?</p>
<p className="text-sm text-foreground font-medium">Comment souhaitez-vous synchroniser ?</p>
<div className="flex flex-col gap-2">
<button
@@ -395,16 +412,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">Sync series metadata only</p>
<p className="text-xs text-muted-foreground">Update description, authors, publishers, and year</p>
<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>
</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">Sync series + books</p>
<p className="text-xs text-muted-foreground">Also fetch book list and show missing volumes</p>
<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>
</button>
</div>
@@ -413,7 +430,7 @@ export function MetadataSearchModal({
onClick={() => { setSelectedCandidate(null); setStep("results"); }}
className="text-sm text-muted-foreground hover:text-foreground"
>
Back to results
Retour aux résultats
</button>
</div>
)}
@@ -422,7 +439,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">Syncing metadata...</span>
<span className="ml-3 text-muted-foreground">Synchronisation des métadonnées...</span>
</div>
)}
@@ -430,7 +447,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">Metadata synced successfully!</p>
<p className="font-medium text-green-600">Métadonnées synchronisées avec succès !</p>
</div>
{/* Sync Report */}
@@ -461,7 +478,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">locked</span>
<span className="text-muted-foreground">verrouillé</span>
</div>
))}
</div>
@@ -480,7 +497,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} matched{syncReport.books_unmatched > 0 && `, ${syncReport.books_unmatched} unmatched`}
Livres {syncReport.books_matched} associé{syncReport.books_matched !== 1 ? "s" : ""}{syncReport.books_unmatched > 0 && `, ${syncReport.books_unmatched} non associé${syncReport.books_unmatched !== 1 ? "s" : ""}`}
</p>
{syncReport.books.length > 0 && (
<div className="space-y-2 max-h-48 overflow-y-auto">
@@ -503,7 +520,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">locked</span>
<span className="text-muted-foreground">verrouillé</span>
</p>
))}
</div>
@@ -521,15 +538,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">External</p>
<p className="text-sm text-muted-foreground">Externe</p>
<p className="text-2xl font-semibold">{missing.total_external}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Local</p>
<p className="text-sm text-muted-foreground">Locaux</p>
<p className="text-2xl font-semibold">{missing.total_local}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Missing</p>
<p className="text-sm text-muted-foreground">Manquants</p>
<p className="text-2xl font-semibold text-warning">{missing.missing_count}</p>
</div>
</div>
@@ -542,14 +559,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} missing book{missing.missing_count !== 1 ? "s" : ""}
{missing.missing_count} livre{missing.missing_count !== 1 ? "s" : ""} manquant{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 || "Unknown"}
{b.title || "Inconnu"}
</p>
))}
</div>
@@ -564,7 +581,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"
>
Close
Fermer
</button>
</div>
)}
@@ -576,7 +593,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">
Linked to <ProviderIcon provider={existingLink.provider} size={16} /> {providerLabel(existingLink.provider)}
Lié à <ProviderIcon provider={existingLink.provider} size={16} /> {providerLabel(existingLink.provider)}
</p>
{existingLink.external_url && (
<a
@@ -585,7 +602,7 @@ export function MetadataSearchModal({
rel="noopener noreferrer"
className="block mt-1 text-xs text-primary hover:underline"
>
View on external source
Voir sur la source externe
</a>
)}
</div>
@@ -618,14 +635,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} missing book{initialMissing.missing_count !== 1 ? "s" : ""}
{initialMissing.missing_count} livre{initialMissing.missing_count !== 1 ? "s" : ""} manquant{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 || "Unknown"}
{b.title || "Inconnu"}
</p>
))}
</div>
@@ -639,14 +656,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"
>
Search again
Rechercher à nouveau
</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"
>
Unlink
Dissocier
</button>
</div>
</div>
@@ -666,13 +683,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" ? "Metadata" : "Search metadata"}
{existingLink && existingLink.status === "approved" ? "Métadonnées" : "Rechercher les métadonnées"}
</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} missing
{initialMissing.missing_count} manquant{initialMissing.missing_count !== 1 ? "s" : ""}
</span>
)}

View File

@@ -76,7 +76,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
onClick={() => setIsOpen(false)}
>
<NavIcon name="settings" />
<span className="font-medium">Settings</span>
<span className="font-medium">Paramètres</span>
</Link>
</div>
</nav>
@@ -90,7 +90,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 ? "Close menu" : "Open menu"}
aria-label={isOpen ? "Fermer le menu" : "Ouvrir le menu"}
aria-expanded={isOpen}
>
{isOpen ? <XIcon /> : <HamburgerIcon />}

View File

@@ -67,7 +67,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="Real-time file watcher"></span>
<span title="Surveillance des fichiers en temps réel"></span>
</label>
<select
@@ -76,10 +76,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">Manual</option>
<option value="hourly">Hourly</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="manual">Manuel</option>
<option value="hourly">Toutes les heures</option>
<option value="daily">Quotidien</option>
<option value="weekly">Hebdomadaire</option>
</select>
<button

View File

@@ -0,0 +1,51 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
interface SeriesFiltersProps {
basePath: string;
currentSeriesStatus?: string;
currentHasMissing: boolean;
seriesStatusOptions: { value: string; label: string }[];
}
export function SeriesFilters({ basePath, currentSeriesStatus, currentHasMissing, seriesStatusOptions }: SeriesFiltersProps) {
const router = useRouter();
const searchParams = useSearchParams();
const updateFilter = useCallback((key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
params.delete("page");
const qs = params.toString();
router.push(`${basePath}${qs ? `?${qs}` : ""}` as any);
}, [router, searchParams, basePath]);
return (
<div className="flex flex-wrap gap-3">
<select
value={currentSeriesStatus || ""}
onChange={(e) => updateFilter("series_status", e.target.value)}
className="px-3 py-2 rounded-lg border border-border bg-card text-foreground text-sm"
>
{seriesStatusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<select
value={currentHasMissing ? "true" : ""}
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>
</select>
</div>
);
}

View File

@@ -71,8 +71,8 @@ const statusVariants: Record<string, BadgeVariant> = {
};
const statusLabels: Record<string, string> = {
extracting_pages: "Extracting pages",
generating_thumbnails: "Thumbnails",
extracting_pages: "Extraction des pages",
generating_thumbnails: "Miniatures",
};
interface StatusBadgeProps {
@@ -96,10 +96,10 @@ const jobTypeVariants: Record<string, BadgeVariant> = {
};
const jobTypeLabels: Record<string, string> = {
rebuild: "Index",
full_rebuild: "Full Index",
thumbnail_rebuild: "Thumbnails",
thumbnail_regenerate: "Regen. Thumbnails",
rebuild: "Indexation",
full_rebuild: "Indexation complète",
thumbnail_rebuild: "Miniatures",
thumbnail_regenerate: "Régén. miniatures",
cbr_to_cbz: "CBR → CBZ",
};

View File

@@ -48,7 +48,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">Show</span>
<span className="text-sm text-muted-foreground">Afficher</span>
<select
value={pageSize.toString()}
onChange={(e) => changePageSize(Number(e.target.value))}
@@ -60,12 +60,12 @@ export function CursorPagination({
</option>
))}
</select>
<span className="text-sm text-muted-foreground">per page</span>
<span className="text-sm text-muted-foreground">par page</span>
</div>
{/* Count info */}
<div className="text-sm text-muted-foreground">
Showing {currentCount} items
Affichage de {currentCount} éléments
</div>
{/* Navigation */}
@@ -79,7 +79,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>
First
Premier
</Button>
<Button
@@ -88,7 +88,7 @@ export function CursorPagination({
onClick={goToNext}
disabled={!hasNextPage}
>
Next
Suivant
<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>
@@ -170,7 +170,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">Show</span>
<span className="text-sm text-muted-foreground">Afficher</span>
<select
value={pageSize.toString()}
onChange={(e) => changePageSize(Number(e.target.value))}
@@ -182,12 +182,12 @@ export function OffsetPagination({
</option>
))}
</select>
<span className="text-sm text-muted-foreground">per page</span>
<span className="text-sm text-muted-foreground">par page</span>
</div>
{/* Page info */}
<div className="text-sm text-muted-foreground">
{startItem}-{endItem} of {totalItems}
{startItem}-{endItem} sur {totalItems}
</div>
{/* Page navigation */}
@@ -196,7 +196,7 @@ export function OffsetPagination({
size="sm"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
title="Previous page"
title="Page précédente"
>
<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 +224,7 @@ export function OffsetPagination({
size="sm"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages}
title="Next page"
title="Page suivante"
>
<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" />