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:
@@ -312,6 +312,8 @@ pub struct SeriesItem {
|
|||||||
pub first_book_id: Uuid,
|
pub first_book_id: Uuid,
|
||||||
#[schema(value_type = String)]
|
#[schema(value_type = String)]
|
||||||
pub library_id: Uuid,
|
pub library_id: Uuid,
|
||||||
|
pub series_status: Option<String>,
|
||||||
|
pub missing_count: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -328,6 +330,12 @@ pub struct ListSeriesQuery {
|
|||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
#[schema(value_type = Option<String>, example = "unread,reading")]
|
#[schema(value_type = Option<String>, example = "unread,reading")]
|
||||||
pub reading_status: Option<String>,
|
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)]
|
#[schema(value_type = Option<i64>, example = 1)]
|
||||||
pub page: Option<i64>,
|
pub page: Option<i64>,
|
||||||
#[schema(value_type = Option<i64>, example = 50)]
|
#[schema(value_type = Option<i64>, example = 50)]
|
||||||
@@ -371,6 +379,8 @@ pub async fn list_series(
|
|||||||
ELSE 'reading'
|
ELSE 'reading'
|
||||||
END"#;
|
END"#;
|
||||||
|
|
||||||
|
let has_missing = query.has_missing.as_deref() == Some("true");
|
||||||
|
|
||||||
// Paramètres dynamiques — $1 = library_id fixe, puis optionnels dans l'ordre
|
// Paramètres dynamiques — $1 = library_id fixe, puis optionnels dans l'ordre
|
||||||
let mut p: usize = 1;
|
let mut p: usize = 1;
|
||||||
|
|
||||||
@@ -382,7 +392,27 @@ pub async fn list_series(
|
|||||||
p += 1; format!("AND {series_status_expr} = ANY(${p})")
|
p += 1; format!("AND {series_status_expr} = ANY(${p})")
|
||||||
} else { String::new() };
|
} 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!(
|
let count_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
WITH sorted_books AS (
|
WITH sorted_books AS (
|
||||||
@@ -396,12 +426,15 @@ pub async fn list_series(
|
|||||||
FROM sorted_books sb
|
FROM sorted_books sb
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||||
GROUP BY sb.name
|
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 limit_p = p + 1;
|
||||||
let offset_p = p + 2;
|
let offset_p = p + 2;
|
||||||
|
|
||||||
@@ -430,17 +463,24 @@ pub async fn list_series(
|
|||||||
FROM sorted_books sb
|
FROM sorted_books sb
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||||
GROUP BY sb.name
|
GROUP BY sb.name
|
||||||
)
|
),
|
||||||
|
{missing_cte}
|
||||||
SELECT
|
SELECT
|
||||||
sc.name,
|
sc.name,
|
||||||
sc.book_count,
|
sc.book_count,
|
||||||
sc.books_read_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
|
FROM series_counts sc
|
||||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
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
|
WHERE TRUE
|
||||||
{q_cond}
|
{q_cond}
|
||||||
{count_rs_cond}
|
{count_rs_cond}
|
||||||
|
{ss_cond}
|
||||||
|
{missing_cond}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''),
|
REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''),
|
||||||
COALESCE(
|
COALESCE(
|
||||||
@@ -465,6 +505,10 @@ pub async fn list_series(
|
|||||||
count_builder = count_builder.bind(statuses.clone());
|
count_builder = count_builder.bind(statuses.clone());
|
||||||
data_builder = data_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);
|
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 total: i64 = count_row.get(0);
|
||||||
|
|
||||||
let mut items: Vec<SeriesItem> = rows
|
let items: Vec<SeriesItem> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| SeriesItem {
|
.map(|row| SeriesItem {
|
||||||
name: row.get("name"),
|
name: row.get("name"),
|
||||||
@@ -482,11 +526,13 @@ pub async fn list_series(
|
|||||||
books_read_count: row.get("books_read_count"),
|
books_read_count: row.get("books_read_count"),
|
||||||
first_book_id: row.get("first_book_id"),
|
first_book_id: row.get("first_book_id"),
|
||||||
library_id,
|
library_id,
|
||||||
|
series_status: row.get("series_status"),
|
||||||
|
missing_count: row.get("missing_count"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(SeriesPage {
|
Ok(Json(SeriesPage {
|
||||||
items: std::mem::take(&mut items),
|
items,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
@@ -501,6 +547,12 @@ pub struct ListAllSeriesQuery {
|
|||||||
pub library_id: Option<Uuid>,
|
pub library_id: Option<Uuid>,
|
||||||
#[schema(value_type = Option<String>, example = "unread,reading")]
|
#[schema(value_type = Option<String>, example = "unread,reading")]
|
||||||
pub reading_status: Option<String>,
|
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)]
|
#[schema(value_type = Option<i64>, example = 1)]
|
||||||
pub page: Option<i64>,
|
pub page: Option<i64>,
|
||||||
#[schema(value_type = Option<i64>, example = 50)]
|
#[schema(value_type = Option<i64>, example = 50)]
|
||||||
@@ -547,6 +599,8 @@ pub async fn list_all_series(
|
|||||||
ELSE 'reading'
|
ELSE 'reading'
|
||||||
END"#;
|
END"#;
|
||||||
|
|
||||||
|
let has_missing = query.has_missing.as_deref() == Some("true");
|
||||||
|
|
||||||
let mut p: usize = 0;
|
let mut p: usize = 0;
|
||||||
|
|
||||||
let lib_cond = if query.library_id.is_some() {
|
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})")
|
p += 1; format!("AND {series_status_expr} = ANY(${p})")
|
||||||
} else { String::new() };
|
} 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!(
|
let count_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
WITH sorted_books AS (
|
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}
|
FROM books {lib_cond}
|
||||||
),
|
),
|
||||||
series_counts AS (
|
series_counts AS (
|
||||||
SELECT sb.name,
|
SELECT sb.name, sb.library_id,
|
||||||
COUNT(*) as book_count,
|
COUNT(*) as book_count,
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
||||||
FROM sorted_books sb
|
FROM sorted_books sb
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||||
GROUP BY sb.name
|
GROUP BY sb.name, sb.library_id
|
||||||
)
|
),
|
||||||
SELECT COUNT(*) FROM series_counts sc WHERE TRUE {q_cond} {rs_cond}
|
{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 (
|
series_counts AS (
|
||||||
SELECT
|
SELECT
|
||||||
sb.name,
|
sb.name,
|
||||||
|
sb.library_id,
|
||||||
COUNT(*) as book_count,
|
COUNT(*) as book_count,
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count,
|
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count,
|
||||||
MAX(sb.updated_at) as latest_updated_at
|
MAX(sb.updated_at) as latest_updated_at
|
||||||
FROM sorted_books sb
|
FROM sorted_books sb
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
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
|
SELECT
|
||||||
sc.name,
|
sc.name,
|
||||||
sc.book_count,
|
sc.book_count,
|
||||||
sc.books_read_count,
|
sc.books_read_count,
|
||||||
sb.id as first_book_id,
|
sb.id as first_book_id,
|
||||||
sb.library_id
|
sb.library_id,
|
||||||
|
sm.status as series_status,
|
||||||
|
mc.missing_count
|
||||||
FROM series_counts sc
|
FROM series_counts sc
|
||||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
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
|
WHERE TRUE
|
||||||
{q_cond}
|
{q_cond}
|
||||||
{rs_cond}
|
{rs_cond}
|
||||||
|
{ss_cond}
|
||||||
|
{missing_cond}
|
||||||
ORDER BY {series_order_clause}
|
ORDER BY {series_order_clause}
|
||||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||||
"#
|
"#
|
||||||
@@ -652,6 +753,10 @@ pub async fn list_all_series(
|
|||||||
count_builder = count_builder.bind(statuses.clone());
|
count_builder = count_builder.bind(statuses.clone());
|
||||||
data_builder = data_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);
|
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"),
|
books_read_count: row.get("books_read_count"),
|
||||||
first_book_id: row.get("first_book_id"),
|
first_book_id: row.get("first_book_id"),
|
||||||
library_id: row.get("library_id"),
|
library_id: row.get("library_id"),
|
||||||
|
series_status: row.get("series_status"),
|
||||||
|
missing_count: row.get("missing_count"),
|
||||||
})
|
})
|
||||||
.collect();
|
.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)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct OngoingQuery {
|
pub struct OngoingQuery {
|
||||||
#[schema(value_type = Option<i64>, example = 10)]
|
#[schema(value_type = Option<i64>, example = 10)]
|
||||||
@@ -756,6 +885,8 @@ pub async fn ongoing_series(
|
|||||||
books_read_count: row.get("books_read_count"),
|
books_read_count: row.get("books_read_count"),
|
||||||
first_book_id: row.get("first_book_id"),
|
first_book_id: row.get("first_book_id"),
|
||||||
library_id: row.get("library_id"),
|
library_id: row.get("library_id"),
|
||||||
|
series_status: None,
|
||||||
|
missing_count: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub struct LibraryResponse {
|
|||||||
pub next_scan_at: Option<chrono::DateTime<chrono::Utc>>,
|
pub next_scan_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
pub watcher_enabled: bool,
|
pub watcher_enabled: bool,
|
||||||
pub metadata_provider: Option<String>,
|
pub metadata_provider: Option<String>,
|
||||||
|
pub fallback_metadata_provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
@@ -46,7 +47,7 @@ pub struct CreateLibraryRequest {
|
|||||||
)]
|
)]
|
||||||
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
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
|
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count
|
||||||
FROM libraries l ORDER BY l.created_at DESC"
|
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"),
|
next_scan_at: row.get("next_scan_at"),
|
||||||
watcher_enabled: row.get("watcher_enabled"),
|
watcher_enabled: row.get("watcher_enabled"),
|
||||||
metadata_provider: row.get("metadata_provider"),
|
metadata_provider: row.get("metadata_provider"),
|
||||||
|
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -118,6 +120,7 @@ pub async fn create_library(
|
|||||||
next_scan_at: None,
|
next_scan_at: None,
|
||||||
watcher_enabled: false,
|
watcher_enabled: false,
|
||||||
metadata_provider: None,
|
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 watcher_enabled = input.watcher_enabled.unwrap_or(false);
|
||||||
|
|
||||||
let result = sqlx::query(
|
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(library_id)
|
||||||
.bind(input.monitor_enabled)
|
.bind(input.monitor_enabled)
|
||||||
@@ -314,12 +317,14 @@ pub async fn update_monitoring(
|
|||||||
next_scan_at: row.get("next_scan_at"),
|
next_scan_at: row.get("next_scan_at"),
|
||||||
watcher_enabled: row.get("watcher_enabled"),
|
watcher_enabled: row.get("watcher_enabled"),
|
||||||
metadata_provider: row.get("metadata_provider"),
|
metadata_provider: row.get("metadata_provider"),
|
||||||
|
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct UpdateMetadataProviderRequest {
|
pub struct UpdateMetadataProviderRequest {
|
||||||
pub metadata_provider: Option<String>,
|
pub metadata_provider: Option<String>,
|
||||||
|
pub fallback_metadata_provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the metadata provider for a library
|
/// Update the metadata provider for a library
|
||||||
@@ -345,12 +350,14 @@ pub async fn update_metadata_provider(
|
|||||||
Json(input): Json<UpdateMetadataProviderRequest>,
|
Json(input): Json<UpdateMetadataProviderRequest>,
|
||||||
) -> Result<Json<LibraryResponse>, ApiError> {
|
) -> Result<Json<LibraryResponse>, ApiError> {
|
||||||
let provider = input.metadata_provider.as_deref().filter(|s| !s.is_empty());
|
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(
|
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(library_id)
|
||||||
.bind(provider)
|
.bind(provider)
|
||||||
|
.bind(fallback)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -374,5 +381,6 @@ pub async fn update_metadata_provider(
|
|||||||
next_scan_at: row.get("next_scan_at"),
|
next_scan_at: row.get("next_scan_at"),
|
||||||
watcher_enabled: row.get("watcher_enabled"),
|
watcher_enabled: row.get("watcher_enabled"),
|
||||||
metadata_provider: row.get("metadata_provider"),
|
metadata_provider: row.get("metadata_provider"),
|
||||||
|
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ mod index_jobs;
|
|||||||
mod komga;
|
mod komga;
|
||||||
mod libraries;
|
mod libraries;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
|
mod metadata_batch;
|
||||||
mod metadata_providers;
|
mod metadata_providers;
|
||||||
mod api_middleware;
|
mod api_middleware;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
@@ -112,6 +113,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/metadata/links", get(metadata::get_metadata_links))
|
.route("/metadata/links", get(metadata::get_metadata_links))
|
||||||
.route("/metadata/missing/:id", get(metadata::get_missing_books))
|
.route("/metadata/missing/:id", get(metadata::get_missing_books))
|
||||||
.route("/metadata/links/:id", delete(metadata::delete_metadata_link))
|
.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())
|
.merge(settings::settings_routes())
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
@@ -129,6 +133,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/libraries/:library_id/series/:name/metadata", get(books::get_series_metadata))
|
.route("/libraries/:library_id/series/:name/metadata", get(books::get_series_metadata))
|
||||||
.route("/series", get(books::list_all_series))
|
.route("/series", get(books::list_all_series))
|
||||||
.route("/series/ongoing", get(books::ongoing_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("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
||||||
.route("/stats", get(stats::get_stats))
|
.route("/stats", get(stats::get_stats))
|
||||||
.route("/search", get(search::search_books))
|
.route("/search", get(search::search_books))
|
||||||
|
|||||||
@@ -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
|
// Check library-level provider first
|
||||||
let row = sqlx::query("SELECT metadata_provider FROM libraries WHERE id = $1")
|
let row = sqlx::query("SELECT metadata_provider FROM libraries WHERE id = $1")
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
@@ -623,7 +623,7 @@ async fn get_provider_for_library(state: &AppState, library_id: Uuid) -> Result<
|
|||||||
Ok("google_books".to_string())
|
Ok("google_books".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_provider_config(
|
pub(crate) async fn load_provider_config(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
provider_name: &str,
|
provider_name: &str,
|
||||||
) -> metadata_providers::ProviderConfig {
|
) -> metadata_providers::ProviderConfig {
|
||||||
@@ -661,7 +661,7 @@ async fn load_provider_config(
|
|||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sync_series_metadata(
|
pub(crate) async fn sync_series_metadata(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
library_id: Uuid,
|
library_id: Uuid,
|
||||||
series_name: &str,
|
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,
|
state: &AppState,
|
||||||
link_id: Uuid,
|
link_id: Uuid,
|
||||||
library_id: Uuid,
|
library_id: Uuid,
|
||||||
|
|||||||
1083
apps/api/src/metadata_batch.rs
Normal file
1083
apps/api/src/metadata_batch.rs
Normal file
File diff suppressed because it is too large
Load Diff
17
apps/backoffice/app/api/metadata/batch/report/route.ts
Normal file
17
apps/backoffice/app/api/metadata/batch/report/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch, MetadataBatchReportDto } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const data = await apiFetch<MetadataBatchReportDto>(`/metadata/batch/${id}/report`);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to fetch report";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/backoffice/app/api/metadata/batch/results/route.ts
Normal file
19
apps/backoffice/app/api/metadata/batch/results/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch, MetadataBatchResultDto } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const status = searchParams.get("status") || "";
|
||||||
|
const params = status ? `?status=${status}` : "";
|
||||||
|
const data = await apiFetch<MetadataBatchResultDto[]>(`/metadata/batch/${id}/results${params}`);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to fetch results";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/backoffice/app/api/metadata/batch/route.ts
Normal file
16
apps/backoffice/app/api/metadata/batch/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await apiFetch<{ id: string; status: string }>("/metadata/batch", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to start batch";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ export default async function BookDetailPage({
|
|||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Link href="/libraries" className="text-muted-foreground hover:text-primary transition-colors">
|
<Link href="/libraries" className="text-muted-foreground hover:text-primary transition-colors">
|
||||||
Libraries
|
Bibliothèques
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-muted-foreground">/</span>
|
<span className="text-muted-foreground">/</span>
|
||||||
{library && (
|
{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">
|
<div className="w-48 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(book.id)}
|
src={getBookCoverUrl(book.id)}
|
||||||
alt={`Cover of ${book.title}`}
|
alt={`Couverture de ${book.title}`}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
|
|||||||
@@ -78,20 +78,20 @@ export default async function BooksPage({
|
|||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
const libraryOptions = [
|
const libraryOptions = [
|
||||||
{ value: "", label: "All libraries" },
|
{ value: "", label: "Toutes les bibliothèques" },
|
||||||
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
|
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ value: "", label: "All" },
|
{ value: "", label: "Tous" },
|
||||||
{ value: "unread", label: "Unread" },
|
{ value: "unread", label: "Non lu" },
|
||||||
{ value: "reading", label: "In progress" },
|
{ value: "reading", label: "En cours" },
|
||||||
{ value: "read", label: "Read" },
|
{ value: "read", label: "Lu" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ value: "", label: "Title" },
|
{ value: "", label: "Titre" },
|
||||||
{ value: "latest", label: "Latest added" },
|
{ value: "latest", label: "Ajout récent" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasFilters = searchQuery || libraryId || readingStatus || sort;
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Books
|
Livres
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,10 +112,10 @@ export default async function BooksPage({
|
|||||||
<LiveSearchForm
|
<LiveSearchForm
|
||||||
basePath="/books"
|
basePath="/books"
|
||||||
fields={[
|
fields={[
|
||||||
{ name: "q", type: "text", label: "Search", placeholder: "Search by title, author, series...", className: "flex-1 w-full" },
|
{ name: "q", type: "text", label: "Rechercher", placeholder: "Rechercher par titre, auteur, série...", className: "flex-1 w-full" },
|
||||||
{ name: "library", type: "select", label: "Library", options: libraryOptions, className: "w-full sm:w-48" },
|
{ name: "library", type: "select", label: "Bibliothèque", options: libraryOptions, className: "w-full sm:w-48" },
|
||||||
{ name: "status", type: "select", label: "Status", options: statusOptions, className: "w-full sm:w-40" },
|
{ name: "status", type: "select", label: "Statut", options: statusOptions, className: "w-full sm:w-40" },
|
||||||
{ name: "sort", type: "select", label: "Sort", options: sortOptions, className: "w-full sm:w-40" },
|
{ name: "sort", type: "select", label: "Tri", options: sortOptions, className: "w-full sm:w-40" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -124,18 +124,18 @@ export default async function BooksPage({
|
|||||||
{/* Résultats */}
|
{/* Résultats */}
|
||||||
{searchQuery && totalHits !== null ? (
|
{searchQuery && totalHits !== null ? (
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}"
|
{totalHits} résultat{totalHits !== 1 ? 's' : ''} pour « {searchQuery} »
|
||||||
</p>
|
</p>
|
||||||
) : !searchQuery && (
|
) : !searchQuery && (
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
{total} book{total !== 1 ? 's' : ''}
|
{total} livre{total !== 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Séries matchantes */}
|
{/* Séries matchantes */}
|
||||||
{seriesHits.length > 0 && (
|
{seriesHits.length > 0 && (
|
||||||
<div className="mb-8">
|
<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">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
{seriesHits.map((s) => (
|
{seriesHits.map((s) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -147,7 +147,7 @@ export default async function BooksPage({
|
|||||||
<div className="aspect-[2/3] relative bg-muted/50">
|
<div className="aspect-[2/3] relative bg-muted/50">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(s.first_book_id)}
|
src={getBookCoverUrl(s.first_book_id)}
|
||||||
alt={`Cover of ${s.name}`}
|
alt={`Couverture de ${s.name}`}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
@@ -155,10 +155,10 @@ export default async function BooksPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
<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>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +171,7 @@ export default async function BooksPage({
|
|||||||
{/* Grille de livres */}
|
{/* Grille de livres */}
|
||||||
{displayBooks.length > 0 ? (
|
{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} />
|
<BooksGrid books={displayBooks} />
|
||||||
|
|
||||||
{!searchQuery && (
|
{!searchQuery && (
|
||||||
@@ -184,7 +184,7 @@ export default async function BooksPage({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={searchQuery ? `No books found for "${searchQuery}"` : "No books available"} />
|
<EmptyState message={searchQuery ? `Aucun livre trouvé pour "${searchQuery}"` : "Aucun livre disponible"} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<BookImage
|
<BookImage
|
||||||
src={coverUrl}
|
src={coverUrl}
|
||||||
alt={`Cover of ${book.title}`}
|
alt={`Couverture de ${book.title}`}
|
||||||
/>
|
/>
|
||||||
{overlay && (
|
{overlay && (
|
||||||
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wide ${overlay.className}`}>
|
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wide ${overlay.className}`}>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount:
|
|||||||
<div className="bg-card rounded-xl border border-border p-6">
|
<div className="bg-card rounded-xl border border-border p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
Preview
|
Aperçu
|
||||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||||
pages {offset + 1}–{Math.min(offset + PAGE_SIZE, pageCount)} / {pageCount}
|
pages {offset + 1}–{Math.min(offset + PAGE_SIZE, pageCount)} / {pageCount}
|
||||||
</span>
|
</span>
|
||||||
@@ -27,14 +27,14 @@ export function BookPreview({ bookId, pageCount }: { bookId: string; pageCount:
|
|||||||
disabled={offset === 0}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOffset((o) => Math.min(o + PAGE_SIZE, pageCount - 1))}
|
onClick={() => setOffset((o) => Math.min(o + PAGE_SIZE, pageCount - 1))}
|
||||||
disabled={offset + PAGE_SIZE >= pageCount}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,22 +23,22 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
|
|||||||
const res = await fetch(`/api/books/${bookId}/convert`, { method: "POST" });
|
const res = await fetch(`/api/books/${bookId}/convert`, { method: "POST" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const job = await res.json();
|
const job = await res.json();
|
||||||
setState({ type: "success", jobId: job.id });
|
setState({ type: "success", jobId: job.id });
|
||||||
} catch (err) {
|
} 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") {
|
if (state.type === "success") {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm text-success">
|
<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">
|
<Link href={`/jobs/${state.jobId}`} className="text-primary hover:underline font-medium">
|
||||||
View job →
|
Voir la tâche →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -52,7 +52,7 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
|
|||||||
className="text-xs text-muted-foreground hover:underline text-left"
|
className="text-xs text-muted-foreground hover:underline text-left"
|
||||||
onClick={() => setState({ type: "idle" })}
|
onClick={() => setState({ type: "idle" })}
|
||||||
>
|
>
|
||||||
Dismiss
|
Fermer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -65,7 +65,7 @@ export function ConvertButton({ bookId }: ConvertButtonProps) {
|
|||||||
onClick={handleConvert}
|
onClick={handleConvert}
|
||||||
disabled={state.type === "loading"}
|
disabled={state.type === "loading"}
|
||||||
>
|
>
|
||||||
{state.type === "loading" ? "Converting…" : "Convert to CBZ"}
|
{state.type === "loading" ? "Conversion…" : "Convertir en CBZ"}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export function FolderBrowser({ initialFolders, selectedPath, onSelect }: Folder
|
|||||||
<div className="max-h-80 overflow-y-auto">
|
<div className="max-h-80 overflow-y-auto">
|
||||||
{tree.length === 0 ? (
|
{tree.length === 0 ? (
|
||||||
<div className="px-3 py-8 text-sm text-muted-foreground text-center">
|
<div className="px-3 py-8 text-sm text-muted-foreground text-center">
|
||||||
No folders found
|
Aucun dossier trouvé
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
tree.map(node => renderNode(node))
|
tree.map(node => renderNode(node))
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
readOnly
|
readOnly
|
||||||
value={selectedPath || "Select a folder..."}
|
value={selectedPath || "Sélectionner un dossier..."}
|
||||||
className={`
|
className={`
|
||||||
w-full px-3 py-2 rounded-lg border bg-card
|
w-full px-3 py-2 rounded-lg border bg-card
|
||||||
text-sm font-mono
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Browse
|
Parcourir
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="font-medium">Select Folder</span>
|
<span className="font-medium">Sélectionner le dossier</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -104,7 +104,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-muted/30">
|
<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">
|
<span className="text-xs text-muted-foreground">
|
||||||
Click a folder to select it
|
Cliquez sur un dossier pour le sélectionner
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -113,7 +113,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
Annuler
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,14 +53,14 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
onComplete?.();
|
onComplete?.();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Failed to parse SSE data");
|
setError("Échec de l'analyse des données SSE");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (err) => {
|
eventSource.onerror = (err) => {
|
||||||
console.error("SSE error:", err);
|
console.error("SSE error:", err);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
setError("Connection lost");
|
setError("Connexion perdue");
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -71,7 +71,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-destructive/10 text-error rounded-lg text-sm">
|
<div className="p-4 bg-destructive/10 text-error rounded-lg text-sm">
|
||||||
Error: {error}
|
Erreur : {error}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
if (!progress) {
|
if (!progress) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-muted-foreground text-sm">
|
<div className="p-4 text-muted-foreground text-sm">
|
||||||
Loading progress...
|
Chargement de la progression...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -88,14 +88,14 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
const processed = progress.processed_files ?? 0;
|
const processed = progress.processed_files ?? 0;
|
||||||
const total = progress.total_files ?? 0;
|
const total = progress.total_files ?? 0;
|
||||||
const isPhase2 = progress.status === "extracting_pages" || progress.status === "generating_thumbnails";
|
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 (
|
return (
|
||||||
<div className="p-4 bg-card rounded-lg border border-border">
|
<div className="p-4 bg-card rounded-lg border border-border">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<StatusBadge status={progress.status} />
|
<StatusBadge status={progress.status} />
|
||||||
{isComplete && (
|
{isComplete && (
|
||||||
<Badge variant="success">Complete</Badge>
|
<Badge variant="success">Terminé</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
<span>{processed} / {total} {unitLabel}</span>
|
<span>{processed} / {total} {unitLabel}</span>
|
||||||
{progress.current_file && (
|
{progress.current_file && (
|
||||||
<span className="truncate max-w-md" title={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.substring(0, 40) + "..."
|
||||||
: progress.current_file}
|
: progress.current_file}
|
||||||
</span>
|
</span>
|
||||||
@@ -114,11 +114,11 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
|
|
||||||
{progress.stats_json && !isPhase2 && (
|
{progress.stats_json && !isPhase2 && (
|
||||||
<div className="flex flex-wrap gap-3 text-xs">
|
<div className="flex flex-wrap gap-3 text-xs">
|
||||||
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
|
<Badge variant="primary">Analysés : {progress.stats_json.scanned_files}</Badge>
|
||||||
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>
|
<Badge variant="success">Indexés : {progress.stats_json.indexed_files}</Badge>
|
||||||
<Badge variant="warning">Removed: {progress.stats_json.removed_files}</Badge>
|
<Badge variant="warning">Supprimés : {progress.stats_json.removed_files}</Badge>
|
||||||
{progress.stats_json.errors > 0 && (
|
{progress.stats_json.errors > 0 && (
|
||||||
<Badge variant="error">Errors: {progress.stats_json.errors}</Badge>
|
<Badge variant="error">Erreurs : {progress.stats_json.errors}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
? job.total_files != null
|
? job.total_files != null
|
||||||
? `${job.processed_files ?? 0}/${job.total_files}`
|
? `${job.processed_files ?? 0}/${job.total_files}`
|
||||||
: scanned > 0
|
: scanned > 0
|
||||||
? `${scanned} scanned`
|
? `${scanned} analysés`
|
||||||
: "-"
|
: "-"
|
||||||
: job.status === "success" && (indexed > 0 || removed > 0 || errors > 0)
|
: job.status === "success" && (indexed > 0 || removed > 0 || errors > 0)
|
||||||
? null // rendered below as ✓ / − / ⚠
|
? null // rendered below as ✓ / − / ⚠
|
||||||
: scanned > 0
|
: scanned > 0
|
||||||
? `${scanned} scanned`
|
? `${scanned} analysés`
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
// Thumbnails column (Phase 2: extracting_pages + generating_thumbnails)
|
// 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"
|
className="text-xs text-primary hover:text-primary/80 hover:underline"
|
||||||
onClick={() => setShowProgress(!showProgress)}
|
onClick={() => setShowProgress(!showProgress)}
|
||||||
>
|
>
|
||||||
{showProgress ? "Hide" : "Show"} progress
|
{showProgress ? "Masquer" : "Afficher"} la progression
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -154,7 +154,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
href={`/jobs/${job.id}`}
|
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"
|
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>
|
</Link>
|
||||||
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
|
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
|
||||||
<Button
|
<Button
|
||||||
@@ -162,7 +162,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onCancel(job.id)}
|
onClick={() => onCancel(job.id)}
|
||||||
>
|
>
|
||||||
Cancel
|
Annuler
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export function JobsIndicator() {
|
|||||||
hover:bg-accent
|
hover:bg-accent
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
"
|
"
|
||||||
title="View all jobs"
|
title="Voir toutes les tâches"
|
||||||
>
|
>
|
||||||
<JobsIcon className="w-[18px] h-[18px]" />
|
<JobsIcon className="w-[18px] h-[18px]" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -187,11 +187,11 @@ export function JobsIndicator() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xl">📊</span>
|
<span className="text-xl">📊</span>
|
||||||
<div>
|
<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">
|
<p className="text-xs text-muted-foreground">
|
||||||
{runningJobs.length > 0
|
{runningJobs.length > 0
|
||||||
? `${runningJobs.length} running, ${pendingJobs.length} pending`
|
? `${runningJobs.length} en cours, ${pendingJobs.length} en attente`
|
||||||
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
|
: `${pendingJobs.length} tâche${pendingJobs.length !== 1 ? 's' : ''} en attente`
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +201,7 @@ export function JobsIndicator() {
|
|||||||
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
|
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
View All →
|
Tout voir →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ export function JobsIndicator() {
|
|||||||
{runningJobs.length > 0 && (
|
{runningJobs.length > 0 && (
|
||||||
<div className="px-4 py-3 border-b border-border/60">
|
<div className="px-4 py-3 border-b border-border/60">
|
||||||
<div className="flex items-center justify-between text-sm mb-2">
|
<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>
|
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar value={totalProgress} size="sm" variant="success" />
|
<ProgressBar value={totalProgress} size="sm" variant="success" />
|
||||||
@@ -221,7 +221,7 @@ export function JobsIndicator() {
|
|||||||
{activeJobs.length === 0 ? (
|
{activeJobs.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||||
<span className="text-4xl mb-2">✅</span>
|
<span className="text-4xl mb-2">✅</span>
|
||||||
<p>No active jobs</p>
|
<p>Aucune tâche active</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border/60">
|
<ul className="divide-y divide-border/60">
|
||||||
@@ -242,7 +242,7 @@ export function JobsIndicator() {
|
|||||||
<div className="flex items-center gap-2 mb-1">
|
<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>
|
<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]">
|
<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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ export function JobsIndicator() {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-4 py-2 border-t border-border/60 bg-muted/50">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -304,7 +304,7 @@ export function JobsIndicator() {
|
|||||||
${isOpen ? 'ring-2 ring-ring ring-offset-2 ring-offset-background' : ''}
|
${isOpen ? 'ring-2 ring-ring ring-offset-2 ring-offset-background' : ''}
|
||||||
`}
|
`}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
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 */}
|
{/* Animated spinner for running jobs */}
|
||||||
{runningJobs.length > 0 && (
|
{runningJobs.length > 0 && (
|
||||||
|
|||||||
@@ -46,12 +46,12 @@ function formatDate(dateStr: string): string {
|
|||||||
|
|
||||||
if (diff < 3600000) {
|
if (diff < 3600000) {
|
||||||
const mins = Math.floor(diff / 60000);
|
const mins = Math.floor(diff / 60000);
|
||||||
if (mins < 1) return "Just now";
|
if (mins < 1) return "À l'instant";
|
||||||
return `${mins}m ago`;
|
return `il y a ${mins}m`;
|
||||||
}
|
}
|
||||||
if (diff < 86400000) {
|
if (diff < 86400000) {
|
||||||
const hours = Math.floor(diff / 3600000);
|
const hours = Math.floor(diff / 3600000);
|
||||||
return `${hours}h ago`;
|
return `il y a ${hours}h`;
|
||||||
}
|
}
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
@@ -103,13 +103,13 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border/60 bg-muted/50">
|
<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">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">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">Statut</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">Fichiers</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">Miniatures</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">Durée</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">Créé</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface LibraryActionsProps {
|
|||||||
scanMode: string;
|
scanMode: string;
|
||||||
watcherEnabled: boolean;
|
watcherEnabled: boolean;
|
||||||
metadataProvider: string | null;
|
metadataProvider: string | null;
|
||||||
|
fallbackMetadataProvider: string | null;
|
||||||
onUpdate?: () => void;
|
onUpdate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export function LibraryActions({
|
|||||||
scanMode,
|
scanMode,
|
||||||
watcherEnabled,
|
watcherEnabled,
|
||||||
metadataProvider,
|
metadataProvider,
|
||||||
|
fallbackMetadataProvider,
|
||||||
onUpdate
|
onUpdate
|
||||||
}: LibraryActionsProps) {
|
}: LibraryActionsProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -43,6 +45,7 @@ export function LibraryActions({
|
|||||||
const watcherEnabled = formData.get("watcher_enabled") === "true";
|
const watcherEnabled = formData.get("watcher_enabled") === "true";
|
||||||
const scanMode = formData.get("scan_mode") as string;
|
const scanMode = formData.get("scan_mode") as string;
|
||||||
const newMetadataProvider = (formData.get("metadata_provider") as string) || null;
|
const newMetadataProvider = (formData.get("metadata_provider") as string) || null;
|
||||||
|
const newFallbackProvider = (formData.get("fallback_metadata_provider") as string) || null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [response] = await Promise.all([
|
const [response] = await Promise.all([
|
||||||
@@ -58,7 +61,7 @@ export function LibraryActions({
|
|||||||
fetch(`/api/libraries/${libraryId}/metadata-provider`, {
|
fetch(`/api/libraries/${libraryId}/metadata-provider`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
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}
|
defaultChecked={monitorEnabled}
|
||||||
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
Auto Scan
|
Scan auto
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,35 +122,55 @@ export function LibraryActions({
|
|||||||
defaultChecked={watcherEnabled}
|
defaultChecked={watcherEnabled}
|
||||||
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
File Watcher ⚡
|
Surveillance fichiers ⚡
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<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
|
<select
|
||||||
name="scan_mode"
|
name="scan_mode"
|
||||||
defaultValue={scanMode}
|
defaultValue={scanMode}
|
||||||
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||||
>
|
>
|
||||||
<option value="manual">Manual</option>
|
<option value="manual">Manuel</option>
|
||||||
<option value="hourly">Hourly</option>
|
<option value="hourly">Toutes les heures</option>
|
||||||
<option value="daily">Daily</option>
|
<option value="daily">Quotidien</option>
|
||||||
<option value="weekly">Weekly</option>
|
<option value="weekly">Hebdomadaire</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||||
{metadataProvider && <ProviderIcon provider={metadataProvider} size={16} />}
|
{metadataProvider && <ProviderIcon provider={metadataProvider} size={16} />}
|
||||||
Metadata Provider
|
Fournisseur
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
name="metadata_provider"
|
name="metadata_provider"
|
||||||
defaultValue={metadataProvider || ""}
|
defaultValue={metadataProvider || ""}
|
||||||
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||||
>
|
>
|
||||||
<option value="">Default</option>
|
<option value="">Par défaut</option>
|
||||||
|
<option value="none">Aucun</option>
|
||||||
|
<option value="google_books">Google Books</option>
|
||||||
|
<option value="comicvine">ComicVine</option>
|
||||||
|
<option value="open_library">Open Library</option>
|
||||||
|
<option value="anilist">AniList</option>
|
||||||
|
<option value="bedetheque">Bédéthèque</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||||
|
{fallbackMetadataProvider && fallbackMetadataProvider !== "none" && <ProviderIcon provider={fallbackMetadataProvider} size={16} />}
|
||||||
|
Secours
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="fallback_metadata_provider"
|
||||||
|
defaultValue={fallbackMetadataProvider || ""}
|
||||||
|
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||||
|
>
|
||||||
|
<option value="">Aucun</option>
|
||||||
<option value="google_books">Google Books</option>
|
<option value="google_books">Google Books</option>
|
||||||
<option value="comicvine">ComicVine</option>
|
<option value="comicvine">ComicVine</option>
|
||||||
<option value="open_library">Open Library</option>
|
<option value="open_library">Open Library</option>
|
||||||
@@ -168,7 +191,7 @@ export function LibraryActions({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
{isPending ? "Saving..." : "Save Settings"}
|
{isPending ? "Enregistrement..." : "Enregistrer"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
|
|||||||
<form action={action}>
|
<form action={action}>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<FormField className="flex-1 min-w-48">
|
<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>
|
||||||
<FormField className="flex-1 min-w-64">
|
<FormField className="flex-1 min-w-64">
|
||||||
<input type="hidden" name="root_path" value={selectedPath} />
|
<input type="hidden" name="root_path" value={selectedPath} />
|
||||||
@@ -30,7 +30,7 @@ export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
|
|||||||
</FormRow>
|
</FormRow>
|
||||||
<div className="mt-4 flex justify-end">
|
<div className="mt-4 flex justify-end">
|
||||||
<Button type="submit" disabled={!selectedPath}>
|
<Button type="submit" disabled={!selectedPath}>
|
||||||
Add Library
|
Ajouter une bibliothèque
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function LibrarySubPageHeader({
|
|||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
Libraries
|
Bibliothèques
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-muted-foreground">/</span>
|
<span className="text-muted-foreground">/</span>
|
||||||
<span className="text-sm text-foreground font-medium">{library.name}</span>
|
<span className="text-sm text-foreground font-medium">{library.name}</span>
|
||||||
@@ -74,7 +74,7 @@ export function LibrarySubPageHeader({
|
|||||||
</svg>
|
</svg>
|
||||||
<span className="text-foreground">
|
<span className="text-foreground">
|
||||||
<span className="font-semibold">{library.book_count}</span>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export function LibrarySubPageHeader({
|
|||||||
variant={library.enabled ? "success" : "muted"}
|
variant={library.enabled ? "success" : "muted"}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{library.enabled ? "Enabled" : "Disabled"}
|
{library.enabled ? "Activée" : "Désactivée"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
|||||||
w-full sm:w-auto
|
w-full sm:w-auto
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Clear
|
Effacer
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -62,6 +62,23 @@ export function MetadataSearchModal({
|
|||||||
// Provider selector: empty string = library default
|
// Provider selector: empty string = library default
|
||||||
const [searchProvider, setSearchProvider] = useState("");
|
const [searchProvider, setSearchProvider] = useState("");
|
||||||
const [activeProvider, setActiveProvider] = 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(() => {
|
const handleOpen = useCallback(() => {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
@@ -109,7 +126,7 @@ export function MetadataSearchModal({
|
|||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
setError(data.error || "Search failed");
|
setError(data.error || "Échec de la recherche");
|
||||||
setStep("results");
|
setStep("results");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -121,7 +138,7 @@ export function MetadataSearchModal({
|
|||||||
}
|
}
|
||||||
setStep("results");
|
setStep("results");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Network error");
|
setError("Erreur réseau");
|
||||||
setStep("results");
|
setStep("results");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,7 +177,7 @@ export function MetadataSearchModal({
|
|||||||
});
|
});
|
||||||
const matchData = await matchResp.json();
|
const matchData = await matchResp.json();
|
||||||
if (!matchResp.ok) {
|
if (!matchResp.ok) {
|
||||||
setError(matchData.error || "Failed to create match");
|
setError(matchData.error || "Échec de la création du lien");
|
||||||
setStep("results");
|
setStep("results");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -179,7 +196,7 @@ export function MetadataSearchModal({
|
|||||||
});
|
});
|
||||||
const approveData = await approveResp.json();
|
const approveData = await approveResp.json();
|
||||||
if (!approveResp.ok) {
|
if (!approveResp.ok) {
|
||||||
setError(approveData.error || "Failed to approve");
|
setError(approveData.error || "Échec de l'approbation");
|
||||||
setStep("results");
|
setStep("results");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -201,7 +218,7 @@ export function MetadataSearchModal({
|
|||||||
|
|
||||||
setStep("done");
|
setStep("done");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Network error");
|
setError("Erreur réseau");
|
||||||
setStep("results");
|
setStep("results");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +262,7 @@ export function MetadataSearchModal({
|
|||||||
{/* Header */}
|
{/* 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">
|
<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">
|
<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>
|
</h3>
|
||||||
<button type="button" onClick={handleClose}>
|
<button type="button" onClick={handleClose}>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-muted-foreground hover:text-foreground">
|
<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 */}
|
{/* Provider selector — visible during searching & results */}
|
||||||
{(step === "searching" || step === "results") && (
|
{(step === "searching" || step === "results") && (
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{PROVIDERS.map((p) => (
|
{visibleProviders.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.value}
|
key={p.value}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -287,7 +304,7 @@ export function MetadataSearchModal({
|
|||||||
{step === "searching" && (
|
{step === "searching" && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
|
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
|
||||||
<span className="ml-3 text-muted-foreground">Searching for "{seriesName}"...</span>
|
<span className="ml-3 text-muted-foreground">Recherche de "{seriesName}"...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -302,11 +319,11 @@ export function MetadataSearchModal({
|
|||||||
{step === "results" && (
|
{step === "results" && (
|
||||||
<>
|
<>
|
||||||
{candidates.length === 0 && !error ? (
|
{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">
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground mb-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 && (
|
{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>
|
<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>
|
||||||
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -395,16 +412,16 @@ export function MetadataSearchModal({
|
|||||||
onClick={() => handleApprove(true, false)}
|
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"
|
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="font-medium text-sm text-foreground">Synchroniser la série uniquement</p>
|
||||||
<p className="text-xs text-muted-foreground">Update description, authors, publishers, and year</p>
|
<p className="text-xs text-muted-foreground">Mettre à jour la description, les auteurs, les éditeurs et l'année</p>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleApprove(true, true)}
|
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"
|
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="font-medium text-sm text-foreground">Synchroniser la série + les livres</p>
|
||||||
<p className="text-xs text-muted-foreground">Also fetch book list and show missing volumes</p>
|
<p className="text-xs text-muted-foreground">Récupérer aussi la liste des livres et afficher les tomes manquants</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -413,7 +430,7 @@ export function MetadataSearchModal({
|
|||||||
onClick={() => { setSelectedCandidate(null); setStep("results"); }}
|
onClick={() => { setSelectedCandidate(null); setStep("results"); }}
|
||||||
className="text-sm text-muted-foreground hover:text-foreground"
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Back to results
|
Retour aux résultats
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -422,7 +439,7 @@ export function MetadataSearchModal({
|
|||||||
{step === "syncing" && (
|
{step === "syncing" && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Icon name="spinner" size="lg" className="animate-spin text-primary" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -430,7 +447,7 @@ export function MetadataSearchModal({
|
|||||||
{step === "done" && (
|
{step === "done" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/30">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Sync Report */}
|
{/* 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" />
|
<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>
|
</svg>
|
||||||
<span className="font-medium">{fieldLabel(f.field)}</span>
|
<span className="font-medium">{fieldLabel(f.field)}</span>
|
||||||
<span className="text-muted-foreground">locked</span>
|
<span className="text-muted-foreground">verrouillé</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -480,7 +497,7 @@ export function MetadataSearchModal({
|
|||||||
{!syncReport.books_message && (syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (
|
{!syncReport.books_message && (syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (
|
||||||
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
|
<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">
|
<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>
|
</p>
|
||||||
{syncReport.books.length > 0 && (
|
{syncReport.books.length > 0 && (
|
||||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="font-medium">{fieldLabel(f.field)}</span>
|
<span className="font-medium">{fieldLabel(f.field)}</span>
|
||||||
<span className="text-muted-foreground">locked</span>
|
<span className="text-muted-foreground">verrouillé</span>
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -521,15 +538,15 @@ export function MetadataSearchModal({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||||
<div>
|
<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>
|
<p className="text-2xl font-semibold">{missing.total_external}</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-2xl font-semibold">{missing.total_local}</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-2xl font-semibold text-warning">{missing.missing_count}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -542,14 +559,14 @@ export function MetadataSearchModal({
|
|||||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
|
<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>
|
</button>
|
||||||
{showMissingList && (
|
{showMissingList && (
|
||||||
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
<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) => (
|
{missing.missing_books.map((b, i) => (
|
||||||
<p key={i} className="text-muted-foreground truncate">
|
<p key={i} className="text-muted-foreground truncate">
|
||||||
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
|
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
|
||||||
{b.title || "Unknown"}
|
{b.title || "Inconnu"}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -564,7 +581,7 @@ export function MetadataSearchModal({
|
|||||||
onClick={() => { handleClose(); router.refresh(); }}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -576,7 +593,7 @@ export function MetadataSearchModal({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-foreground inline-flex items-center gap-1.5">
|
<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>
|
</p>
|
||||||
{existingLink.external_url && (
|
{existingLink.external_url && (
|
||||||
<a
|
<a
|
||||||
@@ -585,7 +602,7 @@ export function MetadataSearchModal({
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block mt-1 text-xs text-primary hover:underline"
|
className="block mt-1 text-xs text-primary hover:underline"
|
||||||
>
|
>
|
||||||
View on external source
|
Voir sur la source externe
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -618,14 +635,14 @@ export function MetadataSearchModal({
|
|||||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Icon name={showMissingList ? "chevronDown" : "chevronRight"} size="sm" />
|
<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>
|
</button>
|
||||||
{showMissingList && (
|
{showMissingList && (
|
||||||
<div className="mt-2 max-h-40 overflow-y-auto p-3 bg-muted/20 rounded-lg text-sm space-y-1">
|
<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) => (
|
{initialMissing.missing_books.map((b, i) => (
|
||||||
<p key={i} className="text-muted-foreground truncate">
|
<p key={i} className="text-muted-foreground truncate">
|
||||||
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
|
{b.volume_number != null && <span className="font-mono mr-2">#{b.volume_number}</span>}
|
||||||
{b.title || "Unknown"}
|
{b.title || "Inconnu"}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -639,14 +656,14 @@ export function MetadataSearchModal({
|
|||||||
onClick={() => { doSearch(""); }}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleUnlink}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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" />
|
<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>
|
</button>
|
||||||
|
|
||||||
{/* Inline badge when linked */}
|
{/* Inline badge when linked */}
|
||||||
{existingLink && existingLink.status === "approved" && initialMissing && initialMissing.missing_count > 0 && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
|||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
<NavIcon name="settings" />
|
<NavIcon name="settings" />
|
||||||
<span className="font-medium">Settings</span>
|
<span className="font-medium">Paramètres</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -90,7 +90,7 @@ export function MobileNav({ navItems }: { navItems: NavItem[] }) {
|
|||||||
<button
|
<button
|
||||||
className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
aria-label={isOpen ? "Close menu" : "Open menu"}
|
aria-label={isOpen ? "Fermer le menu" : "Ouvrir le menu"}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
{isOpen ? <XIcon /> : <HamburgerIcon />}
|
{isOpen ? <XIcon /> : <HamburgerIcon />}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="w-3.5 h-3.5 rounded border-border text-warning focus:ring-warning"
|
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>
|
</label>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
@@ -76,10 +76,10 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
|||||||
disabled={isPending}
|
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"
|
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="manual">Manuel</option>
|
||||||
<option value="hourly">Hourly</option>
|
<option value="hourly">Toutes les heures</option>
|
||||||
<option value="daily">Daily</option>
|
<option value="daily">Quotidien</option>
|
||||||
<option value="weekly">Weekly</option>
|
<option value="weekly">Hebdomadaire</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
51
apps/backoffice/app/components/SeriesFilters.tsx
Normal file
51
apps/backoffice/app/components/SeriesFilters.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
interface SeriesFiltersProps {
|
||||||
|
basePath: string;
|
||||||
|
currentSeriesStatus?: string;
|
||||||
|
currentHasMissing: boolean;
|
||||||
|
seriesStatusOptions: { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeriesFilters({ basePath, currentSeriesStatus, currentHasMissing, seriesStatusOptions }: SeriesFiltersProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const updateFilter = useCallback((key: string, value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
params.delete("page");
|
||||||
|
const qs = params.toString();
|
||||||
|
router.push(`${basePath}${qs ? `?${qs}` : ""}` as any);
|
||||||
|
}, [router, searchParams, basePath]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<select
|
||||||
|
value={currentSeriesStatus || ""}
|
||||||
|
onChange={(e) => updateFilter("series_status", e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-lg border border-border bg-card text-foreground text-sm"
|
||||||
|
>
|
||||||
|
{seriesStatusOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={currentHasMissing ? "true" : ""}
|
||||||
|
onChange={(e) => updateFilter("has_missing", e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-lg border border-border bg-card text-foreground text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Tous</option>
|
||||||
|
<option value="true">Livres manquants</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,8 +71,8 @@ const statusVariants: Record<string, BadgeVariant> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
extracting_pages: "Extracting pages",
|
extracting_pages: "Extraction des pages",
|
||||||
generating_thumbnails: "Thumbnails",
|
generating_thumbnails: "Miniatures",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
interface StatusBadgeProps {
|
||||||
@@ -96,10 +96,10 @@ const jobTypeVariants: Record<string, BadgeVariant> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const jobTypeLabels: Record<string, string> = {
|
const jobTypeLabels: Record<string, string> = {
|
||||||
rebuild: "Index",
|
rebuild: "Indexation",
|
||||||
full_rebuild: "Full Index",
|
full_rebuild: "Indexation complète",
|
||||||
thumbnail_rebuild: "Thumbnails",
|
thumbnail_rebuild: "Miniatures",
|
||||||
thumbnail_regenerate: "Regen. Thumbnails",
|
thumbnail_regenerate: "Régén. miniatures",
|
||||||
cbr_to_cbz: "CBR → CBZ",
|
cbr_to_cbz: "CBR → CBZ",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function CursorPagination({
|
|||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
|
<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 */}
|
{/* Page size selector */}
|
||||||
<div className="flex items-center gap-3">
|
<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
|
<select
|
||||||
value={pageSize.toString()}
|
value={pageSize.toString()}
|
||||||
onChange={(e) => changePageSize(Number(e.target.value))}
|
onChange={(e) => changePageSize(Number(e.target.value))}
|
||||||
@@ -60,12 +60,12 @@ export function CursorPagination({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-sm text-muted-foreground">per page</span>
|
<span className="text-sm text-muted-foreground">par page</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Count info */}
|
{/* Count info */}
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Showing {currentCount} items
|
Affichage de {currentCount} éléments
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* 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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
First
|
Premier
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -88,7 +88,7 @@ export function CursorPagination({
|
|||||||
onClick={goToNext}
|
onClick={goToNext}
|
||||||
disabled={!hasNextPage}
|
disabled={!hasNextPage}
|
||||||
>
|
>
|
||||||
Next
|
Suivant
|
||||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</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">
|
<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 */}
|
{/* Page size selector */}
|
||||||
<div className="flex items-center gap-3">
|
<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
|
<select
|
||||||
value={pageSize.toString()}
|
value={pageSize.toString()}
|
||||||
onChange={(e) => changePageSize(Number(e.target.value))}
|
onChange={(e) => changePageSize(Number(e.target.value))}
|
||||||
@@ -182,12 +182,12 @@ export function OffsetPagination({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-sm text-muted-foreground">per page</span>
|
<span className="text-sm text-muted-foreground">par page</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page info */}
|
{/* Page info */}
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{startItem}-{endItem} of {totalItems}
|
{startItem}-{endItem} sur {totalItems}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page navigation */}
|
{/* Page navigation */}
|
||||||
@@ -196,7 +196,7 @@ export function OffsetPagination({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => goToPage(currentPage - 1)}
|
onClick={() => goToPage(currentPage - 1)}
|
||||||
disabled={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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
@@ -224,7 +224,7 @@ export function OffsetPagination({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => goToPage(currentPage + 1)}
|
onClick={() => goToPage(currentPage + 1)}
|
||||||
disabled={currentPage >= totalPages}
|
disabled={currentPage >= totalPages}
|
||||||
title="Next page"
|
title="Page suivante"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { apiFetch } from "../../../lib/api";
|
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, MetadataBatchReportDto, MetadataBatchResultDto } from "../../../lib/api";
|
||||||
import {
|
import {
|
||||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||||
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
||||||
@@ -44,28 +44,33 @@ interface JobError {
|
|||||||
|
|
||||||
const JOB_TYPE_INFO: Record<string, { label: string; description: string; isThumbnailOnly: boolean }> = {
|
const JOB_TYPE_INFO: Record<string, { label: string; description: string; isThumbnailOnly: boolean }> = {
|
||||||
rebuild: {
|
rebuild: {
|
||||||
label: "Incremental index",
|
label: "Indexation incrémentale",
|
||||||
description: "Scans for new/modified files, analyzes them and generates missing thumbnails.",
|
description: "Scanne les fichiers nouveaux/modifiés, les analyse et génère les miniatures manquantes.",
|
||||||
isThumbnailOnly: false,
|
isThumbnailOnly: false,
|
||||||
},
|
},
|
||||||
full_rebuild: {
|
full_rebuild: {
|
||||||
label: "Full re-index",
|
label: "Réindexation complète",
|
||||||
description: "Clears all existing data then performs a complete re-scan, re-analysis and thumbnail generation.",
|
description: "Supprime toutes les données existantes puis effectue un scan complet, une ré-analyse et la génération des miniatures.",
|
||||||
isThumbnailOnly: false,
|
isThumbnailOnly: false,
|
||||||
},
|
},
|
||||||
thumbnail_rebuild: {
|
thumbnail_rebuild: {
|
||||||
label: "Thumbnail rebuild",
|
label: "Reconstruction des miniatures",
|
||||||
description: "Generates thumbnails only for books that are missing one. Existing thumbnails are preserved.",
|
description: "Génère les miniatures uniquement pour les livres qui n'en ont pas. Les miniatures existantes sont conservées.",
|
||||||
isThumbnailOnly: true,
|
isThumbnailOnly: true,
|
||||||
},
|
},
|
||||||
thumbnail_regenerate: {
|
thumbnail_regenerate: {
|
||||||
label: "Thumbnail regeneration",
|
label: "Regénération des miniatures",
|
||||||
description: "Regenerates all thumbnails from scratch, replacing existing ones.",
|
description: "Regénère toutes les miniatures depuis zéro, en remplaçant les existantes.",
|
||||||
isThumbnailOnly: true,
|
isThumbnailOnly: true,
|
||||||
},
|
},
|
||||||
cbr_to_cbz: {
|
cbr_to_cbz: {
|
||||||
label: "CBR → CBZ conversion",
|
label: "Conversion CBR → CBZ",
|
||||||
description: "Converts a CBR archive to the open CBZ format.",
|
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,
|
isThumbnailOnly: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -112,6 +117,18 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
notFound();
|
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] ?? {
|
const typeInfo = JOB_TYPE_INFO[job.type] ?? {
|
||||||
label: job.type,
|
label: job.type,
|
||||||
description: null,
|
description: null,
|
||||||
@@ -131,21 +148,25 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
const { isThumbnailOnly } = typeInfo;
|
const { isThumbnailOnly } = typeInfo;
|
||||||
|
|
||||||
// Which label to use for the progress card
|
// Which label to use for the progress card
|
||||||
const progressTitle = isThumbnailOnly
|
const progressTitle = isMetadataBatch
|
||||||
? "Thumbnails"
|
? "Recherche de métadonnées"
|
||||||
: isExtractingPages
|
: isThumbnailOnly
|
||||||
? "Phase 2 — Extracting pages"
|
? "Miniatures"
|
||||||
: isThumbnailPhase
|
: isExtractingPages
|
||||||
? "Phase 2 — Thumbnails"
|
? "Phase 2 — Extraction des pages"
|
||||||
: "Phase 1 — Discovery";
|
: isThumbnailPhase
|
||||||
|
? "Phase 2 — Miniatures"
|
||||||
|
: "Phase 1 — Découverte";
|
||||||
|
|
||||||
const progressDescription = isThumbnailOnly
|
const progressDescription = isMetadataBatch
|
||||||
? undefined
|
? "Recherche auprès des fournisseurs externes pour chaque série"
|
||||||
: isExtractingPages
|
: isThumbnailOnly
|
||||||
? "Extracting first page from each archive (page count + raw image)"
|
? undefined
|
||||||
: isThumbnailPhase
|
: isExtractingPages
|
||||||
? "Generating thumbnails for the analyzed books"
|
? "Extraction de la première page de chaque archive (nombre de pages + image brute)"
|
||||||
: "Scanning and indexing files in the library";
|
: 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
|
// Speed metric: thumbnail count for thumbnail jobs, scanned files for index jobs
|
||||||
const speedCount = isThumbnailOnly
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
Back to jobs
|
Retour aux tâches
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Summary banner — completed */}
|
{/* 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" />
|
<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>
|
</svg>
|
||||||
<div className="text-sm text-success">
|
<div className="text-sm text-success">
|
||||||
<span className="font-semibold">Completed in {formatDuration(job.started_at, job.finished_at)}</span>
|
<span className="font-semibold">Terminé en {formatDuration(job.started_at, job.finished_at)}</span>
|
||||||
{job.stats_json && (
|
{isMetadataBatch && batchReport && (
|
||||||
<span className="ml-2 text-success/80">
|
<span className="ml-2 text-success/80">
|
||||||
— {job.stats_json.scanned_files} scanned, {job.stats_json.indexed_files} indexed
|
— {batchReport.auto_matched} auto-associées, {batchReport.already_linked} déjà liées, {batchReport.no_results} aucun résultat, {batchReport.errors} erreurs
|
||||||
{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`}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!job.stats_json && isThumbnailOnly && job.total_files != null && (
|
{!isMetadataBatch && job.stats_json && (
|
||||||
<span className="ml-2 text-success/80">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="text-sm text-destructive">
|
<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 && (
|
{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 && (
|
{job.error_opt && (
|
||||||
<p className="mt-1 text-destructive/70 font-mono text-xs break-all">{job.error_opt}</p>
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
<span className="font-semibold">Cancelled</span>
|
<span className="font-semibold">Annulé</span>
|
||||||
{job.started_at && (
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,7 +260,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
{/* Overview Card */}
|
{/* Overview Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Overview</CardTitle>
|
<CardTitle>Aperçu</CardTitle>
|
||||||
{typeInfo.description && (
|
{typeInfo.description && (
|
||||||
<CardDescription>{typeInfo.description}</CardDescription>
|
<CardDescription>{typeInfo.description}</CardDescription>
|
||||||
)}
|
)}
|
||||||
@@ -252,16 +278,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
<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} />
|
<StatusBadge status={job.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex items-center justify-between py-2 ${(job.book_id || job.started_at) ? "border-b border-border/60" : ""}`}>
|
<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-muted-foreground">Bibliothèque</span>
|
||||||
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
|
<span className="text-sm text-foreground">{job.library_id || "Toutes les bibliothèques"}</span>
|
||||||
</div>
|
</div>
|
||||||
{job.book_id && (
|
{job.book_id && (
|
||||||
<div className={`flex items-center justify-between py-2 ${job.started_at ? "border-b border-border/60" : ""}`}>
|
<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
|
<Link
|
||||||
href={`/books/${job.book_id}`}
|
href={`/books/${job.book_id}`}
|
||||||
className="text-sm text-primary hover:text-primary/80 font-mono hover:underline"
|
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 && (
|
{job.started_at && (
|
||||||
<div className="flex items-center justify-between py-2">
|
<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">
|
<span className="text-sm font-semibold text-foreground">
|
||||||
{formatDuration(job.started_at, job.finished_at)}
|
{formatDuration(job.started_at, job.finished_at)}
|
||||||
</span>
|
</span>
|
||||||
@@ -284,7 +310,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
{/* Timeline Card */}
|
{/* Timeline Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Timeline</CardTitle>
|
<CardTitle>Chronologie</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -296,7 +322,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<div className="flex items-start gap-4">
|
<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="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">
|
<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>
|
<p className="text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,15 +332,15 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<div className="flex items-start gap-4">
|
<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="w-3.5 h-3.5 rounded-full mt-0.5 bg-primary shrink-0 z-10" />
|
||||||
<div className="flex-1 min-w-0">
|
<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-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
|
||||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
<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 && (
|
{job.stats_json && (
|
||||||
<span className="text-muted-foreground font-normal ml-1">
|
<span className="text-muted-foreground font-normal ml-1">
|
||||||
· {job.stats_json.scanned_files} scanned, {job.stats_json.indexed_files} indexed
|
· {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} removed`}
|
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} supprimés`}
|
||||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} warn`}
|
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} avert.`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</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"
|
job.generating_thumbnails_started_at || job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||||
}`} />
|
}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<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-muted-foreground">{new Date(job.phase2_started_at).toLocaleString()}</p>
|
||||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
<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 && (
|
{!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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,26 +375,26 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
}`} />
|
}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{isThumbnailOnly ? "Thumbnails" : "Phase 2b — Generating thumbnails"}
|
{isThumbnailOnly ? "Miniatures" : "Phase 2b — Génération des miniatures"}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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()}
|
{(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>
|
</p>
|
||||||
{(job.generating_thumbnails_started_at || job.finished_at) && (
|
{(job.generating_thumbnails_started_at || job.finished_at) && (
|
||||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
<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.generating_thumbnails_started_at ?? job.phase2_started_at!,
|
||||||
job.finished_at ?? null
|
job.finished_at ?? null
|
||||||
)}
|
)}
|
||||||
{job.total_files != null && job.total_files > 0 && (
|
{job.total_files != null && job.total_files > 0 && (
|
||||||
<span className="text-muted-foreground font-normal ml-1">
|
<span className="text-muted-foreground font-normal ml-1">
|
||||||
· {job.processed_files ?? job.total_files} thumbnails
|
· {job.processed_files ?? job.total_files} miniatures
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!job.finished_at && isThumbnailPhase && (
|
{!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>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,7 +407,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||||
}`} />
|
}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<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>
|
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,7 +418,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<div className="flex items-start gap-4">
|
<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="w-3.5 h-3.5 rounded-full mt-0.5 bg-warning shrink-0 z-10" />
|
||||||
<div className="flex-1 min-w-0">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -405,7 +431,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
}`} />
|
}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{isCompleted ? "Completed" : isFailed ? "Failed" : "Cancelled"}
|
{isCompleted ? "Terminé" : isFailed ? "Échoué" : "Annulé"}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-muted-foreground">{new Date(job.finished_at).toLocaleString()}</p>
|
<p className="text-xs text-muted-foreground">{new Date(job.finished_at).toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -430,13 +456,13 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<StatBox
|
<StatBox
|
||||||
value={job.processed_files ?? 0}
|
value={job.processed_files ?? 0}
|
||||||
label={isThumbnailOnly || isPhase2 ? "Generated" : "Processed"}
|
label={isThumbnailOnly || isPhase2 ? "Générés" : "Traités"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
/>
|
/>
|
||||||
<StatBox value={job.total_files} label="Total" />
|
<StatBox value={job.total_files} label="Total" />
|
||||||
<StatBox
|
<StatBox
|
||||||
value={Math.max(0, job.total_files - (job.processed_files ?? 0))}
|
value={Math.max(0, job.total_files - (job.processed_files ?? 0))}
|
||||||
label="Remaining"
|
label="Restants"
|
||||||
variant={isCompleted ? "default" : "warning"}
|
variant={isCompleted ? "default" : "warning"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -444,7 +470,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
)}
|
)}
|
||||||
{job.current_file && (
|
{job.current_file && (
|
||||||
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
<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>
|
<code className="block mt-1 text-xs font-mono text-foreground break-all">{job.current_file}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -453,10 +479,10 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Index Statistics — index jobs only */}
|
{/* Index Statistics — index jobs only */}
|
||||||
{job.stats_json && !isThumbnailOnly && (
|
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Index statistics</CardTitle>
|
<CardTitle>Statistiques d'indexation</CardTitle>
|
||||||
{job.started_at && (
|
{job.started_at && (
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{formatDuration(job.started_at, job.finished_at)}
|
{formatDuration(job.started_at, job.finished_at)}
|
||||||
@@ -466,11 +492,11 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
<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.scanned_files} label="Scannés" variant="success" />
|
||||||
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
|
<StatBox value={job.stats_json.indexed_files} label="Indexés" variant="primary" />
|
||||||
<StatBox value={job.stats_json.removed_files} label="Removed" variant="warning" />
|
<StatBox value={job.stats_json.removed_files} label="Supprimés" 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.warnings ?? 0} label="Avertissements" 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.errors} label="Erreurs" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -480,7 +506,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
{isThumbnailOnly && isCompleted && job.total_files != null && (
|
{isThumbnailOnly && isCompleted && job.total_files != null && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Thumbnail statistics</CardTitle>
|
<CardTitle>Statistiques des miniatures</CardTitle>
|
||||||
{job.started_at && (
|
{job.started_at && (
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{formatDuration(job.started_at, job.finished_at)}
|
{formatDuration(job.started_at, job.finished_at)}
|
||||||
@@ -490,19 +516,102 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<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" />
|
<StatBox value={job.total_files} label="Total" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* File errors */}
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>File errors ({errors.length})</CardTitle>
|
<CardTitle>Erreurs de fichiers ({errors.length})</CardTitle>
|
||||||
<CardDescription>Errors encountered while processing individual files</CardDescription>
|
<CardDescription>Erreurs rencontrées lors du traitement des fichiers</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
|
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
|
||||||
{errors.map((error) => (
|
{errors.map((error) => (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
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 { 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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -47,6 +47,15 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
redirect(`/jobs?highlight=${result.id}`);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
Index Jobs
|
Tâches d'indexation
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Queue New Job</CardTitle>
|
<CardTitle>Lancer une tâche</CardTitle>
|
||||||
|
<CardDescription>Sélectionnez une bibliothèque (ou toutes) et choisissez l'action à effectuer.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form>
|
<form>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<FormField className="flex-1 max-w-xs">
|
<FormField className="flex-1 max-w-xs">
|
||||||
<FormSelect name="library_id" defaultValue="">
|
<FormSelect name="library_id" defaultValue="">
|
||||||
<option value="">All libraries</option>
|
<option value="">Toutes les bibliothèques</option>
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => (
|
||||||
<option key={lib.id} value={lib.id}>{lib.name}</option>
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Rebuild
|
Reconstruction
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" formAction={triggerFullRebuild} variant="warning">
|
<Button type="submit" formAction={triggerFullRebuild} variant="warning">
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
Full Rebuild
|
Reconstruction complète
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" formAction={triggerThumbnailsRebuild} variant="secondary">
|
<Button type="submit" formAction={triggerThumbnailsRebuild} variant="secondary">
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
Generate thumbnails
|
Générer les miniatures
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" formAction={triggerThumbnailsRegenerate} variant="warning">
|
<Button type="submit" formAction={triggerThumbnailsRegenerate} variant="warning">
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
@@ -104,6 +120,82 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Job types legend */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Référence des types de tâches</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="shrink-0 mt-0.5">
|
||||||
|
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Reconstruction</span>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
Scan incrémental : détecte les fichiers ajoutés, modifiés ou supprimés depuis le dernier scan, les indexe et génère les miniatures manquantes. Les données existantes non modifiées sont conservées. C’est l’action la plus courante et la plus rapide.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="shrink-0 mt-0.5">
|
||||||
|
<svg className="w-5 h-5 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Reconstruction complète</span>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
Supprime toutes les données indexées (livres, séries, miniatures) puis effectue un scan complet depuis zéro. Utile si la base de données est désynchronisée ou corrompue. Opération longue et destructive : les statuts de lecture et les métadonnées manuelles seront perdus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="shrink-0 mt-0.5">
|
||||||
|
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Générer les miniatures</span>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
Génère les miniatures uniquement pour les livres qui n’en ont pas encore. Les miniatures existantes ne sont pas touchées. Utile après un import ou si certaines miniatures sont manquantes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="shrink-0 mt-0.5">
|
||||||
|
<svg className="w-5 h-5 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Regénérer les miniatures</span>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
Regénère toutes les miniatures depuis zéro, en remplaçant les existantes. Utile si la qualité ou la taille des miniatures a changé dans la configuration, ou si des miniatures sont corrompues.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="shrink-0 mt-0.5">
|
||||||
|
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Métadonnées en lot</span>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
Recherche automatiquement les métadonnées de chaque série de la bibliothèque auprès du provider configuré (avec fallback si configuré). Seuls les résultats avec un match unique à 100% de confiance sont appliqués automatiquement. Les séries déjà liées sont ignorées. Un rapport détaillé par série est disponible à la fin du job. <strong>Requiert une bibliothèque spécifique</strong> (ne fonctionne pas sur « Toutes les bibliothèques »).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<JobsList
|
<JobsList
|
||||||
initialJobs={jobs}
|
initialJobs={jobs}
|
||||||
libraries={libraryMap}
|
libraries={libraryMap}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { MobileNav } from "./components/MobileNav";
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "StripStream Backoffice",
|
title: "StripStream Backoffice",
|
||||||
description: "Backoffice administration for StripStream Librarian"
|
description: "Administration backoffice pour StripStream Librarian"
|
||||||
};
|
};
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
@@ -21,17 +21,17 @@ type NavItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ href: "/", label: "Dashboard", icon: "dashboard" },
|
{ href: "/", label: "Tableau de bord", icon: "dashboard" },
|
||||||
{ href: "/books", label: "Books", icon: "books" },
|
{ href: "/books", label: "Livres", icon: "books" },
|
||||||
{ href: "/series", label: "Series", icon: "series" },
|
{ href: "/series", label: "Séries", icon: "series" },
|
||||||
{ href: "/libraries", label: "Libraries", icon: "libraries" },
|
{ href: "/libraries", label: "Bibliothèques", icon: "libraries" },
|
||||||
{ href: "/jobs", label: "Jobs", icon: "jobs" },
|
{ href: "/jobs", label: "Tâches", icon: "jobs" },
|
||||||
{ href: "/tokens", label: "Tokens", icon: "tokens" },
|
{ href: "/tokens", label: "Jetons", icon: "tokens" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="fr" suppressHydrationWarning>
|
||||||
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
|
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{/* Header avec effet glassmorphism */}
|
{/* Header avec effet glassmorphism */}
|
||||||
@@ -76,7 +76,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
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" />
|
<Icon name="settings" size="md" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ export default async function LibraryBooksPage({
|
|||||||
coverUrl: getBookCoverUrl(book.id)
|
coverUrl: getBookCoverUrl(book.id)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const seriesDisplayName = series === "unclassified" ? "Unclassified" : series;
|
const seriesDisplayName = series === "unclassified" ? "Non classé" : series;
|
||||||
const totalPages = Math.ceil(booksPage.total / limit);
|
const totalPages = Math.ceil(booksPage.total / limit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<LibrarySubPageHeader
|
<LibrarySubPageHeader
|
||||||
library={library}
|
library={library}
|
||||||
title={series ? `Books in "${seriesDisplayName}"` : "All Books"}
|
title={series ? `Livres de "${seriesDisplayName}"` : "Tous les livres"}
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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"
|
iconColor="text-success"
|
||||||
filterInfo={series ? {
|
filterInfo={series ? {
|
||||||
label: `Showing books from series "${seriesDisplayName}"`,
|
label: `Livres de la série "${seriesDisplayName}"`,
|
||||||
clearHref: `/libraries/${id}/books`,
|
clearHref: `/libraries/${id}/books`,
|
||||||
clearLabel: "View all books"
|
clearLabel: "Voir tous les livres"
|
||||||
} : undefined}
|
} : 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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default async function SeriesDetailPage({
|
|||||||
|
|
||||||
const totalPages = Math.ceil(booksPage.total / limit);
|
const totalPages = Math.ceil(booksPage.total / limit);
|
||||||
const booksReadCount = booksPage.items.filter((b) => b.reading_status === "read").length;
|
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
|
// Use first book cover as series cover
|
||||||
const coverBookId = booksPage.items[0]?.id;
|
const coverBookId = booksPage.items[0]?.id;
|
||||||
@@ -68,7 +68,7 @@ export default async function SeriesDetailPage({
|
|||||||
href="/libraries"
|
href="/libraries"
|
||||||
className="text-muted-foreground hover:text-primary transition-colors"
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
Libraries
|
Bibliothèques
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-muted-foreground">/</span>
|
<span className="text-muted-foreground">/</span>
|
||||||
<Link
|
<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">
|
<div className="w-40 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(coverBookId)}
|
src={getBookCoverUrl(coverBookId)}
|
||||||
alt={`Cover of ${displayName}`}
|
alt={`Couverture de ${displayName}`}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
|
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
|
||||||
import { OffsetPagination } from "../../../components/ui";
|
import { OffsetPagination } from "../../../components/ui";
|
||||||
import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton";
|
import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton";
|
||||||
|
import { SeriesFilters } from "../../../components/SeriesFilters";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -19,10 +20,13 @@ export default async function LibrarySeriesPage({
|
|||||||
const searchParamsAwaited = await searchParams;
|
const searchParamsAwaited = await searchParams;
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
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)),
|
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) {
|
if (!library) {
|
||||||
@@ -32,11 +36,23 @@ export default async function LibrarySeriesPage({
|
|||||||
const series = seriesPage.items;
|
const series = seriesPage.items;
|
||||||
const totalPages = Math.ceil(seriesPage.total / limit);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<LibrarySubPageHeader
|
<LibrarySubPageHeader
|
||||||
library={library}
|
library={library}
|
||||||
title="Series"
|
title="Séries"
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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"
|
iconColor="text-primary"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SeriesFilters
|
||||||
|
basePath={`/libraries/${id}/series`}
|
||||||
|
currentSeriesStatus={seriesStatus}
|
||||||
|
currentHasMissing={hasMissing}
|
||||||
|
seriesStatusOptions={seriesStatusOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
{series.length > 0 ? (
|
{series.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
<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">
|
<div className="aspect-[2/3] relative bg-muted/50">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(s.first_book_id)}
|
src={getBookCoverUrl(s.first_book_id)}
|
||||||
alt={`Cover of ${s.name}`}
|
alt={`Couverture de ${s.name}`}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
@@ -66,7 +89,7 @@ export default async function LibrarySeriesPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
<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>
|
</h3>
|
||||||
<div className="flex items-center justify-between mt-1">
|
<div className="flex items-center justify-between mt-1">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -78,6 +101,29 @@ export default async function LibrarySeriesPage({
|
|||||||
booksReadCount={s.books_read_count}
|
booksReadCount={s.books_read_count}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -93,7 +139,7 @@ export default async function LibrarySeriesPage({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import Link from "next/link";
|
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 { LibraryActions } from "../components/LibraryActions";
|
||||||
import { LibraryForm } from "../components/LibraryForm";
|
import { LibraryForm } from "../components/LibraryForm";
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +16,7 @@ function formatNextScan(nextScanAt: string | null): string {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = date.getTime() - now.getTime();
|
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 < 60000) return "< 1 min";
|
||||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
|
||||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
|
||||||
@@ -75,6 +75,14 @@ export default async function LibrariesPage() {
|
|||||||
revalidatePath("/jobs");
|
revalidatePath("/jobs");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function batchMetadataAction(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
await startMetadataBatch(id);
|
||||||
|
revalidatePath("/libraries");
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Libraries
|
Bibliothèques
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Library Form */}
|
{/* Add Library Form */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Add New Library</CardTitle>
|
<CardTitle>Ajouter une bibliothèque</CardTitle>
|
||||||
<CardDescription>Create a new library from an existing folder</CardDescription>
|
<CardDescription>Créer une nouvelle bibliothèque à partir d'un dossier existant</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<LibraryForm initialFolders={folders} action={addLibrary} />
|
<LibraryForm initialFolders={folders} action={addLibrary} />
|
||||||
@@ -107,7 +115,7 @@ export default async function LibrariesPage() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg">{lib.name}</CardTitle>
|
<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>
|
</div>
|
||||||
<LibraryActions
|
<LibraryActions
|
||||||
libraryId={lib.id}
|
libraryId={lib.id}
|
||||||
@@ -115,6 +123,7 @@ export default async function LibrariesPage() {
|
|||||||
scanMode={lib.scan_mode}
|
scanMode={lib.scan_mode}
|
||||||
watcherEnabled={lib.watcher_enabled}
|
watcherEnabled={lib.watcher_enabled}
|
||||||
metadataProvider={lib.metadata_provider}
|
metadataProvider={lib.metadata_provider}
|
||||||
|
fallbackMetadataProvider={lib.fallback_metadata_provider}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</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"
|
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="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>
|
||||||
<Link
|
<Link
|
||||||
href={`/libraries/${lib.id}/series`}
|
href={`/libraries/${lib.id}/series`}
|
||||||
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
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="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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div className="flex items-center gap-3 mb-4 text-sm">
|
<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'}`}>
|
<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>
|
</span>
|
||||||
{lib.watcher_enabled && (
|
{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 && (
|
{lib.monitor_enabled && lib.next_scan_at && (
|
||||||
<span className="text-xs text-muted-foreground ml-auto">
|
<span className="text-xs text-muted-foreground ml-auto">
|
||||||
Next: {formatNextScan(lib.next_scan_at)}
|
Prochain : {formatNextScan(lib.next_scan_at)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Index
|
Indexer
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<form className="flex-1">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Full
|
Complet
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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>
|
<form>
|
||||||
<input type="hidden" name="id" value={lib.id} />
|
<input type="hidden" name="id" value={lib.id} />
|
||||||
<Button type="submit" variant="destructive" size="sm" formAction={removeLibrary}>
|
<Button type="submit" variant="destructive" size="sm" formAction={removeLibrary}>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function formatNumber(n: number): string {
|
|||||||
// Donut chart via SVG
|
// Donut chart via SVG
|
||||||
function DonutChart({ data, colors }: { data: { label: string; value: number; color: string }[]; colors?: string[] }) {
|
function DonutChart({ data, colors }: { data: { label: string; value: number; color: string }[]; colors?: string[] }) {
|
||||||
const total = data.reduce((sum, d) => sum + d.value, 0);
|
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 radius = 40;
|
||||||
const circumference = 2 * Math.PI * radius;
|
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
|
// Bar chart via pure CSS
|
||||||
function BarChart({ data, color = "var(--color-primary)" }: { data: { label: string; value: number }[]; color?: string }) {
|
function BarChart({ data, color = "var(--color-primary)" }: { data: { label: string; value: number }[]; color?: string }) {
|
||||||
const max = Math.max(...data.map((d) => d.value), 1);
|
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 (
|
return (
|
||||||
<div className="flex items-end gap-1.5 h-40">
|
<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="max-w-5xl mx-auto">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-4xl font-bold tracking-tight mb-4 text-foreground">StripStream Backoffice</h1>
|
<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>
|
</div>
|
||||||
<QuickLinks />
|
<QuickLinks />
|
||||||
</div>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Dashboard
|
Tableau de bord
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-2 max-w-2xl">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Overview stat cards */}
|
{/* Overview stat cards */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
<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="book" label="Livres" value={formatNumber(overview.total_books)} color="success" />
|
||||||
<StatCard icon="series" label="Series" value={formatNumber(overview.total_series)} color="primary" />
|
<StatCard icon="series" label="Séries" value={formatNumber(overview.total_series)} color="primary" />
|
||||||
<StatCard icon="library" label="Libraries" value={formatNumber(overview.total_libraries)} color="warning" />
|
<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="pages" label="Pages" value={formatNumber(overview.total_pages)} color="primary" />
|
||||||
<StatCard icon="author" label="Authors" value={formatNumber(overview.total_authors)} color="success" />
|
<StatCard icon="author" label="Auteurs" value={formatNumber(overview.total_authors)} color="success" />
|
||||||
<StatCard icon="size" label="Total Size" value={formatBytes(overview.total_size_bytes)} color="warning" />
|
<StatCard icon="size" label="Taille totale" value={formatBytes(overview.total_size_bytes)} color="warning" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts row */}
|
{/* Charts row */}
|
||||||
@@ -174,14 +174,14 @@ export default async function DashboardPage() {
|
|||||||
{/* Reading status donut */}
|
{/* Reading status donut */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Reading Status</CardTitle>
|
<CardTitle className="text-base">Statut de lecture</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DonutChart
|
<DonutChart
|
||||||
data={[
|
data={[
|
||||||
{ label: "Unread", value: reading_status.unread, color: readingColors[0] },
|
{ label: "Non lu", value: reading_status.unread, color: readingColors[0] },
|
||||||
{ label: "In Progress", value: reading_status.reading, color: readingColors[1] },
|
{ label: "En cours", value: reading_status.reading, color: readingColors[1] },
|
||||||
{ label: "Read", value: reading_status.read, color: readingColors[2] },
|
{ label: "Lu", value: reading_status.read, color: readingColors[2] },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -190,12 +190,12 @@ export default async function DashboardPage() {
|
|||||||
{/* By format donut */}
|
{/* By format donut */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">By Format</CardTitle>
|
<CardTitle className="text-base">Par format</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DonutChart
|
<DonutChart
|
||||||
data={by_format.slice(0, 6).map((f, i) => ({
|
data={by_format.slice(0, 6).map((f, i) => ({
|
||||||
label: (f.format || "Unknown").toUpperCase(),
|
label: (f.format || "Inconnu").toUpperCase(),
|
||||||
value: f.count,
|
value: f.count,
|
||||||
color: formatColors[i % formatColors.length],
|
color: formatColors[i % formatColors.length],
|
||||||
}))}
|
}))}
|
||||||
@@ -206,7 +206,7 @@ export default async function DashboardPage() {
|
|||||||
{/* By library donut */}
|
{/* By library donut */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">By Library</CardTitle>
|
<CardTitle className="text-base">Par bibliothèque</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DonutChart
|
<DonutChart
|
||||||
@@ -225,7 +225,7 @@ export default async function DashboardPage() {
|
|||||||
{/* Monthly additions bar chart */}
|
{/* Monthly additions bar chart */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Books Added (Last 12 Months)</CardTitle>
|
<CardTitle className="text-base">Livres ajoutés (12 derniers mois)</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<BarChart
|
<BarChart
|
||||||
@@ -241,7 +241,7 @@ export default async function DashboardPage() {
|
|||||||
{/* Top series */}
|
{/* Top series */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Top Series</CardTitle>
|
<CardTitle className="text-base">Séries populaires</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -251,12 +251,12 @@ export default async function DashboardPage() {
|
|||||||
label={s.series}
|
label={s.series}
|
||||||
value={s.book_count}
|
value={s.book_count}
|
||||||
max={top_series[0]?.book_count || 1}
|
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%)"
|
color="hsl(142 60% 45%)"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{top_series.length === 0 && (
|
{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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -267,7 +267,7 @@ export default async function DashboardPage() {
|
|||||||
{by_library.length > 0 && (
|
{by_library.length > 0 && (
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Libraries</CardTitle>
|
<CardTitle className="text-base">Bibliothèques</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
|
<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
|
<div
|
||||||
className="h-full transition-all duration-500"
|
className="h-full transition-all duration-500"
|
||||||
style={{ width: `${(lib.read_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(142 60% 45%)" }}
|
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
|
<div
|
||||||
className="h-full transition-all duration-500"
|
className="h-full transition-all duration-500"
|
||||||
style={{ width: `${(lib.reading_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(45 93% 47%)" }}
|
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
|
<div
|
||||||
className="h-full transition-all duration-500"
|
className="h-full transition-all duration-500"
|
||||||
style={{ width: `${(lib.unread_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(220 13% 70%)" }}
|
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>
|
||||||
<div className="flex gap-3 text-[11px] text-muted-foreground">
|
<div className="flex gap-3 text-[11px] text-muted-foreground">
|
||||||
<span>{lib.book_count} books</span>
|
<span>{lib.book_count} livres</span>
|
||||||
<span className="text-success">{lib.read_count} read</span>
|
<span className="text-success">{lib.read_count} lu</span>
|
||||||
<span className="text-warning">{lib.reading_count} in progress</span>
|
<span className="text-warning">{lib.reading_count} en cours</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -347,10 +347,10 @@ function StatCard({ icon, label, value, color }: { icon: string; label: string;
|
|||||||
|
|
||||||
function QuickLinks() {
|
function QuickLinks() {
|
||||||
const links = [
|
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: "/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: "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: "/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: "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: "/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: "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: "/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 (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fetchAllSeries, fetchLibraries, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
|
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
|
||||||
import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton";
|
import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton";
|
||||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
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 searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||||
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
||||||
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : 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 page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
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[]),
|
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
|
() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto
|
||||||
),
|
),
|
||||||
|
fetchSeriesStatuses().catch(() => [] as string[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const series = seriesPage.items;
|
const series = seriesPage.items;
|
||||||
const totalPages = Math.ceil(seriesPage.total / limit);
|
const totalPages = Math.ceil(seriesPage.total / limit);
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ value: "", label: "Title" },
|
{ value: "", label: "Titre" },
|
||||||
{ value: "latest", label: "Latest added" },
|
{ value: "latest", label: "Ajout récent" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasFilters = searchQuery || libraryId || readingStatus || sort;
|
const hasFilters = searchQuery || libraryId || readingStatus || sort || seriesStatus || hasMissing;
|
||||||
|
|
||||||
const libraryOptions = [
|
const libraryOptions = [
|
||||||
{ value: "", label: "All libraries" },
|
{ value: "", label: "Toutes les bibliothèques" },
|
||||||
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
|
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ value: "", label: "All" },
|
{ value: "", label: "Tous" },
|
||||||
{ value: "unread", label: "Unread" },
|
{ value: "unread", label: "Non lu" },
|
||||||
{ value: "reading", label: "In progress" },
|
{ value: "reading", label: "En cours" },
|
||||||
{ value: "read", label: "Read" },
|
{ 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 (
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Series
|
Séries
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,10 +84,12 @@ export default async function SeriesPage({
|
|||||||
<LiveSearchForm
|
<LiveSearchForm
|
||||||
basePath="/series"
|
basePath="/series"
|
||||||
fields={[
|
fields={[
|
||||||
{ name: "q", type: "text", label: "Search", placeholder: "Search by series name...", className: "flex-1 w-full" },
|
{ name: "q", type: "text", label: "Rechercher", placeholder: "Rechercher par nom de série...", className: "flex-1 w-full" },
|
||||||
{ name: "library", type: "select", label: "Library", options: libraryOptions, className: "w-full sm:w-48" },
|
{ name: "library", type: "select", label: "Bibliothèque", options: libraryOptions, className: "w-full sm:w-48" },
|
||||||
{ name: "status", type: "select", label: "Status", options: statusOptions, className: "w-full sm:w-40" },
|
{ name: "status", type: "select", label: "Lecture", options: statusOptions, className: "w-full sm:w-36" },
|
||||||
{ name: "sort", type: "select", label: "Sort", options: sortOptions, className: "w-full sm:w-40" },
|
{ 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>
|
</CardContent>
|
||||||
@@ -75,8 +97,8 @@ export default async function SeriesPage({
|
|||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
{seriesPage.total} series
|
{seriesPage.total} séries
|
||||||
{searchQuery && <> matching "{searchQuery}"</>}
|
{searchQuery && <> correspondant à "{searchQuery}"</>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Series Grid */}
|
{/* Series Grid */}
|
||||||
@@ -97,7 +119,7 @@ export default async function SeriesPage({
|
|||||||
<div className="aspect-[2/3] relative bg-muted/50">
|
<div className="aspect-[2/3] relative bg-muted/50">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(s.first_book_id)}
|
src={getBookCoverUrl(s.first_book_id)}
|
||||||
alt={`Cover of ${s.name}`}
|
alt={`Couverture de ${s.name}`}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
@@ -105,7 +127,7 @@ export default async function SeriesPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
<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>
|
</h3>
|
||||||
<div className="flex items-center justify-between mt-1">
|
<div className="flex items-center justify-between mt-1">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -117,6 +139,29 @@ export default async function SeriesPage({
|
|||||||
booksReadCount={s.books_read_count}
|
booksReadCount={s.books_read_count}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -138,7 +183,7 @@ export default async function SeriesPage({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-lg">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
body: JSON.stringify({ value })
|
body: JSON.stringify({ value })
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setSaveMessage("Settings saved successfully");
|
setSaveMessage("Paramètres enregistrés avec succès");
|
||||||
setTimeout(() => setSaveMessage(null), 3000);
|
setTimeout(() => setSaveMessage(null), 3000);
|
||||||
} else {
|
} else {
|
||||||
setSaveMessage("Failed to save settings");
|
setSaveMessage("Échec de l'enregistrement des paramètres");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSaveMessage("Error saving settings");
|
setSaveMessage("Erreur lors de l'enregistrement des paramètres");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
setCacheStats(stats);
|
setCacheStats(stats);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setClearResult({ success: false, message: "Failed to clear cache" });
|
setClearResult({ success: false, message: "Échec du vidage du cache" });
|
||||||
} finally {
|
} finally {
|
||||||
setIsClearing(false);
|
setIsClearing(false);
|
||||||
}
|
}
|
||||||
@@ -150,8 +150,8 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
const [activeTab, setActiveTab] = useState<"general" | "integrations">("general");
|
const [activeTab, setActiveTab] = useState<"general" | "integrations">("general");
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "general" as const, label: "General", icon: "settings" as const },
|
{ id: "general" as const, label: "Général", icon: "settings" as const },
|
||||||
{ id: "integrations" as const, label: "Integrations", icon: "refresh" as const },
|
{ id: "integrations" as const, label: "Intégrations", icon: "refresh" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -159,7 +159,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
<Icon name="settings" size="xl" />
|
<Icon name="settings" size="xl" />
|
||||||
Settings
|
Paramètres
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,15 +195,15 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Icon name="image" size="md" />
|
<Icon name="image" size="md" />
|
||||||
Image Processing
|
Traitement d'images
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>These settings only apply when a client explicitly requests format conversion via the API (e.g. <code className="text-xs bg-muted px-1 rounded">?format=webp&width=800</code>). Pages served without parameters are delivered as-is from the archive, with no processing.</CardDescription>
|
<CardDescription>Ces paramètres s'appliquent uniquement lorsqu'un client demande explicitement une conversion de format via l'API (ex. <code className="text-xs bg-muted px-1 rounded">?format=webp&width=800</code>). Les pages servies sans paramètres sont livrées telles quelles depuis l'archive, sans traitement.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<FormField className="flex-1">
|
<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
|
<FormSelect
|
||||||
value={settings.image_processing.format}
|
value={settings.image_processing.format}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -218,7 +218,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
</FormSelect>
|
</FormSelect>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField className="flex-1">
|
<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
|
<FormInput
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
@@ -235,7 +235,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<FormField className="flex-1">
|
<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
|
<FormSelect
|
||||||
value={settings.image_processing.filter}
|
value={settings.image_processing.filter}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|||||||
@@ -45,15 +45,15 @@ export default async function TokensPage({
|
|||||||
<svg className="w-8 h-8 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
API Tokens
|
Jetons API
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{params.created ? (
|
{params.created ? (
|
||||||
<Card className="mb-6 border-success/50 bg-success/5">
|
<Card className="mb-6 border-success/50 bg-success/5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-success">Token Created</CardTitle>
|
<CardTitle className="text-success">Jeton créé</CardTitle>
|
||||||
<CardDescription>Copy it now, it won't be shown again</CardDescription>
|
<CardDescription>Copiez-le maintenant, il ne sera plus affiché</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<pre className="p-4 bg-background rounded-lg text-sm font-mono text-foreground overflow-x-auto border">{params.created}</pre>
|
<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">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Create New Token</CardTitle>
|
<CardTitle>Créer un nouveau jeton</CardTitle>
|
||||||
<CardDescription>Generate a new API token with the desired scope</CardDescription>
|
<CardDescription>Générer un nouveau jeton API avec la portée souhaitée</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form action={createTokenAction}>
|
<form action={createTokenAction}>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<FormField className="flex-1 min-w-48">
|
<FormField className="flex-1 min-w-48">
|
||||||
<FormInput name="name" placeholder="Token name" required />
|
<FormInput name="name" placeholder="Nom du jeton" required />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField className="w-32">
|
<FormField className="w-32">
|
||||||
<FormSelect name="scope" defaultValue="read">
|
<FormSelect name="scope" defaultValue="read">
|
||||||
<option value="read">Read</option>
|
<option value="read">Lecture</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
</FormField>
|
</FormField>
|
||||||
<Button type="submit">Create Token</Button>
|
<Button type="submit">Créer le jeton</Button>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -89,10 +89,10 @@ export default async function TokensPage({
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border/60 bg-muted/50">
|
<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">Nom</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">Portée</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">Préfixe</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">Statut</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -110,9 +110,9 @@ export default async function TokensPage({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
{token.revoked_at ? (
|
{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>
|
||||||
<td className="px-4 py-3">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Revoke
|
Révoquer
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Delete
|
Supprimer
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type LibraryDto = {
|
|||||||
next_scan_at: string | null;
|
next_scan_at: string | null;
|
||||||
watcher_enabled: boolean;
|
watcher_enabled: boolean;
|
||||||
metadata_provider: string | null;
|
metadata_provider: string | null;
|
||||||
|
fallback_metadata_provider: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IndexJobDto = {
|
export type IndexJobDto = {
|
||||||
@@ -120,6 +121,8 @@ export type SeriesDto = {
|
|||||||
books_read_count: number;
|
books_read_count: number;
|
||||||
first_book_id: string;
|
first_book_id: string;
|
||||||
library_id: string;
|
library_id: string;
|
||||||
|
series_status: string | null;
|
||||||
|
missing_count: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function config() {
|
export function config() {
|
||||||
@@ -296,10 +299,14 @@ export async function fetchSeries(
|
|||||||
libraryId: string,
|
libraryId: string,
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 50,
|
limit: number = 50,
|
||||||
|
seriesStatus?: string,
|
||||||
|
hasMissing?: boolean,
|
||||||
): Promise<SeriesPageDto> {
|
): Promise<SeriesPageDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("page", page.toString());
|
params.set("page", page.toString());
|
||||||
params.set("limit", limit.toString());
|
params.set("limit", limit.toString());
|
||||||
|
if (seriesStatus) params.set("series_status", seriesStatus);
|
||||||
|
if (hasMissing) params.set("has_missing", "true");
|
||||||
|
|
||||||
return apiFetch<SeriesPageDto>(
|
return apiFetch<SeriesPageDto>(
|
||||||
`/libraries/${libraryId}/series?${params.toString()}`,
|
`/libraries/${libraryId}/series?${params.toString()}`,
|
||||||
@@ -313,18 +320,26 @@ export async function fetchAllSeries(
|
|||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 50,
|
limit: number = 50,
|
||||||
sort?: string,
|
sort?: string,
|
||||||
|
seriesStatus?: string,
|
||||||
|
hasMissing?: boolean,
|
||||||
): Promise<SeriesPageDto> {
|
): Promise<SeriesPageDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (libraryId) params.set("library_id", libraryId);
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
if (q) params.set("q", q);
|
if (q) params.set("q", q);
|
||||||
if (readingStatus) params.set("reading_status", readingStatus);
|
if (readingStatus) params.set("reading_status", readingStatus);
|
||||||
if (sort) params.set("sort", sort);
|
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("page", page.toString());
|
||||||
params.set("limit", limit.toString());
|
params.set("limit", limit.toString());
|
||||||
|
|
||||||
return apiFetch<SeriesPageDto>(`/series?${params.toString()}`);
|
return apiFetch<SeriesPageDto>(`/series?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchSeriesStatuses(): Promise<string[]> {
|
||||||
|
return apiFetch<string[]>("/series/statuses");
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchBooks(
|
export async function searchBooks(
|
||||||
query: string,
|
query: string,
|
||||||
libraryId?: 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`, {
|
return apiFetch<LibraryDto>(`/libraries/${libraryId}/metadata-provider`, {
|
||||||
method: "PATCH",
|
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
@@ -36,6 +36,9 @@ pub async fn cleanup_stale_jobs(pool: &PgPool) -> Result<()> {
|
|||||||
Ok(())
|
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.
|
/// Job types that modify book/thumbnail data and must not run concurrently.
|
||||||
const EXCLUSIVE_JOB_TYPES: &[&str] = &[
|
const EXCLUSIVE_JOB_TYPES: &[&str] = &[
|
||||||
"rebuild",
|
"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
|
SELECT j.id, j.type, j.library_id
|
||||||
FROM index_jobs j
|
FROM index_jobs j
|
||||||
WHERE j.status = 'pending'
|
WHERE j.status = 'pending'
|
||||||
|
AND j.type != ALL($3)
|
||||||
AND (
|
AND (
|
||||||
-- Exclusive jobs: only if no other exclusive job is active
|
-- Exclusive jobs: only if no other exclusive job is active
|
||||||
(j.type = ANY($1) AND NOT $2::bool)
|
(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(EXCLUSIVE_JOB_TYPES)
|
||||||
.bind(has_active_exclusive)
|
.bind(has_active_exclusive)
|
||||||
|
.bind(API_ONLY_JOB_TYPES)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
22
infra/migrations/0034_add_metadata_batch.sql
Normal file
22
infra/migrations/0034_add_metadata_batch.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- Add fallback_metadata_provider to libraries
|
||||||
|
ALTER TABLE libraries ADD COLUMN fallback_metadata_provider TEXT;
|
||||||
|
|
||||||
|
-- Table to store batch metadata job results (one row per series per job)
|
||||||
|
CREATE TABLE metadata_batch_results (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
job_id UUID NOT NULL REFERENCES index_jobs(id) ON DELETE CASCADE,
|
||||||
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
series_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL, -- 'auto_matched', 'pending_review', 'no_results', 'too_many_results', 'low_confidence', 'already_linked', 'error'
|
||||||
|
provider_used TEXT,
|
||||||
|
fallback_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
candidates_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
best_confidence REAL,
|
||||||
|
best_candidate_json JSONB,
|
||||||
|
link_id UUID REFERENCES external_metadata_links(id) ON DELETE SET NULL,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_mbr_job_id ON metadata_batch_results(job_id);
|
||||||
|
CREATE INDEX idx_mbr_status ON metadata_batch_results(status);
|
||||||
5
infra/migrations/0035_add_metadata_batch_job_type.sql
Normal file
5
infra/migrations/0035_add_metadata_batch_job_type.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Allow metadata_batch job type in index_jobs
|
||||||
|
ALTER TABLE index_jobs
|
||||||
|
DROP CONSTRAINT IF EXISTS index_jobs_type_check,
|
||||||
|
ADD CONSTRAINT index_jobs_type_check
|
||||||
|
CHECK (type IN ('scan', 'rebuild', 'full_rebuild', 'thumbnail_rebuild', 'thumbnail_regenerate', 'cbr_to_cbz', 'metadata_batch'));
|
||||||
8
infra/migrations/0036_normalize_series_status.sql
Normal file
8
infra/migrations/0036_normalize_series_status.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Normalize series_metadata.status values from provider-specific strings to standard enum values
|
||||||
|
UPDATE series_metadata SET status = 'ongoing' WHERE LOWER(status) LIKE '%en cours%';
|
||||||
|
UPDATE series_metadata SET status = 'ended' WHERE LOWER(status) LIKE '%finie%' OR LOWER(status) LIKE '%terminée%';
|
||||||
|
UPDATE series_metadata SET status = 'hiatus' WHERE LOWER(status) LIKE '%hiatus%' OR LOWER(status) LIKE '%suspendue%';
|
||||||
|
UPDATE series_metadata SET status = 'cancelled' WHERE LOWER(status) LIKE '%annulée%' OR LOWER(status) LIKE '%arrêtée%';
|
||||||
|
UPDATE series_metadata SET status = 'upcoming' WHERE LOWER(status) LIKE '%not_yet_released%';
|
||||||
|
UPDATE series_metadata SET status = 'ongoing' WHERE LOWER(status) = 'releasing';
|
||||||
|
UPDATE series_metadata SET status = 'ended' WHERE LOWER(status) = 'finished';
|
||||||
Reference in New Issue
Block a user