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:
@@ -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}`}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 dé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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -120,7 +120,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
||||
w-full sm:w-auto
|
||||
"
|
||||
>
|
||||
Clear
|
||||
Effacer
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -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 "{seriesName}"...</span>
|
||||
<span className="ml-3 text-muted-foreground">Recherche de "{seriesName}"...</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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
|
||||
|
||||
51
apps/backoffice/app/components/SeriesFilters.tsx
Normal file
51
apps/backoffice/app/components/SeriesFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user