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

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

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

View File

@@ -312,6 +312,8 @@ pub struct SeriesItem {
pub first_book_id: Uuid,
#[schema(value_type = String)]
pub library_id: Uuid,
pub series_status: Option<String>,
pub missing_count: Option<i64>,
}
#[derive(Serialize, ToSchema)]
@@ -328,6 +330,12 @@ pub struct ListSeriesQuery {
pub q: Option<String>,
#[schema(value_type = Option<String>, example = "unread,reading")]
pub reading_status: Option<String>,
/// Filter by series status (e.g. "ongoing", "ended")
#[schema(value_type = Option<String>, example = "ongoing")]
pub series_status: Option<String>,
/// Filter series with missing books: "true" to show only series with missing books
#[schema(value_type = Option<String>, example = "true")]
pub has_missing: Option<String>,
#[schema(value_type = Option<i64>, example = 1)]
pub page: Option<i64>,
#[schema(value_type = Option<i64>, example = 50)]
@@ -371,6 +379,8 @@ pub async fn list_series(
ELSE 'reading'
END"#;
let has_missing = query.has_missing.as_deref() == Some("true");
// Paramètres dynamiques — $1 = library_id fixe, puis optionnels dans l'ordre
let mut p: usize = 1;
@@ -382,7 +392,27 @@ pub async fn list_series(
p += 1; format!("AND {series_status_expr} = ANY(${p})")
} else { String::new() };
// q_cond et count_rs_cond partagent le même p — le count_sql les réutilise directement
let ss_cond = if query.series_status.is_some() {
p += 1; format!("AND sm.status = ${p}")
} else { String::new() };
let missing_cond = if has_missing {
"AND mc.missing_count > 0".to_string()
} else { String::new() };
let missing_cte = format!(
r#"
missing_counts AS (
SELECT eml.series_name,
COUNT(ebm.id) FILTER (WHERE ebm.book_id IS NULL) as missing_count
FROM external_metadata_links eml
JOIN external_book_metadata ebm ON ebm.link_id = eml.id
WHERE eml.library_id = $1 AND eml.status = 'approved'
GROUP BY eml.series_name
)
"#
);
let count_sql = format!(
r#"
WITH sorted_books AS (
@@ -396,12 +426,15 @@ pub async fn list_series(
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
GROUP BY sb.name
)
SELECT COUNT(*) FROM series_counts sc WHERE TRUE {q_cond} {count_rs_cond}
),
{missing_cte}
SELECT COUNT(*) FROM series_counts sc
LEFT JOIN series_metadata sm ON sm.library_id = $1 AND sm.name = sc.name
LEFT JOIN missing_counts mc ON mc.series_name = sc.name
WHERE TRUE {q_cond} {count_rs_cond} {ss_cond} {missing_cond}
"#
);
// DATA: mêmes params dans le même ordre, puis limit/offset à la fin
let limit_p = p + 1;
let offset_p = p + 2;
@@ -430,17 +463,24 @@ pub async fn list_series(
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
GROUP BY sb.name
)
),
{missing_cte}
SELECT
sc.name,
sc.book_count,
sc.books_read_count,
sb.id as first_book_id
sb.id as first_book_id,
sm.status as series_status,
mc.missing_count
FROM series_counts sc
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
LEFT JOIN series_metadata sm ON sm.library_id = $1 AND sm.name = sc.name
LEFT JOIN missing_counts mc ON mc.series_name = sc.name
WHERE TRUE
{q_cond}
{count_rs_cond}
{ss_cond}
{missing_cond}
ORDER BY
REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''),
COALESCE(
@@ -465,6 +505,10 @@ pub async fn list_series(
count_builder = count_builder.bind(statuses.clone());
data_builder = data_builder.bind(statuses.clone());
}
if let Some(ref ss) = query.series_status {
count_builder = count_builder.bind(ss);
data_builder = data_builder.bind(ss);
}
data_builder = data_builder.bind(limit).bind(offset);
@@ -474,7 +518,7 @@ pub async fn list_series(
)?;
let total: i64 = count_row.get(0);
let mut items: Vec<SeriesItem> = rows
let items: Vec<SeriesItem> = rows
.iter()
.map(|row| SeriesItem {
name: row.get("name"),
@@ -482,11 +526,13 @@ pub async fn list_series(
books_read_count: row.get("books_read_count"),
first_book_id: row.get("first_book_id"),
library_id,
series_status: row.get("series_status"),
missing_count: row.get("missing_count"),
})
.collect();
Ok(Json(SeriesPage {
items: std::mem::take(&mut items),
items,
total,
page,
limit,
@@ -501,6 +547,12 @@ pub struct ListAllSeriesQuery {
pub library_id: Option<Uuid>,
#[schema(value_type = Option<String>, example = "unread,reading")]
pub reading_status: Option<String>,
/// Filter by series status (e.g. "ongoing", "ended")
#[schema(value_type = Option<String>, example = "ongoing")]
pub series_status: Option<String>,
/// Filter series with missing books: "true" to show only series with missing books
#[schema(value_type = Option<String>, example = "true")]
pub has_missing: Option<String>,
#[schema(value_type = Option<i64>, example = 1)]
pub page: Option<i64>,
#[schema(value_type = Option<i64>, example = 50)]
@@ -547,6 +599,8 @@ pub async fn list_all_series(
ELSE 'reading'
END"#;
let has_missing = query.has_missing.as_deref() == Some("true");
let mut p: usize = 0;
let lib_cond = if query.library_id.is_some() {
@@ -563,21 +617,60 @@ pub async fn list_all_series(
p += 1; format!("AND {series_status_expr} = ANY(${p})")
} else { String::new() };
let ss_cond = if query.series_status.is_some() {
p += 1; format!("AND sm.status = ${p}")
} else { String::new() };
let missing_cond = if has_missing {
"AND mc.missing_count > 0".to_string()
} else { String::new() };
// Missing counts CTE — needs library_id filter when filtering by library
let missing_cte = if query.library_id.is_some() {
format!(
r#"
missing_counts AS (
SELECT eml.series_name, eml.library_id,
COUNT(ebm.id) FILTER (WHERE ebm.book_id IS NULL) as missing_count
FROM external_metadata_links eml
JOIN external_book_metadata ebm ON ebm.link_id = eml.id
WHERE eml.library_id = $1 AND eml.status = 'approved'
GROUP BY eml.series_name, eml.library_id
)
"#
)
} else {
r#"
missing_counts AS (
SELECT eml.series_name, eml.library_id,
COUNT(ebm.id) FILTER (WHERE ebm.book_id IS NULL) as missing_count
FROM external_metadata_links eml
JOIN external_book_metadata ebm ON ebm.link_id = eml.id
WHERE eml.status = 'approved'
GROUP BY eml.series_name, eml.library_id
)
"#.to_string()
};
let count_sql = format!(
r#"
WITH sorted_books AS (
SELECT COALESCE(NULLIF(series, ''), 'unclassified') as name, id
SELECT COALESCE(NULLIF(series, ''), 'unclassified') as name, id, library_id
FROM books {lib_cond}
),
series_counts AS (
SELECT sb.name,
SELECT sb.name, sb.library_id,
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
GROUP BY sb.name
)
SELECT COUNT(*) FROM series_counts sc WHERE TRUE {q_cond} {rs_cond}
GROUP BY sb.name, sb.library_id
),
{missing_cte}
SELECT COUNT(*) FROM series_counts sc
LEFT JOIN series_metadata sm ON sm.library_id = sc.library_id AND sm.name = sc.name
LEFT JOIN missing_counts mc ON mc.series_name = sc.name AND mc.library_id = sc.library_id
WHERE TRUE {q_cond} {rs_cond} {ss_cond} {missing_cond}
"#
);
@@ -612,24 +705,32 @@ pub async fn list_all_series(
series_counts AS (
SELECT
sb.name,
sb.library_id,
COUNT(*) as book_count,
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count,
MAX(sb.updated_at) as latest_updated_at
FROM sorted_books sb
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
GROUP BY sb.name
)
GROUP BY sb.name, sb.library_id
),
{missing_cte}
SELECT
sc.name,
sc.book_count,
sc.books_read_count,
sb.id as first_book_id,
sb.library_id
sb.library_id,
sm.status as series_status,
mc.missing_count
FROM series_counts sc
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
LEFT JOIN series_metadata sm ON sm.library_id = sc.library_id AND sm.name = sc.name
LEFT JOIN missing_counts mc ON mc.series_name = sc.name AND mc.library_id = sc.library_id
WHERE TRUE
{q_cond}
{rs_cond}
{ss_cond}
{missing_cond}
ORDER BY {series_order_clause}
LIMIT ${limit_p} OFFSET ${offset_p}
"#
@@ -652,6 +753,10 @@ pub async fn list_all_series(
count_builder = count_builder.bind(statuses.clone());
data_builder = data_builder.bind(statuses.clone());
}
if let Some(ref ss) = query.series_status {
count_builder = count_builder.bind(ss);
data_builder = data_builder.bind(ss);
}
data_builder = data_builder.bind(limit).bind(offset);
@@ -669,6 +774,8 @@ pub async fn list_all_series(
books_read_count: row.get("books_read_count"),
first_book_id: row.get("first_book_id"),
library_id: row.get("library_id"),
series_status: row.get("series_status"),
missing_count: row.get("missing_count"),
})
.collect();
@@ -680,6 +787,28 @@ pub async fn list_all_series(
}))
}
/// List all distinct series status values present in the database
#[utoipa::path(
get,
path = "/series/statuses",
tag = "books",
responses(
(status = 200, body = Vec<String>),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn series_statuses(
State(state): State<AppState>,
) -> Result<Json<Vec<String>>, ApiError> {
let rows: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT status FROM series_metadata WHERE status IS NOT NULL ORDER BY status",
)
.fetch_all(&state.pool)
.await?;
Ok(Json(rows))
}
#[derive(Deserialize, ToSchema)]
pub struct OngoingQuery {
#[schema(value_type = Option<i64>, example = 10)]
@@ -756,6 +885,8 @@ pub async fn ongoing_series(
books_read_count: row.get("books_read_count"),
first_book_id: row.get("first_book_id"),
library_id: row.get("library_id"),
series_status: None,
missing_count: None,
})
.collect();

View File

@@ -22,6 +22,7 @@ pub struct LibraryResponse {
pub next_scan_at: Option<chrono::DateTime<chrono::Utc>>,
pub watcher_enabled: bool,
pub metadata_provider: Option<String>,
pub fallback_metadata_provider: Option<String>,
}
#[derive(Deserialize, ToSchema)]
@@ -46,7 +47,7 @@ pub struct CreateLibraryRequest {
)]
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
let rows = sqlx::query(
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled, l.metadata_provider,
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled, l.metadata_provider, l.fallback_metadata_provider,
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count
FROM libraries l ORDER BY l.created_at DESC"
)
@@ -66,6 +67,7 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
next_scan_at: row.get("next_scan_at"),
watcher_enabled: row.get("watcher_enabled"),
metadata_provider: row.get("metadata_provider"),
fallback_metadata_provider: row.get("fallback_metadata_provider"),
})
.collect();
@@ -118,6 +120,7 @@ pub async fn create_library(
next_scan_at: None,
watcher_enabled: false,
metadata_provider: None,
fallback_metadata_provider: None,
}))
}
@@ -284,7 +287,7 @@ pub async fn update_monitoring(
let watcher_enabled = input.watcher_enabled.unwrap_or(false);
let result = sqlx::query(
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider"
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider"
)
.bind(library_id)
.bind(input.monitor_enabled)
@@ -314,12 +317,14 @@ pub async fn update_monitoring(
next_scan_at: row.get("next_scan_at"),
watcher_enabled: row.get("watcher_enabled"),
metadata_provider: row.get("metadata_provider"),
fallback_metadata_provider: row.get("fallback_metadata_provider"),
}))
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateMetadataProviderRequest {
pub metadata_provider: Option<String>,
pub fallback_metadata_provider: Option<String>,
}
/// Update the metadata provider for a library
@@ -345,12 +350,14 @@ pub async fn update_metadata_provider(
Json(input): Json<UpdateMetadataProviderRequest>,
) -> Result<Json<LibraryResponse>, ApiError> {
let provider = input.metadata_provider.as_deref().filter(|s| !s.is_empty());
let fallback = input.fallback_metadata_provider.as_deref().filter(|s| !s.is_empty());
let result = sqlx::query(
"UPDATE libraries SET metadata_provider = $2 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider"
"UPDATE libraries SET metadata_provider = $2, fallback_metadata_provider = $3 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider"
)
.bind(library_id)
.bind(provider)
.bind(fallback)
.fetch_optional(&state.pool)
.await?;
@@ -374,5 +381,6 @@ pub async fn update_metadata_provider(
next_scan_at: row.get("next_scan_at"),
watcher_enabled: row.get("watcher_enabled"),
metadata_provider: row.get("metadata_provider"),
fallback_metadata_provider: row.get("fallback_metadata_provider"),
}))
}

View File

@@ -6,6 +6,7 @@ mod index_jobs;
mod komga;
mod libraries;
mod metadata;
mod metadata_batch;
mod metadata_providers;
mod api_middleware;
mod openapi;
@@ -112,6 +113,9 @@ async fn main() -> anyhow::Result<()> {
.route("/metadata/links", get(metadata::get_metadata_links))
.route("/metadata/missing/:id", get(metadata::get_missing_books))
.route("/metadata/links/:id", delete(metadata::delete_metadata_link))
.route("/metadata/batch", axum::routing::post(metadata_batch::start_batch))
.route("/metadata/batch/:id/report", get(metadata_batch::get_batch_report))
.route("/metadata/batch/:id/results", get(metadata_batch::get_batch_results))
.merge(settings::settings_routes())
.route_layer(middleware::from_fn_with_state(
state.clone(),
@@ -129,6 +133,7 @@ async fn main() -> anyhow::Result<()> {
.route("/libraries/:library_id/series/:name/metadata", get(books::get_series_metadata))
.route("/series", get(books::list_all_series))
.route("/series/ongoing", get(books::ongoing_series))
.route("/series/statuses", get(books::series_statuses))
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
.route("/stats", get(stats::get_stats))
.route("/search", get(search::search_books))

View File

@@ -590,7 +590,7 @@ fn row_to_link_dto(row: &sqlx::postgres::PgRow) -> ExternalMetadataLinkDto {
}
}
async fn get_provider_for_library(state: &AppState, library_id: Uuid) -> Result<String, ApiError> {
pub(crate) async fn get_provider_for_library(state: &AppState, library_id: Uuid) -> Result<String, ApiError> {
// Check library-level provider first
let row = sqlx::query("SELECT metadata_provider FROM libraries WHERE id = $1")
.bind(library_id)
@@ -623,7 +623,7 @@ async fn get_provider_for_library(state: &AppState, library_id: Uuid) -> Result<
Ok("google_books".to_string())
}
async fn load_provider_config(
pub(crate) async fn load_provider_config(
state: &AppState,
provider_name: &str,
) -> metadata_providers::ProviderConfig {
@@ -661,7 +661,7 @@ async fn load_provider_config(
config
}
async fn sync_series_metadata(
pub(crate) async fn sync_series_metadata(
state: &AppState,
library_id: Uuid,
series_name: &str,
@@ -846,7 +846,7 @@ fn normalize_series_status(raw: &str) -> String {
}
}
async fn sync_books_metadata(
pub(crate) async fn sync_books_metadata(
state: &AppState,
link_id: Uuid,
library_id: Uuid,

File diff suppressed because it is too large Load Diff

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -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

View File

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

View File

@@ -71,7 +71,7 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
<div className="relative">
<BookImage
src={coverUrl}
alt={`Cover of ${book.title}`}
alt={`Couverture de ${book.title}`}
/>
{overlay && (
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wide ${overlay.className}`}>

View File

@@ -16,7 +16,7 @@ export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount:
<div className="bg-card rounded-xl border border-border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground">
Preview
Aperçu
<span className="ml-2 text-sm font-normal text-muted-foreground">
pages {offset + 1}{Math.min(offset + PAGE_SIZE, pageCount)} / {pageCount}
</span>
@@ -27,14 +27,14 @@ export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount:
disabled={offset === 0}
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-muted/50 text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Prev
Préc.
</button>
<button
onClick={() => setOffset((o) => Math.min(o + PAGE_SIZE, pageCount - 1))}
disabled={offset + PAGE_SIZE >= pageCount}
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-muted/50 text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
Suiv.
</button>
</div>
</div>

View File

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

View File

@@ -173,7 +173,7 @@ export function FolderBrowser({ initialFolders, selectedPath, onSelect }: Folder
<div className="max-h-80 overflow-y-auto">
{tree.length === 0 ? (
<div className="px-3 py-8 text-sm text-muted-foreground text-center">
No folders found
Aucun dossier trouvé
</div>
) : (
tree.map(node => renderNode(node))

View File

@@ -27,7 +27,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
<input
type="text"
readOnly
value={selectedPath || "Select a folder..."}
value={selectedPath || "Sélectionner un dossier..."}
className={`
w-full px-3 py-2 rounded-lg border bg-card
text-sm font-mono
@@ -57,7 +57,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
Browse
Parcourir
</Button>
</div>
@@ -79,7 +79,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="font-medium">Select Folder</span>
<span className="font-medium">Sélectionner le dossier</span>
</div>
<button
type="button"
@@ -104,7 +104,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-muted/30">
<span className="text-xs text-muted-foreground">
Click a folder to select it
Cliquez sur un dossier pour le sélectionner
</span>
<div className="flex gap-2">
<Button
@@ -113,7 +113,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
size="sm"
onClick={() => setIsOpen(false)}
>
Cancel
Annuler
</Button>
</div>
</div>

View File

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

View File

@@ -63,12 +63,12 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
? job.total_files != null
? `${job.processed_files ?? 0}/${job.total_files}`
: scanned > 0
? `${scanned} scanned`
? `${scanned} analysés`
: "-"
: job.status === "success" && (indexed > 0 || removed > 0 || errors > 0)
? null // rendered below as ✓ / / ⚠
: scanned > 0
? `${scanned} scanned`
? `${scanned} analysés`
: "—";
// Thumbnails column (Phase 2: extracting_pages + generating_thumbnails)
@@ -113,7 +113,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
className="text-xs text-primary hover:text-primary/80 hover:underline"
onClick={() => setShowProgress(!showProgress)}
>
{showProgress ? "Hide" : "Show"} progress
{showProgress ? "Masquer" : "Afficher"} la progression
</button>
)}
</div>
@@ -154,7 +154,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
href={`/jobs/${job.id}`}
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
>
View
Voir
</Link>
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
<Button
@@ -162,7 +162,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
size="sm"
onClick={() => onCancel(job.id)}
>
Cancel
Annuler
</Button>
)}
</div>

View File

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

View File

@@ -46,12 +46,12 @@ function formatDate(dateStr: string): string {
if (diff < 3600000) {
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
return `${mins}m ago`;
if (mins < 1) return "À l'instant";
return `il y a ${mins}m`;
}
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours}h ago`;
return `il y a ${hours}h`;
}
return date.toLocaleDateString();
}
@@ -103,13 +103,13 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
<thead>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">ID</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Library</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Bibliothèque</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Files</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Thumbnails</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Duration</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Created</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Statut</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Fichiers</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Miniatures</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Durée</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Créé</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
</tr>
</thead>

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ export function LibrarySubPageHeader({
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Libraries
Bibliothèques
</Link>
<span className="text-muted-foreground">/</span>
<span className="text-sm text-foreground font-medium">{library.name}</span>
@@ -74,7 +74,7 @@ export function LibrarySubPageHeader({
</svg>
<span className="text-foreground">
<span className="font-semibold">{library.book_count}</span>
<span className="text-muted-foreground ml-1">book{library.book_count !== 1 ? 's' : ''}</span>
<span className="text-muted-foreground ml-1">livre{library.book_count !== 1 ? 's' : ''}</span>
</span>
</div>
@@ -86,7 +86,7 @@ export function LibrarySubPageHeader({
variant={library.enabled ? "success" : "muted"}
className="text-xs"
>
{library.enabled ? "Enabled" : "Disabled"}
{library.enabled ? "Activée" : "Désactivée"}
</Badge>
</div>
</div>

View File

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

View File

@@ -62,6 +62,23 @@ export function MetadataSearchModal({
// Provider selector: empty string = library default
const [searchProvider, setSearchProvider] = useState("");
const [activeProvider, setActiveProvider] = useState("");
const [hiddenProviders, setHiddenProviders] = useState<Set<string>>(new Set());
// Fetch metadata provider settings to hide providers without required API keys
useEffect(() => {
fetch("/api/settings/metadata_providers")
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!data) return;
const hidden = new Set<string>();
// ComicVine requires an API key
if (!data.comicvine?.api_key) hidden.add("comicvine");
setHiddenProviders(hidden);
})
.catch(() => {});
}, []);
const visibleProviders = PROVIDERS.filter((p) => !hiddenProviders.has(p.value));
const handleOpen = useCallback(() => {
setIsOpen(true);
@@ -109,7 +126,7 @@ export function MetadataSearchModal({
});
const data = await resp.json();
if (!resp.ok) {
setError(data.error || "Search failed");
setError(data.error || "Échec de la recherche");
setStep("results");
return;
}
@@ -121,7 +138,7 @@ export function MetadataSearchModal({
}
setStep("results");
} catch {
setError("Network error");
setError("Erreur réseau");
setStep("results");
}
}
@@ -160,7 +177,7 @@ export function MetadataSearchModal({
});
const matchData = await matchResp.json();
if (!matchResp.ok) {
setError(matchData.error || "Failed to create match");
setError(matchData.error || "Échec de la création du lien");
setStep("results");
return;
}
@@ -179,7 +196,7 @@ export function MetadataSearchModal({
});
const approveData = await approveResp.json();
if (!approveResp.ok) {
setError(approveData.error || "Failed to approve");
setError(approveData.error || "Échec de l'approbation");
setStep("results");
return;
}
@@ -201,7 +218,7 @@ export function MetadataSearchModal({
setStep("done");
} catch {
setError("Network error");
setError("Erreur réseau");
setStep("results");
}
}
@@ -245,7 +262,7 @@ export function MetadataSearchModal({
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
<h3 className="font-semibold text-foreground">
{step === "linked" ? "Metadata Link" : "Search External Metadata"}
{step === "linked" ? "Lien métadonnées" : "Rechercher les métadonnées externes"}
</h3>
<button type="button" onClick={handleClose}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-muted-foreground hover:text-foreground">
@@ -258,9 +275,9 @@ export function MetadataSearchModal({
{/* Provider selector — visible during searching & results */}
{(step === "searching" || step === "results") && (
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground whitespace-nowrap">Provider :</label>
<label className="text-sm text-muted-foreground whitespace-nowrap">Fournisseur :</label>
<div className="flex gap-1 flex-wrap">
{PROVIDERS.map((p) => (
{visibleProviders.map((p) => (
<button
key={p.value}
type="button"
@@ -287,7 +304,7 @@ export function MetadataSearchModal({
{step === "searching" && (
<div className="flex items-center justify-center py-12">
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
<span className="ml-3 text-muted-foreground">Searching for &quot;{seriesName}&quot;...</span>
<span className="ml-3 text-muted-foreground">Recherche de &quot;{seriesName}&quot;...</span>
</div>
)}
@@ -302,11 +319,11 @@ export function MetadataSearchModal({
{step === "results" && (
<>
{candidates.length === 0 && !error ? (
<p className="text-muted-foreground text-center py-8">No results found.</p>
<p className="text-muted-foreground text-center py-8">Aucun résultat trouvé.</p>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground mb-2">
{candidates.length} result{candidates.length !== 1 ? "s" : ""} found
{candidates.length} résultat{candidates.length !== 1 ? "s" : ""} trouvé{candidates.length !== 1 ? "s" : ""}
{activeProvider && (
<span className="ml-1 text-xs inline-flex items-center gap-1">via <ProviderIcon provider={activeProvider} size={12} /> <span className="font-medium">{providerLabel(activeProvider)}</span></span>
)}
@@ -387,7 +404,7 @@ export function MetadataSearchModal({
</div>
</div>
<p className="text-sm text-foreground font-medium">How would you like to sync?</p>
<p className="text-sm text-foreground font-medium">Comment souhaitez-vous synchroniser ?</p>
<div className="flex flex-col gap-2">
<button
@@ -395,16 +412,16 @@ export function MetadataSearchModal({
onClick={() => handleApprove(true, false)}
className="w-full p-3 rounded-lg border border-border bg-card text-left hover:bg-muted/40 hover:border-primary/50 transition-colors"
>
<p className="font-medium text-sm text-foreground">Sync series metadata only</p>
<p className="text-xs text-muted-foreground">Update description, authors, publishers, and year</p>
<p className="font-medium text-sm text-foreground">Synchroniser la série uniquement</p>
<p className="text-xs text-muted-foreground">Mettre à jour la description, les auteurs, les éditeurs et l'année</p>
</button>
<button
type="button"
onClick={() => handleApprove(true, true)}
className="w-full p-3 rounded-lg border border-primary/50 bg-primary/5 text-left hover:bg-primary/10 transition-colors"
>
<p className="font-medium text-sm text-foreground">Sync series + books</p>
<p className="text-xs text-muted-foreground">Also fetch book list and show missing volumes</p>
<p className="font-medium text-sm text-foreground">Synchroniser la série + les livres</p>
<p className="text-xs text-muted-foreground">Récupérer aussi la liste des livres et afficher les tomes manquants</p>
</button>
</div>
@@ -413,7 +430,7 @@ export function MetadataSearchModal({
onClick={() => { setSelectedCandidate(null); setStep("results"); }}
className="text-sm text-muted-foreground hover:text-foreground"
>
Back to results
Retour aux résultats
</button>
</div>
)}
@@ -422,7 +439,7 @@ export function MetadataSearchModal({
{step === "syncing" && (
<div className="flex items-center justify-center py-12">
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
<span className="ml-3 text-muted-foreground">Syncing metadata...</span>
<span className="ml-3 text-muted-foreground">Synchronisation des métadonnées...</span>
</div>
)}
@@ -430,7 +447,7 @@ export function MetadataSearchModal({
{step === "done" && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/30">
<p className="font-medium text-green-600">Metadata synced successfully!</p>
<p className="font-medium text-green-600">Métadonnées synchronisées avec succès !</p>
</div>
{/* Sync Report */}
@@ -461,7 +478,7 @@ export function MetadataSearchModal({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="font-medium">{fieldLabel(f.field)}</span>
<span className="text-muted-foreground">locked</span>
<span className="text-muted-foreground">verrouillé</span>
</div>
))}
</div>
@@ -480,7 +497,7 @@ export function MetadataSearchModal({
{!syncReport.books_message && (syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Livres {syncReport.books_matched} matched{syncReport.books_unmatched > 0 && `, ${syncReport.books_unmatched} unmatched`}
Livres {syncReport.books_matched} associé{syncReport.books_matched !== 1 ? "s" : ""}{syncReport.books_unmatched > 0 && `, ${syncReport.books_unmatched} non associé${syncReport.books_unmatched !== 1 ? "s" : ""}`}
</p>
{syncReport.books.length > 0 && (
<div className="space-y-2 max-h-48 overflow-y-auto">
@@ -503,7 +520,7 @@ export function MetadataSearchModal({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="font-medium">{fieldLabel(f.field)}</span>
<span className="text-muted-foreground">locked</span>
<span className="text-muted-foreground">verrouillé</span>
</p>
))}
</div>
@@ -521,15 +538,15 @@ export function MetadataSearchModal({
<div className="space-y-3">
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
<div>
<p className="text-sm text-muted-foreground">External</p>
<p className="text-sm text-muted-foreground">Externe</p>
<p className="text-2xl font-semibold">{missing.total_external}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Local</p>
<p className="text-sm text-muted-foreground">Locaux</p>
<p className="text-2xl font-semibold">{missing.total_local}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Missing</p>
<p className="text-sm text-muted-foreground">Manquants</p>
<p className="text-2xl font-semibold text-warning">{missing.missing_count}</p>
</div>
</div>
@@ -542,14 +559,14 @@ export function MetadataSearchModal({
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
{missing.missing_count} missing book{missing.missing_count !== 1 ? "s" : ""}
{missing.missing_count} livre{missing.missing_count !== 1 ? "s" : ""} manquant{missing.missing_count !== 1 ? "s" : ""}
</button>
{showMissingList && (
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
{missing.missing_books.map((b, i) => (
<p key={i} className="text-muted-foreground truncate">
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
{b.title || "Unknown"}
{b.title || "Inconnu"}
</p>
))}
</div>
@@ -564,7 +581,7 @@ export function MetadataSearchModal({
onClick={() => { handleClose(); router.refresh(); }}
className="w-full p-2.5 rounded-lg bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 transition-colors"
>
Close
Fermer
</button>
</div>
)}
@@ -576,7 +593,7 @@ export function MetadataSearchModal({
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground inline-flex items-center gap-1.5">
Linked to <ProviderIcon provider={existingLink.provider} size={16} /> {providerLabel(existingLink.provider)}
Lié à <ProviderIcon provider={existingLink.provider} size={16} /> {providerLabel(existingLink.provider)}
</p>
{existingLink.external_url && (
<a
@@ -585,7 +602,7 @@ export function MetadataSearchModal({
rel="noopener noreferrer"
className="block mt-1 text-xs text-primary hover:underline"
>
View on external source
Voir sur la source externe
</a>
)}
</div>
@@ -618,14 +635,14 @@ export function MetadataSearchModal({
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
{initialMissing.missing_count} missing book{initialMissing.missing_count !== 1 ? "s" : ""}
{initialMissing.missing_count} livre{initialMissing.missing_count !== 1 ? "s" : ""} manquant{initialMissing.missing_count !== 1 ? "s" : ""}
</button>
{showMissingList && (
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
{initialMissing.missing_books.map((b, i) => (
<p key={i} className="text-muted-foreground truncate">
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
{b.title || "Unknown"}
{b.title || "Inconnu"}
</p>
))}
</div>
@@ -639,14 +656,14 @@ export function MetadataSearchModal({
onClick={() => { doSearch(""); }}
className="flex-1 p-2.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
>
Search again
Rechercher à nouveau
</button>
<button
type="button"
onClick={handleUnlink}
className="p-2.5 rounded-lg border border-destructive/30 bg-destructive/5 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors"
>
Unlink
Dissocier
</button>
</div>
</div>
@@ -666,13 +683,13 @@ export function MetadataSearchModal({
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
>
<Icon name="search" size="sm" />
{existingLink && existingLink.status === "approved" ? "Metadata" : "Search metadata"}
{existingLink && existingLink.status === "approved" ? "Métadonnées" : "Rechercher les métadonnées"}
</button>
{/* Inline badge when linked */}
{existingLink && existingLink.status === "approved" && initialMissing && initialMissing.missing_count > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 text-xs border border-yellow-500/30">
{initialMissing.missing_count} missing
{initialMissing.missing_count} manquant{initialMissing.missing_count !== 1 ? "s" : ""}
</span>
)}

View File

@@ -76,7 +76,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
onClick={() => setIsOpen(false)}
>
<NavIcon name="settings" />
<span className="font-medium">Settings</span>
<span className="font-medium">Paramètres</span>
</Link>
</div>
</nav>
@@ -90,7 +90,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
<button
className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
onClick={() => setIsOpen(!isOpen)}
aria-label={isOpen ? "Close menu" : "Open menu"}
aria-label={isOpen ? "Fermer le menu" : "Ouvrir le menu"}
aria-expanded={isOpen}
>
{isOpen ? <XIcon /> : <HamburgerIcon />}

View File

@@ -67,7 +67,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
disabled={isPending}
className="w-3.5 h-3.5 rounded border-border text-warning focus:ring-warning"
/>
<span title="Real-time file watcher"></span>
<span title="Surveillance des fichiers en temps réel"></span>
</label>
<select
@@ -76,10 +76,10 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
disabled={isPending}
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
>
<option value="manual">Manual</option>
<option value="hourly">Hourly</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="manual">Manuel</option>
<option value="hourly">Toutes les heures</option>
<option value="daily">Quotidien</option>
<option value="weekly">Hebdomadaire</option>
</select>
<button

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ export function CursorPagination({
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
{/* Page size selector */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">Show</span>
<span className="text-sm text-muted-foreground">Afficher</span>
<select
value={pageSize.toString()}
onChange={(e) => changePageSize(Number(e.target.value))}
@@ -60,12 +60,12 @@ export function CursorPagination({
</option>
))}
</select>
<span className="text-sm text-muted-foreground">per page</span>
<span className="text-sm text-muted-foreground">par page</span>
</div>
{/* Count info */}
<div className="text-sm text-muted-foreground">
Showing {currentCount} items
Affichage de {currentCount} éléments
</div>
{/* Navigation */}
@@ -79,7 +79,7 @@ export function CursorPagination({
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
First
Premier
</Button>
<Button
@@ -88,7 +88,7 @@ export function CursorPagination({
onClick={goToNext}
disabled={!hasNextPage}
>
Next
Suivant
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
@@ -170,7 +170,7 @@ export function OffsetPagination({
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
{/* Page size selector */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">Show</span>
<span className="text-sm text-muted-foreground">Afficher</span>
<select
value={pageSize.toString()}
onChange={(e) => changePageSize(Number(e.target.value))}
@@ -182,12 +182,12 @@ export function OffsetPagination({
</option>
))}
</select>
<span className="text-sm text-muted-foreground">per page</span>
<span className="text-sm text-muted-foreground">par page</span>
</div>
{/* Page info */}
<div className="text-sm text-muted-foreground">
{startItem}-{endItem} of {totalItems}
{startItem}-{endItem} sur {totalItems}
</div>
{/* Page navigation */}
@@ -196,7 +196,7 @@ export function OffsetPagination({
size="sm"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
title="Previous page"
title="Page précédente"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
@@ -224,7 +224,7 @@ export function OffsetPagination({
size="sm"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages}
title="Next page"
title="Page suivante"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />

View File

@@ -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&apos;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) => (

View File

@@ -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&apos;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&apos;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&eacute;mental : d&eacute;tecte les fichiers ajout&eacute;s, modifi&eacute;s ou supprim&eacute;s depuis le dernier scan, les indexe et g&eacute;n&egrave;re les miniatures manquantes. Les donn&eacute;es existantes non modifi&eacute;es sont conserv&eacute;es. C&rsquo;est l&rsquo;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&eacute;es index&eacute;es (livres, s&eacute;ries, miniatures) puis effectue un scan complet depuis z&eacute;ro. Utile si la base de donn&eacute;es est d&eacute;synchronis&eacute;e ou corrompue. Op&eacute;ration longue et destructive : les statuts de lecture et les m&eacute;tadonn&eacute;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&eacute;n&egrave;re les miniatures uniquement pour les livres qui n&rsquo;en ont pas encore. Les miniatures existantes ne sont pas touch&eacute;es. Utile apr&egrave;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&eacute;n&egrave;re toutes les miniatures depuis z&eacute;ro, en rempla&ccedil;ant les existantes. Utile si la qualit&eacute; ou la taille des miniatures a chang&eacute; 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&eacute;tadonn&eacute;es de chaque s&eacute;rie de la biblioth&egrave;que aupr&egrave;s du provider configur&eacute; (avec fallback si configur&eacute;). Seuls les r&eacute;sultats avec un match unique &agrave; 100% de confiance sont appliqu&eacute;s automatiquement. Les s&eacute;ries d&eacute;j&agrave; li&eacute;es sont ignor&eacute;es. Un rapport d&eacute;taill&eacute; par s&eacute;rie est disponible &agrave; la fin du job. <strong>Requiert une biblioth&egrave;que sp&eacute;cifique</strong> (ne fonctionne pas sur &laquo; Toutes les bibliothèques &raquo;).
</p>
</div>
</div>
</div>
</CardContent>
</Card>
<JobsList
initialJobs={jobs}
libraries={libraryMap}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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>

View File

@@ -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}>

View File

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

View File

@@ -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 &quot;{searchQuery}&quot;</>}
{seriesPage.total} séries
{searchQuery && <> correspondant à &quot;{searchQuery}&quot;</>}
</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>
)}

View File

@@ -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&apos;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&amp;width=800</code>). Pages served without parameters are delivered as-is from the archive, with no processing.</CardDescription>
<CardDescription>Ces paramètres s&apos;appliquent uniquement lorsqu&apos;un client demande explicitement une conversion de format via l&apos;API (ex. <code className="text-xs bg-muted px-1 rounded">?format=webp&amp;width=800</code>). Les pages servies sans paramètres sont livrées telles quelles depuis l&apos;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) => {

View File

@@ -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>
)}

View File

@@ -9,6 +9,7 @@ export type LibraryDto = {
next_scan_at: string | null;
watcher_enabled: boolean;
metadata_provider: string | null;
fallback_metadata_provider: string | null;
};
export type IndexJobDto = {
@@ -120,6 +121,8 @@ export type SeriesDto = {
books_read_count: number;
first_book_id: string;
library_id: string;
series_status: string | null;
missing_count: number | null;
};
export function config() {
@@ -296,10 +299,14 @@ export async function fetchSeries(
libraryId: string,
page: number = 1,
limit: number = 50,
seriesStatus?: string,
hasMissing?: boolean,
): Promise<SeriesPageDto> {
const params = new URLSearchParams();
params.set("page", page.toString());
params.set("limit", limit.toString());
if (seriesStatus) params.set("series_status", seriesStatus);
if (hasMissing) params.set("has_missing", "true");
return apiFetch<SeriesPageDto>(
`/libraries/${libraryId}/series?${params.toString()}`,
@@ -313,18 +320,26 @@ export async function fetchAllSeries(
page: number = 1,
limit: number = 50,
sort?: string,
seriesStatus?: string,
hasMissing?: boolean,
): Promise<SeriesPageDto> {
const params = new URLSearchParams();
if (libraryId) params.set("library_id", libraryId);
if (q) params.set("q", q);
if (readingStatus) params.set("reading_status", readingStatus);
if (sort) params.set("sort", sort);
if (seriesStatus) params.set("series_status", seriesStatus);
if (hasMissing) params.set("has_missing", "true");
params.set("page", page.toString());
params.set("limit", limit.toString());
return apiFetch<SeriesPageDto>(`/series?${params.toString()}`);
}
export async function fetchSeriesStatuses(): Promise<string[]> {
return apiFetch<string[]>("/series/statuses");
}
export async function searchBooks(
query: string,
libraryId?: string,
@@ -726,9 +741,55 @@ export async function deleteMetadataLink(id: string) {
});
}
export async function updateLibraryMetadataProvider(libraryId: string, provider: string | null) {
export async function updateLibraryMetadataProvider(libraryId: string, provider: string | null, fallbackProvider?: string | null) {
return apiFetch<LibraryDto>(`/libraries/${libraryId}/metadata-provider`, {
method: "PATCH",
body: JSON.stringify({ metadata_provider: provider }),
body: JSON.stringify({ metadata_provider: provider, fallback_metadata_provider: fallbackProvider }),
});
}
// ---------------------------------------------------------------------------
// Batch Metadata
// ---------------------------------------------------------------------------
export type MetadataBatchReportDto = {
job_id: string;
status: string;
total_series: number;
processed: number;
auto_matched: number;
no_results: number;
too_many_results: number;
low_confidence: number;
already_linked: number;
errors: number;
};
export type MetadataBatchResultDto = {
id: string;
series_name: string;
status: string;
provider_used: string | null;
fallback_used: boolean;
candidates_count: number;
best_confidence: number | null;
best_candidate_json: Record<string, unknown> | null;
link_id: string | null;
error_message: string | null;
};
export async function startMetadataBatch(libraryId: string) {
return apiFetch<{ id: string; status: string }>("/metadata/batch", {
method: "POST",
body: JSON.stringify({ library_id: libraryId }),
});
}
export async function getMetadataBatchReport(jobId: string) {
return apiFetch<MetadataBatchReportDto>(`/metadata/batch/${jobId}/report`);
}
export async function getMetadataBatchResults(jobId: string, status?: string) {
const params = status ? `?status=${status}` : "";
return apiFetch<MetadataBatchResultDto[]>(`/metadata/batch/${jobId}/results${params}`);
}

File diff suppressed because one or more lines are too long

View File

@@ -36,6 +36,9 @@ pub async fn cleanup_stale_jobs(pool: &PgPool) -> Result<()> {
Ok(())
}
/// Job types processed by the API, not the indexer.
const API_ONLY_JOB_TYPES: &[&str] = &["metadata_batch"];
/// Job types that modify book/thumbnail data and must not run concurrently.
const EXCLUSIVE_JOB_TYPES: &[&str] = &[
"rebuild",
@@ -75,6 +78,7 @@ pub async fn claim_next_job(pool: &PgPool) -> Result<Option<(Uuid, Option<Uuid>)
SELECT j.id, j.type, j.library_id
FROM index_jobs j
WHERE j.status = 'pending'
AND j.type != ALL($3)
AND (
-- Exclusive jobs: only if no other exclusive job is active
(j.type = ANY($1) AND NOT $2::bool)
@@ -96,6 +100,7 @@ pub async fn claim_next_job(pool: &PgPool) -> Result<Option<(Uuid, Option<Uuid>)
)
.bind(EXCLUSIVE_JOB_TYPES)
.bind(has_active_exclusive)
.bind(API_ONLY_JOB_TYPES)
.fetch_optional(&mut *tx)
.await?;