feat: multi-user reading progress & backoffice impersonation
- Scope all reading progress (books, series, stats) by user via Option<Extension<AuthUser>> — admin sees aggregate, read token sees own data - Fix duplicate book rows when admin views lists (IS NOT NULL guard on JOIN) - Add X-As-User header support: admin can impersonate any user from backoffice - UserSwitcher dropdown in nav header (persisted via as_user_id cookie) - Per-user filter pills on "Currently reading" and "Recently read" dashboard sections - Inline username editing (UsernameEdit component with optimistic update) - PATCH /admin/users/:id endpoint to rename a user - Unassigned read tokens row in users table - Komga sync now requires a user_id — reading progress attributed to selected user - Migration 0051: add user_id column to komga_sync_reports - Nav breakpoints: icons-only from md, labels from xl, hamburger until md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,10 +10,15 @@ use sqlx::Row;
|
|||||||
|
|
||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AuthUser {
|
||||||
|
pub user_id: uuid::Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum Scope {
|
pub enum Scope {
|
||||||
Admin,
|
Admin,
|
||||||
Read,
|
Read { user_id: uuid::Uuid },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn require_admin(
|
pub async fn require_admin(
|
||||||
@@ -40,6 +45,20 @@ pub async fn require_read(
|
|||||||
let token = bearer_token(&req).ok_or_else(|| ApiError::unauthorized("missing bearer token"))?;
|
let token = bearer_token(&req).ok_or_else(|| ApiError::unauthorized("missing bearer token"))?;
|
||||||
let scope = authenticate(&state, token).await?;
|
let scope = authenticate(&state, token).await?;
|
||||||
|
|
||||||
|
if let Scope::Read { user_id } = &scope {
|
||||||
|
req.extensions_mut().insert(AuthUser { user_id: *user_id });
|
||||||
|
} else if matches!(scope, Scope::Admin) {
|
||||||
|
// Admin peut s'impersonifier via le header X-As-User
|
||||||
|
if let Some(as_user_id) = req
|
||||||
|
.headers()
|
||||||
|
.get("X-As-User")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| uuid::Uuid::parse_str(v).ok())
|
||||||
|
{
|
||||||
|
req.extensions_mut().insert(AuthUser { user_id: as_user_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
req.extensions_mut().insert(scope);
|
req.extensions_mut().insert(scope);
|
||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
||||||
}
|
}
|
||||||
@@ -60,8 +79,7 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
|
|||||||
|
|
||||||
let maybe_row = sqlx::query(
|
let maybe_row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, token_hash, scope
|
SELECT id, token_hash, scope, user_id FROM api_tokens
|
||||||
FROM api_tokens
|
|
||||||
WHERE prefix = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())
|
WHERE prefix = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -88,7 +106,12 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
|
|||||||
let scope: String = row.try_get("scope").map_err(|_| ApiError::unauthorized("invalid token"))?;
|
let scope: String = row.try_get("scope").map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||||
match scope.as_str() {
|
match scope.as_str() {
|
||||||
"admin" => Ok(Scope::Admin),
|
"admin" => Ok(Scope::Admin),
|
||||||
"read" => Ok(Scope::Read),
|
"read" => {
|
||||||
|
let user_id: uuid::Uuid = row
|
||||||
|
.try_get("user_id")
|
||||||
|
.map_err(|_| ApiError::unauthorized("read token missing user_id"))?;
|
||||||
|
Ok(Scope::Read { user_id })
|
||||||
|
}
|
||||||
_ => Err(ApiError::unauthorized("invalid token scope")),
|
_ => Err(ApiError::unauthorized("invalid token scope")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use axum::{extract::{Path, Query, State}, Json};
|
use axum::{extract::{Extension, Path, Query, State}, Json};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::{error::ApiError, index_jobs::IndexJobResponse, state::AppState};
|
use crate::{auth::AuthUser, error::ApiError, index_jobs::IndexJobResponse, state::AppState};
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct ListBooksQuery {
|
pub struct ListBooksQuery {
|
||||||
@@ -122,7 +122,9 @@ pub struct BookDetails {
|
|||||||
pub async fn list_books(
|
pub async fn list_books(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<ListBooksQuery>,
|
Query(query): Query<ListBooksQuery>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
) -> Result<Json<BooksPage>, ApiError> {
|
) -> Result<Json<BooksPage>, ApiError> {
|
||||||
|
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||||
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||||
let page = query.page.unwrap_or(1).max(1);
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
@@ -151,6 +153,8 @@ pub async fn list_books(
|
|||||||
Some(_) => { p += 1; format!("AND eml.provider = ${p}") },
|
Some(_) => { p += 1; format!("AND eml.provider = ${p}") },
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
|
p += 1;
|
||||||
|
let uid_p = p;
|
||||||
|
|
||||||
let metadata_links_cte = r#"
|
let metadata_links_cte = r#"
|
||||||
metadata_links AS (
|
metadata_links AS (
|
||||||
@@ -164,7 +168,7 @@ pub async fn list_books(
|
|||||||
let count_sql = format!(
|
let count_sql = format!(
|
||||||
r#"WITH {metadata_links_cte}
|
r#"WITH {metadata_links_cte}
|
||||||
SELECT COUNT(*) FROM books b
|
SELECT COUNT(*) FROM books b
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ${uid_p}::uuid IS NOT NULL AND brp.user_id = ${uid_p}
|
||||||
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
|
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
|
||||||
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||||
AND ($2::text IS NULL OR b.kind = $2)
|
AND ($2::text IS NULL OR b.kind = $2)
|
||||||
@@ -192,7 +196,7 @@ pub async fn list_books(
|
|||||||
brp.current_page AS reading_current_page,
|
brp.current_page AS reading_current_page,
|
||||||
brp.last_read_at AS reading_last_read_at
|
brp.last_read_at AS reading_last_read_at
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ${uid_p}::uuid IS NOT NULL AND brp.user_id = ${uid_p}
|
||||||
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
|
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
|
||||||
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||||
AND ($2::text IS NULL OR b.kind = $2)
|
AND ($2::text IS NULL OR b.kind = $2)
|
||||||
@@ -235,8 +239,8 @@ pub async fn list_books(
|
|||||||
data_builder = data_builder.bind(mp.clone());
|
data_builder = data_builder.bind(mp.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
count_builder = count_builder.bind(user_id);
|
||||||
data_builder = data_builder.bind(limit).bind(offset);
|
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
|
||||||
|
|
||||||
let (count_row, rows) = tokio::try_join!(
|
let (count_row, rows) = tokio::try_join!(
|
||||||
count_builder.fetch_one(&state.pool),
|
count_builder.fetch_one(&state.pool),
|
||||||
@@ -295,7 +299,9 @@ pub async fn list_books(
|
|||||||
pub async fn get_book(
|
pub async fn get_book(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
) -> Result<Json<BookDetails>, ApiError> {
|
) -> Result<Json<BookDetails>, ApiError> {
|
||||||
|
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.locked_fields, b.summary, b.isbn, b.publish_date,
|
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.locked_fields, b.summary, b.isbn, b.publish_date,
|
||||||
@@ -311,11 +317,12 @@ pub async fn get_book(
|
|||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) bf ON TRUE
|
) bf ON TRUE
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
|
||||||
WHERE b.id = $1
|
WHERE b.id = $1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -521,9 +528,9 @@ pub async fn update_book(
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path,
|
RETURNING id, library_id, kind, title, author, authors, series, volume, language, page_count, thumbnail_path,
|
||||||
summary, isbn, publish_date,
|
summary, isbn, publish_date,
|
||||||
COALESCE((SELECT status FROM book_reading_progress WHERE book_id = $1), 'unread') AS reading_status,
|
'unread' AS reading_status,
|
||||||
(SELECT current_page FROM book_reading_progress WHERE book_id = $1) AS reading_current_page,
|
NULL::integer AS reading_current_page,
|
||||||
(SELECT last_read_at FROM book_reading_progress WHERE book_id = $1) AS reading_last_read_at
|
NULL::timestamptz AS reading_last_read_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ pub struct KomgaSyncRequest {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub user_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -45,6 +47,8 @@ pub struct KomgaSyncResponse {
|
|||||||
#[schema(value_type = String)]
|
#[schema(value_type = String)]
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub komga_url: String,
|
pub komga_url: String,
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
pub total_komga_read: i64,
|
pub total_komga_read: i64,
|
||||||
pub matched: i64,
|
pub matched: i64,
|
||||||
pub already_read: i64,
|
pub already_read: i64,
|
||||||
@@ -61,6 +65,8 @@ pub struct KomgaSyncReportSummary {
|
|||||||
#[schema(value_type = String)]
|
#[schema(value_type = String)]
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub komga_url: String,
|
pub komga_url: String,
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
pub total_komga_read: i64,
|
pub total_komga_read: i64,
|
||||||
pub matched: i64,
|
pub matched: i64,
|
||||||
pub already_read: i64,
|
pub already_read: i64,
|
||||||
@@ -215,11 +221,12 @@ pub async fn sync_komga_read_books(
|
|||||||
let mut already_read_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
|
let mut already_read_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
|
||||||
|
|
||||||
if !matched_ids.is_empty() {
|
if !matched_ids.is_empty() {
|
||||||
// Get already-read book IDs
|
// Get already-read book IDs for this user
|
||||||
let ar_rows = sqlx::query(
|
let ar_rows = sqlx::query(
|
||||||
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND status = 'read'",
|
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND user_id = $2 AND status = 'read'",
|
||||||
)
|
)
|
||||||
.bind(&matched_ids)
|
.bind(&matched_ids)
|
||||||
|
.bind(body.user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -228,12 +235,12 @@ pub async fn sync_komga_read_books(
|
|||||||
}
|
}
|
||||||
already_read = already_read_ids.len() as i64;
|
already_read = already_read_ids.len() as i64;
|
||||||
|
|
||||||
// Bulk upsert all matched books as read
|
// Bulk upsert all matched books as read for this user
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||||
SELECT unnest($1::uuid[]), 'read', NULL, NOW(), NOW()
|
SELECT unnest($1::uuid[]), $2, 'read', NULL, NOW(), NOW()
|
||||||
ON CONFLICT (book_id) DO UPDATE
|
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||||
SET status = 'read',
|
SET status = 'read',
|
||||||
current_page = NULL,
|
current_page = NULL,
|
||||||
last_read_at = NOW(),
|
last_read_at = NOW(),
|
||||||
@@ -242,6 +249,7 @@ pub async fn sync_komga_read_books(
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&matched_ids)
|
.bind(&matched_ids)
|
||||||
|
.bind(body.user_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -273,12 +281,13 @@ pub async fn sync_komga_read_books(
|
|||||||
let newly_marked_books_json = serde_json::to_value(&newly_marked_books).unwrap_or_default();
|
let newly_marked_books_json = serde_json::to_value(&newly_marked_books).unwrap_or_default();
|
||||||
let report_row = sqlx::query(
|
let report_row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO komga_sync_reports (komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
|
INSERT INTO komga_sync_reports (komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING id, created_at
|
RETURNING id, created_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&url)
|
.bind(&url)
|
||||||
|
.bind(body.user_id)
|
||||||
.bind(total_komga_read)
|
.bind(total_komga_read)
|
||||||
.bind(matched)
|
.bind(matched)
|
||||||
.bind(already_read)
|
.bind(already_read)
|
||||||
@@ -292,6 +301,7 @@ pub async fn sync_komga_read_books(
|
|||||||
Ok(Json(KomgaSyncResponse {
|
Ok(Json(KomgaSyncResponse {
|
||||||
id: report_row.get("id"),
|
id: report_row.get("id"),
|
||||||
komga_url: url,
|
komga_url: url,
|
||||||
|
user_id: Some(body.user_id),
|
||||||
total_komga_read,
|
total_komga_read,
|
||||||
matched,
|
matched,
|
||||||
already_read,
|
already_read,
|
||||||
@@ -319,7 +329,7 @@ pub async fn list_sync_reports(
|
|||||||
) -> Result<Json<Vec<KomgaSyncReportSummary>>, ApiError> {
|
) -> Result<Json<Vec<KomgaSyncReportSummary>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked,
|
SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked,
|
||||||
jsonb_array_length(unmatched) as unmatched_count, created_at
|
jsonb_array_length(unmatched) as unmatched_count, created_at
|
||||||
FROM komga_sync_reports
|
FROM komga_sync_reports
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -334,6 +344,7 @@ pub async fn list_sync_reports(
|
|||||||
.map(|row| KomgaSyncReportSummary {
|
.map(|row| KomgaSyncReportSummary {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
komga_url: row.get("komga_url"),
|
komga_url: row.get("komga_url"),
|
||||||
|
user_id: row.get("user_id"),
|
||||||
total_komga_read: row.get("total_komga_read"),
|
total_komga_read: row.get("total_komga_read"),
|
||||||
matched: row.get("matched"),
|
matched: row.get("matched"),
|
||||||
already_read: row.get("already_read"),
|
already_read: row.get("already_read"),
|
||||||
@@ -365,7 +376,7 @@ pub async fn get_sync_report(
|
|||||||
) -> Result<Json<KomgaSyncResponse>, ApiError> {
|
) -> Result<Json<KomgaSyncResponse>, ApiError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
|
SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
|
||||||
FROM komga_sync_reports
|
FROM komga_sync_reports
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -386,6 +397,7 @@ pub async fn get_sync_report(
|
|||||||
Ok(Json(KomgaSyncResponse {
|
Ok(Json(KomgaSyncResponse {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
komga_url: row.get("komga_url"),
|
komga_url: row.get("komga_url"),
|
||||||
|
user_id: row.get("user_id"),
|
||||||
total_komga_read: row.get("total_komga_read"),
|
total_komga_read: row.get("total_komga_read"),
|
||||||
matched: row.get("matched"),
|
matched: row.get("matched"),
|
||||||
already_read: row.get("already_read"),
|
already_read: row.get("already_read"),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ mod stats;
|
|||||||
mod telegram;
|
mod telegram;
|
||||||
mod thumbnails;
|
mod thumbnails;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
|
mod users;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
@@ -106,8 +107,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/index/jobs/:id/errors", get(index_jobs::get_job_errors))
|
.route("/index/jobs/:id/errors", get(index_jobs::get_job_errors))
|
||||||
.route("/index/cancel/:id", axum::routing::post(index_jobs::cancel_job))
|
.route("/index/cancel/:id", axum::routing::post(index_jobs::cancel_job))
|
||||||
.route("/folders", get(index_jobs::list_folders))
|
.route("/folders", get(index_jobs::list_folders))
|
||||||
|
.route("/admin/users", get(users::list_users).post(users::create_user))
|
||||||
|
.route("/admin/users/:id", delete(users::delete_user).patch(users::update_user))
|
||||||
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
||||||
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
.route("/admin/tokens/:id", delete(tokens::revoke_token).patch(tokens::update_token))
|
||||||
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
||||||
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
|
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
|
||||||
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
|
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use axum::{extract::{Path, State}, Json};
|
use axum::{extract::{Extension, Path, State}, Json};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{auth::AuthUser, error::ApiError, state::AppState};
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct ReadingProgressResponse {
|
pub struct ReadingProgressResponse {
|
||||||
@@ -42,8 +42,10 @@ pub struct UpdateReadingProgressRequest {
|
|||||||
)]
|
)]
|
||||||
pub async fn get_reading_progress(
|
pub async fn get_reading_progress(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
||||||
|
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
|
||||||
// Verify book exists
|
// Verify book exists
|
||||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
|
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -55,9 +57,10 @@ pub async fn get_reading_progress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1",
|
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1 AND user_id = $2",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(auth_user.user_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -96,9 +99,11 @@ pub async fn get_reading_progress(
|
|||||||
)]
|
)]
|
||||||
pub async fn update_reading_progress(
|
pub async fn update_reading_progress(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<UpdateReadingProgressRequest>,
|
Json(body): Json<UpdateReadingProgressRequest>,
|
||||||
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
||||||
|
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
|
||||||
// Validate status value
|
// Validate status value
|
||||||
if !["unread", "reading", "read"].contains(&body.status.as_str()) {
|
if !["unread", "reading", "read"].contains(&body.status.as_str()) {
|
||||||
return Err(ApiError::bad_request(format!(
|
return Err(ApiError::bad_request(format!(
|
||||||
@@ -143,9 +148,9 @@ pub async fn update_reading_progress(
|
|||||||
|
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||||
VALUES ($1, $2, $3, NOW(), NOW())
|
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||||
ON CONFLICT (book_id) DO UPDATE
|
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||||
SET status = EXCLUDED.status,
|
SET status = EXCLUDED.status,
|
||||||
current_page = EXCLUDED.current_page,
|
current_page = EXCLUDED.current_page,
|
||||||
last_read_at = NOW(),
|
last_read_at = NOW(),
|
||||||
@@ -154,6 +159,7 @@ pub async fn update_reading_progress(
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(auth_user.user_id)
|
||||||
.bind(&body.status)
|
.bind(&body.status)
|
||||||
.bind(current_page)
|
.bind(current_page)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
@@ -194,8 +200,10 @@ pub struct MarkSeriesReadResponse {
|
|||||||
)]
|
)]
|
||||||
pub async fn mark_series_read(
|
pub async fn mark_series_read(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
Json(body): Json<MarkSeriesReadRequest>,
|
Json(body): Json<MarkSeriesReadRequest>,
|
||||||
) -> Result<Json<MarkSeriesReadResponse>, ApiError> {
|
) -> Result<Json<MarkSeriesReadResponse>, ApiError> {
|
||||||
|
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
|
||||||
if !["read", "unread"].contains(&body.status.as_str()) {
|
if !["read", "unread"].contains(&body.status.as_str()) {
|
||||||
return Err(ApiError::bad_request(
|
return Err(ApiError::bad_request(
|
||||||
"status must be 'read' or 'unread'",
|
"status must be 'read' or 'unread'",
|
||||||
@@ -209,24 +217,50 @@ pub async fn mark_series_read(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let sql = if body.status == "unread" {
|
let sql = if body.status == "unread" {
|
||||||
// Delete progress records to reset to unread
|
// Delete progress records to reset to unread (scoped to this user)
|
||||||
|
if body.series == "unclassified" {
|
||||||
format!(
|
format!(
|
||||||
r#"
|
r#"
|
||||||
WITH target_books AS (
|
WITH target_books AS (
|
||||||
SELECT id FROM books WHERE {series_filter}
|
SELECT id FROM books WHERE {series_filter}
|
||||||
)
|
)
|
||||||
DELETE FROM book_reading_progress
|
DELETE FROM book_reading_progress
|
||||||
WHERE book_id IN (SELECT id FROM target_books)
|
WHERE book_id IN (SELECT id FROM target_books) AND user_id = $1
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
WITH target_books AS (
|
||||||
SELECT id, 'read', NULL, NOW(), NOW()
|
SELECT id FROM books WHERE {series_filter}
|
||||||
|
)
|
||||||
|
DELETE FROM book_reading_progress
|
||||||
|
WHERE book_id IN (SELECT id FROM target_books) AND user_id = $2
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if body.series == "unclassified" {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||||
|
SELECT id, $1, 'read', NULL, NOW(), NOW()
|
||||||
FROM books
|
FROM books
|
||||||
WHERE {series_filter}
|
WHERE {series_filter}
|
||||||
ON CONFLICT (book_id) DO UPDATE
|
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||||
|
SET status = 'read',
|
||||||
|
current_page = NULL,
|
||||||
|
last_read_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||||
|
SELECT id, $2, 'read', NULL, NOW(), NOW()
|
||||||
|
FROM books
|
||||||
|
WHERE {series_filter}
|
||||||
|
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||||
SET status = 'read',
|
SET status = 'read',
|
||||||
current_page = NULL,
|
current_page = NULL,
|
||||||
last_read_at = NOW(),
|
last_read_at = NOW(),
|
||||||
@@ -236,9 +270,18 @@ pub async fn mark_series_read(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let result = if body.series == "unclassified" {
|
let result = if body.series == "unclassified" {
|
||||||
sqlx::query(&sql).execute(&state.pool).await?
|
// $1 = user_id (no series bind needed)
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(auth_user.user_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query(&sql).bind(&body.series).execute(&state.pool).await?
|
// $1 = series, $2 = user_id
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(&body.series)
|
||||||
|
.bind(auth_user.user_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(MarkSeriesReadResponse {
|
Ok(Json(MarkSeriesReadResponse {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
use axum::extract::Extension;
|
||||||
use axum::{extract::{Path, Query, State}, Json};
|
use axum::{extract::{Path, Query, State}, Json};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::{books::BookItem, error::ApiError, state::AppState};
|
use crate::{auth::AuthUser, books::BookItem, error::ApiError, state::AppState};
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct SeriesItem {
|
pub struct SeriesItem {
|
||||||
@@ -70,9 +71,11 @@ pub struct ListSeriesQuery {
|
|||||||
)]
|
)]
|
||||||
pub async fn list_series(
|
pub async fn list_series(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
Path(library_id): Path<Uuid>,
|
Path(library_id): Path<Uuid>,
|
||||||
Query(query): Query<ListSeriesQuery>,
|
Query(query): Query<ListSeriesQuery>,
|
||||||
) -> Result<Json<SeriesPage>, ApiError> {
|
) -> Result<Json<SeriesPage>, ApiError> {
|
||||||
|
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||||
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||||
let page = query.page.unwrap_or(1).max(1);
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
@@ -115,6 +118,10 @@ pub async fn list_series(
|
|||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let user_id_p = p + 1;
|
||||||
|
let limit_p = p + 2;
|
||||||
|
let offset_p = p + 3;
|
||||||
|
|
||||||
let missing_cte = r#"
|
let missing_cte = r#"
|
||||||
missing_counts AS (
|
missing_counts AS (
|
||||||
SELECT eml.series_name,
|
SELECT eml.series_name,
|
||||||
@@ -147,7 +154,7 @@ pub async fn list_series(
|
|||||||
COUNT(*) as book_count,
|
COUNT(*) as book_count,
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
||||||
FROM sorted_books sb
|
FROM sorted_books sb
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
|
||||||
GROUP BY sb.name
|
GROUP BY sb.name
|
||||||
),
|
),
|
||||||
{missing_cte},
|
{missing_cte},
|
||||||
@@ -160,9 +167,6 @@ pub async fn list_series(
|
|||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
|
|
||||||
let limit_p = p + 1;
|
|
||||||
let offset_p = p + 2;
|
|
||||||
|
|
||||||
let data_sql = format!(
|
let data_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
WITH sorted_books AS (
|
WITH sorted_books AS (
|
||||||
@@ -186,7 +190,7 @@ pub async fn list_series(
|
|||||||
COUNT(*) as book_count,
|
COUNT(*) as book_count,
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
||||||
FROM sorted_books sb
|
FROM sorted_books sb
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
|
||||||
GROUP BY sb.name
|
GROUP BY sb.name
|
||||||
),
|
),
|
||||||
{missing_cte},
|
{missing_cte},
|
||||||
@@ -245,7 +249,8 @@ pub async fn list_series(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data_builder = data_builder.bind(limit).bind(offset);
|
count_builder = count_builder.bind(user_id);
|
||||||
|
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
|
||||||
|
|
||||||
let (count_row, rows) = tokio::try_join!(
|
let (count_row, rows) = tokio::try_join!(
|
||||||
count_builder.fetch_one(&state.pool),
|
count_builder.fetch_one(&state.pool),
|
||||||
@@ -327,8 +332,10 @@ pub struct ListAllSeriesQuery {
|
|||||||
)]
|
)]
|
||||||
pub async fn list_all_series(
|
pub async fn list_all_series(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
Query(query): Query<ListAllSeriesQuery>,
|
Query(query): Query<ListAllSeriesQuery>,
|
||||||
) -> Result<Json<SeriesPage>, ApiError> {
|
) -> Result<Json<SeriesPage>, ApiError> {
|
||||||
|
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||||
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||||
let page = query.page.unwrap_or(1).max(1);
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
@@ -415,6 +422,10 @@ pub async fn list_all_series(
|
|||||||
)
|
)
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
let user_id_p = p + 1;
|
||||||
|
let limit_p = p + 2;
|
||||||
|
let offset_p = p + 3;
|
||||||
|
|
||||||
let count_sql = format!(
|
let count_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
WITH sorted_books AS (
|
WITH sorted_books AS (
|
||||||
@@ -426,7 +437,7 @@ pub async fn list_all_series(
|
|||||||
COUNT(*) as book_count,
|
COUNT(*) as book_count,
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
||||||
FROM sorted_books sb
|
FROM sorted_books sb
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
|
||||||
GROUP BY sb.name, sb.library_id
|
GROUP BY sb.name, sb.library_id
|
||||||
),
|
),
|
||||||
{missing_cte},
|
{missing_cte},
|
||||||
@@ -445,9 +456,6 @@ pub async fn list_all_series(
|
|||||||
"REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''), COALESCE((REGEXP_MATCH(LOWER(sc.name), '\\d+'))[1]::int, 0), sc.name ASC".to_string()
|
"REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''), COALESCE((REGEXP_MATCH(LOWER(sc.name), '\\d+'))[1]::int, 0), sc.name ASC".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let limit_p = p + 1;
|
|
||||||
let offset_p = p + 2;
|
|
||||||
|
|
||||||
let data_sql = format!(
|
let data_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
WITH sorted_books AS (
|
WITH sorted_books AS (
|
||||||
@@ -475,7 +483,7 @@ pub async fn list_all_series(
|
|||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count,
|
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count,
|
||||||
MAX(sb.updated_at) as latest_updated_at
|
MAX(sb.updated_at) as latest_updated_at
|
||||||
FROM sorted_books sb
|
FROM sorted_books sb
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id AND ${user_id_p}::uuid IS NOT NULL AND brp.user_id = ${user_id_p}
|
||||||
GROUP BY sb.name, sb.library_id
|
GROUP BY sb.name, sb.library_id
|
||||||
),
|
),
|
||||||
{missing_cte},
|
{missing_cte},
|
||||||
@@ -538,7 +546,8 @@ pub async fn list_all_series(
|
|||||||
data_builder = data_builder.bind(author.clone());
|
data_builder = data_builder.bind(author.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
data_builder = data_builder.bind(limit).bind(offset);
|
count_builder = count_builder.bind(user_id);
|
||||||
|
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
|
||||||
|
|
||||||
let (count_row, rows) = tokio::try_join!(
|
let (count_row, rows) = tokio::try_join!(
|
||||||
count_builder.fetch_one(&state.pool),
|
count_builder.fetch_one(&state.pool),
|
||||||
@@ -642,8 +651,10 @@ pub struct OngoingQuery {
|
|||||||
)]
|
)]
|
||||||
pub async fn ongoing_series(
|
pub async fn ongoing_series(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
Query(query): Query<OngoingQuery>,
|
Query(query): Query<OngoingQuery>,
|
||||||
) -> Result<Json<Vec<SeriesItem>>, ApiError> {
|
) -> Result<Json<Vec<SeriesItem>>, ApiError> {
|
||||||
|
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||||
let limit = query.limit.unwrap_or(10).clamp(1, 50);
|
let limit = query.limit.unwrap_or(10).clamp(1, 50);
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@@ -655,7 +666,7 @@ pub async fn ongoing_series(
|
|||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read_count,
|
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read_count,
|
||||||
MAX(brp.last_read_at) AS last_read_at
|
MAX(brp.last_read_at) AS last_read_at
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
|
||||||
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
|
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
|
||||||
HAVING (
|
HAVING (
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
|
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
|
||||||
@@ -685,6 +696,7 @@ pub async fn ongoing_series(
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -721,8 +733,10 @@ pub async fn ongoing_series(
|
|||||||
)]
|
)]
|
||||||
pub async fn ongoing_books(
|
pub async fn ongoing_books(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
Query(query): Query<OngoingQuery>,
|
Query(query): Query<OngoingQuery>,
|
||||||
) -> Result<Json<Vec<BookItem>>, ApiError> {
|
) -> Result<Json<Vec<BookItem>>, ApiError> {
|
||||||
|
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||||
let limit = query.limit.unwrap_or(10).clamp(1, 50);
|
let limit = query.limit.unwrap_or(10).clamp(1, 50);
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@@ -732,7 +746,7 @@ pub async fn ongoing_books(
|
|||||||
COALESCE(NULLIF(b.series, ''), 'unclassified') AS name,
|
COALESCE(NULLIF(b.series, ''), 'unclassified') AS name,
|
||||||
MAX(brp.last_read_at) AS series_last_read_at
|
MAX(brp.last_read_at) AS series_last_read_at
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
|
||||||
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
|
GROUP BY COALESCE(NULLIF(b.series, ''), 'unclassified')
|
||||||
HAVING (
|
HAVING (
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
|
COUNT(brp.book_id) FILTER (WHERE brp.status IN ('read', 'reading')) > 0
|
||||||
@@ -753,7 +767,7 @@ pub async fn ongoing_books(
|
|||||||
) AS rn
|
) AS rn
|
||||||
FROM books b
|
FROM books b
|
||||||
JOIN ongoing_series os ON COALESCE(NULLIF(b.series, ''), 'unclassified') = os.name
|
JOIN ongoing_series os ON COALESCE(NULLIF(b.series, ''), 'unclassified') = os.name
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND $2::uuid IS NOT NULL AND brp.user_id = $2
|
||||||
WHERE COALESCE(brp.status, 'unread') != 'read'
|
WHERE COALESCE(brp.status, 'unread') != 'read'
|
||||||
)
|
)
|
||||||
SELECT id, library_id, kind, format, title, author, authors, series, volume, language, page_count,
|
SELECT id, library_id, kind, format, title, author, authors, series, volume, language, page_count,
|
||||||
@@ -765,6 +779,7 @@ pub async fn ongoing_books(
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Extension, Query, State},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use utoipa::{IntoParams, ToSchema};
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
|
||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{auth::AuthUser, error::ApiError, state::AppState};
|
||||||
|
|
||||||
#[derive(Deserialize, IntoParams)]
|
#[derive(Deserialize, IntoParams)]
|
||||||
pub struct StatsQuery {
|
pub struct StatsQuery {
|
||||||
@@ -90,6 +90,7 @@ pub struct CurrentlyReadingItem {
|
|||||||
pub series: Option<String>,
|
pub series: Option<String>,
|
||||||
pub current_page: i32,
|
pub current_page: i32,
|
||||||
pub page_count: i32,
|
pub page_count: i32,
|
||||||
|
pub username: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -98,6 +99,7 @@ pub struct RecentlyReadItem {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub series: Option<String>,
|
pub series: Option<String>,
|
||||||
pub last_read_at: String,
|
pub last_read_at: String,
|
||||||
|
pub username: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -106,6 +108,13 @@ pub struct MonthlyReading {
|
|||||||
pub books_read: i64,
|
pub books_read: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct UserMonthlyReading {
|
||||||
|
pub month: String,
|
||||||
|
pub username: String,
|
||||||
|
pub books_read: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct JobTimePoint {
|
pub struct JobTimePoint {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -129,6 +138,7 @@ pub struct StatsResponse {
|
|||||||
pub additions_over_time: Vec<MonthlyAdditions>,
|
pub additions_over_time: Vec<MonthlyAdditions>,
|
||||||
pub jobs_over_time: Vec<JobTimePoint>,
|
pub jobs_over_time: Vec<JobTimePoint>,
|
||||||
pub metadata: MetadataStats,
|
pub metadata: MetadataStats,
|
||||||
|
pub users_reading_over_time: Vec<UserMonthlyReading>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get collection statistics for the dashboard
|
/// Get collection statistics for the dashboard
|
||||||
@@ -146,7 +156,9 @@ pub struct StatsResponse {
|
|||||||
pub async fn get_stats(
|
pub async fn get_stats(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<StatsQuery>,
|
Query(query): Query<StatsQuery>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
) -> Result<Json<StatsResponse>, ApiError> {
|
) -> Result<Json<StatsResponse>, ApiError> {
|
||||||
|
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||||
let period = query.period.as_deref().unwrap_or("month");
|
let period = query.period.as_deref().unwrap_or("month");
|
||||||
// Overview + reading status in one query
|
// Overview + reading status in one query
|
||||||
let overview_row = sqlx::query(
|
let overview_row = sqlx::query(
|
||||||
@@ -165,9 +177,10 @@ pub async fn get_stats(
|
|||||||
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading,
|
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading,
|
||||||
COUNT(*) FILTER (WHERE brp.status = 'read') AS read
|
COUNT(*) FILTER (WHERE brp.status = 'read') AS read
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -255,7 +268,7 @@ pub async fn get_stats(
|
|||||||
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread_count
|
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread_count
|
||||||
FROM libraries l
|
FROM libraries l
|
||||||
LEFT JOIN books b ON b.library_id = l.id
|
LEFT JOIN books b ON b.library_id = l.id
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT size_bytes FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
|
SELECT size_bytes FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
|
||||||
) bf ON TRUE
|
) bf ON TRUE
|
||||||
@@ -263,6 +276,7 @@ pub async fn get_stats(
|
|||||||
ORDER BY book_count DESC
|
ORDER BY book_count DESC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -287,13 +301,14 @@ pub async fn get_stats(
|
|||||||
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
|
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
|
||||||
COALESCE(SUM(b.page_count), 0)::BIGINT AS total_pages
|
COALESCE(SUM(b.page_count), 0)::BIGINT AS total_pages
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
WHERE b.series IS NOT NULL AND b.series != ''
|
WHERE b.series IS NOT NULL AND b.series != ''
|
||||||
GROUP BY b.series
|
GROUP BY b.series
|
||||||
ORDER BY book_count DESC
|
ORDER BY book_count DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -432,14 +447,17 @@ pub async fn get_stats(
|
|||||||
// Currently reading books
|
// Currently reading books
|
||||||
let reading_rows = sqlx::query(
|
let reading_rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT b.id AS book_id, b.title, b.series, brp.current_page, b.page_count
|
SELECT b.id AS book_id, b.title, b.series, brp.current_page, b.page_count, u.username
|
||||||
FROM book_reading_progress brp
|
FROM book_reading_progress brp
|
||||||
JOIN books b ON b.id = brp.book_id
|
JOIN books b ON b.id = brp.book_id
|
||||||
|
LEFT JOIN users u ON u.id = brp.user_id
|
||||||
WHERE brp.status = 'reading' AND brp.current_page IS NOT NULL
|
WHERE brp.status = 'reading' AND brp.current_page IS NOT NULL
|
||||||
|
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
ORDER BY brp.updated_at DESC
|
ORDER BY brp.updated_at DESC
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -453,6 +471,7 @@ pub async fn get_stats(
|
|||||||
series: r.get("series"),
|
series: r.get("series"),
|
||||||
current_page: r.get::<Option<i32>, _>("current_page").unwrap_or(0),
|
current_page: r.get::<Option<i32>, _>("current_page").unwrap_or(0),
|
||||||
page_count: r.get::<Option<i32>, _>("page_count").unwrap_or(0),
|
page_count: r.get::<Option<i32>, _>("page_count").unwrap_or(0),
|
||||||
|
username: r.get("username"),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -461,14 +480,18 @@ pub async fn get_stats(
|
|||||||
let recent_rows = sqlx::query(
|
let recent_rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT b.id AS book_id, b.title, b.series,
|
SELECT b.id AS book_id, b.title, b.series,
|
||||||
TO_CHAR(brp.last_read_at, 'YYYY-MM-DD') AS last_read_at
|
TO_CHAR(brp.last_read_at, 'YYYY-MM-DD') AS last_read_at,
|
||||||
|
u.username
|
||||||
FROM book_reading_progress brp
|
FROM book_reading_progress brp
|
||||||
JOIN books b ON b.id = brp.book_id
|
JOIN books b ON b.id = brp.book_id
|
||||||
|
LEFT JOIN users u ON u.id = brp.user_id
|
||||||
WHERE brp.status = 'read' AND brp.last_read_at IS NOT NULL
|
WHERE brp.status = 'read' AND brp.last_read_at IS NOT NULL
|
||||||
|
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
ORDER BY brp.last_read_at DESC
|
ORDER BY brp.last_read_at DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -481,6 +504,7 @@ pub async fn get_stats(
|
|||||||
title: r.get("title"),
|
title: r.get("title"),
|
||||||
series: r.get("series"),
|
series: r.get("series"),
|
||||||
last_read_at: r.get::<Option<String>, _>("last_read_at").unwrap_or_default(),
|
last_read_at: r.get::<Option<String>, _>("last_read_at").unwrap_or_default(),
|
||||||
|
username: r.get("username"),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -499,11 +523,13 @@ pub async fn get_stats(
|
|||||||
FROM book_reading_progress brp
|
FROM book_reading_progress brp
|
||||||
WHERE brp.status = 'read'
|
WHERE brp.status = 'read'
|
||||||
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||||
|
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
GROUP BY brp.last_read_at::date
|
GROUP BY brp.last_read_at::date
|
||||||
) cnt ON cnt.dt = d.dt
|
) cnt ON cnt.dt = d.dt
|
||||||
ORDER BY month ASC
|
ORDER BY month ASC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
@@ -523,11 +549,13 @@ pub async fn get_stats(
|
|||||||
FROM book_reading_progress brp
|
FROM book_reading_progress brp
|
||||||
WHERE brp.status = 'read'
|
WHERE brp.status = 'read'
|
||||||
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||||
|
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
GROUP BY DATE_TRUNC('week', brp.last_read_at)
|
GROUP BY DATE_TRUNC('week', brp.last_read_at)
|
||||||
) cnt ON cnt.dt = d.dt
|
) cnt ON cnt.dt = d.dt
|
||||||
ORDER BY month ASC
|
ORDER BY month ASC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
@@ -547,11 +575,13 @@ pub async fn get_stats(
|
|||||||
FROM book_reading_progress brp
|
FROM book_reading_progress brp
|
||||||
WHERE brp.status = 'read'
|
WHERE brp.status = 'read'
|
||||||
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||||
|
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
GROUP BY DATE_TRUNC('month', brp.last_read_at)
|
GROUP BY DATE_TRUNC('month', brp.last_read_at)
|
||||||
) cnt ON cnt.dt = d.dt
|
) cnt ON cnt.dt = d.dt
|
||||||
ORDER BY month ASC
|
ORDER BY month ASC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
@@ -565,6 +595,93 @@ pub async fn get_stats(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Per-user reading over time (admin view — always all users, no user_id filter)
|
||||||
|
let users_reading_time_rows = match period {
|
||||||
|
"day" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
|
u.username,
|
||||||
|
COALESCE(cnt.books_read, 0) AS books_read
|
||||||
|
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||||
|
CROSS JOIN users u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT brp.last_read_at::date AS dt, brp.user_id, COUNT(*) AS books_read
|
||||||
|
FROM book_reading_progress brp
|
||||||
|
WHERE brp.status = 'read'
|
||||||
|
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||||
|
GROUP BY brp.last_read_at::date, brp.user_id
|
||||||
|
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
|
||||||
|
ORDER BY month ASC, u.username
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
"week" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
|
u.username,
|
||||||
|
COALESCE(cnt.books_read, 0) AS books_read
|
||||||
|
FROM generate_series(
|
||||||
|
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||||
|
DATE_TRUNC('week', NOW()),
|
||||||
|
'1 week'
|
||||||
|
) AS d(dt)
|
||||||
|
CROSS JOIN users u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read
|
||||||
|
FROM book_reading_progress brp
|
||||||
|
WHERE brp.status = 'read'
|
||||||
|
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||||
|
GROUP BY DATE_TRUNC('week', brp.last_read_at), brp.user_id
|
||||||
|
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
|
||||||
|
ORDER BY month ASC, u.username
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||||
|
u.username,
|
||||||
|
COALESCE(cnt.books_read, 0) AS books_read
|
||||||
|
FROM generate_series(
|
||||||
|
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||||
|
DATE_TRUNC('month', NOW()),
|
||||||
|
'1 month'
|
||||||
|
) AS d(dt)
|
||||||
|
CROSS JOIN users u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read
|
||||||
|
FROM book_reading_progress brp
|
||||||
|
WHERE brp.status = 'read'
|
||||||
|
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', brp.last_read_at), brp.user_id
|
||||||
|
) cnt ON cnt.dt = d.dt AND cnt.user_id = u.id
|
||||||
|
ORDER BY month ASC, u.username
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let users_reading_over_time: Vec<UserMonthlyReading> = users_reading_time_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| UserMonthlyReading {
|
||||||
|
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
|
||||||
|
username: r.get("username"),
|
||||||
|
books_read: r.get("books_read"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Jobs over time (with gap filling, grouped by type category)
|
// Jobs over time (with gap filling, grouped by type category)
|
||||||
let jobs_rows = match period {
|
let jobs_rows = match period {
|
||||||
"day" => {
|
"day" => {
|
||||||
@@ -697,5 +814,6 @@ pub async fn get_stats(
|
|||||||
additions_over_time,
|
additions_over_time,
|
||||||
jobs_over_time,
|
jobs_over_time,
|
||||||
metadata,
|
metadata,
|
||||||
|
users_reading_over_time,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ pub struct CreateTokenRequest {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
#[schema(value_type = Option<String>, example = "read")]
|
#[schema(value_type = Option<String>, example = "read")]
|
||||||
pub scope: Option<String>,
|
pub scope: Option<String>,
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -26,6 +28,9 @@ pub struct TokenResponse {
|
|||||||
pub scope: String,
|
pub scope: String,
|
||||||
pub prefix: String,
|
pub prefix: String,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
pub username: Option<String>,
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
pub last_used_at: Option<DateTime<Utc>>,
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
pub revoked_at: Option<DateTime<Utc>>,
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
@@ -71,6 +76,10 @@ pub async fn create_token(
|
|||||||
_ => return Err(ApiError::bad_request("scope must be 'admin' or 'read'")),
|
_ => return Err(ApiError::bad_request("scope must be 'admin' or 'read'")),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if scope == "read" && input.user_id.is_none() {
|
||||||
|
return Err(ApiError::bad_request("user_id is required for read-scoped tokens"));
|
||||||
|
}
|
||||||
|
|
||||||
let mut random = [0u8; 24];
|
let mut random = [0u8; 24];
|
||||||
OsRng.fill_bytes(&mut random);
|
OsRng.fill_bytes(&mut random);
|
||||||
let secret = URL_SAFE_NO_PAD.encode(random);
|
let secret = URL_SAFE_NO_PAD.encode(random);
|
||||||
@@ -85,13 +94,14 @@ pub async fn create_token(
|
|||||||
|
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope) VALUES ($1, $2, $3, $4, $5)",
|
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope, user_id) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(input.name.trim())
|
.bind(input.name.trim())
|
||||||
.bind(&prefix)
|
.bind(&prefix)
|
||||||
.bind(token_hash)
|
.bind(token_hash)
|
||||||
.bind(scope)
|
.bind(scope)
|
||||||
|
.bind(input.user_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -118,7 +128,13 @@ pub async fn create_token(
|
|||||||
)]
|
)]
|
||||||
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenResponse>>, ApiError> {
|
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenResponse>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, name, scope, prefix, last_used_at, revoked_at, created_at FROM api_tokens ORDER BY created_at DESC",
|
r#"
|
||||||
|
SELECT t.id, t.name, t.scope, t.prefix, t.user_id, u.username,
|
||||||
|
t.last_used_at, t.revoked_at, t.created_at
|
||||||
|
FROM api_tokens t
|
||||||
|
LEFT JOIN users u ON u.id = t.user_id
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -130,6 +146,8 @@ pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<Token
|
|||||||
name: row.get("name"),
|
name: row.get("name"),
|
||||||
scope: row.get("scope"),
|
scope: row.get("scope"),
|
||||||
prefix: row.get("prefix"),
|
prefix: row.get("prefix"),
|
||||||
|
user_id: row.get("user_id"),
|
||||||
|
username: row.get("username"),
|
||||||
last_used_at: row.get("last_used_at"),
|
last_used_at: row.get("last_used_at"),
|
||||||
revoked_at: row.get("revoked_at"),
|
revoked_at: row.get("revoked_at"),
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
@@ -171,6 +189,47 @@ pub async fn revoke_token(
|
|||||||
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
|
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateTokenRequest {
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a token's assigned user
|
||||||
|
#[utoipa::path(
|
||||||
|
patch,
|
||||||
|
path = "/admin/tokens/{id}",
|
||||||
|
tag = "tokens",
|
||||||
|
params(
|
||||||
|
("id" = String, Path, description = "Token UUID"),
|
||||||
|
),
|
||||||
|
request_body = UpdateTokenRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Token updated"),
|
||||||
|
(status = 404, description = "Token not found"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin scope required"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn update_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(input): Json<UpdateTokenRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let result = sqlx::query("UPDATE api_tokens SET user_id = $1 WHERE id = $2")
|
||||||
|
.bind(input.user_id)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(ApiError::not_found("token not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"updated": true, "id": id})))
|
||||||
|
}
|
||||||
|
|
||||||
/// Permanently delete a revoked API token
|
/// Permanently delete a revoked API token
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
|
|||||||
195
apps/api/src/users.rs
Normal file
195
apps/api/src/users.rs
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
use axum::{extract::{Path, State}, Json};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::Row;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct UserResponse {
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub token_count: i64,
|
||||||
|
pub books_read: i64,
|
||||||
|
pub books_reading: i64,
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct CreateUserRequest {
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all reader users with their associated token count
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/admin/users",
|
||||||
|
tag = "users",
|
||||||
|
responses(
|
||||||
|
(status = 200, body = Vec<UserResponse>),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin scope required"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn list_users(State(state): State<AppState>) -> Result<Json<Vec<UserResponse>>, ApiError> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT u.id, u.username, u.created_at,
|
||||||
|
COUNT(DISTINCT t.id) AS token_count,
|
||||||
|
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read,
|
||||||
|
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'reading') AS books_reading
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN api_tokens t ON t.user_id = u.id AND t.revoked_at IS NULL
|
||||||
|
LEFT JOIN book_reading_progress brp ON brp.user_id = u.id
|
||||||
|
GROUP BY u.id, u.username, u.created_at
|
||||||
|
ORDER BY u.created_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let items = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| UserResponse {
|
||||||
|
id: row.get("id"),
|
||||||
|
username: row.get("username"),
|
||||||
|
token_count: row.get("token_count"),
|
||||||
|
books_read: row.get("books_read"),
|
||||||
|
books_reading: row.get("books_reading"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new reader user
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/admin/users",
|
||||||
|
tag = "users",
|
||||||
|
request_body = CreateUserRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, body = UserResponse, description = "User created"),
|
||||||
|
(status = 400, description = "Invalid input"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin scope required"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn create_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(input): Json<CreateUserRequest>,
|
||||||
|
) -> Result<Json<UserResponse>, ApiError> {
|
||||||
|
if input.username.trim().is_empty() {
|
||||||
|
return Err(ApiError::bad_request("username is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let row = sqlx::query(
|
||||||
|
"INSERT INTO users (id, username) VALUES ($1, $2) RETURNING id, username, created_at",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(input.username.trim())
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if let sqlx::Error::Database(ref db_err) = e {
|
||||||
|
if db_err.constraint() == Some("users_username_key") {
|
||||||
|
return ApiError::bad_request("username already exists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApiError::from(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(UserResponse {
|
||||||
|
id: row.get("id"),
|
||||||
|
username: row.get("username"),
|
||||||
|
token_count: 0,
|
||||||
|
books_read: 0,
|
||||||
|
books_reading: 0,
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a reader user's username
|
||||||
|
#[utoipa::path(
|
||||||
|
patch,
|
||||||
|
path = "/admin/users/{id}",
|
||||||
|
tag = "users",
|
||||||
|
request_body = CreateUserRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, body = UserResponse, description = "User updated"),
|
||||||
|
(status = 400, description = "Invalid input"),
|
||||||
|
(status = 404, description = "User not found"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin scope required"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn update_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(input): Json<CreateUserRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
if input.username.trim().is_empty() {
|
||||||
|
return Err(ApiError::bad_request("username is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = sqlx::query("UPDATE users SET username = $1 WHERE id = $2")
|
||||||
|
.bind(input.username.trim())
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if let sqlx::Error::Database(ref db_err) = e {
|
||||||
|
if db_err.constraint() == Some("users_username_key") {
|
||||||
|
return ApiError::bad_request("username already exists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApiError::from(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(ApiError::not_found("user not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"updated": true, "id": id})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a reader user (cascades on tokens and reading progress)
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/admin/users/{id}",
|
||||||
|
tag = "users",
|
||||||
|
params(
|
||||||
|
("id" = String, Path, description = "User UUID"),
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "User deleted"),
|
||||||
|
(status = 404, description = "User not found"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin scope required"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn delete_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let result = sqlx::query("DELETE FROM users WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(ApiError::not_found("user not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { ThemeToggle } from "@/app/theme-toggle";
|
import { ThemeToggle } from "@/app/theme-toggle";
|
||||||
import { JobsIndicator } from "@/app/components/JobsIndicator";
|
import { JobsIndicator } from "@/app/components/JobsIndicator";
|
||||||
import { NavIcon, Icon } from "@/app/components/ui";
|
import { NavIcon, Icon } from "@/app/components/ui";
|
||||||
import { LogoutButton } from "@/app/components/LogoutButton";
|
import { LogoutButton } from "@/app/components/LogoutButton";
|
||||||
import { MobileNav } from "@/app/components/MobileNav";
|
import { MobileNav } from "@/app/components/MobileNav";
|
||||||
|
import { UserSwitcher } from "@/app/components/UserSwitcher";
|
||||||
|
import { fetchUsers } from "@/lib/api";
|
||||||
import { getServerTranslations } from "@/lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
import type { TranslationKey } from "@/lib/i18n/fr";
|
import type { TranslationKey } from "@/lib/i18n/fr";
|
||||||
|
|
||||||
@@ -27,6 +31,21 @@ const navItems: NavItem[] = [
|
|||||||
|
|
||||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||||
const { t } = await getServerTranslations();
|
const { t } = await getServerTranslations();
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const activeUserId = cookieStore.get("as_user_id")?.value || null;
|
||||||
|
const users = await fetchUsers().catch(() => []);
|
||||||
|
|
||||||
|
async function setActiveUserAction(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const userId = formData.get("user_id") as string;
|
||||||
|
const store = await cookies();
|
||||||
|
if (userId) {
|
||||||
|
store.set("as_user_id", userId, { path: "/", httpOnly: false, sameSite: "lax" });
|
||||||
|
} else {
|
||||||
|
store.delete("as_user_id");
|
||||||
|
}
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -39,7 +58,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
<Image src="/logo.png" alt="StripStream" width={36} height={36} className="rounded-lg" />
|
<Image src="/logo.png" alt="StripStream" width={36} height={36} className="rounded-lg" />
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-xl font-bold tracking-tight text-foreground">StripStream</span>
|
<span className="text-xl font-bold tracking-tight text-foreground">StripStream</span>
|
||||||
<span className="text-sm text-muted-foreground font-medium hidden md:inline">
|
<span className="text-sm text-muted-foreground font-medium hidden xl:inline">
|
||||||
{t("common.backoffice")}
|
{t("common.backoffice")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,16 +69,22 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
|
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
|
||||||
<NavIcon name={item.icon} />
|
<NavIcon name={item.icon} />
|
||||||
<span className="ml-2 hidden lg:inline">{t(item.labelKey)}</span>
|
<span className="ml-2 hidden xl:inline">{t(item.labelKey)}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UserSwitcher
|
||||||
|
users={users}
|
||||||
|
activeUserId={activeUserId}
|
||||||
|
setActiveUserAction={setActiveUserAction}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
|
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
|
||||||
<JobsIndicator />
|
<JobsIndicator />
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
className="hidden xl:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
title={t("nav.settings")}
|
title={t("nav.settings")}
|
||||||
>
|
>
|
||||||
<Icon name="settings" size="md" />
|
<Icon name="settings" size="md" />
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { fetchStats, StatsResponse, getBookCoverUrl } from "@/lib/api";
|
import { fetchStats, fetchUsers, StatsResponse, UserDto } from "@/lib/api";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui";
|
||||||
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts";
|
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts";
|
||||||
import { PeriodToggle } from "@/app/components/PeriodToggle";
|
import { PeriodToggle } from "@/app/components/PeriodToggle";
|
||||||
import Image from "next/image";
|
import { CurrentlyReadingList, RecentlyReadList } from "@/app/components/ReadingUserFilter";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getServerTranslations } from "@/lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
import type { TranslateFunction } from "@/lib/i18n/dictionaries";
|
import type { TranslateFunction } from "@/lib/i18n/dictionaries";
|
||||||
@@ -70,8 +70,12 @@ export default async function DashboardPage({
|
|||||||
const { t, locale } = await getServerTranslations();
|
const { t, locale } = await getServerTranslations();
|
||||||
|
|
||||||
let stats: StatsResponse | null = null;
|
let stats: StatsResponse | null = null;
|
||||||
|
let users: UserDto[] = [];
|
||||||
try {
|
try {
|
||||||
stats = await fetchStats(period);
|
[stats, users] = await Promise.all([
|
||||||
|
fetchStats(period),
|
||||||
|
fetchUsers().catch(() => []),
|
||||||
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch stats:", e);
|
console.error("Failed to fetch stats:", e);
|
||||||
}
|
}
|
||||||
@@ -94,6 +98,7 @@ export default async function DashboardPage({
|
|||||||
currently_reading = [],
|
currently_reading = [],
|
||||||
recently_read = [],
|
recently_read = [],
|
||||||
reading_over_time = [],
|
reading_over_time = [],
|
||||||
|
users_reading_over_time = [],
|
||||||
by_format,
|
by_format,
|
||||||
by_library,
|
by_library,
|
||||||
top_series,
|
top_series,
|
||||||
@@ -145,37 +150,12 @@ export default async function DashboardPage({
|
|||||||
<CardTitle className="text-base">{t("dashboard.currentlyReading")}</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.currentlyReading")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{currently_reading.length === 0 ? (
|
<CurrentlyReadingList
|
||||||
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noCurrentlyReading")}</p>
|
items={currently_reading}
|
||||||
) : (
|
allLabel={t("dashboard.allUsers")}
|
||||||
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
emptyLabel={t("dashboard.noCurrentlyReading")}
|
||||||
{currently_reading.slice(0, 8).map((book) => {
|
pageProgressTemplate={t("dashboard.pageProgress")}
|
||||||
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
|
|
||||||
return (
|
|
||||||
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
|
||||||
<Image
|
|
||||||
src={getBookCoverUrl(book.book_id)}
|
|
||||||
alt={book.title}
|
|
||||||
width={40}
|
|
||||||
height={56}
|
|
||||||
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
|
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
|
|
||||||
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
|
|
||||||
<div className="mt-1.5 flex items-center gap-2">
|
|
||||||
<div className="h-1.5 flex-1 bg-muted rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-warning rounded-full transition-all" style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-muted-foreground shrink-0">{pct}%</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">{t("dashboard.pageProgress", { current: book.current_page, total: book.page_count })}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -185,28 +165,11 @@ export default async function DashboardPage({
|
|||||||
<CardTitle className="text-base">{t("dashboard.recentlyRead")}</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.recentlyRead")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{recently_read.length === 0 ? (
|
<RecentlyReadList
|
||||||
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noRecentlyRead")}</p>
|
items={recently_read}
|
||||||
) : (
|
allLabel={t("dashboard.allUsers")}
|
||||||
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
emptyLabel={t("dashboard.noRecentlyRead")}
|
||||||
{recently_read.map((book) => (
|
|
||||||
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
|
||||||
<Image
|
|
||||||
src={getBookCoverUrl(book.book_id)}
|
|
||||||
alt={book.title}
|
|
||||||
width={40}
|
|
||||||
height={56}
|
|
||||||
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
|
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
|
|
||||||
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{book.last_read_at}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,22 +182,48 @@ export default async function DashboardPage({
|
|||||||
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{(() => {
|
||||||
|
const userColors = [
|
||||||
|
"hsl(142 60% 45%)", "hsl(198 78% 37%)", "hsl(45 93% 47%)",
|
||||||
|
"hsl(2 72% 48%)", "hsl(280 60% 50%)", "hsl(32 80% 50%)",
|
||||||
|
];
|
||||||
|
const usernames = [...new Set(users_reading_over_time.map(r => r.username))];
|
||||||
|
if (usernames.length === 0) {
|
||||||
|
return (
|
||||||
<RcAreaChart
|
<RcAreaChart
|
||||||
noDataLabel={noDataLabel}
|
noDataLabel={noDataLabel}
|
||||||
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
|
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
|
||||||
color="hsl(142 60% 45%)"
|
color="hsl(142 60% 45%)"
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Pivot: { label, username1: n, username2: n, ... }
|
||||||
|
const byMonth = new Map<string, Record<string, unknown>>();
|
||||||
|
for (const row of users_reading_over_time) {
|
||||||
|
const label = formatChartLabel(row.month, period, locale);
|
||||||
|
if (!byMonth.has(row.month)) byMonth.set(row.month, { label });
|
||||||
|
byMonth.get(row.month)![row.username] = row.books_read;
|
||||||
|
}
|
||||||
|
const chartData = [...byMonth.values()];
|
||||||
|
const lines = usernames.map((u, i) => ({
|
||||||
|
key: u,
|
||||||
|
label: u,
|
||||||
|
color: userColors[i % userColors.length],
|
||||||
|
}));
|
||||||
|
return <RcMultiLineChart data={chartData} lines={lines} noDataLabel={noDataLabel} />;
|
||||||
|
})()}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Charts row */}
|
{/* Charts row */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{/* Reading status donut */}
|
{/* Reading status par lecteur */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">{t("dashboard.readingStatus")}</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.readingStatus")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{users.length === 0 ? (
|
||||||
<RcDonutChart
|
<RcDonutChart
|
||||||
noDataLabel={noDataLabel}
|
noDataLabel={noDataLabel}
|
||||||
data={[
|
data={[
|
||||||
@@ -243,6 +232,34 @@ export default async function DashboardPage({
|
|||||||
{ name: t("status.read"), value: reading_status.read, color: readingColors[2] },
|
{ name: t("status.read"), value: reading_status.read, color: readingColors[2] },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{users.map((user) => {
|
||||||
|
const total = overview.total_books;
|
||||||
|
const read = user.books_read;
|
||||||
|
const reading = user.books_reading;
|
||||||
|
const unread = Math.max(0, total - read - reading);
|
||||||
|
const readPct = total > 0 ? (read / total) * 100 : 0;
|
||||||
|
const readingPct = total > 0 ? (reading / total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={user.id} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium text-foreground truncate">{user.username}</span>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0 ml-2">
|
||||||
|
<span className="text-success font-medium">{read}</span>
|
||||||
|
{reading > 0 && <span className="text-amber-500 font-medium"> · {reading}</span>}
|
||||||
|
<span className="text-muted-foreground/60"> / {total}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-muted rounded-full overflow-hidden flex">
|
||||||
|
<div className="h-full bg-success transition-all duration-500" style={{ width: `${readPct}%` }} />
|
||||||
|
<div className="h-full bg-amber-500 transition-all duration-500" style={{ width: `${readingPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -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 } from "@/lib/api";
|
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto, UserDto } 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";
|
||||||
|
|
||||||
@@ -11,9 +11,10 @@ interface SettingsPageProps {
|
|||||||
initialSettings: Settings;
|
initialSettings: Settings;
|
||||||
initialCacheStats: CacheStats;
|
initialCacheStats: CacheStats;
|
||||||
initialThumbnailStats: ThumbnailStats;
|
initialThumbnailStats: ThumbnailStats;
|
||||||
|
users: UserDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats }: SettingsPageProps) {
|
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users }: SettingsPageProps) {
|
||||||
const { t, locale, setLocale } = useTranslation();
|
const { t, locale, setLocale } = useTranslation();
|
||||||
const [settings, setSettings] = useState<Settings>({
|
const [settings, setSettings] = useState<Settings>({
|
||||||
...initialSettings,
|
...initialSettings,
|
||||||
@@ -29,6 +30,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
const [komgaUrl, setKomgaUrl] = useState("");
|
const [komgaUrl, setKomgaUrl] = useState("");
|
||||||
const [komgaUsername, setKomgaUsername] = useState("");
|
const [komgaUsername, setKomgaUsername] = useState("");
|
||||||
const [komgaPassword, setKomgaPassword] = useState("");
|
const [komgaPassword, setKomgaPassword] = useState("");
|
||||||
|
const [komgaUserId, setKomgaUserId] = useState(users[0]?.id ?? "");
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [syncResult, setSyncResult] = useState<KomgaSyncResponse | null>(null);
|
const [syncResult, setSyncResult] = useState<KomgaSyncResponse | null>(null);
|
||||||
const [syncError, setSyncError] = useState<string | null>(null);
|
const [syncError, setSyncError] = useState<string | null>(null);
|
||||||
@@ -104,6 +106,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
if (data) {
|
if (data) {
|
||||||
if (data.url) setKomgaUrl(data.url);
|
if (data.url) setKomgaUrl(data.url);
|
||||||
if (data.username) setKomgaUsername(data.username);
|
if (data.username) setKomgaUsername(data.username);
|
||||||
|
if (data.user_id) setKomgaUserId(data.user_id);
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, [fetchReports]);
|
}, [fetchReports]);
|
||||||
@@ -128,7 +131,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
const response = await fetch("/api/komga/sync", {
|
const response = await fetch("/api/komga/sync", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword }),
|
body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword, user_id: komgaUserId }),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -140,7 +143,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
fetch("/api/settings/komga", {
|
fetch("/api/settings/komga", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername } }),
|
body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername, user_id: komgaUserId } }),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -627,9 +630,22 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
</FormField>
|
</FormField>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
|
|
||||||
|
{users.length > 0 && (
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("users.title")}</label>
|
||||||
|
<FormSelect value={komgaUserId} onChange={(e) => setKomgaUserId(e.target.value)}>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>{u.username}</option>
|
||||||
|
))}
|
||||||
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleKomgaSync}
|
onClick={handleKomgaSync}
|
||||||
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword}
|
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword || !komgaUserId}
|
||||||
>
|
>
|
||||||
{isSyncing ? (
|
{isSyncing ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getSettings, getCacheStats, getThumbnailStats } from "@/lib/api";
|
import { getSettings, getCacheStats, getThumbnailStats, fetchUsers } from "@/lib/api";
|
||||||
import SettingsPage from "./SettingsPage";
|
import SettingsPage from "./SettingsPage";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -23,5 +23,7 @@ export default async function SettingsPageWrapper() {
|
|||||||
directory: "/data/thumbnails"
|
directory: "/data/thumbnails"
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} />;
|
const users = await fetchUsers().catch(() => []);
|
||||||
|
|
||||||
|
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} users={users} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { listTokens, createToken, revokeToken, deleteToken, TokenDto } from "@/lib/api";
|
import { listTokens, createToken, revokeToken, deleteToken, updateToken, fetchUsers, createUser, deleteUser, updateUser, TokenDto, UserDto } from "@/lib/api";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "@/app/components/ui";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "@/app/components/ui";
|
||||||
|
import { TokenUserSelect } from "@/app/components/TokenUserSelect";
|
||||||
|
import { UsernameEdit } from "@/app/components/UsernameEdit";
|
||||||
import { getServerTranslations } from "@/lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -14,13 +16,15 @@ export default async function TokensPage({
|
|||||||
const { t } = await getServerTranslations();
|
const { t } = await getServerTranslations();
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const tokens = await listTokens().catch(() => [] as TokenDto[]);
|
const tokens = await listTokens().catch(() => [] as TokenDto[]);
|
||||||
|
const users = await fetchUsers().catch(() => [] as UserDto[]);
|
||||||
|
|
||||||
async function createTokenAction(formData: FormData) {
|
async function createTokenAction(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
const scope = formData.get("scope") as string;
|
const scope = formData.get("scope") as string;
|
||||||
|
const userId = (formData.get("user_id") as string) || undefined;
|
||||||
if (name) {
|
if (name) {
|
||||||
const result = await createToken(name, scope);
|
const result = await createToken(name, scope, userId);
|
||||||
revalidatePath("/tokens");
|
revalidatePath("/tokens");
|
||||||
redirect(`/tokens?created=${encodeURIComponent(result.token)}`);
|
redirect(`/tokens?created=${encodeURIComponent(result.token)}`);
|
||||||
}
|
}
|
||||||
@@ -40,6 +44,40 @@ export default async function TokensPage({
|
|||||||
revalidatePath("/tokens");
|
revalidatePath("/tokens");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createUserAction(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const username = formData.get("username") as string;
|
||||||
|
if (username) {
|
||||||
|
await createUser(username);
|
||||||
|
revalidatePath("/tokens");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUserAction(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
await deleteUser(id);
|
||||||
|
revalidatePath("/tokens");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameUserAction(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
const username = formData.get("username") as string;
|
||||||
|
if (username?.trim()) {
|
||||||
|
await updateUser(id, username.trim());
|
||||||
|
revalidatePath("/tokens");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reassignTokenAction(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
const userId = (formData.get("user_id") as string) || null;
|
||||||
|
await updateToken(id, userId);
|
||||||
|
revalidatePath("/tokens");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -51,6 +89,115 @@ export default async function TokensPage({
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Lecteurs ─────────────────────────────────────────── */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">{t("users.title")}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("users.createNew")}</CardTitle>
|
||||||
|
<CardDescription>{t("users.createDescription")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={createUserAction}>
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1 min-w-48">
|
||||||
|
<FormInput name="username" placeholder={t("users.username")} required autoComplete="off" />
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit">{t("users.createButton")}</Button>
|
||||||
|
</FormRow>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden mb-10">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border/60 bg-muted/50">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.name")}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.tokenCount")}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.read")}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.reading")}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.createdAt")}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.actions")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/60">
|
||||||
|
{/* Ligne admin synthétique */}
|
||||||
|
<tr className="hover:bg-accent/50 transition-colors bg-destructive/5">
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-foreground flex items-center gap-2">
|
||||||
|
{process.env.ADMIN_USERNAME ?? "admin"}
|
||||||
|
<Badge variant="destructive">{t("tokens.scopeAdmin")}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{tokens.filter(tok => tok.scope === "admin" && !tok.revoked_at).length}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||||
|
</tr>
|
||||||
|
{/* Ligne tokens read non assignés */}
|
||||||
|
{(() => {
|
||||||
|
const unassigned = tokens.filter(tok => tok.scope === "read" && !tok.user_id && !tok.revoked_at);
|
||||||
|
if (unassigned.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<tr className="hover:bg-accent/50 transition-colors bg-warning/5">
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-muted-foreground italic">
|
||||||
|
{t("tokens.noUser")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-warning font-medium">{unassigned.length}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-accent/50 transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<UsernameEdit userId={user.id} currentUsername={user.username} action={renameUserAction} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground">{user.token_count}</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
{user.books_read > 0
|
||||||
|
? <span className="font-medium text-success">{user.books_read}</span>
|
||||||
|
: <span className="text-muted-foreground/50">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
{user.books_reading > 0
|
||||||
|
? <span className="font-medium text-amber-500">{user.books_reading}</span>
|
||||||
|
: <span className="text-muted-foreground/50">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{new Date(user.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<form action={deleteUserAction}>
|
||||||
|
<input type="hidden" name="id" value={user.id} />
|
||||||
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
{t("common.delete")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Tokens API ───────────────────────────────────────── */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">{t("tokens.apiTokens")}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
{params.created ? (
|
{params.created ? (
|
||||||
<Card className="mb-6 border-success/50 bg-success/5">
|
<Card className="mb-6 border-success/50 bg-success/5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -72,7 +219,7 @@ export default async function TokensPage({
|
|||||||
<form action={createTokenAction}>
|
<form action={createTokenAction}>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<FormField className="flex-1 min-w-48">
|
<FormField className="flex-1 min-w-48">
|
||||||
<FormInput name="name" placeholder={t("tokens.tokenName")} required />
|
<FormInput name="name" placeholder={t("tokens.tokenName")} required autoComplete="off" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField className="w-32">
|
<FormField className="w-32">
|
||||||
<FormSelect name="scope" defaultValue="read">
|
<FormSelect name="scope" defaultValue="read">
|
||||||
@@ -80,6 +227,14 @@ export default async function TokensPage({
|
|||||||
<option value="admin">{t("tokens.scopeAdmin")}</option>
|
<option value="admin">{t("tokens.scopeAdmin")}</option>
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField className="w-48">
|
||||||
|
<FormSelect name="user_id" defaultValue="">
|
||||||
|
<option value="">{t("tokens.noUser")}</option>
|
||||||
|
{users.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>{user.username}</option>
|
||||||
|
))}
|
||||||
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
<Button type="submit">{t("tokens.createButton")}</Button>
|
<Button type="submit">{t("tokens.createButton")}</Button>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
</form>
|
</form>
|
||||||
@@ -92,6 +247,7 @@ export default async function TokensPage({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border/60 bg-muted/50">
|
<tr className="border-b border-border/60 bg-muted/50">
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.name")}</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.name")}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.user")}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.scope")}</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.scope")}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.prefix")}</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.prefix")}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.status")}</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.status")}</th>
|
||||||
@@ -102,6 +258,15 @@ export default async function TokensPage({
|
|||||||
{tokens.map((token) => (
|
{tokens.map((token) => (
|
||||||
<tr key={token.id} className="hover:bg-accent/50 transition-colors">
|
<tr key={token.id} className="hover:bg-accent/50 transition-colors">
|
||||||
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
|
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
<TokenUserSelect
|
||||||
|
tokenId={token.id}
|
||||||
|
currentUserId={token.user_id}
|
||||||
|
users={users}
|
||||||
|
action={reassignTokenAction}
|
||||||
|
noUserLabel={t("tokens.noUser")}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
|
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
|
||||||
{token.scope}
|
{token.scope}
|
||||||
|
|||||||
145
apps/backoffice/app/components/ReadingUserFilter.tsx
Normal file
145
apps/backoffice/app/components/ReadingUserFilter.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { CurrentlyReadingItem, RecentlyReadItem } from "@/lib/api";
|
||||||
|
import { getBookCoverUrl } from "@/lib/api";
|
||||||
|
|
||||||
|
function FilterPills({ usernames, selected, allLabel, onSelect }: {
|
||||||
|
usernames: string[];
|
||||||
|
selected: string | null;
|
||||||
|
allLabel: string;
|
||||||
|
onSelect: (u: string | null) => void;
|
||||||
|
}) {
|
||||||
|
if (usernames.length <= 1) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect(null)}
|
||||||
|
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
selected === null
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{allLabel}
|
||||||
|
</button>
|
||||||
|
{usernames.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u}
|
||||||
|
onClick={() => onSelect(u === selected ? null : u)}
|
||||||
|
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
selected === u
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CurrentlyReadingList({
|
||||||
|
items,
|
||||||
|
allLabel,
|
||||||
|
emptyLabel,
|
||||||
|
pageProgressTemplate,
|
||||||
|
}: {
|
||||||
|
items: CurrentlyReadingItem[];
|
||||||
|
allLabel: string;
|
||||||
|
emptyLabel: string;
|
||||||
|
/** Template with {{current}} and {{total}} placeholders */
|
||||||
|
pageProgressTemplate: string;
|
||||||
|
}) {
|
||||||
|
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const filtered = selected ? items.filter((i) => i.username === selected) : items;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
||||||
|
{filtered.slice(0, 8).map((book) => {
|
||||||
|
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
||||||
|
<Image
|
||||||
|
src={getBookCoverUrl(book.book_id)}
|
||||||
|
alt={book.title}
|
||||||
|
width={40}
|
||||||
|
height={56}
|
||||||
|
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
|
||||||
|
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
|
||||||
|
{book.username && usernames.length > 1 && (
|
||||||
|
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<div className="h-1.5 flex-1 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-warning rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5">{pageProgressTemplate.replace("{{current}}", String(book.current_page)).replace("{{total}}", String(book.page_count))}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentlyReadList({
|
||||||
|
items,
|
||||||
|
allLabel,
|
||||||
|
emptyLabel,
|
||||||
|
}: {
|
||||||
|
items: RecentlyReadItem[];
|
||||||
|
allLabel: string;
|
||||||
|
emptyLabel: string;
|
||||||
|
}) {
|
||||||
|
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const filtered = selected ? items.filter((i) => i.username === selected) : items;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
||||||
|
{filtered.map((book) => (
|
||||||
|
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
||||||
|
<Image
|
||||||
|
src={getBookCoverUrl(book.book_id)}
|
||||||
|
alt={book.title}
|
||||||
|
width={40}
|
||||||
|
height={56}
|
||||||
|
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
|
||||||
|
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
|
||||||
|
{book.username && usernames.length > 1 && (
|
||||||
|
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">{book.last_read_at}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/backoffice/app/components/TokenUserSelect.tsx
Normal file
38
apps/backoffice/app/components/TokenUserSelect.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useOptimistic, useTransition } from "react";
|
||||||
|
|
||||||
|
interface TokenUserSelectProps {
|
||||||
|
tokenId: string;
|
||||||
|
currentUserId?: string;
|
||||||
|
users: { id: string; username: string }[];
|
||||||
|
action: (formData: FormData) => Promise<void>;
|
||||||
|
noUserLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TokenUserSelect({ tokenId, currentUserId, users, action, noUserLabel }: TokenUserSelectProps) {
|
||||||
|
const [optimisticValue, setOptimisticValue] = useOptimistic(currentUserId ?? "");
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={optimisticValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
startTransition(async () => {
|
||||||
|
setOptimisticValue(newValue);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("id", tokenId);
|
||||||
|
fd.append("user_id", newValue);
|
||||||
|
await action(fd);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex h-8 rounded-md border border-input bg-background px-2 py-0 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">{noUserLabel}</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>{u.username}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
apps/backoffice/app/components/UserSwitcher.tsx
Normal file
121
apps/backoffice/app/components/UserSwitcher.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition, useRef, useEffect } from "react";
|
||||||
|
import type { UserDto } from "@/lib/api";
|
||||||
|
|
||||||
|
export function UserSwitcher({
|
||||||
|
users,
|
||||||
|
activeUserId,
|
||||||
|
setActiveUserAction,
|
||||||
|
}: {
|
||||||
|
users: UserDto[];
|
||||||
|
activeUserId: string | null;
|
||||||
|
setActiveUserAction: (formData: FormData) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const activeUser = users.find((u) => u.id === activeUserId) ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function select(userId: string | null) {
|
||||||
|
setOpen(false);
|
||||||
|
startTransition(async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("user_id", userId ?? "");
|
||||||
|
await setActiveUserAction(fd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (users.length === 0) return null;
|
||||||
|
|
||||||
|
const isImpersonating = activeUserId !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||||
|
isImpersonating
|
||||||
|
? "border-primary/40 bg-primary/10 text-primary hover:bg-primary/15"
|
||||||
|
: "border-border/60 bg-muted/40 text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isImpersonating ? (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span className="max-w-[80px] truncate hidden sm:inline">
|
||||||
|
{activeUser ? activeUser.username : "Admin"}
|
||||||
|
</span>
|
||||||
|
<svg className="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full mt-1.5 w-44 rounded-lg border border-border/60 bg-popover shadow-lg z-50 overflow-hidden py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => select(null)}
|
||||||
|
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
|
||||||
|
!isImpersonating
|
||||||
|
? "bg-accent text-foreground font-medium"
|
||||||
|
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
Admin
|
||||||
|
{!isImpersonating && (
|
||||||
|
<svg className="w-3.5 h-3.5 ml-auto text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-px bg-border/60 my-1" />
|
||||||
|
|
||||||
|
{users.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
onClick={() => select(user.id)}
|
||||||
|
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
|
||||||
|
activeUserId === user.id
|
||||||
|
? "bg-accent text-foreground font-medium"
|
||||||
|
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
<span className="truncate">{user.username}</span>
|
||||||
|
{activeUserId === user.id && (
|
||||||
|
<svg className="w-3.5 h-3.5 ml-auto text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
apps/backoffice/app/components/UsernameEdit.tsx
Normal file
73
apps/backoffice/app/components/UsernameEdit.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useOptimistic, useTransition, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function UsernameEdit({
|
||||||
|
userId,
|
||||||
|
currentUsername,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
currentUsername: string;
|
||||||
|
action: (formData: FormData) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [optimisticUsername, setOptimisticUsername] = useOptimistic(currentUsername);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
setEditing(true);
|
||||||
|
setTimeout(() => inputRef.current?.select(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(value: string) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || trimmed === currentUsername) {
|
||||||
|
setEditing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
startTransition(async () => {
|
||||||
|
setOptimisticUsername(trimmed);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("id", userId);
|
||||||
|
fd.append("username", trimmed);
|
||||||
|
await action(fd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
defaultValue={optimisticUsername}
|
||||||
|
className="text-sm font-medium text-foreground bg-background border border-border rounded px-2 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary w-36"
|
||||||
|
autoFocus
|
||||||
|
onBlur={(e) => submit(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") submit((e.target as HTMLInputElement).value);
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={startEdit}
|
||||||
|
className="flex items-center gap-1.5 group/edit text-left"
|
||||||
|
title="Modifier"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-foreground">{optimisticUsername}</span>
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover/edit:opacity-100 transition-opacity shrink-0"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,6 +45,17 @@ export type TokenDto = {
|
|||||||
scope: string;
|
scope: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
revoked_at: string | null;
|
revoked_at: string | null;
|
||||||
|
user_id?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserDto = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
token_count: number;
|
||||||
|
books_read: number;
|
||||||
|
books_reading: number;
|
||||||
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FolderItem = {
|
export type FolderItem = {
|
||||||
@@ -151,6 +162,16 @@ export async function apiFetch<T>(
|
|||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Impersonation : injecte X-As-User si un user est sélectionné dans le backoffice
|
||||||
|
try {
|
||||||
|
const { cookies } = await import("next/headers");
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const asUserId = cookieStore.get("as_user_id")?.value;
|
||||||
|
if (asUserId) headers.set("X-As-User", asUserId);
|
||||||
|
} catch {
|
||||||
|
// Hors contexte Next.js (tests, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
const { next: nextOptions, ...restInit } = init ?? {};
|
const { next: nextOptions, ...restInit } = init ?? {};
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}${path}`, {
|
const res = await fetch(`${baseUrl}${path}`, {
|
||||||
@@ -268,10 +289,32 @@ export async function listTokens() {
|
|||||||
return apiFetch<TokenDto[]>("/admin/tokens");
|
return apiFetch<TokenDto[]>("/admin/tokens");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createToken(name: string, scope: string) {
|
export async function createToken(name: string, scope: string, userId?: string) {
|
||||||
return apiFetch<{ token: string }>("/admin/tokens", {
|
return apiFetch<{ token: string }>("/admin/tokens", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ name, scope }),
|
body: JSON.stringify({ name, scope, ...(userId ? { user_id: userId } : {}) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUsers(): Promise<UserDto[]> {
|
||||||
|
return apiFetch<UserDto[]>("/admin/users");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(username: string): Promise<UserDto> {
|
||||||
|
return apiFetch<UserDto>("/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: string): Promise<void> {
|
||||||
|
return apiFetch<void>(`/admin/users/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: string, username: string): Promise<void> {
|
||||||
|
return apiFetch<void>(`/admin/users/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +326,13 @@ export async function deleteToken(id: string) {
|
|||||||
return apiFetch<void>(`/admin/tokens/${id}/delete`, { method: "POST" });
|
return apiFetch<void>(`/admin/tokens/${id}/delete`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateToken(id: string, userId: string | null) {
|
||||||
|
return apiFetch<void>(`/admin/tokens/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ user_id: userId || null }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchBooks(
|
export async function fetchBooks(
|
||||||
libraryId?: string,
|
libraryId?: string,
|
||||||
series?: string,
|
series?: string,
|
||||||
@@ -557,6 +607,7 @@ export type CurrentlyReadingItem = {
|
|||||||
series: string | null;
|
series: string | null;
|
||||||
current_page: number;
|
current_page: number;
|
||||||
page_count: number;
|
page_count: number;
|
||||||
|
username?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecentlyReadItem = {
|
export type RecentlyReadItem = {
|
||||||
@@ -564,6 +615,7 @@ export type RecentlyReadItem = {
|
|||||||
title: string;
|
title: string;
|
||||||
series: string | null;
|
series: string | null;
|
||||||
last_read_at: string;
|
last_read_at: string;
|
||||||
|
username?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MonthlyReading = {
|
export type MonthlyReading = {
|
||||||
@@ -571,6 +623,12 @@ export type MonthlyReading = {
|
|||||||
books_read: number;
|
books_read: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserMonthlyReading = {
|
||||||
|
month: string;
|
||||||
|
username: string;
|
||||||
|
books_read: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type JobTimePoint = {
|
export type JobTimePoint = {
|
||||||
label: string;
|
label: string;
|
||||||
scan: number;
|
scan: number;
|
||||||
@@ -585,6 +643,7 @@ export type StatsResponse = {
|
|||||||
currently_reading: CurrentlyReadingItem[];
|
currently_reading: CurrentlyReadingItem[];
|
||||||
recently_read: RecentlyReadItem[];
|
recently_read: RecentlyReadItem[];
|
||||||
reading_over_time: MonthlyReading[];
|
reading_over_time: MonthlyReading[];
|
||||||
|
users_reading_over_time: UserMonthlyReading[];
|
||||||
by_format: FormatCount[];
|
by_format: FormatCount[];
|
||||||
by_language: LanguageCount[];
|
by_language: LanguageCount[];
|
||||||
by_library: LibraryStatsItem[];
|
by_library: LibraryStatsItem[];
|
||||||
@@ -699,11 +758,13 @@ export type KomgaSyncRequest = {
|
|||||||
url: string;
|
url: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
user_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type KomgaSyncResponse = {
|
export type KomgaSyncResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
komga_url: string;
|
komga_url: string;
|
||||||
|
user_id?: string;
|
||||||
total_komga_read: number;
|
total_komga_read: number;
|
||||||
matched: number;
|
matched: number;
|
||||||
already_read: number;
|
already_read: number;
|
||||||
@@ -717,6 +778,7 @@ export type KomgaSyncResponse = {
|
|||||||
export type KomgaSyncReportSummary = {
|
export type KomgaSyncReportSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
komga_url: string;
|
komga_url: string;
|
||||||
|
user_id?: string;
|
||||||
total_komga_read: number;
|
total_komga_read: number;
|
||||||
matched: number;
|
matched: number;
|
||||||
already_read: number;
|
already_read: number;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"nav.libraries": "Libraries",
|
"nav.libraries": "Libraries",
|
||||||
"nav.jobs": "Jobs",
|
"nav.jobs": "Jobs",
|
||||||
"nav.tokens": "Tokens",
|
"nav.tokens": "Tokens",
|
||||||
|
"nav.users": "Users",
|
||||||
"nav.settings": "Settings",
|
"nav.settings": "Settings",
|
||||||
"nav.navigation": "Navigation",
|
"nav.navigation": "Navigation",
|
||||||
"nav.closeMenu": "Close menu",
|
"nav.closeMenu": "Close menu",
|
||||||
@@ -96,6 +97,7 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
||||||
"dashboard.noCurrentlyReading": "No books in progress",
|
"dashboard.noCurrentlyReading": "No books in progress",
|
||||||
"dashboard.noRecentlyRead": "No books read recently",
|
"dashboard.noRecentlyRead": "No books read recently",
|
||||||
|
"dashboard.allUsers": "All",
|
||||||
|
|
||||||
// Books page
|
// Books page
|
||||||
"books.title": "Books",
|
"books.title": "Books",
|
||||||
@@ -405,6 +407,21 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"tokens.revoked": "Revoked",
|
"tokens.revoked": "Revoked",
|
||||||
"tokens.active": "Active",
|
"tokens.active": "Active",
|
||||||
"tokens.revoke": "Revoke",
|
"tokens.revoke": "Revoke",
|
||||||
|
"tokens.user": "User",
|
||||||
|
"tokens.noUser": "None (admin)",
|
||||||
|
"tokens.apiTokens": "API Tokens",
|
||||||
|
|
||||||
|
// Users page
|
||||||
|
"users.title": "Users",
|
||||||
|
"users.createNew": "Create a user",
|
||||||
|
"users.createDescription": "Create a user account for read access",
|
||||||
|
"users.username": "Username",
|
||||||
|
"users.createButton": "Create",
|
||||||
|
"users.name": "Username",
|
||||||
|
"users.tokenCount": "Tokens",
|
||||||
|
"users.createdAt": "Created",
|
||||||
|
"users.actions": "Actions",
|
||||||
|
"users.noUsers": "No users",
|
||||||
|
|
||||||
// Settings page
|
// Settings page
|
||||||
"settings.title": "Settings",
|
"settings.title": "Settings",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const fr = {
|
|||||||
"nav.libraries": "Bibliothèques",
|
"nav.libraries": "Bibliothèques",
|
||||||
"nav.jobs": "Tâches",
|
"nav.jobs": "Tâches",
|
||||||
"nav.tokens": "Jetons",
|
"nav.tokens": "Jetons",
|
||||||
|
"nav.users": "Utilisateurs",
|
||||||
"nav.settings": "Paramètres",
|
"nav.settings": "Paramètres",
|
||||||
"nav.navigation": "Navigation",
|
"nav.navigation": "Navigation",
|
||||||
"nav.closeMenu": "Fermer le menu",
|
"nav.closeMenu": "Fermer le menu",
|
||||||
@@ -94,6 +95,7 @@ const fr = {
|
|||||||
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
||||||
"dashboard.noCurrentlyReading": "Aucun livre en cours",
|
"dashboard.noCurrentlyReading": "Aucun livre en cours",
|
||||||
"dashboard.noRecentlyRead": "Aucun livre lu récemment",
|
"dashboard.noRecentlyRead": "Aucun livre lu récemment",
|
||||||
|
"dashboard.allUsers": "Tous",
|
||||||
|
|
||||||
// Books page
|
// Books page
|
||||||
"books.title": "Livres",
|
"books.title": "Livres",
|
||||||
@@ -403,6 +405,21 @@ const fr = {
|
|||||||
"tokens.revoked": "Révoqué",
|
"tokens.revoked": "Révoqué",
|
||||||
"tokens.active": "Actif",
|
"tokens.active": "Actif",
|
||||||
"tokens.revoke": "Révoquer",
|
"tokens.revoke": "Révoquer",
|
||||||
|
"tokens.user": "Utilisateur",
|
||||||
|
"tokens.noUser": "Aucun (admin)",
|
||||||
|
"tokens.apiTokens": "Tokens API",
|
||||||
|
|
||||||
|
// Users page
|
||||||
|
"users.title": "Utilisateurs",
|
||||||
|
"users.createNew": "Créer un utilisateur",
|
||||||
|
"users.createDescription": "Créer un compte utilisateur pour accès lecture",
|
||||||
|
"users.username": "Nom d'utilisateur",
|
||||||
|
"users.createButton": "Créer",
|
||||||
|
"users.name": "Nom d'utilisateur",
|
||||||
|
"users.tokenCount": "Nb de jetons",
|
||||||
|
"users.createdAt": "Créé le",
|
||||||
|
"users.actions": "Actions",
|
||||||
|
"users.noUsers": "Aucun utilisateur",
|
||||||
|
|
||||||
// Settings page
|
// Settings page
|
||||||
"settings.title": "Paramètres",
|
"settings.title": "Paramètres",
|
||||||
|
|||||||
1
infra/migrations/0051_add_user_to_komga_sync.sql
Normal file
1
infra/migrations/0051_add_user_to_komga_sync.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE komga_sync_reports ADD COLUMN user_id UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||||
Reference in New Issue
Block a user