feat: AniList reading status integration
- Add full AniList integration: OAuth connect, series linking, push/pull sync - Push: PLANNING/CURRENT/COMPLETED based on books read vs total_volumes (never auto-complete from owned books alone) - Pull: update local reading progress from AniList list (per-user) - Detailed sync/pull reports with per-series status and progress - Local user selector in settings to scope sync to a specific user - Rename "AniList" tab/buttons to generic "État de lecture" / "Reading status" - Make Bédéthèque and AniList badges clickable links on series detail page - Fix ON CONFLICT error on series link (provider column in PK) - Migration 0054: fix series_metadata missing columns (authors, publishers, locked_fields, total_volumes, status) - Align button heights on series detail page; move MarkSeriesReadButton to action row Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
972
apps/api/src/anilist.rs
Normal file
972
apps/api/src/anilist.rs
Normal file
@@ -0,0 +1,972 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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)
|
||||||
|
async fn load_anilist_settings(pool: &sqlx::PgPool) -> Result<(String, 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()
|
||||||
|
.ok_or_else(|| ApiError::bad_request("AniList user_id not configured"))?;
|
||||||
|
|
||||||
|
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,
|
||||||
|
sm.total_volumes
|
||||||
|
FROM books b
|
||||||
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND brp.user_id = $3
|
||||||
|
LEFT JOIN series_metadata sm ON sm.library_id = $1 AND sm.series_name = $2
|
||||||
|
WHERE b.library_id = $1 AND COALESCE(NULLIF(b.series, ''), 'unclassified') = $2
|
||||||
|
GROUP BY sm.total_volumes
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.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,
|
||||||
|
sm.total_volumes
|
||||||
|
FROM books b
|
||||||
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND brp.user_id = $3
|
||||||
|
LEFT JOIN series_metadata sm ON sm.library_id = $1 AND sm.series_name = $2
|
||||||
|
WHERE b.library_id = $1 AND COALESCE(NULLIF(b.series, ''), 'unclassified') = $2
|
||||||
|
GROUP BY sm.total_volumes
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.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 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))
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ pub struct LibraryResponse {
|
|||||||
/// First book IDs from up to 5 distinct series (for thumbnail fan display)
|
/// First book IDs from up to 5 distinct series (for thumbnail fan display)
|
||||||
#[schema(value_type = Vec<String>)]
|
#[schema(value_type = Vec<String>)]
|
||||||
pub thumbnail_book_ids: Vec<Uuid>,
|
pub thumbnail_book_ids: Vec<Uuid>,
|
||||||
|
pub reading_status_provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
@@ -53,7 +54,7 @@ pub struct CreateLibraryRequest {
|
|||||||
)]
|
)]
|
||||||
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled, l.metadata_provider, 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(*) 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,
|
(SELECT COUNT(DISTINCT COALESCE(NULLIF(b.series, ''), 'unclassified')) FROM books b WHERE b.library_id = l.id) as series_count,
|
||||||
COALESCE((
|
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"),
|
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||||
thumbnail_book_ids: row.get("thumbnail_book_ids"),
|
thumbnail_book_ids: row.get("thumbnail_book_ids"),
|
||||||
|
reading_status_provider: row.get("reading_status_provider"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -149,6 +151,7 @@ pub async fn create_library(
|
|||||||
metadata_refresh_mode: "manual".to_string(),
|
metadata_refresh_mode: "manual".to_string(),
|
||||||
next_metadata_refresh_at: None,
|
next_metadata_refresh_at: None,
|
||||||
thumbnail_book_ids: vec![],
|
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 watcher_enabled = input.watcher_enabled.unwrap_or(false);
|
||||||
|
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4, watcher_enabled = $5, 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(library_id)
|
||||||
.bind(input.monitor_enabled)
|
.bind(input.monitor_enabled)
|
||||||
@@ -389,6 +392,7 @@ pub async fn update_monitoring(
|
|||||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||||
thumbnail_book_ids,
|
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 fallback = input.fallback_metadata_provider.as_deref().filter(|s| !s.is_empty());
|
||||||
|
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE libraries SET metadata_provider = $2, 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(library_id)
|
||||||
.bind(provider)
|
.bind(provider)
|
||||||
@@ -473,5 +477,44 @@ pub async fn update_metadata_provider(
|
|||||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||||
thumbnail_book_ids,
|
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,3 +1,4 @@
|
|||||||
|
mod anilist;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod authors;
|
mod authors;
|
||||||
mod books;
|
mod books;
|
||||||
@@ -94,6 +95,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/libraries/:id", delete(libraries::delete_library))
|
.route("/libraries/:id", delete(libraries::delete_library))
|
||||||
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
|
.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/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", axum::routing::patch(books::update_book))
|
||||||
.route("/books/:id/convert", axum::routing::post(books::convert_book))
|
.route("/books/:id/convert", axum::routing::post(books::convert_book))
|
||||||
.route("/libraries/:library_id/series/:name", axum::routing::patch(series::update_series))
|
.route("/libraries/:library_id/series/:name", axum::routing::patch(series::update_series))
|
||||||
@@ -120,6 +122,17 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
|
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
|
||||||
.route("/komga/reports", get(komga::list_sync_reports))
|
.route("/komga/reports", get(komga::list_sync_reports))
|
||||||
.route("/komga/reports/:id", get(komga::get_sync_report))
|
.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/search", axum::routing::post(metadata::search_metadata))
|
||||||
.route("/metadata/match", axum::routing::post(metadata::create_metadata_match))
|
.route("/metadata/match", axum::routing::post(metadata::create_metadata_match))
|
||||||
.route("/metadata/approve/:id", axum::routing::post(metadata::approve_metadata))
|
.route("/metadata/approve/:id", axum::routing::post(metadata::approve_metadata))
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ pub struct SeriesItem {
|
|||||||
pub series_status: Option<String>,
|
pub series_status: Option<String>,
|
||||||
pub missing_count: Option<i64>,
|
pub missing_count: Option<i64>,
|
||||||
pub metadata_provider: Option<String>,
|
pub metadata_provider: Option<String>,
|
||||||
|
pub anilist_id: Option<i32>,
|
||||||
|
pub anilist_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -202,12 +204,15 @@ pub async fn list_series(
|
|||||||
sb.id as first_book_id,
|
sb.id as first_book_id,
|
||||||
sm.status as series_status,
|
sm.status as series_status,
|
||||||
mc.missing_count,
|
mc.missing_count,
|
||||||
ml.provider as metadata_provider
|
ml.provider as metadata_provider,
|
||||||
|
asl.anilist_id,
|
||||||
|
asl.anilist_url
|
||||||
FROM series_counts sc
|
FROM series_counts sc
|
||||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
||||||
LEFT JOIN series_metadata sm ON sm.library_id = $1 AND sm.name = sc.name
|
LEFT JOIN 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 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 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
|
WHERE TRUE
|
||||||
{q_cond}
|
{q_cond}
|
||||||
{count_rs_cond}
|
{count_rs_cond}
|
||||||
@@ -269,6 +274,8 @@ pub async fn list_series(
|
|||||||
series_status: row.get("series_status"),
|
series_status: row.get("series_status"),
|
||||||
missing_count: row.get("missing_count"),
|
missing_count: row.get("missing_count"),
|
||||||
metadata_provider: row.get("metadata_provider"),
|
metadata_provider: row.get("metadata_provider"),
|
||||||
|
anilist_id: row.get("anilist_id"),
|
||||||
|
anilist_url: row.get("anilist_url"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -496,12 +503,15 @@ pub async fn list_all_series(
|
|||||||
sb.library_id,
|
sb.library_id,
|
||||||
sm.status as series_status,
|
sm.status as series_status,
|
||||||
mc.missing_count,
|
mc.missing_count,
|
||||||
ml.provider as metadata_provider
|
ml.provider as metadata_provider,
|
||||||
|
asl.anilist_id,
|
||||||
|
asl.anilist_url
|
||||||
FROM series_counts sc
|
FROM series_counts sc
|
||||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
||||||
LEFT JOIN series_metadata sm ON sm.library_id = sc.library_id AND sm.name = sc.name
|
LEFT JOIN 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 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 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
|
WHERE TRUE
|
||||||
{q_cond}
|
{q_cond}
|
||||||
{rs_cond}
|
{rs_cond}
|
||||||
@@ -566,6 +576,8 @@ pub async fn list_all_series(
|
|||||||
series_status: row.get("series_status"),
|
series_status: row.get("series_status"),
|
||||||
missing_count: row.get("missing_count"),
|
missing_count: row.get("missing_count"),
|
||||||
metadata_provider: row.get("metadata_provider"),
|
metadata_provider: row.get("metadata_provider"),
|
||||||
|
anilist_id: row.get("anilist_id"),
|
||||||
|
anilist_url: row.get("anilist_url"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -711,6 +723,8 @@ pub async fn ongoing_series(
|
|||||||
series_status: None,
|
series_status: None,
|
||||||
missing_count: None,
|
missing_count: None,
|
||||||
metadata_provider: None,
|
metadata_provider: None,
|
||||||
|
anilist_id: None,
|
||||||
|
anilist_url: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
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,8 @@
|
|||||||
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "@/lib/api";
|
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, getReadingStatusLink, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto, AnilistSeriesLinkDto } from "@/lib/api";
|
||||||
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
|
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
|
||||||
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
|
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
|
||||||
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
|
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
|
||||||
|
import { ProviderIcon, providerLabel } from "@/app/components/ProviderIcon";
|
||||||
import nextDynamic from "next/dynamic";
|
import nextDynamic from "next/dynamic";
|
||||||
import { OffsetPagination } from "@/app/components/ui";
|
import { OffsetPagination } from "@/app/components/ui";
|
||||||
import { SafeHtml } from "@/app/components/SafeHtml";
|
import { SafeHtml } from "@/app/components/SafeHtml";
|
||||||
@@ -14,6 +15,9 @@ const EditSeriesForm = nextDynamic(
|
|||||||
const MetadataSearchModal = nextDynamic(
|
const MetadataSearchModal = nextDynamic(
|
||||||
() => import("@/app/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(
|
const ProwlarrSearchModal = nextDynamic(
|
||||||
() => import("@/app/components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
|
() => import("@/app/components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
|
||||||
);
|
);
|
||||||
@@ -37,7 +41,7 @@ export default async function SeriesDetailPage({
|
|||||||
|
|
||||||
const seriesName = decodeURIComponent(name);
|
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)),
|
fetchLibraries().then((libs) => libs.find((l) => l.id === id)),
|
||||||
fetchBooks(id, seriesName, page, limit).catch(() => ({
|
fetchBooks(id, seriesName, page, limit).catch(() => ({
|
||||||
items: [] as BookDto[],
|
items: [] as BookDto[],
|
||||||
@@ -47,6 +51,7 @@ export default async function SeriesDetailPage({
|
|||||||
})),
|
})),
|
||||||
fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null),
|
fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null),
|
||||||
getMetadataLink(id, seriesName).catch(() => [] as ExternalMetadataLinkDto[]),
|
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;
|
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}
|
{t(`seriesStatus.${seriesMeta.status}` as any) || seriesMeta.status}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{seriesMeta?.description && (
|
{seriesMeta?.description && (
|
||||||
@@ -206,6 +242,12 @@ export default async function SeriesDetailPage({
|
|||||||
existingLink={existingLink}
|
existingLink={existingLink}
|
||||||
initialMissing={missingData}
|
initialMissing={missingData}
|
||||||
/>
|
/>
|
||||||
|
<ReadingStatusModal
|
||||||
|
libraryId={id}
|
||||||
|
seriesName={seriesName}
|
||||||
|
readingStatusProvider={library.reading_status_provider ?? null}
|
||||||
|
existingLink={readingStatusLink}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export default async function LibrariesPage() {
|
|||||||
metadataProvider={lib.metadata_provider}
|
metadataProvider={lib.metadata_provider}
|
||||||
fallbackMetadataProvider={lib.fallback_metadata_provider}
|
fallbackMetadataProvider={lib.fallback_metadata_provider}
|
||||||
metadataRefreshMode={lib.metadata_refresh_mode}
|
metadataRefreshMode={lib.metadata_refresh_mode}
|
||||||
|
readingStatusProvider={lib.reading_status_provider}
|
||||||
/>
|
/>
|
||||||
<form>
|
<form>
|
||||||
<input type="hidden" name="id" value={lib.id} />
|
<input type="hidden" name="id" value={lib.id} />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ProviderIcon } from "@/app/components/ProviderIcon";
|
import { ProviderIcon } from "@/app/components/ProviderIcon";
|
||||||
|
import { ExternalLinkBadge } from "@/app/components/ExternalLinkBadge";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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">
|
<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) => (
|
{series.map((s) => (
|
||||||
<Link
|
<div key={s.name} className="group relative">
|
||||||
key={s.name}
|
|
||||||
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
|
|
||||||
className="group"
|
|
||||||
>
|
|
||||||
<div
|
<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" : ""
|
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">
|
<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" : "" })}
|
{t("series.readCount", { read: String(s.books_read_count), total: String(s.book_count), plural: s.book_count !== 1 ? "s" : "" })}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="relative z-20">
|
||||||
<MarkSeriesReadButton
|
<MarkSeriesReadButton
|
||||||
seriesName={s.name}
|
seriesName={s.name}
|
||||||
bookCount={s.book_count}
|
bookCount={s.book_count}
|
||||||
booksReadCount={s.books_read_count}
|
booksReadCount={s.books_read_count}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{s.series_status && (
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||||
s.series_status === "ongoing" ? "bg-blue-500/15 text-blue-600" :
|
s.series_status === "ongoing" ? "bg-blue-500/15 text-blue-600" :
|
||||||
@@ -177,10 +176,24 @@ export default async function SeriesPage({
|
|||||||
<ProviderIcon provider={s.metadata_provider} size={10} />
|
<ProviderIcon provider={s.metadata_provider} size={10} />
|
||||||
</span>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "@/app/components/ui";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "@/app/components/ui";
|
||||||
import { ProviderIcon } from "@/app/components/ProviderIcon";
|
import { ProviderIcon } from "@/app/components/ProviderIcon";
|
||||||
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto, UserDto } from "@/lib/api";
|
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 { useTranslation } from "@/lib/i18n/context";
|
||||||
import type { Locale } from "@/lib/i18n/types";
|
import type { Locale } from "@/lib/i18n/types";
|
||||||
|
|
||||||
@@ -12,9 +12,10 @@ interface SettingsPageProps {
|
|||||||
initialCacheStats: CacheStats;
|
initialCacheStats: CacheStats;
|
||||||
initialThumbnailStats: ThumbnailStats;
|
initialThumbnailStats: ThumbnailStats;
|
||||||
users: UserDto[];
|
users: UserDto[];
|
||||||
|
initialTab?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users }: SettingsPageProps) {
|
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users, initialTab }: SettingsPageProps) {
|
||||||
const { t, locale, setLocale } = useTranslation();
|
const { t, locale, setLocale } = useTranslation();
|
||||||
const [settings, setSettings] = useState<Settings>({
|
const [settings, setSettings] = useState<Settings>({
|
||||||
...initialSettings,
|
...initialSettings,
|
||||||
@@ -153,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 = [
|
const tabs = [
|
||||||
{ id: "general" as const, label: t("settings.general"), icon: "settings" as const },
|
{ 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: "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 },
|
{ id: "notifications" as const, label: t("settings.notifications"), icon: "bell" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -848,6 +852,10 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
{/* Telegram Notifications */}
|
{/* Telegram Notifications */}
|
||||||
<TelegramCard handleUpdateSetting={handleUpdateSetting} />
|
<TelegramCard handleUpdateSetting={handleUpdateSetting} />
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
{activeTab === "anilist" && (
|
||||||
|
<AnilistTab handleUpdateSetting={handleUpdateSetting} users={users} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1753,3 +1761,407 @@ function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
|
|||||||
</Card>
|
</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>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import SettingsPage from "./SettingsPage";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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(() => ({
|
const settings = await getSettings().catch(() => ({
|
||||||
image_processing: { format: "webp", quality: 85, filter: "lanczos3", max_width: 2160 },
|
image_processing: { format: "webp", quality: 85, filter: "lanczos3", max_width: 2160 },
|
||||||
cache: { enabled: true, directory: "/tmp/stripstream-image-cache", max_size_mb: 10000 },
|
cache: { enabled: true, directory: "/tmp/stripstream-image-cache", max_size_mb: 10000 },
|
||||||
@@ -25,5 +26,5 @@ export default async function SettingsPageWrapper() {
|
|||||||
|
|
||||||
const users = await fetchUsers().catch(() => []);
|
const users = await fetchUsers().catch(() => []);
|
||||||
|
|
||||||
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} users={users} />;
|
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} users={users} initialTab={tab} />;
|
||||||
}
|
}
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ interface LibraryActionsProps {
|
|||||||
metadataProvider: string | null;
|
metadataProvider: string | null;
|
||||||
fallbackMetadataProvider: string | null;
|
fallbackMetadataProvider: string | null;
|
||||||
metadataRefreshMode: string;
|
metadataRefreshMode: string;
|
||||||
|
readingStatusProvider: string | null;
|
||||||
onUpdate?: () => void;
|
onUpdate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export function LibraryActions({
|
|||||||
metadataProvider,
|
metadataProvider,
|
||||||
fallbackMetadataProvider,
|
fallbackMetadataProvider,
|
||||||
metadataRefreshMode,
|
metadataRefreshMode,
|
||||||
|
readingStatusProvider,
|
||||||
}: LibraryActionsProps) {
|
}: LibraryActionsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -40,6 +42,7 @@ export function LibraryActions({
|
|||||||
const newMetadataProvider = (formData.get("metadata_provider") as string) || null;
|
const newMetadataProvider = (formData.get("metadata_provider") as string) || null;
|
||||||
const newFallbackProvider = (formData.get("fallback_metadata_provider") as string) || null;
|
const newFallbackProvider = (formData.get("fallback_metadata_provider") as string) || null;
|
||||||
const newMetadataRefreshMode = formData.get("metadata_refresh_mode") as string;
|
const newMetadataRefreshMode = formData.get("metadata_refresh_mode") as string;
|
||||||
|
const newReadingStatusProvider = (formData.get("reading_status_provider") as string) || null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [response] = await Promise.all([
|
const [response] = await Promise.all([
|
||||||
@@ -58,6 +61,11 @@ export function LibraryActions({
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ metadata_provider: newMetadataProvider, fallback_metadata_provider: newFallbackProvider }),
|
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) {
|
if (response.ok) {
|
||||||
@@ -255,6 +263,34 @@ export function LibraryActions({
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{saveError && (
|
||||||
<p className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-lg break-all">
|
<p className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-lg break-all">
|
||||||
{saveError}
|
{saveError}
|
||||||
|
|||||||
@@ -45,27 +45,27 @@ export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }:
|
|||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={loading}
|
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
|
allRead
|
||||||
? "bg-green-500/15 text-green-600 dark:text-green-400 hover:bg-green-500/25"
|
? "border-green-500/30 bg-green-500/10 text-green-600 hover:bg-green-500/20"
|
||||||
: "bg-muted/50 text-muted-foreground hover:bg-primary/10 hover:text-primary"
|
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary"
|
||||||
} disabled:opacity-50`}
|
}`}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{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" />
|
<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" />
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
</svg>
|
</svg>
|
||||||
) : allRead ? (
|
) : 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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
|
||||||
</svg>
|
</svg>
|
||||||
{label}
|
{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" />
|
<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>
|
</svg>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -683,13 +683,6 @@ export function MetadataSearchModal({
|
|||||||
{existingLink && existingLink.status === "approved" ? t("metadata.metadataButton") : t("metadata.searchButton")}
|
{existingLink && existingLink.status === "approved" ? t("metadata.metadataButton") : t("metadata.searchButton")}
|
||||||
</button>
|
</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}
|
{modal}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,7 +35,9 @@ type IconName =
|
|||||||
| "tag"
|
| "tag"
|
||||||
| "document"
|
| "document"
|
||||||
| "authors"
|
| "authors"
|
||||||
| "bell";
|
| "bell"
|
||||||
|
| "link"
|
||||||
|
| "eye";
|
||||||
|
|
||||||
type IconSize = "sm" | "md" | "lg" | "xl";
|
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",
|
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",
|
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",
|
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>> = {
|
const colorClasses: Partial<Record<IconName, string>> = {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type LibraryDto = {
|
|||||||
next_metadata_refresh_at: string | null;
|
next_metadata_refresh_at: string | null;
|
||||||
series_count: number;
|
series_count: number;
|
||||||
thumbnail_book_ids: string[];
|
thumbnail_book_ids: string[];
|
||||||
|
reading_status_provider: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IndexJobDto = {
|
export type IndexJobDto = {
|
||||||
@@ -140,6 +141,83 @@ export type SeriesDto = {
|
|||||||
series_status: string | null;
|
series_status: string | null;
|
||||||
missing_count: number | null;
|
missing_count: number | null;
|
||||||
metadata_provider: string | 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() {
|
export function config() {
|
||||||
@@ -919,6 +997,12 @@ export async function getMetadataLink(libraryId: string, seriesName: string) {
|
|||||||
return apiFetch<ExternalMetadataLinkDto[]>(`/metadata/links?${params.toString()}`);
|
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) {
|
export async function getMissingBooks(linkId: string) {
|
||||||
return apiFetch<MissingBooksDto>(`/metadata/missing/${linkId}`);
|
return apiFetch<MissingBooksDto>(`/metadata/missing/${linkId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,23 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"libraryActions.metadataRefreshSchedule": "Auto-refresh",
|
"libraryActions.metadataRefreshSchedule": "Auto-refresh",
|
||||||
"libraryActions.metadataRefreshDesc": "Periodically re-fetch metadata for existing series",
|
"libraryActions.metadataRefreshDesc": "Periodically re-fetch metadata for existing series",
|
||||||
"libraryActions.saving": "Saving...",
|
"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
|
// Library sub-page header
|
||||||
"libraryHeader.libraries": "Libraries",
|
"libraryHeader.libraries": "Libraries",
|
||||||
@@ -602,6 +619,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.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.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
|
||||||
"settings.language": "Language",
|
"settings.language": "Language",
|
||||||
"settings.languageDesc": "Choose the interface language",
|
"settings.languageDesc": "Choose the interface language",
|
||||||
|
|||||||
@@ -193,6 +193,23 @@ const fr = {
|
|||||||
"libraryActions.metadataRefreshSchedule": "Rafraîchissement auto",
|
"libraryActions.metadataRefreshSchedule": "Rafraîchissement auto",
|
||||||
"libraryActions.metadataRefreshDesc": "Re-télécharger périodiquement les métadonnées existantes",
|
"libraryActions.metadataRefreshDesc": "Re-télécharger périodiquement les métadonnées existantes",
|
||||||
"libraryActions.saving": "Enregistrement...",
|
"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
|
// Library sub-page header
|
||||||
"libraryHeader.libraries": "Bibliothèques",
|
"libraryHeader.libraries": "Bibliothèques",
|
||||||
@@ -600,6 +617,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.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.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
|
||||||
"settings.language": "Langue",
|
"settings.language": "Langue",
|
||||||
"settings.languageDesc": "Choisir la langue de l'interface",
|
"settings.languageDesc": "Choisir la langue de l'interface",
|
||||||
|
|||||||
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;
|
||||||
Reference in New Issue
Block a user