Compare commits

..

17 Commits

Author SHA1 Message Date
2a7881ac6e chore: bump version to 2.0.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m7s
2026-03-24 12:56:40 +01:00
0950018b38 fix: add autoComplete=off on password fields to suppress WebKit autofill error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:49:02 +01:00
bc796f4ee5 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>
2026-03-24 12:47:58 +01:00
232ecdda41 feat: add backoffice authentication with login page
- Add login page with logo background, glassmorphism card
- Add session management via JWT (jose) with httpOnly cookie
- Add Next.js proxy middleware to protect all routes
- Add logout button in nav
- Restructure app into (app) route group to isolate login layout
- Add ADMIN_USERNAME, ADMIN_PASSWORD, SESSION_SECRET env vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:48:01 +01:00
32d13984a1 chore: bump version to 1.28.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 53s
2026-03-23 19:00:30 +01:00
eab7f2e21b feat: filter metadata refresh to ongoing series & improve job action buttons
- Metadata refresh now skips series with ended/cancelled status
- Add xs size to Button component
- Unify view/cancel button sizes (h-7) with icons (eye & cross)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:59:33 +01:00
b6422fbf3e feat: enhance jobs list stats with tooltips, icons, and refresh count
- Add Tooltip UI component for styled hover tooltips
- Replace native title attributes with Tooltip on all job stats
- Add refresh icon (green) showing actual refreshed count for metadata refresh
- Add icon+tooltip to scanned files stat
- Add icon prop to StatBox component
- Add refreshed field to stats_json types
- Distinct tooltip labels for total links vs refreshed count

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:56:42 +01:00
6dbd0c80e6 feat: improve Telegram notification UI with better formatting
Add visual separators, contextual emojis, bold labels, structured
result sections, and conditional error lines for cleaner messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:46:25 +01:00
0c42a9ed04 fix: add API job poller to process scheduler-created metadata jobs
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m12s
The scheduler (indexer) created metadata_refresh/metadata_batch jobs in DB,
but the indexer excluded them (API_ONLY_JOB_TYPES) and the API only processed
jobs created via its REST endpoints. Scheduler-created jobs stayed pending forever.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 21:05:42 +01:00
95a6e54d06 chore: bump version to 1.27.1 2026-03-22 21:05:23 +01:00
e26219989f feat: add job runs chart and scrollable reading lists on dashboard
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m5s
- Add multi-line chart showing job runs over time by type (scan,
  rebuild, thumbnails, other) with the same day/week/month toggle
- Limit currently reading and recently read lists to 3 visible items
  with a scrollbar for overflow
- Fix NUMERIC→BIGINT cast for SUM/COALESCE in jobs SQL queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:43:45 +01:00
5d33a35407 chore: bump version to 1.27.0 2026-03-22 10:43:25 +01:00
d53572dc33 chore: bump version to 1.26.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m49s
2026-03-22 10:27:59 +01:00
cf1953d11f feat: add day/week/month period toggle for dashboard line charts
Add a period selector (day, week, month) to the reading activity and
books added charts. The API now accepts a ?period= query param and
returns gap-filled data using generate_series so all time slots appear
even with zero values. Labels are locale-aware (short month, weekday).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:27:24 +01:00
6f663eaee7 docs: add MIT license
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:08:15 +01:00
ee65c6263a perf: add ETag and server-side caching for thumbnail proxy
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 49s
Add ETag header to API thumbnail responses for 304 Not Modified support.
Forward If-None-Match/ETag through the Next.js proxy route handler and
add next.revalidate for 24h server-side fetch caching to reduce
SSR-to-API round trips on the libraries page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 06:52:47 +01:00
691b6b22ab chore: bump version to 1.25.0 2026-03-22 06:52:02 +01:00
62 changed files with 2917 additions and 760 deletions

View File

@@ -13,6 +13,12 @@
# Use this token for the first API calls before creating proper API tokens # Use this token for the first API calls before creating proper API tokens
API_BOOTSTRAP_TOKEN=change-me-in-production API_BOOTSTRAP_TOKEN=change-me-in-production
# Backoffice admin credentials (required)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-in-production
# Secret for signing session JWTs (min 32 chars, required)
SESSION_SECRET=change-me-in-production-use-32-chars-min
# ============================================================================= # =============================================================================
# Service Configuration # Service Configuration
# ============================================================================= # =============================================================================

10
Cargo.lock generated
View File

@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "api" name = "api"
version = "1.24.1" version = "2.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -1233,7 +1233,7 @@ dependencies = [
[[package]] [[package]]
name = "indexer" name = "indexer"
version = "1.24.1" version = "2.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -1667,7 +1667,7 @@ dependencies = [
[[package]] [[package]]
name = "notifications" name = "notifications"
version = "1.24.1" version = "2.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"reqwest", "reqwest",
@@ -1786,7 +1786,7 @@ dependencies = [
[[package]] [[package]]
name = "parsers" name = "parsers"
version = "1.24.1" version = "2.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"flate2", "flate2",
@@ -2923,7 +2923,7 @@ dependencies = [
[[package]] [[package]]
name = "stripstream-core" name = "stripstream-core"
version = "1.24.1" version = "2.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",

View File

@@ -10,7 +10,7 @@ resolver = "2"
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
version = "1.24.1" version = "2.0.0"
license = "MIT" license = "MIT"
[workspace.dependencies] [workspace.dependencies]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Julien Froidefond
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -287,4 +287,4 @@ volumes:
## License ## License
[Your License Here] This project is licensed under the [MIT License](LICENSE).

View File

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

View File

@@ -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)
@@ -631,12 +638,17 @@ pub async fn get_thumbnail(
crate::pages::render_book_page_1(&state, book_id, 300, 80).await? crate::pages::render_book_page_1(&state, book_id, 300, 80).await?
}; };
let etag_value = format!("\"{}_{:x}\"", book_id, data.len());
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
headers.insert( headers.insert(
header::CACHE_CONTROL, header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"), HeaderValue::from_static("public, max-age=31536000, immutable"),
); );
if let Ok(v) = HeaderValue::from_str(&etag_value) {
headers.insert(header::ETAG, v);
}
Ok((StatusCode::OK, headers, Body::from(data))) Ok((StatusCode::OK, headers, Body::from(data)))
} }

134
apps/api/src/job_poller.rs Normal file
View File

@@ -0,0 +1,134 @@
use std::time::Duration;
use sqlx::{PgPool, Row};
use tracing::{error, info, trace};
use uuid::Uuid;
use crate::{metadata_batch, metadata_refresh};
/// Poll for pending API-only jobs (`metadata_batch`, `metadata_refresh`) and process them.
/// This mirrors the indexer's worker loop but for job types handled by the API.
pub async fn run_job_poller(pool: PgPool, interval_seconds: u64) {
let wait = Duration::from_secs(interval_seconds.max(1));
loop {
match claim_next_api_job(&pool).await {
Ok(Some((job_id, job_type, library_id))) => {
info!("[JOB_POLLER] Claimed {job_type} job {job_id} library={library_id}");
let pool_clone = pool.clone();
let library_name: Option<String> =
sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id)
.fetch_optional(&pool)
.await
.ok()
.flatten();
tokio::spawn(async move {
let result = match job_type.as_str() {
"metadata_refresh" => {
metadata_refresh::process_metadata_refresh(
&pool_clone,
job_id,
library_id,
)
.await
}
"metadata_batch" => {
metadata_batch::process_metadata_batch(
&pool_clone,
job_id,
library_id,
)
.await
}
_ => Err(format!("Unknown API job type: {job_type}")),
};
if let Err(e) = result {
error!("[JOB_POLLER] {job_type} job {job_id} failed: {e}");
let _ = sqlx::query(
"UPDATE index_jobs SET status = 'failed', error_opt = $2, finished_at = NOW() WHERE id = $1",
)
.bind(job_id)
.bind(e.to_string())
.execute(&pool_clone)
.await;
match job_type.as_str() {
"metadata_refresh" => {
notifications::notify(
pool_clone,
notifications::NotificationEvent::MetadataRefreshFailed {
library_name,
error: e.to_string(),
},
);
}
"metadata_batch" => {
notifications::notify(
pool_clone,
notifications::NotificationEvent::MetadataBatchFailed {
library_name,
error: e.to_string(),
},
);
}
_ => {}
}
}
});
}
Ok(None) => {
trace!("[JOB_POLLER] No pending API jobs, waiting...");
tokio::time::sleep(wait).await;
}
Err(err) => {
error!("[JOB_POLLER] Error claiming job: {err}");
tokio::time::sleep(wait).await;
}
}
}
}
const API_JOB_TYPES: &[&str] = &["metadata_batch", "metadata_refresh"];
async fn claim_next_api_job(pool: &PgPool) -> Result<Option<(Uuid, String, Uuid)>, sqlx::Error> {
let mut tx = pool.begin().await?;
let row = sqlx::query(
r#"
SELECT id, type, library_id
FROM index_jobs
WHERE status = 'pending'
AND type = ANY($1)
AND library_id IS NOT NULL
ORDER BY created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
"#,
)
.bind(API_JOB_TYPES)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.commit().await?;
return Ok(None);
};
let id: Uuid = row.get("id");
let job_type: String = row.get("type");
let library_id: Uuid = row.get("library_id");
sqlx::query(
"UPDATE index_jobs SET status = 'running', started_at = NOW(), error_opt = NULL WHERE id = $1",
)
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some((id, job_type, library_id)))
}

View File

@@ -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"),

View File

@@ -4,6 +4,7 @@ mod books;
mod error; mod error;
mod handlers; mod handlers;
mod index_jobs; mod index_jobs;
mod job_poller;
mod komga; mod komga;
mod libraries; mod libraries;
mod metadata; mod metadata;
@@ -24,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;
@@ -105,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))
@@ -159,6 +163,9 @@ async fn main() -> anyhow::Result<()> {
auth::require_read, auth::require_read,
)); ));
// Clone pool before state is moved into the router
let poller_pool = state.pool.clone();
let app = Router::new() let app = Router::new()
.route("/health", get(handlers::health)) .route("/health", get(handlers::health))
.route("/ready", get(handlers::ready)) .route("/ready", get(handlers::ready))
@@ -170,6 +177,11 @@ async fn main() -> anyhow::Result<()> {
.layer(middleware::from_fn_with_state(state.clone(), api_middleware::request_counter)) .layer(middleware::from_fn_with_state(state.clone(), api_middleware::request_counter))
.with_state(state); .with_state(state);
// Start background poller for API-only jobs (metadata_batch, metadata_refresh)
tokio::spawn(async move {
job_poller::run_job_poller(poller_pool, 5).await;
});
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?; let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
info!(addr = %config.listen_addr, "api listening"); info!(addr = %config.listen_addr, "api listening");
axum::serve(listener, app).await?; axum::serve(listener, app).await?;

View File

@@ -115,14 +115,14 @@ pub async fn start_batch(
let job_id = Uuid::new_v4(); let job_id = Uuid::new_v4();
sqlx::query( sqlx::query(
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_batch', 'pending')", "INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'metadata_batch', 'running', NOW())",
) )
.bind(job_id) .bind(job_id)
.bind(library_id) .bind(library_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
// Spawn the background processing task // Spawn the background processing task (status already 'running' to avoid poller race)
let pool = state.pool.clone(); let pool = state.pool.clone();
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1") let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id) .bind(library_id)
@@ -313,7 +313,7 @@ pub async fn get_batch_results(
// Background processing // Background processing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async fn process_metadata_batch( pub(crate) async fn process_metadata_batch(
pool: &PgPool, pool: &PgPool,
job_id: Uuid, job_id: Uuid,
library_id: Uuid, library_id: Uuid,

View File

@@ -110,9 +110,16 @@ pub async fn start_refresh(
}))); })));
} }
// Check there are approved links to refresh // Check there are approved links to refresh (only ongoing series)
let link_count: i64 = sqlx::query_scalar( let link_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM external_metadata_links WHERE library_id = $1 AND status = 'approved'", r#"
SELECT COUNT(*) FROM external_metadata_links eml
LEFT JOIN series_metadata sm
ON sm.library_id = eml.library_id AND sm.name = eml.series_name
WHERE eml.library_id = $1
AND eml.status = 'approved'
AND COALESCE(sm.status, 'ongoing') NOT IN ('ended', 'cancelled')
"#,
) )
.bind(library_id) .bind(library_id)
.fetch_one(&state.pool) .fetch_one(&state.pool)
@@ -124,14 +131,14 @@ pub async fn start_refresh(
let job_id = Uuid::new_v4(); let job_id = Uuid::new_v4();
sqlx::query( sqlx::query(
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_refresh', 'pending')", "INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'metadata_refresh', 'running', NOW())",
) )
.bind(job_id) .bind(job_id)
.bind(library_id) .bind(library_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
// Spawn the background processing task // Spawn the background processing task (status already 'running' to avoid poller race)
let pool = state.pool.clone(); let pool = state.pool.clone();
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1") let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
.bind(library_id) .bind(library_id)
@@ -222,7 +229,7 @@ pub async fn get_refresh_report(
// Background processing // Background processing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async fn process_metadata_refresh( pub(crate) async fn process_metadata_refresh(
pool: &PgPool, pool: &PgPool,
job_id: Uuid, job_id: Uuid,
library_id: Uuid, library_id: Uuid,
@@ -234,13 +241,17 @@ async fn process_metadata_refresh(
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Get all approved links for this library // Get approved links for this library, only for ongoing series (not ended/cancelled)
let links: Vec<(Uuid, String, String, String)> = sqlx::query_as( let links: Vec<(Uuid, String, String, String)> = sqlx::query_as(
r#" r#"
SELECT id, series_name, provider, external_id SELECT eml.id, eml.series_name, eml.provider, eml.external_id
FROM external_metadata_links FROM external_metadata_links eml
WHERE library_id = $1 AND status = 'approved' LEFT JOIN series_metadata sm
ORDER BY series_name ON sm.library_id = eml.library_id AND sm.name = eml.series_name
WHERE eml.library_id = $1
AND eml.status = 'approved'
AND COALESCE(sm.status, 'ongoing') NOT IN ('ended', 'cancelled')
ORDER BY eml.series_name
"#, "#,
) )
.bind(library_id) .bind(library_id)

View File

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

View File

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

View File

@@ -1,9 +1,18 @@
use axum::{extract::State, Json}; use axum::{
use serde::Serialize; extract::{Extension, Query, State},
Json,
};
use serde::{Deserialize, Serialize};
use sqlx::Row; use sqlx::Row;
use utoipa::ToSchema; use utoipa::{IntoParams, ToSchema};
use crate::{error::ApiError, state::AppState}; use crate::{auth::AuthUser, error::ApiError, state::AppState};
#[derive(Deserialize, IntoParams)]
pub struct StatsQuery {
/// Granularity: "day", "week" or "month" (default: "month")
pub period: Option<String>,
}
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct StatsOverview { pub struct StatsOverview {
@@ -81,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)]
@@ -89,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)]
@@ -97,6 +108,22 @@ 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)]
pub struct JobTimePoint {
pub label: String,
pub scan: i64,
pub rebuild: i64,
pub thumbnail: i64,
pub other: i64,
}
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct StatsResponse { pub struct StatsResponse {
pub overview: StatsOverview, pub overview: StatsOverview,
@@ -109,7 +136,9 @@ pub struct StatsResponse {
pub by_library: Vec<LibraryStats>, pub by_library: Vec<LibraryStats>,
pub top_series: Vec<TopSeries>, pub top_series: Vec<TopSeries>,
pub additions_over_time: Vec<MonthlyAdditions>, pub additions_over_time: Vec<MonthlyAdditions>,
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
@@ -117,6 +146,7 @@ pub struct StatsResponse {
get, get,
path = "/stats", path = "/stats",
tag = "stats", tag = "stats",
params(StatsQuery),
responses( responses(
(status = 200, body = StatsResponse), (status = 200, body = StatsResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
@@ -125,7 +155,11 @@ 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>,
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");
// Overview + reading status in one query // Overview + reading status in one query
let overview_row = sqlx::query( let overview_row = sqlx::query(
r#" r#"
@@ -143,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?;
@@ -233,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
@@ -241,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?;
@@ -265,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?;
@@ -285,20 +322,74 @@ pub async fn get_stats(
}) })
.collect(); .collect();
// Additions over time (last 12 months) // Additions over time (with gap filling)
let additions_rows = sqlx::query( let additions_rows = match period {
"day" => {
sqlx::query(
r#" r#"
SELECT SELECT
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month, TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COUNT(*) AS books_added COALESCE(cnt.books_added, 0) AS books_added
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT created_at::date AS dt, COUNT(*) AS books_added
FROM books FROM books
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months' WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY DATE_TRUNC('month', created_at) GROUP BY created_at::date
) cnt ON cnt.dt = d.dt
ORDER BY month ASC ORDER BY month ASC
"#, "#,
) )
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_added, 0) AS books_added
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('week', created_at) AS dt, COUNT(*) AS books_added
FROM books
WHERE created_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', created_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
COALESCE(cnt.books_added, 0) AS books_added
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('month', created_at) AS dt, COUNT(*) AS books_added
FROM books
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', created_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let additions_over_time: Vec<MonthlyAdditions> = additions_rows let additions_over_time: Vec<MonthlyAdditions> = additions_rows
.iter() .iter()
@@ -356,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?;
@@ -377,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();
@@ -385,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?;
@@ -405,25 +504,88 @@ 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();
// Reading activity over time (last 12 months) // Reading activity over time (with gap filling)
let reading_time_rows = sqlx::query( let reading_time_rows = match period {
"day" => {
sqlx::query(
r#" r#"
SELECT SELECT
TO_CHAR(DATE_TRUNC('month', brp.last_read_at), 'YYYY-MM') AS month, TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COUNT(*) AS books_read COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read
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 >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY DATE_TRUNC('month', brp.last_read_at) AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY brp.last_read_at::date
) cnt ON cnt.dt = d.dt
ORDER BY month ASC ORDER BY month ASC
"#, "#,
) )
.bind(user_id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY DATE_TRUNC('week', brp.last_read_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
LEFT JOIN (
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
AND ($1::uuid IS NULL OR brp.user_id = $1)
GROUP BY DATE_TRUNC('month', brp.last_read_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.bind(user_id)
.fetch_all(&state.pool)
.await?
}
};
let reading_over_time: Vec<MonthlyReading> = reading_time_rows let reading_over_time: Vec<MonthlyReading> = reading_time_rows
.iter() .iter()
@@ -433,6 +595,212 @@ 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)
let jobs_rows = match period {
"day" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT
finished_at::date AS dt,
CASE
WHEN type = 'scan' THEN 'scan'
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
ELSE 'other'
END AS cat,
COUNT(*) AS c
FROM index_jobs
WHERE status IN ('success', 'failed')
AND finished_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY finished_at::date, cat
) cnt ON cnt.dt = d.dt
GROUP BY d.dt
ORDER BY label ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
FROM generate_series(
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
DATE_TRUNC('week', NOW()),
'1 week'
) AS d(dt)
LEFT JOIN (
SELECT
DATE_TRUNC('week', finished_at) AS dt,
CASE
WHEN type = 'scan' THEN 'scan'
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
ELSE 'other'
END AS cat,
COUNT(*) AS c
FROM index_jobs
WHERE status IN ('success', 'failed')
AND finished_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', finished_at), cat
) cnt ON cnt.dt = d.dt
GROUP BY d.dt
ORDER BY label ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS label,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
FROM generate_series(
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
DATE_TRUNC('month', NOW()),
'1 month'
) AS d(dt)
LEFT JOIN (
SELECT
DATE_TRUNC('month', finished_at) AS dt,
CASE
WHEN type = 'scan' THEN 'scan'
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
ELSE 'other'
END AS cat,
COUNT(*) AS c
FROM index_jobs
WHERE status IN ('success', 'failed')
AND finished_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', finished_at), cat
) cnt ON cnt.dt = d.dt
GROUP BY d.dt
ORDER BY label ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let jobs_over_time: Vec<JobTimePoint> = jobs_rows
.iter()
.map(|r| JobTimePoint {
label: r.get("label"),
scan: r.get("scan"),
rebuild: r.get("rebuild"),
thumbnail: r.get("thumbnail"),
other: r.get("other"),
})
.collect();
Ok(Json(StatsResponse { Ok(Json(StatsResponse {
overview, overview,
reading_status, reading_status,
@@ -444,6 +812,8 @@ pub async fn get_stats(
by_library, by_library,
top_series, top_series,
additions_over_time, additions_over_time,
jobs_over_time,
metadata, metadata,
users_reading_over_time,
})) }))
} }

View File

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

View File

@@ -1,7 +1,7 @@
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "../../../lib/api"; import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
import { getServerTranslations } from "../../../lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
import { BooksGrid } from "../../components/BookCard"; import { BooksGrid } from "@/app/components/BookCard";
import { OffsetPagination } from "../../components/ui"; import { OffsetPagination } from "@/app/components/ui";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";

View File

@@ -1,7 +1,7 @@
import { fetchAuthors, AuthorsPageDto } from "../../lib/api"; import { fetchAuthors, AuthorsPageDto } from "@/lib/api";
import { getServerTranslations } from "../../lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
import { LiveSearchForm } from "../components/LiveSearchForm"; import { LiveSearchForm } from "@/app/components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "../components/ui"; import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Link from "next/link"; import Link from "next/link";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";

View File

@@ -1,15 +1,15 @@
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api"; import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "@/lib/api";
import { BookPreview } from "../../components/BookPreview"; import { BookPreview } from "@/app/components/BookPreview";
import { ConvertButton } from "../../components/ConvertButton"; import { ConvertButton } from "@/app/components/ConvertButton";
import { MarkBookReadButton } from "../../components/MarkBookReadButton"; import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
import nextDynamic from "next/dynamic"; import nextDynamic from "next/dynamic";
import { SafeHtml } from "../../components/SafeHtml"; import { SafeHtml } from "@/app/components/SafeHtml";
import { getServerTranslations } from "../../../lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
const EditBookForm = nextDynamic( const EditBookForm = nextDynamic(
() => import("../../components/EditBookForm").then(m => m.EditBookForm) () => import("@/app/components/EditBookForm").then(m => m.EditBookForm)
); );
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";

View File

@@ -1,10 +1,10 @@
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "../../lib/api"; import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "@/lib/api";
import { BooksGrid, EmptyState } from "../components/BookCard"; import { BooksGrid, EmptyState } from "@/app/components/BookCard";
import { LiveSearchForm } from "../components/LiveSearchForm"; import { LiveSearchForm } from "@/app/components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "../components/ui"; import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { getServerTranslations } from "../../lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";

View File

@@ -2,13 +2,13 @@ export const dynamic = "force-dynamic";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "../../../lib/api"; import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "@/lib/api";
import { import {
Card, CardHeader, CardTitle, CardDescription, CardContent, Card, CardHeader, CardTitle, CardDescription, CardContent,
StatusBadge, JobTypeBadge, StatBox, ProgressBar StatusBadge, JobTypeBadge, StatBox, ProgressBar
} from "../../components/ui"; } from "@/app/components/ui";
import { JobDetailLive } from "../../components/JobDetailLive"; import { JobDetailLive } from "@/app/components/JobDetailLive";
import { getServerTranslations } from "../../../lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
interface JobDetailPageProps { interface JobDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -587,7 +587,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatBox value={refreshReport.refreshed} label={t("jobDetail.refreshed")} variant="success" /> <StatBox
value={refreshReport.refreshed}
label={t("jobDetail.refreshed")}
variant="success"
icon={
<svg className="w-6 h-6 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
}
/>
<StatBox value={refreshReport.unchanged} label={t("jobDetail.unchanged")} /> <StatBox value={refreshReport.unchanged} label={t("jobDetail.unchanged")} />
<StatBox value={refreshReport.errors} label={t("jobDetail.errors")} variant={refreshReport.errors > 0 ? "error" : "default"} /> <StatBox value={refreshReport.errors} label={t("jobDetail.errors")} variant={refreshReport.errors > 0 ? "error" : "default"} />
<StatBox value={refreshReport.total_links} label={t("jobDetail.total")} /> <StatBox value={refreshReport.total_links} label={t("jobDetail.total")} />

View File

@@ -1,9 +1,9 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "../../lib/api"; import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "@/lib/api";
import { JobsList } from "../components/JobsList"; import { JobsList } from "@/app/components/JobsList";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "../components/ui"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "@/app/components/ui";
import { getServerTranslations } from "../../lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";

View File

@@ -0,0 +1,127 @@
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { ThemeToggle } from "@/app/theme-toggle";
import { JobsIndicator } from "@/app/components/JobsIndicator";
import { NavIcon, Icon } from "@/app/components/ui";
import { LogoutButton } from "@/app/components/LogoutButton";
import { MobileNav } from "@/app/components/MobileNav";
import { UserSwitcher } from "@/app/components/UserSwitcher";
import { fetchUsers } from "@/lib/api";
import { getServerTranslations } from "@/lib/i18n/server";
import type { TranslationKey } from "@/lib/i18n/fr";
type NavItem = {
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
labelKey: TranslationKey;
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
};
const navItems: NavItem[] = [
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
{ href: "/books", labelKey: "nav.books", icon: "books" },
{ href: "/series", labelKey: "nav.series", icon: "series" },
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
];
export default async function AppLayout({ children }: { children: ReactNode }) {
const { t } = await getServerTranslations();
const cookieStore = await cookies();
const activeUserId = cookieStore.get("as_user_id")?.value || null;
const users = await fetchUsers().catch(() => []);
async function setActiveUserAction(formData: FormData) {
"use server";
const userId = formData.get("user_id") as string;
const store = await cookies();
if (userId) {
store.set("as_user_id", userId, { path: "/", httpOnly: false, sameSite: "lax" });
} else {
store.delete("as_user_id");
}
revalidatePath("/", "layout");
}
return (
<>
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
>
<Image src="/logo.png" alt="StripStream" width={36} height={36} className="rounded-lg" />
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tracking-tight text-foreground">StripStream</span>
<span className="text-sm text-muted-foreground font-medium hidden xl:inline">
{t("common.backoffice")}
</span>
</div>
</Link>
<div className="flex items-center gap-2">
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
<NavIcon name={item.icon} />
<span className="ml-2 hidden xl:inline">{t(item.labelKey)}</span>
</NavLink>
))}
</div>
<UserSwitcher
users={users}
activeUserId={activeUserId}
setActiveUserAction={setActiveUserAction}
/>
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
<JobsIndicator />
<Link
href="/settings"
className="hidden xl:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title={t("nav.settings")}
>
<Icon name="settings" size="md" />
</Link>
<ThemeToggle />
<LogoutButton />
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
</div>
</div>
</nav>
</header>
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
{children}
</main>
</>
);
}
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
return (
<Link
href={href}
title={title}
className="
flex items-center
px-2 lg:px-3 py-2
rounded-lg
text-sm font-medium
text-muted-foreground
hover:text-foreground
hover:bg-accent
transition-colors duration-200
active:scale-[0.98]
"
>
{children}
</Link>
);
}

View File

@@ -1,9 +1,9 @@
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api"; import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "@/lib/api";
import { BooksGrid, EmptyState } from "../../../components/BookCard"; import { BooksGrid, EmptyState } from "@/app/components/BookCard";
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader"; import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
import { OffsetPagination } from "../../../components/ui"; import { OffsetPagination } from "@/app/components/ui";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getServerTranslations } from "../../../../lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";

View File

@@ -1,24 +1,24 @@
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "../../../../../lib/api"; import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "@/lib/api";
import { BooksGrid, EmptyState } from "../../../../components/BookCard"; import { BooksGrid, EmptyState } from "@/app/components/BookCard";
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton"; import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton"; import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
import nextDynamic from "next/dynamic"; import nextDynamic from "next/dynamic";
import { OffsetPagination } from "../../../../components/ui"; import { OffsetPagination } from "@/app/components/ui";
import { SafeHtml } from "../../../../components/SafeHtml"; import { SafeHtml } from "@/app/components/SafeHtml";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
const EditSeriesForm = nextDynamic( const EditSeriesForm = nextDynamic(
() => import("../../../../components/EditSeriesForm").then(m => m.EditSeriesForm) () => import("@/app/components/EditSeriesForm").then(m => m.EditSeriesForm)
); );
const MetadataSearchModal = nextDynamic( const MetadataSearchModal = nextDynamic(
() => import("../../../../components/MetadataSearchModal").then(m => m.MetadataSearchModal) () => import("@/app/components/MetadataSearchModal").then(m => m.MetadataSearchModal)
); );
const ProwlarrSearchModal = nextDynamic( const ProwlarrSearchModal = nextDynamic(
() => import("../../../../components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal) () => import("@/app/components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
); );
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getServerTranslations } from "../../../../../lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";

View File

@@ -1,12 +1,12 @@
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api"; import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "@/lib/api";
import { OffsetPagination } from "../../../components/ui"; import { OffsetPagination } from "@/app/components/ui";
import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton"; import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
import { SeriesFilters } from "../../../components/SeriesFilters"; import { SeriesFilters } from "@/app/components/SeriesFilters";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader"; import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
import { getServerTranslations } from "../../../../lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";

View File

@@ -1,16 +1,16 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "../../lib/api"; import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "@/lib/api";
import type { TranslationKey } from "../../lib/i18n/fr"; import type { TranslationKey } from "@/lib/i18n/fr";
import { getServerTranslations } from "../../lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
import { LibraryActions } from "../components/LibraryActions"; import { LibraryActions } from "@/app/components/LibraryActions";
import { LibraryForm } from "../components/LibraryForm"; import { LibraryForm } from "@/app/components/LibraryForm";
import { ProviderIcon } from "../components/ProviderIcon"; import { ProviderIcon } from "@/app/components/ProviderIcon";
import { import {
Card, CardHeader, CardTitle, CardDescription, CardContent, Card, CardHeader, CardTitle, CardDescription, CardContent,
Button, Badge Button, Badge
} from "../components/ui"; } from "@/app/components/ui";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";

View File

@@ -1,11 +1,12 @@
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 "./components/ui"; import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui";
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar } from "./components/DashboardCharts"; import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts";
import Image from "next/image"; import { PeriodToggle } from "@/app/components/PeriodToggle";
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";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,6 +22,24 @@ function formatNumber(n: number, locale: string): string {
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US"); return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
} }
function formatChartLabel(raw: string, period: "day" | "week" | "month", locale: string): string {
const loc = locale === "fr" ? "fr-FR" : "en-US";
if (period === "month") {
// raw = "YYYY-MM"
const [y, m] = raw.split("-");
const d = new Date(Number(y), Number(m) - 1, 1);
return d.toLocaleDateString(loc, { month: "short" });
}
if (period === "week") {
// raw = "YYYY-MM-DD" (Monday of the week)
const d = new Date(raw + "T00:00:00");
return d.toLocaleDateString(loc, { day: "numeric", month: "short" });
}
// day: raw = "YYYY-MM-DD"
const d = new Date(raw + "T00:00:00");
return d.toLocaleDateString(loc, { weekday: "short", day: "numeric" });
}
// Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed) // Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed)
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) { function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
const pct = max > 0 ? (value / max) * 100 : 0; const pct = max > 0 ? (value / max) * 100 : 0;
@@ -40,12 +59,23 @@ function HorizontalBar({ label, value, max, subLabel, color = "var(--color-prima
); );
} }
export default async function DashboardPage() { export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParamsAwaited = await searchParams;
const rawPeriod = searchParamsAwaited.period;
const period = rawPeriod === "day" ? "day" as const : rawPeriod === "week" ? "week" as const : "month" as const;
const { 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(); [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);
} }
@@ -62,7 +92,20 @@ export default async function DashboardPage() {
); );
} }
const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, metadata } = stats; const {
overview,
reading_status,
currently_reading = [],
recently_read = [],
reading_over_time = [],
users_reading_over_time = [],
by_format,
by_library,
top_series,
additions_over_time,
jobs_over_time = [],
metadata = { total_series: 0, series_linked: 0, series_unlinked: 0, books_with_summary: 0, books_with_isbn: 0, by_provider: [] },
} = stats;
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"]; const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
const formatColors = [ const formatColors = [
@@ -107,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"> 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>
@@ -147,57 +165,65 @@ 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"> 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>
)} )}
{/* Reading activity line chart */} {/* Reading activity line chart */}
{reading_over_time.length > 0 && (
<Card hover={false}> <Card hover={false}>
<CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle> <CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
<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: m.month.slice(5), 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={[
@@ -206,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>
@@ -351,20 +405,47 @@ export default async function DashboardPage() {
</Card> </Card>
</div> </div>
{/* Monthly additions line chart full width */} {/* Additions line chart full width */}
<Card hover={false}> <Card hover={false}>
<CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle> <CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<RcAreaChart <RcAreaChart
noDataLabel={noDataLabel} noDataLabel={noDataLabel}
data={additions_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_added }))} data={additions_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_added }))}
color="hsl(198 78% 37%)" color="hsl(198 78% 37%)"
/> />
</CardContent> </CardContent>
</Card> </Card>
{/* Jobs over time multi-line chart */}
<Card hover={false}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.jobsOverTime")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader>
<CardContent>
<RcMultiLineChart
noDataLabel={noDataLabel}
data={jobs_over_time.map((j) => ({
label: formatChartLabel(j.label, period, locale),
scan: j.scan,
rebuild: j.rebuild,
thumbnail: j.thumbnail,
other: j.other,
}))}
lines={[
{ key: "scan", label: t("dashboard.jobScan"), color: "hsl(198 78% 37%)" },
{ key: "rebuild", label: t("dashboard.jobRebuild"), color: "hsl(142 60% 45%)" },
{ key: "thumbnail", label: t("dashboard.jobThumbnail"), color: "hsl(45 93% 47%)" },
{ key: "other", label: t("dashboard.jobOther"), color: "hsl(280 60% 50%)" },
]}
/>
</CardContent>
</Card>
{/* Quick links */} {/* Quick links */}
<QuickLinks t={t} /> <QuickLinks t={t} />
</div> </div>

View File

@@ -1,11 +1,11 @@
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api"; import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
import { getServerTranslations } from "../../lib/i18n/server"; import { getServerTranslations } from "@/lib/i18n/server";
import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton"; import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
import { LiveSearchForm } from "../components/LiveSearchForm"; import { LiveSearchForm } from "@/app/components/LiveSearchForm";
import { Card, CardContent, OffsetPagination } from "../components/ui"; import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ProviderIcon } from "../components/ProviderIcon"; import { ProviderIcon } from "@/app/components/ProviderIcon";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";

View File

@@ -1,19 +1,20 @@
"use client"; "use client";
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 "../components/ui"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "@/app/components/ui";
import { ProviderIcon } from "../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";
interface SettingsPageProps { 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 {
@@ -620,16 +623,29 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
<FormField className="flex-1"> <FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.password")}</label> <label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.password")}</label>
<FormInput <FormInput
type="password" type="password" autoComplete="off"
value={komgaPassword} value={komgaPassword}
onChange={(e) => setKomgaPassword(e.target.value)} onChange={(e) => setKomgaPassword(e.target.value)}
/> />
</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 ? (
<> <>
@@ -955,7 +971,7 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
{t("settings.googleBooksKey")} {t("settings.googleBooksKey")}
</label> </label>
<FormInput <FormInput
type="password" type="password" autoComplete="off"
placeholder={t("settings.googleBooksPlaceholder")} placeholder={t("settings.googleBooksPlaceholder")}
value={apiKeys.google_books || ""} value={apiKeys.google_books || ""}
onChange={(e) => setApiKeys({ ...apiKeys, google_books: e.target.value })} onChange={(e) => setApiKeys({ ...apiKeys, google_books: e.target.value })}
@@ -970,7 +986,7 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
{t("settings.comicvineKey")} {t("settings.comicvineKey")}
</label> </label>
<FormInput <FormInput
type="password" type="password" autoComplete="off"
placeholder={t("settings.comicvinePlaceholder")} placeholder={t("settings.comicvinePlaceholder")}
value={apiKeys.comicvine || ""} value={apiKeys.comicvine || ""}
onChange={(e) => setApiKeys({ ...apiKeys, comicvine: e.target.value })} onChange={(e) => setApiKeys({ ...apiKeys, comicvine: e.target.value })}
@@ -1312,7 +1328,7 @@ function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
<FormField className="flex-1"> <FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrApiKey")}</label> <label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrApiKey")}</label>
<FormInput <FormInput
type="password" type="password" autoComplete="off"
placeholder={t("settings.prowlarrApiKeyPlaceholder")} placeholder={t("settings.prowlarrApiKeyPlaceholder")}
value={prowlarrApiKey} value={prowlarrApiKey}
onChange={(e) => setProwlarrApiKey(e.target.value)} onChange={(e) => setProwlarrApiKey(e.target.value)}
@@ -1450,7 +1466,7 @@ function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: s
<FormField className="flex-1"> <FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.qbittorrentPassword")}</label> <label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.qbittorrentPassword")}</label>
<FormInput <FormInput
type="password" type="password" autoComplete="off"
value={qbPassword} value={qbPassword}
onChange={(e) => setQbPassword(e.target.value)} onChange={(e) => setQbPassword(e.target.value)}
onBlur={() => saveQbittorrent()} onBlur={() => saveQbittorrent()}
@@ -1616,7 +1632,7 @@ function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
<FormField className="flex-1"> <FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.botToken")}</label> <label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.botToken")}</label>
<FormInput <FormInput
type="password" type="password" autoComplete="off"
placeholder={t("settings.botTokenPlaceholder")} placeholder={t("settings.botTokenPlaceholder")}
value={botToken} value={botToken}
onChange={(e) => setBotToken(e.target.value)} onChange={(e) => setBotToken(e.target.value)}

View File

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

View File

@@ -0,0 +1,316 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listTokens, createToken, revokeToken, deleteToken, updateToken, fetchUsers, createUser, deleteUser, updateUser, TokenDto, UserDto } from "@/lib/api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "@/app/components/ui";
import { TokenUserSelect } from "@/app/components/TokenUserSelect";
import { UsernameEdit } from "@/app/components/UsernameEdit";
import { getServerTranslations } from "@/lib/i18n/server";
export const dynamic = "force-dynamic";
export default async function TokensPage({
searchParams
}: {
searchParams: Promise<{ created?: string }>;
}) {
const { t } = await getServerTranslations();
const params = await searchParams;
const tokens = await listTokens().catch(() => [] as TokenDto[]);
const users = await fetchUsers().catch(() => [] as UserDto[]);
async function createTokenAction(formData: FormData) {
"use server";
const name = formData.get("name") as string;
const scope = formData.get("scope") as string;
const userId = (formData.get("user_id") as string) || undefined;
if (name) {
const result = await createToken(name, scope, userId);
revalidatePath("/tokens");
redirect(`/tokens?created=${encodeURIComponent(result.token)}`);
}
}
async function revokeTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await revokeToken(id);
revalidatePath("/tokens");
}
async function deleteTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await deleteToken(id);
revalidatePath("/tokens");
}
async function createUserAction(formData: FormData) {
"use server";
const username = formData.get("username") as string;
if (username) {
await createUser(username);
revalidatePath("/tokens");
}
}
async function deleteUserAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await deleteUser(id);
revalidatePath("/tokens");
}
async function renameUserAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
const username = formData.get("username") as string;
if (username?.trim()) {
await updateUser(id, username.trim());
revalidatePath("/tokens");
}
}
async function reassignTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
const userId = (formData.get("user_id") as string) || null;
await updateToken(id, userId);
revalidatePath("/tokens");
}
return (
<>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{t("tokens.title")}
</h1>
</div>
{/* ── Lecteurs ─────────────────────────────────────────── */}
<div className="mb-2">
<h2 className="text-xl font-semibold text-foreground">{t("users.title")}</h2>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>{t("users.createNew")}</CardTitle>
<CardDescription>{t("users.createDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form action={createUserAction}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="username" placeholder={t("users.username")} required autoComplete="off" />
</FormField>
<Button type="submit">{t("users.createButton")}</Button>
</FormRow>
</form>
</CardContent>
</Card>
<Card className="overflow-hidden mb-10">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.name")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.tokenCount")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.read")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.reading")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.createdAt")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{/* Ligne admin synthétique */}
<tr className="hover:bg-accent/50 transition-colors bg-destructive/5">
<td className="px-4 py-3 text-sm font-medium text-foreground flex items-center gap-2">
{process.env.ADMIN_USERNAME ?? "admin"}
<Badge variant="destructive">{t("tokens.scopeAdmin")}</Badge>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{tokens.filter(tok => tok.scope === "admin" && !tok.revoked_at).length}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
</tr>
{/* Ligne tokens read non assignés */}
{(() => {
const unassigned = tokens.filter(tok => tok.scope === "read" && !tok.user_id && !tok.revoked_at);
if (unassigned.length === 0) return null;
return (
<tr className="hover:bg-accent/50 transition-colors bg-warning/5">
<td className="px-4 py-3 text-sm font-medium text-muted-foreground italic">
{t("tokens.noUser")}
</td>
<td className="px-4 py-3 text-sm text-warning font-medium">{unassigned.length}</td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
<td className="px-4 py-3 text-sm text-muted-foreground/50"></td>
</tr>
);
})()}
{users.map((user) => (
<tr key={user.id} className="hover:bg-accent/50 transition-colors">
<td className="px-4 py-3">
<UsernameEdit userId={user.id} currentUsername={user.username} action={renameUserAction} />
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{user.token_count}</td>
<td className="px-4 py-3 text-sm">
{user.books_read > 0
? <span className="font-medium text-success">{user.books_read}</span>
: <span className="text-muted-foreground/50"></span>}
</td>
<td className="px-4 py-3 text-sm">
{user.books_reading > 0
? <span className="font-medium text-amber-500">{user.books_reading}</span>
: <span className="text-muted-foreground/50"></span>}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<form action={deleteUserAction}>
<input type="hidden" name="id" value={user.id} />
<Button type="submit" variant="destructive" size="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 ? (
<Card className="mb-6 border-success/50 bg-success/5">
<CardHeader>
<CardTitle className="text-success">{t("tokens.created")}</CardTitle>
<CardDescription>{t("tokens.createdDescription")}</CardDescription>
</CardHeader>
<CardContent>
<pre className="p-4 bg-background rounded-lg text-sm font-mono text-foreground overflow-x-auto border">{params.created}</pre>
</CardContent>
</Card>
) : null}
<Card className="mb-6">
<CardHeader>
<CardTitle>{t("tokens.createNew")}</CardTitle>
<CardDescription>{t("tokens.createDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form action={createTokenAction}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="name" placeholder={t("tokens.tokenName")} required autoComplete="off" />
</FormField>
<FormField className="w-32">
<FormSelect name="scope" defaultValue="read">
<option value="read">{t("tokens.scopeRead")}</option>
<option value="admin">{t("tokens.scopeAdmin")}</option>
</FormSelect>
</FormField>
<FormField className="w-48">
<FormSelect name="user_id" defaultValue="">
<option value="">{t("tokens.noUser")}</option>
{users.map((user) => (
<option key={user.id} value={user.id}>{user.username}</option>
))}
</FormSelect>
</FormField>
<Button type="submit">{t("tokens.createButton")}</Button>
</FormRow>
</form>
</CardContent>
</Card>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.name")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.user")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.scope")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.prefix")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.status")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{tokens.map((token) => (
<tr key={token.id} className="hover:bg-accent/50 transition-colors">
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
<td className="px-4 py-3 text-sm">
<TokenUserSelect
tokenId={token.id}
currentUserId={token.user_id}
users={users}
action={reassignTokenAction}
noUserLabel={t("tokens.noUser")}
/>
</td>
<td className="px-4 py-3 text-sm">
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
{token.scope}
</Badge>
</td>
<td className="px-4 py-3 text-sm">
<code className="px-2 py-1 bg-muted rounded font-mono text-foreground">{token.prefix}</code>
</td>
<td className="px-4 py-3 text-sm">
{token.revoked_at ? (
<Badge variant="error">{t("tokens.revoked")}</Badge>
) : (
<Badge variant="success">{t("tokens.active")}</Badge>
)}
</td>
<td className="px-4 py-3">
{!token.revoked_at ? (
<form action={revokeTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="destructive" size="sm">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t("tokens.revoke")}
</Button>
</form>
) : (
<form action={deleteTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="destructive" size="sm">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{t("common.delete")}
</Button>
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { createSessionToken, SESSION_COOKIE } from "@/lib/session";
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
if (!body || typeof body.username !== "string" || typeof body.password !== "string") {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const expectedUsername = process.env.ADMIN_USERNAME || "admin";
const expectedPassword = process.env.ADMIN_PASSWORD;
if (!expectedPassword) {
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
}
if (body.username !== expectedUsername || body.password !== expectedPassword) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const token = await createSessionToken();
const response = NextResponse.json({ success: true });
response.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60,
path: "/",
});
return response;
}

View File

@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE } from "@/lib/session";
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.delete(SESSION_COOKIE);
return response;
}

View File

@@ -9,10 +9,25 @@ export async function GET(
try { try {
const { baseUrl, token } = config(); const { baseUrl, token } = config();
const ifNoneMatch = request.headers.get("if-none-match");
const fetchHeaders: Record<string, string> = {
Authorization: `Bearer ${token}`,
};
if (ifNoneMatch) {
fetchHeaders["If-None-Match"] = ifNoneMatch;
}
const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, { const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, {
headers: { Authorization: `Bearer ${token}` }, headers: fetchHeaders,
next: { revalidate: 86400 },
}); });
// Forward 304 Not Modified as-is
if (response.status === 304) {
return new NextResponse(null, { status: 304 });
}
if (!response.ok) { if (!response.ok) {
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, { return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
status: response.status status: response.status
@@ -20,13 +35,17 @@ export async function GET(
} }
const contentType = response.headers.get("content-type") || "image/webp"; const contentType = response.headers.get("content-type") || "image/webp";
const etag = response.headers.get("etag");
return new NextResponse(response.body, { const headers: Record<string, string> = {
headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable", "Cache-Control": "public, max-age=31536000, immutable",
}, };
}); if (etag) {
headers["ETag"] = etag;
}
return new NextResponse(response.body, { headers });
} catch (error) { } catch (error) {
console.error("Error fetching thumbnail:", error); console.error("Error fetching thumbnail:", error);
return new NextResponse("Failed to fetch thumbnail", { status: 500 }); return new NextResponse("Failed to fetch thumbnail", { status: 500 });

View File

@@ -3,7 +3,7 @@
import { import {
PieChart, Pie, Cell, ResponsiveContainer, Tooltip, PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
BarChart, Bar, XAxis, YAxis, CartesianGrid, BarChart, Bar, XAxis, YAxis, CartesianGrid,
AreaChart, Area, AreaChart, Area, Line, LineChart,
Legend, Legend,
} from "recharts"; } from "recharts";
@@ -186,3 +186,46 @@ export function RcHorizontalBar({
</ResponsiveContainer> </ResponsiveContainer>
); );
} }
// ---------------------------------------------------------------------------
// Multi-line chart (jobs over time)
// ---------------------------------------------------------------------------
export function RcMultiLineChart({
data,
lines,
noDataLabel,
}: {
data: Record<string, unknown>[];
lines: { key: string; label: string; color: string }[];
noDataLabel?: string;
}) {
const hasData = data.some((d) => lines.some((l) => (d[l.key] as number) > 0));
if (data.length === 0 || !hasData)
return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
return (
<ResponsiveContainer width="100%" height={180}>
<LineChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
{lines.map((l) => (
<Line
key={l.key}
type="monotone"
dataKey={l.key}
name={l.label}
stroke={l.color}
strokeWidth={2}
dot={{ r: 3, fill: l.color }}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -4,7 +4,7 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "../../lib/i18n/context"; import { useTranslation } from "../../lib/i18n/context";
import { JobProgress } from "./JobProgress"; import { JobProgress } from "./JobProgress";
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon } from "./ui"; import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon, Tooltip } from "./ui";
interface JobRowProps { interface JobRowProps {
job: { job: {
@@ -21,6 +21,7 @@ interface JobRowProps {
indexed_files: number; indexed_files: number;
removed_files: number; removed_files: number;
errors: number; errors: number;
refreshed?: number;
} | null; } | null;
progress_percent: number | null; progress_percent: number | null;
processed_files: number | null; processed_files: number | null;
@@ -117,49 +118,74 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
<div className="flex items-center gap-3 text-xs"> <div className="flex items-center gap-3 text-xs">
{/* Files: indexed count */} {/* Files: indexed count */}
{indexed > 0 && ( {indexed > 0 && (
<span className="inline-flex items-center gap-1 text-success" title={t("jobRow.filesIndexed", { count: indexed })}> <Tooltip label={t("jobRow.filesIndexed", { count: indexed })}>
<span className="inline-flex items-center gap-1 text-success">
<Icon name="document" size="sm" /> <Icon name="document" size="sm" />
{indexed} {indexed}
</span> </span>
</Tooltip>
)} )}
{/* Removed files */} {/* Removed files */}
{removed > 0 && ( {removed > 0 && (
<span className="inline-flex items-center gap-1 text-warning" title={t("jobRow.filesRemoved", { count: removed })}> <Tooltip label={t("jobRow.filesRemoved", { count: removed })}>
<span className="inline-flex items-center gap-1 text-warning">
<Icon name="trash" size="sm" /> <Icon name="trash" size="sm" />
{removed} {removed}
</span> </span>
</Tooltip>
)} )}
{/* Thumbnails */} {/* Thumbnails */}
{hasThumbnailPhase && job.total_files != null && job.total_files > 0 && ( {hasThumbnailPhase && job.total_files != null && job.total_files > 0 && (
<span className="inline-flex items-center gap-1 text-primary" title={t("jobRow.thumbnailsGenerated", { count: job.total_files })}> <Tooltip label={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
<span className="inline-flex items-center gap-1 text-primary">
<Icon name="image" size="sm" /> <Icon name="image" size="sm" />
{job.total_files} {job.total_files}
</span> </span>
</Tooltip>
)} )}
{/* Metadata batch: series processed */} {/* Metadata batch: series processed */}
{isMetadataBatch && job.total_files != null && job.total_files > 0 && ( {isMetadataBatch && job.total_files != null && job.total_files > 0 && (
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataProcessed", { count: job.total_files })}> <Tooltip label={t("jobRow.metadataProcessed", { count: job.total_files })}>
<span className="inline-flex items-center gap-1 text-info">
<Icon name="tag" size="sm" /> <Icon name="tag" size="sm" />
{job.total_files} {job.total_files}
</span> </span>
</Tooltip>
)} )}
{/* Metadata refresh: links refreshed */} {/* Metadata refresh: total links + refreshed count */}
{isMetadataRefresh && job.total_files != null && job.total_files > 0 && ( {isMetadataRefresh && job.total_files != null && job.total_files > 0 && (
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataRefreshed", { count: job.total_files })}> <Tooltip label={t("jobRow.metadataLinks", { count: job.total_files })}>
<span className="inline-flex items-center gap-1 text-info">
<Icon name="tag" size="sm" /> <Icon name="tag" size="sm" />
{job.total_files} {job.total_files}
</span> </span>
</Tooltip>
)}
{isMetadataRefresh && job.stats_json?.refreshed != null && job.stats_json.refreshed > 0 && (
<Tooltip label={t("jobRow.metadataRefreshed", { count: job.stats_json.refreshed })}>
<span className="inline-flex items-center gap-1 text-success">
<Icon name="refresh" size="sm" />
{job.stats_json.refreshed}
</span>
</Tooltip>
)} )}
{/* Errors */} {/* Errors */}
{errors > 0 && ( {errors > 0 && (
<span className="inline-flex items-center gap-1 text-error" title={t("jobRow.errors", { count: errors })}> <Tooltip label={t("jobRow.errors", { count: errors })}>
<span className="inline-flex items-center gap-1 text-error">
<Icon name="warning" size="sm" /> <Icon name="warning" size="sm" />
{errors} {errors}
</span> </span>
</Tooltip>
)} )}
{/* Scanned only (no other stats) */} {/* Scanned only (no other stats) */}
{indexed === 0 && removed === 0 && errors === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && scanned > 0 && ( {indexed === 0 && removed === 0 && errors === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && scanned > 0 && (
<span className="text-sm text-muted-foreground">{t("jobRow.scanned", { count: scanned })}</span> <Tooltip label={t("jobRow.scanned", { count: scanned })}>
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Icon name="search" size="sm" />
{scanned}
</span>
</Tooltip>
)} )}
{/* Nothing to show */} {/* Nothing to show */}
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && ( {indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && (
@@ -179,16 +205,23 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href={`/jobs/${job.id}`} href={`/jobs/${job.id}`}
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors" className="inline-flex items-center justify-center gap-1.5 h-7 px-2.5 text-xs font-medium rounded-md bg-primary text-white hover:bg-primary/90 transition-colors"
> >
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{t("jobRow.view")} {t("jobRow.view")}
</Link> </Link>
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && ( {(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
<Button <Button
variant="danger" variant="danger"
size="sm" size="xs"
onClick={() => onCancel(job.id)} onClick={() => onCancel(job.id)}
> >
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
)} )}

View File

@@ -18,6 +18,7 @@ interface Job {
indexed_files: number; indexed_files: number;
removed_files: number; removed_files: number;
errors: number; errors: number;
refreshed?: number;
} | null; } | null;
progress_percent: number | null; progress_percent: number | null;
processed_files: number | null; processed_files: number | null;

View File

@@ -0,0 +1,27 @@
"use client";
import { useRouter } from "next/navigation";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
}
return (
<button
onClick={handleLogout}
title="Se déconnecter"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
type Period = "day" | "week" | "month";
export function PeriodToggle({
labels,
}: {
labels: { day: string; week: string; month: string };
}) {
const router = useRouter();
const searchParams = useSearchParams();
const raw = searchParams.get("period");
const current: Period = raw === "day" ? "day" : raw === "week" ? "week" : "month";
function setPeriod(period: Period) {
const params = new URLSearchParams(searchParams.toString());
if (period === "month") {
params.delete("period");
} else {
params.set("period", period);
}
const qs = params.toString();
router.push(qs ? `?${qs}` : "/", { scroll: false });
}
const options: Period[] = ["day", "week", "month"];
return (
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
{options.map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
current === p
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{labels[p]}
</button>
))}
</div>
);
}

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

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

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

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

View File

@@ -14,7 +14,7 @@ type ButtonVariant =
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode; children: ReactNode;
variant?: ButtonVariant; variant?: ButtonVariant;
size?: "sm" | "md" | "lg"; size?: "xs" | "sm" | "md" | "lg";
} }
const variantStyles: Record<ButtonVariant, string> = { const variantStyles: Record<ButtonVariant, string> = {
@@ -33,6 +33,7 @@ const variantStyles: Record<ButtonVariant, string> = {
}; };
const sizeStyles: Record<string, string> = { const sizeStyles: Record<string, string> = {
xs: "h-7 px-2.5 text-xs rounded-md",
sm: "h-9 px-3 text-xs rounded-md", sm: "h-9 px-3 text-xs rounded-md",
md: "h-10 px-4 py-2 text-sm rounded-md", md: "h-10 px-4 py-2 text-sm rounded-md",
lg: "h-11 px-8 text-base rounded-md", lg: "h-11 px-8 text-base rounded-md",

View File

@@ -4,6 +4,7 @@ interface StatBoxProps {
value: ReactNode; value: ReactNode;
label: string; label: string;
variant?: "default" | "primary" | "success" | "warning" | "error"; variant?: "default" | "primary" | "success" | "warning" | "error";
icon?: ReactNode;
className?: string; className?: string;
} }
@@ -23,10 +24,13 @@ const valueVariantStyles: Record<string, string> = {
error: "text-destructive", error: "text-destructive",
}; };
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) { export function StatBox({ value, label, variant = "default", icon, className = "" }: StatBoxProps) {
return ( return (
<div className={`text-center p-4 rounded-lg transition-colors duration-200 ${variantStyles[variant]} ${className}`}> <div className={`text-center p-4 rounded-lg transition-colors duration-200 ${variantStyles[variant]} ${className}`}>
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span> <div className={`flex items-center justify-center gap-1.5 ${valueVariantStyles[variant]}`}>
{icon && <span className="text-xl">{icon}</span>}
<span className="text-3xl font-bold">{value}</span>
</div>
<span className={`text-xs text-muted-foreground`}>{label}</span> <span className={`text-xs text-muted-foreground`}>{label}</span>
</div> </div>
); );

View File

@@ -0,0 +1,18 @@
import { ReactNode } from "react";
interface TooltipProps {
label: string;
children: ReactNode;
className?: string;
}
export function Tooltip({ label, children, className = "" }: TooltipProps) {
return (
<span className={`relative group/tooltip inline-flex ${className}`}>
{children}
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1 text-xs text-popover-foreground bg-popover border border-border rounded-lg shadow-lg whitespace-nowrap opacity-0 scale-95 transition-all duration-150 group-hover/tooltip:opacity-100 group-hover/tooltip:scale-100 z-50">
{label}
</span>
</span>
);
}

View File

@@ -19,3 +19,4 @@ export {
} from "./Form"; } from "./Form";
export { PageIcon, NavIcon, Icon } from "./Icon"; export { PageIcon, NavIcon, Icon } from "./Icon";
export { CursorPagination, OffsetPagination } from "./Pagination"; export { CursorPagination, OffsetPagination } from "./Pagination";
export { Tooltip } from "./Tooltip";

View File

@@ -1,130 +1,27 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "./theme-provider"; import { ThemeProvider } from "./theme-provider";
import { ThemeToggle } from "./theme-toggle"; import { LocaleProvider } from "@/lib/i18n/context";
import { JobsIndicator } from "./components/JobsIndicator"; import { getServerLocale } from "@/lib/i18n/server";
import { NavIcon, Icon } from "./components/ui";
import { MobileNav } from "./components/MobileNav";
import { LocaleProvider } from "../lib/i18n/context";
import { getServerLocale, getServerTranslations } from "../lib/i18n/server";
import type { TranslationKey } from "../lib/i18n/fr";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "StripStream Backoffice", title: "StripStream Backoffice",
description: "Administration backoffice pour StripStream Librarian" description: "Administration backoffice pour StripStream Librarian"
}; };
type NavItem = {
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
labelKey: TranslationKey;
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
};
const navItems: NavItem[] = [
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
{ href: "/books", labelKey: "nav.books", icon: "books" },
{ href: "/series", labelKey: "nav.series", icon: "series" },
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
];
export default async function RootLayout({ children }: { children: ReactNode }) { export default async function RootLayout({ children }: { children: ReactNode }) {
const locale = await getServerLocale(); const locale = await getServerLocale();
const { t } = await getServerTranslations();
return ( return (
<html lang={locale} suppressHydrationWarning> <html lang={locale} suppressHydrationWarning>
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain"> <body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
<ThemeProvider> <ThemeProvider>
<LocaleProvider initialLocale={locale}> <LocaleProvider initialLocale={locale}>
{/* Header avec effet glassmorphism */}
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
{/* Brand */}
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
>
<Image
src="/logo.png"
alt="StripStream"
width={36}
height={36}
className="rounded-lg"
/>
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tracking-tight text-foreground">
StripStream
</span>
<span className="text-sm text-muted-foreground font-medium hidden md:inline">
{t("common.backoffice")}
</span>
</div>
</Link>
{/* Navigation Links */}
<div className="flex items-center gap-2">
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
<NavIcon name={item.icon} />
<span className="ml-2 hidden lg:inline">{t(item.labelKey)}</span>
</NavLink>
))}
</div>
{/* Actions */}
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
<JobsIndicator />
<Link
href="/settings"
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title={t("nav.settings")}
>
<Icon name="settings" size="md" />
</Link>
<ThemeToggle />
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
</div>
</div>
</nav>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
{children} {children}
</main>
</LocaleProvider> </LocaleProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
); );
} }
// Navigation Link Component
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
return (
<Link
href={href}
title={title}
className="
flex items-center
px-2 lg:px-3 py-2
rounded-lg
text-sm font-medium
text-muted-foreground
hover:text-foreground
hover:bg-accent
transition-colors duration-200
active:scale-[0.98]
"
>
{children}
</Link>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
import { useState, Suspense } from "react";
function LoginForm() {
const searchParams = useSearchParams();
const from = searchParams.get("from") || "/";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (res.ok) {
window.location.href = from;
} else {
const data = await res.json().catch(() => ({}));
setError(data.error || "Identifiants invalides");
}
} catch {
setError("Erreur réseau");
} finally {
setLoading(false);
}
}
return (
<div className="relative min-h-screen flex flex-col items-center justify-center px-4 py-16 overflow-hidden">
{/* Background logo */}
<Image
src="/logo.png"
alt=""
fill
className="object-cover opacity-20"
priority
aria-hidden
/>
{/* Hero */}
<div className="relative flex flex-col items-center mb-10">
<h1 className="text-4xl font-bold tracking-tight text-foreground">
StripStream{" "}
<span className="text-primary font-light">: Librarian</span>
</h1>
<p className="text-sm text-muted-foreground mt-1.5 tracking-wide uppercase font-medium">
Administration
</p>
</div>
{/* Form card */}
<div
className="relative w-full max-w-sm rounded-2xl border border-white/20 backdrop-blur-sm p-8"
style={{ boxShadow: "0 24px 48px -12px rgb(0 0 0 / 0.18)" }}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1.5">
Identifiant
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus
required
disabled={loading}
placeholder="admin"
className="
flex w-full h-11 px-4
rounded-xl border border-input bg-background/60
text-sm text-foreground
placeholder:text-muted-foreground/40
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring
disabled:opacity-50
transition-all duration-200
"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
Mot de passe
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
disabled={loading}
placeholder="••••••••"
className="
flex w-full h-11 px-4
rounded-xl border border-input bg-background/60
text-sm text-foreground
placeholder:text-muted-foreground/40
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring
disabled:opacity-50
transition-all duration-200
"
/>
</div>
{error && (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-destructive/10 border border-destructive/20 text-sm text-destructive">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="
w-full h-11 mt-2
inline-flex items-center justify-center gap-2
rounded-xl font-medium text-sm
bg-primary text-primary-foreground
hover:bg-primary/90
transition-all duration-200 ease-out
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
active:scale-[0.98]
"
style={{ boxShadow: "0 4px 16px -4px hsl(198 78% 37% / 0.5)" }}
>
{loading ? (
<>
<svg className="animate-spin" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Connexion
</>
) : "Se connecter"}
</button>
</form>
</div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}

View File

@@ -1,151 +0,0 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listTokens, createToken, revokeToken, deleteToken, TokenDto } from "../../lib/api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
import { getServerTranslations } from "../../lib/i18n/server";
export const dynamic = "force-dynamic";
export default async function TokensPage({
searchParams
}: {
searchParams: Promise<{ created?: string }>;
}) {
const { t } = await getServerTranslations();
const params = await searchParams;
const tokens = await listTokens().catch(() => [] as TokenDto[]);
async function createTokenAction(formData: FormData) {
"use server";
const name = formData.get("name") as string;
const scope = formData.get("scope") as string;
if (name) {
const result = await createToken(name, scope);
revalidatePath("/tokens");
redirect(`/tokens?created=${encodeURIComponent(result.token)}`);
}
}
async function revokeTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await revokeToken(id);
revalidatePath("/tokens");
}
async function deleteTokenAction(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await deleteToken(id);
revalidatePath("/tokens");
}
return (
<>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{t("tokens.title")}
</h1>
</div>
{params.created ? (
<Card className="mb-6 border-success/50 bg-success/5">
<CardHeader>
<CardTitle className="text-success">{t("tokens.created")}</CardTitle>
<CardDescription>{t("tokens.createdDescription")}</CardDescription>
</CardHeader>
<CardContent>
<pre className="p-4 bg-background rounded-lg text-sm font-mono text-foreground overflow-x-auto border">{params.created}</pre>
</CardContent>
</Card>
) : null}
<Card className="mb-6">
<CardHeader>
<CardTitle>{t("tokens.createNew")}</CardTitle>
<CardDescription>{t("tokens.createDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form action={createTokenAction}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="name" placeholder={t("tokens.tokenName")} required />
</FormField>
<FormField className="w-32">
<FormSelect name="scope" defaultValue="read">
<option value="read">{t("tokens.scopeRead")}</option>
<option value="admin">{t("tokens.scopeAdmin")}</option>
</FormSelect>
</FormField>
<Button type="submit">{t("tokens.createButton")}</Button>
</FormRow>
</form>
</CardContent>
</Card>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.name")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.scope")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.prefix")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.status")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{tokens.map((token) => (
<tr key={token.id} className="hover:bg-accent/50 transition-colors">
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
<td className="px-4 py-3 text-sm">
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
{token.scope}
</Badge>
</td>
<td className="px-4 py-3 text-sm">
<code className="px-2 py-1 bg-muted rounded font-mono text-foreground">{token.prefix}</code>
</td>
<td className="px-4 py-3 text-sm">
{token.revoked_at ? (
<Badge variant="error">{t("tokens.revoked")}</Badge>
) : (
<Badge variant="success">{t("tokens.active")}</Badge>
)}
</td>
<td className="px-4 py-3">
{!token.revoked_at ? (
<form action={revokeTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="destructive" size="sm">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t("tokens.revoke")}
</Button>
</form>
) : (
<form action={deleteTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="destructive" size="sm">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{t("common.delete")}
</Button>
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
);
}

View File

@@ -32,6 +32,7 @@ export type IndexJobDto = {
removed_files: number; removed_files: number;
errors: number; errors: number;
warnings: number; warnings: number;
refreshed?: number;
} | null; } | null;
progress_percent: number | null; progress_percent: number | null;
processed_files: number | null; processed_files: number | null;
@@ -44,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 = {
@@ -150,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}`, {
@@ -267,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 }),
}); });
} }
@@ -282,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,
@@ -556,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 = {
@@ -563,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 = {
@@ -570,22 +623,39 @@ export type MonthlyReading = {
books_read: number; books_read: number;
}; };
export type UserMonthlyReading = {
month: string;
username: string;
books_read: number;
};
export type JobTimePoint = {
label: string;
scan: number;
rebuild: number;
thumbnail: number;
other: number;
};
export type StatsResponse = { export type StatsResponse = {
overview: StatsOverview; overview: StatsOverview;
reading_status: ReadingStatusStats; reading_status: ReadingStatusStats;
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[];
top_series: TopSeriesItem[]; top_series: TopSeriesItem[];
additions_over_time: MonthlyAdditions[]; additions_over_time: MonthlyAdditions[];
jobs_over_time: JobTimePoint[];
metadata: MetadataStats; metadata: MetadataStats;
}; };
export async function fetchStats() { export async function fetchStats(period?: "day" | "week" | "month") {
return apiFetch<StatsResponse>("/stats", { next: { revalidate: 30 } }); const params = period && period !== "month" ? `?period=${period}` : "";
return apiFetch<StatsResponse>(`/stats${params}`, { next: { revalidate: 30 } });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -688,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;
@@ -706,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;

View File

@@ -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",
@@ -70,7 +71,15 @@ const en: Record<TranslationKey, string> = {
"dashboard.readingStatus": "Reading status", "dashboard.readingStatus": "Reading status",
"dashboard.byFormat": "By format", "dashboard.byFormat": "By format",
"dashboard.byLibrary": "By library", "dashboard.byLibrary": "By library",
"dashboard.booksAdded": "Books added (last 12 months)", "dashboard.booksAdded": "Books added",
"dashboard.jobsOverTime": "Job runs",
"dashboard.jobScan": "Scan",
"dashboard.jobRebuild": "Rebuild",
"dashboard.jobThumbnail": "Thumbnails",
"dashboard.jobOther": "Other",
"dashboard.periodDay": "Day",
"dashboard.periodWeek": "Week",
"dashboard.periodMonth": "Month",
"dashboard.popularSeries": "Popular series", "dashboard.popularSeries": "Popular series",
"dashboard.noSeries": "No series yet", "dashboard.noSeries": "No series yet",
"dashboard.unknown": "Unknown", "dashboard.unknown": "Unknown",
@@ -84,10 +93,11 @@ const en: Record<TranslationKey, string> = {
"dashboard.withIsbn": "With ISBN", "dashboard.withIsbn": "With ISBN",
"dashboard.currentlyReading": "Currently reading", "dashboard.currentlyReading": "Currently reading",
"dashboard.recentlyRead": "Recently read", "dashboard.recentlyRead": "Recently read",
"dashboard.readingActivity": "Reading activity (last 12 months)", "dashboard.readingActivity": "Reading activity",
"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",
@@ -250,6 +260,7 @@ const en: Record<TranslationKey, string> = {
"jobRow.thumbnailsGenerated": "{{count}} thumbnails generated", "jobRow.thumbnailsGenerated": "{{count}} thumbnails generated",
"jobRow.metadataProcessed": "{{count}} series processed", "jobRow.metadataProcessed": "{{count}} series processed",
"jobRow.metadataRefreshed": "{{count}} series refreshed", "jobRow.metadataRefreshed": "{{count}} series refreshed",
"jobRow.metadataLinks": "{{count}} links analyzed",
"jobRow.errors": "{{count}} errors", "jobRow.errors": "{{count}} errors",
"jobRow.view": "View", "jobRow.view": "View",
@@ -396,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",

View File

@@ -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",
@@ -68,7 +69,15 @@ const fr = {
"dashboard.readingStatus": "Statut de lecture", "dashboard.readingStatus": "Statut de lecture",
"dashboard.byFormat": "Par format", "dashboard.byFormat": "Par format",
"dashboard.byLibrary": "Par bibliothèque", "dashboard.byLibrary": "Par bibliothèque",
"dashboard.booksAdded": "Livres ajoutés (12 derniers mois)", "dashboard.booksAdded": "Livres ajoutés",
"dashboard.jobsOverTime": "Exécutions de jobs",
"dashboard.jobScan": "Scan",
"dashboard.jobRebuild": "Rebuild",
"dashboard.jobThumbnail": "Thumbnails",
"dashboard.jobOther": "Autre",
"dashboard.periodDay": "Jour",
"dashboard.periodWeek": "Semaine",
"dashboard.periodMonth": "Mois",
"dashboard.popularSeries": "Séries populaires", "dashboard.popularSeries": "Séries populaires",
"dashboard.noSeries": "Aucune série pour le moment", "dashboard.noSeries": "Aucune série pour le moment",
"dashboard.unknown": "Inconnu", "dashboard.unknown": "Inconnu",
@@ -82,10 +91,11 @@ const fr = {
"dashboard.withIsbn": "Avec ISBN", "dashboard.withIsbn": "Avec ISBN",
"dashboard.currentlyReading": "En cours de lecture", "dashboard.currentlyReading": "En cours de lecture",
"dashboard.recentlyRead": "Derniers livres lus", "dashboard.recentlyRead": "Derniers livres lus",
"dashboard.readingActivity": "Activité de lecture (12 derniers mois)", "dashboard.readingActivity": "Activité de lecture",
"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",
@@ -248,6 +258,7 @@ const fr = {
"jobRow.thumbnailsGenerated": "{{count}} miniatures générées", "jobRow.thumbnailsGenerated": "{{count}} miniatures générées",
"jobRow.metadataProcessed": "{{count}} séries traitées", "jobRow.metadataProcessed": "{{count}} séries traitées",
"jobRow.metadataRefreshed": "{{count}} séries rafraîchies", "jobRow.metadataRefreshed": "{{count}} séries rafraîchies",
"jobRow.metadataLinks": "{{count}} liens analysés",
"jobRow.errors": "{{count}} erreurs", "jobRow.errors": "{{count}} erreurs",
"jobRow.view": "Voir", "jobRow.view": "Voir",
@@ -394,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",

View File

@@ -0,0 +1,33 @@
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
export const SESSION_COOKIE = "sl_session";
function getSecret(): Uint8Array {
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error("SESSION_SECRET env var is required");
return new TextEncoder().encode(secret);
}
export async function createSessionToken(): Promise<string> {
return new SignJWT({})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("7d")
.sign(getSecret());
}
export async function verifySessionToken(token: string): Promise<boolean> {
try {
await jwtVerify(token, getSecret());
return true;
} catch {
return false;
}
}
export async function getSession(): Promise<boolean> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return false;
return verifySessionToken(token);
}

View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,13 +1,14 @@
{ {
"name": "stripstream-backoffice", "name": "stripstream-backoffice",
"version": "1.23.0", "version": "1.28.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "stripstream-backoffice", "name": "stripstream-backoffice",
"version": "1.23.0", "version": "1.28.0",
"dependencies": { "dependencies": {
"jose": "^6.2.2",
"next": "^16.1.6", "next": "^16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.0.0", "react": "19.0.0",
@@ -143,9 +144,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -162,9 +160,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -181,9 +176,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -200,9 +192,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -219,9 +208,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -238,9 +224,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -257,9 +240,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -276,9 +256,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -295,9 +272,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -320,9 +294,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -345,9 +316,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -370,9 +338,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -395,9 +360,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -420,9 +382,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -445,9 +404,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -470,9 +426,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -659,9 +612,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -678,9 +628,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -697,9 +644,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -716,9 +660,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -950,9 +891,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -970,9 +908,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -990,9 +925,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1010,9 +942,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1179,6 +1108,7 @@
"integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==", "integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -1311,6 +1241,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -1717,6 +1648,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.31.1", "version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -1860,9 +1800,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1884,9 +1821,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1908,9 +1842,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1932,9 +1863,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2147,6 +2075,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -2168,6 +2097,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -2177,6 +2107,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.25.0" "scheduler": "^0.25.0"
}, },
@@ -2196,6 +2127,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@@ -2248,7 +2180,8 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "stripstream-backoffice", "name": "stripstream-backoffice",
"version": "1.24.1", "version": "2.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 7082", "dev": "next dev -p 7082",
@@ -8,6 +8,7 @@
"start": "next start -p 7082" "start": "next start -p 7082"
}, },
"dependencies": { "dependencies": {
"jose": "^6.2.2",
"next": "^16.1.6", "next": "^16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.0.0", "react": "19.0.0",

38
apps/backoffice/proxy.ts Normal file
View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
import { SESSION_COOKIE } from "./lib/session";
function getSecret(): Uint8Array {
const secret = process.env.SESSION_SECRET;
if (!secret) return new TextEncoder().encode("dev-insecure-secret");
return new TextEncoder().encode(secret);
}
export async function proxy(req: NextRequest) {
const { pathname } = req.nextUrl;
// Skip auth for login page and auth API routes
if (pathname.startsWith("/login") || pathname.startsWith("/api/auth")) {
return NextResponse.next();
}
const token = req.cookies.get(SESSION_COOKIE)?.value;
if (token) {
try {
await jwtVerify(token, getSecret());
return NextResponse.next();
} catch {
// Token invalid or expired
}
}
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("from", pathname);
return NextResponse.redirect(loginUrl);
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon\\.ico|logo\\.png|.*\\.svg).*)",
],
};

View File

@@ -161,7 +161,13 @@ async fn send_telegram_photo(config: &TelegramConfig, caption: &str, photo_path:
/// Send a test message. Returns the result directly (not fire-and-forget). /// Send a test message. Returns the result directly (not fire-and-forget).
pub async fn send_test_message(config: &TelegramConfig) -> Result<()> { pub async fn send_test_message(config: &TelegramConfig) -> Result<()> {
send_telegram(config, "🔔 <b>Stripstream Librarian</b>\nTest notification — connection OK!").await send_telegram(
config,
"🔔 <b>Stripstream Librarian</b>\n\
━━━━━━━━━━━━━━━━━━━━\n\
✅ Test notification — connection OK!",
)
.await
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -265,22 +271,23 @@ fn format_event(event: &NotificationEvent) -> String {
} => { } => {
let lib = library_name.as_deref().unwrap_or("All libraries"); let lib = library_name.as_deref().unwrap_or("All libraries");
let duration = format_duration(*duration_seconds); let duration = format_duration(*duration_seconds);
format!( let mut lines = vec![
"📚 <b>Scan completed</b>\n\ format!(" <b>Scan completed</b>"),
Library: {lib}\n\ format!("━━━━━━━━━━━━━━━━━━━━"),
Type: {job_type}\n\ format!("📂 <b>Library:</b> {lib}"),
New books: {}\n\ format!("🏷 <b>Type:</b> {job_type}"),
New series: {}\n\ format!("⏱ <b>Duration:</b> {duration}"),
Files scanned: {}\n\ String::new(),
Removed: {}\n\ format!("📊 <b>Results</b>"),
Errors: {}\n\ format!(" 📗 New books: <b>{}</b>", stats.indexed_files),
Duration: {duration}", format!(" 📚 New series: <b>{}</b>", stats.new_series),
stats.indexed_files, format!(" 🔎 Files scanned: <b>{}</b>", stats.scanned_files),
stats.new_series, format!(" 🗑 Removed: <b>{}</b>", stats.removed_files),
stats.scanned_files, ];
stats.removed_files, if stats.errors > 0 {
stats.errors, lines.push(format!(" ⚠️ Errors: <b>{}</b>", stats.errors));
) }
lines.join("\n")
} }
NotificationEvent::ScanFailed { NotificationEvent::ScanFailed {
job_type, job_type,
@@ -289,23 +296,28 @@ fn format_event(event: &NotificationEvent) -> String {
} => { } => {
let lib = library_name.as_deref().unwrap_or("All libraries"); let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200); let err = truncate(error, 200);
format!( [
" <b>Scan failed</b>\n\ format!("🚨 <b>Scan failed</b>"),
Library: {lib}\n\ format!("━━━━━━━━━━━━━━━━━━━━"),
Type: {job_type}\n\ format!("📂 <b>Library:</b> {lib}"),
Error: {err}" format!("🏷 <b>Type:</b> {job_type}"),
) String::new(),
format!("💬 <code>{err}</code>"),
]
.join("\n")
} }
NotificationEvent::ScanCancelled { NotificationEvent::ScanCancelled {
job_type, job_type,
library_name, library_name,
} => { } => {
let lib = library_name.as_deref().unwrap_or("All libraries"); let lib = library_name.as_deref().unwrap_or("All libraries");
format!( [
"⏹ <b>Scan cancelled</b>\n\ format!("⏹ <b>Scan cancelled</b>"),
Library: {lib}\n\ format!("━━━━━━━━━━━━━━━━━━━━"),
Type: {job_type}" format!("📂 <b>Library:</b> {lib}"),
) format!("🏷 <b>Type:</b> {job_type}"),
]
.join("\n")
} }
NotificationEvent::ThumbnailCompleted { NotificationEvent::ThumbnailCompleted {
job_type, job_type,
@@ -314,12 +326,14 @@ fn format_event(event: &NotificationEvent) -> String {
} => { } => {
let lib = library_name.as_deref().unwrap_or("All libraries"); let lib = library_name.as_deref().unwrap_or("All libraries");
let duration = format_duration(*duration_seconds); let duration = format_duration(*duration_seconds);
format!( [
"🖼 <b>Thumbnails completed</b>\n\ format!(" <b>Thumbnails completed</b>"),
Library: {lib}\n\ format!("━━━━━━━━━━━━━━━━━━━━"),
Type: {job_type}\n\ format!("📂 <b>Library:</b> {lib}"),
Duration: {duration}" format!("🏷 <b>Type:</b> {job_type}"),
) format!("⏱ <b>Duration:</b> {duration}"),
]
.join("\n")
} }
NotificationEvent::ThumbnailFailed { NotificationEvent::ThumbnailFailed {
job_type, job_type,
@@ -328,12 +342,15 @@ fn format_event(event: &NotificationEvent) -> String {
} => { } => {
let lib = library_name.as_deref().unwrap_or("All libraries"); let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200); let err = truncate(error, 200);
format!( [
" <b>Thumbnails failed</b>\n\ format!("🚨 <b>Thumbnails failed</b>"),
Library: {lib}\n\ format!("━━━━━━━━━━━━━━━━━━━━"),
Type: {job_type}\n\ format!("📂 <b>Library:</b> {lib}"),
Error: {err}" format!("🏷 <b>Type:</b> {job_type}"),
) String::new(),
format!("💬 <code>{err}</code>"),
]
.join("\n")
} }
NotificationEvent::ConversionCompleted { NotificationEvent::ConversionCompleted {
library_name, library_name,
@@ -342,11 +359,13 @@ fn format_event(event: &NotificationEvent) -> String {
} => { } => {
let lib = library_name.as_deref().unwrap_or("Unknown"); let lib = library_name.as_deref().unwrap_or("Unknown");
let title = book_title.as_deref().unwrap_or("Unknown"); let title = book_title.as_deref().unwrap_or("Unknown");
format!( [
"🔄 <b>CBRCBZ conversion completed</b>\n\ format!(" <b>CBRCBZ conversion completed</b>"),
Library: {lib}\n\ format!("━━━━━━━━━━━━━━━━━━━━"),
Book: {title}" format!("📂 <b>Library:</b> {lib}"),
) format!("📖 <b>Book:</b> {title}"),
]
.join("\n")
} }
NotificationEvent::ConversionFailed { NotificationEvent::ConversionFailed {
library_name, library_name,
@@ -357,23 +376,28 @@ fn format_event(event: &NotificationEvent) -> String {
let lib = library_name.as_deref().unwrap_or("Unknown"); let lib = library_name.as_deref().unwrap_or("Unknown");
let title = book_title.as_deref().unwrap_or("Unknown"); let title = book_title.as_deref().unwrap_or("Unknown");
let err = truncate(error, 200); let err = truncate(error, 200);
format!( [
" <b>CBRCBZ conversion failed</b>\n\ format!("🚨 <b>CBRCBZ conversion failed</b>"),
Library: {lib}\n\ format!("━━━━━━━━━━━━━━━━━━━━"),
Book: {title}\n\ format!("📂 <b>Library:</b> {lib}"),
Error: {err}" format!("📖 <b>Book:</b> {title}"),
) String::new(),
format!("💬 <code>{err}</code>"),
]
.join("\n")
} }
NotificationEvent::MetadataApproved { NotificationEvent::MetadataApproved {
series_name, series_name,
provider, provider,
.. ..
} => { } => {
format!( [
"🔗 <b>Metadata linked</b>\n\ format!(" <b>Metadata linked</b>"),
Series: {series_name}\n\ format!("━━━━━━━━━━━━━━━━━━━━"),
Provider: {provider}" format!("📚 <b>Series:</b> {series_name}"),
) format!("🔗 <b>Provider:</b> {provider}"),
]
.join("\n")
} }
NotificationEvent::MetadataBatchCompleted { NotificationEvent::MetadataBatchCompleted {
library_name, library_name,
@@ -381,11 +405,13 @@ fn format_event(event: &NotificationEvent) -> String {
processed, processed,
} => { } => {
let lib = library_name.as_deref().unwrap_or("All libraries"); let lib = library_name.as_deref().unwrap_or("All libraries");
format!( [
"🔍 <b>Metadata batch completed</b>\n\ format!(" <b>Metadata batch completed</b>"),
Library: {lib}\n\ format!("━━━━━━━━━━━━━━━━━━━━"),
Series processed: {processed}/{total_series}" format!("📂 <b>Library:</b> {lib}"),
) format!("📊 <b>Processed:</b> {processed}/{total_series} series"),
]
.join("\n")
} }
NotificationEvent::MetadataBatchFailed { NotificationEvent::MetadataBatchFailed {
library_name, library_name,
@@ -393,11 +419,14 @@ fn format_event(event: &NotificationEvent) -> String {
} => { } => {
let lib = library_name.as_deref().unwrap_or("All libraries"); let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200); let err = truncate(error, 200);
format!( [
" <b>Metadata batch failed</b>\n\ format!("🚨 <b>Metadata batch failed</b>"),
Library: {lib}\n\ format!("━━━━━━━━━━━━━━━━━━━━"),
Error: {err}" format!("📂 <b>Library:</b> {lib}"),
) String::new(),
format!("💬 <code>{err}</code>"),
]
.join("\n")
} }
NotificationEvent::MetadataRefreshCompleted { NotificationEvent::MetadataRefreshCompleted {
library_name, library_name,
@@ -406,13 +435,19 @@ fn format_event(event: &NotificationEvent) -> String {
errors, errors,
} => { } => {
let lib = library_name.as_deref().unwrap_or("All libraries"); let lib = library_name.as_deref().unwrap_or("All libraries");
format!( let mut lines = vec![
"🔄 <b>Metadata refresh completed</b>\n\ format!(" <b>Metadata refresh completed</b>"),
Library: {lib}\n\ format!("━━━━━━━━━━━━━━━━━━━━"),
Updated: {refreshed}\n\ format!("📂 <b>Library:</b> {lib}"),
Unchanged: {unchanged}\n\ String::new(),
Errors: {errors}" format!("📊 <b>Results</b>"),
) format!(" 🔄 Updated: <b>{refreshed}</b>"),
format!(" ▪️ Unchanged: <b>{unchanged}</b>"),
];
if *errors > 0 {
lines.push(format!(" ⚠️ Errors: <b>{errors}</b>"));
}
lines.join("\n")
} }
NotificationEvent::MetadataRefreshFailed { NotificationEvent::MetadataRefreshFailed {
library_name, library_name,
@@ -420,11 +455,14 @@ fn format_event(event: &NotificationEvent) -> String {
} => { } => {
let lib = library_name.as_deref().unwrap_or("All libraries"); let lib = library_name.as_deref().unwrap_or("All libraries");
let err = truncate(error, 200); let err = truncate(error, 200);
format!( [
" <b>Metadata refresh failed</b>\n\ format!("🚨 <b>Metadata refresh failed</b>"),
Library: {lib}\n\ format!("━━━━━━━━━━━━━━━━━━━━"),
Error: {err}" format!("📂 <b>Library:</b> {lib}"),
) String::new(),
format!("💬 <code>{err}</code>"),
]
.join("\n")
} }
} }
} }

View File

@@ -0,0 +1 @@
ALTER TABLE komga_sync_reports ADD COLUMN user_id UUID REFERENCES users(id) ON DELETE SET NULL;