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:
17
apps/backoffice/app/api/metadata/batch/report/route.ts
Normal file
17
apps/backoffice/app/api/metadata/batch/report/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch, MetadataBatchReportDto } from "@/lib/api";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||
}
|
||||
const data = await apiFetch<MetadataBatchReportDto>(`/metadata/batch/${id}/report`);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch report";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
19
apps/backoffice/app/api/metadata/batch/results/route.ts
Normal file
19
apps/backoffice/app/api/metadata/batch/results/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch, MetadataBatchResultDto } from "@/lib/api";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||
}
|
||||
const status = searchParams.get("status") || "";
|
||||
const params = status ? `?status=${status}` : "";
|
||||
const data = await apiFetch<MetadataBatchResultDto[]>(`/metadata/batch/${id}/results${params}`);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch results";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
apps/backoffice/app/api/metadata/batch/route.ts
Normal file
16
apps/backoffice/app/api/metadata/batch/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch<{ id: string; status: string }>("/metadata/batch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to start batch";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export default async function BookDetailPage({
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link href="/libraries" className="text-muted-foreground hover:text-primary transition-colors">
|
||||
Libraries
|
||||
Bibliothèques
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
{library && (
|
||||
@@ -88,7 +88,7 @@ export default async function BookDetailPage({
|
||||
<div className="w-48 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.id)}
|
||||
alt={`Cover of ${book.title}`}
|
||||
alt={`Couverture de ${book.title}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
|
||||
@@ -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 "{searchQuery}"
|
||||
{totalHits} résultat{totalHits !== 1 ? 's' : ''} pour « {searchQuery} »
|
||||
</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"} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { apiFetch } from "../../../lib/api";
|
||||
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, MetadataBatchReportDto, MetadataBatchResultDto } from "../../../lib/api";
|
||||
import {
|
||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
||||
@@ -44,28 +44,33 @@ interface JobError {
|
||||
|
||||
const JOB_TYPE_INFO: Record<string, { label: string; description: string; isThumbnailOnly: boolean }> = {
|
||||
rebuild: {
|
||||
label: "Incremental index",
|
||||
description: "Scans for new/modified files, analyzes them and generates missing thumbnails.",
|
||||
label: "Indexation incrémentale",
|
||||
description: "Scanne les fichiers nouveaux/modifiés, les analyse et génère les miniatures manquantes.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
full_rebuild: {
|
||||
label: "Full re-index",
|
||||
description: "Clears all existing data then performs a complete re-scan, re-analysis and thumbnail generation.",
|
||||
label: "Réindexation complète",
|
||||
description: "Supprime toutes les données existantes puis effectue un scan complet, une ré-analyse et la génération des miniatures.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
thumbnail_rebuild: {
|
||||
label: "Thumbnail rebuild",
|
||||
description: "Generates thumbnails only for books that are missing one. Existing thumbnails are preserved.",
|
||||
label: "Reconstruction des miniatures",
|
||||
description: "Génère les miniatures uniquement pour les livres qui n'en ont pas. Les miniatures existantes sont conservées.",
|
||||
isThumbnailOnly: true,
|
||||
},
|
||||
thumbnail_regenerate: {
|
||||
label: "Thumbnail regeneration",
|
||||
description: "Regenerates all thumbnails from scratch, replacing existing ones.",
|
||||
label: "Regénération des miniatures",
|
||||
description: "Regénère toutes les miniatures depuis zéro, en remplaçant les existantes.",
|
||||
isThumbnailOnly: true,
|
||||
},
|
||||
cbr_to_cbz: {
|
||||
label: "CBR → CBZ conversion",
|
||||
description: "Converts a CBR archive to the open CBZ format.",
|
||||
label: "Conversion CBR → CBZ",
|
||||
description: "Convertit une archive CBR au format ouvert CBZ.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
metadata_batch: {
|
||||
label: "Métadonnées en lot",
|
||||
description: "Recherche les métadonnées auprès des fournisseurs externes pour toutes les séries de la bibliothèque et applique automatiquement les correspondances à 100% de confiance.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
};
|
||||
@@ -112,6 +117,18 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isMetadataBatch = job.type === "metadata_batch";
|
||||
|
||||
// Fetch batch report & results for metadata_batch jobs
|
||||
let batchReport: MetadataBatchReportDto | null = null;
|
||||
let batchResults: MetadataBatchResultDto[] = [];
|
||||
if (isMetadataBatch) {
|
||||
[batchReport, batchResults] = await Promise.all([
|
||||
getMetadataBatchReport(id).catch(() => null),
|
||||
getMetadataBatchResults(id).catch(() => []),
|
||||
]);
|
||||
}
|
||||
|
||||
const typeInfo = JOB_TYPE_INFO[job.type] ?? {
|
||||
label: job.type,
|
||||
description: null,
|
||||
@@ -131,21 +148,25 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
const { isThumbnailOnly } = typeInfo;
|
||||
|
||||
// Which label to use for the progress card
|
||||
const progressTitle = isThumbnailOnly
|
||||
? "Thumbnails"
|
||||
: isExtractingPages
|
||||
? "Phase 2 — Extracting pages"
|
||||
: isThumbnailPhase
|
||||
? "Phase 2 — Thumbnails"
|
||||
: "Phase 1 — Discovery";
|
||||
const progressTitle = isMetadataBatch
|
||||
? "Recherche de métadonnées"
|
||||
: isThumbnailOnly
|
||||
? "Miniatures"
|
||||
: isExtractingPages
|
||||
? "Phase 2 — Extraction des pages"
|
||||
: isThumbnailPhase
|
||||
? "Phase 2 — Miniatures"
|
||||
: "Phase 1 — Découverte";
|
||||
|
||||
const progressDescription = isThumbnailOnly
|
||||
? undefined
|
||||
: isExtractingPages
|
||||
? "Extracting first page from each archive (page count + raw image)"
|
||||
: isThumbnailPhase
|
||||
? "Generating thumbnails for the analyzed books"
|
||||
: "Scanning and indexing files in the library";
|
||||
const progressDescription = isMetadataBatch
|
||||
? "Recherche auprès des fournisseurs externes pour chaque série"
|
||||
: isThumbnailOnly
|
||||
? undefined
|
||||
: isExtractingPages
|
||||
? "Extraction de la première page de chaque archive (nombre de pages + image brute)"
|
||||
: isThumbnailPhase
|
||||
? "Génération des miniatures pour les livres analysés"
|
||||
: "Scan et indexation des fichiers de la bibliothèque";
|
||||
|
||||
// Speed metric: thumbnail count for thumbnail jobs, scanned files for index jobs
|
||||
const speedCount = isThumbnailOnly
|
||||
@@ -166,9 +187,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to jobs
|
||||
Retour aux tâches
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
|
||||
<h1 className="text-3xl font-bold text-foreground mt-2">Détails de la tâche</h1>
|
||||
</div>
|
||||
|
||||
{/* Summary banner — completed */}
|
||||
@@ -178,19 +199,24 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-success">
|
||||
<span className="font-semibold">Completed in {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
{job.stats_json && (
|
||||
<span className="font-semibold">Terminé en {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
{isMetadataBatch && batchReport && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {job.stats_json.scanned_files} scanned, {job.stats_json.indexed_files} indexed
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} removed`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} warnings`}
|
||||
{job.stats_json.errors > 0 && `, ${job.stats_json.errors} errors`}
|
||||
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} thumbnails`}
|
||||
— {batchReport.auto_matched} auto-associées, {batchReport.already_linked} déjà liées, {batchReport.no_results} aucun résultat, {batchReport.errors} erreurs
|
||||
</span>
|
||||
)}
|
||||
{!job.stats_json && isThumbnailOnly && job.total_files != null && (
|
||||
{!isMetadataBatch && job.stats_json && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {job.processed_files ?? job.total_files} thumbnails generated
|
||||
— {job.stats_json.scanned_files} scannés, {job.stats_json.indexed_files} indexés
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} supprimés`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} avertissements`}
|
||||
{job.stats_json.errors > 0 && `, ${job.stats_json.errors} erreurs`}
|
||||
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} miniatures`}
|
||||
</span>
|
||||
)}
|
||||
{!isMetadataBatch && !job.stats_json && isThumbnailOnly && job.total_files != null && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {job.processed_files ?? job.total_files} miniatures générées
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -204,9 +230,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-destructive">
|
||||
<span className="font-semibold">Job failed</span>
|
||||
<span className="font-semibold">Tâche échouée</span>
|
||||
{job.started_at && (
|
||||
<span className="ml-2 text-destructive/80">after {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
<span className="ml-2 text-destructive/80">après {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
)}
|
||||
{job.error_opt && (
|
||||
<p className="mt-1 text-destructive/70 font-mono text-xs break-all">{job.error_opt}</p>
|
||||
@@ -222,9 +248,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="font-semibold">Cancelled</span>
|
||||
<span className="font-semibold">Annulé</span>
|
||||
{job.started_at && (
|
||||
<span className="ml-2">after {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
<span className="ml-2">après {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -234,7 +260,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{/* Overview Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Overview</CardTitle>
|
||||
<CardTitle>Aperçu</CardTitle>
|
||||
{typeInfo.description && (
|
||||
<CardDescription>{typeInfo.description}</CardDescription>
|
||||
)}
|
||||
@@ -252,16 +278,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||
<span className="text-sm text-muted-foreground">Status</span>
|
||||
<span className="text-sm text-muted-foreground">Statut</span>
|
||||
<StatusBadge status={job.status} />
|
||||
</div>
|
||||
<div className={`flex items-center justify-between py-2 ${(job.book_id || job.started_at) ? "border-b border-border/60" : ""}`}>
|
||||
<span className="text-sm text-muted-foreground">Library</span>
|
||||
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
|
||||
<span className="text-sm text-muted-foreground">Bibliothèque</span>
|
||||
<span className="text-sm text-foreground">{job.library_id || "Toutes les bibliothèques"}</span>
|
||||
</div>
|
||||
{job.book_id && (
|
||||
<div className={`flex items-center justify-between py-2 ${job.started_at ? "border-b border-border/60" : ""}`}>
|
||||
<span className="text-sm text-muted-foreground">Book</span>
|
||||
<span className="text-sm text-muted-foreground">Livre</span>
|
||||
<Link
|
||||
href={`/books/${job.book_id}`}
|
||||
className="text-sm text-primary hover:text-primary/80 font-mono hover:underline"
|
||||
@@ -272,7 +298,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
)}
|
||||
{job.started_at && (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-muted-foreground">Duration</span>
|
||||
<span className="text-sm text-muted-foreground">Durée</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatDuration(job.started_at, job.finished_at)}
|
||||
</span>
|
||||
@@ -284,7 +310,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{/* Timeline Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeline</CardTitle>
|
||||
<CardTitle>Chronologie</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative">
|
||||
@@ -296,7 +322,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-muted border-2 border-border shrink-0 z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Created</span>
|
||||
<span className="text-sm font-medium text-foreground">Créé</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,15 +332,15 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-primary shrink-0 z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Phase 1 — Discovery</span>
|
||||
<span className="text-sm font-medium text-foreground">Phase 1 — Découverte</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
|
||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||
Duration: {formatDuration(job.started_at, job.phase2_started_at)}
|
||||
Durée : {formatDuration(job.started_at, job.phase2_started_at)}
|
||||
{job.stats_json && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
· {job.stats_json.scanned_files} scanned, {job.stats_json.indexed_files} indexed
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} removed`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} warn`}
|
||||
· {job.stats_json.scanned_files} scannés, {job.stats_json.indexed_files} indexés
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} supprimés`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} avert.`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -329,12 +355,12 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
job.generating_thumbnails_started_at || job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Phase 2a — Extracting pages</span>
|
||||
<span className="text-sm font-medium text-foreground">Phase 2a — Extraction des pages</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.phase2_started_at).toLocaleString()}</p>
|
||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||
Duration: {formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null)}
|
||||
Durée : {formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null)}
|
||||
{!job.generating_thumbnails_started_at && !job.finished_at && isExtractingPages && (
|
||||
<span className="text-muted-foreground font-normal ml-1">· in progress</span>
|
||||
<span className="text-muted-foreground font-normal ml-1">· en cours</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -349,26 +375,26 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{isThumbnailOnly ? "Thumbnails" : "Phase 2b — Generating thumbnails"}
|
||||
{isThumbnailOnly ? "Miniatures" : "Phase 2b — Génération des miniatures"}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(job.generating_thumbnails_started_at ? new Date(job.generating_thumbnails_started_at) : job.phase2_started_at ? new Date(job.phase2_started_at) : null)?.toLocaleString()}
|
||||
</p>
|
||||
{(job.generating_thumbnails_started_at || job.finished_at) && (
|
||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||
Duration: {formatDuration(
|
||||
Durée : {formatDuration(
|
||||
job.generating_thumbnails_started_at ?? job.phase2_started_at!,
|
||||
job.finished_at ?? null
|
||||
)}
|
||||
{job.total_files != null && job.total_files > 0 && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
· {job.processed_files ?? job.total_files} thumbnails
|
||||
· {job.processed_files ?? job.total_files} miniatures
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{!job.finished_at && isThumbnailPhase && (
|
||||
<span className="text-xs text-muted-foreground">in progress</span>
|
||||
<span className="text-xs text-muted-foreground">en cours</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -381,7 +407,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Started</span>
|
||||
<span className="text-sm font-medium text-foreground">Démarré</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,7 +418,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-warning shrink-0 z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Waiting to start…</span>
|
||||
<span className="text-sm font-medium text-foreground">En attente de démarrage…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -405,7 +431,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{isCompleted ? "Completed" : isFailed ? "Failed" : "Cancelled"}
|
||||
{isCompleted ? "Terminé" : isFailed ? "Échoué" : "Annulé"}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.finished_at).toLocaleString()}</p>
|
||||
</div>
|
||||
@@ -430,13 +456,13 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<StatBox
|
||||
value={job.processed_files ?? 0}
|
||||
label={isThumbnailOnly || isPhase2 ? "Generated" : "Processed"}
|
||||
label={isThumbnailOnly || isPhase2 ? "Générés" : "Traités"}
|
||||
variant="primary"
|
||||
/>
|
||||
<StatBox value={job.total_files} label="Total" />
|
||||
<StatBox
|
||||
value={Math.max(0, job.total_files - (job.processed_files ?? 0))}
|
||||
label="Remaining"
|
||||
label="Restants"
|
||||
variant={isCompleted ? "default" : "warning"}
|
||||
/>
|
||||
</div>
|
||||
@@ -444,7 +470,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
)}
|
||||
{job.current_file && (
|
||||
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">Current file</span>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">Fichier en cours</span>
|
||||
<code className="block mt-1 text-xs font-mono text-foreground break-all">{job.current_file}</code>
|
||||
</div>
|
||||
)}
|
||||
@@ -453,10 +479,10 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
)}
|
||||
|
||||
{/* Index Statistics — index jobs only */}
|
||||
{job.stats_json && !isThumbnailOnly && (
|
||||
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Index statistics</CardTitle>
|
||||
<CardTitle>Statistiques d'indexation</CardTitle>
|
||||
{job.started_at && (
|
||||
<CardDescription>
|
||||
{formatDuration(job.started_at, job.finished_at)}
|
||||
@@ -466,11 +492,11 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||
<StatBox value={job.stats_json.scanned_files} label="Scanned" variant="success" />
|
||||
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
|
||||
<StatBox value={job.stats_json.removed_files} label="Removed" variant="warning" />
|
||||
<StatBox value={job.stats_json.warnings ?? 0} label="Warnings" variant={(job.stats_json.warnings ?? 0) > 0 ? "warning" : "default"} />
|
||||
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||
<StatBox value={job.stats_json.scanned_files} label="Scannés" variant="success" />
|
||||
<StatBox value={job.stats_json.indexed_files} label="Indexés" variant="primary" />
|
||||
<StatBox value={job.stats_json.removed_files} label="Supprimés" variant="warning" />
|
||||
<StatBox value={job.stats_json.warnings ?? 0} label="Avertissements" variant={(job.stats_json.warnings ?? 0) > 0 ? "warning" : "default"} />
|
||||
<StatBox value={job.stats_json.errors} label="Erreurs" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -480,7 +506,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{isThumbnailOnly && isCompleted && job.total_files != null && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Thumbnail statistics</CardTitle>
|
||||
<CardTitle>Statistiques des miniatures</CardTitle>
|
||||
{job.started_at && (
|
||||
<CardDescription>
|
||||
{formatDuration(job.started_at, job.finished_at)}
|
||||
@@ -490,19 +516,102 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatBox value={job.processed_files ?? job.total_files} label="Generated" variant="success" />
|
||||
<StatBox value={job.processed_files ?? job.total_files} label="Générés" variant="success" />
|
||||
<StatBox value={job.total_files} label="Total" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Metadata batch report */}
|
||||
{isMetadataBatch && batchReport && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rapport du lot</CardTitle>
|
||||
<CardDescription>{batchReport.total_series} séries analysées</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<StatBox value={batchReport.auto_matched} label="Auto-associé" variant="success" />
|
||||
<StatBox value={batchReport.already_linked} label="Déjà lié" variant="primary" />
|
||||
<StatBox value={batchReport.no_results} label="Aucun résultat" />
|
||||
<StatBox value={batchReport.too_many_results} label="Trop de résultats" variant="warning" />
|
||||
<StatBox value={batchReport.low_confidence} label="Confiance faible" variant="warning" />
|
||||
<StatBox value={batchReport.errors} label="Erreurs" variant={batchReport.errors > 0 ? "error" : "default"} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Metadata batch results */}
|
||||
{isMetadataBatch && batchResults.length > 0 && (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Résultats par série</CardTitle>
|
||||
<CardDescription>{batchResults.length} séries traitées</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{batchResults.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className={`p-3 rounded-lg border ${
|
||||
r.status === "auto_matched" ? "bg-success/10 border-success/20" :
|
||||
r.status === "already_linked" ? "bg-primary/10 border-primary/20" :
|
||||
r.status === "error" ? "bg-destructive/10 border-destructive/20" :
|
||||
"bg-muted/50 border-border/60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm text-foreground truncate">{r.series_name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap ${
|
||||
r.status === "auto_matched" ? "bg-success/20 text-success" :
|
||||
r.status === "already_linked" ? "bg-primary/20 text-primary" :
|
||||
r.status === "no_results" ? "bg-muted text-muted-foreground" :
|
||||
r.status === "too_many_results" ? "bg-amber-500/15 text-amber-600" :
|
||||
r.status === "low_confidence" ? "bg-amber-500/15 text-amber-600" :
|
||||
r.status === "error" ? "bg-destructive/20 text-destructive" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{r.status === "auto_matched" ? "Auto-associé" :
|
||||
r.status === "already_linked" ? "Déjà lié" :
|
||||
r.status === "no_results" ? "Aucun résultat" :
|
||||
r.status === "too_many_results" ? "Trop de résultats" :
|
||||
r.status === "low_confidence" ? "Confiance faible" :
|
||||
r.status === "error" ? "Erreur" :
|
||||
r.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
{r.provider_used && (
|
||||
<span>{r.provider_used}{r.fallback_used ? " (secours)" : ""}</span>
|
||||
)}
|
||||
{r.candidates_count > 0 && (
|
||||
<span>{r.candidates_count} candidat{r.candidates_count > 1 ? "s" : ""}</span>
|
||||
)}
|
||||
{r.best_confidence != null && (
|
||||
<span>{Math.round(r.best_confidence * 100)}% confiance</span>
|
||||
)}
|
||||
</div>
|
||||
{r.best_candidate_json && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Correspondance : {(r.best_candidate_json as { title?: string }).title || r.best_candidate_json.toString()}
|
||||
</p>
|
||||
)}
|
||||
{r.error_message && (
|
||||
<p className="text-xs text-destructive/80 mt-1">{r.error_message}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* File errors */}
|
||||
{errors.length > 0 && (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>File errors ({errors.length})</CardTitle>
|
||||
<CardDescription>Errors encountered while processing individual files</CardDescription>
|
||||
<CardTitle>Erreurs de fichiers ({errors.length})</CardTitle>
|
||||
<CardDescription>Erreurs rencontrées lors du traitement des fichiers</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{errors.map((error) => (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, IndexJobDto, LibraryDto } from "../../lib/api";
|
||||
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, IndexJobDto, LibraryDto } from "../../lib/api";
|
||||
import { JobsList } from "../components/JobsList";
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, FormField, FormSelect, FormRow } from "../components/ui";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormSelect, FormRow } from "../components/ui";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -47,6 +47,15 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
redirect(`/jobs?highlight=${result.id}`);
|
||||
}
|
||||
|
||||
async function triggerMetadataBatch(formData: FormData) {
|
||||
"use server";
|
||||
const libraryId = formData.get("library_id") as string;
|
||||
if (!libraryId) return;
|
||||
const result = await startMetadataBatch(libraryId);
|
||||
revalidatePath("/jobs");
|
||||
redirect(`/jobs?highlight=${result.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
@@ -54,20 +63,21 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Index Jobs
|
||||
Tâches d'indexation
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Queue New Job</CardTitle>
|
||||
<CardTitle>Lancer une tâche</CardTitle>
|
||||
<CardDescription>Sélectionnez une bibliothèque (ou toutes) et choisissez l'action à effectuer.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<FormRow>
|
||||
<FormField className="flex-1 max-w-xs">
|
||||
<FormSelect name="library_id" defaultValue="">
|
||||
<option value="">All libraries</option>
|
||||
<option value="">Toutes les bibliothèques</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>{lib.name}</option>
|
||||
))}
|
||||
@@ -78,25 +88,31 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Rebuild
|
||||
Reconstruction
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerFullRebuild} variant="warning">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Full Rebuild
|
||||
Reconstruction complète
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerThumbnailsRebuild} variant="secondary">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Generate thumbnails
|
||||
Générer les miniatures
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerThumbnailsRegenerate} variant="warning">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Regenerate thumbnails
|
||||
Regénérer les miniatures
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerMetadataBatch} variant="secondary">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Métadonnées en lot
|
||||
</Button>
|
||||
</div>
|
||||
</FormRow>
|
||||
@@ -104,6 +120,82 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Job types legend */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Référence des types de tâches</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Reconstruction</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Scan incrémental : détecte les fichiers ajoutés, modifiés ou supprimés depuis le dernier scan, les indexe et génère les miniatures manquantes. Les données existantes non modifiées sont conservées. C’est l’action la plus courante et la plus rapide.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Reconstruction complète</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Supprime toutes les données indexées (livres, séries, miniatures) puis effectue un scan complet depuis zéro. Utile si la base de données est désynchronisée ou corrompue. Opération longue et destructive : les statuts de lecture et les métadonnées manuelles seront perdus.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Générer les miniatures</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Génère les miniatures uniquement pour les livres qui n’en ont pas encore. Les miniatures existantes ne sont pas touchées. Utile après un import ou si certaines miniatures sont manquantes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Regénérer les miniatures</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Regénère toutes les miniatures depuis zéro, en remplaçant les existantes. Utile si la qualité ou la taille des miniatures a changé dans la configuration, ou si des miniatures sont corrompues.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Métadonnées en lot</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Recherche automatiquement les métadonnées de chaque série de la bibliothèque auprès du provider configuré (avec fallback si configuré). Seuls les résultats avec un match unique à 100% de confiance sont appliqués automatiquement. Les séries déjà liées sont ignorées. Un rapport détaillé par série est disponible à la fin du job. <strong>Requiert une bibliothèque spécifique</strong> (ne fonctionne pas sur « Toutes les bibliothèques »).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<JobsList
|
||||
initialJobs={jobs}
|
||||
libraries={libraryMap}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { MobileNav } from "./components/MobileNav";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "StripStream Backoffice",
|
||||
description: "Backoffice administration for StripStream Librarian"
|
||||
description: "Administration backoffice pour StripStream Librarian"
|
||||
};
|
||||
|
||||
type NavItem = {
|
||||
@@ -21,17 +21,17 @@ type NavItem = {
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/", label: "Dashboard", icon: "dashboard" },
|
||||
{ href: "/books", label: "Books", icon: "books" },
|
||||
{ href: "/series", label: "Series", icon: "series" },
|
||||
{ href: "/libraries", label: "Libraries", icon: "libraries" },
|
||||
{ href: "/jobs", label: "Jobs", icon: "jobs" },
|
||||
{ href: "/tokens", label: "Tokens", icon: "tokens" },
|
||||
{ href: "/", label: "Tableau de bord", icon: "dashboard" },
|
||||
{ href: "/books", label: "Livres", icon: "books" },
|
||||
{ href: "/series", label: "Séries", icon: "series" },
|
||||
{ href: "/libraries", label: "Bibliothèques", icon: "libraries" },
|
||||
{ href: "/jobs", label: "Tâches", icon: "jobs" },
|
||||
{ href: "/tokens", label: "Jetons", icon: "tokens" },
|
||||
];
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang="fr" suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
|
||||
<ThemeProvider>
|
||||
{/* Header avec effet glassmorphism */}
|
||||
@@ -76,7 +76,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<Link
|
||||
href="/settings"
|
||||
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
title="Settings"
|
||||
title="Paramètres"
|
||||
>
|
||||
<Icon name="settings" size="md" />
|
||||
</Link>
|
||||
|
||||
@@ -38,14 +38,14 @@ export default async function LibraryBooksPage({
|
||||
coverUrl: getBookCoverUrl(book.id)
|
||||
}));
|
||||
|
||||
const seriesDisplayName = series === "unclassified" ? "Unclassified" : series;
|
||||
const seriesDisplayName = series === "unclassified" ? "Non classé" : series;
|
||||
const totalPages = Math.ceil(booksPage.total / limit);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<LibrarySubPageHeader
|
||||
library={library}
|
||||
title={series ? `Books in "${seriesDisplayName}"` : "All Books"}
|
||||
title={series ? `Livres de "${seriesDisplayName}"` : "Tous les livres"}
|
||||
icon={
|
||||
<svg className="w-8 h-8" 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" />
|
||||
@@ -53,9 +53,9 @@ export default async function LibraryBooksPage({
|
||||
}
|
||||
iconColor="text-success"
|
||||
filterInfo={series ? {
|
||||
label: `Showing books from series "${seriesDisplayName}"`,
|
||||
label: `Livres de la série "${seriesDisplayName}"`,
|
||||
clearHref: `/libraries/${id}/books`,
|
||||
clearLabel: "View all books"
|
||||
clearLabel: "Voir tous les livres"
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
@@ -71,7 +71,7 @@ export default async function LibraryBooksPage({
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState message={series ? `No books in series "${seriesDisplayName}"` : "No books in this library yet"} />
|
||||
<EmptyState message={series ? `Aucun livre dans la série "${seriesDisplayName}"` : "Aucun livre dans cette bibliothèque"} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -55,7 +55,7 @@ export default async function SeriesDetailPage({
|
||||
|
||||
const totalPages = Math.ceil(booksPage.total / limit);
|
||||
const booksReadCount = booksPage.items.filter((b) => b.reading_status === "read").length;
|
||||
const displayName = seriesName === "unclassified" ? "Non classifié" : seriesName;
|
||||
const displayName = seriesName === "unclassified" ? "Non classé" : seriesName;
|
||||
|
||||
// Use first book cover as series cover
|
||||
const coverBookId = booksPage.items[0]?.id;
|
||||
@@ -68,7 +68,7 @@ export default async function SeriesDetailPage({
|
||||
href="/libraries"
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
Libraries
|
||||
Bibliothèques
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<Link
|
||||
@@ -88,7 +88,7 @@ export default async function SeriesDetailPage({
|
||||
<div className="w-40 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
|
||||
<Image
|
||||
src={getBookCoverUrl(coverBookId)}
|
||||
alt={`Cover of ${displayName}`}
|
||||
alt={`Couverture de ${displayName}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
|
||||
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
|
||||
import { OffsetPagination } from "../../../components/ui";
|
||||
import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton";
|
||||
import { SeriesFilters } from "../../../components/SeriesFilters";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -19,10 +20,13 @@ export default async function LibrarySeriesPage({
|
||||
const searchParamsAwaited = await searchParams;
|
||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||
const seriesStatus = typeof searchParamsAwaited.series_status === "string" ? searchParamsAwaited.series_status : undefined;
|
||||
const hasMissing = searchParamsAwaited.has_missing === "true";
|
||||
|
||||
const [library, seriesPage] = await Promise.all([
|
||||
const [library, seriesPage, dbStatuses] = await Promise.all([
|
||||
fetchLibraries().then(libs => libs.find(l => l.id === id)),
|
||||
fetchSeries(id, page, limit).catch(() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto)
|
||||
fetchSeries(id, page, limit, seriesStatus, hasMissing).catch(() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto),
|
||||
fetchSeriesStatuses().catch(() => [] as string[]),
|
||||
]);
|
||||
|
||||
if (!library) {
|
||||
@@ -32,11 +36,23 @@ export default async function LibrarySeriesPage({
|
||||
const series = seriesPage.items;
|
||||
const totalPages = Math.ceil(seriesPage.total / limit);
|
||||
|
||||
const KNOWN_STATUSES: Record<string, string> = {
|
||||
ongoing: "En cours",
|
||||
ended: "Terminée",
|
||||
hiatus: "Hiatus",
|
||||
cancelled: "Annulée",
|
||||
upcoming: "À paraître",
|
||||
};
|
||||
const seriesStatusOptions = [
|
||||
{ value: "", label: "Tous les statuts" },
|
||||
...dbStatuses.map((s) => ({ value: s, label: KNOWN_STATUSES[s] || s })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<LibrarySubPageHeader
|
||||
library={library}
|
||||
title="Series"
|
||||
title="Séries"
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
@@ -45,6 +61,13 @@ export default async function LibrarySeriesPage({
|
||||
iconColor="text-primary"
|
||||
/>
|
||||
|
||||
<SeriesFilters
|
||||
basePath={`/libraries/${id}/series`}
|
||||
currentSeriesStatus={seriesStatus}
|
||||
currentHasMissing={hasMissing}
|
||||
seriesStatusOptions={seriesStatusOptions}
|
||||
/>
|
||||
|
||||
{series.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
||||
@@ -58,7 +81,7 @@ export default async function LibrarySeriesPage({
|
||||
<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
|
||||
@@ -66,7 +89,7 @@ export default async function LibrarySeriesPage({
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<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>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -78,6 +101,29 @@ export default async function LibrarySeriesPage({
|
||||
booksReadCount={s.books_read_count}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
|
||||
{s.series_status && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||
s.series_status === "ongoing" ? "bg-blue-500/15 text-blue-600" :
|
||||
s.series_status === "ended" ? "bg-green-500/15 text-green-600" :
|
||||
s.series_status === "hiatus" ? "bg-amber-500/15 text-amber-600" :
|
||||
s.series_status === "cancelled" ? "bg-red-500/15 text-red-600" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{s.series_status === "ongoing" ? "En cours" :
|
||||
s.series_status === "ended" ? "Terminée" :
|
||||
s.series_status === "hiatus" ? "Hiatus" :
|
||||
s.series_status === "cancelled" ? "Annulée" :
|
||||
s.series_status === "upcoming" ? "À paraître" :
|
||||
s.series_status}
|
||||
</span>
|
||||
)}
|
||||
{s.missing_count != null && s.missing_count > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-yellow-500/15 text-yellow-600">
|
||||
{s.missing_count} manquant{s.missing_count > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -93,7 +139,7 @@ export default async function LibrarySeriesPage({
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No series found in this library</p>
|
||||
<p>Aucune série trouvée dans cette bibliothèque</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import Link from "next/link";
|
||||
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, LibraryDto, FolderItem } from "../../lib/api";
|
||||
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, startMetadataBatch, LibraryDto, FolderItem } from "../../lib/api";
|
||||
import { LibraryActions } from "../components/LibraryActions";
|
||||
import { LibraryForm } from "../components/LibraryForm";
|
||||
import {
|
||||
@@ -16,7 +16,7 @@ function formatNextScan(nextScanAt: string | null): string {
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 0) return "Due now";
|
||||
if (diff < 0) return "Imminent";
|
||||
if (diff < 60000) return "< 1 min";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
|
||||
@@ -75,6 +75,14 @@ export default async function LibrariesPage() {
|
||||
revalidatePath("/jobs");
|
||||
}
|
||||
|
||||
async function batchMetadataAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await startMetadataBatch(id);
|
||||
revalidatePath("/libraries");
|
||||
revalidatePath("/jobs");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
@@ -82,15 +90,15 @@ export default async function LibrariesPage() {
|
||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
Libraries
|
||||
Bibliothèques
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Add Library Form */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Add New Library</CardTitle>
|
||||
<CardDescription>Create a new library from an existing folder</CardDescription>
|
||||
<CardTitle>Ajouter une bibliothèque</CardTitle>
|
||||
<CardDescription>Créer une nouvelle bibliothèque à partir d'un dossier existant</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LibraryForm initialFolders={folders} action={addLibrary} />
|
||||
@@ -107,7 +115,7 @@ export default async function LibrariesPage() {
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{lib.name}</CardTitle>
|
||||
{!lib.enabled && <Badge variant="muted" className="mt-1">Disabled</Badge>}
|
||||
{!lib.enabled && <Badge variant="muted" className="mt-1">Désactivée</Badge>}
|
||||
</div>
|
||||
<LibraryActions
|
||||
libraryId={lib.id}
|
||||
@@ -115,6 +123,7 @@ export default async function LibrariesPage() {
|
||||
scanMode={lib.scan_mode}
|
||||
watcherEnabled={lib.watcher_enabled}
|
||||
metadataProvider={lib.metadata_provider}
|
||||
fallbackMetadataProvider={lib.fallback_metadata_provider}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -129,28 +138,28 @@ export default async function LibrariesPage() {
|
||||
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||
>
|
||||
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
|
||||
<span className="text-xs text-muted-foreground">Books</span>
|
||||
<span className="text-xs text-muted-foreground">Livres</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/libraries/${lib.id}/series`}
|
||||
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||
>
|
||||
<span className="block text-2xl font-bold text-foreground">{seriesCount}</span>
|
||||
<span className="text-xs text-muted-foreground">Series</span>
|
||||
<span className="text-xs text-muted-foreground">Séries</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-3 mb-4 text-sm">
|
||||
<span className={`flex items-center gap-1 ${lib.monitor_enabled ? 'text-success' : 'text-muted-foreground'}`}>
|
||||
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? 'Auto' : 'Manual'}
|
||||
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? 'Auto' : 'Manuel'}
|
||||
</span>
|
||||
{lib.watcher_enabled && (
|
||||
<span className="text-warning" title="File watcher active">⚡</span>
|
||||
<span className="text-warning" title="Surveillance de fichiers active">⚡</span>
|
||||
)}
|
||||
{lib.monitor_enabled && lib.next_scan_at && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
Next: {formatNextScan(lib.next_scan_at)}
|
||||
Prochain : {formatNextScan(lib.next_scan_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -163,7 +172,7 @@ export default async function LibrariesPage() {
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Index
|
||||
Indexer
|
||||
</Button>
|
||||
</form>
|
||||
<form className="flex-1">
|
||||
@@ -172,9 +181,19 @@ export default async function LibrariesPage() {
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Full
|
||||
Complet
|
||||
</Button>
|
||||
</form>
|
||||
{lib.metadata_provider !== "none" && (
|
||||
<form>
|
||||
<input type="hidden" name="id" value={lib.id} />
|
||||
<Button type="submit" variant="secondary" size="sm" formAction={batchMetadataAction} title="Métadonnées en lot">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<form>
|
||||
<input type="hidden" name="id" value={lib.id} />
|
||||
<Button type="submit" variant="destructive" size="sm" formAction={removeLibrary}>
|
||||
|
||||
@@ -20,7 +20,7 @@ function formatNumber(n: number): string {
|
||||
// Donut chart via SVG
|
||||
function DonutChart({ data, colors }: { data: { label: string; value: number; color: string }[]; colors?: string[] }) {
|
||||
const total = data.reduce((sum, d) => sum + d.value, 0);
|
||||
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">No data</p>;
|
||||
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">Aucune donnée</p>;
|
||||
|
||||
const radius = 40;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
@@ -70,7 +70,7 @@ function DonutChart({ data, colors }: { data: { label: string; value: number; co
|
||||
// Bar chart via pure CSS
|
||||
function BarChart({ data, color = "var(--color-primary)" }: { data: { label: string; value: number }[]; color?: string }) {
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">No data</p>;
|
||||
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">Aucune donnée</p>;
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-1.5 h-40">
|
||||
@@ -126,7 +126,7 @@ export default async function DashboardPage() {
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-4 text-foreground">StripStream Backoffice</h1>
|
||||
<p className="text-lg text-muted-foreground">Unable to load statistics. Make sure the API is running.</p>
|
||||
<p className="text-lg text-muted-foreground">Impossible de charger les statistiques. Vérifiez que l'API est en cours d'exécution.</p>
|
||||
</div>
|
||||
<QuickLinks />
|
||||
</div>
|
||||
@@ -152,21 +152,21 @@ export default async function DashboardPage() {
|
||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Dashboard
|
||||
Tableau de bord
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2 max-w-2xl">
|
||||
Overview of your comic collection. Manage your libraries, track your reading progress, and explore your books and series.
|
||||
Aperçu de votre collection de bandes dessinées. Gérez vos bibliothèques, suivez votre progression de lecture et explorez vos livres et séries.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Overview stat cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<StatCard icon="book" label="Books" value={formatNumber(overview.total_books)} color="success" />
|
||||
<StatCard icon="series" label="Series" value={formatNumber(overview.total_series)} color="primary" />
|
||||
<StatCard icon="library" label="Libraries" value={formatNumber(overview.total_libraries)} color="warning" />
|
||||
<StatCard icon="book" label="Livres" value={formatNumber(overview.total_books)} color="success" />
|
||||
<StatCard icon="series" label="Séries" value={formatNumber(overview.total_series)} color="primary" />
|
||||
<StatCard icon="library" label="Bibliothèques" value={formatNumber(overview.total_libraries)} color="warning" />
|
||||
<StatCard icon="pages" label="Pages" value={formatNumber(overview.total_pages)} color="primary" />
|
||||
<StatCard icon="author" label="Authors" value={formatNumber(overview.total_authors)} color="success" />
|
||||
<StatCard icon="size" label="Total Size" value={formatBytes(overview.total_size_bytes)} color="warning" />
|
||||
<StatCard icon="author" label="Auteurs" value={formatNumber(overview.total_authors)} color="success" />
|
||||
<StatCard icon="size" label="Taille totale" value={formatBytes(overview.total_size_bytes)} color="warning" />
|
||||
</div>
|
||||
|
||||
{/* Charts row */}
|
||||
@@ -174,14 +174,14 @@ export default async function DashboardPage() {
|
||||
{/* Reading status donut */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Reading Status</CardTitle>
|
||||
<CardTitle className="text-base">Statut de lecture</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DonutChart
|
||||
data={[
|
||||
{ label: "Unread", value: reading_status.unread, color: readingColors[0] },
|
||||
{ label: "In Progress", value: reading_status.reading, color: readingColors[1] },
|
||||
{ label: "Read", value: reading_status.read, color: readingColors[2] },
|
||||
{ label: "Non lu", value: reading_status.unread, color: readingColors[0] },
|
||||
{ label: "En cours", value: reading_status.reading, color: readingColors[1] },
|
||||
{ label: "Lu", value: reading_status.read, color: readingColors[2] },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -190,12 +190,12 @@ export default async function DashboardPage() {
|
||||
{/* By format donut */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">By Format</CardTitle>
|
||||
<CardTitle className="text-base">Par format</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DonutChart
|
||||
data={by_format.slice(0, 6).map((f, i) => ({
|
||||
label: (f.format || "Unknown").toUpperCase(),
|
||||
label: (f.format || "Inconnu").toUpperCase(),
|
||||
value: f.count,
|
||||
color: formatColors[i % formatColors.length],
|
||||
}))}
|
||||
@@ -206,7 +206,7 @@ export default async function DashboardPage() {
|
||||
{/* By library donut */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">By Library</CardTitle>
|
||||
<CardTitle className="text-base">Par bibliothèque</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DonutChart
|
||||
@@ -225,7 +225,7 @@ export default async function DashboardPage() {
|
||||
{/* Monthly additions bar chart */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Books Added (Last 12 Months)</CardTitle>
|
||||
<CardTitle className="text-base">Livres ajoutés (12 derniers mois)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChart
|
||||
@@ -241,7 +241,7 @@ export default async function DashboardPage() {
|
||||
{/* Top series */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Top Series</CardTitle>
|
||||
<CardTitle className="text-base">Séries populaires</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
@@ -251,12 +251,12 @@ export default async function DashboardPage() {
|
||||
label={s.series}
|
||||
value={s.book_count}
|
||||
max={top_series[0]?.book_count || 1}
|
||||
subLabel={`${s.read_count}/${s.book_count} read`}
|
||||
subLabel={`${s.read_count}/${s.book_count} lu`}
|
||||
color="hsl(142 60% 45%)"
|
||||
/>
|
||||
))}
|
||||
{top_series.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">No series yet</p>
|
||||
<p className="text-muted-foreground text-sm text-center py-4">Aucune série pour le moment</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -267,7 +267,7 @@ export default async function DashboardPage() {
|
||||
{by_library.length > 0 && (
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Libraries</CardTitle>
|
||||
<CardTitle className="text-base">Bibliothèques</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
|
||||
@@ -281,23 +281,23 @@ export default async function DashboardPage() {
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${(lib.read_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(142 60% 45%)" }}
|
||||
title={`Read: ${lib.read_count}`}
|
||||
title={`Lu : ${lib.read_count}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${(lib.reading_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(45 93% 47%)" }}
|
||||
title={`In progress: ${lib.reading_count}`}
|
||||
title={`En cours : ${lib.reading_count}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${(lib.unread_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(220 13% 70%)" }}
|
||||
title={`Unread: ${lib.unread_count}`}
|
||||
title={`Non lu : ${lib.unread_count}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{lib.book_count} books</span>
|
||||
<span className="text-success">{lib.read_count} read</span>
|
||||
<span className="text-warning">{lib.reading_count} in progress</span>
|
||||
<span>{lib.book_count} livres</span>
|
||||
<span className="text-success">{lib.read_count} lu</span>
|
||||
<span className="text-warning">{lib.reading_count} en cours</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -347,10 +347,10 @@ function StatCard({ icon, label, value, color }: { icon: string; label: string;
|
||||
|
||||
function QuickLinks() {
|
||||
const links = [
|
||||
{ href: "/libraries", label: "Libraries", bg: "bg-primary/10", text: "text-primary", hoverBg: "group-hover:bg-primary", hoverText: "group-hover:text-primary-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> },
|
||||
{ href: "/books", label: "Books", bg: "bg-success/10", text: "text-success", hoverBg: "group-hover:bg-success", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> },
|
||||
{ href: "/series", label: "Series", bg: "bg-warning/10", text: "text-warning", hoverBg: "group-hover:bg-warning", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> },
|
||||
{ href: "/jobs", label: "Jobs", bg: "bg-destructive/10", text: "text-destructive", hoverBg: "group-hover:bg-destructive", hoverText: "group-hover:text-destructive-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> },
|
||||
{ href: "/libraries", label: "Bibliothèques", bg: "bg-primary/10", text: "text-primary", hoverBg: "group-hover:bg-primary", hoverText: "group-hover:text-primary-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> },
|
||||
{ href: "/books", label: "Livres", bg: "bg-success/10", text: "text-success", hoverBg: "group-hover:bg-success", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> },
|
||||
{ href: "/series", label: "Séries", bg: "bg-warning/10", text: "text-warning", hoverBg: "group-hover:bg-warning", hoverText: "group-hover:text-white", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> },
|
||||
{ href: "/jobs", label: "Tâches", bg: "bg-destructive/10", text: "text-destructive", hoverBg: "group-hover:bg-destructive", hoverText: "group-hover:text-destructive-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchAllSeries, fetchLibraries, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
|
||||
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
|
||||
import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton";
|
||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||
@@ -17,35 +17,55 @@ export default async function SeriesPage({
|
||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
||||
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
||||
const seriesStatus = typeof searchParamsAwaited.series_status === "string" ? searchParamsAwaited.series_status : undefined;
|
||||
const hasMissing = searchParamsAwaited.has_missing === "true";
|
||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||
|
||||
const [libraries, seriesPage] = await Promise.all([
|
||||
const [libraries, seriesPage, dbStatuses] = await Promise.all([
|
||||
fetchLibraries().catch(() => [] as LibraryDto[]),
|
||||
fetchAllSeries(libraryId, searchQuery || undefined, readingStatus, page, limit, sort).catch(
|
||||
fetchAllSeries(libraryId, searchQuery || undefined, readingStatus, page, limit, sort, seriesStatus, hasMissing).catch(
|
||||
() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto
|
||||
),
|
||||
fetchSeriesStatuses().catch(() => [] as string[]),
|
||||
]);
|
||||
|
||||
const series = seriesPage.items;
|
||||
const totalPages = Math.ceil(seriesPage.total / limit);
|
||||
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;
|
||||
const hasFilters = searchQuery || libraryId || readingStatus || sort || seriesStatus || hasMissing;
|
||||
|
||||
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 KNOWN_STATUSES: Record<string, string> = {
|
||||
ongoing: "En cours",
|
||||
ended: "Terminée",
|
||||
hiatus: "Hiatus",
|
||||
cancelled: "Annulée",
|
||||
upcoming: "À paraître",
|
||||
};
|
||||
const seriesStatusOptions = [
|
||||
{ value: "", label: "Tous les statuts" },
|
||||
...dbStatuses.map((s) => ({ value: s, label: KNOWN_STATUSES[s] || s })),
|
||||
];
|
||||
|
||||
const missingOptions = [
|
||||
{ value: "", label: "Tous" },
|
||||
{ value: "true", label: "Livres manquants" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -55,7 +75,7 @@ export default async function SeriesPage({
|
||||
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
Series
|
||||
Séries
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -64,10 +84,12 @@ export default async function SeriesPage({
|
||||
<LiveSearchForm
|
||||
basePath="/series"
|
||||
fields={[
|
||||
{ name: "q", type: "text", label: "Search", placeholder: "Search by series name...", 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 nom de 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: "Lecture", options: statusOptions, className: "w-full sm:w-36" },
|
||||
{ name: "series_status", type: "select", label: "Statut", options: seriesStatusOptions, className: "w-full sm:w-36" },
|
||||
{ name: "has_missing", type: "select", label: "Manquant", options: missingOptions, className: "w-full sm:w-36" },
|
||||
{ name: "sort", type: "select", label: "Tri", options: sortOptions, className: "w-full sm:w-36" },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -75,8 +97,8 @@ export default async function SeriesPage({
|
||||
|
||||
{/* Results count */}
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{seriesPage.total} series
|
||||
{searchQuery && <> matching "{searchQuery}"</>}
|
||||
{seriesPage.total} séries
|
||||
{searchQuery && <> correspondant à "{searchQuery}"</>}
|
||||
</p>
|
||||
|
||||
{/* Series Grid */}
|
||||
@@ -97,7 +119,7 @@ export default async function SeriesPage({
|
||||
<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
|
||||
@@ -105,7 +127,7 @@ export default async function SeriesPage({
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<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>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -117,6 +139,29 @@ export default async function SeriesPage({
|
||||
booksReadCount={s.books_read_count}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
|
||||
{s.series_status && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||
s.series_status === "ongoing" ? "bg-blue-500/15 text-blue-600" :
|
||||
s.series_status === "ended" ? "bg-green-500/15 text-green-600" :
|
||||
s.series_status === "hiatus" ? "bg-amber-500/15 text-amber-600" :
|
||||
s.series_status === "cancelled" ? "bg-red-500/15 text-red-600" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{s.series_status === "ongoing" ? "En cours" :
|
||||
s.series_status === "ended" ? "Terminée" :
|
||||
s.series_status === "hiatus" ? "Hiatus" :
|
||||
s.series_status === "cancelled" ? "Annulée" :
|
||||
s.series_status === "upcoming" ? "À paraître" :
|
||||
s.series_status}
|
||||
</span>
|
||||
)}
|
||||
{s.missing_count != null && s.missing_count > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-yellow-500/15 text-yellow-600">
|
||||
{s.missing_count} manquant{s.missing_count > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -138,7 +183,7 @@ export default async function SeriesPage({
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{hasFilters ? "No series found matching your filters" : "No series available"}
|
||||
{hasFilters ? "Aucune série trouvée correspondant à vos filtres" : "Aucune série disponible"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -55,13 +55,13 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
body: JSON.stringify({ value })
|
||||
});
|
||||
if (response.ok) {
|
||||
setSaveMessage("Settings saved successfully");
|
||||
setSaveMessage("Paramètres enregistrés avec succès");
|
||||
setTimeout(() => setSaveMessage(null), 3000);
|
||||
} else {
|
||||
setSaveMessage("Failed to save settings");
|
||||
setSaveMessage("Échec de l'enregistrement des paramètres");
|
||||
}
|
||||
} catch (error) {
|
||||
setSaveMessage("Error saving settings");
|
||||
setSaveMessage("Erreur lors de l'enregistrement des paramètres");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
setCacheStats(stats);
|
||||
}
|
||||
} catch (error) {
|
||||
setClearResult({ success: false, message: "Failed to clear cache" });
|
||||
setClearResult({ success: false, message: "Échec du vidage du cache" });
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
@@ -150,8 +150,8 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
const [activeTab, setActiveTab] = useState<"general" | "integrations">("general");
|
||||
|
||||
const tabs = [
|
||||
{ id: "general" as const, label: "General", icon: "settings" as const },
|
||||
{ id: "integrations" as const, label: "Integrations", icon: "refresh" as const },
|
||||
{ id: "general" as const, label: "Général", icon: "settings" as const },
|
||||
{ id: "integrations" as const, label: "Intégrations", icon: "refresh" as const },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -159,7 +159,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||
<Icon name="settings" size="xl" />
|
||||
Settings
|
||||
Paramètres
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -195,15 +195,15 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="image" size="md" />
|
||||
Image Processing
|
||||
Traitement d'images
|
||||
</CardTitle>
|
||||
<CardDescription>These settings only apply when a client explicitly requests format conversion via the API (e.g. <code className="text-xs bg-muted px-1 rounded">?format=webp&width=800</code>). Pages served without parameters are delivered as-is from the archive, with no processing.</CardDescription>
|
||||
<CardDescription>Ces paramètres s'appliquent uniquement lorsqu'un client demande explicitement une conversion de format via l'API (ex. <code className="text-xs bg-muted px-1 rounded">?format=webp&width=800</code>). Les pages servies sans paramètres sont livrées telles quelles depuis l'archive, sans traitement.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Default Output Format</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Format de sortie par défaut</label>
|
||||
<FormSelect
|
||||
value={settings.image_processing.format}
|
||||
onChange={(e) => {
|
||||
@@ -218,7 +218,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Default Quality (1-100)</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Qualité par défaut (1-100)</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -235,7 +235,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Default Resize Filter</label>
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">Filtre de redimensionnement par défaut</label>
|
||||
<FormSelect
|
||||
value={settings.image_processing.filter}
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -45,15 +45,15 @@ export default async function TokensPage({
|
||||
<svg className="w-8 h-8 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
API Tokens
|
||||
Jetons API
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{params.created ? (
|
||||
<Card className="mb-6 border-success/50 bg-success/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-success">Token Created</CardTitle>
|
||||
<CardDescription>Copy it now, it won't be shown again</CardDescription>
|
||||
<CardTitle className="text-success">Jeton créé</CardTitle>
|
||||
<CardDescription>Copiez-le maintenant, il ne sera plus affiché</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="p-4 bg-background rounded-lg text-sm font-mono text-foreground overflow-x-auto border">{params.created}</pre>
|
||||
@@ -63,22 +63,22 @@ export default async function TokensPage({
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Create New Token</CardTitle>
|
||||
<CardDescription>Generate a new API token with the desired scope</CardDescription>
|
||||
<CardTitle>Créer un nouveau jeton</CardTitle>
|
||||
<CardDescription>Générer un nouveau jeton API avec la portée souhaitée</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={createTokenAction}>
|
||||
<FormRow>
|
||||
<FormField className="flex-1 min-w-48">
|
||||
<FormInput name="name" placeholder="Token name" required />
|
||||
<FormInput name="name" placeholder="Nom du jeton" required />
|
||||
</FormField>
|
||||
<FormField className="w-32">
|
||||
<FormSelect name="scope" defaultValue="read">
|
||||
<option value="read">Read</option>
|
||||
<option value="read">Lecture</option>
|
||||
<option value="admin">Admin</option>
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
<Button type="submit">Create Token</Button>
|
||||
<Button type="submit">Créer le jeton</Button>
|
||||
</FormRow>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -89,10 +89,10 @@ export default async function TokensPage({
|
||||
<table className="w-full">
|
||||
<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">Name</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Scope</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Prefix</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">Nom</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Portée</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Préfixe</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">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -110,9 +110,9 @@ export default async function TokensPage({
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{token.revoked_at ? (
|
||||
<Badge variant="error">Revoked</Badge>
|
||||
<Badge variant="error">Révoqué</Badge>
|
||||
) : (
|
||||
<Badge variant="success">Active</Badge>
|
||||
<Badge variant="success">Actif</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -123,7 +123,7 @@ export default async function TokensPage({
|
||||
<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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Revoke
|
||||
Révoquer
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
@@ -133,7 +133,7 @@ export default async function TokensPage({
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
Supprimer
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user