Compare commits
35 Commits
ee65c6263a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d977b6b27a | |||
| 9eea43ce99 | |||
| 31538fac24 | |||
| 5f7f96f25a | |||
| 87f5d9b452 | |||
| e995732504 | |||
| ea4b8798a1 | |||
| b2e59d8aa1 | |||
| 6a838fb840 | |||
| 2febab2c39 | |||
| 4049c94fc0 | |||
| cb684ab9ea | |||
| 5e91ecd39d | |||
| f2fa4e3ce8 | |||
| b61ab45fb4 | |||
| fd0f57824d | |||
| 4c10702fb7 | |||
| 301669332c | |||
| f57cc0cae0 | |||
| e94a4a0b13 | |||
| 2a7881ac6e | |||
| 0950018b38 | |||
| bc796f4ee5 | |||
| 232ecdda41 | |||
| 32d13984a1 | |||
| eab7f2e21b | |||
| b6422fbf3e | |||
| 6dbd0c80e6 | |||
| 0c42a9ed04 | |||
| 95a6e54d06 | |||
| e26219989f | |||
| 5d33a35407 | |||
| d53572dc33 | |||
| cf1953d11f | |||
| 6f663eaee7 |
@@ -13,6 +13,12 @@
|
||||
# Use this token for the first API calls before creating proper API tokens
|
||||
API_BOOTSTRAP_TOKEN=change-me-in-production
|
||||
|
||||
# Backoffice admin credentials (required)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-in-production
|
||||
# Secret for signing session JWTs (min 32 chars, required)
|
||||
SESSION_SECRET=change-me-in-production-use-32-chars-min
|
||||
|
||||
# =============================================================================
|
||||
# Service Configuration
|
||||
# =============================================================================
|
||||
|
||||
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "1.25.0"
|
||||
version = "2.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -1233,7 +1233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexer"
|
||||
version = "1.25.0"
|
||||
version = "2.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1667,7 +1667,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notifications"
|
||||
version = "1.25.0"
|
||||
version = "2.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"reqwest",
|
||||
@@ -1786,7 +1786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parsers"
|
||||
version = "1.25.0"
|
||||
version = "2.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"flate2",
|
||||
@@ -2923,7 +2923,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "stripstream-core"
|
||||
version = "1.25.0"
|
||||
version = "2.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
|
||||
@@ -10,7 +10,7 @@ resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "1.25.0"
|
||||
version = "2.3.1"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Julien Froidefond
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -287,4 +287,4 @@ volumes:
|
||||
|
||||
## License
|
||||
|
||||
[Your License Here]
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
|
||||
968
apps/api/src/anilist.rs
Normal file
968
apps/api/src/anilist.rs
Normal file
@@ -0,0 +1,968 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::Row;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
// ─── AniList API client ───────────────────────────────────────────────────────
|
||||
|
||||
const ANILIST_API: &str = "https://graphql.anilist.co";
|
||||
|
||||
pub(crate) async fn anilist_graphql(
|
||||
token: &str,
|
||||
query: &str,
|
||||
variables: Value,
|
||||
) -> Result<Value, ApiError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| ApiError::internal(format!("HTTP client error: {e}")))?;
|
||||
|
||||
let body = serde_json::json!({ "query": query, "variables": variables });
|
||||
|
||||
let resp = client
|
||||
.post(ANILIST_API)
|
||||
.bearer_auth(token)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("AniList request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ApiError::internal(format!("AniList returned {status}: {text}")));
|
||||
}
|
||||
|
||||
let data: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(format!("Failed to parse AniList response: {e}")))?;
|
||||
|
||||
if let Some(errors) = data.get("errors") {
|
||||
let msg = errors[0]["message"].as_str().unwrap_or("Unknown AniList error");
|
||||
return Err(ApiError::internal(format!("AniList API error: {msg}")));
|
||||
}
|
||||
|
||||
Ok(data["data"].clone())
|
||||
}
|
||||
|
||||
/// Load AniList settings from DB: (access_token, anilist_user_id, local_user_id)
|
||||
pub(crate) async fn load_anilist_settings(pool: &sqlx::PgPool) -> Result<(String, Option<i64>, Option<Uuid>), ApiError> {
|
||||
let row = sqlx::query("SELECT value FROM app_settings WHERE key = 'anilist'")
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let value: Value = row
|
||||
.ok_or_else(|| ApiError::bad_request("AniList not configured (missing settings)"))?
|
||||
.get("value");
|
||||
|
||||
let token = value["access_token"]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| ApiError::bad_request("AniList access token not configured"))?
|
||||
.to_string();
|
||||
|
||||
let user_id = value["user_id"].as_i64();
|
||||
|
||||
let local_user_id = value["local_user_id"]
|
||||
.as_str()
|
||||
.and_then(|s| Uuid::parse_str(s).ok());
|
||||
|
||||
Ok((token, user_id, local_user_id))
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct AnilistStatusResponse {
|
||||
pub connected: bool,
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub site_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct AnilistMediaResult {
|
||||
pub id: i32,
|
||||
pub title_romaji: Option<String>,
|
||||
pub title_english: Option<String>,
|
||||
pub title_native: Option<String>,
|
||||
pub site_url: String,
|
||||
pub status: Option<String>,
|
||||
pub volumes: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct AnilistSeriesLinkResponse {
|
||||
#[schema(value_type = String)]
|
||||
pub library_id: Uuid,
|
||||
pub series_name: String,
|
||||
pub anilist_id: i32,
|
||||
pub anilist_title: Option<String>,
|
||||
pub anilist_url: Option<String>,
|
||||
pub status: String,
|
||||
#[schema(value_type = String)]
|
||||
pub linked_at: DateTime<Utc>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub synced_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct AnilistSyncPreviewItem {
|
||||
pub series_name: String,
|
||||
pub anilist_id: i32,
|
||||
pub anilist_title: Option<String>,
|
||||
pub anilist_url: Option<String>,
|
||||
/// Status that would be sent to AniList: PLANNING | CURRENT | COMPLETED
|
||||
pub status: String,
|
||||
pub progress_volumes: i32,
|
||||
pub books_read: i64,
|
||||
pub book_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct AnilistSyncItem {
|
||||
pub series_name: String,
|
||||
pub anilist_title: Option<String>,
|
||||
pub anilist_url: Option<String>,
|
||||
/// Status sent to AniList: PLANNING | CURRENT | COMPLETED
|
||||
pub status: String,
|
||||
pub progress_volumes: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct AnilistSyncReport {
|
||||
pub synced: i32,
|
||||
pub skipped: i32,
|
||||
pub errors: Vec<String>,
|
||||
pub items: Vec<AnilistSyncItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct AnilistPullItem {
|
||||
pub series_name: String,
|
||||
pub anilist_title: Option<String>,
|
||||
pub anilist_url: Option<String>,
|
||||
/// Status received from AniList: COMPLETED | CURRENT | PLANNING | etc.
|
||||
pub anilist_status: String,
|
||||
pub books_updated: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct AnilistPullReport {
|
||||
pub updated: i32,
|
||||
pub skipped: i32,
|
||||
pub errors: Vec<String>,
|
||||
pub items: Vec<AnilistPullItem>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct AnilistSearchRequest {
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct AnilistLinkRequest {
|
||||
pub anilist_id: i32,
|
||||
/// Override display title (optional)
|
||||
pub title: Option<String>,
|
||||
/// Override URL (optional)
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct AnilistLibraryToggleRequest {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Test AniList connection and return viewer info
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/anilist/status",
|
||||
tag = "anilist",
|
||||
responses(
|
||||
(status = 200, body = AnilistStatusResponse),
|
||||
(status = 400, description = "AniList not configured"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn get_status(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<AnilistStatusResponse>, ApiError> {
|
||||
let (token, _, _) = load_anilist_settings(&state.pool).await?;
|
||||
|
||||
let gql = r#"
|
||||
query Viewer {
|
||||
Viewer {
|
||||
id
|
||||
name
|
||||
siteUrl
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let data = anilist_graphql(&token, gql, serde_json::json!({})).await?;
|
||||
|
||||
let viewer = &data["Viewer"];
|
||||
Ok(Json(AnilistStatusResponse {
|
||||
connected: true,
|
||||
user_id: viewer["id"].as_i64().unwrap_or(0),
|
||||
username: viewer["name"].as_str().unwrap_or("").to_string(),
|
||||
site_url: viewer["siteUrl"].as_str().unwrap_or("").to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Search AniList manga by title
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/anilist/search",
|
||||
tag = "anilist",
|
||||
request_body = AnilistSearchRequest,
|
||||
responses(
|
||||
(status = 200, body = Vec<AnilistMediaResult>),
|
||||
(status = 400, description = "AniList not configured"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn search_manga(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<AnilistSearchRequest>,
|
||||
) -> Result<Json<Vec<AnilistMediaResult>>, ApiError> {
|
||||
let (token, _, _) = load_anilist_settings(&state.pool).await?;
|
||||
|
||||
let gql = r#"
|
||||
query SearchManga($search: String) {
|
||||
Page(perPage: 10) {
|
||||
media(search: $search, type: MANGA) {
|
||||
id
|
||||
title { romaji english native }
|
||||
siteUrl
|
||||
status
|
||||
volumes
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let data = anilist_graphql(
|
||||
&token,
|
||||
gql,
|
||||
serde_json::json!({ "search": body.query }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let media = data["Page"]["media"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let results: Vec<AnilistMediaResult> = media
|
||||
.iter()
|
||||
.map(|m| AnilistMediaResult {
|
||||
id: m["id"].as_i64().unwrap_or(0) as i32,
|
||||
title_romaji: m["title"]["romaji"].as_str().map(String::from),
|
||||
title_english: m["title"]["english"].as_str().map(String::from),
|
||||
title_native: m["title"]["native"].as_str().map(String::from),
|
||||
site_url: m["siteUrl"].as_str().unwrap_or("").to_string(),
|
||||
status: m["status"].as_str().map(String::from),
|
||||
volumes: m["volumes"].as_i64().map(|v| v as i32),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(results))
|
||||
}
|
||||
|
||||
/// Get AniList link for a specific series
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/anilist/series/{library_id}/{series_name}",
|
||||
tag = "anilist",
|
||||
params(
|
||||
("library_id" = String, Path, description = "Library UUID"),
|
||||
("series_name" = String, Path, description = "Series name"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = AnilistSeriesLinkResponse),
|
||||
(status = 404, description = "No AniList link for this series"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn get_series_link(
|
||||
State(state): State<AppState>,
|
||||
Path((library_id, series_name)): Path<(Uuid, String)>,
|
||||
) -> Result<Json<AnilistSeriesLinkResponse>, ApiError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT library_id, series_name, anilist_id, anilist_title, anilist_url, status, linked_at, synced_at
|
||||
FROM anilist_series_links
|
||||
WHERE library_id = $1 AND series_name = $2",
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(&series_name)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let row = row.ok_or_else(|| ApiError::not_found("no AniList link for this series"))?;
|
||||
|
||||
Ok(Json(AnilistSeriesLinkResponse {
|
||||
library_id: row.get("library_id"),
|
||||
series_name: row.get("series_name"),
|
||||
anilist_id: row.get("anilist_id"),
|
||||
anilist_title: row.get("anilist_title"),
|
||||
anilist_url: row.get("anilist_url"),
|
||||
status: row.get("status"),
|
||||
linked_at: row.get("linked_at"),
|
||||
synced_at: row.get("synced_at"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Link a series to an AniList media ID
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/anilist/series/{library_id}/{series_name}/link",
|
||||
tag = "anilist",
|
||||
params(
|
||||
("library_id" = String, Path, description = "Library UUID"),
|
||||
("series_name" = String, Path, description = "Series name"),
|
||||
),
|
||||
request_body = AnilistLinkRequest,
|
||||
responses(
|
||||
(status = 200, body = AnilistSeriesLinkResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn link_series(
|
||||
State(state): State<AppState>,
|
||||
Path((library_id, series_name)): Path<(Uuid, String)>,
|
||||
Json(body): Json<AnilistLinkRequest>,
|
||||
) -> Result<Json<AnilistSeriesLinkResponse>, ApiError> {
|
||||
// Try to fetch title/url from AniList if not provided
|
||||
let (anilist_title, anilist_url) = if body.title.is_some() && body.url.is_some() {
|
||||
(body.title, body.url)
|
||||
} else {
|
||||
// Fetch from AniList
|
||||
match load_anilist_settings(&state.pool).await {
|
||||
Ok((token, _, _)) => {
|
||||
let gql = r#"
|
||||
query GetMedia($id: Int) {
|
||||
Media(id: $id, type: MANGA) {
|
||||
title { romaji english }
|
||||
siteUrl
|
||||
}
|
||||
}
|
||||
"#;
|
||||
match anilist_graphql(&token, gql, serde_json::json!({ "id": body.anilist_id })).await {
|
||||
Ok(data) => {
|
||||
let title = data["Media"]["title"]["english"]
|
||||
.as_str()
|
||||
.or_else(|| data["Media"]["title"]["romaji"].as_str())
|
||||
.map(String::from);
|
||||
let url = data["Media"]["siteUrl"].as_str().map(String::from);
|
||||
(title, url)
|
||||
}
|
||||
Err(_) => (body.title, body.url),
|
||||
}
|
||||
}
|
||||
Err(_) => (body.title, body.url),
|
||||
}
|
||||
};
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO anilist_series_links (library_id, series_name, provider, anilist_id, anilist_title, anilist_url, status, linked_at)
|
||||
VALUES ($1, $2, 'anilist', $3, $4, $5, 'linked', NOW())
|
||||
ON CONFLICT (library_id, series_name, provider) DO UPDATE
|
||||
SET anilist_id = EXCLUDED.anilist_id,
|
||||
anilist_title = EXCLUDED.anilist_title,
|
||||
anilist_url = EXCLUDED.anilist_url,
|
||||
status = 'linked',
|
||||
linked_at = NOW(),
|
||||
synced_at = NULL
|
||||
RETURNING library_id, series_name, anilist_id, anilist_title, anilist_url, status, linked_at, synced_at
|
||||
"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(&series_name)
|
||||
.bind(body.anilist_id)
|
||||
.bind(&anilist_title)
|
||||
.bind(&anilist_url)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(AnilistSeriesLinkResponse {
|
||||
library_id: row.get("library_id"),
|
||||
series_name: row.get("series_name"),
|
||||
anilist_id: row.get("anilist_id"),
|
||||
anilist_title: row.get("anilist_title"),
|
||||
anilist_url: row.get("anilist_url"),
|
||||
status: row.get("status"),
|
||||
linked_at: row.get("linked_at"),
|
||||
synced_at: row.get("synced_at"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Remove the AniList link for a series
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/anilist/series/{library_id}/{series_name}/unlink",
|
||||
tag = "anilist",
|
||||
params(
|
||||
("library_id" = String, Path, description = "Library UUID"),
|
||||
("series_name" = String, Path, description = "Series name"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Unlinked"),
|
||||
(status = 404, description = "Link not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn unlink_series(
|
||||
State(state): State<AppState>,
|
||||
Path((library_id, series_name)): Path<(Uuid, String)>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM anilist_series_links WHERE library_id = $1 AND series_name = $2",
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(&series_name)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found("AniList link not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"unlinked": true})))
|
||||
}
|
||||
|
||||
/// Toggle AniList sync for a library
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/anilist/libraries/{id}",
|
||||
tag = "anilist",
|
||||
params(("id" = String, Path, description = "Library UUID")),
|
||||
request_body = AnilistLibraryToggleRequest,
|
||||
responses(
|
||||
(status = 200, description = "Updated"),
|
||||
(status = 404, description = "Library not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn toggle_library(
|
||||
State(state): State<AppState>,
|
||||
Path(library_id): Path<Uuid>,
|
||||
Json(body): Json<AnilistLibraryToggleRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let provider: Option<&str> = if body.enabled { Some("anilist") } else { None };
|
||||
let result = sqlx::query("UPDATE libraries SET reading_status_provider = $2 WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.bind(provider)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found("library not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "library_id": library_id, "reading_status_provider": provider })))
|
||||
}
|
||||
|
||||
/// List series from AniList-enabled libraries that are not yet linked
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/anilist/unlinked",
|
||||
tag = "anilist",
|
||||
responses(
|
||||
(status = 200, description = "List of unlinked series"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_unlinked(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
l.id AS library_id,
|
||||
l.name AS library_name,
|
||||
COALESCE(NULLIF(b.series, ''), 'unclassified') AS series_name
|
||||
FROM books b
|
||||
JOIN libraries l ON l.id = b.library_id
|
||||
LEFT JOIN anilist_series_links asl
|
||||
ON asl.library_id = b.library_id
|
||||
AND asl.series_name = COALESCE(NULLIF(b.series, ''), 'unclassified')
|
||||
WHERE l.reading_status_provider = 'anilist'
|
||||
AND asl.library_id IS NULL
|
||||
GROUP BY l.id, l.name, COALESCE(NULLIF(b.series, ''), 'unclassified')
|
||||
ORDER BY l.name, series_name
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let library_id: Uuid = row.get("library_id");
|
||||
serde_json::json!({
|
||||
"library_id": library_id,
|
||||
"library_name": row.get::<String, _>("library_name"),
|
||||
"series_name": row.get::<String, _>("series_name"),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
/// Preview what would be synced to AniList (dry-run, no writes)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/anilist/sync/preview",
|
||||
tag = "anilist",
|
||||
responses(
|
||||
(status = 200, body = Vec<AnilistSyncPreviewItem>),
|
||||
(status = 400, description = "AniList not configured"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn preview_sync(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<AnilistSyncPreviewItem>>, ApiError> {
|
||||
let (_, _, local_user_id) = load_anilist_settings(&state.pool).await?;
|
||||
let local_user_id = local_user_id
|
||||
.ok_or_else(|| ApiError::bad_request("AniList local user not configured — please select a user in settings"))?;
|
||||
|
||||
let links = sqlx::query(
|
||||
r#"
|
||||
SELECT asl.library_id, asl.series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url
|
||||
FROM anilist_series_links asl
|
||||
JOIN libraries l ON l.id = asl.library_id
|
||||
WHERE l.reading_status_provider = 'anilist'
|
||||
ORDER BY l.name, asl.series_name
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let mut items: Vec<AnilistSyncPreviewItem> = Vec::new();
|
||||
|
||||
for link in &links {
|
||||
let library_id: Uuid = link.get("library_id");
|
||||
let series_name: String = link.get("series_name");
|
||||
let anilist_id: i32 = link.get("anilist_id");
|
||||
let anilist_title: Option<String> = link.get("anilist_title");
|
||||
let anilist_url: Option<String> = link.get("anilist_url");
|
||||
|
||||
let stats = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(*) as book_count,
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read,
|
||||
(SELECT sm.total_volumes FROM series_metadata sm WHERE sm.library_id = $1 AND sm.name = $2 LIMIT 1) as total_volumes
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND brp.user_id = $3
|
||||
WHERE b.library_id = $1 AND COALESCE(NULLIF(b.series, ''), 'unclassified') = $2
|
||||
"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(&series_name)
|
||||
.bind(local_user_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await;
|
||||
|
||||
let (book_count, books_read, total_volumes) = match stats {
|
||||
Ok(row) => {
|
||||
let bc: i64 = row.get("book_count");
|
||||
let br: i64 = row.get("books_read");
|
||||
let tv: Option<i32> = row.get("total_volumes");
|
||||
(bc, br, tv)
|
||||
}
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if book_count == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (status, progress_volumes) = if books_read > 0 && total_volumes.is_some_and(|tv| books_read >= tv as i64) {
|
||||
("COMPLETED".to_string(), books_read as i32)
|
||||
} else if books_read > 0 {
|
||||
("CURRENT".to_string(), books_read as i32)
|
||||
} else {
|
||||
("PLANNING".to_string(), 0i32)
|
||||
};
|
||||
|
||||
items.push(AnilistSyncPreviewItem {
|
||||
series_name,
|
||||
anilist_id,
|
||||
anilist_title,
|
||||
anilist_url,
|
||||
status,
|
||||
progress_volumes,
|
||||
books_read,
|
||||
book_count,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
/// Sync local reading progress to AniList for all enabled libraries
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/anilist/sync",
|
||||
tag = "anilist",
|
||||
responses(
|
||||
(status = 200, body = AnilistSyncReport),
|
||||
(status = 400, description = "AniList not configured"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn sync_to_anilist(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<AnilistSyncReport>, ApiError> {
|
||||
let (token, _, local_user_id) = load_anilist_settings(&state.pool).await?;
|
||||
let local_user_id = local_user_id
|
||||
.ok_or_else(|| ApiError::bad_request("AniList local user not configured — please select a user in settings"))?;
|
||||
|
||||
// Get all series that have AniList links in enabled libraries
|
||||
let links = sqlx::query(
|
||||
r#"
|
||||
SELECT asl.library_id, asl.series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url
|
||||
FROM anilist_series_links asl
|
||||
JOIN libraries l ON l.id = asl.library_id
|
||||
WHERE l.reading_status_provider = 'anilist'
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let mut synced = 0i32;
|
||||
let mut skipped = 0i32;
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
let mut items: Vec<AnilistSyncItem> = Vec::new();
|
||||
|
||||
let gql_update = r#"
|
||||
mutation SaveEntry($mediaId: Int, $status: MediaListStatus, $progressVolumes: Int) {
|
||||
SaveMediaListEntry(mediaId: $mediaId, status: $status, progressVolumes: $progressVolumes) {
|
||||
id
|
||||
status
|
||||
progressVolumes
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
for link in &links {
|
||||
let library_id: Uuid = link.get("library_id");
|
||||
let series_name: String = link.get("series_name");
|
||||
let anilist_id: i32 = link.get("anilist_id");
|
||||
let anilist_title: Option<String> = link.get("anilist_title");
|
||||
let anilist_url: Option<String> = link.get("anilist_url");
|
||||
|
||||
// Get reading progress + total_volumes from series metadata
|
||||
let stats = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(*) as book_count,
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read,
|
||||
(SELECT sm.total_volumes FROM series_metadata sm WHERE sm.library_id = $1 AND sm.name = $2 LIMIT 1) as total_volumes
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND brp.user_id = $3
|
||||
WHERE b.library_id = $1 AND COALESCE(NULLIF(b.series, ''), 'unclassified') = $2
|
||||
"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(&series_name)
|
||||
.bind(local_user_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await;
|
||||
|
||||
let (book_count, books_read, total_volumes) = match stats {
|
||||
Ok(row) => {
|
||||
let bc: i64 = row.get("book_count");
|
||||
let br: i64 = row.get("books_read");
|
||||
let tv: Option<i32> = row.get("total_volumes");
|
||||
(bc, br, tv)
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("{series_name}: DB error: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// COMPLETED only if books_read reaches the known total_volumes
|
||||
// — never auto-complete based solely on owned books
|
||||
let (status, progress_volumes) = if book_count == 0 {
|
||||
skipped += 1;
|
||||
continue;
|
||||
} else if books_read > 0 && total_volumes.is_some_and(|tv| books_read >= tv as i64) {
|
||||
("COMPLETED", books_read as i32)
|
||||
} else if books_read > 0 {
|
||||
("CURRENT", books_read as i32)
|
||||
} else {
|
||||
("PLANNING", 0i32)
|
||||
};
|
||||
|
||||
let vars = serde_json::json!({
|
||||
"mediaId": anilist_id,
|
||||
"status": status,
|
||||
"progressVolumes": progress_volumes,
|
||||
});
|
||||
|
||||
match anilist_graphql(&token, gql_update, vars).await {
|
||||
Ok(_) => {
|
||||
// Update synced_at
|
||||
let _ = sqlx::query(
|
||||
"UPDATE anilist_series_links SET status = 'synced', synced_at = NOW() WHERE library_id = $1 AND series_name = $2",
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(&series_name)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
items.push(AnilistSyncItem {
|
||||
series_name: series_name.clone(),
|
||||
anilist_title,
|
||||
anilist_url,
|
||||
status: status.to_string(),
|
||||
progress_volumes,
|
||||
});
|
||||
synced += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE anilist_series_links SET status = 'error' WHERE library_id = $1 AND series_name = $2",
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(&series_name)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
errors.push(format!("{series_name}: {}", e.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(AnilistSyncReport { synced, skipped, errors, items }))
|
||||
}
|
||||
|
||||
/// Pull reading list from AniList and update local reading progress
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/anilist/pull",
|
||||
tag = "anilist",
|
||||
responses(
|
||||
(status = 200, body = AnilistPullReport),
|
||||
(status = 400, description = "AniList not configured"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn pull_from_anilist(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<AnilistPullReport>, ApiError> {
|
||||
let (token, user_id, local_user_id) = load_anilist_settings(&state.pool).await?;
|
||||
let user_id = user_id
|
||||
.ok_or_else(|| ApiError::bad_request("AniList user_id not configured — please test the connection in settings"))?;
|
||||
let local_user_id = local_user_id
|
||||
.ok_or_else(|| ApiError::bad_request("AniList local user not configured — please select a user in settings"))?;
|
||||
|
||||
let gql = r#"
|
||||
query GetUserMangaList($userId: Int) {
|
||||
MediaListCollection(userId: $userId, type: MANGA) {
|
||||
lists {
|
||||
entries {
|
||||
media { id siteUrl }
|
||||
status
|
||||
progressVolumes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let data = anilist_graphql(&token, gql, serde_json::json!({ "userId": user_id })).await?;
|
||||
|
||||
let lists = data["MediaListCollection"]["lists"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build flat list of (anilist_id, status, progressVolumes)
|
||||
let mut entries: Vec<(i32, String, i32)> = Vec::new();
|
||||
for list in &lists {
|
||||
if let Some(list_entries) = list["entries"].as_array() {
|
||||
for entry in list_entries {
|
||||
let media_id = entry["media"]["id"].as_i64().unwrap_or(0) as i32;
|
||||
let status = entry["status"].as_str().unwrap_or("").to_string();
|
||||
let progress = entry["progressVolumes"].as_i64().unwrap_or(0) as i32;
|
||||
entries.push((media_id, status, progress));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find local series linked to these anilist IDs (in enabled libraries)
|
||||
let link_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT asl.library_id, asl.series_name, asl.anilist_id, asl.anilist_title, asl.anilist_url
|
||||
FROM anilist_series_links asl
|
||||
JOIN libraries l ON l.id = asl.library_id
|
||||
WHERE l.reading_status_provider = 'anilist'
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Build map: anilist_id → (library_id, series_name, anilist_title, anilist_url)
|
||||
let mut link_map: std::collections::HashMap<i32, (Uuid, String, Option<String>, Option<String>)> =
|
||||
std::collections::HashMap::new();
|
||||
for row in &link_rows {
|
||||
let aid: i32 = row.get("anilist_id");
|
||||
let lib: Uuid = row.get("library_id");
|
||||
let name: String = row.get("series_name");
|
||||
let title: Option<String> = row.get("anilist_title");
|
||||
let url: Option<String> = row.get("anilist_url");
|
||||
link_map.insert(aid, (lib, name, title, url));
|
||||
}
|
||||
|
||||
let mut updated = 0i32;
|
||||
let mut skipped = 0i32;
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
let mut items: Vec<AnilistPullItem> = Vec::new();
|
||||
|
||||
for (anilist_id, anilist_status, progress_volumes) in &entries {
|
||||
let Some((library_id, series_name, anilist_title, anilist_url)) = link_map.get(anilist_id) else {
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
// Map AniList status → local reading status
|
||||
let local_status = match anilist_status.as_str() {
|
||||
"COMPLETED" => "read",
|
||||
"CURRENT" | "REPEATING" => "reading",
|
||||
"PLANNING" | "PAUSED" | "DROPPED" => "unread",
|
||||
_ => {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Get all book IDs for this series, ordered by volume
|
||||
let book_rows = sqlx::query(
|
||||
"SELECT id, volume FROM books WHERE library_id = $1 AND COALESCE(NULLIF(series, ''), 'unclassified') = $2 ORDER BY volume NULLS LAST",
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(series_name)
|
||||
.fetch_all(&state.pool)
|
||||
.await;
|
||||
|
||||
let book_rows = match book_rows {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
errors.push(format!("{series_name}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if book_rows.is_empty() {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let total_books = book_rows.len() as i32;
|
||||
let volumes_done = (*progress_volumes).min(total_books);
|
||||
|
||||
for (idx, book_row) in book_rows.iter().enumerate() {
|
||||
let book_id: Uuid = book_row.get("id");
|
||||
let book_status = if local_status == "read" || (idx as i32) < volumes_done {
|
||||
"read"
|
||||
} else if local_status == "reading" && idx as i32 == volumes_done {
|
||||
"reading"
|
||||
} else {
|
||||
"unread"
|
||||
};
|
||||
|
||||
let _ = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||
VALUES ($1, $3, $2, NULL, NOW(), NOW())
|
||||
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||
SET status = EXCLUDED.status, updated_at = NOW()
|
||||
WHERE book_reading_progress.status != EXCLUDED.status
|
||||
"#,
|
||||
)
|
||||
.bind(book_id)
|
||||
.bind(book_status)
|
||||
.bind(local_user_id)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
items.push(AnilistPullItem {
|
||||
series_name: series_name.clone(),
|
||||
anilist_title: anilist_title.clone(),
|
||||
anilist_url: anilist_url.clone(),
|
||||
anilist_status: anilist_status.clone(),
|
||||
books_updated: total_books,
|
||||
});
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
Ok(Json(AnilistPullReport { updated, skipped, errors, items }))
|
||||
}
|
||||
|
||||
/// List all AniList series links
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/anilist/links",
|
||||
tag = "anilist",
|
||||
responses(
|
||||
(status = 200, body = Vec<AnilistSeriesLinkResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_links(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<AnilistSeriesLinkResponse>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT library_id, series_name, anilist_id, anilist_title, anilist_url, status, linked_at, synced_at
|
||||
FROM anilist_series_links
|
||||
ORDER BY linked_at DESC",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let links: Vec<AnilistSeriesLinkResponse> = rows
|
||||
.iter()
|
||||
.map(|row| AnilistSeriesLinkResponse {
|
||||
library_id: row.get("library_id"),
|
||||
series_name: row.get("series_name"),
|
||||
anilist_id: row.get("anilist_id"),
|
||||
anilist_title: row.get("anilist_title"),
|
||||
anilist_url: row.get("anilist_url"),
|
||||
status: row.get("status"),
|
||||
linked_at: row.get("linked_at"),
|
||||
synced_at: row.get("synced_at"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(links))
|
||||
}
|
||||
@@ -10,10 +10,15 @@ use sqlx::Row;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthUser {
|
||||
pub user_id: uuid::Uuid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Scope {
|
||||
Admin,
|
||||
Read,
|
||||
Read { user_id: uuid::Uuid },
|
||||
}
|
||||
|
||||
pub async fn require_admin(
|
||||
@@ -40,6 +45,20 @@ pub async fn require_read(
|
||||
let token = bearer_token(&req).ok_or_else(|| ApiError::unauthorized("missing bearer token"))?;
|
||||
let scope = authenticate(&state, token).await?;
|
||||
|
||||
if let Scope::Read { user_id } = &scope {
|
||||
req.extensions_mut().insert(AuthUser { user_id: *user_id });
|
||||
} else if matches!(scope, Scope::Admin) {
|
||||
// Admin peut s'impersonifier via le header X-As-User
|
||||
if let Some(as_user_id) = req
|
||||
.headers()
|
||||
.get("X-As-User")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| uuid::Uuid::parse_str(v).ok())
|
||||
{
|
||||
req.extensions_mut().insert(AuthUser { user_id: as_user_id });
|
||||
}
|
||||
}
|
||||
|
||||
req.extensions_mut().insert(scope);
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
@@ -60,8 +79,7 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
|
||||
|
||||
let maybe_row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, token_hash, scope
|
||||
FROM api_tokens
|
||||
SELECT id, token_hash, scope, user_id FROM api_tokens
|
||||
WHERE prefix = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())
|
||||
"#,
|
||||
)
|
||||
@@ -88,7 +106,12 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
|
||||
let scope: String = row.try_get("scope").map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||
match scope.as_str() {
|
||||
"admin" => Ok(Scope::Admin),
|
||||
"read" => Ok(Scope::Read),
|
||||
"read" => {
|
||||
let user_id: uuid::Uuid = row
|
||||
.try_get("user_id")
|
||||
.map_err(|_| ApiError::unauthorized("read token missing user_id"))?;
|
||||
Ok(Scope::Read { user_id })
|
||||
}
|
||||
_ => Err(ApiError::unauthorized("invalid token scope")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use axum::{extract::{Path, Query, State}, Json};
|
||||
use axum::{extract::{Extension, Path, Query, State}, Json};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{error::ApiError, index_jobs::IndexJobResponse, state::AppState};
|
||||
use crate::{auth::AuthUser, error::ApiError, index_jobs::IndexJobResponse, state::AppState};
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ListBooksQuery {
|
||||
@@ -122,7 +122,9 @@ pub struct BookDetails {
|
||||
pub async fn list_books(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListBooksQuery>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
) -> Result<Json<BooksPage>, ApiError> {
|
||||
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let offset = (page - 1) * limit;
|
||||
@@ -151,6 +153,8 @@ pub async fn list_books(
|
||||
Some(_) => { p += 1; format!("AND eml.provider = ${p}") },
|
||||
None => String::new(),
|
||||
};
|
||||
p += 1;
|
||||
let uid_p = p;
|
||||
|
||||
let metadata_links_cte = r#"
|
||||
metadata_links AS (
|
||||
@@ -164,7 +168,7 @@ pub async fn list_books(
|
||||
let count_sql = format!(
|
||||
r#"WITH {metadata_links_cte}
|
||||
SELECT COUNT(*) FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ${uid_p}::uuid IS NOT NULL AND brp.user_id = ${uid_p}
|
||||
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
|
||||
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||
AND ($2::text IS NULL OR b.kind = $2)
|
||||
@@ -192,7 +196,7 @@ pub async fn list_books(
|
||||
brp.current_page AS reading_current_page,
|
||||
brp.last_read_at AS reading_last_read_at
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ${uid_p}::uuid IS NOT NULL AND brp.user_id = ${uid_p}
|
||||
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
|
||||
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||
AND ($2::text IS NULL OR b.kind = $2)
|
||||
@@ -235,8 +239,8 @@ pub async fn list_books(
|
||||
data_builder = data_builder.bind(mp.clone());
|
||||
}
|
||||
}
|
||||
|
||||
data_builder = data_builder.bind(limit).bind(offset);
|
||||
count_builder = count_builder.bind(user_id);
|
||||
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
|
||||
|
||||
let (count_row, rows) = tokio::try_join!(
|
||||
count_builder.fetch_one(&state.pool),
|
||||
@@ -295,7 +299,9 @@ pub async fn list_books(
|
||||
pub async fn get_book(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
) -> Result<Json<BookDetails>, ApiError> {
|
||||
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.locked_fields, b.summary, b.isbn, b.publish_date,
|
||||
@@ -311,11 +317,12 @@ pub async fn get_book(
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
) bf ON TRUE
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
|
||||
WHERE b.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -521,9 +528,9 @@ pub async fn update_book(
|
||||
WHERE id = $1
|
||||
RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path,
|
||||
summary, isbn, publish_date,
|
||||
COALESCE((SELECT status FROM book_reading_progress WHERE book_id = $1), 'unread') AS reading_status,
|
||||
(SELECT current_page FROM book_reading_progress WHERE book_id = $1) AS reading_current_page,
|
||||
(SELECT last_read_at FROM book_reading_progress WHERE book_id = $1) AS reading_last_read_at
|
||||
'unread' AS reading_status,
|
||||
NULL::integer AS reading_current_page,
|
||||
NULL::timestamptz AS reading_last_read_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
|
||||
134
apps/api/src/job_poller.rs
Normal file
134
apps/api/src/job_poller.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use sqlx::{PgPool, Row};
|
||||
use tracing::{error, info, trace};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{metadata_batch, metadata_refresh};
|
||||
|
||||
/// Poll for pending API-only jobs (`metadata_batch`, `metadata_refresh`) and process them.
|
||||
/// This mirrors the indexer's worker loop but for job types handled by the API.
|
||||
pub async fn run_job_poller(pool: PgPool, interval_seconds: u64) {
|
||||
let wait = Duration::from_secs(interval_seconds.max(1));
|
||||
|
||||
loop {
|
||||
match claim_next_api_job(&pool).await {
|
||||
Ok(Some((job_id, job_type, library_id))) => {
|
||||
info!("[JOB_POLLER] Claimed {job_type} job {job_id} library={library_id}");
|
||||
|
||||
let pool_clone = pool.clone();
|
||||
let library_name: Option<String> =
|
||||
sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = match job_type.as_str() {
|
||||
"metadata_refresh" => {
|
||||
metadata_refresh::process_metadata_refresh(
|
||||
&pool_clone,
|
||||
job_id,
|
||||
library_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"metadata_batch" => {
|
||||
metadata_batch::process_metadata_batch(
|
||||
&pool_clone,
|
||||
job_id,
|
||||
library_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => Err(format!("Unknown API job type: {job_type}")),
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
error!("[JOB_POLLER] {job_type} job {job_id} failed: {e}");
|
||||
let _ = sqlx::query(
|
||||
"UPDATE index_jobs SET status = 'failed', error_opt = $2, finished_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(e.to_string())
|
||||
.execute(&pool_clone)
|
||||
.await;
|
||||
|
||||
match job_type.as_str() {
|
||||
"metadata_refresh" => {
|
||||
notifications::notify(
|
||||
pool_clone,
|
||||
notifications::NotificationEvent::MetadataRefreshFailed {
|
||||
library_name,
|
||||
error: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
"metadata_batch" => {
|
||||
notifications::notify(
|
||||
pool_clone,
|
||||
notifications::NotificationEvent::MetadataBatchFailed {
|
||||
library_name,
|
||||
error: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
trace!("[JOB_POLLER] No pending API jobs, waiting...");
|
||||
tokio::time::sleep(wait).await;
|
||||
}
|
||||
Err(err) => {
|
||||
error!("[JOB_POLLER] Error claiming job: {err}");
|
||||
tokio::time::sleep(wait).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const API_JOB_TYPES: &[&str] = &["metadata_batch", "metadata_refresh"];
|
||||
|
||||
async fn claim_next_api_job(pool: &PgPool) -> Result<Option<(Uuid, String, Uuid)>, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, type, library_id
|
||||
FROM index_jobs
|
||||
WHERE status = 'pending'
|
||||
AND type = ANY($1)
|
||||
AND library_id IS NOT NULL
|
||||
ORDER BY created_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(API_JOB_TYPES)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
tx.commit().await?;
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let id: Uuid = row.get("id");
|
||||
let job_type: String = row.get("type");
|
||||
let library_id: Uuid = row.get("library_id");
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE index_jobs SET status = 'running', started_at = NOW(), error_opt = NULL WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(Some((id, job_type, library_id)))
|
||||
}
|
||||
@@ -38,6 +38,8 @@ pub struct KomgaSyncRequest {
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[schema(value_type = String)]
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -45,6 +47,8 @@ pub struct KomgaSyncResponse {
|
||||
#[schema(value_type = String)]
|
||||
pub id: Uuid,
|
||||
pub komga_url: String,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub user_id: Option<Uuid>,
|
||||
pub total_komga_read: i64,
|
||||
pub matched: i64,
|
||||
pub already_read: i64,
|
||||
@@ -61,6 +65,8 @@ pub struct KomgaSyncReportSummary {
|
||||
#[schema(value_type = String)]
|
||||
pub id: Uuid,
|
||||
pub komga_url: String,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub user_id: Option<Uuid>,
|
||||
pub total_komga_read: i64,
|
||||
pub matched: i64,
|
||||
pub already_read: i64,
|
||||
@@ -215,11 +221,12 @@ pub async fn sync_komga_read_books(
|
||||
let mut already_read_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
|
||||
|
||||
if !matched_ids.is_empty() {
|
||||
// Get already-read book IDs
|
||||
// Get already-read book IDs for this user
|
||||
let ar_rows = sqlx::query(
|
||||
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND status = 'read'",
|
||||
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND user_id = $2 AND status = 'read'",
|
||||
)
|
||||
.bind(&matched_ids)
|
||||
.bind(body.user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -228,12 +235,12 @@ pub async fn sync_komga_read_books(
|
||||
}
|
||||
already_read = already_read_ids.len() as i64;
|
||||
|
||||
// Bulk upsert all matched books as read
|
||||
// Bulk upsert all matched books as read for this user
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
||||
SELECT unnest($1::uuid[]), 'read', NULL, NOW(), NOW()
|
||||
ON CONFLICT (book_id) DO UPDATE
|
||||
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||
SELECT unnest($1::uuid[]), $2, 'read', NULL, NOW(), NOW()
|
||||
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||
SET status = 'read',
|
||||
current_page = NULL,
|
||||
last_read_at = NOW(),
|
||||
@@ -242,6 +249,7 @@ pub async fn sync_komga_read_books(
|
||||
"#,
|
||||
)
|
||||
.bind(&matched_ids)
|
||||
.bind(body.user_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
}
|
||||
@@ -273,12 +281,13 @@ pub async fn sync_komga_read_books(
|
||||
let newly_marked_books_json = serde_json::to_value(&newly_marked_books).unwrap_or_default();
|
||||
let report_row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO komga_sync_reports (komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
INSERT INTO komga_sync_reports (komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(&url)
|
||||
.bind(body.user_id)
|
||||
.bind(total_komga_read)
|
||||
.bind(matched)
|
||||
.bind(already_read)
|
||||
@@ -292,6 +301,7 @@ pub async fn sync_komga_read_books(
|
||||
Ok(Json(KomgaSyncResponse {
|
||||
id: report_row.get("id"),
|
||||
komga_url: url,
|
||||
user_id: Some(body.user_id),
|
||||
total_komga_read,
|
||||
matched,
|
||||
already_read,
|
||||
@@ -319,7 +329,7 @@ pub async fn list_sync_reports(
|
||||
) -> Result<Json<Vec<KomgaSyncReportSummary>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked,
|
||||
SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked,
|
||||
jsonb_array_length(unmatched) as unmatched_count, created_at
|
||||
FROM komga_sync_reports
|
||||
ORDER BY created_at DESC
|
||||
@@ -334,6 +344,7 @@ pub async fn list_sync_reports(
|
||||
.map(|row| KomgaSyncReportSummary {
|
||||
id: row.get("id"),
|
||||
komga_url: row.get("komga_url"),
|
||||
user_id: row.get("user_id"),
|
||||
total_komga_read: row.get("total_komga_read"),
|
||||
matched: row.get("matched"),
|
||||
already_read: row.get("already_read"),
|
||||
@@ -365,7 +376,7 @@ pub async fn get_sync_report(
|
||||
) -> Result<Json<KomgaSyncResponse>, ApiError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
|
||||
SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
|
||||
FROM komga_sync_reports
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -386,6 +397,7 @@ pub async fn get_sync_report(
|
||||
Ok(Json(KomgaSyncResponse {
|
||||
id: row.get("id"),
|
||||
komga_url: row.get("komga_url"),
|
||||
user_id: row.get("user_id"),
|
||||
total_komga_read: row.get("total_komga_read"),
|
||||
matched: row.get("matched"),
|
||||
already_read: row.get("already_read"),
|
||||
|
||||
@@ -30,6 +30,7 @@ pub struct LibraryResponse {
|
||||
/// First book IDs from up to 5 distinct series (for thumbnail fan display)
|
||||
#[schema(value_type = Vec<String>)]
|
||||
pub thumbnail_book_ids: Vec<Uuid>,
|
||||
pub reading_status_provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
@@ -53,7 +54,7 @@ pub struct CreateLibraryRequest {
|
||||
)]
|
||||
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled, l.metadata_provider, l.fallback_metadata_provider, l.metadata_refresh_mode, l.next_metadata_refresh_at,
|
||||
"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, l.metadata_refresh_mode, l.next_metadata_refresh_at, l.reading_status_provider,
|
||||
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count,
|
||||
(SELECT COUNT(DISTINCT COALESCE(NULLIF(b.series, ''), 'unclassified')) FROM books b WHERE b.library_id = l.id) as series_count,
|
||||
COALESCE((
|
||||
@@ -92,6 +93,7 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
|
||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||
thumbnail_book_ids: row.get("thumbnail_book_ids"),
|
||||
reading_status_provider: row.get("reading_status_provider"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -149,6 +151,7 @@ pub async fn create_library(
|
||||
metadata_refresh_mode: "manual".to_string(),
|
||||
next_metadata_refresh_at: None,
|
||||
thumbnail_book_ids: vec![],
|
||||
reading_status_provider: None,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -336,7 +339,7 @@ pub async fn update_monitoring(
|
||||
let watcher_enabled = input.watcher_enabled.unwrap_or(false);
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5, metadata_refresh_mode = $6, next_metadata_refresh_at = $7 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at"
|
||||
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5, metadata_refresh_mode = $6, next_metadata_refresh_at = $7 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at, watcher_enabled, metadata_provider, fallback_metadata_provider, metadata_refresh_mode, next_metadata_refresh_at, reading_status_provider"
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(input.monitor_enabled)
|
||||
@@ -389,6 +392,7 @@ pub async fn update_monitoring(
|
||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||
thumbnail_book_ids,
|
||||
reading_status_provider: row.get("reading_status_provider"),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -424,7 +428,7 @@ pub async fn update_metadata_provider(
|
||||
let fallback = input.fallback_metadata_provider.as_deref().filter(|s| !s.is_empty());
|
||||
|
||||
let result = sqlx::query(
|
||||
"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, metadata_refresh_mode, next_metadata_refresh_at"
|
||||
"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, metadata_refresh_mode, next_metadata_refresh_at, reading_status_provider"
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(provider)
|
||||
@@ -473,5 +477,44 @@ pub async fn update_metadata_provider(
|
||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||
thumbnail_book_ids,
|
||||
reading_status_provider: row.get("reading_status_provider"),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateReadingStatusProviderRequest {
|
||||
pub reading_status_provider: Option<String>,
|
||||
}
|
||||
|
||||
/// Update the reading status provider for a library
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/libraries/{id}/reading-status-provider",
|
||||
tag = "libraries",
|
||||
params(("id" = String, Path, description = "Library UUID")),
|
||||
request_body = UpdateReadingStatusProviderRequest,
|
||||
responses(
|
||||
(status = 200, description = "Updated"),
|
||||
(status = 404, description = "Library not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn update_reading_status_provider(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(library_id): AxumPath<Uuid>,
|
||||
Json(input): Json<UpdateReadingStatusProviderRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let provider = input.reading_status_provider.as_deref().filter(|s| !s.is_empty());
|
||||
let result = sqlx::query("UPDATE libraries SET reading_status_provider = $2 WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.bind(provider)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found("library not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "reading_status_provider": provider })))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
mod anilist;
|
||||
mod auth;
|
||||
mod authors;
|
||||
mod books;
|
||||
mod error;
|
||||
mod handlers;
|
||||
mod index_jobs;
|
||||
mod job_poller;
|
||||
mod komga;
|
||||
mod libraries;
|
||||
mod metadata;
|
||||
@@ -16,6 +18,7 @@ mod pages;
|
||||
mod prowlarr;
|
||||
mod qbittorrent;
|
||||
mod reading_progress;
|
||||
mod reading_status_match;
|
||||
mod search;
|
||||
mod series;
|
||||
mod settings;
|
||||
@@ -24,6 +27,7 @@ mod stats;
|
||||
mod telegram;
|
||||
mod thumbnails;
|
||||
mod tokens;
|
||||
mod users;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
@@ -92,6 +96,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/libraries/:id", delete(libraries::delete_library))
|
||||
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
|
||||
.route("/libraries/:id/metadata-provider", axum::routing::patch(libraries::update_metadata_provider))
|
||||
.route("/libraries/:id/reading-status-provider", axum::routing::patch(libraries::update_reading_status_provider))
|
||||
.route("/books/:id", axum::routing::patch(books::update_book))
|
||||
.route("/books/:id/convert", axum::routing::post(books::convert_book))
|
||||
.route("/libraries/:library_id/series/:name", axum::routing::patch(series::update_series))
|
||||
@@ -105,8 +110,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/index/jobs/:id/errors", get(index_jobs::get_job_errors))
|
||||
.route("/index/cancel/:id", axum::routing::post(index_jobs::cancel_job))
|
||||
.route("/folders", get(index_jobs::list_folders))
|
||||
.route("/admin/users", get(users::list_users).post(users::create_user))
|
||||
.route("/admin/users/:id", delete(users::delete_user).patch(users::update_user))
|
||||
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
||||
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
||||
.route("/admin/tokens/:id", delete(tokens::revoke_token).patch(tokens::update_token))
|
||||
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
||||
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
|
||||
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
|
||||
@@ -116,6 +123,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
|
||||
.route("/komga/reports", get(komga::list_sync_reports))
|
||||
.route("/komga/reports/:id", get(komga::get_sync_report))
|
||||
.route("/anilist/status", get(anilist::get_status))
|
||||
.route("/anilist/search", axum::routing::post(anilist::search_manga))
|
||||
.route("/anilist/unlinked", get(anilist::list_unlinked))
|
||||
.route("/anilist/sync/preview", get(anilist::preview_sync))
|
||||
.route("/anilist/sync", axum::routing::post(anilist::sync_to_anilist))
|
||||
.route("/anilist/pull", axum::routing::post(anilist::pull_from_anilist))
|
||||
.route("/anilist/links", get(anilist::list_links))
|
||||
.route("/anilist/libraries/:id", axum::routing::patch(anilist::toggle_library))
|
||||
.route("/anilist/series/:library_id/:series_name", get(anilist::get_series_link))
|
||||
.route("/anilist/series/:library_id/:series_name/link", axum::routing::post(anilist::link_series))
|
||||
.route("/anilist/series/:library_id/:series_name/unlink", delete(anilist::unlink_series))
|
||||
.route("/metadata/search", axum::routing::post(metadata::search_metadata))
|
||||
.route("/metadata/match", axum::routing::post(metadata::create_metadata_match))
|
||||
.route("/metadata/approve/:id", axum::routing::post(metadata::approve_metadata))
|
||||
@@ -128,6 +146,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/metadata/batch/:id/results", get(metadata_batch::get_batch_results))
|
||||
.route("/metadata/refresh", axum::routing::post(metadata_refresh::start_refresh))
|
||||
.route("/metadata/refresh/:id/report", get(metadata_refresh::get_refresh_report))
|
||||
.route("/reading-status/match", axum::routing::post(reading_status_match::start_match))
|
||||
.route("/reading-status/match/:id/report", get(reading_status_match::get_match_report))
|
||||
.route("/reading-status/match/:id/results", get(reading_status_match::get_match_results))
|
||||
.merge(settings::settings_routes())
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
@@ -159,6 +180,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
auth::require_read,
|
||||
));
|
||||
|
||||
// Clone pool before state is moved into the router
|
||||
let poller_pool = state.pool.clone();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(handlers::health))
|
||||
.route("/ready", get(handlers::ready))
|
||||
@@ -170,6 +194,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
.layer(middleware::from_fn_with_state(state.clone(), api_middleware::request_counter))
|
||||
.with_state(state);
|
||||
|
||||
// Start background poller for API-only jobs (metadata_batch, metadata_refresh)
|
||||
tokio::spawn(async move {
|
||||
job_poller::run_job_poller(poller_pool, 5).await;
|
||||
});
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
|
||||
info!(addr = %config.listen_addr, "api listening");
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
@@ -115,14 +115,14 @@ pub async fn start_batch(
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_batch', 'pending')",
|
||||
"INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'metadata_batch', 'running', NOW())",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(library_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Spawn the background processing task
|
||||
// Spawn the background processing task (status already 'running' to avoid poller race)
|
||||
let pool = state.pool.clone();
|
||||
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
@@ -313,7 +313,7 @@ pub async fn get_batch_results(
|
||||
// Background processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn process_metadata_batch(
|
||||
pub(crate) async fn process_metadata_batch(
|
||||
pool: &PgPool,
|
||||
job_id: Uuid,
|
||||
library_id: Uuid,
|
||||
|
||||
@@ -110,9 +110,16 @@ pub async fn start_refresh(
|
||||
})));
|
||||
}
|
||||
|
||||
// Check there are approved links to refresh
|
||||
// Check there are approved links to refresh (only ongoing series)
|
||||
let link_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM external_metadata_links WHERE library_id = $1 AND status = 'approved'",
|
||||
r#"
|
||||
SELECT COUNT(*) FROM external_metadata_links eml
|
||||
LEFT JOIN series_metadata sm
|
||||
ON sm.library_id = eml.library_id AND sm.name = eml.series_name
|
||||
WHERE eml.library_id = $1
|
||||
AND eml.status = 'approved'
|
||||
AND COALESCE(sm.status, 'ongoing') NOT IN ('ended', 'cancelled')
|
||||
"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_one(&state.pool)
|
||||
@@ -124,14 +131,14 @@ pub async fn start_refresh(
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_refresh', 'pending')",
|
||||
"INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'metadata_refresh', 'running', NOW())",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(library_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Spawn the background processing task
|
||||
// Spawn the background processing task (status already 'running' to avoid poller race)
|
||||
let pool = state.pool.clone();
|
||||
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
@@ -222,7 +229,7 @@ pub async fn get_refresh_report(
|
||||
// Background processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn process_metadata_refresh(
|
||||
pub(crate) async fn process_metadata_refresh(
|
||||
pool: &PgPool,
|
||||
job_id: Uuid,
|
||||
library_id: Uuid,
|
||||
@@ -234,13 +241,17 @@ async fn process_metadata_refresh(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Get all approved links for this library
|
||||
// Get approved links for this library, only for ongoing series (not ended/cancelled)
|
||||
let links: Vec<(Uuid, String, String, String)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, series_name, provider, external_id
|
||||
FROM external_metadata_links
|
||||
WHERE library_id = $1 AND status = 'approved'
|
||||
ORDER BY series_name
|
||||
SELECT eml.id, eml.series_name, eml.provider, eml.external_id
|
||||
FROM external_metadata_links eml
|
||||
LEFT JOIN series_metadata sm
|
||||
ON sm.library_id = eml.library_id AND sm.name = eml.series_name
|
||||
WHERE eml.library_id = $1
|
||||
AND eml.status = 'approved'
|
||||
AND COALESCE(sm.status, 'ongoing') NOT IN ('ended', 'cancelled')
|
||||
ORDER BY eml.series_name
|
||||
"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use axum::{extract::{Path, State}, Json};
|
||||
use axum::{extract::{Extension, Path, State}, Json};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
use crate::{auth::AuthUser, error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ReadingProgressResponse {
|
||||
@@ -42,8 +42,10 @@ pub struct UpdateReadingProgressRequest {
|
||||
)]
|
||||
pub async fn get_reading_progress(
|
||||
State(state): State<AppState>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
||||
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
|
||||
// Verify book exists
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
|
||||
.bind(id)
|
||||
@@ -55,9 +57,10 @@ pub async fn get_reading_progress(
|
||||
}
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1",
|
||||
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(auth_user.user_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -96,9 +99,11 @@ pub async fn get_reading_progress(
|
||||
)]
|
||||
pub async fn update_reading_progress(
|
||||
State(state): State<AppState>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<UpdateReadingProgressRequest>,
|
||||
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
||||
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
|
||||
// Validate status value
|
||||
if !["unread", "reading", "read"].contains(&body.status.as_str()) {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
@@ -143,9 +148,9 @@ pub async fn update_reading_progress(
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (book_id) DO UPDATE
|
||||
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||
SET status = EXCLUDED.status,
|
||||
current_page = EXCLUDED.current_page,
|
||||
last_read_at = NOW(),
|
||||
@@ -154,6 +159,7 @@ pub async fn update_reading_progress(
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(auth_user.user_id)
|
||||
.bind(&body.status)
|
||||
.bind(current_page)
|
||||
.fetch_one(&state.pool)
|
||||
@@ -194,8 +200,10 @@ pub struct MarkSeriesReadResponse {
|
||||
)]
|
||||
pub async fn mark_series_read(
|
||||
State(state): State<AppState>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
Json(body): Json<MarkSeriesReadRequest>,
|
||||
) -> Result<Json<MarkSeriesReadResponse>, ApiError> {
|
||||
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
|
||||
if !["read", "unread"].contains(&body.status.as_str()) {
|
||||
return Err(ApiError::bad_request(
|
||||
"status must be 'read' or 'unread'",
|
||||
@@ -209,24 +217,50 @@ pub async fn mark_series_read(
|
||||
};
|
||||
|
||||
let sql = if body.status == "unread" {
|
||||
// Delete progress records to reset to unread
|
||||
// Delete progress records to reset to unread (scoped to this user)
|
||||
if body.series == "unclassified" {
|
||||
format!(
|
||||
r#"
|
||||
WITH target_books AS (
|
||||
SELECT id FROM books WHERE {series_filter}
|
||||
)
|
||||
DELETE FROM book_reading_progress
|
||||
WHERE book_id IN (SELECT id FROM target_books)
|
||||
WHERE book_id IN (SELECT id FROM target_books) AND user_id = $1
|
||||
"#
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"
|
||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
||||
SELECT id, 'read', NULL, NOW(), NOW()
|
||||
WITH target_books AS (
|
||||
SELECT id FROM books WHERE {series_filter}
|
||||
)
|
||||
DELETE FROM book_reading_progress
|
||||
WHERE book_id IN (SELECT id FROM target_books) AND user_id = $2
|
||||
"#
|
||||
)
|
||||
}
|
||||
} else if body.series == "unclassified" {
|
||||
format!(
|
||||
r#"
|
||||
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||
SELECT id, $1, 'read', NULL, NOW(), NOW()
|
||||
FROM books
|
||||
WHERE {series_filter}
|
||||
ON CONFLICT (book_id) DO UPDATE
|
||||
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||
SET status = 'read',
|
||||
current_page = NULL,
|
||||
last_read_at = NOW(),
|
||||
updated_at = NOW()
|
||||
"#
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"
|
||||
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||
SELECT id, $2, 'read', NULL, NOW(), NOW()
|
||||
FROM books
|
||||
WHERE {series_filter}
|
||||
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||
SET status = 'read',
|
||||
current_page = NULL,
|
||||
last_read_at = NOW(),
|
||||
@@ -236,9 +270,18 @@ pub async fn mark_series_read(
|
||||
};
|
||||
|
||||
let result = if body.series == "unclassified" {
|
||||
sqlx::query(&sql).execute(&state.pool).await?
|
||||
// $1 = user_id (no series bind needed)
|
||||
sqlx::query(&sql)
|
||||
.bind(auth_user.user_id)
|
||||
.execute(&state.pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query(&sql).bind(&body.series).execute(&state.pool).await?
|
||||
// $1 = series, $2 = user_id
|
||||
sqlx::query(&sql)
|
||||
.bind(&body.series)
|
||||
.bind(auth_user.user_id)
|
||||
.execute(&state.pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(Json(MarkSeriesReadResponse {
|
||||
|
||||
614
apps/api/src/reading_status_match.rs
Normal file
614
apps/api/src/reading_status_match.rs
Normal file
@@ -0,0 +1,614 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::time::Duration;
|
||||
use tracing::{info, warn};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{anilist, error::ApiError, state::AppState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ReadingStatusMatchRequest {
|
||||
pub library_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ReadingStatusMatchReportDto {
|
||||
#[schema(value_type = String)]
|
||||
pub job_id: Uuid,
|
||||
pub status: String,
|
||||
pub total_series: i64,
|
||||
pub linked: i64,
|
||||
pub already_linked: i64,
|
||||
pub no_results: i64,
|
||||
pub ambiguous: i64,
|
||||
pub errors: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ReadingStatusMatchResultDto {
|
||||
#[schema(value_type = String)]
|
||||
pub id: Uuid,
|
||||
pub series_name: String,
|
||||
/// 'linked' | 'already_linked' | 'no_results' | 'ambiguous' | 'error'
|
||||
pub status: String,
|
||||
pub anilist_id: Option<i32>,
|
||||
pub anilist_title: Option<String>,
|
||||
pub anilist_url: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /reading-status/match — Trigger a reading status match job
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/reading-status/match",
|
||||
tag = "reading_status",
|
||||
request_body = ReadingStatusMatchRequest,
|
||||
responses(
|
||||
(status = 200, description = "Job created"),
|
||||
(status = 400, description = "Bad request"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn start_match(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ReadingStatusMatchRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let library_id: Uuid = body
|
||||
.library_id
|
||||
.parse()
|
||||
.map_err(|_| ApiError::bad_request("invalid library_id"))?;
|
||||
|
||||
// Verify library exists and has a reading_status_provider configured
|
||||
let lib_row = sqlx::query("SELECT reading_status_provider FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found("library not found"))?;
|
||||
|
||||
let provider: Option<String> = lib_row.get("reading_status_provider");
|
||||
if provider.is_none() {
|
||||
return Err(ApiError::bad_request(
|
||||
"This library has no reading status provider configured",
|
||||
));
|
||||
}
|
||||
|
||||
// Check AniList is configured globally
|
||||
anilist::load_anilist_settings(&state.pool).await?;
|
||||
|
||||
// Check no existing running job for this library
|
||||
let existing: Option<Uuid> = sqlx::query_scalar(
|
||||
"SELECT id FROM index_jobs WHERE library_id = $1 AND type = 'reading_status_match' AND status IN ('pending', 'running') LIMIT 1",
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if let Some(existing_id) = existing {
|
||||
return Ok(Json(serde_json::json!({
|
||||
"id": existing_id.to_string(),
|
||||
"status": "already_running",
|
||||
})));
|
||||
}
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'reading_status_match', 'running', NOW())",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(library_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let pool = state.pool.clone();
|
||||
let library_name: Option<String> =
|
||||
sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = process_reading_status_match(&pool, job_id, library_id).await {
|
||||
warn!("[READING_STATUS_MATCH] job {job_id} failed: {e}");
|
||||
let _ = sqlx::query(
|
||||
"UPDATE index_jobs SET status = 'failed', error_opt = $2, finished_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(e.to_string())
|
||||
.execute(&pool)
|
||||
.await;
|
||||
notifications::notify(
|
||||
pool.clone(),
|
||||
notifications::NotificationEvent::ReadingStatusMatchFailed {
|
||||
library_name,
|
||||
error: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": job_id.to_string(),
|
||||
"status": "running",
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /reading-status/match/:id/report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/reading-status/match/{id}/report",
|
||||
tag = "reading_status",
|
||||
params(("id" = String, Path, description = "Job UUID")),
|
||||
responses(
|
||||
(status = 200, body = ReadingStatusMatchReportDto),
|
||||
(status = 404, description = "Job not found"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn get_match_report(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(job_id): axum::extract::Path<Uuid>,
|
||||
) -> Result<Json<ReadingStatusMatchReportDto>, ApiError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT status, total_files FROM index_jobs WHERE id = $1 AND type = 'reading_status_match'",
|
||||
)
|
||||
.bind(job_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found("job not found"))?;
|
||||
|
||||
let job_status: String = row.get("status");
|
||||
let total_files: Option<i32> = row.get("total_files");
|
||||
|
||||
let counts = sqlx::query(
|
||||
"SELECT status, COUNT(*) as cnt FROM reading_status_match_results WHERE job_id = $1 GROUP BY status",
|
||||
)
|
||||
.bind(job_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let mut linked = 0i64;
|
||||
let mut already_linked = 0i64;
|
||||
let mut no_results = 0i64;
|
||||
let mut ambiguous = 0i64;
|
||||
let mut errors = 0i64;
|
||||
|
||||
for r in &counts {
|
||||
let status: String = r.get("status");
|
||||
let cnt: i64 = r.get("cnt");
|
||||
match status.as_str() {
|
||||
"linked" => linked = cnt,
|
||||
"already_linked" => already_linked = cnt,
|
||||
"no_results" => no_results = cnt,
|
||||
"ambiguous" => ambiguous = cnt,
|
||||
"error" => errors = cnt,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(ReadingStatusMatchReportDto {
|
||||
job_id,
|
||||
status: job_status,
|
||||
total_series: total_files.unwrap_or(0) as i64,
|
||||
linked,
|
||||
already_linked,
|
||||
no_results,
|
||||
ambiguous,
|
||||
errors,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /reading-status/match/:id/results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/reading-status/match/{id}/results",
|
||||
tag = "reading_status",
|
||||
params(
|
||||
("id" = String, Path, description = "Job UUID"),
|
||||
("status" = Option<String>, Query, description = "Filter by status"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = Vec<ReadingStatusMatchResultDto>),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn get_match_results(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(job_id): axum::extract::Path<Uuid>,
|
||||
axum::extract::Query(query): axum::extract::Query<ResultsQuery>,
|
||||
) -> Result<Json<Vec<ReadingStatusMatchResultDto>>, ApiError> {
|
||||
let rows = if let Some(status_filter) = &query.status {
|
||||
sqlx::query(
|
||||
"SELECT id, series_name, status, anilist_id, anilist_title, anilist_url, error_message
|
||||
FROM reading_status_match_results
|
||||
WHERE job_id = $1 AND status = $2
|
||||
ORDER BY series_name",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(status_filter)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query(
|
||||
"SELECT id, series_name, status, anilist_id, anilist_title, anilist_url, error_message
|
||||
FROM reading_status_match_results
|
||||
WHERE job_id = $1
|
||||
ORDER BY status, series_name",
|
||||
)
|
||||
.bind(job_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
let results = rows
|
||||
.iter()
|
||||
.map(|row| ReadingStatusMatchResultDto {
|
||||
id: row.get("id"),
|
||||
series_name: row.get("series_name"),
|
||||
status: row.get("status"),
|
||||
anilist_id: row.get("anilist_id"),
|
||||
anilist_title: row.get("anilist_title"),
|
||||
anilist_url: row.get("anilist_url"),
|
||||
error_message: row.get("error_message"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(results))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResultsQuery {
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) async fn process_reading_status_match(
|
||||
pool: &PgPool,
|
||||
job_id: Uuid,
|
||||
library_id: Uuid,
|
||||
) -> Result<(), String> {
|
||||
let (token, _, _) = anilist::load_anilist_settings(pool)
|
||||
.await
|
||||
.map_err(|e| e.message)?;
|
||||
|
||||
let series_names: Vec<String> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')
|
||||
FROM books
|
||||
WHERE library_id = $1
|
||||
ORDER BY 1
|
||||
"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let total = series_names.len() as i32;
|
||||
sqlx::query("UPDATE index_jobs SET total_files = $2 WHERE id = $1")
|
||||
.bind(job_id)
|
||||
.bind(total)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let already_linked: std::collections::HashSet<String> = sqlx::query_scalar(
|
||||
"SELECT series_name FROM anilist_series_links WHERE library_id = $1",
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut processed = 0i32;
|
||||
|
||||
for series_name in &series_names {
|
||||
if is_job_cancelled(pool, job_id).await {
|
||||
sqlx::query(
|
||||
"UPDATE index_jobs SET status = 'cancelled', finished_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
processed += 1;
|
||||
let progress = (processed * 100 / total.max(1)).min(100);
|
||||
sqlx::query(
|
||||
"UPDATE index_jobs SET processed_files = $2, progress_percent = $3, current_file = $4 WHERE id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(processed)
|
||||
.bind(progress)
|
||||
.bind(series_name)
|
||||
.execute(pool)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
if series_name == "unclassified" {
|
||||
insert_result(pool, job_id, library_id, series_name, "already_linked", None, None, None, None).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
if already_linked.contains(series_name) {
|
||||
insert_result(pool, job_id, library_id, series_name, "already_linked", None, None, None, None).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
match search_and_link(pool, library_id, series_name, &token).await {
|
||||
Ok(Outcome::Linked { anilist_id, anilist_title, anilist_url }) => {
|
||||
insert_result(pool, job_id, library_id, series_name, "linked", Some(anilist_id), anilist_title.as_deref(), anilist_url.as_deref(), None).await;
|
||||
}
|
||||
Ok(Outcome::NoResults) => {
|
||||
insert_result(pool, job_id, library_id, series_name, "no_results", None, None, None, None).await;
|
||||
}
|
||||
Ok(Outcome::Ambiguous) => {
|
||||
insert_result(pool, job_id, library_id, series_name, "ambiguous", None, None, None, None).await;
|
||||
}
|
||||
Err(e) if e.contains("429") || e.contains("Too Many Requests") => {
|
||||
warn!("[READING_STATUS_MATCH] rate limit hit for '{series_name}', waiting 10s before retry");
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
match search_and_link(pool, library_id, series_name, &token).await {
|
||||
Ok(Outcome::Linked { anilist_id, anilist_title, anilist_url }) => {
|
||||
insert_result(pool, job_id, library_id, series_name, "linked", Some(anilist_id), anilist_title.as_deref(), anilist_url.as_deref(), None).await;
|
||||
}
|
||||
Ok(Outcome::NoResults) => {
|
||||
insert_result(pool, job_id, library_id, series_name, "no_results", None, None, None, None).await;
|
||||
}
|
||||
Ok(Outcome::Ambiguous) => {
|
||||
insert_result(pool, job_id, library_id, series_name, "ambiguous", None, None, None, None).await;
|
||||
}
|
||||
Err(e2) => {
|
||||
return Err(format!(
|
||||
"AniList rate limit exceeded (429) — job stopped after {processed}/{total} series: {e2}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[READING_STATUS_MATCH] series '{series_name}': {e}");
|
||||
insert_result(pool, job_id, library_id, series_name, "error", None, None, None, Some(&e)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Respect AniList rate limit (~90 req/min)
|
||||
tokio::time::sleep(Duration::from_millis(700)).await;
|
||||
}
|
||||
|
||||
// Build stats from results table
|
||||
let counts = sqlx::query(
|
||||
"SELECT status, COUNT(*) as cnt FROM reading_status_match_results WHERE job_id = $1 GROUP BY status",
|
||||
)
|
||||
.bind(job_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut count_linked = 0i64;
|
||||
let mut count_already_linked = 0i64;
|
||||
let mut count_no_results = 0i64;
|
||||
let mut count_ambiguous = 0i64;
|
||||
let mut count_errors = 0i64;
|
||||
for row in &counts {
|
||||
let s: String = row.get("status");
|
||||
let c: i64 = row.get("cnt");
|
||||
match s.as_str() {
|
||||
"linked" => count_linked = c,
|
||||
"already_linked" => count_already_linked = c,
|
||||
"no_results" => count_no_results = c,
|
||||
"ambiguous" => count_ambiguous = c,
|
||||
"error" => count_errors = c,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let stats = serde_json::json!({
|
||||
"total_series": total as i64,
|
||||
"linked": count_linked,
|
||||
"already_linked": count_already_linked,
|
||||
"no_results": count_no_results,
|
||||
"ambiguous": count_ambiguous,
|
||||
"errors": count_errors,
|
||||
});
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE index_jobs SET status = 'success', finished_at = NOW(), stats_json = $2, progress_percent = 100 WHERE id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(&stats)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
info!(
|
||||
"[READING_STATUS_MATCH] job={job_id} completed: {}/{} series, linked={count_linked}, ambiguous={count_ambiguous}, no_results={count_no_results}, errors={count_errors}",
|
||||
processed, total
|
||||
);
|
||||
|
||||
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
notifications::notify(
|
||||
pool.clone(),
|
||||
notifications::NotificationEvent::ReadingStatusMatchCompleted {
|
||||
library_name,
|
||||
total_series: total,
|
||||
linked: count_linked as i32,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn insert_result(
|
||||
pool: &PgPool,
|
||||
job_id: Uuid,
|
||||
library_id: Uuid,
|
||||
series_name: &str,
|
||||
status: &str,
|
||||
anilist_id: Option<i32>,
|
||||
anilist_title: Option<&str>,
|
||||
anilist_url: Option<&str>,
|
||||
error_message: Option<&str>,
|
||||
) {
|
||||
let _ = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO reading_status_match_results
|
||||
(job_id, library_id, series_name, status, anilist_id, anilist_title, anilist_url, error_message)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
"#,
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(library_id)
|
||||
.bind(series_name)
|
||||
.bind(status)
|
||||
.bind(anilist_id)
|
||||
.bind(anilist_title)
|
||||
.bind(anilist_url)
|
||||
.bind(error_message)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
Linked {
|
||||
anilist_id: i32,
|
||||
anilist_title: Option<String>,
|
||||
anilist_url: Option<String>,
|
||||
},
|
||||
NoResults,
|
||||
Ambiguous,
|
||||
}
|
||||
|
||||
async fn search_and_link(
|
||||
pool: &PgPool,
|
||||
library_id: Uuid,
|
||||
series_name: &str,
|
||||
token: &str,
|
||||
) -> Result<Outcome, String> {
|
||||
let gql = r#"
|
||||
query SearchManga($search: String) {
|
||||
Page(perPage: 10) {
|
||||
media(search: $search, type: MANGA, sort: [SEARCH_MATCH]) {
|
||||
id
|
||||
title { romaji english native }
|
||||
siteUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let data = anilist::anilist_graphql(token, gql, serde_json::json!({ "search": series_name }))
|
||||
.await
|
||||
.map_err(|e| e.message)?;
|
||||
|
||||
let media: Vec<serde_json::Value> = match data["Page"]["media"].as_array() {
|
||||
Some(arr) => arr.clone(),
|
||||
None => return Ok(Outcome::NoResults),
|
||||
};
|
||||
|
||||
if media.is_empty() {
|
||||
return Ok(Outcome::NoResults);
|
||||
}
|
||||
|
||||
let normalized_query = normalize_title(series_name);
|
||||
let exact_matches: Vec<_> = media
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
let romaji = m["title"]["romaji"].as_str().map(normalize_title);
|
||||
let english = m["title"]["english"].as_str().map(normalize_title);
|
||||
let native = m["title"]["native"].as_str().map(normalize_title);
|
||||
romaji.as_deref() == Some(&normalized_query)
|
||||
|| english.as_deref() == Some(&normalized_query)
|
||||
|| native.as_deref() == Some(&normalized_query)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let candidate = if exact_matches.len() == 1 {
|
||||
exact_matches[0]
|
||||
} else if exact_matches.is_empty() && media.len() == 1 {
|
||||
&media[0]
|
||||
} else {
|
||||
return Ok(Outcome::Ambiguous);
|
||||
};
|
||||
|
||||
let anilist_id = candidate["id"].as_i64().unwrap_or(0) as i32;
|
||||
let anilist_title = candidate["title"]["english"]
|
||||
.as_str()
|
||||
.or_else(|| candidate["title"]["romaji"].as_str())
|
||||
.map(String::from);
|
||||
let anilist_url = candidate["siteUrl"].as_str().map(String::from);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO anilist_series_links (library_id, series_name, provider, anilist_id, anilist_title, anilist_url, status, linked_at)
|
||||
VALUES ($1, $2, 'anilist', $3, $4, $5, 'linked', NOW())
|
||||
ON CONFLICT (library_id, series_name, provider) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(series_name)
|
||||
.bind(anilist_id)
|
||||
.bind(&anilist_title)
|
||||
.bind(&anilist_url)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Outcome::Linked {
|
||||
anilist_id,
|
||||
anilist_title,
|
||||
anilist_url,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_title(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
.replace([':', '!', '?', '.', ',', '\'', '"', '-', '_'], " ")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
async fn is_job_cancelled(pool: &PgPool, job_id: Uuid) -> bool {
|
||||
sqlx::query_scalar::<_, String>("SELECT status FROM index_jobs WHERE id = $1")
|
||||
.bind(job_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.as_deref()
|
||||
== Some("cancelled")
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
use axum::extract::Extension;
|
||||
use axum::{extract::{Path, Query, State}, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{books::BookItem, error::ApiError, state::AppState};
|
||||
use crate::{auth::AuthUser, books::BookItem, error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SeriesItem {
|
||||
@@ -18,6 +19,8 @@ pub struct SeriesItem {
|
||||
pub series_status: Option<String>,
|
||||
pub missing_count: Option<i64>,
|
||||
pub metadata_provider: Option<String>,
|
||||
pub anilist_id: Option<i32>,
|
||||
pub anilist_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -70,9 +73,11 @@ pub struct ListSeriesQuery {
|
||||
)]
|
||||
pub async fn list_series(
|
||||
State(state): State<AppState>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
Path(library_id): Path<Uuid>,
|
||||
Query(query): Query<ListSeriesQuery>,
|
||||
) -> Result<Json<SeriesPage>, ApiError> {
|
||||
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let offset = (page - 1) * limit;
|
||||
@@ -115,6 +120,10 @@ pub async fn list_series(
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let user_id_p = p + 1;
|
||||
let limit_p = p + 2;
|
||||
let offset_p = p + 3;
|
||||
|
||||
let missing_cte = r#"
|
||||
missing_counts AS (
|
||||
SELECT eml.series_name,
|
||||
@@ -147,7 +156,7 @@ pub async fn list_series(
|
||||
COUNT(*) as book_count,
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
||||
FROM sorted_books sb
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
|
||||
GROUP BY sb.name
|
||||
),
|
||||
{missing_cte},
|
||||
@@ -160,9 +169,6 @@ pub async fn list_series(
|
||||
"#
|
||||
);
|
||||
|
||||
let limit_p = p + 1;
|
||||
let offset_p = p + 2;
|
||||
|
||||
let data_sql = format!(
|
||||
r#"
|
||||
WITH sorted_books AS (
|
||||
@@ -186,7 +192,7 @@ pub async fn list_series(
|
||||
COUNT(*) as book_count,
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
||||
FROM sorted_books sb
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
|
||||
GROUP BY sb.name
|
||||
),
|
||||
{missing_cte},
|
||||
@@ -198,12 +204,15 @@ pub async fn list_series(
|
||||
sb.id as first_book_id,
|
||||
sm.status as series_status,
|
||||
mc.missing_count,
|
||||
ml.provider as metadata_provider
|
||||
ml.provider as metadata_provider,
|
||||
asl.anilist_id,
|
||||
asl.anilist_url
|
||||
FROM series_counts sc
|
||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
||||
LEFT JOIN series_metadata sm ON sm.library_id = $1 AND sm.name = sc.name
|
||||
LEFT JOIN missing_counts mc ON mc.series_name = sc.name
|
||||
LEFT JOIN metadata_links ml ON ml.series_name = sc.name AND ml.library_id = $1
|
||||
LEFT JOIN anilist_series_links asl ON asl.library_id = $1 AND asl.series_name = sc.name AND asl.provider = 'anilist'
|
||||
WHERE TRUE
|
||||
{q_cond}
|
||||
{count_rs_cond}
|
||||
@@ -245,7 +254,8 @@ pub async fn list_series(
|
||||
}
|
||||
}
|
||||
|
||||
data_builder = data_builder.bind(limit).bind(offset);
|
||||
count_builder = count_builder.bind(user_id);
|
||||
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
|
||||
|
||||
let (count_row, rows) = tokio::try_join!(
|
||||
count_builder.fetch_one(&state.pool),
|
||||
@@ -264,6 +274,8 @@ pub async fn list_series(
|
||||
series_status: row.get("series_status"),
|
||||
missing_count: row.get("missing_count"),
|
||||
metadata_provider: row.get("metadata_provider"),
|
||||
anilist_id: row.get("anilist_id"),
|
||||
anilist_url: row.get("anilist_url"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -327,8 +339,10 @@ pub struct ListAllSeriesQuery {
|
||||
)]
|
||||
pub async fn list_all_series(
|
||||
State(state): State<AppState>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
Query(query): Query<ListAllSeriesQuery>,
|
||||
) -> Result<Json<SeriesPage>, ApiError> {
|
||||
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let offset = (page - 1) * limit;
|
||||
@@ -415,6 +429,10 @@ pub async fn list_all_series(
|
||||
)
|
||||
"#;
|
||||
|
||||
let user_id_p = p + 1;
|
||||
let limit_p = p + 2;
|
||||
let offset_p = p + 3;
|
||||
|
||||
let count_sql = format!(
|
||||
r#"
|
||||
WITH sorted_books AS (
|
||||
@@ -426,7 +444,7 @@ pub async fn list_all_series(
|
||||
COUNT(*) as book_count,
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
||||
FROM sorted_books sb
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
|
||||
GROUP BY sb.name, sb.library_id
|
||||
),
|
||||
{missing_cte},
|
||||
@@ -445,9 +463,6 @@ pub async fn list_all_series(
|
||||
"REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''), COALESCE((REGEXP_MATCH(LOWER(sc.name), '\\d+'))[1]::int, 0), sc.name ASC".to_string()
|
||||
};
|
||||
|
||||
let limit_p = p + 1;
|
||||
let offset_p = p + 2;
|
||||
|
||||
let data_sql = format!(
|
||||
r#"
|
||||
WITH sorted_books AS (
|
||||
@@ -475,7 +490,7 @@ pub async fn list_all_series(
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count,
|
||||
MAX(sb.updated_at) as latest_updated_at
|
||||
FROM sorted_books sb
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
|
||||
GROUP BY sb.name, sb.library_id
|
||||
),
|
||||
{missing_cte},
|
||||
@@ -488,12 +503,15 @@ pub async fn list_all_series(
|
||||
sb.library_id,
|
||||
sm.status as series_status,
|
||||
mc.missing_count,
|
||||
ml.provider as metadata_provider
|
||||
ml.provider as metadata_provider,
|
||||
asl.anilist_id,
|
||||
asl.anilist_url
|
||||
FROM series_counts sc
|
||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
||||
LEFT JOIN series_metadata sm ON sm.library_id = sc.library_id AND sm.name = sc.name
|
||||
LEFT JOIN missing_counts mc ON mc.series_name = sc.name AND mc.library_id = sc.library_id
|
||||
LEFT JOIN metadata_links ml ON ml.series_name = sc.name AND ml.library_id = sc.library_id
|
||||
LEFT JOIN anilist_series_links asl ON asl.library_id = sc.library_id AND asl.series_name = sc.name AND asl.provider = 'anilist'
|
||||
WHERE TRUE
|
||||
{q_cond}
|
||||
{rs_cond}
|
||||
@@ -538,7 +556,8 @@ pub async fn list_all_series(
|
||||
data_builder = data_builder.bind(author.clone());
|
||||
}
|
||||
|
||||
data_builder = data_builder.bind(limit).bind(offset);
|
||||
count_builder = count_builder.bind(user_id);
|
||||
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
|
||||
|
||||
let (count_row, rows) = tokio::try_join!(
|
||||
count_builder.fetch_one(&state.pool),
|
||||
@@ -557,6 +576,8 @@ pub async fn list_all_series(
|
||||
series_status: row.get("series_status"),
|
||||
missing_count: row.get("missing_count"),
|
||||
metadata_provider: row.get("metadata_provider"),
|
||||
anilist_id: row.get("anilist_id"),
|
||||
anilist_url: row.get("anilist_url"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -642,8 +663,10 @@ pub struct OngoingQuery {
|
||||
)]
|
||||
pub async fn ongoing_series(
|
||||
State(state): State<AppState>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
Query(query): Query<OngoingQuery>,
|
||||
) -> Result<Json<Vec<SeriesItem>>, ApiError> {
|
||||
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||
let limit = query.limit.unwrap_or(10).clamp(1, 50);
|
||||
|
||||
let rows = sqlx::query(
|
||||
@@ -655,7 +678,7 @@ pub async fn ongoing_series(
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read_count,
|
||||
MAX(brp.last_read_at) AS last_read_at
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
|
||||
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
|
||||
HAVING (
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
|
||||
@@ -685,6 +708,7 @@ pub async fn ongoing_series(
|
||||
"#,
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -699,6 +723,8 @@ pub async fn ongoing_series(
|
||||
series_status: None,
|
||||
missing_count: None,
|
||||
metadata_provider: None,
|
||||
anilist_id: None,
|
||||
anilist_url: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -721,8 +747,10 @@ pub async fn ongoing_series(
|
||||
)]
|
||||
pub async fn ongoing_books(
|
||||
State(state): State<AppState>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
Query(query): Query<OngoingQuery>,
|
||||
) -> Result<Json<Vec<BookItem>>, ApiError> {
|
||||
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||
let limit = query.limit.unwrap_or(10).clamp(1, 50);
|
||||
|
||||
let rows = sqlx::query(
|
||||
@@ -732,7 +760,7 @@ pub async fn ongoing_books(
|
||||
COALESCE(NULLIF(b.series, ''), 'unclassified') AS name,
|
||||
MAX(brp.last_read_at) AS series_last_read_at
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
|
||||
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
|
||||
HAVING (
|
||||
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
|
||||
@@ -753,7 +781,7 @@ pub async fn ongoing_books(
|
||||
) AS rn
|
||||
FROM books b
|
||||
JOIN ongoing_series os ON COALESCE(NULLIF(b.series, ''), 'unclassified') = os.name
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
|
||||
WHERE COALESCE(brp.status, 'unread') != 'read'
|
||||
)
|
||||
SELECT id, library_id, kind, format, title, author, authors, series, volume, language, page_count,
|
||||
@@ -765,6 +793,7 @@ pub async fn ongoing_books(
|
||||
"#,
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Serialize;
|
||||
use axum::{
|
||||
extract::{Extension, Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use utoipa::ToSchema;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
use crate::{auth::AuthUser, error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct StatsQuery {
|
||||
/// Granularity: "day", "week" or "month" (default: "month")
|
||||
pub period: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct StatsOverview {
|
||||
@@ -81,6 +90,7 @@ pub struct CurrentlyReadingItem {
|
||||
pub series: Option<String>,
|
||||
pub current_page: i32,
|
||||
pub page_count: i32,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -89,12 +99,31 @@ pub struct RecentlyReadItem {
|
||||
pub title: String,
|
||||
pub series: Option<String>,
|
||||
pub last_read_at: String,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct MonthlyReading {
|
||||
pub month: String,
|
||||
pub books_read: i64,
|
||||
pub pages_read: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserMonthlyReading {
|
||||
pub month: String,
|
||||
pub username: String,
|
||||
pub books_read: i64,
|
||||
pub pages_read: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct JobTimePoint {
|
||||
pub label: String,
|
||||
pub scan: i64,
|
||||
pub rebuild: i64,
|
||||
pub thumbnail: i64,
|
||||
pub other: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -109,7 +138,9 @@ pub struct StatsResponse {
|
||||
pub by_library: Vec<LibraryStats>,
|
||||
pub top_series: Vec<TopSeries>,
|
||||
pub additions_over_time: Vec<MonthlyAdditions>,
|
||||
pub jobs_over_time: Vec<JobTimePoint>,
|
||||
pub metadata: MetadataStats,
|
||||
pub users_reading_over_time: Vec<UserMonthlyReading>,
|
||||
}
|
||||
|
||||
/// Get collection statistics for the dashboard
|
||||
@@ -117,6 +148,7 @@ pub struct StatsResponse {
|
||||
get,
|
||||
path = "/stats",
|
||||
tag = "stats",
|
||||
params(StatsQuery),
|
||||
responses(
|
||||
(status = 200, body = StatsResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
@@ -125,7 +157,11 @@ pub struct StatsResponse {
|
||||
)]
|
||||
pub async fn get_stats(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<StatsQuery>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
) -> Result<Json<StatsResponse>, ApiError> {
|
||||
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||
let period = query.period.as_deref().unwrap_or("month");
|
||||
// Overview + reading status in one query
|
||||
let overview_row = sqlx::query(
|
||||
r#"
|
||||
@@ -143,9 +179,10 @@ pub async fn get_stats(
|
||||
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading,
|
||||
COUNT(*) FILTER (WHERE brp.status = 'read') AS read
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -233,7 +270,7 @@ pub async fn get_stats(
|
||||
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread_count
|
||||
FROM libraries l
|
||||
LEFT JOIN books b ON b.library_id = l.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT size_bytes FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
|
||||
) bf ON TRUE
|
||||
@@ -241,6 +278,7 @@ pub async fn get_stats(
|
||||
ORDER BY book_count DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -265,13 +303,14 @@ pub async fn get_stats(
|
||||
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
|
||||
COALESCE(SUM(b.page_count), 0)::BIGINT AS total_pages
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
WHERE b.series IS NOT NULL AND b.series != ''
|
||||
GROUP BY b.series
|
||||
ORDER BY book_count DESC
|
||||
LIMIT 10
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -285,20 +324,74 @@ pub async fn get_stats(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Additions over time (last 12 months)
|
||||
let additions_rows = sqlx::query(
|
||||
// Additions over time (with gap filling)
|
||||
let additions_rows = match period {
|
||||
"day" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month,
|
||||
COUNT(*) AS books_added
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_added, 0) AS books_added
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT created_at::date AS dt, COUNT(*) AS books_added
|
||||
FROM books
|
||||
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', created_at)
|
||||
WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||
GROUP BY created_at::date
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
.await?
|
||||
}
|
||||
"week" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_added, 0) AS books_added
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||
DATE_TRUNC('week', NOW()),
|
||||
'1 week'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('week', created_at) AS dt, COUNT(*) AS books_added
|
||||
FROM books
|
||||
WHERE created_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||
GROUP BY DATE_TRUNC('week', created_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||
COALESCE(cnt.books_added, 0) AS books_added
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||
DATE_TRUNC('month', NOW()),
|
||||
'1 month'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('month', created_at) AS dt, COUNT(*) AS books_added
|
||||
FROM books
|
||||
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', created_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
|
||||
.iter()
|
||||
@@ -356,14 +449,17 @@ pub async fn get_stats(
|
||||
// Currently reading books
|
||||
let reading_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT b.id AS book_id, b.title, b.series, brp.current_page, b.page_count
|
||||
SELECT b.id AS book_id, b.title, b.series, brp.current_page, b.page_count, u.username
|
||||
FROM book_reading_progress brp
|
||||
JOIN books b ON b.id = brp.book_id
|
||||
LEFT JOIN users u ON u.id = brp.user_id
|
||||
WHERE brp.status = 'reading' AND brp.current_page IS NOT NULL
|
||||
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
ORDER BY brp.updated_at DESC
|
||||
LIMIT 20
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -377,6 +473,7 @@ pub async fn get_stats(
|
||||
series: r.get("series"),
|
||||
current_page: r.get::<Option<i32>, _>("current_page").unwrap_or(0),
|
||||
page_count: r.get::<Option<i32>, _>("page_count").unwrap_or(0),
|
||||
username: r.get("username"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -385,14 +482,18 @@ pub async fn get_stats(
|
||||
let recent_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT b.id AS book_id, b.title, b.series,
|
||||
TO_CHAR(brp.last_read_at, 'YYYY-MM-DD') AS last_read_at
|
||||
TO_CHAR(brp.last_read_at, 'YYYY-MM-DD') AS last_read_at,
|
||||
u.username
|
||||
FROM book_reading_progress brp
|
||||
JOIN books b ON b.id = brp.book_id
|
||||
LEFT JOIN users u ON u.id = brp.user_id
|
||||
WHERE brp.status = 'read' AND brp.last_read_at IS NOT NULL
|
||||
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
ORDER BY brp.last_read_at DESC
|
||||
LIMIT 10
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -405,31 +506,320 @@ pub async fn get_stats(
|
||||
title: r.get("title"),
|
||||
series: r.get("series"),
|
||||
last_read_at: r.get::<Option<String>, _>("last_read_at").unwrap_or_default(),
|
||||
username: r.get("username"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Reading activity over time (last 12 months)
|
||||
let reading_time_rows = sqlx::query(
|
||||
// Reading activity over time (with gap filling)
|
||||
let reading_time_rows = match period {
|
||||
"day" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(DATE_TRUNC('month', brp.last_read_at), 'YYYY-MM') AS month,
|
||||
COUNT(*) AS books_read
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_read, 0) AS books_read,
|
||||
COALESCE(cnt.pages_read, 0) AS pages_read
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read,
|
||||
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
|
||||
FROM book_reading_progress brp
|
||||
JOIN books b ON b.id = brp.book_id
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', brp.last_read_at)
|
||||
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
GROUP BY brp.last_read_at::date
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
.await?
|
||||
}
|
||||
"week" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_read, 0) AS books_read,
|
||||
COALESCE(cnt.pages_read, 0) AS pages_read
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||
DATE_TRUNC('week', NOW()),
|
||||
'1 week'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, COUNT(*) AS books_read,
|
||||
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
|
||||
FROM book_reading_progress brp
|
||||
JOIN books b ON b.id = brp.book_id
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
GROUP BY DATE_TRUNC('week', brp.last_read_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||
COALESCE(cnt.books_read, 0) AS books_read,
|
||||
COALESCE(cnt.pages_read, 0) AS pages_read
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||
DATE_TRUNC('month', NOW()),
|
||||
'1 month'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, COUNT(*) AS books_read,
|
||||
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
|
||||
FROM book_reading_progress brp
|
||||
JOIN books b ON b.id = brp.book_id
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
GROUP BY DATE_TRUNC('month', brp.last_read_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let reading_over_time: Vec<MonthlyReading> = reading_time_rows
|
||||
.iter()
|
||||
.map(|r| MonthlyReading {
|
||||
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
|
||||
books_read: r.get("books_read"),
|
||||
pages_read: r.get("pages_read"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Per-user reading over time (admin view — always all users, no user_id filter)
|
||||
let users_reading_time_rows = match period {
|
||||
"day" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
u.username,
|
||||
COALESCE(cnt.books_read, 0) AS books_read,
|
||||
COALESCE(cnt.pages_read, 0) AS pages_read
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||
CROSS JOIN users u
|
||||
LEFT JOIN (
|
||||
SELECT brp.last_read_at::date AS dt, brp.user_id, COUNT(*) AS books_read,
|
||||
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
|
||||
FROM book_reading_progress brp
|
||||
JOIN books b ON b.id = brp.book_id
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||
GROUP BY brp.last_read_at::date, brp.user_id
|
||||
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
|
||||
ORDER BY month ASC, u.username
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
"week" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
u.username,
|
||||
COALESCE(cnt.books_read, 0) AS books_read,
|
||||
COALESCE(cnt.pages_read, 0) AS pages_read
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||
DATE_TRUNC('week', NOW()),
|
||||
'1 week'
|
||||
) AS d(dt)
|
||||
CROSS JOIN users u
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read,
|
||||
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
|
||||
FROM book_reading_progress brp
|
||||
JOIN books b ON b.id = brp.book_id
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||
GROUP BY DATE_TRUNC('week', brp.last_read_at), brp.user_id
|
||||
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
|
||||
ORDER BY month ASC, u.username
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||
u.username,
|
||||
COALESCE(cnt.books_read, 0) AS books_read,
|
||||
COALESCE(cnt.pages_read, 0) AS pages_read
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||
DATE_TRUNC('month', NOW()),
|
||||
'1 month'
|
||||
) AS d(dt)
|
||||
CROSS JOIN users u
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read,
|
||||
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
|
||||
FROM book_reading_progress brp
|
||||
JOIN books b ON b.id = brp.book_id
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', brp.last_read_at), brp.user_id
|
||||
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
|
||||
ORDER BY month ASC, u.username
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let users_reading_over_time: Vec<UserMonthlyReading> = users_reading_time_rows
|
||||
.iter()
|
||||
.map(|r| UserMonthlyReading {
|
||||
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
|
||||
username: r.get("username"),
|
||||
books_read: r.get("books_read"),
|
||||
pages_read: r.get("pages_read"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Jobs over time (with gap filling, grouped by type category)
|
||||
let jobs_rows = match period {
|
||||
"day" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
finished_at::date AS dt,
|
||||
CASE
|
||||
WHEN type = 'scan' THEN 'scan'
|
||||
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||
ELSE 'other'
|
||||
END AS cat,
|
||||
COUNT(*) AS c
|
||||
FROM index_jobs
|
||||
WHERE status IN ('success', 'failed')
|
||||
AND finished_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||
GROUP BY finished_at::date, cat
|
||||
) cnt ON cnt.dt = d.dt
|
||||
GROUP BY d.dt
|
||||
ORDER BY label ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
"week" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||
DATE_TRUNC('week', NOW()),
|
||||
'1 week'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
DATE_TRUNC('week', finished_at) AS dt,
|
||||
CASE
|
||||
WHEN type = 'scan' THEN 'scan'
|
||||
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||
ELSE 'other'
|
||||
END AS cat,
|
||||
COUNT(*) AS c
|
||||
FROM index_jobs
|
||||
WHERE status IN ('success', 'failed')
|
||||
AND finished_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||
GROUP BY DATE_TRUNC('week', finished_at), cat
|
||||
) cnt ON cnt.dt = d.dt
|
||||
GROUP BY d.dt
|
||||
ORDER BY label ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM') AS label,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||
DATE_TRUNC('month', NOW()),
|
||||
'1 month'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
DATE_TRUNC('month', finished_at) AS dt,
|
||||
CASE
|
||||
WHEN type = 'scan' THEN 'scan'
|
||||
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||
ELSE 'other'
|
||||
END AS cat,
|
||||
COUNT(*) AS c
|
||||
FROM index_jobs
|
||||
WHERE status IN ('success', 'failed')
|
||||
AND finished_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', finished_at), cat
|
||||
) cnt ON cnt.dt = d.dt
|
||||
GROUP BY d.dt
|
||||
ORDER BY label ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let jobs_over_time: Vec<JobTimePoint> = jobs_rows
|
||||
.iter()
|
||||
.map(|r| JobTimePoint {
|
||||
label: r.get("label"),
|
||||
scan: r.get("scan"),
|
||||
rebuild: r.get("rebuild"),
|
||||
thumbnail: r.get("thumbnail"),
|
||||
other: r.get("other"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -444,6 +834,8 @@ pub async fn get_stats(
|
||||
by_library,
|
||||
top_series,
|
||||
additions_over_time,
|
||||
jobs_over_time,
|
||||
metadata,
|
||||
users_reading_over_time,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ pub struct CreateTokenRequest {
|
||||
pub name: String,
|
||||
#[schema(value_type = Option<String>, example = "read")]
|
||||
pub scope: Option<String>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -26,6 +28,9 @@ pub struct TokenResponse {
|
||||
pub scope: String,
|
||||
pub prefix: String,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub user_id: Option<Uuid>,
|
||||
pub username: Option<String>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
@@ -71,6 +76,10 @@ pub async fn create_token(
|
||||
_ => return Err(ApiError::bad_request("scope must be 'admin' or 'read'")),
|
||||
};
|
||||
|
||||
if scope == "read" && input.user_id.is_none() {
|
||||
return Err(ApiError::bad_request("user_id is required for read-scoped tokens"));
|
||||
}
|
||||
|
||||
let mut random = [0u8; 24];
|
||||
OsRng.fill_bytes(&mut random);
|
||||
let secret = URL_SAFE_NO_PAD.encode(random);
|
||||
@@ -85,13 +94,14 @@ pub async fn create_token(
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope) VALUES ($1, $2, $3, $4, $5)",
|
||||
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope, user_id) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(input.name.trim())
|
||||
.bind(&prefix)
|
||||
.bind(token_hash)
|
||||
.bind(scope)
|
||||
.bind(input.user_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -118,7 +128,13 @@ pub async fn create_token(
|
||||
)]
|
||||
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenResponse>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, scope, prefix, last_used_at, revoked_at, created_at FROM api_tokens ORDER BY created_at DESC",
|
||||
r#"
|
||||
SELECT t.id, t.name, t.scope, t.prefix, t.user_id, u.username,
|
||||
t.last_used_at, t.revoked_at, t.created_at
|
||||
FROM api_tokens t
|
||||
LEFT JOIN users u ON u.id = t.user_id
|
||||
ORDER BY t.created_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
@@ -130,6 +146,8 @@ pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<Token
|
||||
name: row.get("name"),
|
||||
scope: row.get("scope"),
|
||||
prefix: row.get("prefix"),
|
||||
user_id: row.get("user_id"),
|
||||
username: row.get("username"),
|
||||
last_used_at: row.get("last_used_at"),
|
||||
revoked_at: row.get("revoked_at"),
|
||||
created_at: row.get("created_at"),
|
||||
@@ -171,6 +189,47 @@ pub async fn revoke_token(
|
||||
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateTokenRequest {
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Update a token's assigned user
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/admin/tokens/{id}",
|
||||
tag = "tokens",
|
||||
params(
|
||||
("id" = String, Path, description = "Token UUID"),
|
||||
),
|
||||
request_body = UpdateTokenRequest,
|
||||
responses(
|
||||
(status = 200, description = "Token updated"),
|
||||
(status = 404, description = "Token not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn update_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(input): Json<UpdateTokenRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let result = sqlx::query("UPDATE api_tokens SET user_id = $1 WHERE id = $2")
|
||||
.bind(input.user_id)
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found("token not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"updated": true, "id": id})))
|
||||
}
|
||||
|
||||
/// Permanently delete a revoked API token
|
||||
#[utoipa::path(
|
||||
post,
|
||||
|
||||
195
apps/api/src/users.rs
Normal file
195
apps/api/src/users.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use axum::{extract::{Path, State}, Json};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserResponse {
|
||||
#[schema(value_type = String)]
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub token_count: i64,
|
||||
pub books_read: i64,
|
||||
pub books_reading: i64,
|
||||
#[schema(value_type = String)]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// List all reader users with their associated token count
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/admin/users",
|
||||
tag = "users",
|
||||
responses(
|
||||
(status = 200, body = Vec<UserResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_users(State(state): State<AppState>) -> Result<Json<Vec<UserResponse>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT u.id, u.username, u.created_at,
|
||||
COUNT(DISTINCT t.id) AS token_count,
|
||||
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read,
|
||||
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'reading') AS books_reading
|
||||
FROM users u
|
||||
LEFT JOIN api_tokens t ON t.user_id = u.id AND t.revoked_at IS NULL
|
||||
LEFT JOIN book_reading_progress brp ON brp.user_id = u.id
|
||||
GROUP BY u.id, u.username, u.created_at
|
||||
ORDER BY u.created_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|row| UserResponse {
|
||||
id: row.get("id"),
|
||||
username: row.get("username"),
|
||||
token_count: row.get("token_count"),
|
||||
books_read: row.get("books_read"),
|
||||
books_reading: row.get("books_reading"),
|
||||
created_at: row.get("created_at"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
/// Create a new reader user
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/admin/users",
|
||||
tag = "users",
|
||||
request_body = CreateUserRequest,
|
||||
responses(
|
||||
(status = 200, body = UserResponse, description = "User created"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn create_user(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateUserRequest>,
|
||||
) -> Result<Json<UserResponse>, ApiError> {
|
||||
if input.username.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("username is required"));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let row = sqlx::query(
|
||||
"INSERT INTO users (id, username) VALUES ($1, $2) RETURNING id, username, created_at",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(input.username.trim())
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db_err) = e {
|
||||
if db_err.constraint() == Some("users_username_key") {
|
||||
return ApiError::bad_request("username already exists");
|
||||
}
|
||||
}
|
||||
ApiError::from(e)
|
||||
})?;
|
||||
|
||||
Ok(Json(UserResponse {
|
||||
id: row.get("id"),
|
||||
username: row.get("username"),
|
||||
token_count: 0,
|
||||
books_read: 0,
|
||||
books_reading: 0,
|
||||
created_at: row.get("created_at"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update a reader user's username
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/admin/users/{id}",
|
||||
tag = "users",
|
||||
request_body = CreateUserRequest,
|
||||
responses(
|
||||
(status = 200, body = UserResponse, description = "User updated"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 404, description = "User not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn update_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(input): Json<CreateUserRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
if input.username.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("username is required"));
|
||||
}
|
||||
|
||||
let result = sqlx::query("UPDATE users SET username = $1 WHERE id = $2")
|
||||
.bind(input.username.trim())
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db_err) = e {
|
||||
if db_err.constraint() == Some("users_username_key") {
|
||||
return ApiError::bad_request("username already exists");
|
||||
}
|
||||
}
|
||||
ApiError::from(e)
|
||||
})?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found("user not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"updated": true, "id": id})))
|
||||
}
|
||||
|
||||
/// Delete a reader user (cascades on tokens and reading progress)
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/admin/users/{id}",
|
||||
tag = "users",
|
||||
params(
|
||||
("id" = String, Path, description = "User UUID"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "User deleted"),
|
||||
(status = 404, description = "User not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn delete_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let result = sqlx::query("DELETE FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found("user not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
|
||||
}
|
||||
97
apps/backoffice/app/(app)/anilist/callback/page.tsx
Normal file
97
apps/backoffice/app/(app)/anilist/callback/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AnilistCallbackPage() {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
async function handleCallback() {
|
||||
const hash = window.location.hash.slice(1); // remove leading #
|
||||
const params = new URLSearchParams(hash);
|
||||
const accessToken = params.get("access_token");
|
||||
|
||||
if (!accessToken) {
|
||||
setStatus("error");
|
||||
setMessage("Aucun token trouvé dans l'URL de callback.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read existing settings to preserve client_id
|
||||
const existingResp = await fetch("/api/settings/anilist").catch(() => null);
|
||||
const existing = existingResp?.ok ? await existingResp.json().catch(() => ({})) : {};
|
||||
|
||||
const save = (extra: Record<string, unknown>) =>
|
||||
fetch("/api/settings/anilist", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: { ...existing, access_token: accessToken, ...extra } }),
|
||||
});
|
||||
|
||||
const saveResp = await save({});
|
||||
if (!saveResp.ok) throw new Error("Impossible de sauvegarder le token");
|
||||
|
||||
// Auto-fetch user info to populate user_id
|
||||
const statusResp = await fetch("/api/anilist/status");
|
||||
if (statusResp.ok) {
|
||||
const data = await statusResp.json();
|
||||
if (data.user_id) {
|
||||
await save({ user_id: data.user_id });
|
||||
}
|
||||
setMessage(`Connecté en tant que ${data.username}`);
|
||||
} else {
|
||||
setMessage("Token sauvegardé.");
|
||||
}
|
||||
|
||||
setStatus("success");
|
||||
setTimeout(() => router.push("/settings?tab=anilist"), 2000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setMessage(e instanceof Error ? e.message : "Erreur inconnue");
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback();
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center space-y-4 p-8">
|
||||
{status === "loading" && (
|
||||
<>
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto" />
|
||||
<p className="text-muted-foreground">Connexion AniList en cours…</p>
|
||||
</>
|
||||
)}
|
||||
{status === "success" && (
|
||||
<>
|
||||
<div className="w-12 h-12 rounded-full bg-success/15 flex items-center justify-center mx-auto">
|
||||
<svg className="w-6 h-6 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-success font-medium">{message}</p>
|
||||
<p className="text-sm text-muted-foreground">Redirection vers les paramètres…</p>
|
||||
</>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<>
|
||||
<div className="w-12 h-12 rounded-full bg-destructive/15 flex items-center justify-center mx-auto">
|
||||
<svg className="w-6 h-6 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-destructive font-medium">{message}</p>
|
||||
<a href="/settings" className="text-sm text-primary hover:underline">
|
||||
Retour aux paramètres
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "../../../lib/api";
|
||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||
import { BooksGrid } from "../../components/BookCard";
|
||||
import { OffsetPagination } from "../../components/ui";
|
||||
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import { BooksGrid } from "@/app/components/BookCard";
|
||||
import { OffsetPagination } from "@/app/components/ui";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fetchAuthors, AuthorsPageDto } from "../../lib/api";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||
import { fetchAuthors, AuthorsPageDto } from "@/lib/api";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -1,15 +1,15 @@
|
||||
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api";
|
||||
import { BookPreview } from "../../components/BookPreview";
|
||||
import { ConvertButton } from "../../components/ConvertButton";
|
||||
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
|
||||
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "@/lib/api";
|
||||
import { BookPreview } from "@/app/components/BookPreview";
|
||||
import { ConvertButton } from "@/app/components/ConvertButton";
|
||||
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
|
||||
import nextDynamic from "next/dynamic";
|
||||
import { SafeHtml } from "../../components/SafeHtml";
|
||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||
import { SafeHtml } from "@/app/components/SafeHtml";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
const EditBookForm = nextDynamic(
|
||||
() => import("../../components/EditBookForm").then(m => m.EditBookForm)
|
||||
() => import("@/app/components/EditBookForm").then(m => m.EditBookForm)
|
||||
);
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "../../lib/api";
|
||||
import { BooksGrid, EmptyState } from "../components/BookCard";
|
||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "@/lib/api";
|
||||
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
|
||||
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -2,13 +2,13 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "../../../lib/api";
|
||||
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, getReadingStatusMatchReport, getReadingStatusMatchResults, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto, ReadingStatusMatchReportDto, ReadingStatusMatchResultDto } from "@/lib/api";
|
||||
import {
|
||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
||||
} from "../../components/ui";
|
||||
import { JobDetailLive } from "../../components/JobDetailLive";
|
||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||
} from "@/app/components/ui";
|
||||
import { JobDetailLive } from "@/app/components/JobDetailLive";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
interface JobDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -132,10 +132,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
description: t("jobType.metadata_refreshDesc"),
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
reading_status_match: {
|
||||
label: t("jobType.reading_status_matchLabel"),
|
||||
description: t("jobType.reading_status_matchDesc"),
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
const isMetadataBatch = job.type === "metadata_batch";
|
||||
const isMetadataRefresh = job.type === "metadata_refresh";
|
||||
const isReadingStatusMatch = job.type === "reading_status_match";
|
||||
|
||||
// Fetch batch report & results for metadata_batch jobs
|
||||
let batchReport: MetadataBatchReportDto | null = null;
|
||||
@@ -153,6 +159,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
refreshReport = await getMetadataRefreshReport(id).catch(() => null);
|
||||
}
|
||||
|
||||
// Fetch reading status match report & results
|
||||
let readingStatusReport: ReadingStatusMatchReportDto | null = null;
|
||||
let readingStatusResults: ReadingStatusMatchResultDto[] = [];
|
||||
if (isReadingStatusMatch) {
|
||||
[readingStatusReport, readingStatusResults] = await Promise.all([
|
||||
getReadingStatusMatchReport(id).catch(() => null),
|
||||
getReadingStatusMatchResults(id).catch(() => []),
|
||||
]);
|
||||
}
|
||||
|
||||
const typeInfo = JOB_TYPE_INFO[job.type] ?? {
|
||||
label: job.type,
|
||||
description: null,
|
||||
@@ -177,6 +193,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
? t("jobDetail.metadataSearch")
|
||||
: isMetadataRefresh
|
||||
? t("jobDetail.metadataRefresh")
|
||||
: isReadingStatusMatch
|
||||
? t("jobDetail.readingStatusMatch")
|
||||
: isThumbnailOnly
|
||||
? t("jobType.thumbnail_rebuild")
|
||||
: isExtractingPages
|
||||
@@ -189,6 +207,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
? t("jobDetail.metadataSearchDesc")
|
||||
: isMetadataRefresh
|
||||
? t("jobDetail.metadataRefreshDesc")
|
||||
: isReadingStatusMatch
|
||||
? t("jobDetail.readingStatusMatchDesc")
|
||||
: isThumbnailOnly
|
||||
? undefined
|
||||
: isExtractingPages
|
||||
@@ -240,7 +260,12 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
— {refreshReport.refreshed} {t("jobDetail.refreshed").toLowerCase()}, {refreshReport.unchanged} {t("jobDetail.unchanged").toLowerCase()}, {refreshReport.errors} {t("jobDetail.errors").toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
{!isMetadataBatch && !isMetadataRefresh && job.stats_json && (
|
||||
{isReadingStatusMatch && readingStatusReport && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {readingStatusReport.linked} {t("jobDetail.linked").toLowerCase()}, {readingStatusReport.no_results} {t("jobDetail.noResults").toLowerCase()}, {readingStatusReport.ambiguous} {t("jobDetail.ambiguous").toLowerCase()}, {readingStatusReport.errors} {t("jobDetail.errors").toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
{!isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && job.stats_json && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()}
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`}
|
||||
@@ -249,7 +274,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} ${t("jobType.thumbnail_rebuild").toLowerCase()}`}
|
||||
</span>
|
||||
)}
|
||||
{!isMetadataBatch && !isMetadataRefresh && !job.stats_json && isThumbnailOnly && job.total_files != null && (
|
||||
{!isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !job.stats_json && isThumbnailOnly && job.total_files != null && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {job.processed_files ?? job.total_files} {t("jobDetail.generated").toLowerCase()}
|
||||
</span>
|
||||
@@ -514,7 +539,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
)}
|
||||
|
||||
{/* Index Statistics — index jobs only */}
|
||||
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && !isMetadataRefresh && (
|
||||
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("jobDetail.indexStats")}</CardTitle>
|
||||
@@ -587,7 +612,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<StatBox value={refreshReport.refreshed} label={t("jobDetail.refreshed")} variant="success" />
|
||||
<StatBox
|
||||
value={refreshReport.refreshed}
|
||||
label={t("jobDetail.refreshed")}
|
||||
variant="success"
|
||||
icon={
|
||||
<svg className="w-6 h-6 text-success" 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>
|
||||
}
|
||||
/>
|
||||
<StatBox value={refreshReport.unchanged} label={t("jobDetail.unchanged")} />
|
||||
<StatBox value={refreshReport.errors} label={t("jobDetail.errors")} variant={refreshReport.errors > 0 ? "error" : "default"} />
|
||||
<StatBox value={refreshReport.total_links} label={t("jobDetail.total")} />
|
||||
@@ -704,6 +738,95 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reading status match — summary report */}
|
||||
{isReadingStatusMatch && readingStatusReport && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("jobDetail.readingStatusMatchReport")}</CardTitle>
|
||||
<CardDescription>{t("jobDetail.seriesAnalyzed", { count: String(readingStatusReport.total_series) })}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<StatBox value={readingStatusReport.linked} label={t("jobDetail.linked")} variant="success" />
|
||||
<StatBox value={readingStatusReport.already_linked} label={t("jobDetail.alreadyLinked")} variant="primary" />
|
||||
<StatBox value={readingStatusReport.no_results} label={t("jobDetail.noResults")} />
|
||||
<StatBox value={readingStatusReport.ambiguous} label={t("jobDetail.ambiguous")} variant="warning" />
|
||||
<StatBox value={readingStatusReport.errors} label={t("jobDetail.errors")} variant={readingStatusReport.errors > 0 ? "error" : "default"} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reading status match — per-series detail */}
|
||||
{isReadingStatusMatch && readingStatusResults.length > 0 && (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("jobDetail.resultsBySeries")}</CardTitle>
|
||||
<CardDescription>{t("jobDetail.seriesProcessed", { count: String(readingStatusResults.length) })}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{readingStatusResults.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className={`p-3 rounded-lg border ${
|
||||
r.status === "linked" ? "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" :
|
||||
r.status === "ambiguous" ? "bg-amber-500/10 border-amber-500/20" :
|
||||
"bg-muted/50 border-border/60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{job.library_id ? (
|
||||
<Link
|
||||
href={`/libraries/${job.library_id}/series/${encodeURIComponent(r.series_name)}`}
|
||||
className="font-medium text-sm text-primary hover:underline truncate"
|
||||
>
|
||||
{r.series_name}
|
||||
</Link>
|
||||
) : (
|
||||
<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 === "linked" ? "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 === "ambiguous" ? "bg-amber-500/15 text-amber-600" :
|
||||
r.status === "error" ? "bg-destructive/20 text-destructive" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{r.status === "linked" ? t("jobDetail.linked") :
|
||||
r.status === "already_linked" ? t("jobDetail.alreadyLinked") :
|
||||
r.status === "no_results" ? t("jobDetail.noResults") :
|
||||
r.status === "ambiguous" ? t("jobDetail.ambiguous") :
|
||||
r.status === "error" ? t("common.error") :
|
||||
r.status}
|
||||
</span>
|
||||
</div>
|
||||
{r.status === "linked" && r.anilist_title && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<svg className="w-3 h-3 text-success shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
{r.anilist_url ? (
|
||||
<a href={r.anilist_url} target="_blank" rel="noopener noreferrer" className="text-success hover:underline">
|
||||
{r.anilist_title}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-success">{r.anilist_title}</span>
|
||||
)}
|
||||
{r.anilist_id && <span className="text-muted-foreground/60">#{r.anilist_id}</span>}
|
||||
</div>
|
||||
)}
|
||||
{r.error_message && (
|
||||
<p className="text-xs text-destructive/80 mt-1">{r.error_message}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Metadata batch results */}
|
||||
{isMetadataBatch && batchResults.length > 0 && (
|
||||
<Card className="lg:col-span-2">
|
||||
@@ -1,9 +1,9 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "../../lib/api";
|
||||
import { JobsList } from "../components/JobsList";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "../components/ui";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, startReadingStatusMatch, IndexJobDto, LibraryDto } from "@/lib/api";
|
||||
import { JobsList } from "@/app/components/JobsList";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "@/app/components/ui";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -16,6 +16,7 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
]);
|
||||
|
||||
const libraryMap = new Map(libraries.map(l => [l.id, l.name]));
|
||||
const readingStatusLibraries = libraries.filter(l => l.reading_status_provider);
|
||||
|
||||
async function triggerRebuild(formData: FormData) {
|
||||
"use server";
|
||||
@@ -118,6 +119,36 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerReadingStatusMatch(formData: FormData) {
|
||||
"use server";
|
||||
const libraryId = formData.get("library_id") as string;
|
||||
if (libraryId) {
|
||||
let result;
|
||||
try {
|
||||
result = await startReadingStatusMatch(libraryId);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
revalidatePath("/jobs");
|
||||
redirect(`/jobs?highlight=${result.id}`);
|
||||
} else {
|
||||
// All libraries — only those with reading_status_provider configured
|
||||
const allLibraries = await fetchLibraries().catch(() => [] as LibraryDto[]);
|
||||
let lastId: string | undefined;
|
||||
for (const lib of allLibraries) {
|
||||
if (!lib.reading_status_provider) continue;
|
||||
try {
|
||||
const result = await startReadingStatusMatch(lib.id);
|
||||
if (result.status !== "already_running") lastId = result.id;
|
||||
} catch {
|
||||
// Skip libraries with errors
|
||||
}
|
||||
}
|
||||
revalidatePath("/jobs");
|
||||
redirect(lastId ? `/jobs?highlight=${lastId}` : "/jobs");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
@@ -254,6 +285,30 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading status group — only shown if at least one library has a provider configured */}
|
||||
{readingStatusLibraries.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
{t("jobs.groupReadingStatus")}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<button type="submit" formAction={triggerReadingStatusMatch}
|
||||
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span className="font-medium text-sm text-foreground">{t("jobs.matchReadingStatus")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.matchReadingStatusShort")}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
127
apps/backoffice/app/(app)/layout.tsx
Normal file
127
apps/backoffice/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { ThemeToggle } from "@/app/theme-toggle";
|
||||
import { JobsIndicator } from "@/app/components/JobsIndicator";
|
||||
import { NavIcon, Icon } from "@/app/components/ui";
|
||||
import { LogoutButton } from "@/app/components/LogoutButton";
|
||||
import { MobileNav } from "@/app/components/MobileNav";
|
||||
import { UserSwitcher } from "@/app/components/UserSwitcher";
|
||||
import { fetchUsers } from "@/lib/api";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import type { TranslationKey } from "@/lib/i18n/fr";
|
||||
|
||||
type NavItem = {
|
||||
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||
labelKey: TranslationKey;
|
||||
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
|
||||
{ href: "/books", labelKey: "nav.books", icon: "books" },
|
||||
{ href: "/series", labelKey: "nav.series", icon: "series" },
|
||||
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
|
||||
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
|
||||
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
|
||||
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
|
||||
];
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
const { t } = await getServerTranslations();
|
||||
const cookieStore = await cookies();
|
||||
const activeUserId = cookieStore.get("as_user_id")?.value || null;
|
||||
const users = await fetchUsers().catch(() => []);
|
||||
|
||||
async function setActiveUserAction(formData: FormData) {
|
||||
"use server";
|
||||
const userId = formData.get("user_id") as string;
|
||||
const store = await cookies();
|
||||
if (userId) {
|
||||
store.set("as_user_id", userId, { path: "/", httpOnly: false, sameSite: "lax" });
|
||||
} else {
|
||||
store.delete("as_user_id");
|
||||
}
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
|
||||
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
|
||||
>
|
||||
<Image src="/logo.png" alt="StripStream" width={36} height={36} className="rounded-lg" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xl font-bold tracking-tight text-foreground">StripStream</span>
|
||||
<span className="text-sm text-muted-foreground font-medium hidden xl:inline">
|
||||
{t("common.backoffice")}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
|
||||
<NavIcon name={item.icon} />
|
||||
<span className="ml-2 hidden xl:inline">{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<UserSwitcher
|
||||
users={users}
|
||||
activeUserId={activeUserId}
|
||||
setActiveUserAction={setActiveUserAction}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
|
||||
<JobsIndicator />
|
||||
<Link
|
||||
href="/settings"
|
||||
className="hidden xl:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
title={t("nav.settings")}
|
||||
>
|
||||
<Icon name="settings" size="md" />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<LogoutButton />
|
||||
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={title}
|
||||
className="
|
||||
flex items-center
|
||||
px-2 lg:px-3 py-2
|
||||
rounded-lg
|
||||
text-sm font-medium
|
||||
text-muted-foreground
|
||||
hover:text-foreground
|
||||
hover:bg-accent
|
||||
transition-colors duration-200
|
||||
active:scale-[0.98]
|
||||
"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
|
||||
import { BooksGrid, EmptyState } from "../../../components/BookCard";
|
||||
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
||||
import { OffsetPagination } from "../../../components/ui";
|
||||
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "@/lib/api";
|
||||
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
|
||||
import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
|
||||
import { OffsetPagination } from "@/app/components/ui";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getServerTranslations } from "../../../../lib/i18n/server";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "../../../../../lib/api";
|
||||
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
|
||||
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
|
||||
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
||||
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, getReadingStatusLink, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto, AnilistSeriesLinkDto } from "@/lib/api";
|
||||
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
|
||||
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
|
||||
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
|
||||
import { ProviderIcon, providerLabel } from "@/app/components/ProviderIcon";
|
||||
import nextDynamic from "next/dynamic";
|
||||
import { OffsetPagination } from "../../../../components/ui";
|
||||
import { SafeHtml } from "../../../../components/SafeHtml";
|
||||
import { OffsetPagination } from "@/app/components/ui";
|
||||
import { SafeHtml } from "@/app/components/SafeHtml";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
const EditSeriesForm = nextDynamic(
|
||||
() => import("../../../../components/EditSeriesForm").then(m => m.EditSeriesForm)
|
||||
() => import("@/app/components/EditSeriesForm").then(m => m.EditSeriesForm)
|
||||
);
|
||||
const MetadataSearchModal = nextDynamic(
|
||||
() => import("../../../../components/MetadataSearchModal").then(m => m.MetadataSearchModal)
|
||||
() => import("@/app/components/MetadataSearchModal").then(m => m.MetadataSearchModal)
|
||||
);
|
||||
const ReadingStatusModal = nextDynamic(
|
||||
() => import("@/app/components/ReadingStatusModal").then(m => m.ReadingStatusModal)
|
||||
);
|
||||
const ProwlarrSearchModal = nextDynamic(
|
||||
() => import("../../../../components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
|
||||
() => import("@/app/components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
|
||||
);
|
||||
import { notFound } from "next/navigation";
|
||||
import { getServerTranslations } from "../../../../../lib/i18n/server";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -37,7 +41,7 @@ export default async function SeriesDetailPage({
|
||||
|
||||
const seriesName = decodeURIComponent(name);
|
||||
|
||||
const [library, booksPage, seriesMeta, metadataLinks] = await Promise.all([
|
||||
const [library, booksPage, seriesMeta, metadataLinks, readingStatusLink] = await Promise.all([
|
||||
fetchLibraries().then((libs) => libs.find((l) => l.id === id)),
|
||||
fetchBooks(id, seriesName, page, limit).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
@@ -47,6 +51,7 @@ export default async function SeriesDetailPage({
|
||||
})),
|
||||
fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null),
|
||||
getMetadataLink(id, seriesName).catch(() => [] as ExternalMetadataLinkDto[]),
|
||||
getReadingStatusLink(id, seriesName).catch(() => null as AnilistSeriesLinkDto | null),
|
||||
]);
|
||||
|
||||
const existingLink = metadataLinks.find((l) => l.status === "approved") ?? metadataLinks[0] ?? null;
|
||||
@@ -126,6 +131,37 @@ export default async function SeriesDetailPage({
|
||||
{t(`seriesStatus.${seriesMeta.status}` as any) || seriesMeta.status}
|
||||
</span>
|
||||
)}
|
||||
{existingLink?.status === "approved" && (
|
||||
existingLink.external_url ? (
|
||||
<a
|
||||
href={existingLink.external_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/30 hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<ProviderIcon provider={existingLink.provider} size={12} />
|
||||
{providerLabel(existingLink.provider)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/30">
|
||||
<ProviderIcon provider={existingLink.provider} size={12} />
|
||||
{providerLabel(existingLink.provider)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{readingStatusLink && (
|
||||
<a
|
||||
href={readingStatusLink.anilist_url ?? `https://anilist.co/manga/${readingStatusLink.anilist_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-600 text-xs border border-cyan-500/30 hover:bg-cyan-500/20 transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6.361 2.943 0 21.056h4.942l1.077-3.133H11.4l1.077 3.133H17.5L11.128 2.943H6.361zm1.58 11.152 1.84-5.354 1.84 5.354H7.941zM17.358 2.943v18.113h4.284V2.943h-4.284z"/>
|
||||
</svg>
|
||||
AniList
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{seriesMeta?.description && (
|
||||
@@ -206,6 +242,12 @@ export default async function SeriesDetailPage({
|
||||
existingLink={existingLink}
|
||||
initialMissing={missingData}
|
||||
/>
|
||||
<ReadingStatusModal
|
||||
libraryId={id}
|
||||
seriesName={seriesName}
|
||||
readingStatusProvider={library.reading_status_provider ?? null}
|
||||
existingLink={readingStatusLink}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,12 +1,12 @@
|
||||
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
|
||||
import { OffsetPagination } from "../../../components/ui";
|
||||
import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton";
|
||||
import { SeriesFilters } from "../../../components/SeriesFilters";
|
||||
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "@/lib/api";
|
||||
import { OffsetPagination } from "@/app/components/ui";
|
||||
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
|
||||
import { SeriesFilters } from "@/app/components/SeriesFilters";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
||||
import { getServerTranslations } from "../../../../lib/i18n/server";
|
||||
import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "../../lib/api";
|
||||
import type { TranslationKey } from "../../lib/i18n/fr";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
import { LibraryActions } from "../components/LibraryActions";
|
||||
import { LibraryForm } from "../components/LibraryForm";
|
||||
import { ProviderIcon } from "../components/ProviderIcon";
|
||||
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "@/lib/api";
|
||||
import type { TranslationKey } from "@/lib/i18n/fr";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import { LibraryActions } from "@/app/components/LibraryActions";
|
||||
import { LibraryForm } from "@/app/components/LibraryForm";
|
||||
import { ProviderIcon } from "@/app/components/ProviderIcon";
|
||||
import {
|
||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||
Button, Badge
|
||||
} from "../components/ui";
|
||||
} from "@/app/components/ui";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -146,6 +146,7 @@ export default async function LibrariesPage() {
|
||||
metadataProvider={lib.metadata_provider}
|
||||
fallbackMetadataProvider={lib.fallback_metadata_provider}
|
||||
metadataRefreshMode={lib.metadata_refresh_mode}
|
||||
readingStatusProvider={lib.reading_status_provider}
|
||||
/>
|
||||
<form>
|
||||
<input type="hidden" name="id" value={lib.id} />
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from "react";
|
||||
import { fetchStats, StatsResponse, getBookCoverUrl } from "../lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
|
||||
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar } from "./components/DashboardCharts";
|
||||
import Image from "next/image";
|
||||
import { fetchStats, fetchUsers, StatsResponse, UserDto } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui";
|
||||
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts";
|
||||
import { PeriodToggle } from "@/app/components/PeriodToggle";
|
||||
import { MetricToggle } from "@/app/components/MetricToggle";
|
||||
import { CurrentlyReadingList, RecentlyReadList } from "@/app/components/ReadingUserFilter";
|
||||
import Link from "next/link";
|
||||
import { getServerTranslations } from "../lib/i18n/server";
|
||||
import type { TranslateFunction } from "../lib/i18n/dictionaries";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import type { TranslateFunction } from "@/lib/i18n/dictionaries";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -21,6 +23,24 @@ function formatNumber(n: number, locale: string): string {
|
||||
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
|
||||
}
|
||||
|
||||
function formatChartLabel(raw: string, period: "day" | "week" | "month", locale: string): string {
|
||||
const loc = locale === "fr" ? "fr-FR" : "en-US";
|
||||
if (period === "month") {
|
||||
// raw = "YYYY-MM"
|
||||
const [y, m] = raw.split("-");
|
||||
const d = new Date(Number(y), Number(m) - 1, 1);
|
||||
return d.toLocaleDateString(loc, { month: "short" });
|
||||
}
|
||||
if (period === "week") {
|
||||
// raw = "YYYY-MM-DD" (Monday of the week)
|
||||
const d = new Date(raw + "T00:00:00");
|
||||
return d.toLocaleDateString(loc, { day: "numeric", month: "short" });
|
||||
}
|
||||
// day: raw = "YYYY-MM-DD"
|
||||
const d = new Date(raw + "T00:00:00");
|
||||
return d.toLocaleDateString(loc, { weekday: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed)
|
||||
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
|
||||
const pct = max > 0 ? (value / max) * 100 : 0;
|
||||
@@ -40,12 +60,24 @@ function HorizontalBar({ label, value, max, subLabel, color = "var(--color-prima
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
export default async function DashboardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const searchParamsAwaited = await searchParams;
|
||||
const rawPeriod = searchParamsAwaited.period;
|
||||
const period = rawPeriod === "day" ? "day" as const : rawPeriod === "week" ? "week" as const : "month" as const;
|
||||
const metric = searchParamsAwaited.metric === "pages" ? "pages" as const : "books" as const;
|
||||
const { t, locale } = await getServerTranslations();
|
||||
|
||||
let stats: StatsResponse | null = null;
|
||||
let users: UserDto[] = [];
|
||||
try {
|
||||
stats = await fetchStats();
|
||||
[stats, users] = await Promise.all([
|
||||
fetchStats(period),
|
||||
fetchUsers().catch(() => []),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch stats:", e);
|
||||
}
|
||||
@@ -62,7 +94,20 @@ export default async function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, metadata } = stats;
|
||||
const {
|
||||
overview,
|
||||
reading_status,
|
||||
currently_reading = [],
|
||||
recently_read = [],
|
||||
reading_over_time = [],
|
||||
users_reading_over_time = [],
|
||||
by_format,
|
||||
by_library,
|
||||
top_series,
|
||||
additions_over_time,
|
||||
jobs_over_time = [],
|
||||
metadata = { total_series: 0, series_linked: 0, series_unlinked: 0, books_with_summary: 0, books_with_isbn: 0, by_provider: [] },
|
||||
} = stats;
|
||||
|
||||
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
|
||||
const formatColors = [
|
||||
@@ -107,37 +152,12 @@ export default async function DashboardPage() {
|
||||
<CardTitle className="text-base">{t("dashboard.currentlyReading")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{currently_reading.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noCurrentlyReading")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{currently_reading.slice(0, 8).map((book) => {
|
||||
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
|
||||
return (
|
||||
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.book_id)}
|
||||
alt={book.title}
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
|
||||
<CurrentlyReadingList
|
||||
items={currently_reading}
|
||||
allLabel={t("dashboard.allUsers")}
|
||||
emptyLabel={t("dashboard.noCurrentlyReading")}
|
||||
pageProgressTemplate={t("dashboard.pageProgress")}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
|
||||
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full bg-warning rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">{pct}%</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{t("dashboard.pageProgress", { current: book.current_page, total: book.page_count })}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -147,57 +167,69 @@ export default async function DashboardPage() {
|
||||
<CardTitle className="text-base">{t("dashboard.recentlyRead")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recently_read.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noRecentlyRead")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recently_read.map((book) => (
|
||||
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.book_id)}
|
||||
alt={book.title}
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
|
||||
<RecentlyReadList
|
||||
items={recently_read}
|
||||
allLabel={t("dashboard.allUsers")}
|
||||
emptyLabel={t("dashboard.noRecentlyRead")}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
|
||||
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{book.last_read_at}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reading activity line chart */}
|
||||
{reading_over_time.length > 0 && (
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<MetricToggle labels={{ books: t("dashboard.metricBooks"), pages: t("dashboard.metricPages") }} />
|
||||
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
const userColors = [
|
||||
"hsl(142 60% 45%)", "hsl(198 78% 37%)", "hsl(45 93% 47%)",
|
||||
"hsl(2 72% 48%)", "hsl(280 60% 50%)", "hsl(32 80% 50%)",
|
||||
];
|
||||
const dataKey = metric === "pages" ? "pages_read" : "books_read";
|
||||
const usernames = [...new Set(users_reading_over_time.map(r => r.username))];
|
||||
if (usernames.length === 0) {
|
||||
return (
|
||||
<RcAreaChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={reading_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_read }))}
|
||||
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m[dataKey] }))}
|
||||
color="hsl(142 60% 45%)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Pivot: { label, username1: n, username2: n, ... }
|
||||
const byMonth = new Map<string, Record<string, unknown>>();
|
||||
for (const row of users_reading_over_time) {
|
||||
const label = formatChartLabel(row.month, period, locale);
|
||||
if (!byMonth.has(row.month)) byMonth.set(row.month, { label });
|
||||
byMonth.get(row.month)![row.username] = row[dataKey];
|
||||
}
|
||||
const chartData = [...byMonth.values()];
|
||||
const lines = usernames.map((u, i) => ({
|
||||
key: u,
|
||||
label: u,
|
||||
color: userColors[i % userColors.length],
|
||||
}));
|
||||
return <RcMultiLineChart data={chartData} lines={lines} noDataLabel={noDataLabel} />;
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Charts row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Reading status donut */}
|
||||
{/* Reading status par lecteur */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("dashboard.readingStatus")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{users.length === 0 ? (
|
||||
<RcDonutChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={[
|
||||
@@ -206,6 +238,34 @@ export default async function DashboardPage() {
|
||||
{ name: t("status.read"), value: reading_status.read, color: readingColors[2] },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{users.map((user) => {
|
||||
const total = overview.total_books;
|
||||
const read = user.books_read;
|
||||
const reading = user.books_reading;
|
||||
const unread = Math.max(0, total - read - reading);
|
||||
const readPct = total > 0 ? (read / total) * 100 : 0;
|
||||
const readingPct = total > 0 ? (reading / total) * 100 : 0;
|
||||
return (
|
||||
<div key={user.id} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-foreground truncate">{user.username}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">
|
||||
<span className="text-success font-medium">{read}</span>
|
||||
{reading > 0 && <span className="text-amber-500 font-medium"> · {reading}</span>}
|
||||
<span className="text-muted-foreground/60"> / {total}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden flex">
|
||||
<div className="h-full bg-success transition-all duration-500" style={{ width: `${readPct}%` }} />
|
||||
<div className="h-full bg-amber-500 transition-all duration-500" style={{ width: `${readingPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -351,20 +411,47 @@ export default async function DashboardPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Monthly additions line chart – full width */}
|
||||
{/* Additions line chart – full width */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
|
||||
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RcAreaChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={additions_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_added }))}
|
||||
data={additions_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_added }))}
|
||||
color="hsl(198 78% 37%)"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Jobs over time – multi-line chart */}
|
||||
<Card hover={false}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("dashboard.jobsOverTime")}</CardTitle>
|
||||
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RcMultiLineChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={jobs_over_time.map((j) => ({
|
||||
label: formatChartLabel(j.label, period, locale),
|
||||
scan: j.scan,
|
||||
rebuild: j.rebuild,
|
||||
thumbnail: j.thumbnail,
|
||||
other: j.other,
|
||||
}))}
|
||||
lines={[
|
||||
{ key: "scan", label: t("dashboard.jobScan"), color: "hsl(198 78% 37%)" },
|
||||
{ key: "rebuild", label: t("dashboard.jobRebuild"), color: "hsl(142 60% 45%)" },
|
||||
{ key: "thumbnail", label: t("dashboard.jobThumbnail"), color: "hsl(45 93% 47%)" },
|
||||
{ key: "other", label: t("dashboard.jobOther"), color: "hsl(280 60% 50%)" },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick links */}
|
||||
<QuickLinks t={t} />
|
||||
</div>
|
||||
@@ -1,11 +1,12 @@
|
||||
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton";
|
||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
|
||||
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ProviderIcon } from "../components/ProviderIcon";
|
||||
import { ProviderIcon } from "@/app/components/ProviderIcon";
|
||||
import { ExternalLinkBadge } from "@/app/components/ExternalLinkBadge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -122,13 +123,9 @@ export default async function SeriesPage({
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{series.map((s) => (
|
||||
<Link
|
||||
key={s.name}
|
||||
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
|
||||
className="group"
|
||||
>
|
||||
<div key={s.name} className="group relative">
|
||||
<div
|
||||
className={`bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md hover:-translate-y-1 transition-all duration-200 ${
|
||||
className={`bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden group-hover:shadow-md group-hover:-translate-y-1 transition-all duration-200 ${
|
||||
s.books_read_count >= s.book_count ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -149,13 +146,15 @@ export default async function SeriesPage({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||
</p>
|
||||
<div className="relative z-20">
|
||||
<MarkSeriesReadButton
|
||||
seriesName={s.name}
|
||||
bookCount={s.book_count}
|
||||
booksReadCount={s.books_read_count}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
|
||||
</div>
|
||||
<div className="relative z-20 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" :
|
||||
@@ -177,10 +176,24 @@ export default async function SeriesPage({
|
||||
<ProviderIcon provider={s.metadata_provider} size={10} />
|
||||
</span>
|
||||
)}
|
||||
{s.anilist_id && (
|
||||
<ExternalLinkBadge
|
||||
href={s.anilist_url ?? `https://anilist.co/manga/${s.anilist_id}`}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-cyan-500/15 text-cyan-600 hover:bg-cyan-500/25"
|
||||
>
|
||||
AL
|
||||
</ExternalLinkBadge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{/* Link overlay covering the full card — below interactive elements */}
|
||||
<Link
|
||||
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
|
||||
className="absolute inset-0 z-10 rounded-xl"
|
||||
aria-label={s.name === "unclassified" ? t("books.unclassified") : s.name}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
|
||||
import { ProviderIcon } from "../components/ProviderIcon";
|
||||
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto } from "../../lib/api";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import type { Locale } from "../../lib/i18n/types";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "@/app/components/ui";
|
||||
import { ProviderIcon } from "@/app/components/ProviderIcon";
|
||||
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto, UserDto, AnilistStatusDto, AnilistSyncReportDto, AnilistPullReportDto, AnilistSyncPreviewItemDto, AnilistSyncItemDto, AnilistPullItemDto } from "@/lib/api";
|
||||
import { useTranslation } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/types";
|
||||
|
||||
interface SettingsPageProps {
|
||||
initialSettings: Settings;
|
||||
initialCacheStats: CacheStats;
|
||||
initialThumbnailStats: ThumbnailStats;
|
||||
users: UserDto[];
|
||||
initialTab?: string;
|
||||
}
|
||||
|
||||
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats }: SettingsPageProps) {
|
||||
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users, initialTab }: SettingsPageProps) {
|
||||
const { t, locale, setLocale } = useTranslation();
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
...initialSettings,
|
||||
@@ -29,6 +31,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
const [komgaUrl, setKomgaUrl] = useState("");
|
||||
const [komgaUsername, setKomgaUsername] = useState("");
|
||||
const [komgaPassword, setKomgaPassword] = useState("");
|
||||
const [komgaUserId, setKomgaUserId] = useState(users[0]?.id ?? "");
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<KomgaSyncResponse | null>(null);
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
@@ -104,6 +107,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
if (data) {
|
||||
if (data.url) setKomgaUrl(data.url);
|
||||
if (data.username) setKomgaUsername(data.username);
|
||||
if (data.user_id) setKomgaUserId(data.user_id);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [fetchReports]);
|
||||
@@ -128,7 +132,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
const response = await fetch("/api/komga/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword }),
|
||||
body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword, user_id: komgaUserId }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
@@ -140,7 +144,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
fetch("/api/settings/komga", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername } }),
|
||||
body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername, user_id: komgaUserId } }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
@@ -150,11 +154,14 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
}
|
||||
}
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"general" | "integrations" | "notifications">("general");
|
||||
const [activeTab, setActiveTab] = useState<"general" | "integrations" | "anilist" | "notifications">(
|
||||
initialTab === "anilist" || initialTab === "integrations" || initialTab === "notifications" ? initialTab : "general"
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{ id: "general" as const, label: t("settings.general"), icon: "settings" as const },
|
||||
{ id: "integrations" as const, label: t("settings.integrations"), icon: "refresh" as const },
|
||||
{ id: "anilist" as const, label: t("settings.anilist"), icon: "link" as const },
|
||||
{ id: "notifications" as const, label: t("settings.notifications"), icon: "bell" as const },
|
||||
];
|
||||
|
||||
@@ -620,16 +627,29 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.password")}</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
type="password" autoComplete="off"
|
||||
value={komgaPassword}
|
||||
onChange={(e) => setKomgaPassword(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
|
||||
{users.length > 0 && (
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("users.title")}</label>
|
||||
<FormSelect value={komgaUserId} onChange={(e) => setKomgaUserId(e.target.value)}>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.username}</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleKomgaSync}
|
||||
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword}
|
||||
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword || !komgaUserId}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<>
|
||||
@@ -832,6 +852,10 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
{/* Telegram Notifications */}
|
||||
<TelegramCard handleUpdateSetting={handleUpdateSetting} />
|
||||
</>)}
|
||||
|
||||
{activeTab === "anilist" && (
|
||||
<AnilistTab handleUpdateSetting={handleUpdateSetting} users={users} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -955,7 +979,7 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
|
||||
{t("settings.googleBooksKey")}
|
||||
</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
type="password" autoComplete="off"
|
||||
placeholder={t("settings.googleBooksPlaceholder")}
|
||||
value={apiKeys.google_books || ""}
|
||||
onChange={(e) => setApiKeys({ ...apiKeys, google_books: e.target.value })}
|
||||
@@ -970,7 +994,7 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
|
||||
{t("settings.comicvineKey")}
|
||||
</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
type="password" autoComplete="off"
|
||||
placeholder={t("settings.comicvinePlaceholder")}
|
||||
value={apiKeys.comicvine || ""}
|
||||
onChange={(e) => setApiKeys({ ...apiKeys, comicvine: e.target.value })}
|
||||
@@ -1312,7 +1336,7 @@ function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrApiKey")}</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
type="password" autoComplete="off"
|
||||
placeholder={t("settings.prowlarrApiKeyPlaceholder")}
|
||||
value={prowlarrApiKey}
|
||||
onChange={(e) => setProwlarrApiKey(e.target.value)}
|
||||
@@ -1450,7 +1474,7 @@ function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: s
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.qbittorrentPassword")}</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
type="password" autoComplete="off"
|
||||
value={qbPassword}
|
||||
onChange={(e) => setQbPassword(e.target.value)}
|
||||
onBlur={() => saveQbittorrent()}
|
||||
@@ -1616,7 +1640,7 @@ function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.botToken")}</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
type="password" autoComplete="off"
|
||||
placeholder={t("settings.botTokenPlaceholder")}
|
||||
value={botToken}
|
||||
onChange={(e) => setBotToken(e.target.value)}
|
||||
@@ -1737,3 +1761,407 @@ function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AniList sub-component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AnilistTab({
|
||||
handleUpdateSetting,
|
||||
users,
|
||||
}: {
|
||||
handleUpdateSetting: (key: string, value: unknown) => Promise<void>;
|
||||
users: UserDto[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [localUserId, setLocalUserId] = useState("");
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [viewer, setViewer] = useState<AnilistStatusDto | null>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncReport, setSyncReport] = useState<AnilistSyncReportDto | null>(null);
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [pullReport, setPullReport] = useState<AnilistPullReportDto | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [isPreviewing, setIsPreviewing] = useState(false);
|
||||
const [previewItems, setPreviewItems] = useState<AnilistSyncPreviewItemDto[] | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/anilist")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
if (data.client_id) setClientId(String(data.client_id));
|
||||
if (data.access_token) setToken(data.access_token);
|
||||
if (data.user_id) setUserId(String(data.user_id));
|
||||
if (data.local_user_id) setLocalUserId(String(data.local_user_id));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function buildAnilistSettings() {
|
||||
return {
|
||||
client_id: clientId || undefined,
|
||||
access_token: token || undefined,
|
||||
user_id: userId ? Number(userId) : undefined,
|
||||
local_user_id: localUserId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function handleConnect() {
|
||||
if (!clientId) return;
|
||||
// Save client_id first, then open OAuth URL
|
||||
handleUpdateSetting("anilist", buildAnilistSettings()).then(() => {
|
||||
window.location.href = `https://anilist.co/api/v2/oauth/authorize?client_id=${encodeURIComponent(clientId)}&response_type=token`;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveToken() {
|
||||
await handleUpdateSetting("anilist", buildAnilistSettings());
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
setIsTesting(true);
|
||||
setViewer(null);
|
||||
setTestError(null);
|
||||
try {
|
||||
// Save token first so the API reads the current value
|
||||
await handleUpdateSetting("anilist", buildAnilistSettings());
|
||||
const resp = await fetch("/api/anilist/status");
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || "Connection failed");
|
||||
setViewer(data);
|
||||
if (!userId && data.user_id) setUserId(String(data.user_id));
|
||||
} catch (e) {
|
||||
setTestError(e instanceof Error ? e.message : "Connection failed");
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreview() {
|
||||
setIsPreviewing(true);
|
||||
setPreviewItems(null);
|
||||
setActionError(null);
|
||||
try {
|
||||
const resp = await fetch("/api/anilist/sync/preview");
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || "Preview failed");
|
||||
setPreviewItems(data);
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : "Preview failed");
|
||||
} finally {
|
||||
setIsPreviewing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSync() {
|
||||
setIsSyncing(true);
|
||||
setSyncReport(null);
|
||||
setActionError(null);
|
||||
try {
|
||||
const resp = await fetch("/api/anilist/sync", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || "Sync failed");
|
||||
setSyncReport(data);
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : "Sync failed");
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePull() {
|
||||
setIsPulling(true);
|
||||
setPullReport(null);
|
||||
setActionError(null);
|
||||
try {
|
||||
const resp = await fetch("/api/anilist/pull", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || "Pull failed");
|
||||
setPullReport(data);
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : "Pull failed");
|
||||
} finally {
|
||||
setIsPulling(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="link" size="md" />
|
||||
{t("settings.anilistTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("settings.anilistDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("settings.anilistConnectDesc")}</p>
|
||||
{/* Redirect URL info */}
|
||||
<div className="rounded-md bg-muted/50 border px-3 py-2 text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-medium text-foreground">{t("settings.anilistRedirectUrlLabel")}</p>
|
||||
<code className="select-all font-mono">{typeof window !== "undefined" ? `${window.location.origin}/anilist/callback` : "/anilist/callback"}</code>
|
||||
<p>{t("settings.anilistRedirectUrlHint")}</p>
|
||||
</div>
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.anilistClientId")}</label>
|
||||
<FormInput
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
placeholder={t("settings.anilistClientIdPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button onClick={handleConnect} disabled={!clientId}>
|
||||
<Icon name="link" size="sm" className="mr-2" />
|
||||
{t("settings.anilistConnectButton")}
|
||||
</Button>
|
||||
<Button onClick={handleTestConnection} disabled={isTesting || !token} variant="secondary">
|
||||
{isTesting ? (
|
||||
<><Icon name="spinner" size="sm" className="animate-spin mr-2" />{t("settings.testing")}</>
|
||||
) : (
|
||||
<><Icon name="refresh" size="sm" className="mr-2" />{t("settings.anilistTestConnection")}</>
|
||||
)}
|
||||
</Button>
|
||||
{viewer && (
|
||||
<span className="text-sm text-success font-medium">
|
||||
{t("settings.anilistConnected")} <strong>{viewer.username}</strong>
|
||||
{" · "}
|
||||
<a href={viewer.site_url} target="_blank" rel="noopener noreferrer" className="underline">AniList</a>
|
||||
</span>
|
||||
)}
|
||||
{token && !viewer && (
|
||||
<span className="text-sm text-muted-foreground">{t("settings.anilistTokenPresent")}</span>
|
||||
)}
|
||||
{testError && <span className="text-sm text-destructive">{testError}</span>}
|
||||
</div>
|
||||
<details className="group">
|
||||
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground select-none">
|
||||
{t("settings.anilistManualToken")}
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.anilistToken")}</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder={t("settings.anilistTokenPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.anilistUserId")}</label>
|
||||
<FormInput
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder={t("settings.anilistUserIdPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
<Button onClick={handleSaveToken} disabled={!token}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
<div className="border-t border-border/50 pt-4 mt-2">
|
||||
<p className="text-sm font-medium text-foreground mb-1">{t("settings.anilistLocalUserTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground mb-3">{t("settings.anilistLocalUserDesc")}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={localUserId}
|
||||
onChange={(e) => setLocalUserId(e.target.value)}
|
||||
autoComplete="off"
|
||||
className="flex-1 text-sm border border-border rounded-lg px-3 py-2.5 bg-background focus:outline-none focus:ring-2 focus:ring-ring h-10"
|
||||
>
|
||||
<option value="">{t("settings.anilistLocalUserNone")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.username}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button onClick={() => handleUpdateSetting("anilist", buildAnilistSettings())} disabled={!localUserId}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="refresh" size="md" />
|
||||
{t("settings.anilistSyncTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">{t("settings.anilistSyncDesc")}</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button onClick={handlePreview} disabled={isPreviewing} variant="secondary">
|
||||
{isPreviewing ? (
|
||||
<><Icon name="spinner" size="sm" className="animate-spin mr-2" />{t("settings.anilistPreviewing")}</>
|
||||
) : (
|
||||
<><Icon name="eye" size="sm" className="mr-2" />{t("settings.anilistPreviewButton")}</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={handleSync} disabled={isSyncing}>
|
||||
{isSyncing ? (
|
||||
<><Icon name="spinner" size="sm" className="animate-spin mr-2" />{t("settings.anilistSyncing")}</>
|
||||
) : (
|
||||
<><Icon name="refresh" size="sm" className="mr-2" />{t("settings.anilistSyncButton")}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{syncReport && (
|
||||
<div className="mt-2 border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-muted/50 flex items-center gap-3">
|
||||
<span className="text-sm text-success font-medium">{t("settings.anilistSynced", { count: String(syncReport.synced) })}</span>
|
||||
{syncReport.skipped > 0 && <span className="text-sm text-muted-foreground">{t("settings.anilistSkipped", { count: String(syncReport.skipped) })}</span>}
|
||||
{syncReport.errors.length > 0 && <span className="text-sm text-destructive">{t("settings.anilistErrors", { count: String(syncReport.errors.length) })}</span>}
|
||||
</div>
|
||||
{syncReport.items.length > 0 && (
|
||||
<div className="divide-y max-h-60 overflow-y-auto">
|
||||
{syncReport.items.map((item: AnilistSyncItemDto) => (
|
||||
<div key={item.series_name} className="flex items-center justify-between px-4 py-2 text-sm">
|
||||
<a
|
||||
href={item.anilist_url ?? `https://anilist.co/manga/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate font-medium hover:underline min-w-0 mr-3"
|
||||
>
|
||||
{item.anilist_title ?? item.series_name}
|
||||
</a>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
||||
item.status === "COMPLETED" ? "bg-green-500/15 text-green-600" :
|
||||
item.status === "CURRENT" ? "bg-blue-500/15 text-blue-600" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>{item.status}</span>
|
||||
{item.progress_volumes > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{item.progress_volumes} vol.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{syncReport.errors.map((err: string, i: number) => (
|
||||
<p key={i} className="text-xs text-destructive px-4 py-1 border-t">{err}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">{t("settings.anilistPullDesc")}</p>
|
||||
<Button onClick={handlePull} disabled={isPulling}>
|
||||
{isPulling ? (
|
||||
<><Icon name="spinner" size="sm" className="animate-spin mr-2" />{t("settings.anilistPulling")}</>
|
||||
) : (
|
||||
<><Icon name="refresh" size="sm" className="mr-2" />{t("settings.anilistPullButton")}</>
|
||||
)}
|
||||
</Button>
|
||||
{pullReport && (
|
||||
<div className="mt-2 border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-muted/50 flex items-center gap-3">
|
||||
<span className="text-sm text-success font-medium">{t("settings.anilistUpdated", { count: String(pullReport.updated) })}</span>
|
||||
{pullReport.skipped > 0 && <span className="text-sm text-muted-foreground">{t("settings.anilistSkipped", { count: String(pullReport.skipped) })}</span>}
|
||||
{pullReport.errors.length > 0 && <span className="text-sm text-destructive">{t("settings.anilistErrors", { count: String(pullReport.errors.length) })}</span>}
|
||||
</div>
|
||||
{pullReport.items.length > 0 && (
|
||||
<div className="divide-y max-h-60 overflow-y-auto">
|
||||
{pullReport.items.map((item: AnilistPullItemDto) => (
|
||||
<div key={item.series_name} className="flex items-center justify-between px-4 py-2 text-sm">
|
||||
<a
|
||||
href={item.anilist_url ?? `https://anilist.co/manga/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate font-medium hover:underline min-w-0 mr-3"
|
||||
>
|
||||
{item.anilist_title ?? item.series_name}
|
||||
</a>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
||||
item.anilist_status === "COMPLETED" ? "bg-green-500/15 text-green-600" :
|
||||
item.anilist_status === "CURRENT" ? "bg-blue-500/15 text-blue-600" :
|
||||
item.anilist_status === "PLANNING" ? "bg-amber-500/15 text-amber-600" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>{item.anilist_status}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.books_updated} {t("dashboard.books").toLowerCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pullReport.errors.map((err: string, i: number) => (
|
||||
<p key={i} className="text-xs text-destructive px-4 py-1 border-t">{err}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
{previewItems !== null && (
|
||||
<div className="mt-2 border rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t("settings.anilistPreviewTitle", { count: String(previewItems.length) })}</span>
|
||||
<button onClick={() => setPreviewItems(null)} className="text-xs text-muted-foreground hover:text-foreground">✕</button>
|
||||
</div>
|
||||
{previewItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground px-4 py-3">{t("settings.anilistPreviewEmpty")}</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{previewItems.map((item) => (
|
||||
<div key={`${item.anilist_id}-${item.series_name}`} className="flex items-center justify-between px-4 py-2 text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<a
|
||||
href={item.anilist_url ?? `https://anilist.co/manga/${item.anilist_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate font-medium hover:underline"
|
||||
>
|
||||
{item.anilist_title ?? item.series_name}
|
||||
</a>
|
||||
{item.anilist_title && item.anilist_title !== item.series_name && (
|
||||
<span className="text-muted-foreground truncate hidden sm:inline">— {item.series_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||
<span className="text-xs text-muted-foreground">{item.books_read}/{item.book_count}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||
item.status === "COMPLETED" ? "bg-success/15 text-success" :
|
||||
item.status === "CURRENT" ? "bg-blue-500/15 text-blue-600" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { getSettings, getCacheStats, getThumbnailStats } from "../../lib/api";
|
||||
import { getSettings, getCacheStats, getThumbnailStats, fetchUsers } from "@/lib/api";
|
||||
import SettingsPage from "./SettingsPage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SettingsPageWrapper() {
|
||||
export default async function SettingsPageWrapper({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {
|
||||
const { tab } = await searchParams;
|
||||
const settings = await getSettings().catch(() => ({
|
||||
image_processing: { format: "webp", quality: 85, filter: "lanczos3", max_width: 2160 },
|
||||
cache: { enabled: true, directory: "/tmp/stripstream-image-cache", max_size_mb: 10000 },
|
||||
@@ -23,5 +24,7 @@ export default async function SettingsPageWrapper() {
|
||||
directory: "/data/thumbnails"
|
||||
}));
|
||||
|
||||
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} />;
|
||||
const users = await fetchUsers().catch(() => []);
|
||||
|
||||
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} users={users} initialTab={tab} />;
|
||||
}
|
||||
316
apps/backoffice/app/(app)/tokens/page.tsx
Normal file
316
apps/backoffice/app/(app)/tokens/page.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTokens, createToken, revokeToken, deleteToken, updateToken, fetchUsers, createUser, deleteUser, updateUser, TokenDto, UserDto } from "@/lib/api";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "@/app/components/ui";
|
||||
import { TokenUserSelect } from "@/app/components/TokenUserSelect";
|
||||
import { UsernameEdit } from "@/app/components/UsernameEdit";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TokensPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{ created?: string }>;
|
||||
}) {
|
||||
const { t } = await getServerTranslations();
|
||||
const params = await searchParams;
|
||||
const tokens = await listTokens().catch(() => [] as TokenDto[]);
|
||||
const users = await fetchUsers().catch(() => [] as UserDto[]);
|
||||
|
||||
async function createTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const name = formData.get("name") as string;
|
||||
const scope = formData.get("scope") as string;
|
||||
const userId = (formData.get("user_id") as string) || undefined;
|
||||
if (name) {
|
||||
const result = await createToken(name, scope, userId);
|
||||
revalidatePath("/tokens");
|
||||
redirect(`/tokens?created=${encodeURIComponent(result.token)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await revokeToken(id);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
|
||||
async function deleteTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await deleteToken(id);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
|
||||
async function createUserAction(formData: FormData) {
|
||||
"use server";
|
||||
const username = formData.get("username") as string;
|
||||
if (username) {
|
||||
await createUser(username);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUserAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await deleteUser(id);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
|
||||
async function renameUserAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
const username = formData.get("username") as string;
|
||||
if (username?.trim()) {
|
||||
await updateUser(id, username.trim());
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
}
|
||||
|
||||
async function reassignTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
const userId = (formData.get("user_id") as string) || null;
|
||||
await updateToken(id, userId);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||
<svg className="w-8 h-8 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
{t("tokens.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* ── Lecteurs ─────────────────────────────────────────── */}
|
||||
<div className="mb-2">
|
||||
<h2 className="text-xl font-semibold text-foreground">{t("users.title")}</h2>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("users.createNew")}</CardTitle>
|
||||
<CardDescription>{t("users.createDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={createUserAction}>
|
||||
<FormRow>
|
||||
<FormField className="flex-1 min-w-48">
|
||||
<FormInput name="username" placeholder={t("users.username")} required autoComplete="off" />
|
||||
</FormField>
|
||||
<Button type="submit">{t("users.createButton")}</Button>
|
||||
</FormRow>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden mb-10">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/50">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.name")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.tokenCount")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.read")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.reading")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.createdAt")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{/* Ligne admin synthétique */}
|
||||
<tr className="hover:bg-accent/50 transition-colors bg-destructive/5">
|
||||
<td className="px-4 py-3 text-sm font-medium text-foreground flex items-center gap-2">
|
||||
{process.env.ADMIN_USERNAME ?? "admin"}
|
||||
<Badge variant="destructive">{t("tokens.scopeAdmin")}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{tokens.filter(tok => tok.scope === "admin" && !tok.revoked_at).length}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
</tr>
|
||||
{/* Ligne tokens read non assignés */}
|
||||
{(() => {
|
||||
const unassigned = tokens.filter(tok => tok.scope === "read" && !tok.user_id && !tok.revoked_at);
|
||||
if (unassigned.length === 0) return null;
|
||||
return (
|
||||
<tr className="hover:bg-accent/50 transition-colors bg-warning/5">
|
||||
<td className="px-4 py-3 text-sm font-medium text-muted-foreground italic">
|
||||
{t("tokens.noUser")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-warning font-medium">{unassigned.length}</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
</tr>
|
||||
);
|
||||
})()}
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-accent/50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<UsernameEdit userId={user.id} currentUsername={user.username} action={renameUserAction} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{user.token_count}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{user.books_read > 0
|
||||
? <span className="font-medium text-success">{user.books_read}</span>
|
||||
: <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{user.books_reading > 0
|
||||
? <span className="font-medium text-amber-500">{user.books_reading}</span>
|
||||
: <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<form action={deleteUserAction}>
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
<Button type="submit" variant="destructive" size="xs">
|
||||
<svg className="w-3.5 h-3.5 mr-1.5" 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>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── Tokens API ───────────────────────────────────────── */}
|
||||
<div className="mb-2">
|
||||
<h2 className="text-xl font-semibold text-foreground">{t("tokens.apiTokens")}</h2>
|
||||
</div>
|
||||
|
||||
{params.created ? (
|
||||
<Card className="mb-6 border-success/50 bg-success/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-success">{t("tokens.created")}</CardTitle>
|
||||
<CardDescription>{t("tokens.createdDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="p-4 bg-background rounded-lg text-sm font-mono text-foreground overflow-x-auto border">{params.created}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("tokens.createNew")}</CardTitle>
|
||||
<CardDescription>{t("tokens.createDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={createTokenAction}>
|
||||
<FormRow>
|
||||
<FormField className="flex-1 min-w-48">
|
||||
<FormInput name="name" placeholder={t("tokens.tokenName")} required autoComplete="off" />
|
||||
</FormField>
|
||||
<FormField className="w-32">
|
||||
<FormSelect name="scope" defaultValue="read">
|
||||
<option value="read">{t("tokens.scopeRead")}</option>
|
||||
<option value="admin">{t("tokens.scopeAdmin")}</option>
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
<FormField className="w-48">
|
||||
<FormSelect name="user_id" defaultValue="">
|
||||
<option value="">{t("tokens.noUser")}</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>{user.username}</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
<Button type="submit">{t("tokens.createButton")}</Button>
|
||||
</FormRow>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/50">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.name")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.user")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.scope")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.prefix")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.status")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{tokens.map((token) => (
|
||||
<tr key={token.id} className="hover:bg-accent/50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<TokenUserSelect
|
||||
tokenId={token.id}
|
||||
currentUserId={token.user_id}
|
||||
users={users}
|
||||
action={reassignTokenAction}
|
||||
noUserLabel={t("tokens.noUser")}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
|
||||
{token.scope}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<code className="px-2 py-1 bg-muted rounded font-mono text-foreground">{token.prefix}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{token.revoked_at ? (
|
||||
<Badge variant="error">{t("tokens.revoked")}</Badge>
|
||||
) : (
|
||||
<Badge variant="success">{t("tokens.active")}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{!token.revoked_at ? (
|
||||
<form action={revokeTokenAction}>
|
||||
<input type="hidden" name="id" value={token.id} />
|
||||
<Button type="submit" variant="destructive" size="xs">
|
||||
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{t("tokens.revoke")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form action={deleteTokenAction}>
|
||||
<input type="hidden" name="id" value={token.id} />
|
||||
<Button type="submit" variant="destructive" size="xs">
|
||||
<svg className="w-3.5 h-3.5 mr-1.5" 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>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
apps/backoffice/app/api/anilist/libraries/[id]/route.ts
Normal file
20
apps/backoffice/app/api/anilist/libraries/[id]/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const data = await apiFetch(`/anilist/libraries/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to update library AniList setting";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
apps/backoffice/app/api/anilist/links/route.ts
Normal file
12
apps/backoffice/app/api/anilist/links/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await apiFetch("/anilist/links");
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch AniList links";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
apps/backoffice/app/api/anilist/pull/route.ts
Normal file
12
apps/backoffice/app/api/anilist/pull/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const data = await apiFetch("/anilist/pull", { method: "POST", body: "{}" });
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to pull from AniList";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
apps/backoffice/app/api/anilist/search/route.ts
Normal file
16
apps/backoffice/app/api/anilist/search/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch("/anilist/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to search AniList";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type Params = Promise<{ libraryId: string; seriesName: string }>;
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Params }) {
|
||||
try {
|
||||
const { libraryId, seriesName } = await params;
|
||||
const data = await apiFetch(
|
||||
`/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`,
|
||||
);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Not found";
|
||||
return NextResponse.json({ error: message }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Params }) {
|
||||
try {
|
||||
const { libraryId, seriesName } = await params;
|
||||
const body = await request.json();
|
||||
const data = await apiFetch(
|
||||
`/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}/link`,
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to link series";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Params }) {
|
||||
try {
|
||||
const { libraryId, seriesName } = await params;
|
||||
const data = await apiFetch(
|
||||
`/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}/unlink`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to unlink series";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
apps/backoffice/app/api/anilist/status/route.ts
Normal file
12
apps/backoffice/app/api/anilist/status/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await apiFetch("/anilist/status");
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to get AniList status";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
apps/backoffice/app/api/anilist/sync/preview/route.ts
Normal file
12
apps/backoffice/app/api/anilist/sync/preview/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await apiFetch("/anilist/sync/preview");
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to preview sync";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
apps/backoffice/app/api/anilist/sync/route.ts
Normal file
12
apps/backoffice/app/api/anilist/sync/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const data = await apiFetch("/anilist/sync", { method: "POST", body: "{}" });
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to sync to AniList";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
apps/backoffice/app/api/anilist/unlinked/route.ts
Normal file
12
apps/backoffice/app/api/anilist/unlinked/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await apiFetch("/anilist/unlinked");
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch unlinked series";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
31
apps/backoffice/app/api/auth/login/route.ts
Normal file
31
apps/backoffice/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createSessionToken, SESSION_COOKIE } from "@/lib/session";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body || typeof body.username !== "string" || typeof body.password !== "string") {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const expectedUsername = process.env.ADMIN_USERNAME || "admin";
|
||||
const expectedPassword = process.env.ADMIN_PASSWORD;
|
||||
|
||||
if (!expectedPassword) {
|
||||
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
|
||||
}
|
||||
|
||||
if (body.username !== expectedUsername || body.password !== expectedPassword) {
|
||||
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = await createSessionToken();
|
||||
const response = NextResponse.json({ success: true });
|
||||
response.cookies.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
path: "/",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
8
apps/backoffice/app/api/auth/logout/route.ts
Normal file
8
apps/backoffice/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SESSION_COOKIE } from "@/lib/session";
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true });
|
||||
response.cookies.delete(SESSION_COOKIE);
|
||||
return response;
|
||||
}
|
||||
41
apps/backoffice/app/api/jobs/[id]/replay/route.ts
Normal file
41
apps/backoffice/app/api/jobs/[id]/replay/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiFetch, IndexJobDto, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, startReadingStatusMatch } from "@/lib/api";
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const job = await apiFetch<IndexJobDto>(`/index/jobs/${id}`);
|
||||
const libraryId = job.library_id ?? undefined;
|
||||
|
||||
switch (job.type) {
|
||||
case "rebuild":
|
||||
return NextResponse.json(await rebuildIndex(libraryId));
|
||||
case "full_rebuild":
|
||||
return NextResponse.json(await rebuildIndex(libraryId, true));
|
||||
case "rescan":
|
||||
return NextResponse.json(await rebuildIndex(libraryId, false, true));
|
||||
case "scan":
|
||||
return NextResponse.json(await rebuildIndex(libraryId));
|
||||
case "thumbnail_rebuild":
|
||||
return NextResponse.json(await rebuildThumbnails(libraryId));
|
||||
case "thumbnail_regenerate":
|
||||
return NextResponse.json(await regenerateThumbnails(libraryId));
|
||||
case "metadata_batch":
|
||||
if (!libraryId) return NextResponse.json({ error: "Library ID required for metadata batch" }, { status: 400 });
|
||||
return NextResponse.json(await startMetadataBatch(libraryId));
|
||||
case "metadata_refresh":
|
||||
if (!libraryId) return NextResponse.json({ error: "Library ID required for metadata refresh" }, { status: 400 });
|
||||
return NextResponse.json(await startMetadataRefresh(libraryId));
|
||||
case "reading_status_match":
|
||||
if (!libraryId) return NextResponse.json({ error: "Library ID required for reading status match" }, { status: 400 });
|
||||
return NextResponse.json(await startReadingStatusMatch(libraryId));
|
||||
default:
|
||||
return NextResponse.json({ error: `Cannot replay job type: ${job.type}` }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to replay job" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
11
apps/backoffice/app/api/jobs/list/route.ts
Normal file
11
apps/backoffice/app/api/jobs/list/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listJobs } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await listJobs();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to fetch jobs" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = await apiFetch(`/libraries/${id}/reading-status-provider`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to update reading status provider";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
AreaChart, Area,
|
||||
AreaChart, Area, Line, LineChart,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
|
||||
@@ -186,3 +186,46 @@ export function RcHorizontalBar({
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-line chart (jobs over time)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RcMultiLineChart({
|
||||
data,
|
||||
lines,
|
||||
noDataLabel,
|
||||
}: {
|
||||
data: Record<string, unknown>[];
|
||||
lines: { key: string; label: string; color: string }[];
|
||||
noDataLabel?: string;
|
||||
}) {
|
||||
const hasData = data.some((d) => lines.some((l) => (d[l.key] as number) > 0));
|
||||
if (data.length === 0 || !hasData)
|
||||
return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{lines.map((l) => (
|
||||
<Line
|
||||
key={l.key}
|
||||
type="monotone"
|
||||
dataKey={l.key}
|
||||
name={l.label}
|
||||
stroke={l.color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: l.color }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
21
apps/backoffice/app/components/ExternalLinkBadge.tsx
Normal file
21
apps/backoffice/app/components/ExternalLinkBadge.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
interface ExternalLinkBadgeProps {
|
||||
href: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ExternalLinkBadge({ href, className, children }: ExternalLinkBadgeProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={className}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import { JobProgress } from "./JobProgress";
|
||||
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon } from "./ui";
|
||||
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon, Tooltip } from "./ui";
|
||||
|
||||
interface JobRowProps {
|
||||
job: {
|
||||
@@ -21,6 +21,7 @@ interface JobRowProps {
|
||||
indexed_files: number;
|
||||
removed_files: number;
|
||||
errors: number;
|
||||
refreshed?: number;
|
||||
} | null;
|
||||
progress_percent: number | null;
|
||||
processed_files: number | null;
|
||||
@@ -29,11 +30,14 @@ interface JobRowProps {
|
||||
libraryName: string | undefined;
|
||||
highlighted?: boolean;
|
||||
onCancel: (id: string) => void;
|
||||
onReplay: (id: string) => void;
|
||||
formatDate: (date: string) => string;
|
||||
formatDuration: (start: string, end: string | null) => string;
|
||||
}
|
||||
|
||||
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
|
||||
const REPLAYABLE_TYPES = new Set(["rebuild", "full_rebuild", "rescan", "scan", "thumbnail_rebuild", "thumbnail_regenerate", "metadata_batch", "metadata_refresh", "reading_status_match"]);
|
||||
|
||||
export function JobRow({ job, libraryName, highlighted, onCancel, onReplay, formatDate, formatDuration }: JobRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const isActive = job.status === "running" || job.status === "pending" || job.status === "extracting_pages" || job.status === "generating_thumbnails";
|
||||
const [showProgress, setShowProgress] = useState(highlighted || isActive);
|
||||
@@ -117,49 +121,74 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{/* Files: indexed count */}
|
||||
{indexed > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-success" title={t("jobRow.filesIndexed", { count: indexed })}>
|
||||
<Tooltip label={t("jobRow.filesIndexed", { count: indexed })}>
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<Icon name="document" size="sm" />
|
||||
{indexed}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Removed files */}
|
||||
{removed > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-warning" title={t("jobRow.filesRemoved", { count: removed })}>
|
||||
<Tooltip label={t("jobRow.filesRemoved", { count: removed })}>
|
||||
<span className="inline-flex items-center gap-1 text-warning">
|
||||
<Icon name="trash" size="sm" />
|
||||
{removed}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Thumbnails */}
|
||||
{hasThumbnailPhase && job.total_files != null && job.total_files > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-primary" title={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
|
||||
<Tooltip label={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
|
||||
<span className="inline-flex items-center gap-1 text-primary">
|
||||
<Icon name="image" size="sm" />
|
||||
{job.total_files}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Metadata batch: series processed */}
|
||||
{isMetadataBatch && job.total_files != null && job.total_files > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataProcessed", { count: job.total_files })}>
|
||||
<Tooltip label={t("jobRow.metadataProcessed", { count: job.total_files })}>
|
||||
<span className="inline-flex items-center gap-1 text-info">
|
||||
<Icon name="tag" size="sm" />
|
||||
{job.total_files}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Metadata refresh: links refreshed */}
|
||||
{/* Metadata refresh: total links + refreshed count */}
|
||||
{isMetadataRefresh && job.total_files != null && job.total_files > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataRefreshed", { count: job.total_files })}>
|
||||
<Tooltip label={t("jobRow.metadataLinks", { count: job.total_files })}>
|
||||
<span className="inline-flex items-center gap-1 text-info">
|
||||
<Icon name="tag" size="sm" />
|
||||
{job.total_files}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isMetadataRefresh && job.stats_json?.refreshed != null && job.stats_json.refreshed > 0 && (
|
||||
<Tooltip label={t("jobRow.metadataRefreshed", { count: job.stats_json.refreshed })}>
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<Icon name="refresh" size="sm" />
|
||||
{job.stats_json.refreshed}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Errors */}
|
||||
{errors > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-error" title={t("jobRow.errors", { count: errors })}>
|
||||
<Tooltip label={t("jobRow.errors", { count: errors })}>
|
||||
<span className="inline-flex items-center gap-1 text-error">
|
||||
<Icon name="warning" size="sm" />
|
||||
{errors}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Scanned only (no other stats) */}
|
||||
{indexed === 0 && removed === 0 && errors === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && scanned > 0 && (
|
||||
<span className="text-sm text-muted-foreground">{t("jobRow.scanned", { count: scanned })}</span>
|
||||
<Tooltip label={t("jobRow.scanned", { count: scanned })}>
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Icon name="search" size="sm" />
|
||||
{scanned}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Nothing to show */}
|
||||
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && (
|
||||
@@ -179,19 +208,38 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
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 justify-center gap-1.5 h-7 px-2.5 text-xs font-medium rounded-md bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
{t("jobRow.view")}
|
||||
</Link>
|
||||
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
|
||||
{isActive && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
size="xs"
|
||||
onClick={() => onCancel(job.id)}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
{!isActive && REPLAYABLE_TYPES.has(job.type) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={() => onReplay(job.id)}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 mr-1.5" 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>
|
||||
{t("jobRow.replay")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -18,6 +18,7 @@ interface Job {
|
||||
indexed_files: number;
|
||||
removed_files: number;
|
||||
errors: number;
|
||||
refreshed?: number;
|
||||
} | null;
|
||||
progress_percent: number | null;
|
||||
processed_files: number | null;
|
||||
@@ -94,6 +95,26 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
||||
}
|
||||
};
|
||||
|
||||
const refreshJobs = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/jobs/list");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) setJobs(data);
|
||||
}
|
||||
} catch { /* SSE will catch up */ }
|
||||
};
|
||||
|
||||
const handleReplay = async (id: string) => {
|
||||
const response = await fetch(`/api/jobs/${id}/replay`, { method: "POST" });
|
||||
if (response.ok) {
|
||||
await refreshJobs();
|
||||
} else {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
console.error("Failed to replay job:", data?.error ?? response.status);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -118,6 +139,7 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
||||
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
|
||||
highlighted={job.id === highlightJobId}
|
||||
onCancel={handleCancel}
|
||||
onReplay={handleReplay}
|
||||
formatDate={formatDate}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ interface LibraryActionsProps {
|
||||
metadataProvider: string | null;
|
||||
fallbackMetadataProvider: string | null;
|
||||
metadataRefreshMode: string;
|
||||
readingStatusProvider: string | null;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ export function LibraryActions({
|
||||
metadataProvider,
|
||||
fallbackMetadataProvider,
|
||||
metadataRefreshMode,
|
||||
readingStatusProvider,
|
||||
}: LibraryActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -40,6 +42,7 @@ export function LibraryActions({
|
||||
const newMetadataProvider = (formData.get("metadata_provider") as string) || null;
|
||||
const newFallbackProvider = (formData.get("fallback_metadata_provider") as string) || null;
|
||||
const newMetadataRefreshMode = formData.get("metadata_refresh_mode") as string;
|
||||
const newReadingStatusProvider = (formData.get("reading_status_provider") as string) || null;
|
||||
|
||||
try {
|
||||
const [response] = await Promise.all([
|
||||
@@ -58,6 +61,11 @@ export function LibraryActions({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ metadata_provider: newMetadataProvider, fallback_metadata_provider: newFallbackProvider }),
|
||||
}),
|
||||
fetch(`/api/libraries/${libraryId}/reading-status-provider`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reading_status_provider: newReadingStatusProvider }),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (response.ok) {
|
||||
@@ -255,6 +263,34 @@ export function LibraryActions({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-border/40" />
|
||||
|
||||
{/* Section: État de lecture */}
|
||||
<div className="space-y-5">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
|
||||
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
{t("libraryActions.sectionReadingStatus")}
|
||||
</h3>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t("libraryActions.readingStatusProvider")}
|
||||
</label>
|
||||
<select
|
||||
name="reading_status_provider"
|
||||
defaultValue={readingStatusProvider || ""}
|
||||
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
|
||||
>
|
||||
<option value="">{t("libraryActions.none")}</option>
|
||||
<option value="anilist">AniList</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.readingStatusProviderDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<p className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-lg break-all">
|
||||
{saveError}
|
||||
|
||||
27
apps/backoffice/app/components/LogoutButton.tsx
Normal file
27
apps/backoffice/app/components/LogoutButton.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title="Se déconnecter"
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -45,27 +45,27 @@ export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }:
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full font-medium transition-colors ${
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors disabled:opacity-50 ${
|
||||
allRead
|
||||
? "bg-green-500/15 text-green-600 dark:text-green-400 hover:bg-green-500/25"
|
||||
: "bg-muted/50 text-muted-foreground hover:bg-primary/10 hover:text-primary"
|
||||
} disabled:opacity-50`}
|
||||
? "border-green-500/30 bg-green-500/10 text-green-600 hover:bg-green-500/20"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary"
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : allRead ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
|
||||
</svg>
|
||||
{label}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
|
||||
</svg>
|
||||
{label}
|
||||
|
||||
@@ -683,13 +683,6 @@ export function MetadataSearchModal({
|
||||
{existingLink && existingLink.status === "approved" ? t("metadata.metadataButton") : t("metadata.searchButton")}
|
||||
</button>
|
||||
|
||||
{existingLink && existingLink.status === "approved" && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/30">
|
||||
<ProviderIcon provider={existingLink.provider} size={12} />
|
||||
<span>{providerLabel(existingLink.provider)}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
|
||||
47
apps/backoffice/app/components/MetricToggle.tsx
Normal file
47
apps/backoffice/app/components/MetricToggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
type Metric = "books" | "pages";
|
||||
|
||||
export function MetricToggle({
|
||||
labels,
|
||||
}: {
|
||||
labels: { books: string; pages: string };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const raw = searchParams.get("metric");
|
||||
const current: Metric = raw === "pages" ? "pages" : "books";
|
||||
|
||||
function setMetric(metric: Metric) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (metric === "books") {
|
||||
params.delete("metric");
|
||||
} else {
|
||||
params.set("metric", metric);
|
||||
}
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `?${qs}` : "/", { scroll: false });
|
||||
}
|
||||
|
||||
const options: Metric[] = ["books", "pages"];
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
|
||||
{options.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMetric(m)}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
current === m
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{labels[m]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal file
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
type Period = "day" | "week" | "month";
|
||||
|
||||
export function PeriodToggle({
|
||||
labels,
|
||||
}: {
|
||||
labels: { day: string; week: string; month: string };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const raw = searchParams.get("period");
|
||||
const current: Period = raw === "day" ? "day" : raw === "week" ? "week" : "month";
|
||||
|
||||
function setPeriod(period: Period) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (period === "month") {
|
||||
params.delete("period");
|
||||
} else {
|
||||
params.set("period", period);
|
||||
}
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `?${qs}` : "/", { scroll: false });
|
||||
}
|
||||
|
||||
const options: Period[] = ["day", "week", "month"];
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
|
||||
{options.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
current === p
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{labels[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
apps/backoffice/app/components/ReadingStatusModal.tsx
Normal file
242
apps/backoffice/app/components/ReadingStatusModal.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "./ui";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import type { AnilistMediaResultDto, AnilistSeriesLinkDto } from "../../lib/api";
|
||||
|
||||
interface ReadingStatusModalProps {
|
||||
libraryId: string;
|
||||
seriesName: string;
|
||||
readingStatusProvider: string | null;
|
||||
existingLink: AnilistSeriesLinkDto | null;
|
||||
}
|
||||
|
||||
type ModalStep = "idle" | "searching" | "results" | "linked";
|
||||
|
||||
export function ReadingStatusModal({
|
||||
libraryId,
|
||||
seriesName,
|
||||
readingStatusProvider,
|
||||
existingLink,
|
||||
}: ReadingStatusModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [step, setStep] = useState<ModalStep>(existingLink ? "linked" : "idle");
|
||||
const [query, setQuery] = useState(seriesName);
|
||||
const [candidates, setCandidates] = useState<AnilistMediaResultDto[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [link, setLink] = useState<AnilistSeriesLinkDto | null>(existingLink);
|
||||
const [isLinking, setIsLinking] = useState(false);
|
||||
const [isUnlinking, setIsUnlinking] = useState(false);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
setStep(link ? "linked" : "idle");
|
||||
setQuery(seriesName);
|
||||
setCandidates([]);
|
||||
setError(null);
|
||||
}, [link, seriesName]);
|
||||
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
|
||||
async function handleSearch() {
|
||||
setStep("searching");
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetch("/api/anilist/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || "Search failed");
|
||||
setCandidates(data);
|
||||
setStep("results");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Search failed");
|
||||
setStep("idle");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLink(candidate: AnilistMediaResultDto) {
|
||||
setIsLinking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ anilist_id: candidate.id }),
|
||||
}
|
||||
);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || "Link failed");
|
||||
setLink(data);
|
||||
setStep("linked");
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Link failed");
|
||||
} finally {
|
||||
setIsLinking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnlink() {
|
||||
setIsUnlinking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!resp.ok) throw new Error("Unlink failed");
|
||||
setLink(null);
|
||||
setStep("idle");
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Unlink failed");
|
||||
} finally {
|
||||
setIsUnlinking(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!readingStatusProvider) return null;
|
||||
|
||||
const providerLabel = readingStatusProvider === "anilist" ? "AniList" : readingStatusProvider;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
{t("readingStatus.button")}
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50" onClick={handleClose} />
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<svg className="w-5 h-5 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span className="font-semibold text-lg">{providerLabel} — {seriesName}</span>
|
||||
</div>
|
||||
<button type="button" onClick={handleClose} className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
{/* Linked state */}
|
||||
{step === "linked" && link && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/40">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">{link.anilist_title ?? seriesName}</p>
|
||||
{link.anilist_url && (
|
||||
<a href={link.anilist_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">
|
||||
{link.anilist_url}
|
||||
</a>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
ID: {link.anilist_id} · {t(`readingStatus.status.${link.status}` as any) || link.status}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-cyan-500/15 text-cyan-600 shrink-0">
|
||||
{providerLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setStep("idle")}>
|
||||
{t("readingStatus.changeLink")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleUnlink} disabled={isUnlinking} className="text-destructive hover:text-destructive">
|
||||
{isUnlinking ? t("common.loading") : t("readingStatus.unlink")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search form */}
|
||||
{(step === "idle" || step === "results") && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
|
||||
placeholder={t("readingStatus.searchPlaceholder")}
|
||||
className="flex-1 text-sm border border-border rounded-lg px-3 py-2 bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<Button onClick={handleSearch} size="sm">
|
||||
{t("readingStatus.search")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{step === "results" && candidates.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("readingStatus.noResults")}</p>
|
||||
)}
|
||||
|
||||
{step === "results" && candidates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{candidates.map((c) => (
|
||||
<div key={c.id} className="flex items-center gap-3 p-3 rounded-lg border border-border/60 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{c.title_romaji ?? c.title_english}</p>
|
||||
{c.title_english && c.title_english !== c.title_romaji && (
|
||||
<p className="text-xs text-muted-foreground truncate">{c.title_english}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{c.volumes && <span className="text-xs text-muted-foreground">{c.volumes} vol.</span>}
|
||||
{c.status && <span className="text-xs text-muted-foreground">{c.status}</span>}
|
||||
<a href={c.site_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">↗</a>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => handleLink(c)} disabled={isLinking} className="shrink-0">
|
||||
{t("readingStatus.link")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Searching spinner */}
|
||||
{step === "searching" && (
|
||||
<div className="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">{t("readingStatus.searching")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
145
apps/backoffice/app/components/ReadingUserFilter.tsx
Normal file
145
apps/backoffice/app/components/ReadingUserFilter.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { CurrentlyReadingItem, RecentlyReadItem } from "@/lib/api";
|
||||
import { getBookCoverUrl } from "@/lib/api";
|
||||
|
||||
function FilterPills({ usernames, selected, allLabel, onSelect }: {
|
||||
usernames: string[];
|
||||
selected: string | null;
|
||||
allLabel: string;
|
||||
onSelect: (u: string | null) => void;
|
||||
}) {
|
||||
if (usernames.length <= 1) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
<button
|
||||
onClick={() => onSelect(null)}
|
||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||
selected === null
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{allLabel}
|
||||
</button>
|
||||
{usernames.map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
onClick={() => onSelect(u === selected ? null : u)}
|
||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||
selected === u
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{u}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CurrentlyReadingList({
|
||||
items,
|
||||
allLabel,
|
||||
emptyLabel,
|
||||
pageProgressTemplate,
|
||||
}: {
|
||||
items: CurrentlyReadingItem[];
|
||||
allLabel: string;
|
||||
emptyLabel: string;
|
||||
/** Template with {{current}} and {{total}} placeholders */
|
||||
pageProgressTemplate: string;
|
||||
}) {
|
||||
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const filtered = selected ? items.filter((i) => i.username === selected) : items;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
||||
{filtered.slice(0, 8).map((book) => {
|
||||
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
|
||||
return (
|
||||
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.book_id)}
|
||||
alt={book.title}
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
|
||||
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
|
||||
{book.username && usernames.length > 1 && (
|
||||
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
|
||||
)}
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full bg-warning rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">{pct}%</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{pageProgressTemplate.replace("{{current}}", String(book.current_page)).replace("{{total}}", String(book.page_count))}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentlyReadList({
|
||||
items,
|
||||
allLabel,
|
||||
emptyLabel,
|
||||
}: {
|
||||
items: RecentlyReadItem[];
|
||||
allLabel: string;
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const filtered = selected ? items.filter((i) => i.username === selected) : items;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
||||
{filtered.map((book) => (
|
||||
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.book_id)}
|
||||
alt={book.title}
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
|
||||
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
|
||||
{book.username && usernames.length > 1 && (
|
||||
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{book.last_read_at}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/backoffice/app/components/TokenUserSelect.tsx
Normal file
38
apps/backoffice/app/components/TokenUserSelect.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useOptimistic, useTransition } from "react";
|
||||
|
||||
interface TokenUserSelectProps {
|
||||
tokenId: string;
|
||||
currentUserId?: string;
|
||||
users: { id: string; username: string }[];
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
noUserLabel: string;
|
||||
}
|
||||
|
||||
export function TokenUserSelect({ tokenId, currentUserId, users, action, noUserLabel }: TokenUserSelectProps) {
|
||||
const [optimisticValue, setOptimisticValue] = useOptimistic(currentUserId ?? "");
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<select
|
||||
value={optimisticValue}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
startTransition(async () => {
|
||||
setOptimisticValue(newValue);
|
||||
const fd = new FormData();
|
||||
fd.append("id", tokenId);
|
||||
fd.append("user_id", newValue);
|
||||
await action(fd);
|
||||
});
|
||||
}}
|
||||
className="flex h-8 rounded-md border border-input bg-background px-2 py-0 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">{noUserLabel}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.username}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
121
apps/backoffice/app/components/UserSwitcher.tsx
Normal file
121
apps/backoffice/app/components/UserSwitcher.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition, useRef, useEffect } from "react";
|
||||
import type { UserDto } from "@/lib/api";
|
||||
|
||||
export function UserSwitcher({
|
||||
users,
|
||||
activeUserId,
|
||||
setActiveUserAction,
|
||||
}: {
|
||||
users: UserDto[];
|
||||
activeUserId: string | null;
|
||||
setActiveUserAction: (formData: FormData) => Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const activeUser = users.find((u) => u.id === activeUserId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
function select(userId: string | null) {
|
||||
setOpen(false);
|
||||
startTransition(async () => {
|
||||
const fd = new FormData();
|
||||
fd.append("user_id", userId ?? "");
|
||||
await setActiveUserAction(fd);
|
||||
});
|
||||
}
|
||||
|
||||
if (users.length === 0) return null;
|
||||
|
||||
const isImpersonating = activeUserId !== null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
isImpersonating
|
||||
? "border-primary/40 bg-primary/10 text-primary hover:bg-primary/15"
|
||||
: "border-border/60 bg-muted/40 text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{isImpersonating ? (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="max-w-[80px] truncate hidden sm:inline">
|
||||
{activeUser ? activeUser.username : "Admin"}
|
||||
</span>
|
||||
<svg className="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1.5 w-44 rounded-lg border border-border/60 bg-popover shadow-lg z-50 overflow-hidden py-1">
|
||||
<button
|
||||
onClick={() => select(null)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
|
||||
!isImpersonating
|
||||
? "bg-accent text-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
Admin
|
||||
{!isImpersonating && (
|
||||
<svg className="w-3.5 h-3.5 ml-auto text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-border/60 my-1" />
|
||||
|
||||
{users.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => select(user.id)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
|
||||
activeUserId === user.id
|
||||
? "bg-accent text-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span className="truncate">{user.username}</span>
|
||||
{activeUserId === user.id && (
|
||||
<svg className="w-3.5 h-3.5 ml-auto text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
apps/backoffice/app/components/UsernameEdit.tsx
Normal file
73
apps/backoffice/app/components/UsernameEdit.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useOptimistic, useTransition, useRef, useState } from "react";
|
||||
|
||||
export function UsernameEdit({
|
||||
userId,
|
||||
currentUsername,
|
||||
action,
|
||||
}: {
|
||||
userId: string;
|
||||
currentUsername: string;
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
}) {
|
||||
const [optimisticUsername, setOptimisticUsername] = useOptimistic(currentUsername);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function startEdit() {
|
||||
setEditing(true);
|
||||
setTimeout(() => inputRef.current?.select(), 0);
|
||||
}
|
||||
|
||||
function submit(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === currentUsername) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
setEditing(false);
|
||||
startTransition(async () => {
|
||||
setOptimisticUsername(trimmed);
|
||||
const fd = new FormData();
|
||||
fd.append("id", userId);
|
||||
fd.append("username", trimmed);
|
||||
await action(fd);
|
||||
});
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
defaultValue={optimisticUsername}
|
||||
className="text-sm font-medium text-foreground bg-background border border-border rounded px-2 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary w-36"
|
||||
autoFocus
|
||||
onBlur={(e) => submit(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit((e.target as HTMLInputElement).value);
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={startEdit}
|
||||
className="flex items-center gap-1.5 group/edit text-left"
|
||||
title="Modifier"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">{optimisticUsername}</span>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover/edit:opacity-100 transition-opacity shrink-0"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -117,6 +117,7 @@ export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
|
||||
cbr_to_cbz: t("jobType.cbr_to_cbz"),
|
||||
metadata_batch: t("jobType.metadata_batch"),
|
||||
metadata_refresh: t("jobType.metadata_refresh"),
|
||||
reading_status_match: t("jobType.reading_status_match"),
|
||||
};
|
||||
const label = jobTypeLabels[key] ?? type;
|
||||
return <Badge variant={variant} className={className}>{label}</Badge>;
|
||||
|
||||
@@ -14,7 +14,7 @@ type ButtonVariant =
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: "sm" | "md" | "lg";
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
@@ -33,6 +33,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
};
|
||||
|
||||
const sizeStyles: Record<string, string> = {
|
||||
xs: "h-7 px-2.5 text-xs rounded-md",
|
||||
sm: "h-9 px-3 text-xs rounded-md",
|
||||
md: "h-10 px-4 py-2 text-sm rounded-md",
|
||||
lg: "h-11 px-8 text-base rounded-md",
|
||||
@@ -50,7 +51,7 @@ export function Button({
|
||||
<button
|
||||
className={`
|
||||
inline-flex items-center justify-center
|
||||
font-medium
|
||||
font-medium cursor-pointer
|
||||
transition-all duration-200 ease-out
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
|
||||
@@ -35,7 +35,9 @@ type IconName =
|
||||
| "tag"
|
||||
| "document"
|
||||
| "authors"
|
||||
| "bell";
|
||||
| "bell"
|
||||
| "link"
|
||||
| "eye";
|
||||
|
||||
type IconSize = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
@@ -90,6 +92,8 @@ const icons: Record<IconName, string> = {
|
||||
document: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
|
||||
authors: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z",
|
||||
bell: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9",
|
||||
link: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1",
|
||||
eye: "M15 12a3 3 0 11-6 0 3 3 0 016 0zm-3-9C7.477 3 3.268 6.11 1.5 12c1.768 5.89 5.977 9 10.5 9s8.732-3.11 10.5-9C20.732 6.11 16.523 3 12 3z",
|
||||
};
|
||||
|
||||
const colorClasses: Partial<Record<IconName, string>> = {
|
||||
|
||||
@@ -4,6 +4,7 @@ interface StatBoxProps {
|
||||
value: ReactNode;
|
||||
label: string;
|
||||
variant?: "default" | "primary" | "success" | "warning" | "error";
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -23,10 +24,13 @@ const valueVariantStyles: Record<string, string> = {
|
||||
error: "text-destructive",
|
||||
};
|
||||
|
||||
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
|
||||
export function StatBox({ value, label, variant = "default", icon, className = "" }: StatBoxProps) {
|
||||
return (
|
||||
<div className={`text-center p-4 rounded-lg transition-colors duration-200 ${variantStyles[variant]} ${className}`}>
|
||||
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span>
|
||||
<div className={`flex items-center justify-center gap-1.5 ${valueVariantStyles[variant]}`}>
|
||||
{icon && <span className="text-xl">{icon}</span>}
|
||||
<span className="text-3xl font-bold">{value}</span>
|
||||
</div>
|
||||
<span className={`text-xs text-muted-foreground`}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
18
apps/backoffice/app/components/ui/Tooltip.tsx
Normal file
18
apps/backoffice/app/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface TooltipProps {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tooltip({ label, children, className = "" }: TooltipProps) {
|
||||
return (
|
||||
<span className={`relative group/tooltip inline-flex ${className}`}>
|
||||
{children}
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1 text-xs text-popover-foreground bg-popover border border-border rounded-lg shadow-lg whitespace-nowrap opacity-0 scale-95 transition-all duration-150 group-hover/tooltip:opacity-100 group-hover/tooltip:scale-100 z-50">
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -19,3 +19,4 @@ export {
|
||||
} from "./Form";
|
||||
export { PageIcon, NavIcon, Icon } from "./Icon";
|
||||
export { CursorPagination, OffsetPagination } from "./Pagination";
|
||||
export { Tooltip } from "./Tooltip";
|
||||
|
||||
@@ -1,130 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "./theme-provider";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { JobsIndicator } from "./components/JobsIndicator";
|
||||
import { NavIcon, Icon } from "./components/ui";
|
||||
import { MobileNav } from "./components/MobileNav";
|
||||
import { LocaleProvider } from "../lib/i18n/context";
|
||||
import { getServerLocale, getServerTranslations } from "../lib/i18n/server";
|
||||
import type { TranslationKey } from "../lib/i18n/fr";
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { getServerLocale } from "@/lib/i18n/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "StripStream Backoffice",
|
||||
description: "Administration backoffice pour StripStream Librarian"
|
||||
};
|
||||
|
||||
type NavItem = {
|
||||
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||
labelKey: TranslationKey;
|
||||
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
|
||||
{ href: "/books", labelKey: "nav.books", icon: "books" },
|
||||
{ href: "/series", labelKey: "nav.series", icon: "series" },
|
||||
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
|
||||
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
|
||||
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
|
||||
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
|
||||
];
|
||||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
const locale = await getServerLocale();
|
||||
const { t } = await getServerTranslations();
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
|
||||
<ThemeProvider>
|
||||
<LocaleProvider initialLocale={locale}>
|
||||
{/* Header avec effet glassmorphism */}
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
|
||||
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
{/* Brand */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
|
||||
>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="StripStream"
|
||||
width={36}
|
||||
height={36}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xl font-bold tracking-tight text-foreground">
|
||||
StripStream
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground font-medium hidden md:inline">
|
||||
{t("common.backoffice")}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
|
||||
<NavIcon name={item.icon} />
|
||||
<span className="ml-2 hidden lg:inline">{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
|
||||
<JobsIndicator />
|
||||
<Link
|
||||
href="/settings"
|
||||
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
title={t("nav.settings")}
|
||||
>
|
||||
<Icon name="settings" size="md" />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
||||
{children}
|
||||
</main>
|
||||
</LocaleProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
// Navigation Link Component
|
||||
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={title}
|
||||
className="
|
||||
flex items-center
|
||||
px-2 lg:px-3 py-2
|
||||
rounded-lg
|
||||
text-sm font-medium
|
||||
text-muted-foreground
|
||||
hover:text-foreground
|
||||
hover:bg-accent
|
||||
transition-colors duration-200
|
||||
active:scale-[0.98]
|
||||
"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
168
apps/backoffice/app/login/page.tsx
Normal file
168
apps/backoffice/app/login/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState, Suspense } from "react";
|
||||
|
||||
function LoginForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const from = searchParams.get("from") || "/";
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = from;
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error || "Identifiants invalides");
|
||||
}
|
||||
} catch {
|
||||
setError("Erreur réseau");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col items-center justify-center px-4 py-16 overflow-hidden">
|
||||
|
||||
{/* Background logo */}
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover opacity-20"
|
||||
priority
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Hero */}
|
||||
<div className="relative flex flex-col items-center mb-10">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-foreground">
|
||||
StripStream{" "}
|
||||
<span className="text-primary font-light">: Librarian</span>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1.5 tracking-wide uppercase font-medium">
|
||||
Administration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form card */}
|
||||
<div
|
||||
className="relative w-full max-w-sm rounded-2xl border border-white/20 backdrop-blur-sm p-8"
|
||||
style={{ boxShadow: "0 24px 48px -12px rgb(0 0 0 / 0.18)" }}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1.5">
|
||||
Identifiant
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="admin"
|
||||
className="
|
||||
flex w-full h-11 px-4
|
||||
rounded-xl border border-input bg-background/60
|
||||
text-sm text-foreground
|
||||
placeholder:text-muted-foreground/40
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring
|
||||
disabled:opacity-50
|
||||
transition-all duration-200
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="••••••••"
|
||||
className="
|
||||
flex w-full h-11 px-4
|
||||
rounded-xl border border-input bg-background/60
|
||||
text-sm text-foreground
|
||||
placeholder:text-muted-foreground/40
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring
|
||||
disabled:opacity-50
|
||||
transition-all duration-200
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-destructive/10 border border-destructive/20 text-sm text-destructive">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="
|
||||
w-full h-11 mt-2
|
||||
inline-flex items-center justify-center gap-2
|
||||
rounded-xl font-medium text-sm
|
||||
bg-primary text-primary-foreground
|
||||
hover:bg-primary/90
|
||||
transition-all duration-200 ease-out
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
active:scale-[0.98]
|
||||
"
|
||||
style={{ boxShadow: "0 4px 16px -4px hsl(198 78% 37% / 0.5)" }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Connexion…
|
||||
</>
|
||||
) : "Se connecter"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTokens, createToken, revokeToken, deleteToken, TokenDto } from "../../lib/api";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TokensPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{ created?: string }>;
|
||||
}) {
|
||||
const { t } = await getServerTranslations();
|
||||
const params = await searchParams;
|
||||
const tokens = await listTokens().catch(() => [] as TokenDto[]);
|
||||
|
||||
async function createTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const name = formData.get("name") as string;
|
||||
const scope = formData.get("scope") as string;
|
||||
if (name) {
|
||||
const result = await createToken(name, scope);
|
||||
revalidatePath("/tokens");
|
||||
redirect(`/tokens?created=${encodeURIComponent(result.token)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await revokeToken(id);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
|
||||
async function deleteTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await deleteToken(id);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||
<svg className="w-8 h-8 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
{t("tokens.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{params.created ? (
|
||||
<Card className="mb-6 border-success/50 bg-success/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-success">{t("tokens.created")}</CardTitle>
|
||||
<CardDescription>{t("tokens.createdDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="p-4 bg-background rounded-lg text-sm font-mono text-foreground overflow-x-auto border">{params.created}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("tokens.createNew")}</CardTitle>
|
||||
<CardDescription>{t("tokens.createDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={createTokenAction}>
|
||||
<FormRow>
|
||||
<FormField className="flex-1 min-w-48">
|
||||
<FormInput name="name" placeholder={t("tokens.tokenName")} required />
|
||||
</FormField>
|
||||
<FormField className="w-32">
|
||||
<FormSelect name="scope" defaultValue="read">
|
||||
<option value="read">{t("tokens.scopeRead")}</option>
|
||||
<option value="admin">{t("tokens.scopeAdmin")}</option>
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
<Button type="submit">{t("tokens.createButton")}</Button>
|
||||
</FormRow>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/50">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.name")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.scope")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.prefix")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.status")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{tokens.map((token) => (
|
||||
<tr key={token.id} className="hover:bg-accent/50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
|
||||
{token.scope}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<code className="px-2 py-1 bg-muted rounded font-mono text-foreground">{token.prefix}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{token.revoked_at ? (
|
||||
<Badge variant="error">{t("tokens.revoked")}</Badge>
|
||||
) : (
|
||||
<Badge variant="success">{t("tokens.active")}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{!token.revoked_at ? (
|
||||
<form action={revokeTokenAction}>
|
||||
<input type="hidden" name="id" value={token.id} />
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{t("tokens.revoke")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form action={deleteTokenAction}>
|
||||
<input type="hidden" name="id" value={token.id} />
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export type LibraryDto = {
|
||||
next_metadata_refresh_at: string | null;
|
||||
series_count: number;
|
||||
thumbnail_book_ids: string[];
|
||||
reading_status_provider: string | null;
|
||||
};
|
||||
|
||||
export type IndexJobDto = {
|
||||
@@ -32,6 +33,7 @@ export type IndexJobDto = {
|
||||
removed_files: number;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
refreshed?: number;
|
||||
} | null;
|
||||
progress_percent: number | null;
|
||||
processed_files: number | null;
|
||||
@@ -44,6 +46,17 @@ export type TokenDto = {
|
||||
scope: string;
|
||||
prefix: string;
|
||||
revoked_at: string | null;
|
||||
user_id?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type UserDto = {
|
||||
id: string;
|
||||
username: string;
|
||||
token_count: number;
|
||||
books_read: number;
|
||||
books_reading: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type FolderItem = {
|
||||
@@ -128,6 +141,83 @@ export type SeriesDto = {
|
||||
series_status: string | null;
|
||||
missing_count: number | null;
|
||||
metadata_provider: string | null;
|
||||
anilist_id: number | null;
|
||||
anilist_url: string | null;
|
||||
};
|
||||
|
||||
export type AnilistStatusDto = {
|
||||
connected: boolean;
|
||||
user_id: number;
|
||||
username: string;
|
||||
site_url: string;
|
||||
};
|
||||
|
||||
export type AnilistMediaResultDto = {
|
||||
id: number;
|
||||
title_romaji: string | null;
|
||||
title_english: string | null;
|
||||
title_native: string | null;
|
||||
site_url: string;
|
||||
status: string | null;
|
||||
volumes: number | null;
|
||||
};
|
||||
|
||||
export type AnilistSeriesLinkDto = {
|
||||
library_id: string;
|
||||
series_name: string;
|
||||
anilist_id: number;
|
||||
anilist_title: string | null;
|
||||
anilist_url: string | null;
|
||||
status: string;
|
||||
linked_at: string;
|
||||
synced_at: string | null;
|
||||
};
|
||||
|
||||
export type AnilistUnlinkedSeriesDto = {
|
||||
library_id: string;
|
||||
library_name: string;
|
||||
series_name: string;
|
||||
};
|
||||
|
||||
export type AnilistSyncPreviewItemDto = {
|
||||
series_name: string;
|
||||
anilist_id: number;
|
||||
anilist_title: string | null;
|
||||
anilist_url: string | null;
|
||||
status: "PLANNING" | "CURRENT" | "COMPLETED";
|
||||
progress_volumes: number;
|
||||
books_read: number;
|
||||
book_count: number;
|
||||
};
|
||||
|
||||
export type AnilistSyncItemDto = {
|
||||
series_name: string;
|
||||
anilist_title: string | null;
|
||||
anilist_url: string | null;
|
||||
status: string;
|
||||
progress_volumes: number;
|
||||
};
|
||||
|
||||
export type AnilistSyncReportDto = {
|
||||
synced: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
items: AnilistSyncItemDto[];
|
||||
};
|
||||
|
||||
export type AnilistPullItemDto = {
|
||||
series_name: string;
|
||||
anilist_title: string | null;
|
||||
anilist_url: string | null;
|
||||
anilist_status: string;
|
||||
books_updated: number;
|
||||
};
|
||||
|
||||
export type AnilistPullReportDto = {
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
items: AnilistPullItemDto[];
|
||||
};
|
||||
|
||||
export function config() {
|
||||
@@ -150,6 +240,16 @@ export async function apiFetch<T>(
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
// Impersonation : injecte X-As-User si un user est sélectionné dans le backoffice
|
||||
try {
|
||||
const { cookies } = await import("next/headers");
|
||||
const cookieStore = await cookies();
|
||||
const asUserId = cookieStore.get("as_user_id")?.value;
|
||||
if (asUserId) headers.set("X-As-User", asUserId);
|
||||
} catch {
|
||||
// Hors contexte Next.js (tests, etc.)
|
||||
}
|
||||
|
||||
const { next: nextOptions, ...restInit } = init ?? {};
|
||||
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
@@ -267,10 +367,32 @@ export async function listTokens() {
|
||||
return apiFetch<TokenDto[]>("/admin/tokens");
|
||||
}
|
||||
|
||||
export async function createToken(name: string, scope: string) {
|
||||
export async function createToken(name: string, scope: string, userId?: string) {
|
||||
return apiFetch<{ token: string }>("/admin/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, scope }),
|
||||
body: JSON.stringify({ name, scope, ...(userId ? { user_id: userId } : {}) }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchUsers(): Promise<UserDto[]> {
|
||||
return apiFetch<UserDto[]>("/admin/users");
|
||||
}
|
||||
|
||||
export async function createUser(username: string): Promise<UserDto> {
|
||||
return apiFetch<UserDto>("/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/admin/users/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, username: string): Promise<void> {
|
||||
return apiFetch<void>(`/admin/users/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -282,6 +404,13 @@ export async function deleteToken(id: string) {
|
||||
return apiFetch<void>(`/admin/tokens/${id}/delete`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function updateToken(id: string, userId: string | null) {
|
||||
return apiFetch<void>(`/admin/tokens/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ user_id: userId || null }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchBooks(
|
||||
libraryId?: string,
|
||||
series?: string,
|
||||
@@ -556,6 +685,7 @@ export type CurrentlyReadingItem = {
|
||||
series: string | null;
|
||||
current_page: number;
|
||||
page_count: number;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type RecentlyReadItem = {
|
||||
@@ -563,11 +693,28 @@ export type RecentlyReadItem = {
|
||||
title: string;
|
||||
series: string | null;
|
||||
last_read_at: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type MonthlyReading = {
|
||||
month: string;
|
||||
books_read: number;
|
||||
pages_read: number;
|
||||
};
|
||||
|
||||
export type UserMonthlyReading = {
|
||||
month: string;
|
||||
username: string;
|
||||
books_read: number;
|
||||
pages_read: number;
|
||||
};
|
||||
|
||||
export type JobTimePoint = {
|
||||
label: string;
|
||||
scan: number;
|
||||
rebuild: number;
|
||||
thumbnail: number;
|
||||
other: number;
|
||||
};
|
||||
|
||||
export type StatsResponse = {
|
||||
@@ -576,16 +723,19 @@ export type StatsResponse = {
|
||||
currently_reading: CurrentlyReadingItem[];
|
||||
recently_read: RecentlyReadItem[];
|
||||
reading_over_time: MonthlyReading[];
|
||||
users_reading_over_time: UserMonthlyReading[];
|
||||
by_format: FormatCount[];
|
||||
by_language: LanguageCount[];
|
||||
by_library: LibraryStatsItem[];
|
||||
top_series: TopSeriesItem[];
|
||||
additions_over_time: MonthlyAdditions[];
|
||||
jobs_over_time: JobTimePoint[];
|
||||
metadata: MetadataStats;
|
||||
};
|
||||
|
||||
export async function fetchStats() {
|
||||
return apiFetch<StatsResponse>("/stats", { next: { revalidate: 30 } });
|
||||
export async function fetchStats(period?: "day" | "week" | "month") {
|
||||
const params = period && period !== "month" ? `?period=${period}` : "";
|
||||
return apiFetch<StatsResponse>(`/stats${params}`, { next: { revalidate: 30 } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -688,11 +838,13 @@ export type KomgaSyncRequest = {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
user_id: string;
|
||||
};
|
||||
|
||||
export type KomgaSyncResponse = {
|
||||
id: string;
|
||||
komga_url: string;
|
||||
user_id?: string;
|
||||
total_komga_read: number;
|
||||
matched: number;
|
||||
already_read: number;
|
||||
@@ -706,6 +858,7 @@ export type KomgaSyncResponse = {
|
||||
export type KomgaSyncReportSummary = {
|
||||
id: string;
|
||||
komga_url: string;
|
||||
user_id?: string;
|
||||
total_komga_read: number;
|
||||
matched: number;
|
||||
already_read: number;
|
||||
@@ -846,6 +999,12 @@ export async function getMetadataLink(libraryId: string, seriesName: string) {
|
||||
return apiFetch<ExternalMetadataLinkDto[]>(`/metadata/links?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function getReadingStatusLink(libraryId: string, seriesName: string) {
|
||||
return apiFetch<AnilistSeriesLinkDto>(
|
||||
`/anilist/series/${libraryId}/${encodeURIComponent(seriesName)}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMissingBooks(linkId: string) {
|
||||
return apiFetch<MissingBooksDto>(`/metadata/missing/${linkId}`);
|
||||
}
|
||||
@@ -907,6 +1066,42 @@ export async function startMetadataRefresh(libraryId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function startReadingStatusMatch(libraryId: string) {
|
||||
return apiFetch<{ id: string; status: string }>("/reading-status/match", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ library_id: libraryId }),
|
||||
});
|
||||
}
|
||||
|
||||
export type ReadingStatusMatchReportDto = {
|
||||
job_id: string;
|
||||
status: string;
|
||||
total_series: number;
|
||||
linked: number;
|
||||
already_linked: number;
|
||||
no_results: number;
|
||||
ambiguous: number;
|
||||
errors: number;
|
||||
};
|
||||
|
||||
export type ReadingStatusMatchResultDto = {
|
||||
id: string;
|
||||
series_name: string;
|
||||
status: "linked" | "already_linked" | "no_results" | "ambiguous" | "error";
|
||||
anilist_id: number | null;
|
||||
anilist_title: string | null;
|
||||
anilist_url: string | null;
|
||||
error_message: string | null;
|
||||
};
|
||||
|
||||
export async function getReadingStatusMatchReport(jobId: string) {
|
||||
return apiFetch<ReadingStatusMatchReportDto>(`/reading-status/match/${jobId}/report`);
|
||||
}
|
||||
|
||||
export async function getReadingStatusMatchResults(jobId: string) {
|
||||
return apiFetch<ReadingStatusMatchResultDto[]>(`/reading-status/match/${jobId}/results`);
|
||||
}
|
||||
|
||||
export type RefreshFieldDiff = {
|
||||
field: string;
|
||||
old?: unknown;
|
||||
|
||||
@@ -8,6 +8,7 @@ const en: Record<TranslationKey, string> = {
|
||||
"nav.libraries": "Libraries",
|
||||
"nav.jobs": "Jobs",
|
||||
"nav.tokens": "Tokens",
|
||||
"nav.users": "Users",
|
||||
"nav.settings": "Settings",
|
||||
"nav.navigation": "Navigation",
|
||||
"nav.closeMenu": "Close menu",
|
||||
@@ -70,7 +71,17 @@ const en: Record<TranslationKey, string> = {
|
||||
"dashboard.readingStatus": "Reading status",
|
||||
"dashboard.byFormat": "By format",
|
||||
"dashboard.byLibrary": "By library",
|
||||
"dashboard.booksAdded": "Books added (last 12 months)",
|
||||
"dashboard.booksAdded": "Books added",
|
||||
"dashboard.jobsOverTime": "Job runs",
|
||||
"dashboard.jobScan": "Scan",
|
||||
"dashboard.jobRebuild": "Rebuild",
|
||||
"dashboard.jobThumbnail": "Thumbnails",
|
||||
"dashboard.jobOther": "Other",
|
||||
"dashboard.periodDay": "Day",
|
||||
"dashboard.periodWeek": "Week",
|
||||
"dashboard.periodMonth": "Month",
|
||||
"dashboard.metricBooks": "Books",
|
||||
"dashboard.metricPages": "Pages",
|
||||
"dashboard.popularSeries": "Popular series",
|
||||
"dashboard.noSeries": "No series yet",
|
||||
"dashboard.unknown": "Unknown",
|
||||
@@ -84,10 +95,11 @@ const en: Record<TranslationKey, string> = {
|
||||
"dashboard.withIsbn": "With ISBN",
|
||||
"dashboard.currentlyReading": "Currently reading",
|
||||
"dashboard.recentlyRead": "Recently read",
|
||||
"dashboard.readingActivity": "Reading activity (last 12 months)",
|
||||
"dashboard.readingActivity": "Reading activity",
|
||||
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
||||
"dashboard.noCurrentlyReading": "No books in progress",
|
||||
"dashboard.noRecentlyRead": "No books read recently",
|
||||
"dashboard.allUsers": "All",
|
||||
|
||||
// Books page
|
||||
"books.title": "Books",
|
||||
@@ -185,6 +197,23 @@ const en: Record<TranslationKey, string> = {
|
||||
"libraryActions.metadataRefreshSchedule": "Auto-refresh",
|
||||
"libraryActions.metadataRefreshDesc": "Periodically re-fetch metadata for existing series",
|
||||
"libraryActions.saving": "Saving...",
|
||||
"libraryActions.sectionReadingStatus": "Reading Status",
|
||||
"libraryActions.readingStatusProvider": "Reading Status Provider",
|
||||
"libraryActions.readingStatusProviderDesc": "Syncs reading states (read / reading / planned) with an external service",
|
||||
|
||||
// Reading status modal
|
||||
"readingStatus.button": "Reading status",
|
||||
"readingStatus.linkTo": "Link to {{provider}}",
|
||||
"readingStatus.search": "Search",
|
||||
"readingStatus.searching": "Searching…",
|
||||
"readingStatus.searchPlaceholder": "Series title…",
|
||||
"readingStatus.noResults": "No results.",
|
||||
"readingStatus.link": "Link",
|
||||
"readingStatus.unlink": "Unlink",
|
||||
"readingStatus.changeLink": "Change",
|
||||
"readingStatus.status.linked": "linked",
|
||||
"readingStatus.status.synced": "synced",
|
||||
"readingStatus.status.error": "error",
|
||||
|
||||
// Library sub-page header
|
||||
"libraryHeader.libraries": "Libraries",
|
||||
@@ -230,6 +259,9 @@ const en: Record<TranslationKey, string> = {
|
||||
"jobs.generateThumbnailsDescription": "Generates thumbnails only for books that don't have one yet. Existing thumbnails are not affected. Useful after an import or if some thumbnails are missing.",
|
||||
"jobs.regenerateThumbnailsDescription": "Regenerates all thumbnails from scratch, replacing existing ones. Useful if thumbnail quality or size has changed in the configuration, or if thumbnails are corrupted.",
|
||||
"jobs.batchMetadataDescription": "Automatically searches metadata for each series in the library from the configured provider (with fallback if configured). Only results with a unique 100% confidence match are applied automatically. Already linked series are skipped. A detailed per-series report is available at the end of the job. <strong>Requires a specific library</strong> (does not work on \"All libraries\").",
|
||||
"jobs.groupReadingStatus": "Reading status",
|
||||
"jobs.matchReadingStatus": "Match series",
|
||||
"jobs.matchReadingStatusShort": "Auto-link unmatched series to the reading status provider",
|
||||
|
||||
// Jobs list
|
||||
"jobsList.id": "ID",
|
||||
@@ -250,8 +282,10 @@ const en: Record<TranslationKey, string> = {
|
||||
"jobRow.thumbnailsGenerated": "{{count}} thumbnails generated",
|
||||
"jobRow.metadataProcessed": "{{count}} series processed",
|
||||
"jobRow.metadataRefreshed": "{{count}} series refreshed",
|
||||
"jobRow.metadataLinks": "{{count}} links analyzed",
|
||||
"jobRow.errors": "{{count}} errors",
|
||||
"jobRow.view": "View",
|
||||
"jobRow.replay": "Replay",
|
||||
|
||||
// Job progress
|
||||
"jobProgress.loadingProgress": "Loading progress...",
|
||||
@@ -329,6 +363,11 @@ const en: Record<TranslationKey, string> = {
|
||||
"jobDetail.match": "Match: {{title}}",
|
||||
"jobDetail.fileErrors": "File errors ({{count}})",
|
||||
"jobDetail.fileErrorsDesc": "Errors encountered while processing files",
|
||||
"jobDetail.readingStatusMatch": "Series matching",
|
||||
"jobDetail.readingStatusMatchDesc": "Searching each series against the reading status provider",
|
||||
"jobDetail.readingStatusMatchReport": "Match report",
|
||||
"jobDetail.linked": "Linked",
|
||||
"jobDetail.ambiguous": "Ambiguous",
|
||||
|
||||
// Job types
|
||||
"jobType.rebuild": "Indexing",
|
||||
@@ -355,6 +394,9 @@ const en: Record<TranslationKey, string> = {
|
||||
"jobType.metadata_batchDesc": "Searches external metadata providers for all series in the library and automatically applies 100% confidence matches.",
|
||||
"jobType.metadata_refreshLabel": "Metadata refresh",
|
||||
"jobType.metadata_refreshDesc": "Re-downloads and updates metadata for all series already linked to an external provider.",
|
||||
"jobType.reading_status_match": "Reading status match",
|
||||
"jobType.reading_status_matchLabel": "Series matching (reading status)",
|
||||
"jobType.reading_status_matchDesc": "Automatically searches each series in the library against the configured reading status provider (e.g. AniList) and creates links for unambiguously identified series.",
|
||||
|
||||
// Status badges
|
||||
"statusBadge.extracting_pages": "Extracting pages",
|
||||
@@ -396,6 +438,21 @@ const en: Record<TranslationKey, string> = {
|
||||
"tokens.revoked": "Revoked",
|
||||
"tokens.active": "Active",
|
||||
"tokens.revoke": "Revoke",
|
||||
"tokens.user": "User",
|
||||
"tokens.noUser": "None (admin)",
|
||||
"tokens.apiTokens": "API Tokens",
|
||||
|
||||
// Users page
|
||||
"users.title": "Users",
|
||||
"users.createNew": "Create a user",
|
||||
"users.createDescription": "Create a user account for read access",
|
||||
"users.username": "Username",
|
||||
"users.createButton": "Create",
|
||||
"users.name": "Username",
|
||||
"users.tokenCount": "Tokens",
|
||||
"users.createdAt": "Created",
|
||||
"users.actions": "Actions",
|
||||
"users.noUsers": "No users",
|
||||
|
||||
// Settings page
|
||||
"settings.title": "Settings",
|
||||
@@ -576,6 +633,59 @@ const en: Record<TranslationKey, string> = {
|
||||
"settings.telegramHelpChat": "Send a message to your bot, then open <code>https://api.telegram.org/bot<TOKEN>/getUpdates</code> in your browser. The <b>chat id</b> is in <code>message.chat.id</code>.",
|
||||
"settings.telegramHelpGroup": "For a group: add the bot to the group, send a message, then check the same URL. Group IDs are negative (e.g. <code>-123456789</code>).",
|
||||
|
||||
// Settings - AniList
|
||||
"settings.anilist": "Reading status",
|
||||
"settings.anilistTitle": "AniList Sync",
|
||||
"settings.anilistDesc": "Sync your reading progress with AniList. Get a personal access token at anilist.co/settings/developer.",
|
||||
"settings.anilistToken": "Personal Access Token",
|
||||
"settings.anilistTokenPlaceholder": "AniList token...",
|
||||
"settings.anilistUserId": "AniList User ID",
|
||||
"settings.anilistUserIdPlaceholder": "Numeric (e.g. 123456)",
|
||||
"settings.anilistConnected": "Connected as",
|
||||
"settings.anilistNotConnected": "Not connected",
|
||||
"settings.anilistTestConnection": "Test connection",
|
||||
"settings.anilistLibraries": "Libraries",
|
||||
"settings.anilistLibrariesDesc": "Enable AniList sync per library",
|
||||
"settings.anilistEnabled": "AniList sync enabled",
|
||||
"settings.anilistLocalUserTitle": "Local user",
|
||||
"settings.anilistLocalUserDesc": "Select the local user whose reading progress is synced with this AniList account",
|
||||
"settings.anilistLocalUserNone": "— Select a user —",
|
||||
"settings.anilistSyncTitle": "Sync",
|
||||
"settings.anilistSyncDesc": "Push local reading progress to AniList. Rules: none read → PLANNING · at least 1 read → CURRENT (progress = volumes read) · all published volumes read (total_volumes known) → COMPLETED.",
|
||||
"settings.anilistSyncButton": "Sync to AniList",
|
||||
"settings.anilistPullButton": "Pull from AniList",
|
||||
"settings.anilistPullDesc": "Import your AniList reading list and update local reading progress. Rules: COMPLETED/CURRENT/REPEATING → books marked read up to the progress volume · PLANNING/PAUSED/DROPPED → unread.",
|
||||
"settings.anilistSyncing": "Syncing...",
|
||||
"settings.anilistPulling": "Pulling...",
|
||||
"settings.anilistSynced": "{{count}} series synced",
|
||||
"settings.anilistUpdated": "{{count}} series updated",
|
||||
"settings.anilistSkipped": "{{count}} skipped",
|
||||
"settings.anilistErrors": "{{count}} error(s)",
|
||||
"settings.anilistLinks": "AniList Links",
|
||||
"settings.anilistLinksDesc": "Series linked to AniList",
|
||||
"settings.anilistNoLinks": "No series linked to AniList",
|
||||
"settings.anilistUnlink": "Unlink",
|
||||
"settings.anilistSyncStatus": "synced",
|
||||
"settings.anilistLinkedStatus": "linked",
|
||||
"settings.anilistErrorStatus": "error",
|
||||
"settings.anilistUnlinkedTitle": "{{count}} unlinked series",
|
||||
"settings.anilistUnlinkedDesc": "These series belong to AniList-enabled libraries but have no AniList link yet. Search each one to link it.",
|
||||
"settings.anilistSearchButton": "Search",
|
||||
"settings.anilistSearchNoResults": "No AniList results.",
|
||||
"settings.anilistLinkButton": "Link",
|
||||
"settings.anilistRedirectUrlLabel": "Redirect URL to configure in your AniList app:",
|
||||
"settings.anilistRedirectUrlHint": "Paste this URL in the « Redirect URL » field of your application at anilist.co/settings/developer.",
|
||||
"settings.anilistTokenPresent": "Token present — not verified",
|
||||
"settings.anilistPreviewButton": "Preview",
|
||||
"settings.anilistPreviewing": "Loading...",
|
||||
"settings.anilistPreviewTitle": "{{count}} series to sync",
|
||||
"settings.anilistPreviewEmpty": "No series to sync (link series to AniList first).",
|
||||
"settings.anilistClientId": "AniList Client ID",
|
||||
"settings.anilistClientIdPlaceholder": "E.g. 37777",
|
||||
"settings.anilistConnectButton": "Connect with AniList",
|
||||
"settings.anilistConnectDesc": "Use OAuth to connect automatically. Find your Client ID in your AniList apps (anilist.co/settings/developer).",
|
||||
"settings.anilistManualToken": "Manual token (advanced)",
|
||||
|
||||
// Settings - Language
|
||||
"settings.language": "Language",
|
||||
"settings.languageDesc": "Choose the interface language",
|
||||
|
||||
@@ -6,6 +6,7 @@ const fr = {
|
||||
"nav.libraries": "Bibliothèques",
|
||||
"nav.jobs": "Tâches",
|
||||
"nav.tokens": "Jetons",
|
||||
"nav.users": "Utilisateurs",
|
||||
"nav.settings": "Paramètres",
|
||||
"nav.navigation": "Navigation",
|
||||
"nav.closeMenu": "Fermer le menu",
|
||||
@@ -68,7 +69,17 @@ const fr = {
|
||||
"dashboard.readingStatus": "Statut de lecture",
|
||||
"dashboard.byFormat": "Par format",
|
||||
"dashboard.byLibrary": "Par bibliothèque",
|
||||
"dashboard.booksAdded": "Livres ajoutés (12 derniers mois)",
|
||||
"dashboard.booksAdded": "Livres ajoutés",
|
||||
"dashboard.jobsOverTime": "Exécutions de jobs",
|
||||
"dashboard.jobScan": "Scan",
|
||||
"dashboard.jobRebuild": "Rebuild",
|
||||
"dashboard.jobThumbnail": "Thumbnails",
|
||||
"dashboard.jobOther": "Autre",
|
||||
"dashboard.periodDay": "Jour",
|
||||
"dashboard.periodWeek": "Semaine",
|
||||
"dashboard.periodMonth": "Mois",
|
||||
"dashboard.metricBooks": "Livres",
|
||||
"dashboard.metricPages": "Pages",
|
||||
"dashboard.popularSeries": "Séries populaires",
|
||||
"dashboard.noSeries": "Aucune série pour le moment",
|
||||
"dashboard.unknown": "Inconnu",
|
||||
@@ -82,10 +93,11 @@ const fr = {
|
||||
"dashboard.withIsbn": "Avec ISBN",
|
||||
"dashboard.currentlyReading": "En cours de lecture",
|
||||
"dashboard.recentlyRead": "Derniers livres lus",
|
||||
"dashboard.readingActivity": "Activité de lecture (12 derniers mois)",
|
||||
"dashboard.readingActivity": "Activité de lecture",
|
||||
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
||||
"dashboard.noCurrentlyReading": "Aucun livre en cours",
|
||||
"dashboard.noRecentlyRead": "Aucun livre lu récemment",
|
||||
"dashboard.allUsers": "Tous",
|
||||
|
||||
// Books page
|
||||
"books.title": "Livres",
|
||||
@@ -183,6 +195,23 @@ const fr = {
|
||||
"libraryActions.metadataRefreshSchedule": "Rafraîchissement auto",
|
||||
"libraryActions.metadataRefreshDesc": "Re-télécharger périodiquement les métadonnées existantes",
|
||||
"libraryActions.saving": "Enregistrement...",
|
||||
"libraryActions.sectionReadingStatus": "État de lecture",
|
||||
"libraryActions.readingStatusProvider": "Provider d'état de lecture",
|
||||
"libraryActions.readingStatusProviderDesc": "Synchronise les états de lecture (lu / en cours / planifié) avec un service externe",
|
||||
|
||||
// Reading status modal
|
||||
"readingStatus.button": "État de lecture",
|
||||
"readingStatus.linkTo": "Lier à {{provider}}",
|
||||
"readingStatus.search": "Rechercher",
|
||||
"readingStatus.searching": "Recherche en cours…",
|
||||
"readingStatus.searchPlaceholder": "Titre de la série…",
|
||||
"readingStatus.noResults": "Aucun résultat.",
|
||||
"readingStatus.link": "Lier",
|
||||
"readingStatus.unlink": "Délier",
|
||||
"readingStatus.changeLink": "Changer",
|
||||
"readingStatus.status.linked": "lié",
|
||||
"readingStatus.status.synced": "synchronisé",
|
||||
"readingStatus.status.error": "erreur",
|
||||
|
||||
// Library sub-page header
|
||||
"libraryHeader.libraries": "Bibliothèques",
|
||||
@@ -228,6 +257,9 @@ const fr = {
|
||||
"jobs.generateThumbnailsDescription": "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.",
|
||||
"jobs.regenerateThumbnailsDescription": "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.",
|
||||
"jobs.batchMetadataDescription": "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 \u00ab Toutes les bibliothèques \u00bb).",
|
||||
"jobs.groupReadingStatus": "Statut de lecture",
|
||||
"jobs.matchReadingStatus": "Correspondance des séries",
|
||||
"jobs.matchReadingStatusShort": "Lier automatiquement les séries non associées au provider",
|
||||
|
||||
// Jobs list
|
||||
"jobsList.id": "ID",
|
||||
@@ -248,8 +280,10 @@ const fr = {
|
||||
"jobRow.thumbnailsGenerated": "{{count}} miniatures générées",
|
||||
"jobRow.metadataProcessed": "{{count}} séries traitées",
|
||||
"jobRow.metadataRefreshed": "{{count}} séries rafraîchies",
|
||||
"jobRow.metadataLinks": "{{count}} liens analysés",
|
||||
"jobRow.errors": "{{count}} erreurs",
|
||||
"jobRow.view": "Voir",
|
||||
"jobRow.replay": "Rejouer",
|
||||
|
||||
// Job progress
|
||||
"jobProgress.loadingProgress": "Chargement de la progression...",
|
||||
@@ -327,6 +361,11 @@ const fr = {
|
||||
"jobDetail.match": "Correspondance : {{title}}",
|
||||
"jobDetail.fileErrors": "Erreurs de fichiers ({{count}})",
|
||||
"jobDetail.fileErrorsDesc": "Erreurs rencontrées lors du traitement des fichiers",
|
||||
"jobDetail.readingStatusMatch": "Correspondance des séries",
|
||||
"jobDetail.readingStatusMatchDesc": "Recherche de chaque série sur le provider de statut de lecture",
|
||||
"jobDetail.readingStatusMatchReport": "Rapport de correspondance",
|
||||
"jobDetail.linked": "Liées",
|
||||
"jobDetail.ambiguous": "Ambiguës",
|
||||
|
||||
// Job types
|
||||
"jobType.rebuild": "Indexation",
|
||||
@@ -353,6 +392,9 @@ const fr = {
|
||||
"jobType.metadata_batchDesc": "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.",
|
||||
"jobType.metadata_refreshLabel": "Rafraîchissement métadonnées",
|
||||
"jobType.metadata_refreshDesc": "Re-télécharge et met à jour les métadonnées pour toutes les séries déjà liées à un fournisseur externe.",
|
||||
"jobType.reading_status_match": "Correspondance statut lecture",
|
||||
"jobType.reading_status_matchLabel": "Correspondance des séries (statut lecture)",
|
||||
"jobType.reading_status_matchDesc": "Recherche automatiquement chaque série de la bibliothèque sur le provider de statut de lecture configuré (ex. AniList) et crée les liens pour les séries identifiées sans ambiguïté.",
|
||||
|
||||
// Status badges
|
||||
"statusBadge.extracting_pages": "Extraction des pages",
|
||||
@@ -394,6 +436,21 @@ const fr = {
|
||||
"tokens.revoked": "Révoqué",
|
||||
"tokens.active": "Actif",
|
||||
"tokens.revoke": "Révoquer",
|
||||
"tokens.user": "Utilisateur",
|
||||
"tokens.noUser": "Aucun (admin)",
|
||||
"tokens.apiTokens": "Tokens API",
|
||||
|
||||
// Users page
|
||||
"users.title": "Utilisateurs",
|
||||
"users.createNew": "Créer un utilisateur",
|
||||
"users.createDescription": "Créer un compte utilisateur pour accès lecture",
|
||||
"users.username": "Nom d'utilisateur",
|
||||
"users.createButton": "Créer",
|
||||
"users.name": "Nom d'utilisateur",
|
||||
"users.tokenCount": "Nb de jetons",
|
||||
"users.createdAt": "Créé le",
|
||||
"users.actions": "Actions",
|
||||
"users.noUsers": "Aucun utilisateur",
|
||||
|
||||
// Settings page
|
||||
"settings.title": "Paramètres",
|
||||
@@ -574,6 +631,59 @@ const fr = {
|
||||
"settings.telegramHelpChat": "Envoyez un message à votre bot, puis ouvrez <code>https://api.telegram.org/bot<TOKEN>/getUpdates</code> dans votre navigateur. Le <b>chat id</b> apparaît dans <code>message.chat.id</code>.",
|
||||
"settings.telegramHelpGroup": "Pour un groupe : ajoutez le bot au groupe, envoyez un message, puis consultez la même URL. Les IDs de groupe sont négatifs (ex: <code>-123456789</code>).",
|
||||
|
||||
// Settings - AniList
|
||||
"settings.anilist": "État de lecture",
|
||||
"settings.anilistTitle": "Synchronisation AniList",
|
||||
"settings.anilistDesc": "Synchronisez votre progression de lecture avec AniList. Obtenez un token d'accès personnel sur anilist.co/settings/developer.",
|
||||
"settings.anilistToken": "Token d'accès personnel",
|
||||
"settings.anilistTokenPlaceholder": "Token AniList...",
|
||||
"settings.anilistUserId": "ID utilisateur AniList",
|
||||
"settings.anilistUserIdPlaceholder": "Numérique (ex: 123456)",
|
||||
"settings.anilistConnected": "Connecté en tant que",
|
||||
"settings.anilistNotConnected": "Non connecté",
|
||||
"settings.anilistTestConnection": "Tester la connexion",
|
||||
"settings.anilistLibraries": "Bibliothèques",
|
||||
"settings.anilistLibrariesDesc": "Activer la synchronisation AniList pour chaque bibliothèque",
|
||||
"settings.anilistEnabled": "Sync AniList activée",
|
||||
"settings.anilistLocalUserTitle": "Utilisateur local",
|
||||
"settings.anilistLocalUserDesc": "Choisir l'utilisateur local dont la progression est synchronisée avec ce compte AniList",
|
||||
"settings.anilistLocalUserNone": "— Sélectionner un utilisateur —",
|
||||
"settings.anilistSyncTitle": "Synchronisation",
|
||||
"settings.anilistSyncDesc": "Envoyer la progression locale vers AniList. Règles : aucun lu → PLANNING · au moins 1 lu → CURRENT (progression = nbre de tomes lus) · tous les tomes publiés lus (total_volumes connu) → COMPLETED.",
|
||||
"settings.anilistSyncButton": "Synchroniser vers AniList",
|
||||
"settings.anilistPullButton": "Importer depuis AniList",
|
||||
"settings.anilistPullDesc": "Importer votre liste de lecture AniList et mettre à jour la progression locale. Règles : COMPLETED/CURRENT/REPEATING → livres marqués lus jusqu'au volume de progression · PLANNING/PAUSED/DROPPED → non lus.",
|
||||
"settings.anilistSyncing": "Synchronisation...",
|
||||
"settings.anilistPulling": "Import...",
|
||||
"settings.anilistSynced": "{{count}} série(s) synchronisée(s)",
|
||||
"settings.anilistUpdated": "{{count}} série(s) mise(s) à jour",
|
||||
"settings.anilistSkipped": "{{count}} ignorée(s)",
|
||||
"settings.anilistErrors": "{{count}} erreur(s)",
|
||||
"settings.anilistLinks": "Liens AniList",
|
||||
"settings.anilistLinksDesc": "Séries associées à AniList",
|
||||
"settings.anilistNoLinks": "Aucune série liée à AniList",
|
||||
"settings.anilistUnlink": "Délier",
|
||||
"settings.anilistSyncStatus": "synced",
|
||||
"settings.anilistLinkedStatus": "linked",
|
||||
"settings.anilistErrorStatus": "error",
|
||||
"settings.anilistUnlinkedTitle": "{{count}} série(s) non liée(s)",
|
||||
"settings.anilistUnlinkedDesc": "Ces séries appartiennent à des bibliothèques activées mais n'ont pas encore de lien AniList. Recherchez chacune pour la lier.",
|
||||
"settings.anilistSearchButton": "Rechercher",
|
||||
"settings.anilistSearchNoResults": "Aucun résultat AniList.",
|
||||
"settings.anilistLinkButton": "Lier",
|
||||
"settings.anilistRedirectUrlLabel": "URL de redirection à configurer dans votre app AniList :",
|
||||
"settings.anilistRedirectUrlHint": "Collez cette URL dans le champ « Redirect URL » de votre application sur anilist.co/settings/developer.",
|
||||
"settings.anilistTokenPresent": "Token présent — non vérifié",
|
||||
"settings.anilistPreviewButton": "Prévisualiser",
|
||||
"settings.anilistPreviewing": "Chargement...",
|
||||
"settings.anilistPreviewTitle": "{{count}} série(s) à synchroniser",
|
||||
"settings.anilistPreviewEmpty": "Aucune série à synchroniser (liez des séries à AniList d'abord).",
|
||||
"settings.anilistClientId": "Client ID AniList",
|
||||
"settings.anilistClientIdPlaceholder": "Ex: 37777",
|
||||
"settings.anilistConnectButton": "Connecter avec AniList",
|
||||
"settings.anilistConnectDesc": "Utilisez OAuth pour vous connecter automatiquement. Le Client ID se trouve dans vos applications AniList (anilist.co/settings/developer).",
|
||||
"settings.anilistManualToken": "Token manuel (avancé)",
|
||||
|
||||
// Settings - Language
|
||||
"settings.language": "Langue",
|
||||
"settings.languageDesc": "Choisir la langue de l'interface",
|
||||
|
||||
33
apps/backoffice/lib/session.ts
Normal file
33
apps/backoffice/lib/session.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const SESSION_COOKIE = "sl_session";
|
||||
|
||||
function getSecret(): Uint8Array {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
if (!secret) throw new Error("SESSION_SECRET env var is required");
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
export async function createSessionToken(): Promise<string> {
|
||||
return new SignJWT({})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("7d")
|
||||
.sign(getSecret());
|
||||
}
|
||||
|
||||
export async function verifySessionToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
await jwtVerify(token, getSecret());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return false;
|
||||
return verifySessionToken(token);
|
||||
}
|
||||
2
apps/backoffice/next-env.d.ts
vendored
2
apps/backoffice/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
107
apps/backoffice/package-lock.json
generated
107
apps/backoffice/package-lock.json
generated
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "stripstream-backoffice",
|
||||
"version": "1.23.0",
|
||||
"version": "1.28.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stripstream-backoffice",
|
||||
"version": "1.23.0",
|
||||
"version": "1.28.0",
|
||||
"dependencies": {
|
||||
"jose": "^6.2.2",
|
||||
"next": "^16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.0.0",
|
||||
@@ -143,9 +144,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -162,9 +160,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -181,9 +176,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -200,9 +192,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -219,9 +208,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -238,9 +224,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -257,9 +240,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -276,9 +256,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -295,9 +272,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -320,9 +294,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -345,9 +316,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -370,9 +338,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -395,9 +360,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -420,9 +382,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -445,9 +404,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -470,9 +426,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -659,9 +612,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -678,9 +628,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -697,9 +644,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -716,9 +660,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -950,9 +891,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -970,9 +908,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -990,9 +925,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1010,9 +942,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1179,6 +1108,7 @@
|
||||
"integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -1311,6 +1241,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -1717,6 +1648,15 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||
@@ -1860,9 +1800,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1884,9 +1821,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1908,9 +1842,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1932,9 +1863,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2147,6 +2075,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -2168,6 +2097,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
||||
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -2177,6 +2107,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
|
||||
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.25.0"
|
||||
},
|
||||
@@ -2196,6 +2127,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -2248,7 +2180,8 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stripstream-backoffice",
|
||||
"version": "1.25.0",
|
||||
"version": "2.3.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 7082",
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "next start -p 7082"
|
||||
},
|
||||
"dependencies": {
|
||||
"jose": "^6.2.2",
|
||||
"next": "^16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.0.0",
|
||||
|
||||
38
apps/backoffice/proxy.ts
Normal file
38
apps/backoffice/proxy.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { jwtVerify } from "jose";
|
||||
import { SESSION_COOKIE } from "./lib/session";
|
||||
|
||||
function getSecret(): Uint8Array {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
if (!secret) return new TextEncoder().encode("dev-insecure-secret");
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
export async function proxy(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// Skip auth for login page and auth API routes
|
||||
if (pathname.startsWith("/login") || pathname.startsWith("/api/auth")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const token = req.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (token) {
|
||||
try {
|
||||
await jwtVerify(token, getSecret());
|
||||
return NextResponse.next();
|
||||
} catch {
|
||||
// Token invalid or expired
|
||||
}
|
||||
}
|
||||
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("from", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|favicon\\.ico|logo\\.png|.*\\.svg).*)",
|
||||
],
|
||||
};
|
||||
@@ -43,6 +43,10 @@ pub struct EventToggles {
|
||||
pub metadata_refresh_completed: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub metadata_refresh_failed: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub reading_status_match_completed: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub reading_status_match_failed: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -63,6 +67,8 @@ fn default_events() -> EventToggles {
|
||||
metadata_batch_failed: true,
|
||||
metadata_refresh_completed: true,
|
||||
metadata_refresh_failed: true,
|
||||
reading_status_match_completed: true,
|
||||
reading_status_match_failed: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +167,13 @@ async fn send_telegram_photo(config: &TelegramConfig, caption: &str, photo_path:
|
||||
|
||||
/// Send a test message. Returns the result directly (not fire-and-forget).
|
||||
pub async fn send_test_message(config: &TelegramConfig) -> Result<()> {
|
||||
send_telegram(config, "🔔 <b>Stripstream Librarian</b>\nTest notification — connection OK!").await
|
||||
send_telegram(
|
||||
config,
|
||||
"🔔 <b>Stripstream Librarian</b>\n\
|
||||
━━━━━━━━━━━━━━━━━━━━\n\
|
||||
✅ Test notification — connection OK!",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -243,6 +255,16 @@ pub enum NotificationEvent {
|
||||
library_name: Option<String>,
|
||||
error: String,
|
||||
},
|
||||
// Reading status match (auto-link series to provider)
|
||||
ReadingStatusMatchCompleted {
|
||||
library_name: Option<String>,
|
||||
total_series: i32,
|
||||
linked: i32,
|
||||
},
|
||||
ReadingStatusMatchFailed {
|
||||
library_name: Option<String>,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Classify an indexer job_type string into the right event constructor category.
|
||||
@@ -265,22 +287,23 @@ fn format_event(event: &NotificationEvent) -> String {
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let duration = format_duration(*duration_seconds);
|
||||
format!(
|
||||
"📚 <b>Scan completed</b>\n\
|
||||
Library: {lib}\n\
|
||||
Type: {job_type}\n\
|
||||
New books: {}\n\
|
||||
New series: {}\n\
|
||||
Files scanned: {}\n\
|
||||
Removed: {}\n\
|
||||
Errors: {}\n\
|
||||
Duration: {duration}",
|
||||
stats.indexed_files,
|
||||
stats.new_series,
|
||||
stats.scanned_files,
|
||||
stats.removed_files,
|
||||
stats.errors,
|
||||
)
|
||||
let mut lines = vec![
|
||||
format!("✅ <b>Scan completed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("🏷 <b>Type:</b> {job_type}"),
|
||||
format!("⏱ <b>Duration:</b> {duration}"),
|
||||
String::new(),
|
||||
format!("📊 <b>Results</b>"),
|
||||
format!(" 📗 New books: <b>{}</b>", stats.indexed_files),
|
||||
format!(" 📚 New series: <b>{}</b>", stats.new_series),
|
||||
format!(" 🔎 Files scanned: <b>{}</b>", stats.scanned_files),
|
||||
format!(" 🗑 Removed: <b>{}</b>", stats.removed_files),
|
||||
];
|
||||
if stats.errors > 0 {
|
||||
lines.push(format!(" ⚠️ Errors: <b>{}</b>", stats.errors));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
NotificationEvent::ScanFailed {
|
||||
job_type,
|
||||
@@ -289,23 +312,28 @@ fn format_event(event: &NotificationEvent) -> String {
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let err = truncate(error, 200);
|
||||
format!(
|
||||
"❌ <b>Scan failed</b>\n\
|
||||
Library: {lib}\n\
|
||||
Type: {job_type}\n\
|
||||
Error: {err}"
|
||||
)
|
||||
[
|
||||
format!("🚨 <b>Scan failed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("🏷 <b>Type:</b> {job_type}"),
|
||||
String::new(),
|
||||
format!("💬 <code>{err}</code>"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::ScanCancelled {
|
||||
job_type,
|
||||
library_name,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
format!(
|
||||
"⏹ <b>Scan cancelled</b>\n\
|
||||
Library: {lib}\n\
|
||||
Type: {job_type}"
|
||||
)
|
||||
[
|
||||
format!("⏹ <b>Scan cancelled</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("🏷 <b>Type:</b> {job_type}"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::ThumbnailCompleted {
|
||||
job_type,
|
||||
@@ -314,12 +342,14 @@ fn format_event(event: &NotificationEvent) -> String {
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let duration = format_duration(*duration_seconds);
|
||||
format!(
|
||||
"🖼 <b>Thumbnails completed</b>\n\
|
||||
Library: {lib}\n\
|
||||
Type: {job_type}\n\
|
||||
Duration: {duration}"
|
||||
)
|
||||
[
|
||||
format!("✅ <b>Thumbnails completed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("🏷 <b>Type:</b> {job_type}"),
|
||||
format!("⏱ <b>Duration:</b> {duration}"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::ThumbnailFailed {
|
||||
job_type,
|
||||
@@ -328,12 +358,15 @@ fn format_event(event: &NotificationEvent) -> String {
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let err = truncate(error, 200);
|
||||
format!(
|
||||
"❌ <b>Thumbnails failed</b>\n\
|
||||
Library: {lib}\n\
|
||||
Type: {job_type}\n\
|
||||
Error: {err}"
|
||||
)
|
||||
[
|
||||
format!("🚨 <b>Thumbnails failed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("🏷 <b>Type:</b> {job_type}"),
|
||||
String::new(),
|
||||
format!("💬 <code>{err}</code>"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::ConversionCompleted {
|
||||
library_name,
|
||||
@@ -342,11 +375,13 @@ fn format_event(event: &NotificationEvent) -> String {
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("Unknown");
|
||||
let title = book_title.as_deref().unwrap_or("Unknown");
|
||||
format!(
|
||||
"🔄 <b>CBR→CBZ conversion completed</b>\n\
|
||||
Library: {lib}\n\
|
||||
Book: {title}"
|
||||
)
|
||||
[
|
||||
format!("✅ <b>CBR → CBZ conversion completed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("📖 <b>Book:</b> {title}"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::ConversionFailed {
|
||||
library_name,
|
||||
@@ -357,23 +392,28 @@ fn format_event(event: &NotificationEvent) -> String {
|
||||
let lib = library_name.as_deref().unwrap_or("Unknown");
|
||||
let title = book_title.as_deref().unwrap_or("Unknown");
|
||||
let err = truncate(error, 200);
|
||||
format!(
|
||||
"❌ <b>CBR→CBZ conversion failed</b>\n\
|
||||
Library: {lib}\n\
|
||||
Book: {title}\n\
|
||||
Error: {err}"
|
||||
)
|
||||
[
|
||||
format!("🚨 <b>CBR → CBZ conversion failed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("📖 <b>Book:</b> {title}"),
|
||||
String::new(),
|
||||
format!("💬 <code>{err}</code>"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::MetadataApproved {
|
||||
series_name,
|
||||
provider,
|
||||
..
|
||||
} => {
|
||||
format!(
|
||||
"🔗 <b>Metadata linked</b>\n\
|
||||
Series: {series_name}\n\
|
||||
Provider: {provider}"
|
||||
)
|
||||
[
|
||||
format!("✅ <b>Metadata linked</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📚 <b>Series:</b> {series_name}"),
|
||||
format!("🔗 <b>Provider:</b> {provider}"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::MetadataBatchCompleted {
|
||||
library_name,
|
||||
@@ -381,11 +421,13 @@ fn format_event(event: &NotificationEvent) -> String {
|
||||
processed,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
format!(
|
||||
"🔍 <b>Metadata batch completed</b>\n\
|
||||
Library: {lib}\n\
|
||||
Series processed: {processed}/{total_series}"
|
||||
)
|
||||
[
|
||||
format!("✅ <b>Metadata batch completed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("📊 <b>Processed:</b> {processed}/{total_series} series"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::MetadataBatchFailed {
|
||||
library_name,
|
||||
@@ -393,11 +435,14 @@ fn format_event(event: &NotificationEvent) -> String {
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let err = truncate(error, 200);
|
||||
format!(
|
||||
"❌ <b>Metadata batch failed</b>\n\
|
||||
Library: {lib}\n\
|
||||
Error: {err}"
|
||||
)
|
||||
[
|
||||
format!("🚨 <b>Metadata batch failed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
String::new(),
|
||||
format!("💬 <code>{err}</code>"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::MetadataRefreshCompleted {
|
||||
library_name,
|
||||
@@ -406,13 +451,19 @@ fn format_event(event: &NotificationEvent) -> String {
|
||||
errors,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
format!(
|
||||
"🔄 <b>Metadata refresh completed</b>\n\
|
||||
Library: {lib}\n\
|
||||
Updated: {refreshed}\n\
|
||||
Unchanged: {unchanged}\n\
|
||||
Errors: {errors}"
|
||||
)
|
||||
let mut lines = vec![
|
||||
format!("✅ <b>Metadata refresh completed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
String::new(),
|
||||
format!("📊 <b>Results</b>"),
|
||||
format!(" 🔄 Updated: <b>{refreshed}</b>"),
|
||||
format!(" ▪️ Unchanged: <b>{unchanged}</b>"),
|
||||
];
|
||||
if *errors > 0 {
|
||||
lines.push(format!(" ⚠️ Errors: <b>{errors}</b>"));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
NotificationEvent::MetadataRefreshFailed {
|
||||
library_name,
|
||||
@@ -420,11 +471,45 @@ fn format_event(event: &NotificationEvent) -> String {
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let err = truncate(error, 200);
|
||||
format!(
|
||||
"❌ <b>Metadata refresh failed</b>\n\
|
||||
Library: {lib}\n\
|
||||
Error: {err}"
|
||||
)
|
||||
[
|
||||
format!("🚨 <b>Metadata refresh failed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
String::new(),
|
||||
format!("💬 <code>{err}</code>"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::ReadingStatusMatchCompleted {
|
||||
library_name,
|
||||
total_series,
|
||||
linked,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
[
|
||||
format!("✅ <b>Reading status match completed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
String::new(),
|
||||
format!("📊 <b>Results</b>"),
|
||||
format!(" 🔗 Linked: <b>{linked}</b> / <b>{total_series}</b> series"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::ReadingStatusMatchFailed {
|
||||
library_name,
|
||||
error,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let err = truncate(error, 200);
|
||||
[
|
||||
format!("🚨 <b>Reading status match failed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
String::new(),
|
||||
format!("💬 <code>{err}</code>"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,6 +551,8 @@ fn is_event_enabled(config: &TelegramConfig, event: &NotificationEvent) -> bool
|
||||
NotificationEvent::MetadataBatchFailed { .. } => config.events.metadata_batch_failed,
|
||||
NotificationEvent::MetadataRefreshCompleted { .. } => config.events.metadata_refresh_completed,
|
||||
NotificationEvent::MetadataRefreshFailed { .. } => config.events.metadata_refresh_failed,
|
||||
NotificationEvent::ReadingStatusMatchCompleted { .. } => config.events.reading_status_match_completed,
|
||||
NotificationEvent::ReadingStatusMatchFailed { .. } => config.events.reading_status_match_failed,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
36
infra/migrations/0050_add_users.sql
Normal file
36
infra/migrations/0050_add_users.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Les tokens read ont un user_id obligatoire, les tokens admin NULL
|
||||
ALTER TABLE api_tokens ADD COLUMN user_id UUID REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- Rendre book_reading_progress par user
|
||||
ALTER TABLE book_reading_progress DROP CONSTRAINT book_reading_progress_pkey;
|
||||
ALTER TABLE book_reading_progress ADD COLUMN user_id UUID REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- Créer un user par défaut si des données existantes doivent être migrées
|
||||
INSERT INTO users (id, username)
|
||||
SELECT '00000000-0000-0000-0000-000000000001', 'default'
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM book_reading_progress WHERE user_id IS NULL
|
||||
UNION ALL
|
||||
SELECT 1 FROM api_tokens WHERE scope = 'read' AND user_id IS NULL
|
||||
);
|
||||
|
||||
-- Rattacher les anciennes progressions de lecture au user default
|
||||
UPDATE book_reading_progress
|
||||
SET user_id = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE user_id IS NULL;
|
||||
|
||||
-- Rattacher les anciens tokens read au user default
|
||||
UPDATE api_tokens
|
||||
SET user_id = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE scope = 'read' AND user_id IS NULL;
|
||||
|
||||
ALTER TABLE book_reading_progress ALTER COLUMN user_id SET NOT NULL;
|
||||
ALTER TABLE book_reading_progress ADD PRIMARY KEY (book_id, user_id);
|
||||
DROP INDEX IF EXISTS idx_book_reading_progress_status;
|
||||
CREATE INDEX idx_book_reading_progress_status ON book_reading_progress(status, user_id);
|
||||
1
infra/migrations/0051_add_user_to_komga_sync.sql
Normal file
1
infra/migrations/0051_add_user_to_komga_sync.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE komga_sync_reports ADD COLUMN user_id UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||
16
infra/migrations/0052_add_anilist_integration.sql
Normal file
16
infra/migrations/0052_add_anilist_integration.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Add AniList sync support
|
||||
ALTER TABLE libraries ADD COLUMN anilist_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE anilist_series_links (
|
||||
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
series_name TEXT NOT NULL,
|
||||
anilist_id INTEGER NOT NULL,
|
||||
anilist_title TEXT,
|
||||
anilist_url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'linked', -- 'linked' | 'synced' | 'error'
|
||||
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
synced_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (library_id, series_name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_anilist_series_links_library ON anilist_series_links(library_id);
|
||||
10
infra/migrations/0053_add_reading_status_provider.sql
Normal file
10
infra/migrations/0053_add_reading_status_provider.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Replace anilist_enabled boolean with generic reading_status_provider
|
||||
ALTER TABLE libraries ADD COLUMN reading_status_provider TEXT;
|
||||
UPDATE libraries SET reading_status_provider = 'anilist' WHERE anilist_enabled = TRUE;
|
||||
ALTER TABLE libraries DROP COLUMN anilist_enabled;
|
||||
|
||||
-- Add provider column to anilist_series_links for future multi-provider support
|
||||
ALTER TABLE anilist_series_links ADD COLUMN provider TEXT NOT NULL DEFAULT 'anilist';
|
||||
-- Update the primary key to include provider
|
||||
ALTER TABLE anilist_series_links DROP CONSTRAINT anilist_series_links_pkey;
|
||||
ALTER TABLE anilist_series_links ADD PRIMARY KEY (library_id, series_name, provider);
|
||||
30
infra/migrations/0054_fix_series_metadata_columns.sql
Normal file
30
infra/migrations/0054_fix_series_metadata_columns.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- Corrective migration: add series_metadata columns that may be missing if migrations
|
||||
-- 0022-0033 were baselined (marked applied) without actually running their SQL.
|
||||
-- All statements use IF NOT EXISTS / idempotent patterns so re-running is safe.
|
||||
|
||||
-- From 0022: add authors + publishers arrays, remove old singular publisher column
|
||||
ALTER TABLE series_metadata ADD COLUMN IF NOT EXISTS authors TEXT[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE series_metadata ADD COLUMN IF NOT EXISTS publishers TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Migrate old singular publisher value if the column still exists
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'series_metadata' AND column_name = 'publisher'
|
||||
) THEN
|
||||
UPDATE series_metadata
|
||||
SET publishers = ARRAY[publisher]
|
||||
WHERE publisher IS NOT NULL AND publisher != '' AND cardinality(publishers) = 0;
|
||||
ALTER TABLE series_metadata DROP COLUMN publisher;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- From 0030: locked_fields
|
||||
ALTER TABLE series_metadata ADD COLUMN IF NOT EXISTS locked_fields JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- From 0031: total_volumes
|
||||
ALTER TABLE series_metadata ADD COLUMN IF NOT EXISTS total_volumes INTEGER;
|
||||
|
||||
-- From 0033: status
|
||||
ALTER TABLE series_metadata ADD COLUMN IF NOT EXISTS status TEXT;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add reading_status_match job type: auto-matches library series against the
|
||||
-- configured reading status provider (e.g. AniList) and creates links.
|
||||
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', 'rescan', 'thumbnail_rebuild', 'thumbnail_regenerate', 'cbr_to_cbz', 'metadata_batch', 'metadata_refresh', 'reading_status_match'));
|
||||
16
infra/migrations/0056_add_reading_status_match_results.sql
Normal file
16
infra/migrations/0056_add_reading_status_match_results.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Table to store per-series results for reading_status_match jobs
|
||||
CREATE TABLE reading_status_match_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, -- 'linked', 'already_linked', 'no_results', 'ambiguous', 'error'
|
||||
anilist_id INTEGER,
|
||||
anilist_title TEXT,
|
||||
anilist_url TEXT,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_rsmr_job_id ON reading_status_match_results(job_id);
|
||||
CREATE INDEX idx_rsmr_status ON reading_status_match_results(status);
|
||||
383
infra/perf.sh
Executable file
383
infra/perf.sh
Executable file
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env bash
|
||||
# perf.sh — Performance benchmarks for Stripstream Librarian
|
||||
#
|
||||
# Measures:
|
||||
# - Indexer: full rebuild phase durations (discovery / extracting_pages / generating_thumbnails)
|
||||
# - Indexer: incremental rebuild speed (should skip unchanged dirs via mtime cache)
|
||||
# - Indexer: thumbnail rebuild (generate missing) and regenerate (force all)
|
||||
# - API: page render latency (cold + warm/cached), thumbnail fetch, books list, search
|
||||
#
|
||||
# Usage:
|
||||
# BASE_API=http://localhost:7080 API_TOKEN=my-token bash infra/perf.sh
|
||||
#
|
||||
# Optional env:
|
||||
# JOB_TIMEOUT seconds to wait for a job to complete (default 600)
|
||||
# BENCH_N number of API requests per endpoint for latency measurement (default 10)
|
||||
# LIBRARY_ID restrict rebuild jobs to a specific library UUID
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_API="${BASE_API:-http://127.0.0.1:7080}"
|
||||
TOKEN="${API_TOKEN:-stripstream-dev-bootstrap-token}"
|
||||
JOB_TIMEOUT="${JOB_TIMEOUT:-600}"
|
||||
BENCH_N="${BENCH_N:-10}"
|
||||
LIBRARY_ID="${LIBRARY_ID:-}"
|
||||
export BASE_API TOKEN
|
||||
|
||||
# ─── colours ────────────────────────────────────────────────────────────────
|
||||
|
||||
BOLD="\033[1m"; RESET="\033[0m"; GREEN="\033[32m"; YELLOW="\033[33m"; CYAN="\033[36m"; RED="\033[31m"
|
||||
header() { echo -e "\n${BOLD}${CYAN}▶ $*${RESET}"; }
|
||||
ok() { echo -e " ${GREEN}✓${RESET} $*"; }
|
||||
warn() { echo -e " ${YELLOW}⚠${RESET} $*"; }
|
||||
fail() { echo -e " ${RED}✗${RESET} $*"; }
|
||||
row() { printf " %-40s %s\n" "$1" "$2"; }
|
||||
|
||||
# ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
auth() { curl -fsS -H "Authorization: Bearer $TOKEN" "$@"; }
|
||||
|
||||
# Wait for job to finish; print a dot every 2s.
|
||||
wait_job() {
|
||||
local job_id="$1" label="${2:-job}" waited=0 status
|
||||
printf " waiting for %s ." "$label"
|
||||
while true; do
|
||||
status="$(auth "$BASE_API/index/jobs/$job_id" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))")"
|
||||
case "$status" in
|
||||
success) echo " done"; return 0 ;;
|
||||
failed) echo " FAILED"; fail "$label failed"; return 1 ;;
|
||||
cancelled) echo " cancelled"; fail "$label was cancelled"; return 1 ;;
|
||||
esac
|
||||
if [ "$waited" -ge "$JOB_TIMEOUT" ]; then
|
||||
echo " timeout"; fail "$label timed out after ${JOB_TIMEOUT}s (last: $status)"; return 1
|
||||
fi
|
||||
printf "."; sleep 2; waited=$((waited + 2))
|
||||
done
|
||||
}
|
||||
|
||||
# Fetch /index/jobs/:id/details and pretty-print phase durations + throughput.
|
||||
report_job() {
|
||||
local job_id="$1" label="$2"
|
||||
local details
|
||||
details="$(auth "$BASE_API/index/jobs/$job_id")"
|
||||
export PERF_DETAILS="$details" PERF_LABEL="$label"
|
||||
python3 - <<'PY'
|
||||
import json, os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def parse(s):
|
||||
if not s: return None
|
||||
# Handle both with and without microseconds
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ",
|
||||
"%Y-%m-%dT%H:%M:%S.%f+00:00", "%Y-%m-%dT%H:%M:%S+00:00"):
|
||||
try: return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
|
||||
except ValueError: pass
|
||||
return None
|
||||
|
||||
d = json.loads(os.environ["PERF_DETAILS"])
|
||||
label = os.environ["PERF_LABEL"]
|
||||
|
||||
started = parse(d.get("started_at"))
|
||||
phase2 = parse(d.get("phase2_started_at"))
|
||||
thumbs = parse(d.get("generating_thumbnails_started_at"))
|
||||
finished = parse(d.get("finished_at"))
|
||||
stats = d.get("stats_json") or {}
|
||||
total_files = d.get("total_files") or 0
|
||||
|
||||
def secs(a, b):
|
||||
if a and b: return (b - a).total_seconds()
|
||||
return None
|
||||
|
||||
def fmt(s):
|
||||
if s is None: return "n/a"
|
||||
if s < 1: return f"{s*1000:.0f}ms"
|
||||
return f"{s:.1f}s"
|
||||
|
||||
def tps(n, s):
|
||||
if n and s and s > 0: return f"{n/s:.1f}/s"
|
||||
return "n/a"
|
||||
|
||||
t_total = secs(started, finished)
|
||||
t_discover = secs(started, phase2)
|
||||
t_extract = secs(phase2, thumbs)
|
||||
t_thumbs = secs(thumbs, finished)
|
||||
indexed = stats.get("indexed_files", 0)
|
||||
|
||||
print(f" {'Total':38s} {fmt(t_total)}")
|
||||
if t_discover is not None:
|
||||
print(f" {' Phase 1 – discovery':38s} {fmt(t_discover)} ({tps(indexed, t_discover)} books indexed)")
|
||||
if t_extract is not None:
|
||||
print(f" {' Phase 2A – extracting_pages':38s} {fmt(t_extract)} ({tps(total_files, t_extract)} books/s)")
|
||||
if t_thumbs is not None:
|
||||
print(f" {' Phase 2B – generating_thumbnails':38s} {fmt(t_thumbs)} ({tps(total_files, t_thumbs)} thumbs/s)")
|
||||
print(f" {' Files indexed':38s} {indexed} / {total_files}")
|
||||
if stats.get("errors"):
|
||||
print(f" {' Errors':38s} {stats['errors']}")
|
||||
PY
|
||||
}
|
||||
|
||||
# Measure avg latency of a GET endpoint over N requests.
|
||||
measure_latency() {
|
||||
local label="$1" url="$2" n="${3:-$BENCH_N}"
|
||||
local total=0 i
|
||||
for i in $(seq 1 "$n"); do
|
||||
local t
|
||||
t=$(curl -s -o /dev/null -w '%{time_total}' -H "Authorization: Bearer $TOKEN" "$url")
|
||||
total=$(python3 -c "print($total + $t)")
|
||||
done
|
||||
local avg_ms
|
||||
avg_ms=$(python3 -c "print(round(($total / $n)*1000, 1))")
|
||||
row "$label" "${avg_ms}ms (n=$n)"
|
||||
}
|
||||
|
||||
# Build optional library_id JSON fragment
|
||||
lib_json() {
|
||||
if [ -n "$LIBRARY_ID" ]; then echo "\"library_id\":\"$LIBRARY_ID\","; else echo ""; fi
|
||||
}
|
||||
|
||||
enqueue_rebuild() {
|
||||
local full="${1:-false}"
|
||||
auth -X POST -H "Content-Type: application/json" \
|
||||
-d "{$(lib_json)\"full\":$full}" \
|
||||
"$BASE_API/index/rebuild" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])"
|
||||
}
|
||||
|
||||
enqueue_thumb_rebuild() {
|
||||
auth -X POST -H "Content-Type: application/json" \
|
||||
-d "{$(lib_json | sed 's/,$//')}" \
|
||||
"$BASE_API/index/thumbnails/rebuild" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])"
|
||||
}
|
||||
|
||||
enqueue_thumb_regen() {
|
||||
auth -X POST -H "Content-Type: application/json" \
|
||||
-d "{$(lib_json | sed 's/,$//')}" \
|
||||
"$BASE_API/index/thumbnails/regenerate" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])"
|
||||
}
|
||||
|
||||
# ─── health check ────────────────────────────────────────────────────────────
|
||||
|
||||
header "Health"
|
||||
curl -fsS "$BASE_API/health" >/dev/null && ok "API healthy"
|
||||
|
||||
BOOKS_JSON="$(auth "$BASE_API/books")"
|
||||
BOOK_COUNT="$(echo "$BOOKS_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('total',0))")"
|
||||
FIRST_BOOK_ID="$(echo "$BOOKS_JSON" | python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(items[0]['id'] if items else '')")"
|
||||
ok "Books in index: $BOOK_COUNT"
|
||||
if [ -n "$LIBRARY_ID" ]; then ok "Scoped to library: $LIBRARY_ID"; fi
|
||||
|
||||
# ─── 1. full rebuild ─────────────────────────────────────────────────────────
|
||||
|
||||
header "1 / Full Rebuild"
|
||||
JOB_FULL="$(enqueue_rebuild true)"
|
||||
ok "job $JOB_FULL"
|
||||
wait_job "$JOB_FULL" "full rebuild"
|
||||
report_job "$JOB_FULL" "full rebuild"
|
||||
|
||||
# ─── 2. incremental rebuild (dirs unchanged → mtime skip) ───────────────────
|
||||
|
||||
header "2 / Incremental Rebuild (should be fast — mtime cache)"
|
||||
JOB_INCR="$(enqueue_rebuild false)"
|
||||
ok "job $JOB_INCR"
|
||||
wait_job "$JOB_INCR" "incremental rebuild"
|
||||
report_job "$JOB_INCR" "incremental rebuild"
|
||||
|
||||
python3 - <<'PY'
|
||||
import json, os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def parse(s):
|
||||
if not s: return None
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ",
|
||||
"%Y-%m-%dT%H:%M:%S.%f+00:00", "%Y-%m-%dT%H:%M:%S+00:00"):
|
||||
try: return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
|
||||
except ValueError: pass
|
||||
return None
|
||||
|
||||
full_id = os.environ.get("PERF_FULL_ID", "")
|
||||
incr_id = os.environ.get("PERF_INCR_ID", "")
|
||||
if not full_id or not incr_id:
|
||||
exit(0)
|
||||
PY
|
||||
# Speedup ratio via env export
|
||||
export PERF_FULL_ID="$JOB_FULL" PERF_INCR_ID="$JOB_INCR"
|
||||
python3 - <<'PY'
|
||||
import json, os, subprocess
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def parse(s):
|
||||
if not s: return None
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ",
|
||||
"%Y-%m-%dT%H:%M:%S.%f+00:00", "%Y-%m-%dT%H:%M:%S+00:00"):
|
||||
try: return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
|
||||
except ValueError: pass
|
||||
return None
|
||||
|
||||
base = os.environ.get("BASE_API", "http://127.0.0.1:7080")
|
||||
token = os.environ.get("TOKEN", "")
|
||||
|
||||
import urllib.request
|
||||
|
||||
def fetch(url):
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
def duration(job_id):
|
||||
d = fetch(f"{base}/index/jobs/{job_id}")
|
||||
s = parse(d.get("started_at"))
|
||||
f = parse(d.get("finished_at"))
|
||||
if s and f: return (f - s).total_seconds()
|
||||
return None
|
||||
|
||||
t_full = duration(os.environ["PERF_FULL_ID"])
|
||||
t_incr = duration(os.environ["PERF_INCR_ID"])
|
||||
|
||||
if t_full and t_incr:
|
||||
ratio = t_full / t_incr if t_incr > 0 else 0
|
||||
print(f" {'Speedup (full vs incremental)':38s} {ratio:.1f}x ({t_full:.1f}s → {t_incr:.1f}s)")
|
||||
PY
|
||||
|
||||
# ─── 3. thumbnail rebuild (generate missing) ─────────────────────────────────
|
||||
|
||||
header "3 / Thumbnail Rebuild (generate missing only)"
|
||||
JOB_TREB="$(enqueue_thumb_rebuild)"
|
||||
ok "job $JOB_TREB"
|
||||
wait_job "$JOB_TREB" "thumbnail rebuild"
|
||||
report_job "$JOB_TREB" "thumbnail rebuild"
|
||||
|
||||
# ─── 4. thumbnail regenerate (force all) ─────────────────────────────────────
|
||||
|
||||
header "4 / Thumbnail Regenerate (force all)"
|
||||
JOB_TREG="$(enqueue_thumb_regen)"
|
||||
ok "job $JOB_TREG"
|
||||
wait_job "$JOB_TREG" "thumbnail regenerate"
|
||||
report_job "$JOB_TREG" "thumbnail regenerate"
|
||||
|
||||
# ─── 5. API latency ──────────────────────────────────────────────────────────
|
||||
|
||||
header "5 / API Latency (n=$BENCH_N requests each)"
|
||||
|
||||
measure_latency "books list" "$BASE_API/books"
|
||||
measure_latency "search (query)" "$BASE_API/search?q=marvel"
|
||||
|
||||
if [ -n "$FIRST_BOOK_ID" ]; then
|
||||
# Cold page render: clear cache between runs by using different params
|
||||
measure_latency "page render (width=1080, webp)" \
|
||||
"$BASE_API/books/$FIRST_BOOK_ID/pages/1?format=webp&quality=80&width=1080"
|
||||
|
||||
# Warm render: same URL repeated → should hit LRU cache
|
||||
measure_latency "page render (warm/cached)" \
|
||||
"$BASE_API/books/$FIRST_BOOK_ID/pages/1?format=webp&quality=80&width=1080"
|
||||
|
||||
measure_latency "thumbnail fetch" \
|
||||
"$BASE_API/books/$FIRST_BOOK_ID/thumbnail"
|
||||
else
|
||||
warn "No books found — skipping page/thumbnail latency tests"
|
||||
fi
|
||||
|
||||
# ─── 6. Page render deep-dive ────────────────────────────────────────────────
|
||||
#
|
||||
# Tests what the refactoring touches: archive reading for each format.
|
||||
# Uses width-cycling to bypass disk cache and measure real decode cost.
|
||||
# Tests: per-format cold render, sequential pages, concurrent throughput.
|
||||
|
||||
header "6 / Page Render Deep-Dive"
|
||||
|
||||
if [ -z "$FIRST_BOOK_ID" ]; then
|
||||
warn "No books found — skipping deep-dive"
|
||||
else
|
||||
|
||||
# Resolve one book per format (API may not support ?format= filter; graceful fallback)
|
||||
resolve_book_by_format() {
|
||||
local fmt="$1"
|
||||
local id
|
||||
id=$(auth "$BASE_API/books?format=$fmt&limit=1" 2>/dev/null \
|
||||
| python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(items[0]['id'] if items else '')" 2>/dev/null || echo "")
|
||||
echo "$id"
|
||||
}
|
||||
BOOK_CBZ=$(resolve_book_by_format cbz)
|
||||
BOOK_CBR=$(resolve_book_by_format cbr)
|
||||
BOOK_PDF=$(resolve_book_by_format pdf)
|
||||
|
||||
# Cold render: cycle widths (480..487) across N requests so each misses disk cache
|
||||
measure_latency_cold() {
|
||||
local label="$1" book_id="$2" n="${3:-$BENCH_N}"
|
||||
local total=0 i
|
||||
for i in $(seq 1 "$n"); do
|
||||
local w=$((480 + i)) # unique width → unique cache key
|
||||
local t
|
||||
t=$(curl -s -o /dev/null -w '%{time_total}' \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
"$BASE_API/books/$book_id/pages/1?format=webp&quality=80&width=$w")
|
||||
total=$(python3 -c "print($total + $t)")
|
||||
done
|
||||
local avg_ms
|
||||
avg_ms=$(python3 -c "print(round(($total / $n)*1000, 1))")
|
||||
row "$label" "${avg_ms}ms (cold, n=$n)"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo " Cold render latency by format (cache-busted widths):"
|
||||
[ -n "$BOOK_CBZ" ] && measure_latency_cold "CBZ page 1 (cold)" "$BOOK_CBZ" \
|
||||
|| warn "No CBZ book found"
|
||||
[ -n "$BOOK_CBR" ] && measure_latency_cold "CBR page 1 (cold)" "$BOOK_CBR" \
|
||||
|| warn "No CBR book found"
|
||||
[ -n "$BOOK_PDF" ] && measure_latency_cold "PDF page 1 (cold)" "$BOOK_PDF" \
|
||||
|| warn "No PDF book found"
|
||||
|
||||
# Warm render: same URL repeated → LRU / disk cache
|
||||
echo ""
|
||||
echo " Warm render (disk cache, same URL):"
|
||||
# One cold request first, then N warm
|
||||
curl -s -o /dev/null -H "Authorization: Bearer $TOKEN" \
|
||||
"$BASE_API/books/$FIRST_BOOK_ID/pages/1?format=webp&quality=80&width=600" >/dev/null
|
||||
measure_latency "page render (warm/disk-cached)" \
|
||||
"$BASE_API/books/$FIRST_BOOK_ID/pages/1?format=webp&quality=80&width=600"
|
||||
|
||||
# Sequential pages: measures archive open+close overhead across consecutive pages
|
||||
echo ""
|
||||
echo " Sequential pages (pages 1–10, same book, cold widths):"
|
||||
SEQ_TOTAL=0
|
||||
for PAGE in $(seq 1 10); do
|
||||
local_t=$(curl -s -o /dev/null -w '%{time_total}' \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
"$BASE_API/books/$FIRST_BOOK_ID/pages/$PAGE?format=webp&quality=80&width=$((500 + PAGE))")
|
||||
local_ms=$(python3 -c "print(round($local_t*1000, 1))")
|
||||
SEQ_TOTAL=$(python3 -c "print($SEQ_TOTAL + $local_t)")
|
||||
row " page $PAGE" "${local_ms}ms"
|
||||
done
|
||||
SEQ_AVG=$(python3 -c "print(round($SEQ_TOTAL / 10 * 1000, 1))")
|
||||
row " avg (10 pages)" "${SEQ_AVG}ms"
|
||||
|
||||
# Concurrent throughput: N requests in parallel → measures semaphore + CPU saturation
|
||||
CONC_N="${CONC_N:-10}"
|
||||
echo ""
|
||||
echo " Concurrent rendering ($CONC_N simultaneous requests, cold widths):"
|
||||
CONC_START=$(date +%s%3N)
|
||||
PIDS=()
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
curl -s -o /dev/null \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
"$BASE_API/books/$FIRST_BOOK_ID/pages/$i?format=webp&quality=80&width=$((550 + i))" &
|
||||
PIDS+=($!)
|
||||
done
|
||||
for PID in "${PIDS[@]}"; do wait "$PID" 2>/dev/null || true; done
|
||||
CONC_END=$(date +%s%3N)
|
||||
CONC_MS=$((CONC_END - CONC_START))
|
||||
CONC_PER=$(python3 -c "print(round($CONC_MS / $CONC_N, 1))")
|
||||
row " wall time (${CONC_N} pages in parallel)" "${CONC_MS}ms (~${CONC_PER}ms/page)"
|
||||
|
||||
fi
|
||||
|
||||
# ─── summary ─────────────────────────────────────────────────────────────────
|
||||
|
||||
header "Summary"
|
||||
ok "Full rebuild job: $JOB_FULL"
|
||||
ok "Incremental rebuild job: $JOB_INCR"
|
||||
ok "Thumbnail rebuild job: $JOB_TREB"
|
||||
ok "Thumbnail regenerate job: $JOB_TREG"
|
||||
echo -e "\n${BOLD}perf done${RESET}"
|
||||
Reference in New Issue
Block a user