Compare commits
60 Commits
70889ca955
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a7881ac6e | |||
| 0950018b38 | |||
| bc796f4ee5 | |||
| 232ecdda41 | |||
| 32d13984a1 | |||
| eab7f2e21b | |||
| b6422fbf3e | |||
| 6dbd0c80e6 | |||
| 0c42a9ed04 | |||
| 95a6e54d06 | |||
| e26219989f | |||
| 5d33a35407 | |||
| d53572dc33 | |||
| cf1953d11f | |||
| 6f663eaee7 | |||
| ee65c6263a | |||
| 691b6b22ab | |||
| 11c80a16a3 | |||
| c366b44c54 | |||
| 92f80542e6 | |||
| 3a25e42a20 | |||
| 24763bf5a7 | |||
| 08f0397029 | |||
| 766e3a01b2 | |||
| 626e2e035d | |||
| cfd2321db2 | |||
| 1b715033ce | |||
| 81d1586501 | |||
| bd74c9e3e3 | |||
| 41228430cf | |||
| 6a4ba06fac | |||
| e5c3542d3f | |||
| 24516f1069 | |||
| 5383cdef60 | |||
| be5c3f7a34 | |||
| caa9922ff9 | |||
| 135f000c71 | |||
| d9e50a4235 | |||
| 5f6eb5a5cb | |||
| 41c77fca2e | |||
| 49621f3fb1 | |||
| 6df743b2e6 | |||
| edfefc0128 | |||
| b0185abefe | |||
| b9e54cbfd8 | |||
| 3f0bd783cd | |||
| fc8856c83f | |||
| bd09f3d943 | |||
| 1f434c3d67 | |||
| 4972a403df | |||
| 629708cdd0 | |||
| 560087a897 | |||
| 27f553b005 | |||
| ed7665248e | |||
| 736b8aedc0 | |||
| 3daa49ae6c | |||
| 5fb24188e1 | |||
| 54f972db17 | |||
| acd8b62382 | |||
| cc65e3d1ad |
@@ -13,6 +13,12 @@
|
||||
# Use this token for the first API calls before creating proper API tokens
|
||||
API_BOOTSTRAP_TOKEN=change-me-in-production
|
||||
|
||||
# Backoffice admin credentials (required)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-in-production
|
||||
# Secret for signing session JWTs (min 32 chars, required)
|
||||
SESSION_SECRET=change-me-in-production-use-32-chars-min
|
||||
|
||||
# =============================================================================
|
||||
# Service Configuration
|
||||
# =============================================================================
|
||||
|
||||
25
Cargo.lock
generated
25
Cargo.lock
generated
@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "1.12.0"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -76,6 +76,7 @@ dependencies = [
|
||||
"image",
|
||||
"jpeg-decoder",
|
||||
"lru",
|
||||
"notifications",
|
||||
"parsers",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
@@ -1232,7 +1233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexer"
|
||||
version = "1.12.0"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1240,6 +1241,7 @@ dependencies = [
|
||||
"futures",
|
||||
"image",
|
||||
"jpeg-decoder",
|
||||
"notifications",
|
||||
"num_cpus",
|
||||
"parsers",
|
||||
"reqwest",
|
||||
@@ -1663,6 +1665,19 @@ dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notifications"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1771,7 +1786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parsers"
|
||||
version = "1.12.0"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"flate2",
|
||||
@@ -2270,6 +2285,7 @@ dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -2278,6 +2294,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -2906,7 +2923,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "stripstream-core"
|
||||
version = "1.12.0"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
|
||||
@@ -3,13 +3,14 @@ members = [
|
||||
"apps/api",
|
||||
"apps/indexer",
|
||||
"crates/core",
|
||||
"crates/notifications",
|
||||
"crates/parsers",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "1.12.0"
|
||||
version = "2.0.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -22,7 +23,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
|
||||
jpeg-decoder = "0.3"
|
||||
lru = "0.12"
|
||||
rayon = "1.10"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
|
||||
rand = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Julien Froidefond
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
78
README.md
78
README.md
@@ -81,28 +81,66 @@ The backoffice will be available at http://localhost:7082
|
||||
|
||||
## Features
|
||||
|
||||
### Libraries Management
|
||||
- Create and manage multiple libraries
|
||||
- Configure automatic scanning schedules (hourly, daily, weekly)
|
||||
- Real-time file watcher for instant indexing
|
||||
- Full and incremental rebuild options
|
||||
> For the full feature list, business rules, and API details, see [docs/FEATURES.md](docs/FEATURES.md).
|
||||
|
||||
### Books Management
|
||||
- Support for CBZ, CBR, and PDF formats
|
||||
- Automatic metadata extraction
|
||||
- Series and volume detection
|
||||
- Full-text search powered by PostgreSQL
|
||||
### Libraries
|
||||
- Multi-library management with per-library configuration
|
||||
- Incremental and full scanning, real-time filesystem watcher
|
||||
- Per-library metadata provider selection (Google Books, ComicVine, BedéThèque, AniList, Open Library)
|
||||
|
||||
### Jobs Monitoring
|
||||
- Real-time job progress tracking
|
||||
- Detailed statistics (scanned, indexed, removed, errors)
|
||||
- Job history and logs
|
||||
- Cancel pending jobs
|
||||
### Books & Series
|
||||
- **Formats**: CBZ, CBR, PDF, EPUB
|
||||
- Automatic metadata extraction (title, series, volume, authors, page count) from filenames and directory structure
|
||||
- Series aggregation with missing volume detection
|
||||
- Thumbnail generation (WebP/JPEG/PNG) with lazy generation and bulk rebuild
|
||||
- CBR → CBZ conversion
|
||||
|
||||
### Search
|
||||
- Full-text search across titles, authors, and series
|
||||
- Library filtering
|
||||
- Real-time suggestions
|
||||
### Reading Progress
|
||||
- Per-book tracking: unread / reading / read with current page
|
||||
- Series-level aggregated reading status
|
||||
- Bulk mark-as-read for series
|
||||
|
||||
### Search & Discovery
|
||||
- Full-text search across titles, authors, and series (PostgreSQL `pg_trgm`)
|
||||
- Author listing with book/series counts
|
||||
- Filtering by reading status, series status, format, metadata provider
|
||||
|
||||
### External Metadata
|
||||
- Search, match, approve/reject workflow with confidence scoring
|
||||
- Batch auto-matching and scheduled metadata refresh
|
||||
- Field locking to protect manual edits from sync
|
||||
|
||||
### Notifications
|
||||
- **Telegram**: real-time notifications via Telegram Bot API
|
||||
- 12 granular event toggles (scans, thumbnails, conversions, metadata)
|
||||
- Book thumbnail images included in notifications where applicable
|
||||
- Test connection from settings
|
||||
|
||||
### External Integrations
|
||||
- **Komga**: import reading progress
|
||||
- **Prowlarr**: search for missing volumes
|
||||
- **qBittorrent**: add torrents directly from search results
|
||||
|
||||
### Background Jobs
|
||||
- Rebuild, rescan, thumbnail generation, metadata batch, CBR conversion
|
||||
- Real-time progress via Server-Sent Events (SSE)
|
||||
- Job history, error tracking, cancellation
|
||||
|
||||
### Page Rendering
|
||||
- On-demand page extraction from all formats
|
||||
- Image processing (format, quality, max width, resampling filter)
|
||||
- LRU in-memory + disk cache
|
||||
|
||||
### Security
|
||||
- Token-based auth (`admin` / `read` scopes) with Argon2 hashing
|
||||
- Rate limiting, token expiration and revocation
|
||||
|
||||
### Web UI (Backoffice)
|
||||
- Dashboard with statistics, interactive charts (recharts), and reading progress
|
||||
- Currently reading & recently read sections
|
||||
- Library, book, series, author management
|
||||
- Live job monitoring, metadata search modals, settings panel
|
||||
- Notification settings with per-event toggle configuration
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -249,4 +287,4 @@ volumes:
|
||||
|
||||
## License
|
||||
|
||||
[Your License Here]
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
|
||||
@@ -15,6 +15,7 @@ futures = "0.3"
|
||||
image.workspace = true
|
||||
jpeg-decoder.workspace = true
|
||||
lru.workspace = true
|
||||
notifications = { path = "../../crates/notifications" }
|
||||
stripstream-core = { path = "../../crates/core" }
|
||||
parsers = { path = "../../crates/parsers" }
|
||||
rand.workspace = true
|
||||
|
||||
@@ -6,13 +6,15 @@ COPY Cargo.toml ./
|
||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||
COPY crates/notifications/Cargo.toml crates/notifications/Cargo.toml
|
||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||
|
||||
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/parsers/src && \
|
||||
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/notifications/src crates/parsers/src && \
|
||||
echo "fn main() {}" > apps/api/src/main.rs && \
|
||||
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
||||
echo "" > apps/indexer/src/lib.rs && \
|
||||
echo "" > crates/core/src/lib.rs && \
|
||||
echo "" > crates/notifications/src/lib.rs && \
|
||||
echo "" > crates/parsers/src/lib.rs
|
||||
|
||||
# Build dependencies only (cached as long as Cargo.toml files don't change)
|
||||
@@ -26,12 +28,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
COPY apps/api/src apps/api/src
|
||||
COPY apps/indexer/src apps/indexer/src
|
||||
COPY crates/core/src crates/core/src
|
||||
COPY crates/notifications/src crates/notifications/src
|
||||
COPY crates/parsers/src crates/parsers/src
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/target \
|
||||
touch apps/api/src/main.rs crates/core/src/lib.rs crates/parsers/src/lib.rs && \
|
||||
touch apps/api/src/main.rs crates/core/src/lib.rs crates/notifications/src/lib.rs crates/parsers/src/lib.rs && \
|
||||
cargo build --release -p api && \
|
||||
cp /app/target/release/api /usr/local/bin/api
|
||||
|
||||
|
||||
@@ -10,10 +10,15 @@ use sqlx::Row;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthUser {
|
||||
pub user_id: uuid::Uuid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Scope {
|
||||
Admin,
|
||||
Read,
|
||||
Read { user_id: uuid::Uuid },
|
||||
}
|
||||
|
||||
pub async fn require_admin(
|
||||
@@ -40,6 +45,20 @@ pub async fn require_read(
|
||||
let token = bearer_token(&req).ok_or_else(|| ApiError::unauthorized("missing bearer token"))?;
|
||||
let scope = authenticate(&state, token).await?;
|
||||
|
||||
if let Scope::Read { user_id } = &scope {
|
||||
req.extensions_mut().insert(AuthUser { user_id: *user_id });
|
||||
} else if matches!(scope, Scope::Admin) {
|
||||
// Admin peut s'impersonifier via le header X-As-User
|
||||
if let Some(as_user_id) = req
|
||||
.headers()
|
||||
.get("X-As-User")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| uuid::Uuid::parse_str(v).ok())
|
||||
{
|
||||
req.extensions_mut().insert(AuthUser { user_id: as_user_id });
|
||||
}
|
||||
}
|
||||
|
||||
req.extensions_mut().insert(scope);
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
@@ -60,8 +79,7 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
|
||||
|
||||
let maybe_row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, token_hash, scope
|
||||
FROM api_tokens
|
||||
SELECT id, token_hash, scope, user_id FROM api_tokens
|
||||
WHERE prefix = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())
|
||||
"#,
|
||||
)
|
||||
@@ -88,7 +106,12 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
|
||||
let scope: String = row.try_get("scope").map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||
match scope.as_str() {
|
||||
"admin" => Ok(Scope::Admin),
|
||||
"read" => Ok(Scope::Read),
|
||||
"read" => {
|
||||
let user_id: uuid::Uuid = row
|
||||
.try_get("user_id")
|
||||
.map_err(|_| ApiError::unauthorized("read token missing user_id"))?;
|
||||
Ok(Scope::Read { user_id })
|
||||
}
|
||||
_ => Err(ApiError::unauthorized("invalid token scope")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ pub async fn list_authors(
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| format!("%{s}%"));
|
||||
|
||||
// Aggregate unique authors from books.authors + books.author
|
||||
// Aggregate unique authors from books.authors + books.author + series_metadata.authors
|
||||
let sql = format!(
|
||||
r#"
|
||||
WITH all_authors AS (
|
||||
@@ -79,18 +79,21 @@ pub async fn list_authors(
|
||||
)
|
||||
) AS name
|
||||
FROM books
|
||||
UNION
|
||||
SELECT DISTINCT UNNEST(authors) AS name
|
||||
FROM series_metadata
|
||||
WHERE authors != '{{}}'
|
||||
),
|
||||
filtered AS (
|
||||
SELECT name FROM all_authors
|
||||
WHERE ($1::text IS NULL OR name ILIKE $1)
|
||||
),
|
||||
counted AS (
|
||||
book_counts AS (
|
||||
SELECT
|
||||
f.name,
|
||||
COUNT(DISTINCT b.id) AS book_count,
|
||||
COUNT(DISTINCT NULLIF(b.series, '')) AS series_count
|
||||
f.name AS author_name,
|
||||
COUNT(DISTINCT b.id) AS book_count
|
||||
FROM filtered f
|
||||
JOIN books b ON (
|
||||
LEFT JOIN books b ON (
|
||||
f.name = ANY(
|
||||
COALESCE(
|
||||
NULLIF(b.authors, '{{}}'),
|
||||
@@ -99,9 +102,24 @@ pub async fn list_authors(
|
||||
)
|
||||
)
|
||||
GROUP BY f.name
|
||||
),
|
||||
series_counts AS (
|
||||
SELECT
|
||||
f.name AS author_name,
|
||||
COUNT(DISTINCT (sm.library_id, sm.name)) AS series_count
|
||||
FROM filtered f
|
||||
LEFT JOIN series_metadata sm ON (
|
||||
f.name = ANY(sm.authors) AND sm.authors != '{{}}'
|
||||
)
|
||||
SELECT name, book_count, series_count
|
||||
FROM counted
|
||||
GROUP BY f.name
|
||||
)
|
||||
SELECT
|
||||
f.name,
|
||||
COALESCE(bc.book_count, 0) AS book_count,
|
||||
COALESCE(sc.series_count, 0) AS series_count
|
||||
FROM filtered f
|
||||
LEFT JOIN book_counts bc ON bc.author_name = f.name
|
||||
LEFT JOIN series_counts sc ON sc.author_name = f.name
|
||||
ORDER BY {order_clause}
|
||||
LIMIT $2 OFFSET $3
|
||||
"#
|
||||
@@ -116,6 +134,10 @@ pub async fn list_authors(
|
||||
)
|
||||
) AS name
|
||||
FROM books
|
||||
UNION
|
||||
SELECT DISTINCT UNNEST(authors) AS name
|
||||
FROM series_metadata
|
||||
WHERE authors != '{}'
|
||||
)
|
||||
SELECT COUNT(*) AS total
|
||||
FROM all_authors
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,10 @@ pub struct RebuildRequest {
|
||||
pub library_id: Option<Uuid>,
|
||||
#[schema(value_type = Option<bool>, example = false)]
|
||||
pub full: Option<bool>,
|
||||
/// Deep rescan: clears directory mtimes to force re-walking all directories,
|
||||
/// discovering newly supported formats without deleting existing data.
|
||||
#[schema(value_type = Option<bool>, example = false)]
|
||||
pub rescan: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -117,7 +121,8 @@ pub async fn enqueue_rebuild(
|
||||
) -> Result<Json<IndexJobResponse>, ApiError> {
|
||||
let library_id = payload.as_ref().and_then(|p| p.0.library_id);
|
||||
let is_full = payload.as_ref().and_then(|p| p.0.full).unwrap_or(false);
|
||||
let job_type = if is_full { "full_rebuild" } else { "rebuild" };
|
||||
let is_rescan = payload.as_ref().and_then(|p| p.0.rescan).unwrap_or(false);
|
||||
let job_type = if is_full { "full_rebuild" } else if is_rescan { "rescan" } else { "rebuild" };
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
sqlx::query(
|
||||
|
||||
134
apps/api/src/job_poller.rs
Normal file
134
apps/api/src/job_poller.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use sqlx::{PgPool, Row};
|
||||
use tracing::{error, info, trace};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{metadata_batch, metadata_refresh};
|
||||
|
||||
/// Poll for pending API-only jobs (`metadata_batch`, `metadata_refresh`) and process them.
|
||||
/// This mirrors the indexer's worker loop but for job types handled by the API.
|
||||
pub async fn run_job_poller(pool: PgPool, interval_seconds: u64) {
|
||||
let wait = Duration::from_secs(interval_seconds.max(1));
|
||||
|
||||
loop {
|
||||
match claim_next_api_job(&pool).await {
|
||||
Ok(Some((job_id, job_type, library_id))) => {
|
||||
info!("[JOB_POLLER] Claimed {job_type} job {job_id} library={library_id}");
|
||||
|
||||
let pool_clone = pool.clone();
|
||||
let library_name: Option<String> =
|
||||
sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = match job_type.as_str() {
|
||||
"metadata_refresh" => {
|
||||
metadata_refresh::process_metadata_refresh(
|
||||
&pool_clone,
|
||||
job_id,
|
||||
library_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"metadata_batch" => {
|
||||
metadata_batch::process_metadata_batch(
|
||||
&pool_clone,
|
||||
job_id,
|
||||
library_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => Err(format!("Unknown API job type: {job_type}")),
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
error!("[JOB_POLLER] {job_type} job {job_id} failed: {e}");
|
||||
let _ = sqlx::query(
|
||||
"UPDATE index_jobs SET status = 'failed', error_opt = $2, finished_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(e.to_string())
|
||||
.execute(&pool_clone)
|
||||
.await;
|
||||
|
||||
match job_type.as_str() {
|
||||
"metadata_refresh" => {
|
||||
notifications::notify(
|
||||
pool_clone,
|
||||
notifications::NotificationEvent::MetadataRefreshFailed {
|
||||
library_name,
|
||||
error: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
"metadata_batch" => {
|
||||
notifications::notify(
|
||||
pool_clone,
|
||||
notifications::NotificationEvent::MetadataBatchFailed {
|
||||
library_name,
|
||||
error: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
trace!("[JOB_POLLER] No pending API jobs, waiting...");
|
||||
tokio::time::sleep(wait).await;
|
||||
}
|
||||
Err(err) => {
|
||||
error!("[JOB_POLLER] Error claiming job: {err}");
|
||||
tokio::time::sleep(wait).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const API_JOB_TYPES: &[&str] = &["metadata_batch", "metadata_refresh"];
|
||||
|
||||
async fn claim_next_api_job(pool: &PgPool) -> Result<Option<(Uuid, String, Uuid)>, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, type, library_id
|
||||
FROM index_jobs
|
||||
WHERE status = 'pending'
|
||||
AND type = ANY($1)
|
||||
AND library_id IS NOT NULL
|
||||
ORDER BY created_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(API_JOB_TYPES)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
tx.commit().await?;
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let id: Uuid = row.get("id");
|
||||
let job_type: String = row.get("type");
|
||||
let library_id: Uuid = row.get("library_id");
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE index_jobs SET status = 'running', started_at = NOW(), error_opt = NULL WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(Some((id, job_type, library_id)))
|
||||
}
|
||||
@@ -38,6 +38,8 @@ pub struct KomgaSyncRequest {
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[schema(value_type = String)]
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -45,6 +47,8 @@ pub struct KomgaSyncResponse {
|
||||
#[schema(value_type = String)]
|
||||
pub id: Uuid,
|
||||
pub komga_url: String,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub user_id: Option<Uuid>,
|
||||
pub total_komga_read: i64,
|
||||
pub matched: i64,
|
||||
pub already_read: i64,
|
||||
@@ -61,6 +65,8 @@ pub struct KomgaSyncReportSummary {
|
||||
#[schema(value_type = String)]
|
||||
pub id: Uuid,
|
||||
pub komga_url: String,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub user_id: Option<Uuid>,
|
||||
pub total_komga_read: i64,
|
||||
pub matched: i64,
|
||||
pub already_read: i64,
|
||||
@@ -215,11 +221,12 @@ pub async fn sync_komga_read_books(
|
||||
let mut already_read_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
|
||||
|
||||
if !matched_ids.is_empty() {
|
||||
// Get already-read book IDs
|
||||
// Get already-read book IDs for this user
|
||||
let ar_rows = sqlx::query(
|
||||
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND status = 'read'",
|
||||
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND user_id = $2 AND status = 'read'",
|
||||
)
|
||||
.bind(&matched_ids)
|
||||
.bind(body.user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -228,12 +235,12 @@ pub async fn sync_komga_read_books(
|
||||
}
|
||||
already_read = already_read_ids.len() as i64;
|
||||
|
||||
// Bulk upsert all matched books as read
|
||||
// Bulk upsert all matched books as read for this user
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
||||
SELECT unnest($1::uuid[]), 'read', NULL, NOW(), NOW()
|
||||
ON CONFLICT (book_id) DO UPDATE
|
||||
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||
SELECT unnest($1::uuid[]), $2, 'read', NULL, NOW(), NOW()
|
||||
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||
SET status = 'read',
|
||||
current_page = NULL,
|
||||
last_read_at = NOW(),
|
||||
@@ -242,6 +249,7 @@ pub async fn sync_komga_read_books(
|
||||
"#,
|
||||
)
|
||||
.bind(&matched_ids)
|
||||
.bind(body.user_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
}
|
||||
@@ -273,12 +281,13 @@ pub async fn sync_komga_read_books(
|
||||
let newly_marked_books_json = serde_json::to_value(&newly_marked_books).unwrap_or_default();
|
||||
let report_row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO komga_sync_reports (komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
INSERT INTO komga_sync_reports (komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(&url)
|
||||
.bind(body.user_id)
|
||||
.bind(total_komga_read)
|
||||
.bind(matched)
|
||||
.bind(already_read)
|
||||
@@ -292,6 +301,7 @@ pub async fn sync_komga_read_books(
|
||||
Ok(Json(KomgaSyncResponse {
|
||||
id: report_row.get("id"),
|
||||
komga_url: url,
|
||||
user_id: Some(body.user_id),
|
||||
total_komga_read,
|
||||
matched,
|
||||
already_read,
|
||||
@@ -319,7 +329,7 @@ pub async fn list_sync_reports(
|
||||
) -> Result<Json<Vec<KomgaSyncReportSummary>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked,
|
||||
SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked,
|
||||
jsonb_array_length(unmatched) as unmatched_count, created_at
|
||||
FROM komga_sync_reports
|
||||
ORDER BY created_at DESC
|
||||
@@ -334,6 +344,7 @@ pub async fn list_sync_reports(
|
||||
.map(|row| KomgaSyncReportSummary {
|
||||
id: row.get("id"),
|
||||
komga_url: row.get("komga_url"),
|
||||
user_id: row.get("user_id"),
|
||||
total_komga_read: row.get("total_komga_read"),
|
||||
matched: row.get("matched"),
|
||||
already_read: row.get("already_read"),
|
||||
@@ -365,7 +376,7 @@ pub async fn get_sync_report(
|
||||
) -> Result<Json<KomgaSyncResponse>, ApiError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
|
||||
SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
|
||||
FROM komga_sync_reports
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -386,6 +397,7 @@ pub async fn get_sync_report(
|
||||
Ok(Json(KomgaSyncResponse {
|
||||
id: row.get("id"),
|
||||
komga_url: row.get("komga_url"),
|
||||
user_id: row.get("user_id"),
|
||||
total_komga_read: row.get("total_komga_read"),
|
||||
matched: row.get("matched"),
|
||||
already_read: row.get("already_read"),
|
||||
|
||||
@@ -26,6 +26,10 @@ pub struct LibraryResponse {
|
||||
pub metadata_refresh_mode: String,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub next_metadata_refresh_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub series_count: i64,
|
||||
/// First book IDs from up to 5 distinct series (for thumbnail fan display)
|
||||
#[schema(value_type = Vec<String>)]
|
||||
pub thumbnail_book_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
@@ -44,14 +48,27 @@ pub struct CreateLibraryRequest {
|
||||
responses(
|
||||
(status = 200, body = Vec<LibraryResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, l.watcher_enabled, l.metadata_provider, l.fallback_metadata_provider, l.metadata_refresh_mode, l.next_metadata_refresh_at,
|
||||
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count
|
||||
(SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count,
|
||||
(SELECT COUNT(DISTINCT COALESCE(NULLIF(b.series, ''), 'unclassified')) FROM books b WHERE b.library_id = l.id) as series_count,
|
||||
COALESCE((
|
||||
SELECT ARRAY_AGG(first_id ORDER BY series_name)
|
||||
FROM (
|
||||
SELECT DISTINCT ON (COALESCE(NULLIF(b.series, ''), 'unclassified'))
|
||||
COALESCE(NULLIF(b.series, ''), 'unclassified') as series_name,
|
||||
b.id as first_id
|
||||
FROM books b
|
||||
WHERE b.library_id = l.id
|
||||
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'),
|
||||
b.volume NULLS LAST, b.title ASC
|
||||
LIMIT 5
|
||||
) sub
|
||||
), ARRAY[]::uuid[]) as thumbnail_book_ids
|
||||
FROM libraries l ORDER BY l.created_at DESC"
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
@@ -65,6 +82,7 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
|
||||
root_path: row.get("root_path"),
|
||||
enabled: row.get("enabled"),
|
||||
book_count: row.get("book_count"),
|
||||
series_count: row.get("series_count"),
|
||||
monitor_enabled: row.get("monitor_enabled"),
|
||||
scan_mode: row.get("scan_mode"),
|
||||
next_scan_at: row.get("next_scan_at"),
|
||||
@@ -73,6 +91,7 @@ pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<Li
|
||||
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||
thumbnail_book_ids: row.get("thumbnail_book_ids"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -120,6 +139,7 @@ pub async fn create_library(
|
||||
root_path,
|
||||
enabled: true,
|
||||
book_count: 0,
|
||||
series_count: 0,
|
||||
monitor_enabled: false,
|
||||
scan_mode: "manual".to_string(),
|
||||
next_scan_at: None,
|
||||
@@ -128,6 +148,7 @@ pub async fn create_library(
|
||||
fallback_metadata_provider: None,
|
||||
metadata_refresh_mode: "manual".to_string(),
|
||||
next_metadata_refresh_at: None,
|
||||
thumbnail_book_ids: vec![],
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -199,7 +220,6 @@ use crate::index_jobs::{IndexJobResponse, RebuildRequest};
|
||||
(status = 200, body = IndexJobResponse),
|
||||
(status = 404, description = "Library not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
@@ -219,7 +239,8 @@ pub async fn scan_library(
|
||||
}
|
||||
|
||||
let is_full = payload.as_ref().and_then(|p| p.full).unwrap_or(false);
|
||||
let job_type = if is_full { "full_rebuild" } else { "rebuild" };
|
||||
let is_rescan = payload.as_ref().and_then(|p| p.rescan).unwrap_or(false);
|
||||
let job_type = if is_full { "full_rebuild" } else if is_rescan { "rescan" } else { "rebuild" };
|
||||
|
||||
// Create indexing job for this library
|
||||
let job_id = Uuid::new_v4();
|
||||
@@ -336,12 +357,29 @@ pub async fn update_monitoring(
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let thumbnail_book_ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT b.id FROM books b
|
||||
WHERE b.library_id = $1
|
||||
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC
|
||||
LIMIT 5"
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(LibraryResponse {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
root_path: row.get("root_path"),
|
||||
enabled: row.get("enabled"),
|
||||
book_count,
|
||||
series_count,
|
||||
monitor_enabled: row.get("monitor_enabled"),
|
||||
scan_mode: row.get("scan_mode"),
|
||||
next_scan_at: row.get("next_scan_at"),
|
||||
@@ -350,6 +388,7 @@ pub async fn update_monitoring(
|
||||
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||
thumbnail_book_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -402,12 +441,29 @@ pub async fn update_metadata_provider(
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let series_count: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT COALESCE(NULLIF(series, ''), 'unclassified')) FROM books WHERE library_id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let thumbnail_book_ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT b.id FROM books b
|
||||
WHERE b.library_id = $1
|
||||
ORDER BY COALESCE(NULLIF(b.series, ''), 'unclassified'), b.volume NULLS LAST, b.title ASC
|
||||
LIMIT 5"
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(LibraryResponse {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
root_path: row.get("root_path"),
|
||||
enabled: row.get("enabled"),
|
||||
book_count,
|
||||
series_count,
|
||||
monitor_enabled: row.get("monitor_enabled"),
|
||||
scan_mode: row.get("scan_mode"),
|
||||
next_scan_at: row.get("next_scan_at"),
|
||||
@@ -416,5 +472,6 @@ pub async fn update_metadata_provider(
|
||||
fallback_metadata_provider: row.get("fallback_metadata_provider"),
|
||||
metadata_refresh_mode: row.get("metadata_refresh_mode"),
|
||||
next_metadata_refresh_at: row.get("next_metadata_refresh_at"),
|
||||
thumbnail_book_ids,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ mod books;
|
||||
mod error;
|
||||
mod handlers;
|
||||
mod index_jobs;
|
||||
mod job_poller;
|
||||
mod komga;
|
||||
mod libraries;
|
||||
mod metadata;
|
||||
@@ -17,11 +18,14 @@ mod prowlarr;
|
||||
mod qbittorrent;
|
||||
mod reading_progress;
|
||||
mod search;
|
||||
mod series;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod stats;
|
||||
mod telegram;
|
||||
mod thumbnails;
|
||||
mod tokens;
|
||||
mod users;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
@@ -86,14 +90,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
};
|
||||
|
||||
let admin_routes = Router::new()
|
||||
.route("/libraries", get(libraries::list_libraries).post(libraries::create_library))
|
||||
.route("/libraries", axum::routing::post(libraries::create_library))
|
||||
.route("/libraries/:id", delete(libraries::delete_library))
|
||||
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
|
||||
.route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring))
|
||||
.route("/libraries/:id/metadata-provider", axum::routing::patch(libraries::update_metadata_provider))
|
||||
.route("/books/:id", axum::routing::patch(books::update_book))
|
||||
.route("/books/:id/convert", axum::routing::post(books::convert_book))
|
||||
.route("/libraries/:library_id/series/:name", axum::routing::patch(books::update_series))
|
||||
.route("/libraries/:library_id/series/:name", axum::routing::patch(series::update_series))
|
||||
.route("/index/rebuild", axum::routing::post(index_jobs::enqueue_rebuild))
|
||||
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
|
||||
.route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate))
|
||||
@@ -104,13 +107,16 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/index/jobs/:id/errors", get(index_jobs::get_job_errors))
|
||||
.route("/index/cancel/:id", axum::routing::post(index_jobs::cancel_job))
|
||||
.route("/folders", get(index_jobs::list_folders))
|
||||
.route("/admin/users", get(users::list_users).post(users::create_user))
|
||||
.route("/admin/users/:id", delete(users::delete_user).patch(users::update_user))
|
||||
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
||||
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
||||
.route("/admin/tokens/:id", delete(tokens::revoke_token).patch(tokens::update_token))
|
||||
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
||||
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
|
||||
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
|
||||
.route("/qbittorrent/add", axum::routing::post(qbittorrent::add_torrent))
|
||||
.route("/qbittorrent/test", get(qbittorrent::test_qbittorrent))
|
||||
.route("/telegram/test", get(telegram::test_telegram))
|
||||
.route("/komga/sync", axum::routing::post(komga::sync_komga_read_books))
|
||||
.route("/komga/reports", get(komga::list_sync_reports))
|
||||
.route("/komga/reports/:id", get(komga::get_sync_report))
|
||||
@@ -133,18 +139,20 @@ async fn main() -> anyhow::Result<()> {
|
||||
));
|
||||
|
||||
let read_routes = Router::new()
|
||||
.route("/libraries", get(libraries::list_libraries))
|
||||
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_library))
|
||||
.route("/books", get(books::list_books))
|
||||
.route("/books/ongoing", get(books::ongoing_books))
|
||||
.route("/books/ongoing", get(series::ongoing_books))
|
||||
.route("/books/:id", get(books::get_book))
|
||||
.route("/books/:id/thumbnail", get(books::get_thumbnail))
|
||||
.route("/books/:id/pages/:n", get(pages::get_page))
|
||||
.route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress))
|
||||
.route("/libraries/:library_id/series", get(books::list_series))
|
||||
.route("/libraries/:library_id/series/:name/metadata", get(books::get_series_metadata))
|
||||
.route("/series", get(books::list_all_series))
|
||||
.route("/series/ongoing", get(books::ongoing_series))
|
||||
.route("/series/statuses", get(books::series_statuses))
|
||||
.route("/series/provider-statuses", get(books::provider_statuses))
|
||||
.route("/libraries/:library_id/series", get(series::list_series))
|
||||
.route("/libraries/:library_id/series/:name/metadata", get(series::get_series_metadata))
|
||||
.route("/series", get(series::list_all_series))
|
||||
.route("/series/ongoing", get(series::ongoing_series))
|
||||
.route("/series/statuses", get(series::series_statuses))
|
||||
.route("/series/provider-statuses", get(series::provider_statuses))
|
||||
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
||||
.route("/authors", get(authors::list_authors))
|
||||
.route("/stats", get(stats::get_stats))
|
||||
@@ -155,6 +163,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
auth::require_read,
|
||||
));
|
||||
|
||||
// Clone pool before state is moved into the router
|
||||
let poller_pool = state.pool.clone();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(handlers::health))
|
||||
.route("/ready", get(handlers::ready))
|
||||
@@ -166,6 +177,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
.layer(middleware::from_fn_with_state(state.clone(), api_middleware::request_counter))
|
||||
.with_state(state);
|
||||
|
||||
// Start background poller for API-only jobs (metadata_batch, metadata_refresh)
|
||||
tokio::spawn(async move {
|
||||
job_poller::run_job_poller(poller_pool, 5).await;
|
||||
});
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
|
||||
info!(addr = %config.listen_addr, "api listening");
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
@@ -369,6 +369,26 @@ pub async fn approve_metadata(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Notify via Telegram (with first book thumbnail if available)
|
||||
let provider_for_notif: String = row.get("provider");
|
||||
let thumbnail_path: Option<String> = sqlx::query_scalar(
|
||||
"SELECT thumbnail_path FROM books WHERE library_id = $1 AND series_name = $2 AND thumbnail_path IS NOT NULL ORDER BY sort_order LIMIT 1",
|
||||
)
|
||||
.bind(library_id)
|
||||
.bind(&series_name)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
notifications::notify(
|
||||
state.pool.clone(),
|
||||
notifications::NotificationEvent::MetadataApproved {
|
||||
series_name: series_name.clone(),
|
||||
provider: provider_for_notif,
|
||||
thumbnail_path,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Json(ApproveResponse {
|
||||
status: "approved".to_string(),
|
||||
report,
|
||||
|
||||
@@ -115,15 +115,21 @@ pub async fn start_batch(
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_batch', 'pending')",
|
||||
"INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'metadata_batch', 'running', NOW())",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(library_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Spawn the background processing task
|
||||
// Spawn the background processing task (status already 'running' to avoid poller race)
|
||||
let pool = state.pool.clone();
|
||||
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = process_metadata_batch(&pool, job_id, library_id).await {
|
||||
warn!("[METADATA_BATCH] job {job_id} failed: {e}");
|
||||
@@ -134,6 +140,13 @@ pub async fn start_batch(
|
||||
.bind(e.to_string())
|
||||
.execute(&pool)
|
||||
.await;
|
||||
notifications::notify(
|
||||
pool.clone(),
|
||||
notifications::NotificationEvent::MetadataBatchFailed {
|
||||
library_name,
|
||||
error: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -300,7 +313,7 @@ pub async fn get_batch_results(
|
||||
// Background processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn process_metadata_batch(
|
||||
pub(crate) async fn process_metadata_batch(
|
||||
pool: &PgPool,
|
||||
job_id: Uuid,
|
||||
library_id: Uuid,
|
||||
@@ -621,6 +634,21 @@ async fn process_metadata_batch(
|
||||
|
||||
info!("[METADATA_BATCH] job={job_id} completed: {processed}/{total} series processed");
|
||||
|
||||
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
notifications::notify(
|
||||
pool.clone(),
|
||||
notifications::NotificationEvent::MetadataBatchCompleted {
|
||||
library_name,
|
||||
total_series: total,
|
||||
processed,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -110,9 +110,16 @@ pub async fn start_refresh(
|
||||
})));
|
||||
}
|
||||
|
||||
// Check there are approved links to refresh
|
||||
// Check there are approved links to refresh (only ongoing series)
|
||||
let link_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM external_metadata_links WHERE library_id = $1 AND status = 'approved'",
|
||||
r#"
|
||||
SELECT COUNT(*) FROM external_metadata_links eml
|
||||
LEFT JOIN series_metadata sm
|
||||
ON sm.library_id = eml.library_id AND sm.name = eml.series_name
|
||||
WHERE eml.library_id = $1
|
||||
AND eml.status = 'approved'
|
||||
AND COALESCE(sm.status, 'ongoing') NOT IN ('ended', 'cancelled')
|
||||
"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_one(&state.pool)
|
||||
@@ -124,15 +131,21 @@ pub async fn start_refresh(
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_refresh', 'pending')",
|
||||
"INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'metadata_refresh', 'running', NOW())",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(library_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Spawn the background processing task
|
||||
// Spawn the background processing task (status already 'running' to avoid poller race)
|
||||
let pool = state.pool.clone();
|
||||
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = process_metadata_refresh(&pool, job_id, library_id).await {
|
||||
warn!("[METADATA_REFRESH] job {job_id} failed: {e}");
|
||||
@@ -143,6 +156,13 @@ pub async fn start_refresh(
|
||||
.bind(e.to_string())
|
||||
.execute(&pool)
|
||||
.await;
|
||||
notifications::notify(
|
||||
pool.clone(),
|
||||
notifications::NotificationEvent::MetadataRefreshFailed {
|
||||
library_name,
|
||||
error: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -209,7 +229,7 @@ pub async fn get_refresh_report(
|
||||
// Background processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn process_metadata_refresh(
|
||||
pub(crate) async fn process_metadata_refresh(
|
||||
pool: &PgPool,
|
||||
job_id: Uuid,
|
||||
library_id: Uuid,
|
||||
@@ -221,13 +241,17 @@ async fn process_metadata_refresh(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Get all approved links for this library
|
||||
// Get approved links for this library, only for ongoing series (not ended/cancelled)
|
||||
let links: Vec<(Uuid, String, String, String)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, series_name, provider, external_id
|
||||
FROM external_metadata_links
|
||||
WHERE library_id = $1 AND status = 'approved'
|
||||
ORDER BY series_name
|
||||
SELECT eml.id, eml.series_name, eml.provider, eml.external_id
|
||||
FROM external_metadata_links eml
|
||||
LEFT JOIN series_metadata sm
|
||||
ON sm.library_id = eml.library_id AND sm.name = eml.series_name
|
||||
WHERE eml.library_id = $1
|
||||
AND eml.status = 'approved'
|
||||
AND COALESCE(sm.status, 'ongoing') NOT IN ('ended', 'cancelled')
|
||||
ORDER BY eml.series_name
|
||||
"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
@@ -319,6 +343,22 @@ async fn process_metadata_refresh(
|
||||
|
||||
info!("[METADATA_REFRESH] job={job_id} completed: {refreshed} updated, {unchanged} unchanged, {errors} errors");
|
||||
|
||||
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(library_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
notifications::notify(
|
||||
pool.clone(),
|
||||
notifications::NotificationEvent::MetadataRefreshCompleted {
|
||||
library_name,
|
||||
refreshed,
|
||||
unchanged,
|
||||
errors,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@ use utoipa::OpenApi;
|
||||
crate::reading_progress::update_reading_progress,
|
||||
crate::reading_progress::mark_series_read,
|
||||
crate::books::get_thumbnail,
|
||||
crate::books::list_series,
|
||||
crate::books::list_all_series,
|
||||
crate::books::ongoing_series,
|
||||
crate::books::ongoing_books,
|
||||
crate::series::list_series,
|
||||
crate::series::list_all_series,
|
||||
crate::series::ongoing_series,
|
||||
crate::series::ongoing_books,
|
||||
crate::books::convert_book,
|
||||
crate::books::update_book,
|
||||
crate::books::get_series_metadata,
|
||||
crate::books::update_series,
|
||||
crate::series::get_series_metadata,
|
||||
crate::series::update_series,
|
||||
crate::pages::get_page,
|
||||
crate::search::search_books,
|
||||
crate::index_jobs::enqueue_rebuild,
|
||||
@@ -35,6 +35,7 @@ use utoipa::OpenApi;
|
||||
crate::libraries::delete_library,
|
||||
crate::libraries::scan_library,
|
||||
crate::libraries::update_monitoring,
|
||||
crate::libraries::update_metadata_provider,
|
||||
crate::tokens::list_tokens,
|
||||
crate::tokens::create_token,
|
||||
crate::tokens::revoke_token,
|
||||
@@ -54,8 +55,8 @@ use utoipa::OpenApi;
|
||||
crate::metadata::get_metadata_links,
|
||||
crate::metadata::get_missing_books,
|
||||
crate::metadata::delete_metadata_link,
|
||||
crate::books::series_statuses,
|
||||
crate::books::provider_statuses,
|
||||
crate::series::series_statuses,
|
||||
crate::series::provider_statuses,
|
||||
crate::settings::list_status_mappings,
|
||||
crate::settings::upsert_status_mapping,
|
||||
crate::settings::delete_status_mapping,
|
||||
@@ -63,6 +64,14 @@ use utoipa::OpenApi;
|
||||
crate::prowlarr::test_prowlarr,
|
||||
crate::qbittorrent::add_torrent,
|
||||
crate::qbittorrent::test_qbittorrent,
|
||||
crate::metadata_batch::start_batch,
|
||||
crate::metadata_batch::get_batch_report,
|
||||
crate::metadata_batch::get_batch_results,
|
||||
crate::metadata_refresh::start_refresh,
|
||||
crate::metadata_refresh::get_refresh_report,
|
||||
crate::komga::sync_komga_read_books,
|
||||
crate::komga::list_sync_reports,
|
||||
crate::komga::get_sync_report,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
@@ -74,14 +83,14 @@ use utoipa::OpenApi;
|
||||
crate::reading_progress::UpdateReadingProgressRequest,
|
||||
crate::reading_progress::MarkSeriesReadRequest,
|
||||
crate::reading_progress::MarkSeriesReadResponse,
|
||||
crate::books::SeriesItem,
|
||||
crate::books::SeriesPage,
|
||||
crate::books::ListAllSeriesQuery,
|
||||
crate::books::OngoingQuery,
|
||||
crate::series::SeriesItem,
|
||||
crate::series::SeriesPage,
|
||||
crate::series::ListAllSeriesQuery,
|
||||
crate::series::OngoingQuery,
|
||||
crate::books::UpdateBookRequest,
|
||||
crate::books::SeriesMetadata,
|
||||
crate::books::UpdateSeriesRequest,
|
||||
crate::books::UpdateSeriesResponse,
|
||||
crate::series::SeriesMetadata,
|
||||
crate::series::UpdateSeriesRequest,
|
||||
crate::series::UpdateSeriesResponse,
|
||||
crate::pages::PageQuery,
|
||||
crate::search::SearchQuery,
|
||||
crate::search::SearchResponse,
|
||||
@@ -96,6 +105,7 @@ use utoipa::OpenApi;
|
||||
crate::libraries::LibraryResponse,
|
||||
crate::libraries::CreateLibraryRequest,
|
||||
crate::libraries::UpdateMonitoringRequest,
|
||||
crate::libraries::UpdateMetadataProviderRequest,
|
||||
crate::tokens::CreateTokenRequest,
|
||||
crate::tokens::TokenResponse,
|
||||
crate::tokens::CreatedTokenResponse,
|
||||
@@ -137,7 +147,16 @@ use utoipa::OpenApi;
|
||||
crate::prowlarr::ProwlarrRelease,
|
||||
crate::prowlarr::ProwlarrCategory,
|
||||
crate::prowlarr::ProwlarrSearchResponse,
|
||||
crate::prowlarr::MissingVolumeInput,
|
||||
crate::prowlarr::ProwlarrTestResponse,
|
||||
crate::metadata_batch::MetadataBatchRequest,
|
||||
crate::metadata_batch::MetadataBatchReportDto,
|
||||
crate::metadata_batch::MetadataBatchResultDto,
|
||||
crate::metadata_refresh::MetadataRefreshRequest,
|
||||
crate::metadata_refresh::MetadataRefreshReportDto,
|
||||
crate::komga::KomgaSyncRequest,
|
||||
crate::komga::KomgaSyncResponse,
|
||||
crate::komga::KomgaSyncReportSummary,
|
||||
ErrorResponse,
|
||||
)
|
||||
),
|
||||
@@ -145,11 +164,16 @@ use utoipa::OpenApi;
|
||||
("Bearer" = [])
|
||||
),
|
||||
tags(
|
||||
(name = "authors", description = "Author browsing and listing"),
|
||||
(name = "books", description = "Read-only endpoints for browsing and searching books"),
|
||||
(name = "books", description = "Book browsing, details and management"),
|
||||
(name = "series", description = "Series browsing, filtering and management"),
|
||||
(name = "search", description = "Full-text search across books and series"),
|
||||
(name = "reading-progress", description = "Reading progress tracking per book"),
|
||||
(name = "libraries", description = "Library management endpoints (Admin only)"),
|
||||
(name = "authors", description = "Author browsing and listing"),
|
||||
(name = "stats", description = "Collection statistics and dashboard data"),
|
||||
(name = "libraries", description = "Library listing, scanning, and management (create/delete/settings: Admin only)"),
|
||||
(name = "indexing", description = "Search index management and job control (Admin only)"),
|
||||
(name = "metadata", description = "External metadata providers and matching (Admin only)"),
|
||||
(name = "komga", description = "Komga read-status sync (Admin only)"),
|
||||
(name = "tokens", description = "API token management (Admin only)"),
|
||||
(name = "settings", description = "Application settings and cache management (Admin only)"),
|
||||
(name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"),
|
||||
|
||||
@@ -351,6 +351,7 @@ async fn prefetch_page(state: AppState, params: &PrefetchParams<'_>) {
|
||||
Some(ref e) if e == "cbz" => "cbz",
|
||||
Some(ref e) if e == "cbr" => "cbr",
|
||||
Some(ref e) if e == "pdf" => "pdf",
|
||||
Some(ref e) if e == "epub" => "epub",
|
||||
_ => return,
|
||||
}
|
||||
.to_string();
|
||||
@@ -479,6 +480,7 @@ fn render_page(
|
||||
"cbz" => parsers::BookFormat::Cbz,
|
||||
"cbr" => parsers::BookFormat::Cbr,
|
||||
"pdf" => parsers::BookFormat::Pdf,
|
||||
"epub" => parsers::BookFormat::Epub,
|
||||
_ => return Err(ApiError::bad_request("unsupported source format")),
|
||||
};
|
||||
|
||||
|
||||
@@ -7,15 +7,39 @@ use crate::{error::ApiError, state::AppState};
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct MissingVolumeInput {
|
||||
pub volume_number: Option<i32>,
|
||||
#[allow(dead_code)]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ProwlarrSearchRequest {
|
||||
pub series_name: String,
|
||||
pub volume_number: Option<i32>,
|
||||
pub custom_query: Option<String>,
|
||||
pub missing_volumes: Option<Vec<MissingVolumeInput>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProwlarrRawRelease {
|
||||
pub guid: String,
|
||||
pub title: String,
|
||||
pub size: i64,
|
||||
pub download_url: Option<String>,
|
||||
pub indexer: Option<String>,
|
||||
pub seeders: Option<i32>,
|
||||
pub leechers: Option<i32>,
|
||||
pub publish_date: Option<String>,
|
||||
pub protocol: Option<String>,
|
||||
pub info_url: Option<String>,
|
||||
pub categories: Option<Vec<ProwlarrCategory>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProwlarrRelease {
|
||||
pub guid: String,
|
||||
pub title: String,
|
||||
@@ -28,6 +52,8 @@ pub struct ProwlarrRelease {
|
||||
pub protocol: Option<String>,
|
||||
pub info_url: Option<String>,
|
||||
pub categories: Option<Vec<ProwlarrCategory>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub matched_missing_volumes: Option<Vec<i32>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -83,6 +109,107 @@ async fn load_prowlarr_config(
|
||||
Ok((url, config.api_key, categories))
|
||||
}
|
||||
|
||||
// ─── Volume matching ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract volume numbers from a release title.
|
||||
/// Looks for patterns like: T01, Tome 01, Vol. 01, v01, #01,
|
||||
/// or standalone numbers that appear after common separators.
|
||||
fn extract_volumes_from_title(title: &str) -> Vec<i32> {
|
||||
let lower = title.to_lowercase();
|
||||
let mut volumes = Vec::new();
|
||||
|
||||
// Patterns: T01, Tome 01, Tome01, Vol 01, Vol.01, v01, #01
|
||||
let prefixes = ["tome", "vol.", "vol ", "t", "v", "#"];
|
||||
let chars: Vec<char> = lower.chars().collect();
|
||||
let len = chars.len();
|
||||
|
||||
for prefix in &prefixes {
|
||||
let mut start = 0;
|
||||
while let Some(pos) = lower[start..].find(prefix) {
|
||||
let abs_pos = start + pos;
|
||||
let after = abs_pos + prefix.len();
|
||||
|
||||
// For single-char prefixes (t, v, #), ensure it's at a word boundary
|
||||
if prefix.len() == 1 && *prefix != "#" {
|
||||
if abs_pos > 0 && chars[abs_pos - 1].is_alphanumeric() {
|
||||
start = after;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip optional spaces after prefix
|
||||
let mut i = after;
|
||||
while i < len && chars[i] == ' ' {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Read digits
|
||||
let digit_start = i;
|
||||
while i < len && chars[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if i > digit_start {
|
||||
if let Ok(num) = lower[digit_start..i].parse::<i32>() {
|
||||
if !volumes.contains(&num) {
|
||||
volumes.push(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start = after;
|
||||
}
|
||||
}
|
||||
|
||||
volumes
|
||||
}
|
||||
|
||||
/// Match releases against missing volume numbers.
|
||||
fn match_missing_volumes(
|
||||
releases: Vec<ProwlarrRawRelease>,
|
||||
missing: &[MissingVolumeInput],
|
||||
) -> Vec<ProwlarrRelease> {
|
||||
let missing_numbers: Vec<i32> = missing
|
||||
.iter()
|
||||
.filter_map(|m| m.volume_number)
|
||||
.collect();
|
||||
|
||||
releases
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let matched = if missing_numbers.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let title_volumes = extract_volumes_from_title(&r.title);
|
||||
let matched: Vec<i32> = title_volumes
|
||||
.into_iter()
|
||||
.filter(|v| missing_numbers.contains(v))
|
||||
.collect();
|
||||
if matched.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(matched)
|
||||
}
|
||||
};
|
||||
|
||||
ProwlarrRelease {
|
||||
guid: r.guid,
|
||||
title: r.title,
|
||||
size: r.size,
|
||||
download_url: r.download_url,
|
||||
indexer: r.indexer,
|
||||
seeders: r.seeders,
|
||||
leechers: r.leechers,
|
||||
publish_date: r.publish_date,
|
||||
protocol: r.protocol,
|
||||
info_url: r.info_url,
|
||||
categories: r.categories,
|
||||
matched_missing_volumes: matched,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Search for releases on Prowlarr
|
||||
@@ -149,13 +276,35 @@ pub async fn search_prowlarr(
|
||||
|
||||
tracing::debug!("Prowlarr raw response length: {} chars", raw_text.len());
|
||||
|
||||
let results: Vec<ProwlarrRelease> = serde_json::from_str(&raw_text)
|
||||
let raw_releases: Vec<ProwlarrRawRelease> = serde_json::from_str(&raw_text)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to parse Prowlarr response: {e}");
|
||||
tracing::error!("Raw response (first 500 chars): {}", &raw_text[..raw_text.len().min(500)]);
|
||||
ApiError::internal(format!("Failed to parse Prowlarr response: {e}"))
|
||||
})?;
|
||||
|
||||
let results = if let Some(missing) = &body.missing_volumes {
|
||||
match_missing_volumes(raw_releases, missing)
|
||||
} else {
|
||||
raw_releases
|
||||
.into_iter()
|
||||
.map(|r| ProwlarrRelease {
|
||||
guid: r.guid,
|
||||
title: r.title,
|
||||
size: r.size,
|
||||
download_url: r.download_url,
|
||||
indexer: r.indexer,
|
||||
seeders: r.seeders,
|
||||
leechers: r.leechers,
|
||||
publish_date: r.publish_date,
|
||||
protocol: r.protocol,
|
||||
info_url: r.info_url,
|
||||
categories: r.categories,
|
||||
matched_missing_volumes: None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
Ok(Json(ProwlarrSearchResponse { results, query }))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use axum::{extract::{Path, State}, Json};
|
||||
use axum::{extract::{Extension, Path, State}, Json};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
use crate::{auth::AuthUser, error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ReadingProgressResponse {
|
||||
@@ -42,8 +42,10 @@ pub struct UpdateReadingProgressRequest {
|
||||
)]
|
||||
pub async fn get_reading_progress(
|
||||
State(state): State<AppState>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
||||
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
|
||||
// Verify book exists
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
|
||||
.bind(id)
|
||||
@@ -55,9 +57,10 @@ pub async fn get_reading_progress(
|
||||
}
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1",
|
||||
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(auth_user.user_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -96,9 +99,11 @@ pub async fn get_reading_progress(
|
||||
)]
|
||||
pub async fn update_reading_progress(
|
||||
State(state): State<AppState>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<UpdateReadingProgressRequest>,
|
||||
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
||||
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
|
||||
// Validate status value
|
||||
if !["unread", "reading", "read"].contains(&body.status.as_str()) {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
@@ -143,9 +148,9 @@ pub async fn update_reading_progress(
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (book_id) DO UPDATE
|
||||
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||
SET status = EXCLUDED.status,
|
||||
current_page = EXCLUDED.current_page,
|
||||
last_read_at = NOW(),
|
||||
@@ -154,6 +159,7 @@ pub async fn update_reading_progress(
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(auth_user.user_id)
|
||||
.bind(&body.status)
|
||||
.bind(current_page)
|
||||
.fetch_one(&state.pool)
|
||||
@@ -194,8 +200,10 @@ pub struct MarkSeriesReadResponse {
|
||||
)]
|
||||
pub async fn mark_series_read(
|
||||
State(state): State<AppState>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
Json(body): Json<MarkSeriesReadRequest>,
|
||||
) -> Result<Json<MarkSeriesReadResponse>, ApiError> {
|
||||
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
|
||||
if !["read", "unread"].contains(&body.status.as_str()) {
|
||||
return Err(ApiError::bad_request(
|
||||
"status must be 'read' or 'unread'",
|
||||
@@ -209,24 +217,50 @@ pub async fn mark_series_read(
|
||||
};
|
||||
|
||||
let sql = if body.status == "unread" {
|
||||
// Delete progress records to reset to unread
|
||||
// Delete progress records to reset to unread (scoped to this user)
|
||||
if body.series == "unclassified" {
|
||||
format!(
|
||||
r#"
|
||||
WITH target_books AS (
|
||||
SELECT id FROM books WHERE {series_filter}
|
||||
)
|
||||
DELETE FROM book_reading_progress
|
||||
WHERE book_id IN (SELECT id FROM target_books)
|
||||
WHERE book_id IN (SELECT id FROM target_books) AND user_id = $1
|
||||
"#
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"
|
||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
||||
SELECT id, 'read', NULL, NOW(), NOW()
|
||||
WITH target_books AS (
|
||||
SELECT id FROM books WHERE {series_filter}
|
||||
)
|
||||
DELETE FROM book_reading_progress
|
||||
WHERE book_id IN (SELECT id FROM target_books) AND user_id = $2
|
||||
"#
|
||||
)
|
||||
}
|
||||
} else if body.series == "unclassified" {
|
||||
format!(
|
||||
r#"
|
||||
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||
SELECT id, $1, 'read', NULL, NOW(), NOW()
|
||||
FROM books
|
||||
WHERE {series_filter}
|
||||
ON CONFLICT (book_id) DO UPDATE
|
||||
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||
SET status = 'read',
|
||||
current_page = NULL,
|
||||
last_read_at = NOW(),
|
||||
updated_at = NOW()
|
||||
"#
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"
|
||||
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||
SELECT id, $2, 'read', NULL, NOW(), NOW()
|
||||
FROM books
|
||||
WHERE {series_filter}
|
||||
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||
SET status = 'read',
|
||||
current_page = NULL,
|
||||
last_read_at = NOW(),
|
||||
@@ -236,9 +270,18 @@ pub async fn mark_series_read(
|
||||
};
|
||||
|
||||
let result = if body.series == "unclassified" {
|
||||
sqlx::query(&sql).execute(&state.pool).await?
|
||||
// $1 = user_id (no series bind needed)
|
||||
sqlx::query(&sql)
|
||||
.bind(auth_user.user_id)
|
||||
.execute(&state.pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query(&sql).bind(&body.series).execute(&state.pool).await?
|
||||
// $1 = series, $2 = user_id
|
||||
sqlx::query(&sql)
|
||||
.bind(&body.series)
|
||||
.bind(auth_user.user_id)
|
||||
.execute(&state.pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(Json(MarkSeriesReadResponse {
|
||||
|
||||
@@ -43,11 +43,11 @@ pub struct SearchResponse {
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/search",
|
||||
tag = "books",
|
||||
tag = "search",
|
||||
params(
|
||||
("q" = String, Query, description = "Search query (books + series via PostgreSQL full-text)"),
|
||||
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
||||
("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf)"),
|
||||
("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf, epub)"),
|
||||
("kind" = Option<String>, Query, description = "Filter by kind (alias for type)"),
|
||||
("limit" = Option<usize>, Query, description = "Max results per type (max 100)"),
|
||||
),
|
||||
|
||||
1043
apps/api/src/series.rs
Normal file
1043
apps/api/src/series.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,18 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Serialize;
|
||||
use axum::{
|
||||
extract::{Extension, Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use utoipa::ToSchema;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
use crate::{auth::AuthUser, error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct StatsQuery {
|
||||
/// Granularity: "day", "week" or "month" (default: "month")
|
||||
pub period: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct StatsOverview {
|
||||
@@ -74,23 +83,70 @@ pub struct ProviderCount {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct CurrentlyReadingItem {
|
||||
pub book_id: String,
|
||||
pub title: String,
|
||||
pub series: Option<String>,
|
||||
pub current_page: i32,
|
||||
pub page_count: i32,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct RecentlyReadItem {
|
||||
pub book_id: String,
|
||||
pub title: String,
|
||||
pub series: Option<String>,
|
||||
pub last_read_at: String,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct MonthlyReading {
|
||||
pub month: String,
|
||||
pub books_read: i64,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub struct StatsResponse {
|
||||
pub overview: StatsOverview,
|
||||
pub reading_status: ReadingStatusStats,
|
||||
pub currently_reading: Vec<CurrentlyReadingItem>,
|
||||
pub recently_read: Vec<RecentlyReadItem>,
|
||||
pub reading_over_time: Vec<MonthlyReading>,
|
||||
pub by_format: Vec<FormatCount>,
|
||||
pub by_language: Vec<LanguageCount>,
|
||||
pub by_library: Vec<LibraryStats>,
|
||||
pub top_series: Vec<TopSeries>,
|
||||
pub additions_over_time: Vec<MonthlyAdditions>,
|
||||
pub jobs_over_time: Vec<JobTimePoint>,
|
||||
pub metadata: MetadataStats,
|
||||
pub users_reading_over_time: Vec<UserMonthlyReading>,
|
||||
}
|
||||
|
||||
/// Get collection statistics for the dashboard
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/stats",
|
||||
tag = "books",
|
||||
tag = "stats",
|
||||
params(StatsQuery),
|
||||
responses(
|
||||
(status = 200, body = StatsResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
@@ -99,7 +155,11 @@ pub struct StatsResponse {
|
||||
)]
|
||||
pub async fn get_stats(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<StatsQuery>,
|
||||
user: Option<Extension<AuthUser>>,
|
||||
) -> Result<Json<StatsResponse>, ApiError> {
|
||||
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||
let period = query.period.as_deref().unwrap_or("month");
|
||||
// Overview + reading status in one query
|
||||
let overview_row = sqlx::query(
|
||||
r#"
|
||||
@@ -117,9 +177,10 @@ pub async fn get_stats(
|
||||
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading,
|
||||
COUNT(*) FILTER (WHERE brp.status = 'read') AS read
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -207,7 +268,7 @@ pub async fn get_stats(
|
||||
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread_count
|
||||
FROM libraries l
|
||||
LEFT JOIN books b ON b.library_id = l.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT size_bytes FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
|
||||
) bf ON TRUE
|
||||
@@ -215,6 +276,7 @@ pub async fn get_stats(
|
||||
ORDER BY book_count DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -239,13 +301,14 @@ pub async fn get_stats(
|
||||
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
|
||||
COALESCE(SUM(b.page_count), 0)::BIGINT AS total_pages
|
||||
FROM books b
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
WHERE b.series IS NOT NULL AND b.series != ''
|
||||
GROUP BY b.series
|
||||
ORDER BY book_count DESC
|
||||
LIMIT 10
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -259,20 +322,74 @@ pub async fn get_stats(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Additions over time (last 12 months)
|
||||
let additions_rows = sqlx::query(
|
||||
// Additions over time (with gap filling)
|
||||
let additions_rows = match period {
|
||||
"day" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month,
|
||||
COUNT(*) AS books_added
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_added, 0) AS books_added
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT created_at::date AS dt, COUNT(*) AS books_added
|
||||
FROM books
|
||||
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', created_at)
|
||||
WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||
GROUP BY created_at::date
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
.await?
|
||||
}
|
||||
"week" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
COALESCE(cnt.books_added, 0) AS books_added
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||
DATE_TRUNC('week', NOW()),
|
||||
'1 week'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('week', created_at) AS dt, COUNT(*) AS books_added
|
||||
FROM books
|
||||
WHERE created_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||
GROUP BY DATE_TRUNC('week', created_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||
COALESCE(cnt.books_added, 0) AS books_added
|
||||
FROM generate_series(
|
||||
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||
DATE_TRUNC('month', NOW()),
|
||||
'1 month'
|
||||
) AS d(dt)
|
||||
LEFT JOIN (
|
||||
SELECT DATE_TRUNC('month', created_at) AS dt, COUNT(*) AS books_added
|
||||
FROM books
|
||||
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||
GROUP BY DATE_TRUNC('month', created_at)
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
|
||||
.iter()
|
||||
@@ -327,14 +444,376 @@ pub async fn get_stats(
|
||||
by_provider,
|
||||
};
|
||||
|
||||
// Currently reading books
|
||||
let reading_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT b.id AS book_id, b.title, b.series, brp.current_page, b.page_count, u.username
|
||||
FROM book_reading_progress brp
|
||||
JOIN books b ON b.id = brp.book_id
|
||||
LEFT JOIN users u ON u.id = brp.user_id
|
||||
WHERE brp.status = 'reading' AND brp.current_page IS NOT NULL
|
||||
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
ORDER BY brp.updated_at DESC
|
||||
LIMIT 20
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let currently_reading: Vec<CurrentlyReadingItem> = reading_rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let id: uuid::Uuid = r.get("book_id");
|
||||
CurrentlyReadingItem {
|
||||
book_id: id.to_string(),
|
||||
title: r.get("title"),
|
||||
series: r.get("series"),
|
||||
current_page: r.get::<Option<i32>, _>("current_page").unwrap_or(0),
|
||||
page_count: r.get::<Option<i32>, _>("page_count").unwrap_or(0),
|
||||
username: r.get("username"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Recently read books
|
||||
let recent_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT b.id AS book_id, b.title, b.series,
|
||||
TO_CHAR(brp.last_read_at, 'YYYY-MM-DD') AS last_read_at,
|
||||
u.username
|
||||
FROM book_reading_progress brp
|
||||
JOIN books b ON b.id = brp.book_id
|
||||
LEFT JOIN users u ON u.id = brp.user_id
|
||||
WHERE brp.status = 'read' AND brp.last_read_at IS NOT NULL
|
||||
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
ORDER BY brp.last_read_at DESC
|
||||
LIMIT 10
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let recently_read: Vec<RecentlyReadItem> = recent_rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let id: uuid::Uuid = r.get("book_id");
|
||||
RecentlyReadItem {
|
||||
book_id: id.to_string(),
|
||||
title: r.get("title"),
|
||||
series: r.get("series"),
|
||||
last_read_at: r.get::<Option<String>, _>("last_read_at").unwrap_or_default(),
|
||||
username: r.get("username"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Reading activity over time (with gap filling)
|
||||
let reading_time_rows = match period {
|
||||
"day" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
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
|
||||
WHERE brp.status = 'read'
|
||||
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||
GROUP BY brp.last_read_at::date
|
||||
) cnt ON cnt.dt = d.dt
|
||||
ORDER BY month ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?
|
||||
}
|
||||
"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
|
||||
.iter()
|
||||
.map(|r| MonthlyReading {
|
||||
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
|
||||
books_read: r.get("books_read"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Per-user reading over time (admin view — always all users, no user_id filter)
|
||||
let users_reading_time_rows = match period {
|
||||
"day" => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||
u.username,
|
||||
COALESCE(cnt.books_read, 0) AS books_read
|
||||
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 {
|
||||
overview,
|
||||
reading_status,
|
||||
currently_reading,
|
||||
recently_read,
|
||||
reading_over_time,
|
||||
by_format,
|
||||
by_language,
|
||||
by_library,
|
||||
top_series,
|
||||
additions_over_time,
|
||||
jobs_over_time,
|
||||
metadata,
|
||||
users_reading_over_time,
|
||||
}))
|
||||
}
|
||||
|
||||
46
apps/api/src/telegram.rs
Normal file
46
apps/api/src/telegram.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct TelegramTestResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Test Telegram connection by sending a test message
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/telegram/test",
|
||||
tag = "notifications",
|
||||
responses(
|
||||
(status = 200, body = TelegramTestResponse),
|
||||
(status = 400, description = "Telegram not configured"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn test_telegram(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<TelegramTestResponse>, ApiError> {
|
||||
let config = notifications::load_telegram_config(&state.pool)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
ApiError::bad_request(
|
||||
"Telegram is not configured or disabled. Set bot_token, chat_id, and enable it.",
|
||||
)
|
||||
})?;
|
||||
|
||||
match notifications::send_test_message(&config).await {
|
||||
Ok(()) => Ok(Json(TelegramTestResponse {
|
||||
success: true,
|
||||
message: "Test message sent successfully".to_string(),
|
||||
})),
|
||||
Err(e) => Ok(Json(TelegramTestResponse {
|
||||
success: false,
|
||||
message: format!("Failed to send: {e}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ pub struct CreateTokenRequest {
|
||||
pub name: String,
|
||||
#[schema(value_type = Option<String>, example = "read")]
|
||||
pub scope: Option<String>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -26,6 +28,9 @@ pub struct TokenResponse {
|
||||
pub scope: String,
|
||||
pub prefix: String,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub user_id: Option<Uuid>,
|
||||
pub username: Option<String>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
@@ -71,6 +76,10 @@ pub async fn create_token(
|
||||
_ => return Err(ApiError::bad_request("scope must be 'admin' or 'read'")),
|
||||
};
|
||||
|
||||
if scope == "read" && input.user_id.is_none() {
|
||||
return Err(ApiError::bad_request("user_id is required for read-scoped tokens"));
|
||||
}
|
||||
|
||||
let mut random = [0u8; 24];
|
||||
OsRng.fill_bytes(&mut random);
|
||||
let secret = URL_SAFE_NO_PAD.encode(random);
|
||||
@@ -85,13 +94,14 @@ pub async fn create_token(
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope) VALUES ($1, $2, $3, $4, $5)",
|
||||
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope, user_id) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(input.name.trim())
|
||||
.bind(&prefix)
|
||||
.bind(token_hash)
|
||||
.bind(scope)
|
||||
.bind(input.user_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -118,7 +128,13 @@ pub async fn create_token(
|
||||
)]
|
||||
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenResponse>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, scope, prefix, last_used_at, revoked_at, created_at FROM api_tokens ORDER BY created_at DESC",
|
||||
r#"
|
||||
SELECT t.id, t.name, t.scope, t.prefix, t.user_id, u.username,
|
||||
t.last_used_at, t.revoked_at, t.created_at
|
||||
FROM api_tokens t
|
||||
LEFT JOIN users u ON u.id = t.user_id
|
||||
ORDER BY t.created_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
@@ -130,6 +146,8 @@ pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<Token
|
||||
name: row.get("name"),
|
||||
scope: row.get("scope"),
|
||||
prefix: row.get("prefix"),
|
||||
user_id: row.get("user_id"),
|
||||
username: row.get("username"),
|
||||
last_used_at: row.get("last_used_at"),
|
||||
revoked_at: row.get("revoked_at"),
|
||||
created_at: row.get("created_at"),
|
||||
@@ -171,6 +189,47 @@ pub async fn revoke_token(
|
||||
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateTokenRequest {
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Update a token's assigned user
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/admin/tokens/{id}",
|
||||
tag = "tokens",
|
||||
params(
|
||||
("id" = String, Path, description = "Token UUID"),
|
||||
),
|
||||
request_body = UpdateTokenRequest,
|
||||
responses(
|
||||
(status = 200, description = "Token updated"),
|
||||
(status = 404, description = "Token not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn update_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(input): Json<UpdateTokenRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let result = sqlx::query("UPDATE api_tokens SET user_id = $1 WHERE id = $2")
|
||||
.bind(input.user_id)
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found("token not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"updated": true, "id": id})))
|
||||
}
|
||||
|
||||
/// Permanently delete a revoked API token
|
||||
#[utoipa::path(
|
||||
post,
|
||||
|
||||
195
apps/api/src/users.rs
Normal file
195
apps/api/src/users.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use axum::{extract::{Path, State}, Json};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserResponse {
|
||||
#[schema(value_type = String)]
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub token_count: i64,
|
||||
pub books_read: i64,
|
||||
pub books_reading: i64,
|
||||
#[schema(value_type = String)]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// List all reader users with their associated token count
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/admin/users",
|
||||
tag = "users",
|
||||
responses(
|
||||
(status = 200, body = Vec<UserResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn list_users(State(state): State<AppState>) -> Result<Json<Vec<UserResponse>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT u.id, u.username, u.created_at,
|
||||
COUNT(DISTINCT t.id) AS token_count,
|
||||
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read,
|
||||
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'reading') AS books_reading
|
||||
FROM users u
|
||||
LEFT JOIN api_tokens t ON t.user_id = u.id AND t.revoked_at IS NULL
|
||||
LEFT JOIN book_reading_progress brp ON brp.user_id = u.id
|
||||
GROUP BY u.id, u.username, u.created_at
|
||||
ORDER BY u.created_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|row| UserResponse {
|
||||
id: row.get("id"),
|
||||
username: row.get("username"),
|
||||
token_count: row.get("token_count"),
|
||||
books_read: row.get("books_read"),
|
||||
books_reading: row.get("books_reading"),
|
||||
created_at: row.get("created_at"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
/// Create a new reader user
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/admin/users",
|
||||
tag = "users",
|
||||
request_body = CreateUserRequest,
|
||||
responses(
|
||||
(status = 200, body = UserResponse, description = "User created"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn create_user(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateUserRequest>,
|
||||
) -> Result<Json<UserResponse>, ApiError> {
|
||||
if input.username.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("username is required"));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let row = sqlx::query(
|
||||
"INSERT INTO users (id, username) VALUES ($1, $2) RETURNING id, username, created_at",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(input.username.trim())
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db_err) = e {
|
||||
if db_err.constraint() == Some("users_username_key") {
|
||||
return ApiError::bad_request("username already exists");
|
||||
}
|
||||
}
|
||||
ApiError::from(e)
|
||||
})?;
|
||||
|
||||
Ok(Json(UserResponse {
|
||||
id: row.get("id"),
|
||||
username: row.get("username"),
|
||||
token_count: 0,
|
||||
books_read: 0,
|
||||
books_reading: 0,
|
||||
created_at: row.get("created_at"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update a reader user's username
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/admin/users/{id}",
|
||||
tag = "users",
|
||||
request_body = CreateUserRequest,
|
||||
responses(
|
||||
(status = 200, body = UserResponse, description = "User updated"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 404, description = "User not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn update_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(input): Json<CreateUserRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
if input.username.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("username is required"));
|
||||
}
|
||||
|
||||
let result = sqlx::query("UPDATE users SET username = $1 WHERE id = $2")
|
||||
.bind(input.username.trim())
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db_err) = e {
|
||||
if db_err.constraint() == Some("users_username_key") {
|
||||
return ApiError::bad_request("username already exists");
|
||||
}
|
||||
}
|
||||
ApiError::from(e)
|
||||
})?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found("user not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"updated": true, "id": id})))
|
||||
}
|
||||
|
||||
/// Delete a reader user (cascades on tokens and reading progress)
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/admin/users/{id}",
|
||||
tag = "users",
|
||||
params(
|
||||
("id" = String, Path, description = "User UUID"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "User deleted"),
|
||||
(status = 404, description = "User not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden - Admin scope required"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn delete_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let result = sqlx::query("DELETE FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found("user not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "../../../lib/api";
|
||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||
import { BooksGrid } from "../../components/BookCard";
|
||||
import { OffsetPagination } from "../../components/ui";
|
||||
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import { BooksGrid } from "@/app/components/BookCard";
|
||||
import { OffsetPagination } from "@/app/components/ui";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -21,26 +21,19 @@ export default async function AuthorDetailPage({
|
||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||
|
||||
// Fetch books by this author (server-side filtering via API) and series
|
||||
// Fetch books by this author (server-side filtering via API) and series by this author
|
||||
const [booksPage, seriesPage] = await Promise.all([
|
||||
fetchBooks(undefined, undefined, page, limit, undefined, undefined, authorName).catch(
|
||||
() => ({ items: [], total: 0, page: 1, limit }) as BooksPageDto
|
||||
),
|
||||
fetchAllSeries(undefined, undefined, undefined, 1, 200).catch(
|
||||
fetchAllSeries(undefined, undefined, undefined, 1, 200, undefined, undefined, undefined, undefined, authorName).catch(
|
||||
() => ({ items: [], total: 0, page: 1, limit: 200 }) as SeriesPageDto
|
||||
),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(booksPage.total / limit);
|
||||
|
||||
// Extract unique series names from this author's books
|
||||
const authorSeriesNames = new Set(
|
||||
booksPage.items
|
||||
.map((b) => b.series)
|
||||
.filter((s): s is string => s != null && s !== "")
|
||||
);
|
||||
|
||||
const authorSeries = seriesPage.items.filter((s) => authorSeriesNames.has(s.name));
|
||||
const authorSeries = seriesPage.items;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -95,7 +88,7 @@ export default async function AuthorDetailPage({
|
||||
alt={s.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fetchAuthors, AuthorsPageDto } from "../../lib/api";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||
import { fetchAuthors, AuthorsPageDto } from "@/lib/api";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -1,12 +1,16 @@
|
||||
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api";
|
||||
import { BookPreview } from "../../components/BookPreview";
|
||||
import { ConvertButton } from "../../components/ConvertButton";
|
||||
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
|
||||
import { EditBookForm } from "../../components/EditBookForm";
|
||||
import { SafeHtml } from "../../components/SafeHtml";
|
||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "@/lib/api";
|
||||
import { BookPreview } from "@/app/components/BookPreview";
|
||||
import { ConvertButton } from "@/app/components/ConvertButton";
|
||||
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
|
||||
import nextDynamic from "next/dynamic";
|
||||
import { SafeHtml } from "@/app/components/SafeHtml";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
const EditBookForm = nextDynamic(
|
||||
() => import("@/app/components/EditBookForm").then(m => m.EditBookForm)
|
||||
);
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -95,7 +99,7 @@ export default async function BookDetailPage({
|
||||
alt={t("bookDetail.coverOf", { title: book.title })}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
sizes="192px"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
@@ -1,10 +1,10 @@
|
||||
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "../../lib/api";
|
||||
import { BooksGrid, EmptyState } from "../components/BookCard";
|
||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "@/lib/api";
|
||||
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
|
||||
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -18,6 +18,8 @@ export default async function BooksPage({
|
||||
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
||||
const format = typeof searchParamsAwaited.format === "string" ? searchParamsAwaited.format : undefined;
|
||||
const metadataProvider = typeof searchParamsAwaited.metadata === "string" ? searchParamsAwaited.metadata : undefined;
|
||||
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||
@@ -62,7 +64,7 @@ export default async function BooksPage({
|
||||
totalHits = searchResponse.estimated_total_hits;
|
||||
}
|
||||
} else {
|
||||
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort).catch(() => ({
|
||||
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort, undefined, format, metadataProvider).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
@@ -91,12 +93,26 @@ export default async function BooksPage({
|
||||
{ value: "read", label: t("status.read") },
|
||||
];
|
||||
|
||||
const formatOptions = [
|
||||
{ value: "", label: t("books.allFormats") },
|
||||
{ value: "cbz", label: "CBZ" },
|
||||
{ value: "cbr", label: "CBR" },
|
||||
{ value: "pdf", label: "PDF" },
|
||||
{ value: "epub", label: "EPUB" },
|
||||
];
|
||||
|
||||
const metadataOptions = [
|
||||
{ value: "", label: t("series.metadataAll") },
|
||||
{ value: "linked", label: t("series.metadataLinked") },
|
||||
{ value: "unlinked", label: t("series.metadataUnlinked") },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "", label: t("books.sortTitle") },
|
||||
{ value: "latest", label: t("books.sortLatest") },
|
||||
];
|
||||
|
||||
const hasFilters = searchQuery || libraryId || readingStatus || sort;
|
||||
const hasFilters = searchQuery || libraryId || readingStatus || format || metadataProvider || sort;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -117,6 +133,8 @@ export default async function BooksPage({
|
||||
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder") },
|
||||
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
|
||||
{ name: "status", type: "select", label: t("books.status"), options: statusOptions },
|
||||
{ name: "format", type: "select", label: t("books.format"), options: formatOptions },
|
||||
{ name: "metadata", type: "select", label: t("series.metadata"), options: metadataOptions },
|
||||
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
|
||||
]}
|
||||
/>
|
||||
@@ -152,7 +170,7 @@ export default async function BooksPage({
|
||||
alt={t("books.coverOf", { name: s.name })}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
@@ -1,11 +1,14 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "../../../lib/api";
|
||||
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "@/lib/api";
|
||||
import {
|
||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
||||
} from "../../components/ui";
|
||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
||||
} from "@/app/components/ui";
|
||||
import { JobDetailLive } from "@/app/components/JobDetailLive";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
interface JobDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -99,6 +102,11 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
description: t("jobType.full_rebuildDesc"),
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
rescan: {
|
||||
label: t("jobType.rescanLabel"),
|
||||
description: t("jobType.rescanDesc"),
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
thumbnail_rebuild: {
|
||||
label: t("jobType.thumbnail_rebuildLabel"),
|
||||
description: t("jobType.thumbnail_rebuildDesc"),
|
||||
@@ -158,6 +166,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
const isCompleted = job.status === "success";
|
||||
const isFailed = job.status === "failed";
|
||||
const isCancelled = job.status === "cancelled";
|
||||
const isTerminal = isCompleted || isFailed || isCancelled;
|
||||
const isExtractingPages = job.status === "extracting_pages";
|
||||
const isThumbnailPhase = job.status === "generating_thumbnails";
|
||||
const isPhase2 = isExtractingPages || isThumbnailPhase;
|
||||
@@ -199,6 +208,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<JobDetailLive jobId={id} isTerminal={isTerminal} />
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/jobs"
|
||||
@@ -577,7 +587,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<StatBox value={refreshReport.refreshed} label={t("jobDetail.refreshed")} variant="success" />
|
||||
<StatBox
|
||||
value={refreshReport.refreshed}
|
||||
label={t("jobDetail.refreshed")}
|
||||
variant="success"
|
||||
icon={
|
||||
<svg className="w-6 h-6 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<StatBox value={refreshReport.unchanged} label={t("jobDetail.unchanged")} />
|
||||
<StatBox value={refreshReport.errors} label={t("jobDetail.errors")} variant={refreshReport.errors > 0 ? "error" : "default"} />
|
||||
<StatBox value={refreshReport.total_links} label={t("jobDetail.total")} />
|
||||
@@ -1,9 +1,9 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "../../lib/api";
|
||||
import { JobsList } from "../components/JobsList";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "../components/ui";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "@/lib/api";
|
||||
import { JobsList } from "@/app/components/JobsList";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "@/app/components/ui";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -33,6 +33,14 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
redirect(`/jobs?highlight=${result.id}`);
|
||||
}
|
||||
|
||||
async function triggerRescan(formData: FormData) {
|
||||
"use server";
|
||||
const libraryId = formData.get("library_id") as string;
|
||||
const result = await rebuildIndex(libraryId || undefined, false, true);
|
||||
revalidatePath("/jobs");
|
||||
redirect(`/jobs?highlight=${result.id}`);
|
||||
}
|
||||
|
||||
async function triggerThumbnailsRebuild(formData: FormData) {
|
||||
"use server";
|
||||
const libraryId = formData.get("library_id") as string;
|
||||
@@ -52,7 +60,7 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
async function triggerMetadataBatch(formData: FormData) {
|
||||
"use server";
|
||||
const libraryId = formData.get("library_id") as string;
|
||||
if (!libraryId) return;
|
||||
if (libraryId) {
|
||||
let result;
|
||||
try {
|
||||
result = await startMetadataBatch(libraryId);
|
||||
@@ -62,12 +70,28 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
}
|
||||
revalidatePath("/jobs");
|
||||
redirect(`/jobs?highlight=${result.id}`);
|
||||
} else {
|
||||
// All libraries — skip those with metadata disabled
|
||||
const allLibraries = await fetchLibraries().catch(() => [] as LibraryDto[]);
|
||||
let lastId: string | undefined;
|
||||
for (const lib of allLibraries) {
|
||||
if (lib.metadata_provider === "none") continue;
|
||||
try {
|
||||
const result = await startMetadataBatch(lib.id);
|
||||
if (result.status !== "already_running") lastId = result.id;
|
||||
} catch {
|
||||
// Library may have metadata disabled or other issue — skip
|
||||
}
|
||||
}
|
||||
revalidatePath("/jobs");
|
||||
redirect(lastId ? `/jobs?highlight=${lastId}` : "/jobs");
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerMetadataRefresh(formData: FormData) {
|
||||
"use server";
|
||||
const libraryId = formData.get("library_id") as string;
|
||||
if (!libraryId) return;
|
||||
if (libraryId) {
|
||||
let result;
|
||||
try {
|
||||
result = await startMetadataRefresh(libraryId);
|
||||
@@ -76,6 +100,22 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
}
|
||||
revalidatePath("/jobs");
|
||||
redirect(`/jobs?highlight=${result.id}`);
|
||||
} else {
|
||||
// All libraries — skip those with metadata disabled
|
||||
const allLibraries = await fetchLibraries().catch(() => [] as LibraryDto[]);
|
||||
let lastId: string | undefined;
|
||||
for (const lib of allLibraries) {
|
||||
if (lib.metadata_provider === "none") continue;
|
||||
try {
|
||||
const result = await startMetadataRefresh(lib.id);
|
||||
if (result.status !== "already_running") lastId = result.id;
|
||||
} catch {
|
||||
// Library may have metadata disabled or no approved links — skip
|
||||
}
|
||||
}
|
||||
revalidatePath("/jobs");
|
||||
redirect(lastId ? `/jobs?highlight=${lastId}` : "/jobs");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -127,13 +167,23 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.rebuildShort")}</p>
|
||||
</button>
|
||||
<button type="submit" formAction={triggerFullRebuild}
|
||||
className="w-full text-left rounded-lg border border-warning/30 bg-warning/5 p-3 hover:bg-warning/10 transition-colors group cursor-pointer">
|
||||
<button type="submit" formAction={triggerRescan}
|
||||
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-warning shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span className="font-medium text-sm text-foreground">{t("jobs.rescan")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.rescanShort")}</p>
|
||||
</button>
|
||||
<button type="submit" formAction={triggerFullRebuild}
|
||||
className="w-full text-left rounded-lg border border-destructive/30 bg-destructive/5 p-3 hover:bg-destructive/10 transition-colors group cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-destructive shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="font-medium text-sm text-warning">{t("jobs.fullRebuild")}</span>
|
||||
<span className="font-medium text-sm text-destructive">{t("jobs.fullRebuild")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.fullRebuildShort")}</p>
|
||||
</button>
|
||||
@@ -179,7 +229,6 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
{t("jobs.groupMetadata")}
|
||||
<span className="text-xs font-normal text-muted-foreground">({t("jobs.requiresLibrary")})</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<button type="submit" formAction={triggerMetadataBatch}
|
||||
127
apps/backoffice/app/(app)/layout.tsx
Normal file
127
apps/backoffice/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { ThemeToggle } from "@/app/theme-toggle";
|
||||
import { JobsIndicator } from "@/app/components/JobsIndicator";
|
||||
import { NavIcon, Icon } from "@/app/components/ui";
|
||||
import { LogoutButton } from "@/app/components/LogoutButton";
|
||||
import { MobileNav } from "@/app/components/MobileNav";
|
||||
import { UserSwitcher } from "@/app/components/UserSwitcher";
|
||||
import { fetchUsers } from "@/lib/api";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import type { TranslationKey } from "@/lib/i18n/fr";
|
||||
|
||||
type NavItem = {
|
||||
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||
labelKey: TranslationKey;
|
||||
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
|
||||
{ href: "/books", labelKey: "nav.books", icon: "books" },
|
||||
{ href: "/series", labelKey: "nav.series", icon: "series" },
|
||||
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
|
||||
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
|
||||
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
|
||||
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
|
||||
];
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
const { t } = await getServerTranslations();
|
||||
const cookieStore = await cookies();
|
||||
const activeUserId = cookieStore.get("as_user_id")?.value || null;
|
||||
const users = await fetchUsers().catch(() => []);
|
||||
|
||||
async function setActiveUserAction(formData: FormData) {
|
||||
"use server";
|
||||
const userId = formData.get("user_id") as string;
|
||||
const store = await cookies();
|
||||
if (userId) {
|
||||
store.set("as_user_id", userId, { path: "/", httpOnly: false, sameSite: "lax" });
|
||||
} else {
|
||||
store.delete("as_user_id");
|
||||
}
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
|
||||
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
|
||||
>
|
||||
<Image src="/logo.png" alt="StripStream" width={36} height={36} className="rounded-lg" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xl font-bold tracking-tight text-foreground">StripStream</span>
|
||||
<span className="text-sm text-muted-foreground font-medium hidden xl:inline">
|
||||
{t("common.backoffice")}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
|
||||
<NavIcon name={item.icon} />
|
||||
<span className="ml-2 hidden xl:inline">{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<UserSwitcher
|
||||
users={users}
|
||||
activeUserId={activeUserId}
|
||||
setActiveUserAction={setActiveUserAction}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
|
||||
<JobsIndicator />
|
||||
<Link
|
||||
href="/settings"
|
||||
className="hidden xl:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
title={t("nav.settings")}
|
||||
>
|
||||
<Icon name="settings" size="md" />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<LogoutButton />
|
||||
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={title}
|
||||
className="
|
||||
flex items-center
|
||||
px-2 lg:px-3 py-2
|
||||
rounded-lg
|
||||
text-sm font-medium
|
||||
text-muted-foreground
|
||||
hover:text-foreground
|
||||
hover:bg-accent
|
||||
transition-colors duration-200
|
||||
active:scale-[0.98]
|
||||
"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
|
||||
import { BooksGrid, EmptyState } from "../../../components/BookCard";
|
||||
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
||||
import { OffsetPagination } from "../../../components/ui";
|
||||
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "@/lib/api";
|
||||
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
|
||||
import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
|
||||
import { OffsetPagination } from "@/app/components/ui";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getServerTranslations } from "../../../../lib/i18n/server";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "../../../../../lib/api";
|
||||
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
|
||||
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
|
||||
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
||||
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
|
||||
import { MetadataSearchModal } from "../../../../components/MetadataSearchModal";
|
||||
import { ProwlarrSearchModal } from "../../../../components/ProwlarrSearchModal";
|
||||
import { OffsetPagination } from "../../../../components/ui";
|
||||
import { SafeHtml } from "../../../../components/SafeHtml";
|
||||
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "@/lib/api";
|
||||
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
|
||||
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
|
||||
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
|
||||
import nextDynamic from "next/dynamic";
|
||||
import { OffsetPagination } from "@/app/components/ui";
|
||||
import { SafeHtml } from "@/app/components/SafeHtml";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
const EditSeriesForm = nextDynamic(
|
||||
() => import("@/app/components/EditSeriesForm").then(m => m.EditSeriesForm)
|
||||
);
|
||||
const MetadataSearchModal = nextDynamic(
|
||||
() => import("@/app/components/MetadataSearchModal").then(m => m.MetadataSearchModal)
|
||||
);
|
||||
const ProwlarrSearchModal = nextDynamic(
|
||||
() => import("@/app/components/ProwlarrSearchModal").then(m => m.ProwlarrSearchModal)
|
||||
);
|
||||
import { notFound } from "next/navigation";
|
||||
import { getServerTranslations } from "../../../../../lib/i18n/server";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -94,7 +102,7 @@ export default async function SeriesDetailPage({
|
||||
alt={t("books.coverOf", { name: displayName })}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
sizes="160px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,12 +1,12 @@
|
||||
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
|
||||
import { OffsetPagination } from "../../../components/ui";
|
||||
import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton";
|
||||
import { SeriesFilters } from "../../../components/SeriesFilters";
|
||||
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "@/lib/api";
|
||||
import { OffsetPagination } from "@/app/components/ui";
|
||||
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
|
||||
import { SeriesFilters } from "@/app/components/SeriesFilters";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
||||
import { getServerTranslations } from "../../../../lib/i18n/server";
|
||||
import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -86,7 +86,7 @@ export default async function LibrarySeriesPage({
|
||||
alt={t("books.coverOf", { name: s.name })}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
227
apps/backoffice/app/(app)/libraries/page.tsx
Normal file
227
apps/backoffice/app/(app)/libraries/page.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "@/lib/api";
|
||||
import type { TranslationKey } from "@/lib/i18n/fr";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import { LibraryActions } from "@/app/components/LibraryActions";
|
||||
import { LibraryForm } from "@/app/components/LibraryForm";
|
||||
import { ProviderIcon } from "@/app/components/ProviderIcon";
|
||||
import {
|
||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||
Button, Badge
|
||||
} from "@/app/components/ui";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatNextScan(nextScanAt: string | null, imminentLabel: string): string {
|
||||
if (!nextScanAt) return "-";
|
||||
const date = new Date(nextScanAt);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 0) return imminentLabel;
|
||||
if (diff < 60000) return "< 1 min";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
|
||||
return `${Math.floor(diff / 86400000)}d`;
|
||||
}
|
||||
|
||||
export default async function LibrariesPage() {
|
||||
const { t } = await getServerTranslations();
|
||||
const [libraries, folders] = await Promise.all([
|
||||
fetchLibraries().catch(() => [] as LibraryDto[]),
|
||||
listFolders().catch(() => [] as FolderItem[])
|
||||
]);
|
||||
|
||||
const thumbnailMap = new Map(
|
||||
libraries.map(lib => [
|
||||
lib.id,
|
||||
(lib.thumbnail_book_ids || []).map(bookId => getBookCoverUrl(bookId)),
|
||||
])
|
||||
);
|
||||
|
||||
async function addLibrary(formData: FormData) {
|
||||
"use server";
|
||||
const name = formData.get("name") as string;
|
||||
const rootPath = formData.get("root_path") as string;
|
||||
if (name && rootPath) {
|
||||
await createLibrary(name, rootPath);
|
||||
revalidatePath("/libraries");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLibrary(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await deleteLibrary(id);
|
||||
revalidatePath("/libraries");
|
||||
}
|
||||
|
||||
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-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
{t("libraries.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Add Library Form */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("libraries.addLibrary")}</CardTitle>
|
||||
<CardDescription>{t("libraries.addLibraryDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LibraryForm initialFolders={folders} action={addLibrary} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Libraries Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{libraries.map((lib) => {
|
||||
const thumbnails = thumbnailMap.get(lib.id) || [];
|
||||
return (
|
||||
<Card key={lib.id} className="flex flex-col overflow-hidden">
|
||||
{/* Thumbnail fan */}
|
||||
{thumbnails.length > 0 ? (
|
||||
<Link href={`/libraries/${lib.id}/series`} className="block relative h-48 overflow-hidden bg-muted/10">
|
||||
<Image
|
||||
src={thumbnails[0]}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover blur-xl scale-110 opacity-40"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-end justify-center">
|
||||
{thumbnails.map((url, i) => {
|
||||
const count = thumbnails.length;
|
||||
const mid = (count - 1) / 2;
|
||||
const angle = (i - mid) * 12;
|
||||
const radius = 220;
|
||||
const rad = ((angle - 90) * Math.PI) / 180;
|
||||
const cx = Math.cos(rad) * radius;
|
||||
const cy = Math.sin(rad) * radius;
|
||||
return (
|
||||
<Image
|
||||
key={i}
|
||||
src={url}
|
||||
alt=""
|
||||
width={96}
|
||||
height={144}
|
||||
className="absolute object-cover shadow-lg"
|
||||
style={{
|
||||
transform: `translate(${cx}px, ${cy}px) rotate(${angle}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
zIndex: count - Math.abs(Math.round(i - mid)),
|
||||
bottom: '-185px',
|
||||
}}
|
||||
sizes="96px"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="h-8 bg-muted/10" />
|
||||
)}
|
||||
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{lib.name}</CardTitle>
|
||||
{!lib.enabled && <Badge variant="muted" className="mt-1">{t("libraries.disabled")}</Badge>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<LibraryActions
|
||||
libraryId={lib.id}
|
||||
monitorEnabled={lib.monitor_enabled}
|
||||
scanMode={lib.scan_mode}
|
||||
watcherEnabled={lib.watcher_enabled}
|
||||
metadataProvider={lib.metadata_provider}
|
||||
fallbackMetadataProvider={lib.fallback_metadata_provider}
|
||||
metadataRefreshMode={lib.metadata_refresh_mode}
|
||||
/>
|
||||
<form>
|
||||
<input type="hidden" name="id" value={lib.id} />
|
||||
<Button type="submit" variant="ghost" size="sm" formAction={removeLibrary} className="text-muted-foreground hover:text-destructive">
|
||||
<svg className="w-4 h-4" 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>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<code className="text-xs font-mono text-muted-foreground break-all">{lib.root_path}</code>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pt-0">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<Link
|
||||
href={`/libraries/${lib.id}/books`}
|
||||
className="text-center p-2.5 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||
>
|
||||
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
|
||||
<span className="text-xs text-muted-foreground">{t("libraries.books")}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/libraries/${lib.id}/series`}
|
||||
className="text-center p-2.5 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||
>
|
||||
<span className="block text-2xl font-bold text-foreground">{lib.series_count}</span>
|
||||
<span className="text-xs text-muted-foreground">{t("libraries.series")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Configuration tags */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||
lib.monitor_enabled
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-muted/50 text-muted-foreground'
|
||||
}`}>
|
||||
<span className="text-[9px]">{lib.monitor_enabled ? '●' : '○'}</span>
|
||||
{t("libraries.scanLabel", { mode: t(`monitoring.${lib.scan_mode}` as TranslationKey) })}
|
||||
</span>
|
||||
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||
lib.watcher_enabled
|
||||
? 'bg-warning/10 text-warning'
|
||||
: 'bg-muted/50 text-muted-foreground'
|
||||
}`}>
|
||||
<span>{lib.watcher_enabled ? '⚡' : '○'}</span>
|
||||
<span>{t("libraries.watcherLabel")}</span>
|
||||
</span>
|
||||
|
||||
{lib.metadata_provider && lib.metadata_provider !== "none" && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-primary/10 text-primary">
|
||||
<ProviderIcon provider={lib.metadata_provider} size={11} />
|
||||
{lib.metadata_provider.replace('_', ' ')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{lib.metadata_refresh_mode !== "manual" && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-muted/50 text-muted-foreground">
|
||||
{t("libraries.metaRefreshLabel", { mode: t(`monitoring.${lib.metadata_refresh_mode}` as TranslationKey) })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{lib.monitor_enabled && lib.next_scan_at && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-muted/50 text-muted-foreground">
|
||||
{t("libraries.nextScan", { time: formatNextScan(lib.next_scan_at, t("libraries.imminent")) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from "react";
|
||||
import { fetchStats, StatsResponse } from "../lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
|
||||
import { fetchStats, fetchUsers, StatsResponse, UserDto } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui";
|
||||
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts";
|
||||
import { PeriodToggle } from "@/app/components/PeriodToggle";
|
||||
import { CurrentlyReadingList, RecentlyReadList } from "@/app/components/ReadingUserFilter";
|
||||
import Link from "next/link";
|
||||
import { getServerTranslations } from "../lib/i18n/server";
|
||||
import type { TranslateFunction } from "../lib/i18n/dictionaries";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import type { TranslateFunction } from "@/lib/i18n/dictionaries";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -19,84 +22,25 @@ function formatNumber(n: number, locale: string): string {
|
||||
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
|
||||
}
|
||||
|
||||
// Donut chart via SVG
|
||||
function DonutChart({ data, colors, noDataLabel, locale = "fr" }: { data: { label: string; value: number; color: string }[]; colors?: string[]; noDataLabel?: string; locale?: string }) {
|
||||
const total = data.reduce((sum, d) => sum + d.value, 0);
|
||||
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||
|
||||
const radius = 40;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
let offset = 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<svg viewBox="0 0 100 100" className="w-32 h-32 shrink-0">
|
||||
{data.map((d, i) => {
|
||||
const pct = d.value / total;
|
||||
const dashLength = pct * circumference;
|
||||
const currentOffset = offset;
|
||||
offset += dashLength;
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={d.color}
|
||||
strokeWidth="16"
|
||||
strokeDasharray={`${dashLength} ${circumference - dashLength}`}
|
||||
strokeDashoffset={-currentOffset}
|
||||
transform="rotate(-90 50 50)"
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<text x="50" y="50" textAnchor="middle" dominantBaseline="central" className="fill-foreground text-[10px] font-bold">
|
||||
{formatNumber(total, locale)}
|
||||
</text>
|
||||
</svg>
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: d.color }} />
|
||||
<span className="text-muted-foreground truncate">{d.label}</span>
|
||||
<span className="font-medium text-foreground ml-auto">{d.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
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" });
|
||||
}
|
||||
|
||||
// Bar chart via pure CSS
|
||||
function BarChart({ data, color = "var(--color-primary)", noDataLabel }: { data: { label: string; value: number }[]; color?: string; noDataLabel?: string }) {
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-1.5 h-40">
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1 min-w-0">
|
||||
<span className="text-[10px] text-muted-foreground font-medium">{d.value || ""}</span>
|
||||
<div
|
||||
className="w-full rounded-t-sm transition-all duration-500 min-h-[2px]"
|
||||
style={{
|
||||
height: `${(d.value / max) * 100}%`,
|
||||
backgroundColor: color,
|
||||
opacity: d.value === 0 ? 0.2 : 1,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground truncate w-full text-center">
|
||||
{d.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Horizontal progress bar for library breakdown
|
||||
// Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed)
|
||||
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
|
||||
const pct = max > 0 ? (value / max) * 100 : 0;
|
||||
return (
|
||||
@@ -115,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();
|
||||
|
||||
let stats: StatsResponse | null = null;
|
||||
let users: UserDto[] = [];
|
||||
try {
|
||||
stats = await fetchStats();
|
||||
[stats, users] = await Promise.all([
|
||||
fetchStats(period),
|
||||
fetchUsers().catch(() => []),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch stats:", e);
|
||||
}
|
||||
@@ -137,7 +92,20 @@ export default async function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const { overview, reading_status, by_format, by_language, by_library, top_series, additions_over_time, metadata } = stats;
|
||||
const {
|
||||
overview,
|
||||
reading_status,
|
||||
currently_reading = [],
|
||||
recently_read = [],
|
||||
reading_over_time = [],
|
||||
users_reading_over_time = [],
|
||||
by_format,
|
||||
by_library,
|
||||
top_series,
|
||||
additions_over_time,
|
||||
jobs_over_time = [],
|
||||
metadata = { total_series: 0, series_linked: 0, series_unlinked: 0, books_with_summary: 0, books_with_isbn: 0, by_provider: [] },
|
||||
} = stats;
|
||||
|
||||
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
|
||||
const formatColors = [
|
||||
@@ -146,7 +114,6 @@ export default async function DashboardPage() {
|
||||
"hsl(170 60% 45%)", "hsl(220 60% 50%)",
|
||||
];
|
||||
|
||||
const maxLibBooks = Math.max(...by_library.map((l) => l.book_count), 1);
|
||||
const noDataLabel = t("common.noData");
|
||||
|
||||
return (
|
||||
@@ -174,23 +141,125 @@ export default async function DashboardPage() {
|
||||
<StatCard icon="size" label={t("dashboard.totalSize")} value={formatBytes(overview.total_size_bytes)} color="warning" />
|
||||
</div>
|
||||
|
||||
{/* Currently reading + Recently read */}
|
||||
{(currently_reading.length > 0 || recently_read.length > 0) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Currently reading */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("dashboard.currentlyReading")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CurrentlyReadingList
|
||||
items={currently_reading}
|
||||
allLabel={t("dashboard.allUsers")}
|
||||
emptyLabel={t("dashboard.noCurrentlyReading")}
|
||||
pageProgressTemplate={t("dashboard.pageProgress")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recently read */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("dashboard.recentlyRead")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RecentlyReadList
|
||||
items={recently_read}
|
||||
allLabel={t("dashboard.allUsers")}
|
||||
emptyLabel={t("dashboard.noRecentlyRead")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reading activity line chart */}
|
||||
<Card hover={false}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
|
||||
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
const userColors = [
|
||||
"hsl(142 60% 45%)", "hsl(198 78% 37%)", "hsl(45 93% 47%)",
|
||||
"hsl(2 72% 48%)", "hsl(280 60% 50%)", "hsl(32 80% 50%)",
|
||||
];
|
||||
const usernames = [...new Set(users_reading_over_time.map(r => r.username))];
|
||||
if (usernames.length === 0) {
|
||||
return (
|
||||
<RcAreaChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
|
||||
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>
|
||||
</Card>
|
||||
|
||||
{/* Charts row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Reading status donut */}
|
||||
{/* Reading status par lecteur */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("dashboard.readingStatus")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DonutChart
|
||||
locale={locale}
|
||||
{users.length === 0 ? (
|
||||
<RcDonutChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={[
|
||||
{ label: t("status.unread"), value: reading_status.unread, color: readingColors[0] },
|
||||
{ label: t("status.reading"), value: reading_status.reading, color: readingColors[1] },
|
||||
{ label: t("status.read"), value: reading_status.read, color: readingColors[2] },
|
||||
{ name: t("status.unread"), value: reading_status.unread, color: readingColors[0] },
|
||||
{ name: t("status.reading"), value: reading_status.reading, color: readingColors[1] },
|
||||
{ name: t("status.read"), value: reading_status.read, color: readingColors[2] },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{users.map((user) => {
|
||||
const total = overview.total_books;
|
||||
const read = user.books_read;
|
||||
const reading = user.books_reading;
|
||||
const unread = Math.max(0, total - read - reading);
|
||||
const readPct = total > 0 ? (read / total) * 100 : 0;
|
||||
const readingPct = total > 0 ? (reading / total) * 100 : 0;
|
||||
return (
|
||||
<div key={user.id} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-foreground truncate">{user.username}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">
|
||||
<span className="text-success font-medium">{read}</span>
|
||||
{reading > 0 && <span className="text-amber-500 font-medium"> · {reading}</span>}
|
||||
<span className="text-muted-foreground/60"> / {total}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden flex">
|
||||
<div className="h-full bg-success transition-all duration-500" style={{ width: `${readPct}%` }} />
|
||||
<div className="h-full bg-amber-500 transition-all duration-500" style={{ width: `${readingPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -200,11 +269,10 @@ export default async function DashboardPage() {
|
||||
<CardTitle className="text-base">{t("dashboard.byFormat")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DonutChart
|
||||
locale={locale}
|
||||
<RcDonutChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={by_format.slice(0, 6).map((f, i) => ({
|
||||
label: (f.format || t("dashboard.unknown")).toUpperCase(),
|
||||
name: (f.format || t("dashboard.unknown")).toUpperCase(),
|
||||
value: f.count,
|
||||
color: formatColors[i % formatColors.length],
|
||||
}))}
|
||||
@@ -218,11 +286,10 @@ export default async function DashboardPage() {
|
||||
<CardTitle className="text-base">{t("dashboard.byLibrary")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DonutChart
|
||||
locale={locale}
|
||||
<RcDonutChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={by_library.slice(0, 6).map((l, i) => ({
|
||||
label: l.library_name,
|
||||
name: l.library_name,
|
||||
value: l.book_count,
|
||||
color: formatColors[i % formatColors.length],
|
||||
}))}
|
||||
@@ -239,12 +306,11 @@ export default async function DashboardPage() {
|
||||
<CardTitle className="text-base">{t("dashboard.metadataCoverage")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DonutChart
|
||||
locale={locale}
|
||||
<RcDonutChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={[
|
||||
{ label: t("dashboard.seriesLinked"), value: metadata.series_linked, color: "hsl(142 60% 45%)" },
|
||||
{ label: t("dashboard.seriesUnlinked"), value: metadata.series_unlinked, color: "hsl(220 13% 70%)" },
|
||||
{ name: t("dashboard.seriesLinked"), value: metadata.series_linked, color: "hsl(142 60% 45%)" },
|
||||
{ name: t("dashboard.seriesUnlinked"), value: metadata.series_unlinked, color: "hsl(220 13% 70%)" },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -256,11 +322,10 @@ export default async function DashboardPage() {
|
||||
<CardTitle className="text-base">{t("dashboard.byProvider")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DonutChart
|
||||
locale={locale}
|
||||
<RcDonutChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={metadata.by_provider.map((p, i) => ({
|
||||
label: p.provider.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
name: p.provider.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
value: p.count,
|
||||
color: formatColors[i % formatColors.length],
|
||||
}))}
|
||||
@@ -294,24 +359,32 @@ export default async function DashboardPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Second row */}
|
||||
{/* Libraries breakdown + Top series */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Monthly additions bar chart */}
|
||||
{by_library.length > 0 && (
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
|
||||
<CardTitle className="text-base">{t("dashboard.libraries")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={additions_over_time.map((m) => ({
|
||||
label: m.month.slice(5), // "MM" from "YYYY-MM"
|
||||
value: m.books_added,
|
||||
<RcStackedBar
|
||||
data={by_library.map((lib) => ({
|
||||
name: lib.library_name,
|
||||
read: lib.read_count,
|
||||
reading: lib.reading_count,
|
||||
unread: lib.unread_count,
|
||||
sizeLabel: formatBytes(lib.size_bytes),
|
||||
}))}
|
||||
color="hsl(198 78% 37%)"
|
||||
labels={{
|
||||
read: t("status.read"),
|
||||
reading: t("status.reading"),
|
||||
unread: t("status.unread"),
|
||||
books: t("dashboard.books"),
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Top series */}
|
||||
<Card hover={false}>
|
||||
@@ -319,67 +392,59 @@ export default async function DashboardPage() {
|
||||
<CardTitle className="text-base">{t("dashboard.popularSeries")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{top_series.slice(0, 8).map((s, i) => (
|
||||
<HorizontalBar
|
||||
key={i}
|
||||
label={s.series}
|
||||
value={s.book_count}
|
||||
max={top_series[0]?.book_count || 1}
|
||||
subLabel={t("dashboard.readCount", { read: s.read_count, total: s.book_count })}
|
||||
<RcHorizontalBar
|
||||
noDataLabel={t("dashboard.noSeries")}
|
||||
data={top_series.slice(0, 8).map((s) => ({
|
||||
name: s.series,
|
||||
value: s.book_count,
|
||||
subLabel: t("dashboard.readCount", { read: s.read_count, total: s.book_count }),
|
||||
}))}
|
||||
color="hsl(142 60% 45%)"
|
||||
/>
|
||||
))}
|
||||
{top_series.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noSeries")}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Libraries breakdown */}
|
||||
{by_library.length > 0 && (
|
||||
{/* Additions line chart – full width */}
|
||||
<Card hover={false}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("dashboard.libraries")}</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
|
||||
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
|
||||
{by_library.map((lib, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="font-medium text-foreground text-sm">{lib.library_name}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatBytes(lib.size_bytes)}</span>
|
||||
</div>
|
||||
<div className="h-3 bg-muted rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${(lib.read_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(142 60% 45%)" }}
|
||||
title={`${t("status.read")} : ${lib.read_count}`}
|
||||
<RcAreaChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={additions_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_added }))}
|
||||
color="hsl(198 78% 37%)"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Jobs over time – multi-line chart */}
|
||||
<Card hover={false}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("dashboard.jobsOverTime")}</CardTitle>
|
||||
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RcMultiLineChart
|
||||
noDataLabel={noDataLabel}
|
||||
data={jobs_over_time.map((j) => ({
|
||||
label: formatChartLabel(j.label, period, locale),
|
||||
scan: j.scan,
|
||||
rebuild: j.rebuild,
|
||||
thumbnail: j.thumbnail,
|
||||
other: j.other,
|
||||
}))}
|
||||
lines={[
|
||||
{ key: "scan", label: t("dashboard.jobScan"), color: "hsl(198 78% 37%)" },
|
||||
{ key: "rebuild", label: t("dashboard.jobRebuild"), color: "hsl(142 60% 45%)" },
|
||||
{ key: "thumbnail", label: t("dashboard.jobThumbnail"), color: "hsl(45 93% 47%)" },
|
||||
{ key: "other", label: t("dashboard.jobOther"), color: "hsl(280 60% 50%)" },
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${(lib.reading_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(45 93% 47%)" }}
|
||||
title={`${t("status.reading")} : ${lib.reading_count}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${(lib.unread_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(220 13% 70%)" }}
|
||||
title={`${t("status.unread")} : ${lib.unread_count}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{lib.book_count} {t("dashboard.books").toLowerCase()}</span>
|
||||
<span className="text-success">{lib.read_count} {t("status.read").toLowerCase()}</span>
|
||||
<span className="text-warning">{lib.reading_count} {t("status.reading").toLowerCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick links */}
|
||||
<QuickLinks t={t} />
|
||||
@@ -1,11 +1,11 @@
|
||||
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton";
|
||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
|
||||
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
||||
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ProviderIcon } from "../components/ProviderIcon";
|
||||
import { ProviderIcon } from "@/app/components/ProviderIcon";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -138,7 +138,7 @@ export default async function SeriesPage({
|
||||
alt={t("books.coverOf", { name: s.name })}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
@@ -1,19 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
|
||||
import { ProviderIcon } from "../components/ProviderIcon";
|
||||
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto } from "../../lib/api";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import type { Locale } from "../../lib/i18n/types";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "@/app/components/ui";
|
||||
import { ProviderIcon } from "@/app/components/ProviderIcon";
|
||||
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto, UserDto } from "@/lib/api";
|
||||
import { useTranslation } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/types";
|
||||
|
||||
interface SettingsPageProps {
|
||||
initialSettings: Settings;
|
||||
initialCacheStats: CacheStats;
|
||||
initialThumbnailStats: ThumbnailStats;
|
||||
users: UserDto[];
|
||||
}
|
||||
|
||||
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats }: SettingsPageProps) {
|
||||
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users }: SettingsPageProps) {
|
||||
const { t, locale, setLocale } = useTranslation();
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
...initialSettings,
|
||||
@@ -29,6 +30,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
const [komgaUrl, setKomgaUrl] = useState("");
|
||||
const [komgaUsername, setKomgaUsername] = useState("");
|
||||
const [komgaPassword, setKomgaPassword] = useState("");
|
||||
const [komgaUserId, setKomgaUserId] = useState(users[0]?.id ?? "");
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<KomgaSyncResponse | null>(null);
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
@@ -104,6 +106,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
if (data) {
|
||||
if (data.url) setKomgaUrl(data.url);
|
||||
if (data.username) setKomgaUsername(data.username);
|
||||
if (data.user_id) setKomgaUserId(data.user_id);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [fetchReports]);
|
||||
@@ -128,7 +131,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
const response = await fetch("/api/komga/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword }),
|
||||
body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword, user_id: komgaUserId }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
@@ -140,7 +143,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
fetch("/api/settings/komga", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername } }),
|
||||
body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername, user_id: komgaUserId } }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
@@ -150,11 +153,12 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
}
|
||||
}
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"general" | "integrations">("general");
|
||||
const [activeTab, setActiveTab] = useState<"general" | "integrations" | "notifications">("general");
|
||||
|
||||
const tabs = [
|
||||
{ id: "general" as const, label: t("settings.general"), icon: "settings" as const },
|
||||
{ id: "integrations" as const, label: t("settings.integrations"), icon: "refresh" as const },
|
||||
{ id: "notifications" as const, label: t("settings.notifications"), icon: "bell" as const },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -619,16 +623,29 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.password")}</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
type="password" autoComplete="off"
|
||||
value={komgaPassword}
|
||||
onChange={(e) => setKomgaPassword(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
|
||||
{users.length > 0 && (
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("users.title")}</label>
|
||||
<FormSelect value={komgaUserId} onChange={(e) => setKomgaUserId(e.target.value)}>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.username}</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleKomgaSync}
|
||||
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword}
|
||||
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword || !komgaUserId}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<>
|
||||
@@ -734,7 +751,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
{new Date(r.created_at).toLocaleString(locale)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate ml-2" title={r.komga_url}>
|
||||
{r.komga_url}
|
||||
@@ -826,6 +843,11 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>)}
|
||||
|
||||
{activeTab === "notifications" && (<>
|
||||
{/* Telegram Notifications */}
|
||||
<TelegramCard handleUpdateSetting={handleUpdateSetting} />
|
||||
</>)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -949,7 +971,7 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
|
||||
{t("settings.googleBooksKey")}
|
||||
</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
type="password" autoComplete="off"
|
||||
placeholder={t("settings.googleBooksPlaceholder")}
|
||||
value={apiKeys.google_books || ""}
|
||||
onChange={(e) => setApiKeys({ ...apiKeys, google_books: e.target.value })}
|
||||
@@ -964,7 +986,7 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
|
||||
{t("settings.comicvineKey")}
|
||||
</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
type="password" autoComplete="off"
|
||||
placeholder={t("settings.comicvinePlaceholder")}
|
||||
value={apiKeys.comicvine || ""}
|
||||
onChange={(e) => setApiKeys({ ...apiKeys, comicvine: e.target.value })}
|
||||
@@ -1306,7 +1328,7 @@ function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrApiKey")}</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
type="password" autoComplete="off"
|
||||
placeholder={t("settings.prowlarrApiKeyPlaceholder")}
|
||||
value={prowlarrApiKey}
|
||||
onChange={(e) => setProwlarrApiKey(e.target.value)}
|
||||
@@ -1444,7 +1466,7 @@ function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: s
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.qbittorrentPassword")}</label>
|
||||
<FormInput
|
||||
type="password"
|
||||
type="password" autoComplete="off"
|
||||
value={qbPassword}
|
||||
onChange={(e) => setQbPassword(e.target.value)}
|
||||
onBlur={() => saveQbittorrent()}
|
||||
@@ -1480,3 +1502,254 @@ function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: s
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telegram Notifications sub-component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_EVENTS = {
|
||||
scan_completed: true,
|
||||
scan_failed: true,
|
||||
scan_cancelled: true,
|
||||
thumbnail_completed: true,
|
||||
thumbnail_failed: true,
|
||||
conversion_completed: true,
|
||||
conversion_failed: true,
|
||||
metadata_approved: true,
|
||||
metadata_batch_completed: true,
|
||||
metadata_batch_failed: true,
|
||||
metadata_refresh_completed: true,
|
||||
metadata_refresh_failed: true,
|
||||
};
|
||||
|
||||
function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
|
||||
const { t } = useTranslation();
|
||||
const [botToken, setBotToken] = useState("");
|
||||
const [chatId, setChatId] = useState("");
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [events, setEvents] = useState(DEFAULT_EVENTS);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/telegram")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
if (data.bot_token) setBotToken(data.bot_token);
|
||||
if (data.chat_id) setChatId(data.chat_id);
|
||||
if (data.enabled !== undefined) setEnabled(data.enabled);
|
||||
if (data.events) setEvents({ ...DEFAULT_EVENTS, ...data.events });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function saveTelegram(token?: string, chat?: string, en?: boolean, ev?: typeof events) {
|
||||
handleUpdateSetting("telegram", {
|
||||
bot_token: token ?? botToken,
|
||||
chat_id: chat ?? chatId,
|
||||
enabled: en ?? enabled,
|
||||
events: ev ?? events,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const resp = await fetch("/api/telegram/test");
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
setTestResult({ success: false, message: data.error });
|
||||
} else {
|
||||
setTestResult(data);
|
||||
}
|
||||
} catch {
|
||||
setTestResult({ success: false, message: "Failed to connect" });
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="bell" size="md" />
|
||||
{t("settings.telegram")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("settings.telegramDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Setup guide */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="text-sm text-primary hover:text-primary/80 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<Icon name={showHelp ? "chevronDown" : "chevronRight"} size="sm" />
|
||||
{t("settings.telegramHelp")}
|
||||
</button>
|
||||
{showHelp && (
|
||||
<div className="mt-3 p-4 rounded-lg bg-muted/30 space-y-3 text-sm text-foreground">
|
||||
<div>
|
||||
<p className="font-medium mb-1">1. Bot Token</p>
|
||||
<p className="text-muted-foreground" dangerouslySetInnerHTML={{ __html: t("settings.telegramHelpBot") }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium mb-1">2. Chat ID</p>
|
||||
<p className="text-muted-foreground" dangerouslySetInnerHTML={{ __html: t("settings.telegramHelpChat") }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium mb-1">3. Group chat</p>
|
||||
<p className="text-muted-foreground" dangerouslySetInnerHTML={{ __html: t("settings.telegramHelpGroup") }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => {
|
||||
setEnabled(e.target.checked);
|
||||
saveTelegram(undefined, undefined, e.target.checked);
|
||||
}}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-muted rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
||||
</label>
|
||||
<span className="text-sm font-medium text-foreground">{t("settings.telegramEnabled")}</span>
|
||||
</div>
|
||||
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.botToken")}</label>
|
||||
<FormInput
|
||||
type="password" autoComplete="off"
|
||||
placeholder={t("settings.botTokenPlaceholder")}
|
||||
value={botToken}
|
||||
onChange={(e) => setBotToken(e.target.value)}
|
||||
onBlur={() => saveTelegram()}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<FormField className="flex-1">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.chatId")}</label>
|
||||
<FormInput
|
||||
type="text"
|
||||
placeholder={t("settings.chatIdPlaceholder")}
|
||||
value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)}
|
||||
onBlur={() => saveTelegram()}
|
||||
/>
|
||||
</FormField>
|
||||
</FormRow>
|
||||
|
||||
{/* Event toggles grouped by category */}
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<h4 className="text-sm font-medium text-foreground mb-4">{t("settings.telegramEvents")}</h4>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-5">
|
||||
{([
|
||||
{
|
||||
category: t("settings.eventCategoryScan"),
|
||||
icon: "search" as const,
|
||||
items: [
|
||||
{ key: "scan_completed" as const, label: t("settings.eventCompleted") },
|
||||
{ key: "scan_failed" as const, label: t("settings.eventFailed") },
|
||||
{ key: "scan_cancelled" as const, label: t("settings.eventCancelled") },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: t("settings.eventCategoryThumbnail"),
|
||||
icon: "image" as const,
|
||||
items: [
|
||||
{ key: "thumbnail_completed" as const, label: t("settings.eventCompleted") },
|
||||
{ key: "thumbnail_failed" as const, label: t("settings.eventFailed") },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: t("settings.eventCategoryConversion"),
|
||||
icon: "refresh" as const,
|
||||
items: [
|
||||
{ key: "conversion_completed" as const, label: t("settings.eventCompleted") },
|
||||
{ key: "conversion_failed" as const, label: t("settings.eventFailed") },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: t("settings.eventCategoryMetadata"),
|
||||
icon: "tag" as const,
|
||||
items: [
|
||||
{ key: "metadata_approved" as const, label: t("settings.eventLinked") },
|
||||
{ key: "metadata_batch_completed" as const, label: t("settings.eventBatchCompleted") },
|
||||
{ key: "metadata_batch_failed" as const, label: t("settings.eventBatchFailed") },
|
||||
{ key: "metadata_refresh_completed" as const, label: t("settings.eventRefreshCompleted") },
|
||||
{ key: "metadata_refresh_failed" as const, label: t("settings.eventRefreshFailed") },
|
||||
],
|
||||
},
|
||||
]).map(({ category, icon, items }) => (
|
||||
<div key={category}>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2 flex items-center gap-1.5">
|
||||
<Icon name={icon} size="sm" className="text-muted-foreground" />
|
||||
{category}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{items.map(({ key, label }) => (
|
||||
<label key={key} className="flex items-center justify-between py-1.5 cursor-pointer group">
|
||||
<span className="text-sm text-foreground group-hover:text-foreground/80">{label}</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={events[key]}
|
||||
onChange={(e) => {
|
||||
const updated = { ...events, [key]: e.target.checked };
|
||||
setEvents(updated);
|
||||
saveTelegram(undefined, undefined, undefined, updated);
|
||||
}}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-muted rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary" />
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTesting || !botToken || !chatId || !enabled}
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
|
||||
{t("settings.testing")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="refresh" size="sm" className="mr-2" />
|
||||
{t("settings.testConnection")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<span className={`text-sm font-medium ${testResult.success ? "text-success" : "text-destructive"}`}>
|
||||
{testResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSettings, getCacheStats, getThumbnailStats } from "../../lib/api";
|
||||
import { getSettings, getCacheStats, getThumbnailStats, fetchUsers } from "@/lib/api";
|
||||
import SettingsPage from "./SettingsPage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -23,5 +23,7 @@ export default async function SettingsPageWrapper() {
|
||||
directory: "/data/thumbnails"
|
||||
}));
|
||||
|
||||
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} />;
|
||||
const users = await fetchUsers().catch(() => []);
|
||||
|
||||
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} users={users} />;
|
||||
}
|
||||
316
apps/backoffice/app/(app)/tokens/page.tsx
Normal file
316
apps/backoffice/app/(app)/tokens/page.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTokens, createToken, revokeToken, deleteToken, updateToken, fetchUsers, createUser, deleteUser, updateUser, TokenDto, UserDto } from "@/lib/api";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "@/app/components/ui";
|
||||
import { TokenUserSelect } from "@/app/components/TokenUserSelect";
|
||||
import { UsernameEdit } from "@/app/components/UsernameEdit";
|
||||
import { getServerTranslations } from "@/lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TokensPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{ created?: string }>;
|
||||
}) {
|
||||
const { t } = await getServerTranslations();
|
||||
const params = await searchParams;
|
||||
const tokens = await listTokens().catch(() => [] as TokenDto[]);
|
||||
const users = await fetchUsers().catch(() => [] as UserDto[]);
|
||||
|
||||
async function createTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const name = formData.get("name") as string;
|
||||
const scope = formData.get("scope") as string;
|
||||
const userId = (formData.get("user_id") as string) || undefined;
|
||||
if (name) {
|
||||
const result = await createToken(name, scope, userId);
|
||||
revalidatePath("/tokens");
|
||||
redirect(`/tokens?created=${encodeURIComponent(result.token)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await revokeToken(id);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
|
||||
async function deleteTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await deleteToken(id);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
|
||||
async function createUserAction(formData: FormData) {
|
||||
"use server";
|
||||
const username = formData.get("username") as string;
|
||||
if (username) {
|
||||
await createUser(username);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUserAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await deleteUser(id);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
|
||||
async function renameUserAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
const username = formData.get("username") as string;
|
||||
if (username?.trim()) {
|
||||
await updateUser(id, username.trim());
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
}
|
||||
|
||||
async function reassignTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
const userId = (formData.get("user_id") as string) || null;
|
||||
await updateToken(id, userId);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||
<svg className="w-8 h-8 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
{t("tokens.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* ── Lecteurs ─────────────────────────────────────────── */}
|
||||
<div className="mb-2">
|
||||
<h2 className="text-xl font-semibold text-foreground">{t("users.title")}</h2>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("users.createNew")}</CardTitle>
|
||||
<CardDescription>{t("users.createDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={createUserAction}>
|
||||
<FormRow>
|
||||
<FormField className="flex-1 min-w-48">
|
||||
<FormInput name="username" placeholder={t("users.username")} required autoComplete="off" />
|
||||
</FormField>
|
||||
<Button type="submit">{t("users.createButton")}</Button>
|
||||
</FormRow>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden mb-10">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/50">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.name")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.tokenCount")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.read")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("status.reading")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.createdAt")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("users.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{/* Ligne admin synthétique */}
|
||||
<tr className="hover:bg-accent/50 transition-colors bg-destructive/5">
|
||||
<td className="px-4 py-3 text-sm font-medium text-foreground flex items-center gap-2">
|
||||
{process.env.ADMIN_USERNAME ?? "admin"}
|
||||
<Badge variant="destructive">{t("tokens.scopeAdmin")}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{tokens.filter(tok => tok.scope === "admin" && !tok.revoked_at).length}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
</tr>
|
||||
{/* Ligne tokens read non assignés */}
|
||||
{(() => {
|
||||
const unassigned = tokens.filter(tok => tok.scope === "read" && !tok.user_id && !tok.revoked_at);
|
||||
if (unassigned.length === 0) return null;
|
||||
return (
|
||||
<tr className="hover:bg-accent/50 transition-colors bg-warning/5">
|
||||
<td className="px-4 py-3 text-sm font-medium text-muted-foreground italic">
|
||||
{t("tokens.noUser")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-warning font-medium">{unassigned.length}</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground/50">—</td>
|
||||
</tr>
|
||||
);
|
||||
})()}
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-accent/50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<UsernameEdit userId={user.id} currentUsername={user.username} action={renameUserAction} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{user.token_count}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{user.books_read > 0
|
||||
? <span className="font-medium text-success">{user.books_read}</span>
|
||||
: <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{user.books_reading > 0
|
||||
? <span className="font-medium text-amber-500">{user.books_reading}</span>
|
||||
: <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<form action={deleteUserAction}>
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
<Button type="submit" variant="destructive" size="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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
apps/backoffice/app/api/auth/login/route.ts
Normal file
31
apps/backoffice/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createSessionToken, SESSION_COOKIE } from "@/lib/session";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body || typeof body.username !== "string" || typeof body.password !== "string") {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const expectedUsername = process.env.ADMIN_USERNAME || "admin";
|
||||
const expectedPassword = process.env.ADMIN_PASSWORD;
|
||||
|
||||
if (!expectedPassword) {
|
||||
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
|
||||
}
|
||||
|
||||
if (body.username !== expectedUsername || body.password !== expectedPassword) {
|
||||
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = await createSessionToken();
|
||||
const response = NextResponse.json({ success: true });
|
||||
response.cookies.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
path: "/",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
8
apps/backoffice/app/api/auth/logout/route.ts
Normal file
8
apps/backoffice/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SESSION_COOKIE } from "@/lib/session";
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true });
|
||||
response.cookies.delete(SESSION_COOKIE);
|
||||
return response;
|
||||
}
|
||||
@@ -28,12 +28,9 @@ export async function GET(
|
||||
});
|
||||
}
|
||||
|
||||
// Récupérer le content-type et les données
|
||||
const contentType = response.headers.get("content-type") || "image/webp";
|
||||
const imageBuffer = await response.arrayBuffer();
|
||||
|
||||
// Retourner l'image avec le bon content-type
|
||||
return new NextResponse(imageBuffer, {
|
||||
return new NextResponse(response.body, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=300",
|
||||
|
||||
@@ -9,10 +9,25 @@ export async function GET(
|
||||
|
||||
try {
|
||||
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`, {
|
||||
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) {
|
||||
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
|
||||
status: response.status
|
||||
@@ -20,14 +35,17 @@ export async function GET(
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "image/webp";
|
||||
const imageBuffer = await response.arrayBuffer();
|
||||
const etag = response.headers.get("etag");
|
||||
|
||||
return new NextResponse(imageBuffer, {
|
||||
headers: {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
};
|
||||
if (etag) {
|
||||
headers["ETag"] = etag;
|
||||
}
|
||||
|
||||
return new NextResponse(response.body, { headers });
|
||||
} catch (error) {
|
||||
console.error("Error fetching thumbnail:", error);
|
||||
return new NextResponse("Failed to fetch thumbnail", { status: 500 });
|
||||
|
||||
@@ -11,6 +11,7 @@ export async function GET(request: NextRequest) {
|
||||
let lastData: string | null = null;
|
||||
let isActive = true;
|
||||
let consecutiveErrors = 0;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const fetchJobs = async () => {
|
||||
if (!isActive) return;
|
||||
@@ -25,23 +26,28 @@ export async function GET(request: NextRequest) {
|
||||
const data = await response.json();
|
||||
const dataStr = JSON.stringify(data);
|
||||
|
||||
// Send if data changed
|
||||
// Send only if data changed
|
||||
if (dataStr !== lastData && isActive) {
|
||||
lastData = dataStr;
|
||||
try {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
||||
);
|
||||
} catch (err) {
|
||||
// Controller closed, ignore
|
||||
} catch {
|
||||
isActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Adapt interval: 2s when active jobs exist, 15s when idle
|
||||
const hasActiveJobs = data.some((j: { status: string }) =>
|
||||
j.status === "running" || j.status === "pending" || j.status === "extracting_pages" || j.status === "generating_thumbnails"
|
||||
);
|
||||
const nextInterval = hasActiveJobs ? 2000 : 15000;
|
||||
restartInterval(nextInterval);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isActive) {
|
||||
consecutiveErrors++;
|
||||
// Only log first failure and every 30th to avoid spam
|
||||
if (consecutiveErrors === 1 || consecutiveErrors % 30 === 0) {
|
||||
console.warn(`SSE fetch error (${consecutiveErrors} consecutive):`, error);
|
||||
}
|
||||
@@ -49,22 +55,18 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
await fetchJobs();
|
||||
const restartInterval = (ms: number) => {
|
||||
if (intervalId !== null) clearInterval(intervalId);
|
||||
intervalId = setInterval(fetchJobs, ms);
|
||||
};
|
||||
|
||||
// Poll every 2 seconds
|
||||
const interval = setInterval(async () => {
|
||||
if (!isActive) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
// Initial fetch + start polling
|
||||
await fetchJobs();
|
||||
}, 2000);
|
||||
|
||||
// Cleanup
|
||||
request.signal.addEventListener("abort", () => {
|
||||
isActive = false;
|
||||
clearInterval(interval);
|
||||
if (intervalId !== null) clearInterval(intervalId);
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
|
||||
12
apps/backoffice/app/api/telegram/test/route.ts
Normal file
12
apps/backoffice/app/api/telegram/test/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await apiFetch("/telegram/test");
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to test Telegram connection";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { memo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { BookDto, ReadingStatus } from "../../lib/api";
|
||||
@@ -17,7 +17,7 @@ interface BookCardProps {
|
||||
readingStatus?: ReadingStatus;
|
||||
}
|
||||
|
||||
function BookImage({ src, alt }: { src: string; alt: string }) {
|
||||
const BookImage = memo(function BookImage({ src, alt }: { src: string; alt: string }) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
@@ -51,13 +51,12 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
onError={() => setHasError(true)}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function BookCard({ book, readingStatus }: BookCardProps) {
|
||||
export const BookCard = memo(function BookCard({ book, readingStatus }: BookCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
|
||||
const status = readingStatus ?? book.reading_status;
|
||||
@@ -115,6 +114,7 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
||||
${(book.format ?? book.kind) === 'cbz' ? 'bg-success/10 text-success' : ''}
|
||||
${(book.format ?? book.kind) === 'cbr' ? 'bg-warning/10 text-warning' : ''}
|
||||
${(book.format ?? book.kind) === 'pdf' ? 'bg-destructive/10 text-destructive' : ''}
|
||||
${(book.format ?? book.kind) === 'epub' ? 'bg-info/10 text-info' : ''}
|
||||
`}>
|
||||
{book.format ?? book.kind}
|
||||
</span>
|
||||
@@ -128,7 +128,7 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
interface BooksGridProps {
|
||||
books: (BookDto & { coverUrl?: string })[];
|
||||
|
||||
231
apps/backoffice/app/components/DashboardCharts.tsx
Normal file
231
apps/backoffice/app/components/DashboardCharts.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
AreaChart, Area, Line, LineChart,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Donut
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RcDonutChart({
|
||||
data,
|
||||
noDataLabel,
|
||||
}: {
|
||||
data: { name: string; value: number; color: string }[];
|
||||
noDataLabel?: string;
|
||||
}) {
|
||||
const total = data.reduce((s, d) => s + d.value, 0);
|
||||
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<ResponsiveContainer width={130} height={130}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={32}
|
||||
outerRadius={55}
|
||||
dataKey="value"
|
||||
strokeWidth={0}
|
||||
>
|
||||
{data.map((d, i) => (
|
||||
<Cell key={i} fill={d.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => value}
|
||||
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: d.color }} />
|
||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||
<span className="font-medium text-foreground ml-auto">{d.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bar chart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RcBarChart({
|
||||
data,
|
||||
color = "hsl(198 78% 37%)",
|
||||
noDataLabel,
|
||||
}: {
|
||||
data: { label: string; value: number }[];
|
||||
color?: string;
|
||||
noDataLabel?: string;
|
||||
}) {
|
||||
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart 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 }}
|
||||
/>
|
||||
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Area / Line chart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RcAreaChart({
|
||||
data,
|
||||
color = "hsl(142 60% 45%)",
|
||||
noDataLabel,
|
||||
}: {
|
||||
data: { label: string; value: number }[];
|
||||
color?: string;
|
||||
noDataLabel?: string;
|
||||
}) {
|
||||
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<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 }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="value" stroke={color} strokeWidth={2} fill="url(#areaGradient)" dot={{ r: 3, fill: color }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Horizontal stacked bar (libraries breakdown)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RcStackedBar({
|
||||
data,
|
||||
labels,
|
||||
}: {
|
||||
data: { name: string; read: number; reading: number; unread: number; sizeLabel: string }[];
|
||||
labels: { read: string; reading: string; unread: string; books: string };
|
||||
}) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={data.length * 60 + 30}>
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 5, bottom: 0, left: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--color-border)" opacity={0.3} />
|
||||
<XAxis type="number" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 12, fill: "var(--color-foreground)" }} axisLine={false} tickLine={false} width={120} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
formatter={(value: string) => <span className="text-muted-foreground">{value}</span>}
|
||||
/>
|
||||
<Bar dataKey="read" stackId="a" fill="hsl(142 60% 45%)" name={labels.read} radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="reading" stackId="a" fill="hsl(45 93% 47%)" name={labels.reading} />
|
||||
<Bar dataKey="unread" stackId="a" fill="hsl(220 13% 70%)" name={labels.unread} radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Horizontal bar chart (top series)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RcHorizontalBar({
|
||||
data,
|
||||
color = "hsl(142 60% 45%)",
|
||||
noDataLabel,
|
||||
}: {
|
||||
data: { name: string; value: number; subLabel: string }[];
|
||||
color?: string;
|
||||
noDataLabel?: string;
|
||||
}) {
|
||||
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-4">{noDataLabel}</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={data.length * 40 + 10}>
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 5, bottom: 0, left: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--color-border)" opacity={0.3} />
|
||||
<XAxis type="number" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "var(--color-foreground)" }} axisLine={false} tickLine={false} width={120} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="value" fill={color} radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { FolderBrowser } from "./FolderBrowser";
|
||||
import { FolderItem } from "../../lib/api";
|
||||
import { Button } from "./ui";
|
||||
@@ -64,7 +65,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
</div>
|
||||
|
||||
{/* Popup Modal */}
|
||||
{isOpen && (
|
||||
{isOpen && createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
@@ -121,7 +122,8 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
44
apps/backoffice/app/components/JobDetailLive.tsx
Normal file
44
apps/backoffice/app/components/JobDetailLive.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface JobDetailLiveProps {
|
||||
jobId: string;
|
||||
isTerminal: boolean;
|
||||
}
|
||||
|
||||
export function JobDetailLive({ jobId, isTerminal }: JobDetailLiveProps) {
|
||||
const router = useRouter();
|
||||
const isTerminalRef = useRef(isTerminal);
|
||||
isTerminalRef.current = isTerminal;
|
||||
|
||||
useEffect(() => {
|
||||
if (isTerminalRef.current) return;
|
||||
|
||||
const eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
router.refresh();
|
||||
|
||||
if (data.status === "success" || data.status === "failed" || data.status === "cancelled") {
|
||||
eventSource.close();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [jobId, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
import { JobProgress } from "./JobProgress";
|
||||
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon } from "./ui";
|
||||
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon, Tooltip } from "./ui";
|
||||
|
||||
interface JobRowProps {
|
||||
job: {
|
||||
@@ -21,6 +21,7 @@ interface JobRowProps {
|
||||
indexed_files: number;
|
||||
removed_files: number;
|
||||
errors: number;
|
||||
refreshed?: number;
|
||||
} | null;
|
||||
progress_percent: number | null;
|
||||
processed_files: number | null;
|
||||
@@ -117,49 +118,74 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{/* Files: indexed count */}
|
||||
{indexed > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-success" title={t("jobRow.filesIndexed", { count: indexed })}>
|
||||
<Tooltip label={t("jobRow.filesIndexed", { count: indexed })}>
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<Icon name="document" size="sm" />
|
||||
{indexed}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Removed files */}
|
||||
{removed > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-warning" title={t("jobRow.filesRemoved", { count: removed })}>
|
||||
<Tooltip label={t("jobRow.filesRemoved", { count: removed })}>
|
||||
<span className="inline-flex items-center gap-1 text-warning">
|
||||
<Icon name="trash" size="sm" />
|
||||
{removed}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Thumbnails */}
|
||||
{hasThumbnailPhase && job.total_files != null && job.total_files > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-primary" title={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
|
||||
<Tooltip label={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
|
||||
<span className="inline-flex items-center gap-1 text-primary">
|
||||
<Icon name="image" size="sm" />
|
||||
{job.total_files}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Metadata batch: series processed */}
|
||||
{isMetadataBatch && job.total_files != null && job.total_files > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataProcessed", { count: job.total_files })}>
|
||||
<Tooltip label={t("jobRow.metadataProcessed", { count: job.total_files })}>
|
||||
<span className="inline-flex items-center gap-1 text-info">
|
||||
<Icon name="tag" size="sm" />
|
||||
{job.total_files}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Metadata refresh: links refreshed */}
|
||||
{/* Metadata refresh: total links + refreshed count */}
|
||||
{isMetadataRefresh && job.total_files != null && job.total_files > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataRefreshed", { count: job.total_files })}>
|
||||
<Tooltip label={t("jobRow.metadataLinks", { count: job.total_files })}>
|
||||
<span className="inline-flex items-center gap-1 text-info">
|
||||
<Icon name="tag" size="sm" />
|
||||
{job.total_files}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isMetadataRefresh && job.stats_json?.refreshed != null && job.stats_json.refreshed > 0 && (
|
||||
<Tooltip label={t("jobRow.metadataRefreshed", { count: job.stats_json.refreshed })}>
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<Icon name="refresh" size="sm" />
|
||||
{job.stats_json.refreshed}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Errors */}
|
||||
{errors > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-error" title={t("jobRow.errors", { count: errors })}>
|
||||
<Tooltip label={t("jobRow.errors", { count: errors })}>
|
||||
<span className="inline-flex items-center gap-1 text-error">
|
||||
<Icon name="warning" size="sm" />
|
||||
{errors}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Scanned only (no other stats) */}
|
||||
{indexed === 0 && removed === 0 && errors === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && scanned > 0 && (
|
||||
<span className="text-sm text-muted-foreground">{t("jobRow.scanned", { count: scanned })}</span>
|
||||
<Tooltip label={t("jobRow.scanned", { count: scanned })}>
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Icon name="search" size="sm" />
|
||||
{scanned}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Nothing to show */}
|
||||
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && (
|
||||
@@ -179,16 +205,23 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/jobs/${job.id}`}
|
||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
className="inline-flex items-center justify-center gap-1.5 h-7 px-2.5 text-xs font-medium rounded-md bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
{t("jobRow.view")}
|
||||
</Link>
|
||||
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
size="xs"
|
||||
onClick={() => onCancel(job.id)}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -54,21 +54,62 @@ export function JobsIndicator() {
|
||||
const [popinStyle, setPopinStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchActiveJobs = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/jobs/active");
|
||||
if (response.ok) {
|
||||
const jobs = await response.json();
|
||||
setActiveJobs(jobs);
|
||||
let eventSource: EventSource | null = null;
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const connect = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch jobs:", error);
|
||||
eventSource = new EventSource("/api/jobs/stream");
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const allJobs: Job[] = JSON.parse(event.data);
|
||||
const active = allJobs.filter(j =>
|
||||
j.status === "running" || j.status === "pending" ||
|
||||
j.status === "extracting_pages" || j.status === "generating_thumbnails"
|
||||
);
|
||||
setActiveJobs(active);
|
||||
} catch {
|
||||
// ignore malformed data
|
||||
}
|
||||
};
|
||||
|
||||
fetchActiveJobs();
|
||||
const interval = setInterval(fetchActiveJobs, 2000);
|
||||
return () => clearInterval(interval);
|
||||
eventSource.onerror = () => {
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
// Reconnect after 5s on error
|
||||
reconnectTimeout = setTimeout(connect, 5000);
|
||||
};
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
disconnect();
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Position the popin relative to the button
|
||||
|
||||
@@ -18,6 +18,7 @@ interface Job {
|
||||
indexed_files: number;
|
||||
removed_files: number;
|
||||
errors: number;
|
||||
refreshed?: number;
|
||||
} | null;
|
||||
progress_percent: number | null;
|
||||
processed_files: number | null;
|
||||
@@ -40,34 +41,21 @@ function formatDuration(start: string, end: string | null): string {
|
||||
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
|
||||
}
|
||||
|
||||
function getDateParts(dateStr: string): { mins: number; hours: number; useDate: boolean; date: Date } {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 3600000) {
|
||||
const mins = Math.floor(diff / 60000);
|
||||
return { mins, hours: 0, useDate: false, date };
|
||||
}
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
return { mins: 0, hours, useDate: false, date };
|
||||
}
|
||||
return { mins: 0, hours: 0, useDate: true, date };
|
||||
}
|
||||
|
||||
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, locale } = useTranslation();
|
||||
const [jobs, setJobs] = useState(initialJobs);
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const parts = getDateParts(dateStr);
|
||||
if (parts.useDate) {
|
||||
return parts.date.toLocaleDateString();
|
||||
}
|
||||
if (parts.mins < 1) return t("time.justNow");
|
||||
if (parts.hours > 0) return t("time.hoursAgo", { count: parts.hours });
|
||||
return t("time.minutesAgo", { count: parts.mins });
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return dateStr;
|
||||
const loc = locale === "fr" ? "fr-FR" : "en-US";
|
||||
return date.toLocaleString(loc, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
// Refresh jobs list via SSE
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useTransition } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "../components/ui";
|
||||
import { ProviderIcon } from "../components/ProviderIcon";
|
||||
import { useTranslation } from "../../lib/i18n/context";
|
||||
@@ -24,23 +25,11 @@ export function LibraryActions({
|
||||
metadataProvider,
|
||||
fallbackMetadataProvider,
|
||||
metadataRefreshMode,
|
||||
onUpdate
|
||||
}: LibraryActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (formData: FormData) => {
|
||||
setSaveError(null);
|
||||
@@ -89,11 +78,11 @@ export function LibraryActions({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={isOpen ? "bg-accent" : ""}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -102,12 +91,54 @@ export function LibraryActions({
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-72 bg-card rounded-xl shadow-md border border-border/60 p-4 z-50">
|
||||
{isOpen && createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span className="font-semibold text-lg">{t("libraryActions.settingsTitle")}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form action={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<div className="p-6 space-y-8 max-h-[70vh] overflow-y-auto">
|
||||
|
||||
{/* Section: Indexation */}
|
||||
<div className="space-y-5">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
|
||||
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
{t("libraryActions.sectionIndexation")}
|
||||
</h3>
|
||||
|
||||
{/* Auto scan */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="monitor_enabled"
|
||||
@@ -117,10 +148,23 @@ export function LibraryActions({
|
||||
/>
|
||||
{t("libraryActions.autoScan")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1.5 ml-6">{t("libraryActions.autoScanDesc")}</p>
|
||||
</div>
|
||||
<select
|
||||
name="scan_mode"
|
||||
defaultValue={scanMode}
|
||||
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[130px] shrink-0"
|
||||
>
|
||||
<option value="manual">{t("monitoring.manual")}</option>
|
||||
<option value="hourly">{t("monitoring.hourly")}</option>
|
||||
<option value="daily">{t("monitoring.daily")}</option>
|
||||
<option value="weekly">{t("monitoring.weekly")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
{/* File watcher */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="watcher_enabled"
|
||||
@@ -130,31 +174,32 @@ export function LibraryActions({
|
||||
/>
|
||||
{t("libraryActions.fileWatch")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1.5 ml-6">{t("libraryActions.fileWatchDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">{t("libraryActions.schedule")}</label>
|
||||
<select
|
||||
name="scan_mode"
|
||||
defaultValue={scanMode}
|
||||
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||
>
|
||||
<option value="manual">{t("monitoring.manual")}</option>
|
||||
<option value="hourly">{t("monitoring.hourly")}</option>
|
||||
<option value="daily">{t("monitoring.daily")}</option>
|
||||
<option value="weekly">{t("monitoring.weekly")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<hr className="border-border/40" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Section: Metadata */}
|
||||
<div className="space-y-5">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
|
||||
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
{t("libraryActions.sectionMetadata")}
|
||||
</h3>
|
||||
|
||||
{/* Provider */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||
{metadataProvider && <ProviderIcon provider={metadataProvider} size={16} />}
|
||||
{metadataProvider && metadataProvider !== "none" && <ProviderIcon provider={metadataProvider} size={16} />}
|
||||
{t("libraryActions.provider")}
|
||||
</label>
|
||||
<select
|
||||
name="metadata_provider"
|
||||
defaultValue={metadataProvider || ""}
|
||||
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
|
||||
>
|
||||
<option value="">{t("libraryActions.default")}</option>
|
||||
<option value="none">{t("libraryActions.none")}</option>
|
||||
@@ -165,8 +210,12 @@ export function LibraryActions({
|
||||
<option value="bedetheque">Bédéthèque</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.providerDesc")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Fallback */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||
{fallbackMetadataProvider && fallbackMetadataProvider !== "none" && <ProviderIcon provider={fallbackMetadataProvider} size={16} />}
|
||||
{t("libraryActions.fallback")}
|
||||
@@ -174,7 +223,7 @@ export function LibraryActions({
|
||||
<select
|
||||
name="fallback_metadata_provider"
|
||||
defaultValue={fallbackMetadataProvider || ""}
|
||||
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
|
||||
>
|
||||
<option value="">{t("libraryActions.none")}</option>
|
||||
<option value="google_books">Google Books</option>
|
||||
@@ -184,13 +233,17 @@ export function LibraryActions({
|
||||
<option value="bedetheque">Bédéthèque</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.fallbackDesc")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Metadata refresh */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label className="text-sm font-medium text-foreground">{t("libraryActions.metadataRefreshSchedule")}</label>
|
||||
<select
|
||||
name="metadata_refresh_mode"
|
||||
defaultValue={metadataRefreshMode}
|
||||
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||
className="text-sm border border-border rounded-lg px-3 py-1.5 bg-background min-w-[160px] shrink-0"
|
||||
>
|
||||
<option value="manual">{t("monitoring.manual")}</option>
|
||||
<option value="hourly">{t("monitoring.hourly")}</option>
|
||||
@@ -198,17 +251,30 @@ export function LibraryActions({
|
||||
<option value="weekly">{t("monitoring.weekly")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">{t("libraryActions.metadataRefreshDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<p className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded-lg break-all">
|
||||
<p className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-lg break-all">
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-border/50 bg-muted/30">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? t("libraryActions.saving") : t("common.save")}
|
||||
@@ -216,7 +282,10 @@ export function LibraryActions({
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ const FILTER_ICONS: Record<string, string> = {
|
||||
metadata_provider: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
|
||||
// Sort - arrows up/down
|
||||
sort: "M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12",
|
||||
// Format - document/file
|
||||
format: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z",
|
||||
// Metadata - link/chain
|
||||
metadata: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1",
|
||||
};
|
||||
|
||||
interface FieldDef {
|
||||
@@ -35,12 +39,17 @@ interface LiveSearchFormProps {
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = "filters:";
|
||||
|
||||
export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearchFormProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
|
||||
const storageKey = `${STORAGE_KEY_PREFIX}${basePath}`;
|
||||
|
||||
const buildUrl = useCallback((): string => {
|
||||
if (!formRef.current) return basePath;
|
||||
@@ -54,16 +63,58 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
||||
return qs ? `${basePath}?${qs}` : basePath;
|
||||
}, [basePath]);
|
||||
|
||||
const saveFilters = useCallback(() => {
|
||||
if (!formRef.current) return;
|
||||
const formData = new FormData(formRef.current);
|
||||
const filters: Record<string, string> = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
const str = value.toString().trim();
|
||||
if (str) filters[key] = str;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(filters));
|
||||
} catch {}
|
||||
}, [storageKey]);
|
||||
|
||||
const navigate = useCallback((immediate: boolean) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (immediate) {
|
||||
saveFilters();
|
||||
router.replace(buildUrl() as any);
|
||||
} else {
|
||||
timerRef.current = setTimeout(() => {
|
||||
saveFilters();
|
||||
router.replace(buildUrl() as any);
|
||||
}, debounceMs);
|
||||
}
|
||||
}, [router, buildUrl, debounceMs]);
|
||||
}, [router, buildUrl, debounceMs, saveFilters]);
|
||||
|
||||
// Restore filters from localStorage on mount if URL has no filters
|
||||
useEffect(() => {
|
||||
if (restoredRef.current) return;
|
||||
restoredRef.current = true;
|
||||
|
||||
const hasUrlFilters = fields.some((f) => {
|
||||
const val = searchParams.get(f.name);
|
||||
return val && val.trim() !== "";
|
||||
});
|
||||
if (hasUrlFilters) return;
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (!saved) return;
|
||||
const filters: Record<string, string> = JSON.parse(saved);
|
||||
const fieldNames = new Set(fields.map((f) => f.name));
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (fieldNames.has(key) && value) params.set(key, value);
|
||||
}
|
||||
const qs = params.toString();
|
||||
if (qs) {
|
||||
router.replace(`${basePath}?${qs}` as any);
|
||||
}
|
||||
} catch {}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -85,6 +136,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
saveFilters();
|
||||
router.replace(buildUrl() as any);
|
||||
}}
|
||||
className="space-y-4"
|
||||
@@ -145,7 +197,11 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
||||
{hasFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.replace(basePath as any)}
|
||||
onClick={() => {
|
||||
formRef.current?.reset();
|
||||
try { localStorage.removeItem(storageKey); } catch {}
|
||||
router.replace(basePath as any);
|
||||
}}
|
||||
className="
|
||||
inline-flex items-center gap-1
|
||||
h-8 px-2.5
|
||||
|
||||
27
apps/backoffice/app/components/LogoutButton.tsx
Normal file
27
apps/backoffice/app/components/LogoutButton.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title="Se déconnecter"
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal file
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
type Period = "day" | "week" | "month";
|
||||
|
||||
export function PeriodToggle({
|
||||
labels,
|
||||
}: {
|
||||
labels: { day: string; week: string; month: string };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const raw = searchParams.get("period");
|
||||
const current: Period = raw === "day" ? "day" : raw === "week" ? "week" : "month";
|
||||
|
||||
function setPeriod(period: Period) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (period === "month") {
|
||||
params.delete("period");
|
||||
} else {
|
||||
params.set("period", period);
|
||||
}
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `?${qs}` : "/", { scroll: false });
|
||||
}
|
||||
|
||||
const options: Period[] = ["day", "week", "month"];
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
|
||||
{options.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
current === p
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{labels[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,7 +64,11 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
||||
setError(null);
|
||||
setResults([]);
|
||||
try {
|
||||
const body = { series_name: seriesName, custom_query: searchQuery.trim() };
|
||||
const missing_volumes = missingBooks?.map((b) => ({
|
||||
volume_number: b.volume_number,
|
||||
title: b.title,
|
||||
})) ?? undefined;
|
||||
const body = { series_name: seriesName, custom_query: searchQuery.trim(), missing_volumes };
|
||||
const resp = await fetch("/api/prowlarr/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -237,12 +241,23 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{results.map((release, i) => (
|
||||
<tr key={release.guid || i} className="hover:bg-muted/20 transition-colors">
|
||||
{results.map((release, i) => {
|
||||
const hasMissing = release.matchedMissingVolumes && release.matchedMissingVolumes.length > 0;
|
||||
return (
|
||||
<tr key={release.guid || i} className={`transition-colors ${hasMissing ? "bg-green-500/10 hover:bg-green-500/20 border-l-2 border-l-green-500" : "hover:bg-muted/20"}`}>
|
||||
<td className="px-3 py-2 max-w-[400px]">
|
||||
<span className="truncate block" title={release.title}>
|
||||
{release.title}
|
||||
</span>
|
||||
{hasMissing && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{release.matchedMissingVolumes!.map((vol) => (
|
||||
<span key={vol} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/20 text-green-600">
|
||||
{t("prowlarr.missingVol", { vol })}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
|
||||
{release.indexer || "—"}
|
||||
@@ -325,7 +340,8 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
145
apps/backoffice/app/components/ReadingUserFilter.tsx
Normal file
145
apps/backoffice/app/components/ReadingUserFilter.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { CurrentlyReadingItem, RecentlyReadItem } from "@/lib/api";
|
||||
import { getBookCoverUrl } from "@/lib/api";
|
||||
|
||||
function FilterPills({ usernames, selected, allLabel, onSelect }: {
|
||||
usernames: string[];
|
||||
selected: string | null;
|
||||
allLabel: string;
|
||||
onSelect: (u: string | null) => void;
|
||||
}) {
|
||||
if (usernames.length <= 1) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
<button
|
||||
onClick={() => onSelect(null)}
|
||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||
selected === null
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{allLabel}
|
||||
</button>
|
||||
{usernames.map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
onClick={() => onSelect(u === selected ? null : u)}
|
||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||
selected === u
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{u}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CurrentlyReadingList({
|
||||
items,
|
||||
allLabel,
|
||||
emptyLabel,
|
||||
pageProgressTemplate,
|
||||
}: {
|
||||
items: CurrentlyReadingItem[];
|
||||
allLabel: string;
|
||||
emptyLabel: string;
|
||||
/** Template with {{current}} and {{total}} placeholders */
|
||||
pageProgressTemplate: string;
|
||||
}) {
|
||||
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const filtered = selected ? items.filter((i) => i.username === selected) : items;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
||||
{filtered.slice(0, 8).map((book) => {
|
||||
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
|
||||
return (
|
||||
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.book_id)}
|
||||
alt={book.title}
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
|
||||
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
|
||||
{book.username && usernames.length > 1 && (
|
||||
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
|
||||
)}
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full bg-warning rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">{pct}%</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{pageProgressTemplate.replace("{{current}}", String(book.current_page)).replace("{{total}}", String(book.page_count))}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentlyReadList({
|
||||
items,
|
||||
allLabel,
|
||||
emptyLabel,
|
||||
}: {
|
||||
items: RecentlyReadItem[];
|
||||
allLabel: string;
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
const usernames = [...new Set(items.map((i) => i.username).filter((u): u is string => !!u))];
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const filtered = selected ? items.filter((i) => i.username === selected) : items;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FilterPills usernames={usernames} selected={selected} allLabel={allLabel} onSelect={setSelected} />
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
||||
{filtered.map((book) => (
|
||||
<Link key={`${book.book_id}-${book.username}`} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
||||
<Image
|
||||
src={getBookCoverUrl(book.book_id)}
|
||||
alt={book.title}
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-10 h-14 object-cover rounded shadow-sm shrink-0 bg-muted"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate group-hover:text-primary transition-colors">{book.title}</p>
|
||||
{book.series && <p className="text-xs text-muted-foreground truncate">{book.series}</p>}
|
||||
{book.username && usernames.length > 1 && (
|
||||
<p className="text-[10px] text-primary/70 font-medium">{book.username}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{book.last_read_at}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/backoffice/app/components/TokenUserSelect.tsx
Normal file
38
apps/backoffice/app/components/TokenUserSelect.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useOptimistic, useTransition } from "react";
|
||||
|
||||
interface TokenUserSelectProps {
|
||||
tokenId: string;
|
||||
currentUserId?: string;
|
||||
users: { id: string; username: string }[];
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
noUserLabel: string;
|
||||
}
|
||||
|
||||
export function TokenUserSelect({ tokenId, currentUserId, users, action, noUserLabel }: TokenUserSelectProps) {
|
||||
const [optimisticValue, setOptimisticValue] = useOptimistic(currentUserId ?? "");
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<select
|
||||
value={optimisticValue}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
startTransition(async () => {
|
||||
setOptimisticValue(newValue);
|
||||
const fd = new FormData();
|
||||
fd.append("id", tokenId);
|
||||
fd.append("user_id", newValue);
|
||||
await action(fd);
|
||||
});
|
||||
}}
|
||||
className="flex h-8 rounded-md border border-input bg-background px-2 py-0 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">{noUserLabel}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.username}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
121
apps/backoffice/app/components/UserSwitcher.tsx
Normal file
121
apps/backoffice/app/components/UserSwitcher.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition, useRef, useEffect } from "react";
|
||||
import type { UserDto } from "@/lib/api";
|
||||
|
||||
export function UserSwitcher({
|
||||
users,
|
||||
activeUserId,
|
||||
setActiveUserAction,
|
||||
}: {
|
||||
users: UserDto[];
|
||||
activeUserId: string | null;
|
||||
setActiveUserAction: (formData: FormData) => Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const activeUser = users.find((u) => u.id === activeUserId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
function select(userId: string | null) {
|
||||
setOpen(false);
|
||||
startTransition(async () => {
|
||||
const fd = new FormData();
|
||||
fd.append("user_id", userId ?? "");
|
||||
await setActiveUserAction(fd);
|
||||
});
|
||||
}
|
||||
|
||||
if (users.length === 0) return null;
|
||||
|
||||
const isImpersonating = activeUserId !== null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
isImpersonating
|
||||
? "border-primary/40 bg-primary/10 text-primary hover:bg-primary/15"
|
||||
: "border-border/60 bg-muted/40 text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{isImpersonating ? (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="max-w-[80px] truncate hidden sm:inline">
|
||||
{activeUser ? activeUser.username : "Admin"}
|
||||
</span>
|
||||
<svg className="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1.5 w-44 rounded-lg border border-border/60 bg-popover shadow-lg z-50 overflow-hidden py-1">
|
||||
<button
|
||||
onClick={() => select(null)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
|
||||
!isImpersonating
|
||||
? "bg-accent text-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
Admin
|
||||
{!isImpersonating && (
|
||||
<svg className="w-3.5 h-3.5 ml-auto text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-border/60 my-1" />
|
||||
|
||||
{users.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => select(user.id)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
|
||||
activeUserId === user.id
|
||||
? "bg-accent text-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span className="truncate">{user.username}</span>
|
||||
{activeUserId === user.id && (
|
||||
<svg className="w-3.5 h-3.5 ml-auto text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
apps/backoffice/app/components/UsernameEdit.tsx
Normal file
73
apps/backoffice/app/components/UsernameEdit.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useOptimistic, useTransition, useRef, useState } from "react";
|
||||
|
||||
export function UsernameEdit({
|
||||
userId,
|
||||
currentUsername,
|
||||
action,
|
||||
}: {
|
||||
userId: string;
|
||||
currentUsername: string;
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
}) {
|
||||
const [optimisticUsername, setOptimisticUsername] = useOptimistic(currentUsername);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function startEdit() {
|
||||
setEditing(true);
|
||||
setTimeout(() => inputRef.current?.select(), 0);
|
||||
}
|
||||
|
||||
function submit(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === currentUsername) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
setEditing(false);
|
||||
startTransition(async () => {
|
||||
setOptimisticUsername(trimmed);
|
||||
const fd = new FormData();
|
||||
fd.append("id", userId);
|
||||
fd.append("username", trimmed);
|
||||
await action(fd);
|
||||
});
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
defaultValue={optimisticUsername}
|
||||
className="text-sm font-medium text-foreground bg-background border border-border rounded px-2 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary w-36"
|
||||
autoFocus
|
||||
onBlur={(e) => submit(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit((e.target as HTMLInputElement).value);
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={startEdit}
|
||||
className="flex items-center gap-1.5 group/edit text-left"
|
||||
title="Modifier"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">{optimisticUsername}</span>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover/edit:opacity-100 transition-opacity shrink-0"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -93,6 +93,7 @@ export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
|
||||
// Job type badge
|
||||
const jobTypeVariants: Record<string, BadgeVariant> = {
|
||||
rebuild: "primary",
|
||||
rescan: "primary",
|
||||
full_rebuild: "warning",
|
||||
thumbnail_rebuild: "secondary",
|
||||
thumbnail_regenerate: "warning",
|
||||
@@ -109,6 +110,7 @@ export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
|
||||
const variant = jobTypeVariants[key] || "default";
|
||||
const jobTypeLabels: Record<string, string> = {
|
||||
rebuild: t("jobType.rebuild"),
|
||||
rescan: t("jobType.rescan"),
|
||||
full_rebuild: t("jobType.full_rebuild"),
|
||||
thumbnail_rebuild: t("jobType.thumbnail_rebuild"),
|
||||
thumbnail_regenerate: t("jobType.thumbnail_regenerate"),
|
||||
|
||||
@@ -14,7 +14,7 @@ type ButtonVariant =
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: "sm" | "md" | "lg";
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
@@ -33,6 +33,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
};
|
||||
|
||||
const sizeStyles: Record<string, string> = {
|
||||
xs: "h-7 px-2.5 text-xs rounded-md",
|
||||
sm: "h-9 px-3 text-xs rounded-md",
|
||||
md: "h-10 px-4 py-2 text-sm rounded-md",
|
||||
lg: "h-11 px-8 text-base rounded-md",
|
||||
|
||||
@@ -34,7 +34,8 @@ type IconName =
|
||||
| "warning"
|
||||
| "tag"
|
||||
| "document"
|
||||
| "authors";
|
||||
| "authors"
|
||||
| "bell";
|
||||
|
||||
type IconSize = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
@@ -88,6 +89,7 @@ const icons: Record<IconName, string> = {
|
||||
tag: "M7 7h.01M7 3h5a1.99 1.99 0 011.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
|
||||
document: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
|
||||
authors: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z",
|
||||
bell: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9",
|
||||
};
|
||||
|
||||
const colorClasses: Partial<Record<IconName, string>> = {
|
||||
|
||||
@@ -4,6 +4,7 @@ interface StatBoxProps {
|
||||
value: ReactNode;
|
||||
label: string;
|
||||
variant?: "default" | "primary" | "success" | "warning" | "error";
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -23,10 +24,13 @@ const valueVariantStyles: Record<string, string> = {
|
||||
error: "text-destructive",
|
||||
};
|
||||
|
||||
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
|
||||
export function StatBox({ value, label, variant = "default", icon, className = "" }: StatBoxProps) {
|
||||
return (
|
||||
<div className={`text-center p-4 rounded-lg transition-colors duration-200 ${variantStyles[variant]} ${className}`}>
|
||||
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span>
|
||||
<div className={`flex items-center justify-center gap-1.5 ${valueVariantStyles[variant]}`}>
|
||||
{icon && <span className="text-xl">{icon}</span>}
|
||||
<span className="text-3xl font-bold">{value}</span>
|
||||
</div>
|
||||
<span className={`text-xs text-muted-foreground`}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
18
apps/backoffice/app/components/ui/Tooltip.tsx
Normal file
18
apps/backoffice/app/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface TooltipProps {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tooltip({ label, children, className = "" }: TooltipProps) {
|
||||
return (
|
||||
<span className={`relative group/tooltip inline-flex ${className}`}>
|
||||
{children}
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1 text-xs text-popover-foreground bg-popover border border-border rounded-lg shadow-lg whitespace-nowrap opacity-0 scale-95 transition-all duration-150 group-hover/tooltip:opacity-100 group-hover/tooltip:scale-100 z-50">
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -19,3 +19,4 @@ export {
|
||||
} from "./Form";
|
||||
export { PageIcon, NavIcon, Icon } from "./Icon";
|
||||
export { CursorPagination, OffsetPagination } from "./Pagination";
|
||||
export { Tooltip } from "./Tooltip";
|
||||
|
||||
@@ -1,130 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "./theme-provider";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { JobsIndicator } from "./components/JobsIndicator";
|
||||
import { NavIcon, Icon } from "./components/ui";
|
||||
import { MobileNav } from "./components/MobileNav";
|
||||
import { LocaleProvider } from "../lib/i18n/context";
|
||||
import { getServerLocale, getServerTranslations } from "../lib/i18n/server";
|
||||
import type { TranslationKey } from "../lib/i18n/fr";
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { getServerLocale } from "@/lib/i18n/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "StripStream Backoffice",
|
||||
description: "Administration backoffice pour StripStream Librarian"
|
||||
};
|
||||
|
||||
type NavItem = {
|
||||
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||
labelKey: TranslationKey;
|
||||
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
|
||||
{ href: "/books", labelKey: "nav.books", icon: "books" },
|
||||
{ href: "/series", labelKey: "nav.series", icon: "series" },
|
||||
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
|
||||
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
|
||||
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
|
||||
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
|
||||
];
|
||||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
const locale = await getServerLocale();
|
||||
const { t } = await getServerTranslations();
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
|
||||
<ThemeProvider>
|
||||
<LocaleProvider initialLocale={locale}>
|
||||
{/* Header avec effet glassmorphism */}
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
|
||||
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
{/* Brand */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
|
||||
>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="StripStream"
|
||||
width={36}
|
||||
height={36}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xl font-bold tracking-tight text-foreground">
|
||||
StripStream
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground font-medium hidden md:inline">
|
||||
{t("common.backoffice")}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
|
||||
<NavIcon name={item.icon} />
|
||||
<span className="ml-2 hidden lg:inline">{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
|
||||
<JobsIndicator />
|
||||
<Link
|
||||
href="/settings"
|
||||
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
title={t("nav.settings")}
|
||||
>
|
||||
<Icon name="settings" size="md" />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
||||
{children}
|
||||
</main>
|
||||
</LocaleProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
// Navigation Link Component
|
||||
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={title}
|
||||
className="
|
||||
flex items-center
|
||||
px-2 lg:px-3 py-2
|
||||
rounded-lg
|
||||
text-sm font-medium
|
||||
text-muted-foreground
|
||||
hover:text-foreground
|
||||
hover:bg-accent
|
||||
transition-colors duration-200
|
||||
active:scale-[0.98]
|
||||
"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import Link from "next/link";
|
||||
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, startMetadataBatch, LibraryDto, FolderItem } from "../../lib/api";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
import { LibraryActions } from "../components/LibraryActions";
|
||||
import { LibraryForm } from "../components/LibraryForm";
|
||||
import {
|
||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||
Button, Badge
|
||||
} from "../components/ui";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatNextScan(nextScanAt: string | null, imminentLabel: string): string {
|
||||
if (!nextScanAt) return "-";
|
||||
const date = new Date(nextScanAt);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 0) return imminentLabel;
|
||||
if (diff < 60000) return "< 1 min";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
|
||||
return `${Math.floor(diff / 86400000)}d`;
|
||||
}
|
||||
|
||||
export default async function LibrariesPage() {
|
||||
const { t } = await getServerTranslations();
|
||||
const [libraries, folders] = await Promise.all([
|
||||
fetchLibraries().catch(() => [] as LibraryDto[]),
|
||||
listFolders().catch(() => [] as FolderItem[])
|
||||
]);
|
||||
|
||||
const seriesCounts = await Promise.all(
|
||||
libraries.map(async (lib) => {
|
||||
try {
|
||||
const seriesPage = await fetchSeries(lib.id);
|
||||
return { id: lib.id, count: seriesPage.items.length };
|
||||
} catch {
|
||||
return { id: lib.id, count: 0 };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const seriesCountMap = new Map(seriesCounts.map(s => [s.id, s.count]));
|
||||
|
||||
async function addLibrary(formData: FormData) {
|
||||
"use server";
|
||||
const name = formData.get("name") as string;
|
||||
const rootPath = formData.get("root_path") as string;
|
||||
if (name && rootPath) {
|
||||
await createLibrary(name, rootPath);
|
||||
revalidatePath("/libraries");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLibrary(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await deleteLibrary(id);
|
||||
revalidatePath("/libraries");
|
||||
}
|
||||
|
||||
async function scanLibraryAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await scanLibrary(id);
|
||||
revalidatePath("/libraries");
|
||||
revalidatePath("/jobs");
|
||||
}
|
||||
|
||||
async function scanLibraryFullAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await scanLibrary(id, true);
|
||||
revalidatePath("/libraries");
|
||||
revalidatePath("/jobs");
|
||||
}
|
||||
|
||||
async function batchMetadataAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
try {
|
||||
await startMetadataBatch(id);
|
||||
} catch {
|
||||
// Library may have metadata disabled — ignore silently
|
||||
return;
|
||||
}
|
||||
revalidatePath("/libraries");
|
||||
revalidatePath("/jobs");
|
||||
}
|
||||
|
||||
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-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
{t("libraries.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Add Library Form */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("libraries.addLibrary")}</CardTitle>
|
||||
<CardDescription>{t("libraries.addLibraryDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LibraryForm initialFolders={folders} action={addLibrary} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Libraries Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{libraries.map((lib) => {
|
||||
const seriesCount = seriesCountMap.get(lib.id) || 0;
|
||||
return (
|
||||
<Card key={lib.id} className="flex flex-col">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{lib.name}</CardTitle>
|
||||
{!lib.enabled && <Badge variant="muted" className="mt-1">{t("libraries.disabled")}</Badge>}
|
||||
</div>
|
||||
<LibraryActions
|
||||
libraryId={lib.id}
|
||||
monitorEnabled={lib.monitor_enabled}
|
||||
scanMode={lib.scan_mode}
|
||||
watcherEnabled={lib.watcher_enabled}
|
||||
metadataProvider={lib.metadata_provider}
|
||||
fallbackMetadataProvider={lib.fallback_metadata_provider}
|
||||
metadataRefreshMode={lib.metadata_refresh_mode}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pt-0">
|
||||
{/* Path */}
|
||||
<code className="text-xs font-mono text-muted-foreground mb-4 break-all block">{lib.root_path}</code>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<Link
|
||||
href={`/libraries/${lib.id}/books`}
|
||||
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||
>
|
||||
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
|
||||
<span className="text-xs text-muted-foreground">{t("libraries.books")}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/libraries/${lib.id}/series`}
|
||||
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||
>
|
||||
<span className="block text-2xl font-bold text-foreground">{seriesCount}</span>
|
||||
<span className="text-xs text-muted-foreground">{t("libraries.series")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-3 mb-4 text-sm">
|
||||
<span className={`flex items-center gap-1 ${lib.monitor_enabled ? 'text-success' : 'text-muted-foreground'}`}>
|
||||
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? t("libraries.auto") : t("libraries.manual")}
|
||||
</span>
|
||||
{lib.watcher_enabled && (
|
||||
<span className="text-warning" title="Surveillance de fichiers active">⚡</span>
|
||||
)}
|
||||
{lib.monitor_enabled && lib.next_scan_at && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{t("libraries.nextScan", { time: formatNextScan(lib.next_scan_at, t("libraries.imminent")) })}
|
||||
</span>
|
||||
)}
|
||||
{lib.metadata_refresh_mode !== "manual" && lib.next_metadata_refresh_at && (
|
||||
<span className="text-xs text-muted-foreground ml-auto" title={t("libraries.nextMetadataRefresh", { time: formatNextScan(lib.next_metadata_refresh_at, t("libraries.imminent")) })}>
|
||||
{t("libraries.nextMetadataRefreshShort", { time: formatNextScan(lib.next_metadata_refresh_at, t("libraries.imminent")) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<form className="flex-1">
|
||||
<input type="hidden" name="id" value={lib.id} />
|
||||
<Button type="submit" variant="default" size="sm" className="w-full" formAction={scanLibraryAction}>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{t("libraries.index")}
|
||||
</Button>
|
||||
</form>
|
||||
<form className="flex-1">
|
||||
<input type="hidden" name="id" value={lib.id} />
|
||||
<Button type="submit" variant="secondary" size="sm" className="w-full" formAction={scanLibraryFullAction}>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{t("libraries.fullIndex")}
|
||||
</Button>
|
||||
</form>
|
||||
{lib.metadata_provider !== "none" && (
|
||||
<form>
|
||||
<input type="hidden" name="id" value={lib.id} />
|
||||
<Button type="submit" variant="secondary" size="sm" formAction={batchMetadataAction} title={t("libraries.batchMetadata")}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<form>
|
||||
<input type="hidden" name="id" value={lib.id} />
|
||||
<Button type="submit" variant="destructive" size="sm" formAction={removeLibrary}>
|
||||
<svg className="w-4 h-4" 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>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
168
apps/backoffice/app/login/page.tsx
Normal file
168
apps/backoffice/app/login/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState, Suspense } from "react";
|
||||
|
||||
function LoginForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const from = searchParams.get("from") || "/";
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = from;
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error || "Identifiants invalides");
|
||||
}
|
||||
} catch {
|
||||
setError("Erreur réseau");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col items-center justify-center px-4 py-16 overflow-hidden">
|
||||
|
||||
{/* Background logo */}
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover opacity-20"
|
||||
priority
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Hero */}
|
||||
<div className="relative flex flex-col items-center mb-10">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-foreground">
|
||||
StripStream{" "}
|
||||
<span className="text-primary font-light">: Librarian</span>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1.5 tracking-wide uppercase font-medium">
|
||||
Administration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form card */}
|
||||
<div
|
||||
className="relative w-full max-w-sm rounded-2xl border border-white/20 backdrop-blur-sm p-8"
|
||||
style={{ boxShadow: "0 24px 48px -12px rgb(0 0 0 / 0.18)" }}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1.5">
|
||||
Identifiant
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="admin"
|
||||
className="
|
||||
flex w-full h-11 px-4
|
||||
rounded-xl border border-input bg-background/60
|
||||
text-sm text-foreground
|
||||
placeholder:text-muted-foreground/40
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring
|
||||
disabled:opacity-50
|
||||
transition-all duration-200
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="••••••••"
|
||||
className="
|
||||
flex w-full h-11 px-4
|
||||
rounded-xl border border-input bg-background/60
|
||||
text-sm text-foreground
|
||||
placeholder:text-muted-foreground/40
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring
|
||||
disabled:opacity-50
|
||||
transition-all duration-200
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-destructive/10 border border-destructive/20 text-sm text-destructive">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="
|
||||
w-full h-11 mt-2
|
||||
inline-flex items-center justify-center gap-2
|
||||
rounded-xl font-medium text-sm
|
||||
bg-primary text-primary-foreground
|
||||
hover:bg-primary/90
|
||||
transition-all duration-200 ease-out
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
active:scale-[0.98]
|
||||
"
|
||||
style={{ boxShadow: "0 4px 16px -4px hsl(198 78% 37% / 0.5)" }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Connexion…
|
||||
</>
|
||||
) : "Se connecter"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTokens, createToken, revokeToken, deleteToken, TokenDto } from "../../lib/api";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
|
||||
import { getServerTranslations } from "../../lib/i18n/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TokensPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{ created?: string }>;
|
||||
}) {
|
||||
const { t } = await getServerTranslations();
|
||||
const params = await searchParams;
|
||||
const tokens = await listTokens().catch(() => [] as TokenDto[]);
|
||||
|
||||
async function createTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const name = formData.get("name") as string;
|
||||
const scope = formData.get("scope") as string;
|
||||
if (name) {
|
||||
const result = await createToken(name, scope);
|
||||
revalidatePath("/tokens");
|
||||
redirect(`/tokens?created=${encodeURIComponent(result.token)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await revokeToken(id);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
|
||||
async function deleteTokenAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await deleteToken(id);
|
||||
revalidatePath("/tokens");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||
<svg className="w-8 h-8 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
{t("tokens.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{params.created ? (
|
||||
<Card className="mb-6 border-success/50 bg-success/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-success">{t("tokens.created")}</CardTitle>
|
||||
<CardDescription>{t("tokens.createdDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="p-4 bg-background rounded-lg text-sm font-mono text-foreground overflow-x-auto border">{params.created}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("tokens.createNew")}</CardTitle>
|
||||
<CardDescription>{t("tokens.createDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={createTokenAction}>
|
||||
<FormRow>
|
||||
<FormField className="flex-1 min-w-48">
|
||||
<FormInput name="name" placeholder={t("tokens.tokenName")} required />
|
||||
</FormField>
|
||||
<FormField className="w-32">
|
||||
<FormSelect name="scope" defaultValue="read">
|
||||
<option value="read">{t("tokens.scopeRead")}</option>
|
||||
<option value="admin">{t("tokens.scopeAdmin")}</option>
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
<Button type="submit">{t("tokens.createButton")}</Button>
|
||||
</FormRow>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/50">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.name")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.scope")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.prefix")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.status")}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("tokens.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{tokens.map((token) => (
|
||||
<tr key={token.id} className="hover:bg-accent/50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<Badge variant={token.scope === "admin" ? "destructive" : "secondary"}>
|
||||
{token.scope}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<code className="px-2 py-1 bg-muted rounded font-mono text-foreground">{token.prefix}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{token.revoked_at ? (
|
||||
<Badge variant="error">{t("tokens.revoked")}</Badge>
|
||||
) : (
|
||||
<Badge variant="success">{t("tokens.active")}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{!token.revoked_at ? (
|
||||
<form action={revokeTokenAction}>
|
||||
<input type="hidden" name="id" value={token.id} />
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{t("tokens.revoke")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form action={deleteTokenAction}>
|
||||
<input type="hidden" name="id" value={token.id} />
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export type LibraryDto = {
|
||||
fallback_metadata_provider: string | null;
|
||||
metadata_refresh_mode: string;
|
||||
next_metadata_refresh_at: string | null;
|
||||
series_count: number;
|
||||
thumbnail_book_ids: string[];
|
||||
};
|
||||
|
||||
export type IndexJobDto = {
|
||||
@@ -30,6 +32,7 @@ export type IndexJobDto = {
|
||||
removed_files: number;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
refreshed?: number;
|
||||
} | null;
|
||||
progress_percent: number | null;
|
||||
processed_files: number | null;
|
||||
@@ -42,6 +45,17 @@ export type TokenDto = {
|
||||
scope: string;
|
||||
prefix: string;
|
||||
revoked_at: string | null;
|
||||
user_id?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type UserDto = {
|
||||
id: string;
|
||||
username: string;
|
||||
token_count: number;
|
||||
books_read: number;
|
||||
books_reading: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type FolderItem = {
|
||||
@@ -139,7 +153,7 @@ export function config() {
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
init?: RequestInit & { next?: { revalidate?: number; tags?: string[] } },
|
||||
): Promise<T> {
|
||||
const { baseUrl, token } = config();
|
||||
const headers = new Headers(init?.headers || {});
|
||||
@@ -148,10 +162,22 @@ export async function apiFetch<T>(
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
// Impersonation : injecte X-As-User si un user est sélectionné dans le backoffice
|
||||
try {
|
||||
const { cookies } = await import("next/headers");
|
||||
const cookieStore = await cookies();
|
||||
const asUserId = cookieStore.get("as_user_id")?.value;
|
||||
if (asUserId) headers.set("X-As-User", asUserId);
|
||||
} catch {
|
||||
// Hors contexte Next.js (tests, etc.)
|
||||
}
|
||||
|
||||
const { next: nextOptions, ...restInit } = init ?? {};
|
||||
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
...restInit,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
...(nextOptions ? { next: nextOptions } : { cache: "no-store" as const }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -166,7 +192,7 @@ export async function apiFetch<T>(
|
||||
}
|
||||
|
||||
export async function fetchLibraries() {
|
||||
return apiFetch<LibraryDto[]>("/libraries");
|
||||
return apiFetch<LibraryDto[]>("/libraries", { next: { revalidate: 30 } });
|
||||
}
|
||||
|
||||
export async function createLibrary(name: string, rootPath: string) {
|
||||
@@ -221,10 +247,11 @@ export async function listJobs() {
|
||||
return apiFetch<IndexJobDto[]>("/index/status");
|
||||
}
|
||||
|
||||
export async function rebuildIndex(libraryId?: string, full?: boolean) {
|
||||
const body: { library_id?: string; full?: boolean } = {};
|
||||
export async function rebuildIndex(libraryId?: string, full?: boolean, rescan?: boolean) {
|
||||
const body: { library_id?: string; full?: boolean; rescan?: boolean } = {};
|
||||
if (libraryId) body.library_id = libraryId;
|
||||
if (full) body.full = true;
|
||||
if (rescan) body.rescan = true;
|
||||
return apiFetch<IndexJobDto>("/index/rebuild", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
@@ -262,10 +289,32 @@ export async function listTokens() {
|
||||
return apiFetch<TokenDto[]>("/admin/tokens");
|
||||
}
|
||||
|
||||
export async function createToken(name: string, scope: string) {
|
||||
export async function createToken(name: string, scope: string, userId?: string) {
|
||||
return apiFetch<{ token: string }>("/admin/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, scope }),
|
||||
body: JSON.stringify({ name, scope, ...(userId ? { user_id: userId } : {}) }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchUsers(): Promise<UserDto[]> {
|
||||
return apiFetch<UserDto[]>("/admin/users");
|
||||
}
|
||||
|
||||
export async function createUser(username: string): Promise<UserDto> {
|
||||
return apiFetch<UserDto>("/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/admin/users/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, username: string): Promise<void> {
|
||||
return apiFetch<void>(`/admin/users/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -277,6 +326,13 @@ export async function deleteToken(id: string) {
|
||||
return apiFetch<void>(`/admin/tokens/${id}/delete`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function updateToken(id: string, userId: string | null) {
|
||||
return apiFetch<void>(`/admin/tokens/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ user_id: userId || null }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchBooks(
|
||||
libraryId?: string,
|
||||
series?: string,
|
||||
@@ -285,6 +341,8 @@ export async function fetchBooks(
|
||||
readingStatus?: string,
|
||||
sort?: string,
|
||||
author?: string,
|
||||
format?: string,
|
||||
metadataProvider?: string,
|
||||
): Promise<BooksPageDto> {
|
||||
const params = new URLSearchParams();
|
||||
if (libraryId) params.set("library_id", libraryId);
|
||||
@@ -292,6 +350,8 @@ export async function fetchBooks(
|
||||
if (readingStatus) params.set("reading_status", readingStatus);
|
||||
if (sort) params.set("sort", sort);
|
||||
if (author) params.set("author", author);
|
||||
if (format) params.set("format", format);
|
||||
if (metadataProvider) params.set("metadata_provider", metadataProvider);
|
||||
params.set("page", page.toString());
|
||||
params.set("limit", limit.toString());
|
||||
|
||||
@@ -333,6 +393,7 @@ export async function fetchAllSeries(
|
||||
seriesStatus?: string,
|
||||
hasMissing?: boolean,
|
||||
metadataProvider?: string,
|
||||
author?: string,
|
||||
): Promise<SeriesPageDto> {
|
||||
const params = new URLSearchParams();
|
||||
if (libraryId) params.set("library_id", libraryId);
|
||||
@@ -342,6 +403,7 @@ export async function fetchAllSeries(
|
||||
if (seriesStatus) params.set("series_status", seriesStatus);
|
||||
if (hasMissing) params.set("has_missing", "true");
|
||||
if (metadataProvider) params.set("metadata_provider", metadataProvider);
|
||||
if (author) params.set("author", author);
|
||||
params.set("page", page.toString());
|
||||
params.set("limit", limit.toString());
|
||||
|
||||
@@ -349,7 +411,7 @@ export async function fetchAllSeries(
|
||||
}
|
||||
|
||||
export async function fetchSeriesStatuses(): Promise<string[]> {
|
||||
return apiFetch<string[]>("/series/statuses");
|
||||
return apiFetch<string[]>("/series/statuses", { next: { revalidate: 300 } });
|
||||
}
|
||||
|
||||
export async function searchBooks(
|
||||
@@ -414,7 +476,7 @@ export type ThumbnailStats = {
|
||||
};
|
||||
|
||||
export async function getSettings() {
|
||||
return apiFetch<Settings>("/settings");
|
||||
return apiFetch<Settings>("/settings", { next: { revalidate: 60 } });
|
||||
}
|
||||
|
||||
export async function updateSetting(key: string, value: unknown) {
|
||||
@@ -425,7 +487,7 @@ export async function updateSetting(key: string, value: unknown) {
|
||||
}
|
||||
|
||||
export async function getCacheStats() {
|
||||
return apiFetch<CacheStats>("/settings/cache/stats");
|
||||
return apiFetch<CacheStats>("/settings/cache/stats", { next: { revalidate: 30 } });
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
@@ -435,7 +497,7 @@ export async function clearCache() {
|
||||
}
|
||||
|
||||
export async function getThumbnailStats() {
|
||||
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats");
|
||||
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats", { next: { revalidate: 30 } });
|
||||
}
|
||||
|
||||
// Status mappings
|
||||
@@ -446,7 +508,7 @@ export type StatusMappingDto = {
|
||||
};
|
||||
|
||||
export async function fetchStatusMappings(): Promise<StatusMappingDto[]> {
|
||||
return apiFetch<StatusMappingDto[]>("/settings/status-mappings");
|
||||
return apiFetch<StatusMappingDto[]>("/settings/status-mappings", { next: { revalidate: 60 } });
|
||||
}
|
||||
|
||||
export async function upsertStatusMapping(provider_status: string, mapped_status: string): Promise<StatusMappingDto> {
|
||||
@@ -539,19 +601,61 @@ export type MetadataStats = {
|
||||
by_provider: ProviderCount[];
|
||||
};
|
||||
|
||||
export type CurrentlyReadingItem = {
|
||||
book_id: string;
|
||||
title: string;
|
||||
series: string | null;
|
||||
current_page: number;
|
||||
page_count: number;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type RecentlyReadItem = {
|
||||
book_id: string;
|
||||
title: string;
|
||||
series: string | null;
|
||||
last_read_at: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type MonthlyReading = {
|
||||
month: string;
|
||||
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 = {
|
||||
overview: StatsOverview;
|
||||
reading_status: ReadingStatusStats;
|
||||
currently_reading: CurrentlyReadingItem[];
|
||||
recently_read: RecentlyReadItem[];
|
||||
reading_over_time: MonthlyReading[];
|
||||
users_reading_over_time: UserMonthlyReading[];
|
||||
by_format: FormatCount[];
|
||||
by_language: LanguageCount[];
|
||||
by_library: LibraryStatsItem[];
|
||||
top_series: TopSeriesItem[];
|
||||
additions_over_time: MonthlyAdditions[];
|
||||
jobs_over_time: JobTimePoint[];
|
||||
metadata: MetadataStats;
|
||||
};
|
||||
|
||||
export async function fetchStats() {
|
||||
return apiFetch<StatsResponse>("/stats");
|
||||
export async function fetchStats(period?: "day" | "week" | "month") {
|
||||
const params = period && period !== "month" ? `?period=${period}` : "";
|
||||
return apiFetch<StatsResponse>(`/stats${params}`, { next: { revalidate: 30 } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -654,11 +758,13 @@ export type KomgaSyncRequest = {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
user_id: string;
|
||||
};
|
||||
|
||||
export type KomgaSyncResponse = {
|
||||
id: string;
|
||||
komga_url: string;
|
||||
user_id?: string;
|
||||
total_komga_read: number;
|
||||
matched: number;
|
||||
already_read: number;
|
||||
@@ -672,6 +778,7 @@ export type KomgaSyncResponse = {
|
||||
export type KomgaSyncReportSummary = {
|
||||
id: string;
|
||||
komga_url: string;
|
||||
user_id?: string;
|
||||
total_komga_read: number;
|
||||
matched: number;
|
||||
already_read: number;
|
||||
@@ -939,6 +1046,7 @@ export type ProwlarrRelease = {
|
||||
protocol: string | null;
|
||||
infoUrl: string | null;
|
||||
categories: ProwlarrCategory[] | null;
|
||||
matchedMissingVolumes: number[] | null;
|
||||
};
|
||||
|
||||
export type ProwlarrSearchResponse = {
|
||||
|
||||
@@ -8,6 +8,7 @@ const en: Record<TranslationKey, string> = {
|
||||
"nav.libraries": "Libraries",
|
||||
"nav.jobs": "Jobs",
|
||||
"nav.tokens": "Tokens",
|
||||
"nav.users": "Users",
|
||||
"nav.settings": "Settings",
|
||||
"nav.navigation": "Navigation",
|
||||
"nav.closeMenu": "Close menu",
|
||||
@@ -70,7 +71,15 @@ const en: Record<TranslationKey, string> = {
|
||||
"dashboard.readingStatus": "Reading status",
|
||||
"dashboard.byFormat": "By format",
|
||||
"dashboard.byLibrary": "By library",
|
||||
"dashboard.booksAdded": "Books added (last 12 months)",
|
||||
"dashboard.booksAdded": "Books added",
|
||||
"dashboard.jobsOverTime": "Job runs",
|
||||
"dashboard.jobScan": "Scan",
|
||||
"dashboard.jobRebuild": "Rebuild",
|
||||
"dashboard.jobThumbnail": "Thumbnails",
|
||||
"dashboard.jobOther": "Other",
|
||||
"dashboard.periodDay": "Day",
|
||||
"dashboard.periodWeek": "Week",
|
||||
"dashboard.periodMonth": "Month",
|
||||
"dashboard.popularSeries": "Popular series",
|
||||
"dashboard.noSeries": "No series yet",
|
||||
"dashboard.unknown": "Unknown",
|
||||
@@ -82,6 +91,13 @@ const en: Record<TranslationKey, string> = {
|
||||
"dashboard.bookMetadata": "Book metadata",
|
||||
"dashboard.withSummary": "With summary",
|
||||
"dashboard.withIsbn": "With ISBN",
|
||||
"dashboard.currentlyReading": "Currently reading",
|
||||
"dashboard.recentlyRead": "Recently read",
|
||||
"dashboard.readingActivity": "Reading activity",
|
||||
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
||||
"dashboard.noCurrentlyReading": "No books in progress",
|
||||
"dashboard.noRecentlyRead": "No books read recently",
|
||||
"dashboard.allUsers": "All",
|
||||
|
||||
// Books page
|
||||
"books.title": "Books",
|
||||
@@ -100,6 +116,8 @@ const en: Record<TranslationKey, string> = {
|
||||
"books.noResults": "No books found for \"{{query}}\"",
|
||||
"books.noBooks": "No books available",
|
||||
"books.coverOf": "Cover of {{name}}",
|
||||
"books.format": "Format",
|
||||
"books.allFormats": "All formats",
|
||||
|
||||
// Series page
|
||||
"series.title": "Series",
|
||||
@@ -140,6 +158,9 @@ const en: Record<TranslationKey, string> = {
|
||||
"libraries.imminent": "Imminent",
|
||||
"libraries.nextMetadataRefresh": "Next metadata refresh: {{time}}",
|
||||
"libraries.nextMetadataRefreshShort": "Meta.: {{time}}",
|
||||
"libraries.scanLabel": "Scan: {{mode}}",
|
||||
"libraries.watcherLabel": "File watch",
|
||||
"libraries.metaRefreshLabel": "Meta refresh: {{mode}}",
|
||||
"libraries.index": "Index",
|
||||
"libraries.fullIndex": "Full",
|
||||
"libraries.batchMetadata": "Batch metadata",
|
||||
@@ -157,14 +178,22 @@ const en: Record<TranslationKey, string> = {
|
||||
"librarySeries.noBooksInSeries": "No books in this series",
|
||||
|
||||
// Library actions
|
||||
"libraryActions.autoScan": "Auto scan",
|
||||
"libraryActions.fileWatch": "File watch ⚡",
|
||||
"libraryActions.schedule": "📅 Schedule",
|
||||
"libraryActions.settingsTitle": "Library settings",
|
||||
"libraryActions.sectionIndexation": "Indexation",
|
||||
"libraryActions.sectionMetadata": "Metadata",
|
||||
"libraryActions.autoScan": "Scheduled scan",
|
||||
"libraryActions.autoScanDesc": "Automatically scan for new and modified files",
|
||||
"libraryActions.fileWatch": "Real-time file watch",
|
||||
"libraryActions.fileWatchDesc": "Detect file changes instantly via filesystem events",
|
||||
"libraryActions.schedule": "Frequency",
|
||||
"libraryActions.provider": "Provider",
|
||||
"libraryActions.fallback": "Fallback",
|
||||
"libraryActions.providerDesc": "Source used to fetch series and volume metadata",
|
||||
"libraryActions.fallback": "Fallback provider",
|
||||
"libraryActions.fallbackDesc": "Used when the primary provider returns no results",
|
||||
"libraryActions.default": "Default",
|
||||
"libraryActions.none": "None",
|
||||
"libraryActions.metadataRefreshSchedule": "Refresh meta.",
|
||||
"libraryActions.metadataRefreshSchedule": "Auto-refresh",
|
||||
"libraryActions.metadataRefreshDesc": "Periodically re-fetch metadata for existing series",
|
||||
"libraryActions.saving": "Saving...",
|
||||
|
||||
// Library sub-page header
|
||||
@@ -186,6 +215,7 @@ const en: Record<TranslationKey, string> = {
|
||||
"jobs.startJobDescription": "Select a library (or all) and choose the action to perform.",
|
||||
"jobs.allLibraries": "All libraries",
|
||||
"jobs.rebuild": "Rebuild",
|
||||
"jobs.rescan": "Deep rescan",
|
||||
"jobs.fullRebuild": "Full rebuild",
|
||||
"jobs.generateThumbnails": "Generate thumbnails",
|
||||
"jobs.regenerateThumbnails": "Regenerate thumbnails",
|
||||
@@ -198,12 +228,14 @@ const en: Record<TranslationKey, string> = {
|
||||
"jobs.groupMetadata": "Metadata",
|
||||
"jobs.requiresLibrary": "Requires a specific library",
|
||||
"jobs.rebuildShort": "Scan new & modified files",
|
||||
"jobs.rescanShort": "Re-walk all directories to discover new formats",
|
||||
"jobs.fullRebuildShort": "Delete all & re-scan from scratch",
|
||||
"jobs.generateThumbnailsShort": "Missing thumbnails only",
|
||||
"jobs.regenerateThumbnailsShort": "Recreate all thumbnails",
|
||||
"jobs.batchMetadataShort": "Auto-match unlinked series",
|
||||
"jobs.refreshMetadataShort": "Update existing linked series",
|
||||
"jobs.rebuildDescription": "Incremental scan: detects files added, modified, or deleted since the last scan, indexes them, and generates missing thumbnails. Existing unmodified data is preserved. This is the most common and fastest action.",
|
||||
"jobs.rescanDescription": "Re-walks all directories regardless of whether they changed, discovering files in newly supported formats (e.g. EPUB). Existing books and metadata are fully preserved — only genuinely new files are added. Slower than a rebuild but safe for your data.",
|
||||
"jobs.fullRebuildDescription": "Deletes all indexed data (books, series, thumbnails) then performs a full scan from scratch. Useful if the database is out of sync or corrupted. Long and destructive operation: reading statuses and manual metadata will be lost.",
|
||||
"jobs.generateThumbnailsDescription": "Generates thumbnails only for books that don't have one yet. Existing thumbnails are not affected. Useful after an import or if some thumbnails are missing.",
|
||||
"jobs.regenerateThumbnailsDescription": "Regenerates all thumbnails from scratch, replacing existing ones. Useful if thumbnail quality or size has changed in the configuration, or if thumbnails are corrupted.",
|
||||
@@ -228,6 +260,7 @@ const en: Record<TranslationKey, string> = {
|
||||
"jobRow.thumbnailsGenerated": "{{count}} thumbnails generated",
|
||||
"jobRow.metadataProcessed": "{{count}} series processed",
|
||||
"jobRow.metadataRefreshed": "{{count}} series refreshed",
|
||||
"jobRow.metadataLinks": "{{count}} links analyzed",
|
||||
"jobRow.errors": "{{count}} errors",
|
||||
"jobRow.view": "View",
|
||||
|
||||
@@ -310,6 +343,7 @@ const en: Record<TranslationKey, string> = {
|
||||
|
||||
// Job types
|
||||
"jobType.rebuild": "Indexing",
|
||||
"jobType.rescan": "Deep rescan",
|
||||
"jobType.full_rebuild": "Full indexing",
|
||||
"jobType.thumbnail_rebuild": "Thumbnails",
|
||||
"jobType.thumbnail_regenerate": "Regen. thumbnails",
|
||||
@@ -318,6 +352,8 @@ const en: Record<TranslationKey, string> = {
|
||||
"jobType.metadata_refresh": "Refresh meta.",
|
||||
"jobType.rebuildLabel": "Incremental indexing",
|
||||
"jobType.rebuildDesc": "Scans new/modified files, analyzes them, and generates missing thumbnails.",
|
||||
"jobType.rescanLabel": "Deep rescan",
|
||||
"jobType.rescanDesc": "Re-walks all directories to discover files in newly supported formats (e.g. EPUB). Existing data is preserved — only new files are added.",
|
||||
"jobType.full_rebuildLabel": "Full reindexing",
|
||||
"jobType.full_rebuildDesc": "Deletes all existing data then performs a full scan, re-analysis, and thumbnail generation.",
|
||||
"jobType.thumbnail_rebuildLabel": "Thumbnail rebuild",
|
||||
@@ -371,6 +407,21 @@ const en: Record<TranslationKey, string> = {
|
||||
"tokens.revoked": "Revoked",
|
||||
"tokens.active": "Active",
|
||||
"tokens.revoke": "Revoke",
|
||||
"tokens.user": "User",
|
||||
"tokens.noUser": "None (admin)",
|
||||
"tokens.apiTokens": "API Tokens",
|
||||
|
||||
// Users page
|
||||
"users.title": "Users",
|
||||
"users.createNew": "Create a user",
|
||||
"users.createDescription": "Create a user account for read access",
|
||||
"users.username": "Username",
|
||||
"users.createButton": "Create",
|
||||
"users.name": "Username",
|
||||
"users.tokenCount": "Tokens",
|
||||
"users.createdAt": "Created",
|
||||
"users.actions": "Actions",
|
||||
"users.noUsers": "No users",
|
||||
|
||||
// Settings page
|
||||
"settings.title": "Settings",
|
||||
@@ -514,6 +565,7 @@ const en: Record<TranslationKey, string> = {
|
||||
"prowlarr.sending": "Sending...",
|
||||
"prowlarr.sentSuccess": "Sent to qBittorrent",
|
||||
"prowlarr.sentError": "Failed to send to qBittorrent",
|
||||
"prowlarr.missingVol": "Vol. {{vol}} missing",
|
||||
|
||||
// Settings - qBittorrent
|
||||
"settings.qbittorrent": "qBittorrent",
|
||||
@@ -523,6 +575,33 @@ const en: Record<TranslationKey, string> = {
|
||||
"settings.qbittorrentUsername": "Username",
|
||||
"settings.qbittorrentPassword": "Password",
|
||||
|
||||
// Settings - Telegram Notifications
|
||||
"settings.notifications": "Notifications",
|
||||
"settings.telegram": "Telegram",
|
||||
"settings.telegramDesc": "Receive Telegram notifications for scans, errors, and metadata linking.",
|
||||
"settings.botToken": "Bot Token",
|
||||
"settings.botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||
"settings.chatId": "Chat ID",
|
||||
"settings.chatIdPlaceholder": "123456789",
|
||||
"settings.telegramEnabled": "Enable Telegram notifications",
|
||||
"settings.telegramEvents": "Events",
|
||||
"settings.eventCategoryScan": "Scans",
|
||||
"settings.eventCategoryThumbnail": "Thumbnails",
|
||||
"settings.eventCategoryConversion": "CBR → CBZ Conversion",
|
||||
"settings.eventCategoryMetadata": "Metadata",
|
||||
"settings.eventCompleted": "Completed",
|
||||
"settings.eventFailed": "Failed",
|
||||
"settings.eventCancelled": "Cancelled",
|
||||
"settings.eventLinked": "Linked",
|
||||
"settings.eventBatchCompleted": "Batch completed",
|
||||
"settings.eventBatchFailed": "Batch failed",
|
||||
"settings.eventRefreshCompleted": "Refresh completed",
|
||||
"settings.eventRefreshFailed": "Refresh failed",
|
||||
"settings.telegramHelp": "How to get the required information?",
|
||||
"settings.telegramHelpBot": "Open Telegram, search for <b>@BotFather</b>, send <code>/newbot</code> and follow the instructions. Copy the token it gives you.",
|
||||
"settings.telegramHelpChat": "Send a message to your bot, then open <code>https://api.telegram.org/bot<TOKEN>/getUpdates</code> in your browser. The <b>chat id</b> is in <code>message.chat.id</code>.",
|
||||
"settings.telegramHelpGroup": "For a group: add the bot to the group, send a message, then check the same URL. Group IDs are negative (e.g. <code>-123456789</code>).",
|
||||
|
||||
// Settings - Language
|
||||
"settings.language": "Language",
|
||||
"settings.languageDesc": "Choose the interface language",
|
||||
|
||||
@@ -6,6 +6,7 @@ const fr = {
|
||||
"nav.libraries": "Bibliothèques",
|
||||
"nav.jobs": "Tâches",
|
||||
"nav.tokens": "Jetons",
|
||||
"nav.users": "Utilisateurs",
|
||||
"nav.settings": "Paramètres",
|
||||
"nav.navigation": "Navigation",
|
||||
"nav.closeMenu": "Fermer le menu",
|
||||
@@ -68,7 +69,15 @@ const fr = {
|
||||
"dashboard.readingStatus": "Statut de lecture",
|
||||
"dashboard.byFormat": "Par format",
|
||||
"dashboard.byLibrary": "Par bibliothèque",
|
||||
"dashboard.booksAdded": "Livres ajoutés (12 derniers mois)",
|
||||
"dashboard.booksAdded": "Livres ajoutés",
|
||||
"dashboard.jobsOverTime": "Exécutions de jobs",
|
||||
"dashboard.jobScan": "Scan",
|
||||
"dashboard.jobRebuild": "Rebuild",
|
||||
"dashboard.jobThumbnail": "Thumbnails",
|
||||
"dashboard.jobOther": "Autre",
|
||||
"dashboard.periodDay": "Jour",
|
||||
"dashboard.periodWeek": "Semaine",
|
||||
"dashboard.periodMonth": "Mois",
|
||||
"dashboard.popularSeries": "Séries populaires",
|
||||
"dashboard.noSeries": "Aucune série pour le moment",
|
||||
"dashboard.unknown": "Inconnu",
|
||||
@@ -80,6 +89,13 @@ const fr = {
|
||||
"dashboard.bookMetadata": "Métadonnées livres",
|
||||
"dashboard.withSummary": "Avec résumé",
|
||||
"dashboard.withIsbn": "Avec ISBN",
|
||||
"dashboard.currentlyReading": "En cours de lecture",
|
||||
"dashboard.recentlyRead": "Derniers livres lus",
|
||||
"dashboard.readingActivity": "Activité de lecture",
|
||||
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
||||
"dashboard.noCurrentlyReading": "Aucun livre en cours",
|
||||
"dashboard.noRecentlyRead": "Aucun livre lu récemment",
|
||||
"dashboard.allUsers": "Tous",
|
||||
|
||||
// Books page
|
||||
"books.title": "Livres",
|
||||
@@ -98,6 +114,8 @@ const fr = {
|
||||
"books.noResults": "Aucun livre trouvé pour \"{{query}}\"",
|
||||
"books.noBooks": "Aucun livre disponible",
|
||||
"books.coverOf": "Couverture de {{name}}",
|
||||
"books.format": "Format",
|
||||
"books.allFormats": "Tous les formats",
|
||||
|
||||
// Series page
|
||||
"series.title": "Séries",
|
||||
@@ -138,6 +156,9 @@ const fr = {
|
||||
"libraries.imminent": "Imminent",
|
||||
"libraries.nextMetadataRefresh": "Prochain rafraîchissement méta. : {{time}}",
|
||||
"libraries.nextMetadataRefreshShort": "Méta. : {{time}}",
|
||||
"libraries.scanLabel": "Scan : {{mode}}",
|
||||
"libraries.watcherLabel": "Surveillance fichiers",
|
||||
"libraries.metaRefreshLabel": "Rafraîch. méta. : {{mode}}",
|
||||
"libraries.index": "Indexer",
|
||||
"libraries.fullIndex": "Complet",
|
||||
"libraries.batchMetadata": "Métadonnées en lot",
|
||||
@@ -155,14 +176,22 @@ const fr = {
|
||||
"librarySeries.noBooksInSeries": "Aucun livre dans cette série",
|
||||
|
||||
// Library actions
|
||||
"libraryActions.autoScan": "Scan auto",
|
||||
"libraryActions.fileWatch": "Surveillance fichiers ⚡",
|
||||
"libraryActions.schedule": "📅 Planification",
|
||||
"libraryActions.settingsTitle": "Paramètres de la bibliothèque",
|
||||
"libraryActions.sectionIndexation": "Indexation",
|
||||
"libraryActions.sectionMetadata": "Métadonnées",
|
||||
"libraryActions.autoScan": "Scan planifié",
|
||||
"libraryActions.autoScanDesc": "Scanner automatiquement les fichiers nouveaux et modifiés",
|
||||
"libraryActions.fileWatch": "Surveillance en temps réel",
|
||||
"libraryActions.fileWatchDesc": "Détecter les changements de fichiers instantanément",
|
||||
"libraryActions.schedule": "Fréquence",
|
||||
"libraryActions.provider": "Fournisseur",
|
||||
"libraryActions.fallback": "Secours",
|
||||
"libraryActions.providerDesc": "Source utilisée pour récupérer les métadonnées des séries",
|
||||
"libraryActions.fallback": "Fournisseur de secours",
|
||||
"libraryActions.fallbackDesc": "Utilisé quand le fournisseur principal ne retourne aucun résultat",
|
||||
"libraryActions.default": "Par défaut",
|
||||
"libraryActions.none": "Aucun",
|
||||
"libraryActions.metadataRefreshSchedule": "Rafraîchir méta.",
|
||||
"libraryActions.metadataRefreshSchedule": "Rafraîchissement auto",
|
||||
"libraryActions.metadataRefreshDesc": "Re-télécharger périodiquement les métadonnées existantes",
|
||||
"libraryActions.saving": "Enregistrement...",
|
||||
|
||||
// Library sub-page header
|
||||
@@ -183,8 +212,9 @@ const fr = {
|
||||
"jobs.startJob": "Lancer une tâche",
|
||||
"jobs.startJobDescription": "Sélectionnez une bibliothèque (ou toutes) et choisissez l'action à effectuer.",
|
||||
"jobs.allLibraries": "Toutes les bibliothèques",
|
||||
"jobs.rebuild": "Reconstruction",
|
||||
"jobs.fullRebuild": "Reconstruction complète",
|
||||
"jobs.rebuild": "Mise à jour",
|
||||
"jobs.rescan": "Rescan complet",
|
||||
"jobs.fullRebuild": "Reconstruction complète (destructif)",
|
||||
"jobs.generateThumbnails": "Générer les miniatures",
|
||||
"jobs.regenerateThumbnails": "Regénérer les miniatures",
|
||||
"jobs.batchMetadata": "Métadonnées en lot",
|
||||
@@ -196,12 +226,14 @@ const fr = {
|
||||
"jobs.groupMetadata": "Métadonnées",
|
||||
"jobs.requiresLibrary": "Requiert une bibliothèque spécifique",
|
||||
"jobs.rebuildShort": "Scanner les fichiers nouveaux et modifiés",
|
||||
"jobs.fullRebuildShort": "Tout supprimer et re-scanner depuis zéro",
|
||||
"jobs.rescanShort": "Re-parcourir tous les dossiers pour découvrir de nouveaux formats",
|
||||
"jobs.fullRebuildShort": "Tout supprimer et re-scanner depuis zéro. Les métadonnées, statuts de lecture et liens seront perdus.",
|
||||
"jobs.generateThumbnailsShort": "Miniatures manquantes uniquement",
|
||||
"jobs.regenerateThumbnailsShort": "Recréer toutes les miniatures",
|
||||
"jobs.batchMetadataShort": "Lier automatiquement les séries non liées",
|
||||
"jobs.refreshMetadataShort": "Mettre à jour les séries déjà liées",
|
||||
"jobs.rebuildDescription": "Scan incrémental : détecte les fichiers ajoutés, modifiés ou supprimés depuis le dernier scan, les indexe et génère les miniatures manquantes. Les données existantes non modifiées sont conservées. C'est l'action la plus courante et la plus rapide.",
|
||||
"jobs.rescanDescription": "Re-parcourt tous les dossiers même s'ils n'ont pas changé, pour découvrir les fichiers dans les formats nouvellement supportés (ex. EPUB). Les livres et métadonnées existants sont entièrement préservés — seuls les fichiers réellement nouveaux sont ajoutés. Plus lent qu'un rebuild mais sans risque pour vos données.",
|
||||
"jobs.fullRebuildDescription": "Supprime toutes les données indexées (livres, séries, miniatures) puis effectue un scan complet depuis zéro. Utile si la base de données est désynchronisée ou corrompue. Opération longue et destructive : les statuts de lecture et les métadonnées manuelles seront perdus.",
|
||||
"jobs.generateThumbnailsDescription": "Génère les miniatures uniquement pour les livres qui n'en ont pas encore. Les miniatures existantes ne sont pas touchées. Utile après un import ou si certaines miniatures sont manquantes.",
|
||||
"jobs.regenerateThumbnailsDescription": "Regénère toutes les miniatures depuis zéro, en remplaçant les existantes. Utile si la qualité ou la taille des miniatures a changé dans la configuration, ou si des miniatures sont corrompues.",
|
||||
@@ -226,6 +258,7 @@ const fr = {
|
||||
"jobRow.thumbnailsGenerated": "{{count}} miniatures générées",
|
||||
"jobRow.metadataProcessed": "{{count}} séries traitées",
|
||||
"jobRow.metadataRefreshed": "{{count}} séries rafraîchies",
|
||||
"jobRow.metadataLinks": "{{count}} liens analysés",
|
||||
"jobRow.errors": "{{count}} erreurs",
|
||||
"jobRow.view": "Voir",
|
||||
|
||||
@@ -308,6 +341,7 @@ const fr = {
|
||||
|
||||
// Job types
|
||||
"jobType.rebuild": "Indexation",
|
||||
"jobType.rescan": "Rescan complet",
|
||||
"jobType.full_rebuild": "Indexation complète",
|
||||
"jobType.thumbnail_rebuild": "Miniatures",
|
||||
"jobType.thumbnail_regenerate": "Régén. miniatures",
|
||||
@@ -316,6 +350,8 @@ const fr = {
|
||||
"jobType.metadata_refresh": "Rafraîchir méta.",
|
||||
"jobType.rebuildLabel": "Indexation incrémentale",
|
||||
"jobType.rebuildDesc": "Scanne les fichiers nouveaux/modifiés, les analyse et génère les miniatures manquantes.",
|
||||
"jobType.rescanLabel": "Rescan complet",
|
||||
"jobType.rescanDesc": "Re-parcourt tous les dossiers pour découvrir les fichiers dans les formats nouvellement supportés (ex. EPUB). Les données existantes sont préservées — seuls les nouveaux fichiers sont ajoutés.",
|
||||
"jobType.full_rebuildLabel": "Réindexation complète",
|
||||
"jobType.full_rebuildDesc": "Supprime toutes les données existantes puis effectue un scan complet, une ré-analyse et la génération des miniatures.",
|
||||
"jobType.thumbnail_rebuildLabel": "Reconstruction des miniatures",
|
||||
@@ -369,6 +405,21 @@ const fr = {
|
||||
"tokens.revoked": "Révoqué",
|
||||
"tokens.active": "Actif",
|
||||
"tokens.revoke": "Révoquer",
|
||||
"tokens.user": "Utilisateur",
|
||||
"tokens.noUser": "Aucun (admin)",
|
||||
"tokens.apiTokens": "Tokens API",
|
||||
|
||||
// Users page
|
||||
"users.title": "Utilisateurs",
|
||||
"users.createNew": "Créer un utilisateur",
|
||||
"users.createDescription": "Créer un compte utilisateur pour accès lecture",
|
||||
"users.username": "Nom d'utilisateur",
|
||||
"users.createButton": "Créer",
|
||||
"users.name": "Nom d'utilisateur",
|
||||
"users.tokenCount": "Nb de jetons",
|
||||
"users.createdAt": "Créé le",
|
||||
"users.actions": "Actions",
|
||||
"users.noUsers": "Aucun utilisateur",
|
||||
|
||||
// Settings page
|
||||
"settings.title": "Paramètres",
|
||||
@@ -512,6 +563,7 @@ const fr = {
|
||||
"prowlarr.sending": "Envoi...",
|
||||
"prowlarr.sentSuccess": "Envoyé à qBittorrent",
|
||||
"prowlarr.sentError": "Échec de l'envoi à qBittorrent",
|
||||
"prowlarr.missingVol": "T{{vol}} manquant",
|
||||
|
||||
// Settings - qBittorrent
|
||||
"settings.qbittorrent": "qBittorrent",
|
||||
@@ -521,6 +573,33 @@ const fr = {
|
||||
"settings.qbittorrentUsername": "Nom d'utilisateur",
|
||||
"settings.qbittorrentPassword": "Mot de passe",
|
||||
|
||||
// Settings - Telegram Notifications
|
||||
"settings.notifications": "Notifications",
|
||||
"settings.telegram": "Telegram",
|
||||
"settings.telegramDesc": "Recevoir des notifications Telegram lors des scans, erreurs et liaisons de métadonnées.",
|
||||
"settings.botToken": "Bot Token",
|
||||
"settings.botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||
"settings.chatId": "Chat ID",
|
||||
"settings.chatIdPlaceholder": "123456789",
|
||||
"settings.telegramEnabled": "Activer les notifications Telegram",
|
||||
"settings.telegramEvents": "Événements",
|
||||
"settings.eventCategoryScan": "Scans",
|
||||
"settings.eventCategoryThumbnail": "Miniatures",
|
||||
"settings.eventCategoryConversion": "Conversion CBR → CBZ",
|
||||
"settings.eventCategoryMetadata": "Métadonnées",
|
||||
"settings.eventCompleted": "Terminé",
|
||||
"settings.eventFailed": "Échoué",
|
||||
"settings.eventCancelled": "Annulé",
|
||||
"settings.eventLinked": "Liée",
|
||||
"settings.eventBatchCompleted": "Batch terminé",
|
||||
"settings.eventBatchFailed": "Batch échoué",
|
||||
"settings.eventRefreshCompleted": "Rafraîchissement terminé",
|
||||
"settings.eventRefreshFailed": "Rafraîchissement échoué",
|
||||
"settings.telegramHelp": "Comment obtenir les informations ?",
|
||||
"settings.telegramHelpBot": "Ouvrez Telegram, recherchez <b>@BotFather</b>, envoyez <code>/newbot</code> et suivez les instructions. Copiez le token fourni.",
|
||||
"settings.telegramHelpChat": "Envoyez un message à votre bot, puis ouvrez <code>https://api.telegram.org/bot<TOKEN>/getUpdates</code> dans votre navigateur. Le <b>chat id</b> apparaît dans <code>message.chat.id</code>.",
|
||||
"settings.telegramHelpGroup": "Pour un groupe : ajoutez le bot au groupe, envoyez un message, puis consultez la même URL. Les IDs de groupe sont négatifs (ex: <code>-123456789</code>).",
|
||||
|
||||
// Settings - Language
|
||||
"settings.language": "Langue",
|
||||
"settings.languageDesc": "Choisir la langue de l'interface",
|
||||
|
||||
33
apps/backoffice/lib/session.ts
Normal file
33
apps/backoffice/lib/session.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const SESSION_COOKIE = "sl_session";
|
||||
|
||||
function getSecret(): Uint8Array {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
if (!secret) throw new Error("SESSION_SECRET env var is required");
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
export async function createSessionToken(): Promise<string> {
|
||||
return new SignJWT({})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("7d")
|
||||
.sign(getSecret());
|
||||
}
|
||||
|
||||
export async function verifySessionToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
await jwtVerify(token, getSecret());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return false;
|
||||
return verifySessionToken(token);
|
||||
}
|
||||
2
apps/backoffice/next-env.d.ts
vendored
2
apps/backoffice/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
typedRoutes: true
|
||||
typedRoutes: true,
|
||||
images: {
|
||||
minimumCacheTTL: 86400,
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
516
apps/backoffice/package-lock.json
generated
516
apps/backoffice/package-lock.json
generated
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"name": "stripstream-backoffice",
|
||||
"version": "1.4.0",
|
||||
"version": "1.28.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stripstream-backoffice",
|
||||
"version": "1.4.0",
|
||||
"version": "1.28.0",
|
||||
"dependencies": {
|
||||
"jose": "^6.2.2",
|
||||
"next": "^16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"recharts": "^3.8.0",
|
||||
"sanitize-html": "^2.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -142,9 +144,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -161,9 +160,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -180,9 +176,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -199,9 +192,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -218,9 +208,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -237,9 +224,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -256,9 +240,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -275,9 +256,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -294,9 +272,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -319,9 +294,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -344,9 +316,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -369,9 +338,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -394,9 +360,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -419,9 +382,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -444,9 +404,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -469,9 +426,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -658,9 +612,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -677,9 +628,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -696,9 +644,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -715,9 +660,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -759,6 +701,54 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -901,9 +891,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -921,9 +908,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -941,9 +925,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -961,9 +942,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1051,6 +1029,69 @@
|
||||
"tailwindcss": "4.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
|
||||
@@ -1065,8 +1106,9 @@
|
||||
"version": "19.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz",
|
||||
"integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -1124,6 +1166,12 @@
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.27",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||
@@ -1193,6 +1241,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -1233,11 +1282,147 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
@@ -1347,6 +1532,16 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -1369,6 +1564,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -1409,6 +1610,25 @@
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
@@ -1428,6 +1648,15 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||
@@ -1571,9 +1800,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1595,9 +1821,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1619,9 +1842,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1643,9 +1863,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1858,6 +2075,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -1879,6 +2097,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
||||
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -1888,6 +2107,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
|
||||
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.25.0"
|
||||
},
|
||||
@@ -1895,6 +2115,89 @@
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.1",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.1.tgz",
|
||||
@@ -2026,6 +2329,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -2083,6 +2392,37 @@
|
||||
"peerDependencies": {
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stripstream-backoffice",
|
||||
"version": "1.12.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 7082",
|
||||
@@ -8,10 +8,12 @@
|
||||
"start": "next start -p 7082"
|
||||
},
|
||||
"dependencies": {
|
||||
"jose": "^6.2.2",
|
||||
"next": "^16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"recharts": "^3.8.0",
|
||||
"sanitize-html": "^2.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
38
apps/backoffice/proxy.ts
Normal file
38
apps/backoffice/proxy.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { jwtVerify } from "jose";
|
||||
import { SESSION_COOKIE } from "./lib/session";
|
||||
|
||||
function getSecret(): Uint8Array {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
if (!secret) return new TextEncoder().encode("dev-insecure-secret");
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
export async function proxy(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// Skip auth for login page and auth API routes
|
||||
if (pathname.startsWith("/login") || pathname.startsWith("/api/auth")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const token = req.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (token) {
|
||||
try {
|
||||
await jwtVerify(token, getSecret());
|
||||
return NextResponse.next();
|
||||
} catch {
|
||||
// Token invalid or expired
|
||||
}
|
||||
}
|
||||
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("from", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|favicon\\.ico|logo\\.png|.*\\.svg).*)",
|
||||
],
|
||||
};
|
||||
@@ -14,6 +14,7 @@ futures = "0.3"
|
||||
image.workspace = true
|
||||
jpeg-decoder.workspace = true
|
||||
num_cpus.workspace = true
|
||||
notifications = { path = "../../crates/notifications" }
|
||||
parsers = { path = "../../crates/parsers" }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -6,13 +6,15 @@ COPY Cargo.toml ./
|
||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||
COPY crates/notifications/Cargo.toml crates/notifications/Cargo.toml
|
||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||
|
||||
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/parsers/src && \
|
||||
RUN mkdir -p apps/api/src apps/indexer/src crates/core/src crates/notifications/src crates/parsers/src && \
|
||||
echo "fn main() {}" > apps/api/src/main.rs && \
|
||||
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
||||
echo "" > apps/indexer/src/lib.rs && \
|
||||
echo "" > crates/core/src/lib.rs && \
|
||||
echo "" > crates/notifications/src/lib.rs && \
|
||||
echo "" > crates/parsers/src/lib.rs
|
||||
|
||||
# Build dependencies only (cached as long as Cargo.toml files don't change)
|
||||
@@ -25,12 +27,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
COPY apps/api/src apps/api/src
|
||||
COPY apps/indexer/src apps/indexer/src
|
||||
COPY crates/core/src crates/core/src
|
||||
COPY crates/notifications/src crates/notifications/src
|
||||
COPY crates/parsers/src crates/parsers/src
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/target \
|
||||
touch apps/indexer/src/main.rs crates/core/src/lib.rs crates/parsers/src/lib.rs && \
|
||||
touch apps/indexer/src/main.rs crates/core/src/lib.rs crates/notifications/src/lib.rs crates/parsers/src/lib.rs && \
|
||||
cargo build --release -p indexer && \
|
||||
cp /app/target/release/indexer /usr/local/bin/indexer
|
||||
|
||||
|
||||
@@ -290,6 +290,7 @@ fn book_format_from_str(s: &str) -> Option<BookFormat> {
|
||||
"cbz" => Some(BookFormat::Cbz),
|
||||
"cbr" => Some(BookFormat::Cbr),
|
||||
"pdf" => Some(BookFormat::Pdf),
|
||||
"epub" => Some(BookFormat::Epub),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ const API_ONLY_JOB_TYPES: &[&str] = &["metadata_batch", "metadata_refresh"];
|
||||
const EXCLUSIVE_JOB_TYPES: &[&str] = &[
|
||||
"rebuild",
|
||||
"full_rebuild",
|
||||
"rescan",
|
||||
"scan",
|
||||
"thumbnail_rebuild",
|
||||
"thumbnail_regenerate",
|
||||
@@ -211,11 +212,29 @@ pub async fn process_job(
|
||||
}
|
||||
|
||||
let is_full_rebuild = job_type == "full_rebuild";
|
||||
let is_rescan = job_type == "rescan";
|
||||
info!(
|
||||
"[JOB] {} type={} full_rebuild={}",
|
||||
job_id, job_type, is_full_rebuild
|
||||
"[JOB] {} type={} full_rebuild={} rescan={}",
|
||||
job_id, job_type, is_full_rebuild, is_rescan
|
||||
);
|
||||
|
||||
// Rescan: clear directory mtimes to force re-walking all directories,
|
||||
// but keep existing data intact (unlike full_rebuild)
|
||||
if is_rescan {
|
||||
if let Some(library_id) = target_library_id {
|
||||
let _ = sqlx::query("DELETE FROM directory_mtimes WHERE library_id = $1")
|
||||
.bind(library_id)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
info!("[JOB] Rescan: cleared directory mtimes for library {}", library_id);
|
||||
} else {
|
||||
let _ = sqlx::query("DELETE FROM directory_mtimes")
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
info!("[JOB] Rescan: cleared all directory mtimes");
|
||||
}
|
||||
}
|
||||
|
||||
// Full rebuild: delete existing data first
|
||||
if is_full_rebuild {
|
||||
info!("[JOB] Full rebuild: deleting existing data");
|
||||
@@ -258,7 +277,7 @@ pub async fn process_job(
|
||||
// For full rebuilds, the DB is already cleared, so we must walk the filesystem.
|
||||
let library_ids: Vec<uuid::Uuid> = libraries.iter().map(|r| r.get("id")).collect();
|
||||
|
||||
let total_files: usize = if !is_full_rebuild {
|
||||
let total_files: usize = if !is_full_rebuild && !is_rescan {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM book_files bf JOIN books b ON b.id = bf.book_id WHERE b.library_id = ANY($1)"
|
||||
)
|
||||
@@ -309,6 +328,7 @@ pub async fn process_job(
|
||||
removed_files: 0,
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
new_series: 0,
|
||||
};
|
||||
|
||||
let mut total_processed_count = 0i32;
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::{
|
||||
utils,
|
||||
AppState,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct JobStats {
|
||||
@@ -22,6 +23,7 @@ pub struct JobStats {
|
||||
pub removed_files: usize,
|
||||
pub errors: usize,
|
||||
pub warnings: usize,
|
||||
pub new_series: usize,
|
||||
}
|
||||
|
||||
const BATCH_SIZE: usize = 100;
|
||||
@@ -106,6 +108,18 @@ pub async fn scan_library_discovery(
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
// Track existing series names for new_series counting
|
||||
let existing_series: HashSet<String> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT COALESCE(NULLIF(series, ''), 'unclassified') FROM books WHERE library_id = $1",
|
||||
)
|
||||
.bind(library_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut seen_new_series: HashSet<String> = HashSet::new();
|
||||
|
||||
let mut seen: HashMap<String, bool> = HashMap::new();
|
||||
let mut library_processed_count = 0i32;
|
||||
let mut last_progress_update = std::time::Instant::now();
|
||||
@@ -382,6 +396,12 @@ pub async fn scan_library_discovery(
|
||||
let book_id = Uuid::new_v4();
|
||||
let file_id = Uuid::new_v4();
|
||||
|
||||
// Track new series
|
||||
let series_key = parsed.series.as_deref().unwrap_or("unclassified").to_string();
|
||||
if !existing_series.contains(&series_key) && seen_new_series.insert(series_key) {
|
||||
stats.new_series += 1;
|
||||
}
|
||||
|
||||
books_to_insert.push(BookInsert {
|
||||
book_id,
|
||||
library_id,
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn compute_fingerprint(path: &Path, size: u64, mtime: &DateTime<Utc>) -> Res
|
||||
|
||||
pub fn kind_from_format(format: BookFormat) -> &'static str {
|
||||
match format {
|
||||
BookFormat::Pdf => "ebook",
|
||||
BookFormat::Pdf | BookFormat::Epub => "ebook",
|
||||
BookFormat::Cbz | BookFormat::Cbr => "comic",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::time::Duration;
|
||||
use sqlx::Row;
|
||||
use tracing::{error, info, trace};
|
||||
use uuid::Uuid;
|
||||
use crate::{job, scheduler, watcher, AppState};
|
||||
|
||||
pub async fn run_worker(state: AppState, interval_seconds: u64) {
|
||||
@@ -34,21 +36,183 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) {
|
||||
}
|
||||
});
|
||||
|
||||
struct JobInfo {
|
||||
job_type: String,
|
||||
library_name: Option<String>,
|
||||
book_title: Option<String>,
|
||||
thumbnail_path: Option<String>,
|
||||
}
|
||||
|
||||
async fn load_job_info(
|
||||
pool: &sqlx::PgPool,
|
||||
job_id: Uuid,
|
||||
library_id: Option<Uuid>,
|
||||
) -> JobInfo {
|
||||
let row = sqlx::query("SELECT type, book_id FROM index_jobs WHERE id = $1")
|
||||
.bind(job_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let (job_type, book_id): (String, Option<Uuid>) = match row {
|
||||
Some(r) => (r.get("type"), r.get("book_id")),
|
||||
None => ("unknown".to_string(), None),
|
||||
};
|
||||
|
||||
let library_name: Option<String> = if let Some(lib_id) = library_id {
|
||||
sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||
.bind(lib_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (book_title, thumbnail_path): (Option<String>, Option<String>) = if let Some(bid) = book_id {
|
||||
let row = sqlx::query("SELECT title, thumbnail_path FROM books WHERE id = $1")
|
||||
.bind(bid)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
match row {
|
||||
Some(r) => (r.get("title"), r.get("thumbnail_path")),
|
||||
None => (None, None),
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
JobInfo { job_type, library_name, book_title, thumbnail_path }
|
||||
}
|
||||
|
||||
async fn load_scan_stats(pool: &sqlx::PgPool, job_id: Uuid) -> notifications::ScanStats {
|
||||
let row = sqlx::query("SELECT stats_json FROM index_jobs WHERE id = $1")
|
||||
.bind(job_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if let Some(row) = row {
|
||||
if let Ok(val) = row.try_get::<serde_json::Value, _>("stats_json") {
|
||||
return notifications::ScanStats {
|
||||
scanned_files: val.get("scanned_files").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
|
||||
indexed_files: val.get("indexed_files").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
|
||||
removed_files: val.get("removed_files").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
|
||||
new_series: val.get("new_series").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
|
||||
errors: val.get("errors").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
notifications::ScanStats {
|
||||
scanned_files: 0,
|
||||
indexed_files: 0,
|
||||
removed_files: 0,
|
||||
new_series: 0,
|
||||
errors: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_completed_event(
|
||||
job_type: &str,
|
||||
library_name: Option<String>,
|
||||
book_title: Option<String>,
|
||||
thumbnail_path: Option<String>,
|
||||
stats: notifications::ScanStats,
|
||||
duration_seconds: u64,
|
||||
) -> notifications::NotificationEvent {
|
||||
match notifications::job_type_category(job_type) {
|
||||
"thumbnail" => notifications::NotificationEvent::ThumbnailCompleted {
|
||||
job_type: job_type.to_string(),
|
||||
library_name,
|
||||
duration_seconds,
|
||||
},
|
||||
"conversion" => notifications::NotificationEvent::ConversionCompleted {
|
||||
library_name,
|
||||
book_title,
|
||||
thumbnail_path,
|
||||
},
|
||||
_ => notifications::NotificationEvent::ScanCompleted {
|
||||
job_type: job_type.to_string(),
|
||||
library_name,
|
||||
stats,
|
||||
duration_seconds,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_failed_event(
|
||||
job_type: &str,
|
||||
library_name: Option<String>,
|
||||
book_title: Option<String>,
|
||||
thumbnail_path: Option<String>,
|
||||
error: String,
|
||||
) -> notifications::NotificationEvent {
|
||||
match notifications::job_type_category(job_type) {
|
||||
"thumbnail" => notifications::NotificationEvent::ThumbnailFailed {
|
||||
job_type: job_type.to_string(),
|
||||
library_name,
|
||||
error,
|
||||
},
|
||||
"conversion" => notifications::NotificationEvent::ConversionFailed {
|
||||
library_name,
|
||||
book_title,
|
||||
thumbnail_path,
|
||||
error,
|
||||
},
|
||||
_ => notifications::NotificationEvent::ScanFailed {
|
||||
job_type: job_type.to_string(),
|
||||
library_name,
|
||||
error,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
match job::claim_next_job(&state.pool).await {
|
||||
Ok(Some((job_id, library_id))) => {
|
||||
info!("[INDEXER] Starting job {} library={:?}", job_id, library_id);
|
||||
let started_at = std::time::Instant::now();
|
||||
let info = load_job_info(&state.pool, job_id, library_id).await;
|
||||
|
||||
if let Err(err) = job::process_job(&state, job_id, library_id).await {
|
||||
let err_str = err.to_string();
|
||||
if err_str.contains("cancelled") || err_str.contains("Cancelled") {
|
||||
info!("[INDEXER] Job {} was cancelled by user", job_id);
|
||||
// Status is already 'cancelled' in DB, don't change it
|
||||
notifications::notify(
|
||||
state.pool.clone(),
|
||||
notifications::NotificationEvent::ScanCancelled {
|
||||
job_type: info.job_type.clone(),
|
||||
library_name: info.library_name.clone(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
error!("[INDEXER] Job {} failed: {}", job_id, err);
|
||||
let _ = job::fail_job(&state.pool, job_id, &err_str).await;
|
||||
notifications::notify(
|
||||
state.pool.clone(),
|
||||
build_failed_event(&info.job_type, info.library_name.clone(), info.book_title.clone(), info.thumbnail_path.clone(), err_str),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
info!("[INDEXER] Job {} completed", job_id);
|
||||
let stats = load_scan_stats(&state.pool, job_id).await;
|
||||
notifications::notify(
|
||||
state.pool.clone(),
|
||||
build_completed_event(
|
||||
&info.job_type,
|
||||
info.library_name.clone(),
|
||||
info.book_title.clone(),
|
||||
info.thumbnail_path.clone(),
|
||||
stats,
|
||||
started_at.elapsed().as_secs(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
|
||||
13
crates/notifications/Cargo.toml
Normal file
13
crates/notifications/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "notifications"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sqlx.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
551
crates/notifications/src/lib.rs
Normal file
551
crates/notifications/src/lib.rs
Normal file
@@ -0,0 +1,551 @@
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use tracing::{info, warn};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TelegramConfig {
|
||||
pub bot_token: String,
|
||||
pub chat_id: String,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_events")]
|
||||
pub events: EventToggles,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EventToggles {
|
||||
#[serde(default = "default_true")]
|
||||
pub scan_completed: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub scan_failed: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub scan_cancelled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub thumbnail_completed: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub thumbnail_failed: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub conversion_completed: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub conversion_failed: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub metadata_approved: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub metadata_batch_completed: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub metadata_batch_failed: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub metadata_refresh_completed: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub metadata_refresh_failed: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_events() -> EventToggles {
|
||||
EventToggles {
|
||||
scan_completed: true,
|
||||
scan_failed: true,
|
||||
scan_cancelled: true,
|
||||
thumbnail_completed: true,
|
||||
thumbnail_failed: true,
|
||||
conversion_completed: true,
|
||||
conversion_failed: true,
|
||||
metadata_approved: true,
|
||||
metadata_batch_completed: true,
|
||||
metadata_batch_failed: true,
|
||||
metadata_refresh_completed: true,
|
||||
metadata_refresh_failed: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the Telegram config from `app_settings` (key = "telegram").
|
||||
/// Returns `None` when the row is missing, disabled, or has empty credentials.
|
||||
pub async fn load_telegram_config(pool: &PgPool) -> Option<TelegramConfig> {
|
||||
let row = sqlx::query_scalar::<_, serde_json::Value>(
|
||||
"SELECT value FROM app_settings WHERE key = 'telegram'",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()??;
|
||||
|
||||
let config: TelegramConfig = serde_json::from_value(row).ok()?;
|
||||
|
||||
if !config.enabled || config.bot_token.is_empty() || config.chat_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(config)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telegram HTTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn build_client() -> Result<reqwest::Client> {
|
||||
Ok(reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()?)
|
||||
}
|
||||
|
||||
async fn send_telegram(config: &TelegramConfig, text: &str) -> Result<()> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendMessage",
|
||||
config.bot_token
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"chat_id": config.chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
});
|
||||
|
||||
let resp = build_client()?.post(&url).json(&body).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Telegram API returned {status}: {text}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_telegram_photo(config: &TelegramConfig, caption: &str, photo_path: &str) -> Result<()> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendPhoto",
|
||||
config.bot_token
|
||||
);
|
||||
|
||||
let photo_bytes = tokio::fs::read(photo_path).await?;
|
||||
let filename = std::path::Path::new(photo_path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let mime = if filename.ends_with(".webp") {
|
||||
"image/webp"
|
||||
} else if filename.ends_with(".png") {
|
||||
"image/png"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
};
|
||||
|
||||
let part = reqwest::multipart::Part::bytes(photo_bytes)
|
||||
.file_name(filename)
|
||||
.mime_str(mime)?;
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text("chat_id", config.chat_id.clone())
|
||||
.text("caption", caption.to_string())
|
||||
.text("parse_mode", "HTML")
|
||||
.part("photo", part);
|
||||
|
||||
let resp = build_client()?.post(&url).multipart(form).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Telegram API returned {status}: {text}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a test message. Returns the result directly (not fire-and-forget).
|
||||
pub async fn send_test_message(config: &TelegramConfig) -> Result<()> {
|
||||
send_telegram(
|
||||
config,
|
||||
"🔔 <b>Stripstream Librarian</b>\n\
|
||||
━━━━━━━━━━━━━━━━━━━━\n\
|
||||
✅ Test notification — connection OK!",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notification events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct ScanStats {
|
||||
pub scanned_files: usize,
|
||||
pub indexed_files: usize,
|
||||
pub removed_files: usize,
|
||||
pub new_series: usize,
|
||||
pub errors: usize,
|
||||
}
|
||||
|
||||
pub enum NotificationEvent {
|
||||
// Scan jobs (rebuild, full_rebuild, rescan, scan)
|
||||
ScanCompleted {
|
||||
job_type: String,
|
||||
library_name: Option<String>,
|
||||
stats: ScanStats,
|
||||
duration_seconds: u64,
|
||||
},
|
||||
ScanFailed {
|
||||
job_type: String,
|
||||
library_name: Option<String>,
|
||||
error: String,
|
||||
},
|
||||
ScanCancelled {
|
||||
job_type: String,
|
||||
library_name: Option<String>,
|
||||
},
|
||||
// Thumbnail jobs (thumbnail_rebuild, thumbnail_regenerate)
|
||||
ThumbnailCompleted {
|
||||
job_type: String,
|
||||
library_name: Option<String>,
|
||||
duration_seconds: u64,
|
||||
},
|
||||
ThumbnailFailed {
|
||||
job_type: String,
|
||||
library_name: Option<String>,
|
||||
error: String,
|
||||
},
|
||||
// CBR→CBZ conversion
|
||||
ConversionCompleted {
|
||||
library_name: Option<String>,
|
||||
book_title: Option<String>,
|
||||
thumbnail_path: Option<String>,
|
||||
},
|
||||
ConversionFailed {
|
||||
library_name: Option<String>,
|
||||
book_title: Option<String>,
|
||||
thumbnail_path: Option<String>,
|
||||
error: String,
|
||||
},
|
||||
// Metadata manual approve
|
||||
MetadataApproved {
|
||||
series_name: String,
|
||||
provider: String,
|
||||
thumbnail_path: Option<String>,
|
||||
},
|
||||
// Metadata batch (auto-match)
|
||||
MetadataBatchCompleted {
|
||||
library_name: Option<String>,
|
||||
total_series: i32,
|
||||
processed: i32,
|
||||
},
|
||||
MetadataBatchFailed {
|
||||
library_name: Option<String>,
|
||||
error: String,
|
||||
},
|
||||
// Metadata refresh
|
||||
MetadataRefreshCompleted {
|
||||
library_name: Option<String>,
|
||||
refreshed: i32,
|
||||
unchanged: i32,
|
||||
errors: i32,
|
||||
},
|
||||
MetadataRefreshFailed {
|
||||
library_name: Option<String>,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Classify an indexer job_type string into the right event constructor category.
|
||||
/// Returns "scan", "thumbnail", or "conversion".
|
||||
pub fn job_type_category(job_type: &str) -> &'static str {
|
||||
match job_type {
|
||||
"thumbnail_rebuild" | "thumbnail_regenerate" => "thumbnail",
|
||||
"cbr_to_cbz" => "conversion",
|
||||
_ => "scan",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_event(event: &NotificationEvent) -> String {
|
||||
match event {
|
||||
NotificationEvent::ScanCompleted {
|
||||
job_type,
|
||||
library_name,
|
||||
stats,
|
||||
duration_seconds,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let duration = format_duration(*duration_seconds);
|
||||
let mut lines = vec![
|
||||
format!("✅ <b>Scan completed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("🏷 <b>Type:</b> {job_type}"),
|
||||
format!("⏱ <b>Duration:</b> {duration}"),
|
||||
String::new(),
|
||||
format!("📊 <b>Results</b>"),
|
||||
format!(" 📗 New books: <b>{}</b>", stats.indexed_files),
|
||||
format!(" 📚 New series: <b>{}</b>", stats.new_series),
|
||||
format!(" 🔎 Files scanned: <b>{}</b>", stats.scanned_files),
|
||||
format!(" 🗑 Removed: <b>{}</b>", stats.removed_files),
|
||||
];
|
||||
if stats.errors > 0 {
|
||||
lines.push(format!(" ⚠️ Errors: <b>{}</b>", stats.errors));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
NotificationEvent::ScanFailed {
|
||||
job_type,
|
||||
library_name,
|
||||
error,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let err = truncate(error, 200);
|
||||
[
|
||||
format!("🚨 <b>Scan failed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("🏷 <b>Type:</b> {job_type}"),
|
||||
String::new(),
|
||||
format!("💬 <code>{err}</code>"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::ScanCancelled {
|
||||
job_type,
|
||||
library_name,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
[
|
||||
format!("⏹ <b>Scan cancelled</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("🏷 <b>Type:</b> {job_type}"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::ThumbnailCompleted {
|
||||
job_type,
|
||||
library_name,
|
||||
duration_seconds,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let duration = format_duration(*duration_seconds);
|
||||
[
|
||||
format!("✅ <b>Thumbnails completed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("🏷 <b>Type:</b> {job_type}"),
|
||||
format!("⏱ <b>Duration:</b> {duration}"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::ThumbnailFailed {
|
||||
job_type,
|
||||
library_name,
|
||||
error,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let err = truncate(error, 200);
|
||||
[
|
||||
format!("🚨 <b>Thumbnails failed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("🏷 <b>Type:</b> {job_type}"),
|
||||
String::new(),
|
||||
format!("💬 <code>{err}</code>"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::ConversionCompleted {
|
||||
library_name,
|
||||
book_title,
|
||||
..
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("Unknown");
|
||||
let title = book_title.as_deref().unwrap_or("Unknown");
|
||||
[
|
||||
format!("✅ <b>CBR → CBZ conversion completed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("📖 <b>Book:</b> {title}"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::ConversionFailed {
|
||||
library_name,
|
||||
book_title,
|
||||
error,
|
||||
..
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("Unknown");
|
||||
let title = book_title.as_deref().unwrap_or("Unknown");
|
||||
let err = truncate(error, 200);
|
||||
[
|
||||
format!("🚨 <b>CBR → CBZ conversion failed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("📖 <b>Book:</b> {title}"),
|
||||
String::new(),
|
||||
format!("💬 <code>{err}</code>"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::MetadataApproved {
|
||||
series_name,
|
||||
provider,
|
||||
..
|
||||
} => {
|
||||
[
|
||||
format!("✅ <b>Metadata linked</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📚 <b>Series:</b> {series_name}"),
|
||||
format!("🔗 <b>Provider:</b> {provider}"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::MetadataBatchCompleted {
|
||||
library_name,
|
||||
total_series,
|
||||
processed,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
[
|
||||
format!("✅ <b>Metadata batch completed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
format!("📊 <b>Processed:</b> {processed}/{total_series} series"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::MetadataBatchFailed {
|
||||
library_name,
|
||||
error,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let err = truncate(error, 200);
|
||||
[
|
||||
format!("🚨 <b>Metadata batch failed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
String::new(),
|
||||
format!("💬 <code>{err}</code>"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
NotificationEvent::MetadataRefreshCompleted {
|
||||
library_name,
|
||||
refreshed,
|
||||
unchanged,
|
||||
errors,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let mut lines = vec![
|
||||
format!("✅ <b>Metadata refresh completed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
String::new(),
|
||||
format!("📊 <b>Results</b>"),
|
||||
format!(" 🔄 Updated: <b>{refreshed}</b>"),
|
||||
format!(" ▪️ Unchanged: <b>{unchanged}</b>"),
|
||||
];
|
||||
if *errors > 0 {
|
||||
lines.push(format!(" ⚠️ Errors: <b>{errors}</b>"));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
NotificationEvent::MetadataRefreshFailed {
|
||||
library_name,
|
||||
error,
|
||||
} => {
|
||||
let lib = library_name.as_deref().unwrap_or("All libraries");
|
||||
let err = truncate(error, 200);
|
||||
[
|
||||
format!("🚨 <b>Metadata refresh failed</b>"),
|
||||
format!("━━━━━━━━━━━━━━━━━━━━"),
|
||||
format!("📂 <b>Library:</b> {lib}"),
|
||||
String::new(),
|
||||
format!("💬 <code>{err}</code>"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() > max {
|
||||
format!("{}…", &s[..max])
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_duration(secs: u64) -> String {
|
||||
if secs < 60 {
|
||||
format!("{secs}s")
|
||||
} else {
|
||||
let m = secs / 60;
|
||||
let s = secs % 60;
|
||||
format!("{m}m{s}s")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry point — fire & forget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns whether this event type is enabled in the config.
|
||||
fn is_event_enabled(config: &TelegramConfig, event: &NotificationEvent) -> bool {
|
||||
match event {
|
||||
NotificationEvent::ScanCompleted { .. } => config.events.scan_completed,
|
||||
NotificationEvent::ScanFailed { .. } => config.events.scan_failed,
|
||||
NotificationEvent::ScanCancelled { .. } => config.events.scan_cancelled,
|
||||
NotificationEvent::ThumbnailCompleted { .. } => config.events.thumbnail_completed,
|
||||
NotificationEvent::ThumbnailFailed { .. } => config.events.thumbnail_failed,
|
||||
NotificationEvent::ConversionCompleted { .. } => config.events.conversion_completed,
|
||||
NotificationEvent::ConversionFailed { .. } => config.events.conversion_failed,
|
||||
NotificationEvent::MetadataApproved { .. } => config.events.metadata_approved,
|
||||
NotificationEvent::MetadataBatchCompleted { .. } => config.events.metadata_batch_completed,
|
||||
NotificationEvent::MetadataBatchFailed { .. } => config.events.metadata_batch_failed,
|
||||
NotificationEvent::MetadataRefreshCompleted { .. } => config.events.metadata_refresh_completed,
|
||||
NotificationEvent::MetadataRefreshFailed { .. } => config.events.metadata_refresh_failed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract thumbnail path from event if present and file exists on disk.
|
||||
fn event_thumbnail(event: &NotificationEvent) -> Option<&str> {
|
||||
let path = match event {
|
||||
NotificationEvent::ConversionCompleted { thumbnail_path, .. } => thumbnail_path.as_deref(),
|
||||
NotificationEvent::ConversionFailed { thumbnail_path, .. } => thumbnail_path.as_deref(),
|
||||
NotificationEvent::MetadataApproved { thumbnail_path, .. } => thumbnail_path.as_deref(),
|
||||
_ => None,
|
||||
};
|
||||
path.filter(|p| std::path::Path::new(p).exists())
|
||||
}
|
||||
|
||||
/// Load config + format + send in a spawned task. Errors are only logged.
|
||||
pub fn notify(pool: PgPool, event: NotificationEvent) {
|
||||
tokio::spawn(async move {
|
||||
let config = match load_telegram_config(&pool).await {
|
||||
Some(c) => c,
|
||||
None => return, // disabled or not configured
|
||||
};
|
||||
|
||||
if !is_event_enabled(&config, &event) {
|
||||
return;
|
||||
}
|
||||
|
||||
let text = format_event(&event);
|
||||
let sent = if let Some(photo) = event_thumbnail(&event) {
|
||||
match send_telegram_photo(&config, &text, photo).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
warn!("[TELEGRAM] Photo send failed, falling back to text: {e}");
|
||||
send_telegram(&config, &text).await
|
||||
}
|
||||
}
|
||||
} else {
|
||||
send_telegram(&config, &text).await
|
||||
};
|
||||
|
||||
match sent {
|
||||
Ok(()) => info!("[TELEGRAM] Notification sent"),
|
||||
Err(e) => warn!("[TELEGRAM] Failed to send notification: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub enum BookFormat {
|
||||
Cbz,
|
||||
Cbr,
|
||||
Pdf,
|
||||
Epub,
|
||||
}
|
||||
|
||||
impl BookFormat {
|
||||
@@ -17,6 +18,7 @@ impl BookFormat {
|
||||
Self::Cbz => "cbz",
|
||||
Self::Cbr => "cbr",
|
||||
Self::Pdf => "pdf",
|
||||
Self::Epub => "epub",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +37,7 @@ pub fn detect_format(path: &Path) -> Option<BookFormat> {
|
||||
"cbz" => Some(BookFormat::Cbz),
|
||||
"cbr" => Some(BookFormat::Cbr),
|
||||
"pdf" => Some(BookFormat::Pdf),
|
||||
"epub" => Some(BookFormat::Epub),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -144,6 +147,7 @@ pub fn parse_metadata(
|
||||
BookFormat::Cbz => parse_cbz_page_count(path).ok(),
|
||||
BookFormat::Cbr => parse_cbr_page_count(path).ok(),
|
||||
BookFormat::Pdf => parse_pdf_page_count(path).ok(),
|
||||
BookFormat::Epub => parse_epub_page_count(path).ok(),
|
||||
};
|
||||
|
||||
Ok(meta)
|
||||
@@ -156,6 +160,7 @@ pub fn analyze_book(path: &Path, format: BookFormat, pdf_render_scale: u32) -> R
|
||||
BookFormat::Cbz => analyze_cbz(path, true),
|
||||
BookFormat::Cbr => analyze_cbr(path, true),
|
||||
BookFormat::Pdf => analyze_pdf(path, pdf_render_scale),
|
||||
BookFormat::Epub => analyze_epub(path),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,6 +535,7 @@ pub fn list_archive_images(path: &Path, format: BookFormat) -> Result<Vec<String
|
||||
BookFormat::Cbz => list_cbz_images(path),
|
||||
BookFormat::Cbr => list_cbr_images(path),
|
||||
BookFormat::Pdf => Err(anyhow::anyhow!("list_archive_images not applicable for PDF")),
|
||||
BookFormat::Epub => get_epub_image_index(path),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,6 +635,7 @@ pub fn extract_image_by_name(path: &Path, format: BookFormat, image_name: &str)
|
||||
BookFormat::Cbz => extract_cbz_by_name(path, image_name),
|
||||
BookFormat::Cbr => extract_cbr_by_name(path, image_name),
|
||||
BookFormat::Pdf => Err(anyhow::anyhow!("use extract_page for PDF")),
|
||||
BookFormat::Epub => extract_cbz_by_name(path, image_name),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,6 +728,7 @@ pub fn extract_page(path: &Path, format: BookFormat, page_number: u32, pdf_rende
|
||||
let width = if pdf_render_width == 0 { 1200 } else { pdf_render_width };
|
||||
render_pdf_page_n(path, page_number, width)
|
||||
}
|
||||
BookFormat::Epub => extract_epub_page(path, page_number),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -894,6 +902,340 @@ fn render_pdf_page_n(path: &Path, page_number: u32, width: u32) -> Result<Vec<u8
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// EPUB support — spine-aware image index with cache
|
||||
// ============================================================
|
||||
|
||||
/// Cache of ordered image paths per EPUB file. Avoids re-parsing OPF/XHTML on every page request.
|
||||
static EPUB_INDEX_CACHE: OnceLock<Mutex<HashMap<PathBuf, Vec<String>>>> = OnceLock::new();
|
||||
|
||||
fn epub_index_cache() -> &'static Mutex<HashMap<PathBuf, Vec<String>>> {
|
||||
EPUB_INDEX_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
// Pre-compiled regex patterns for EPUB XML parsing (compiled once on first use)
|
||||
static RE_EPUB_ROOTFILE: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static RE_EPUB_ITEM: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static RE_EPUB_ITEMREF: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static RE_EPUB_IMG_SRC: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static RE_EPUB_SVG_HREF: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static RE_EPUB_ATTR_ID: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static RE_EPUB_ATTR_HREF: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static RE_EPUB_ATTR_MEDIA: OnceLock<regex::Regex> = OnceLock::new();
|
||||
|
||||
struct EpubManifestItem {
|
||||
href: String,
|
||||
media_type: String,
|
||||
}
|
||||
|
||||
/// Build the ordered list of image paths for an EPUB file.
|
||||
/// Walks the OPF spine to determine reading order, parses XHTML/SVG pages
|
||||
/// for image references, and falls back to CBZ-style listing if no
|
||||
/// images are found through the spine.
|
||||
fn build_epub_image_index(path: &Path) -> Result<Vec<String>> {
|
||||
let file = std::fs::File::open(path)
|
||||
.with_context(|| format!("cannot open epub: {}", path.display()))?;
|
||||
let mut archive = zip::ZipArchive::new(file)
|
||||
.with_context(|| format!("invalid epub zip: {}", path.display()))?;
|
||||
|
||||
// 1. Find OPF path from META-INF/container.xml
|
||||
let opf_path = {
|
||||
let mut entry = archive
|
||||
.by_name("META-INF/container.xml")
|
||||
.context("missing META-INF/container.xml — not a valid EPUB")?;
|
||||
let mut buf = Vec::new();
|
||||
entry.read_to_end(&mut buf)?;
|
||||
let xml = String::from_utf8_lossy(&buf);
|
||||
let re = RE_EPUB_ROOTFILE.get_or_init(|| {
|
||||
regex::Regex::new(r#"<(?:\w+:)?rootfile[^>]+full-path="([^"]+)""#).unwrap()
|
||||
});
|
||||
re.captures(&xml)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| decode_xml_entities(m.as_str()))
|
||||
.context("no rootfile found in container.xml")?
|
||||
};
|
||||
|
||||
let opf_dir = std::path::Path::new(&opf_path)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 2. Parse OPF manifest + spine
|
||||
let (manifest, spine_idrefs) = {
|
||||
let mut entry = archive
|
||||
.by_name(&opf_path)
|
||||
.with_context(|| format!("missing OPF file: {}", opf_path))?;
|
||||
let mut buf = Vec::new();
|
||||
entry.read_to_end(&mut buf)?;
|
||||
let xml = String::from_utf8_lossy(&buf);
|
||||
parse_epub_opf(&xml, &opf_dir)?
|
||||
};
|
||||
|
||||
// 3. Walk spine entries to build ordered image list
|
||||
let re_img = RE_EPUB_IMG_SRC.get_or_init(|| {
|
||||
regex::Regex::new(r#"(?i)<img\s[^>]*src=["']([^"']+)["']"#).unwrap()
|
||||
});
|
||||
let re_svg = RE_EPUB_SVG_HREF.get_or_init(|| {
|
||||
regex::Regex::new(r#"(?i)<image\s[^>]*(?:xlink:)?href=["']([^"']+)["']"#).unwrap()
|
||||
});
|
||||
|
||||
let mut images: Vec<String> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for idref in &spine_idrefs {
|
||||
let item = match manifest.get(idref.as_str()) {
|
||||
Some(item) => item,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Direct raster image in spine (rare but possible)
|
||||
if item.media_type.starts_with("image/") && !item.media_type.contains("svg") {
|
||||
if seen.insert(item.href.clone()) {
|
||||
images.push(item.href.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read XHTML/SVG content — entry is dropped at end of match arm, releasing archive borrow
|
||||
let content = match archive.by_name(&item.href) {
|
||||
Ok(mut entry) => {
|
||||
let mut buf = Vec::new();
|
||||
match entry.read_to_end(&mut buf) {
|
||||
Ok(_) => String::from_utf8_lossy(&buf).to_string(),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let content_dir = std::path::Path::new(&item.href)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Extract <img src="..."> and <image [xlink:]href="...">
|
||||
for re in [re_img, re_svg] {
|
||||
for cap in re.captures_iter(&content) {
|
||||
if let Some(src) = cap.get(1) {
|
||||
let src_str = src.as_str();
|
||||
if src_str.starts_with("data:") {
|
||||
continue;
|
||||
}
|
||||
let decoded = decode_xml_entities(&percent_decode_epub(src_str));
|
||||
let resolved = resolve_epub_path(&content_dir, &decoded);
|
||||
if seen.insert(resolved.clone()) {
|
||||
images.push(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback: no images from spine → list all images in ZIP (CBZ-style)
|
||||
if images.is_empty() {
|
||||
for i in 0..archive.len() {
|
||||
if let Ok(entry) = archive.by_index(i) {
|
||||
let name = entry.name().to_string();
|
||||
if is_image_name(&name.to_ascii_lowercase()) && seen.insert(name.clone()) {
|
||||
images.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
images.sort_by(|a, b| natord::compare(a, b));
|
||||
}
|
||||
|
||||
if images.is_empty() {
|
||||
return Err(anyhow::anyhow!("no images found in epub: {}", path.display()));
|
||||
}
|
||||
|
||||
Ok(images)
|
||||
}
|
||||
|
||||
fn parse_epub_opf(
|
||||
xml: &str,
|
||||
opf_dir: &str,
|
||||
) -> Result<(HashMap<String, EpubManifestItem>, Vec<String>)> {
|
||||
let re_item = RE_EPUB_ITEM.get_or_init(|| {
|
||||
regex::Regex::new(r#"(?s)<(?:\w+:)?item\s([^>]+?)/?>"#).unwrap()
|
||||
});
|
||||
let re_itemref = RE_EPUB_ITEMREF.get_or_init(|| {
|
||||
regex::Regex::new(r#"<(?:\w+:)?itemref\s[^>]*idref="([^"]+)""#).unwrap()
|
||||
});
|
||||
let re_id = RE_EPUB_ATTR_ID.get_or_init(|| {
|
||||
regex::Regex::new(r#"(?:^|\s)id="([^"]+)""#).unwrap()
|
||||
});
|
||||
let re_href = RE_EPUB_ATTR_HREF.get_or_init(|| {
|
||||
regex::Regex::new(r#"(?:^|\s)href="([^"]+)""#).unwrap()
|
||||
});
|
||||
let re_media = RE_EPUB_ATTR_MEDIA.get_or_init(|| {
|
||||
regex::Regex::new(r#"media-type="([^"]+)""#).unwrap()
|
||||
});
|
||||
|
||||
let mut manifest: HashMap<String, EpubManifestItem> = HashMap::new();
|
||||
for cap in re_item.captures_iter(xml) {
|
||||
if let Some(attrs) = cap.get(1) {
|
||||
let a = attrs.as_str();
|
||||
let id = re_id.captures(a).and_then(|c| c.get(1));
|
||||
let href = re_href.captures(a).and_then(|c| c.get(1));
|
||||
let media = re_media.captures(a).and_then(|c| c.get(1));
|
||||
|
||||
if let (Some(id), Some(href), Some(media)) = (id, href, media) {
|
||||
let decoded_href = decode_xml_entities(&percent_decode_epub(href.as_str()));
|
||||
let resolved = resolve_epub_path(opf_dir, &decoded_href);
|
||||
manifest.insert(
|
||||
id.as_str().to_string(),
|
||||
EpubManifestItem {
|
||||
href: resolved,
|
||||
media_type: media.as_str().to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let spine_idrefs: Vec<String> = re_itemref
|
||||
.captures_iter(xml)
|
||||
.filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
|
||||
.collect();
|
||||
|
||||
Ok((manifest, spine_idrefs))
|
||||
}
|
||||
|
||||
/// Get the cached image index for an EPUB, building it on first access.
|
||||
fn get_epub_image_index(path: &Path) -> Result<Vec<String>> {
|
||||
{
|
||||
let cache = epub_index_cache().lock().unwrap();
|
||||
if let Some(names) = cache.get(path) {
|
||||
return Ok(names.clone());
|
||||
}
|
||||
}
|
||||
let images = build_epub_image_index(path)?;
|
||||
{
|
||||
let mut cache = epub_index_cache().lock().unwrap();
|
||||
cache.insert(path.to_path_buf(), images.clone());
|
||||
}
|
||||
Ok(images)
|
||||
}
|
||||
|
||||
fn parse_epub_page_count(path: &Path) -> Result<i32> {
|
||||
let images = build_epub_image_index(path)?;
|
||||
Ok(images.len() as i32)
|
||||
}
|
||||
|
||||
fn analyze_epub(path: &Path) -> Result<(i32, Vec<u8>)> {
|
||||
let images = get_epub_image_index(path)?;
|
||||
let count = images.len() as i32;
|
||||
|
||||
let file = std::fs::File::open(path)
|
||||
.with_context(|| format!("cannot open epub: {}", path.display()))?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
|
||||
for img_path in &images {
|
||||
if let Ok(mut entry) = archive.by_name(img_path) {
|
||||
let mut buf = Vec::new();
|
||||
if entry.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
|
||||
return Ok((count, buf));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"no readable images in epub: {}",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_epub_page(path: &Path, page_number: u32) -> Result<Vec<u8>> {
|
||||
let images = get_epub_image_index(path)?;
|
||||
let index = page_number as usize - 1;
|
||||
let img_path = images
|
||||
.get(index)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"page {} out of range (total: {})",
|
||||
page_number,
|
||||
images.len()
|
||||
)
|
||||
})?;
|
||||
|
||||
let file = std::fs::File::open(path)
|
||||
.with_context(|| format!("cannot open epub: {}", path.display()))?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
let mut entry = archive
|
||||
.by_name(img_path)
|
||||
.with_context(|| format!("image '{}' not found in epub", img_path))?;
|
||||
let mut buf = Vec::new();
|
||||
entry.read_to_end(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
// --- EPUB path/encoding helpers ---
|
||||
|
||||
fn resolve_epub_path(base_dir: &str, href: &str) -> String {
|
||||
if let Some(stripped) = href.strip_prefix('/') {
|
||||
return normalize_epub_path(stripped);
|
||||
}
|
||||
if base_dir.is_empty() {
|
||||
return normalize_epub_path(href);
|
||||
}
|
||||
normalize_epub_path(&format!("{}/{}", base_dir, href))
|
||||
}
|
||||
|
||||
fn normalize_epub_path(path: &str) -> String {
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for part in path.split('/') {
|
||||
match part {
|
||||
".." => {
|
||||
parts.pop();
|
||||
}
|
||||
"." | "" => {}
|
||||
_ => parts.push(part),
|
||||
}
|
||||
}
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
fn percent_decode_epub(s: &str) -> String {
|
||||
if !s.contains('%') {
|
||||
return s.to_string();
|
||||
}
|
||||
let bytes = s.as_bytes();
|
||||
let mut result = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let (Some(h), Some(l)) = (epub_hex_val(bytes[i + 1]), epub_hex_val(bytes[i + 2])) {
|
||||
result.push(h * 16 + l);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&result).to_string()
|
||||
}
|
||||
|
||||
fn epub_hex_val(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_xml_entities(s: &str) -> String {
|
||||
if !s.contains('&') {
|
||||
return s.to_string();
|
||||
}
|
||||
s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
/// Convert a CBR file to CBZ in-place (same directory, same stem).
|
||||
///
|
||||
/// The conversion is safe: a `.cbz.tmp` file is written first, verified, then
|
||||
|
||||
341
docs/FEATURES.md
Normal file
341
docs/FEATURES.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# Stripstream Librarian — Features & Business Rules
|
||||
|
||||
## Libraries
|
||||
|
||||
### Multi-Library Management
|
||||
- Create and manage multiple independent libraries, each with its own root path
|
||||
- Enable/disable libraries individually
|
||||
- Delete a library cascades to all its books, jobs, and metadata
|
||||
|
||||
### Scanning & Indexing
|
||||
- **Incremental scan**: uses directory mtime tracking to skip unchanged directories
|
||||
- **Full rebuild**: force re-walk all directories, ignoring cached mtimes
|
||||
- **Rescan**: deep rescan to discover newly supported formats
|
||||
- **Two-phase pipeline**:
|
||||
- Phase 1 (Discovery): fast filename-based metadata extraction (no archive I/O)
|
||||
- Phase 2 (Analysis): extract page counts, first page image from archives
|
||||
|
||||
### Real-Time Monitoring
|
||||
- **Automatic periodic scanning**: configurable interval (default 5 seconds)
|
||||
- **Filesystem watcher**: real-time detection of file changes for instant indexing
|
||||
- Each can be toggled per library (`monitor_enabled`, `watcher_enabled`)
|
||||
|
||||
---
|
||||
|
||||
## Books
|
||||
|
||||
### Format Support
|
||||
- **CBZ** (ZIP-based comic archives)
|
||||
- **CBR** (RAR-based comic archives)
|
||||
- **PDF**
|
||||
- **EPUB**
|
||||
- Automatic format detection from file extension and magic bytes
|
||||
|
||||
### Metadata Extraction
|
||||
- **Title**: derived from filename or external metadata
|
||||
- **Series**: derived from directory structure (first directory level under library root)
|
||||
- **Volume**: extracted from filename with pattern detection:
|
||||
- `T##` (Tome) — most common for French comics
|
||||
- `Vol.##`, `Vol ##`, `Volume ##`
|
||||
- `###` (standalone number)
|
||||
- `-## ` (dash-separated)
|
||||
- **Author(s)**: single scalar and array support
|
||||
- **Page count**: extracted from archive analysis
|
||||
- **Language**, **kind** (ebook, comic, bd)
|
||||
|
||||
### Thumbnails
|
||||
- Generated from the first page of each archive
|
||||
- Output format configurable: WebP (default), JPEG, PNG
|
||||
- Configurable dimensions (default 300×400)
|
||||
- Lazy generation: created on first access if missing
|
||||
- Bulk operations: rebuild missing or regenerate all
|
||||
|
||||
### CBR to CBZ Conversion
|
||||
- Convert RAR archives to ZIP format
|
||||
- Tracked as background job with progress
|
||||
|
||||
---
|
||||
|
||||
## Series
|
||||
|
||||
### Automatic Aggregation
|
||||
- Series derived from directory structure during scanning
|
||||
- Books without series grouped as "unclassified"
|
||||
|
||||
### Series Metadata
|
||||
- Description, publisher, start year, status (`ongoing`, `ended`, `completed`, `on_hold`, `hiatus`)
|
||||
- Total volume count (from external providers)
|
||||
- Authors (aggregated from books or metadata)
|
||||
|
||||
### Filtering & Discovery
|
||||
- Filter by: series name (partial match), reading status, series status, metadata provider linkage
|
||||
- Sort by: name, reading status, book count
|
||||
- **Missing books detection**: identifies gaps in volume numbering within a series
|
||||
|
||||
---
|
||||
|
||||
## Reading Progress
|
||||
|
||||
### Per-Book Tracking
|
||||
- Three states: `unread` (default), `reading`, `read`
|
||||
- Current page tracking when status is `reading`
|
||||
- `last_read_at` timestamp auto-updated
|
||||
|
||||
### Series-Level Status
|
||||
- Calculated from book statuses:
|
||||
- All read → series `read`
|
||||
- None read → series `unread`
|
||||
- Mixed → series `reading`
|
||||
|
||||
### Bulk Operations
|
||||
- Mark entire series as read (updates all books)
|
||||
|
||||
---
|
||||
|
||||
## Search & Discovery
|
||||
|
||||
### Full-Text Search
|
||||
- PostgreSQL-based (`ILIKE` + `pg_trgm`)
|
||||
- Searches across: book titles, series names, authors (scalar and array fields), series metadata authors
|
||||
- Case-insensitive partial matching
|
||||
- Library-scoped filtering
|
||||
|
||||
### Results
|
||||
- Book hits: title, authors, series, volume, language, kind
|
||||
- Series hits: name, book count, read count, first book (for linking)
|
||||
- Processing time included in response
|
||||
|
||||
---
|
||||
|
||||
## Authors
|
||||
|
||||
- Unique author aggregation from books and series metadata
|
||||
- Per-author book and series count
|
||||
- Searchable by name (partial match)
|
||||
- Sortable by name or book count
|
||||
|
||||
---
|
||||
|
||||
## External Metadata
|
||||
|
||||
### Supported Providers
|
||||
| Provider | Focus |
|
||||
|----------|-------|
|
||||
| Google Books | General books (default fallback) |
|
||||
| ComicVine | Comics |
|
||||
| BedéThèque | Franco-Belgian comics |
|
||||
| AniList | Manga/anime |
|
||||
| Open Library | General books |
|
||||
|
||||
### Provider Configuration
|
||||
- Global default provider with library-level override
|
||||
- Fallback provider if primary is unavailable
|
||||
|
||||
### Matching Workflow
|
||||
1. **Search**: query a provider, get candidates with confidence scores
|
||||
2. **Match**: link a series to an external result (status `pending`)
|
||||
3. **Approve**: validate and sync metadata to series and books
|
||||
4. **Reject**: discard a match
|
||||
|
||||
### Batch Processing
|
||||
- Auto-match all series in a library via `metadata_batch` job
|
||||
- Configurable confidence threshold
|
||||
- Result statuses: `auto_matched`, `no_results`, `too_many_results`, `low_confidence`, `already_linked`
|
||||
|
||||
### Metadata Refresh
|
||||
- Update approved links with latest data from providers
|
||||
- Change tracking reports per series/book
|
||||
- Non-destructive: only updates when provider has new data
|
||||
|
||||
### Field Locking
|
||||
- Individual book fields can be locked to prevent external sync from overwriting manual edits
|
||||
|
||||
---
|
||||
|
||||
## External Integrations
|
||||
|
||||
### Komga Sync
|
||||
- Import reading progress from a Komga server
|
||||
- Matches local series/books by name
|
||||
- Detailed sync report: matched, already read, newly marked, unmatched
|
||||
|
||||
### Prowlarr (Indexer Search)
|
||||
- Search Prowlarr for missing volumes in a series
|
||||
- Volume pattern matching against release titles
|
||||
- Results: title, size, seeders/leechers, download URL, matched missing volumes
|
||||
|
||||
### qBittorrent
|
||||
- Add torrents directly from Prowlarr search results
|
||||
- Connection test endpoint
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
### Telegram
|
||||
- Real-time notifications via Telegram Bot API (`sendMessage` and `sendPhoto`)
|
||||
- Configuration: bot token, chat ID, enable/disable toggle
|
||||
- Test connection button in settings
|
||||
|
||||
### Granular Event Toggles
|
||||
12 individually configurable notification events grouped by category:
|
||||
|
||||
| Category | Events |
|
||||
|----------|--------|
|
||||
| Scans | `scan_completed`, `scan_failed`, `scan_cancelled` |
|
||||
| Thumbnails | `thumbnail_completed`, `thumbnail_failed`, `thumbnail_cancelled` |
|
||||
| Conversion | `conversion_completed`, `conversion_failed`, `conversion_cancelled` |
|
||||
| Metadata | `metadata_approved`, `metadata_batch_completed`, `metadata_refresh_completed` |
|
||||
|
||||
### Thumbnail Images in Notifications
|
||||
- Book cover thumbnails attached to applicable notifications (conversion, metadata approval)
|
||||
- Uses `sendPhoto` multipart upload with fallback to text-only `sendMessage`
|
||||
|
||||
### Implementation
|
||||
- Shared `crates/notifications` crate used by both API and indexer
|
||||
- Fire-and-forget: notification failures are logged but never block the main operation
|
||||
- Messages formatted in HTML with event-specific icons
|
||||
|
||||
---
|
||||
|
||||
## Page Rendering & Caching
|
||||
|
||||
### Page Extraction
|
||||
- Render any page from supported archive formats
|
||||
- 1-indexed page numbers
|
||||
|
||||
### Image Processing
|
||||
- Output formats: original, JPEG, PNG, WebP
|
||||
- Quality parameter (1–100)
|
||||
- Max width parameter (1–2160 px)
|
||||
- Configurable resampling filter: lanczos3, nearest, triangle/bilinear
|
||||
- Concurrent render limit (default 8) with semaphore
|
||||
|
||||
### Caching
|
||||
- **LRU in-memory cache**: 512 entries
|
||||
- **Disk cache**: SHA256-keyed, two-level directory structure
|
||||
- Cache key = hash(path + page + format + quality + width)
|
||||
- Configurable cache directory and max size
|
||||
- Manual cache clear via settings
|
||||
|
||||
---
|
||||
|
||||
## Background Jobs
|
||||
|
||||
### Job Types
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `rebuild` | Incremental scan |
|
||||
| `full_rebuild` | Full filesystem rescan |
|
||||
| `rescan` | Deep rescan for new formats |
|
||||
| `thumbnail_rebuild` | Generate missing thumbnails |
|
||||
| `thumbnail_regenerate` | Clear and regenerate all thumbnails |
|
||||
| `cbr_to_cbz` | Convert RAR to ZIP |
|
||||
| `metadata_batch` | Auto-match series to metadata |
|
||||
| `metadata_refresh` | Update approved metadata links |
|
||||
|
||||
### Job Lifecycle
|
||||
- Status flow: `pending` → `running` → `success` | `failed` | `cancelled`
|
||||
- Intermediate statuses: `extracting_pages`, `generating_thumbnails`
|
||||
- Real-time progress via **Server-Sent Events** (SSE)
|
||||
- Per-file error tracking (non-fatal: job continues on errors)
|
||||
- Cancellation support for pending/running jobs
|
||||
|
||||
### Progress Tracking
|
||||
- Percentage (0–100), current file, processed/total counts
|
||||
- Timing: started_at, finished_at, phase2_started_at
|
||||
- Stats JSON blob with job-specific metrics
|
||||
|
||||
---
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
### Token System
|
||||
- **Bootstrap token**: admin token via `API_BOOTSTRAP_TOKEN` env var
|
||||
- **API tokens**: create, list, revoke with scopes
|
||||
- Token format: `stl_{prefix}_{secret}` with Argon2 hashing
|
||||
- Expiration dates, last usage tracking, revocation
|
||||
|
||||
### Access Control
|
||||
- **Two scopes**: `admin` (full access) and `read` (read-only)
|
||||
- Route-level middleware enforcement
|
||||
- Rate limiting: configurable sliding window (default 120 req/s)
|
||||
|
||||
---
|
||||
|
||||
## Backoffice (Web UI)
|
||||
|
||||
### Dashboard
|
||||
- Statistics cards: books, series, authors, libraries, pages, total size
|
||||
- Interactive charts (recharts): donut, area, stacked bar, horizontal bar
|
||||
- Reading status breakdown, format distribution, library distribution
|
||||
- Currently reading section with progress bars
|
||||
- Recently read section with cover thumbnails
|
||||
- Reading activity over time (area chart)
|
||||
- Books added over time (area chart)
|
||||
- Per-library stacked reading progress
|
||||
- Top series by book count
|
||||
- Metadata coverage and provider breakdown
|
||||
|
||||
### Pages
|
||||
- **Libraries**: list, create, delete, configure monitoring and metadata provider
|
||||
- **Books**: global list with filtering/sorting, detail view with metadata and page rendering
|
||||
- **Series**: global list, per-library view, detail with metadata management
|
||||
- **Authors**: list with book/series counts, detail with author's books
|
||||
- **Jobs**: history, live progress via SSE, error details
|
||||
- **Tokens**: create, list, revoke API tokens
|
||||
- **Settings**: image processing, cache, thumbnails, external services (Prowlarr, qBittorrent), notifications (Telegram)
|
||||
|
||||
### Interactive Features
|
||||
- Real-time search with suggestions
|
||||
- Metadata search and matching modals
|
||||
- Prowlarr search modal for missing volumes
|
||||
- Folder browser/picker for library paths
|
||||
- Book/series editing forms
|
||||
- Quick reading status toggles
|
||||
- CBR to CBZ conversion trigger
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### Documentation
|
||||
- OpenAPI/Swagger UI available at `/swagger-ui`
|
||||
- Health check (`/health`), readiness (`/ready`), Prometheus metrics (`/metrics`)
|
||||
|
||||
### Public Endpoints (no auth)
|
||||
- `GET /health`, `GET /ready`, `GET /metrics`, `GET /swagger-ui`
|
||||
|
||||
### Read Endpoints (read scope)
|
||||
- Libraries, books, series, authors listing and detail
|
||||
- Book pages and thumbnails
|
||||
- Reading progress get/update
|
||||
- Full-text search, collection statistics
|
||||
|
||||
### Admin Endpoints (admin scope)
|
||||
- Library CRUD and configuration
|
||||
- Book metadata editing, CBR conversion
|
||||
- Series metadata editing
|
||||
- Indexing job management (trigger, cancel, stream)
|
||||
- API token management
|
||||
- Metadata operations (search, match, approve, reject, batch, refresh)
|
||||
- External integrations (Prowlarr, qBittorrent, Komga)
|
||||
- Application settings and cache management
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
### Key Design Decisions
|
||||
- PostgreSQL with `pg_trgm` for full-text search (no external search engine)
|
||||
- All deletions cascade from libraries
|
||||
- Unique constraints: file paths, token prefixes, metadata links (library + series + provider)
|
||||
- Directory mtime caching for incremental scan optimization
|
||||
- Connection pool: 10 (API), 20 (indexer)
|
||||
|
||||
### Archive Resilience
|
||||
- CBZ: fallback streaming reader if central directory corrupted
|
||||
- CBR: RAR extraction via system `unar`, fallback to CBZ parsing
|
||||
- PDF: `pdfinfo` for page count, `pdftoppm` for rendering
|
||||
- EPUB: ZIP-based extraction
|
||||
- FD exhaustion detection: aborts if too many consecutive IO errors
|
||||
10
infra/migrations/0046_add_epub_format.sql
Normal file
10
infra/migrations/0046_add_epub_format.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Add EPUB to allowed format values in book_files and books tables.
|
||||
-- PostgreSQL CHECK constraints are dropped+recreated (no ALTER CONSTRAINT).
|
||||
|
||||
-- book_files.format
|
||||
ALTER TABLE book_files DROP CONSTRAINT IF EXISTS book_files_format_check;
|
||||
ALTER TABLE book_files ADD CONSTRAINT book_files_format_check CHECK (format IN ('pdf', 'cbz', 'cbr', 'epub'));
|
||||
|
||||
-- books.format (denormalized column added in 0020)
|
||||
ALTER TABLE books DROP CONSTRAINT IF EXISTS books_format_check;
|
||||
ALTER TABLE books ADD CONSTRAINT books_format_check CHECK (format IN ('pdf', 'cbz', 'cbr', 'epub'));
|
||||
7
infra/migrations/0047_add_rescan_job_type.sql
Normal file
7
infra/migrations/0047_add_rescan_job_type.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Add rescan job type: clears directory mtimes to force re-walking all directories
|
||||
-- while preserving existing data (unlike full_rebuild which deletes everything).
|
||||
-- Useful for discovering newly supported formats (e.g. EPUB) without losing metadata.
|
||||
ALTER TABLE index_jobs
|
||||
DROP CONSTRAINT IF EXISTS index_jobs_type_check,
|
||||
ADD CONSTRAINT index_jobs_type_check
|
||||
CHECK (type IN ('scan', 'rebuild', 'full_rebuild', 'rescan', 'thumbnail_rebuild', 'thumbnail_regenerate', 'cbr_to_cbz', 'metadata_batch', 'metadata_refresh'));
|
||||
3
infra/migrations/0048_add_telegram_settings.sql
Normal file
3
infra/migrations/0048_add_telegram_settings.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
INSERT INTO app_settings (key, value) VALUES
|
||||
('telegram', '{"bot_token": "", "chat_id": "", "enabled": false, "events": {"job_completed": true, "job_failed": true, "job_cancelled": true, "metadata_approved": true}}')
|
||||
ON CONFLICT DO NOTHING;
|
||||
8
infra/migrations/0049_update_telegram_events.sql
Normal file
8
infra/migrations/0049_update_telegram_events.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Update telegram events from 4 generic toggles to 12 granular toggles
|
||||
UPDATE app_settings
|
||||
SET value = jsonb_set(
|
||||
value,
|
||||
'{events}',
|
||||
'{"scan_completed": true, "scan_failed": true, "scan_cancelled": true, "thumbnail_completed": true, "thumbnail_failed": true, "conversion_completed": true, "conversion_failed": true, "metadata_approved": true, "metadata_batch_completed": true, "metadata_batch_failed": true, "metadata_refresh_completed": true, "metadata_refresh_failed": true}'::jsonb
|
||||
)
|
||||
WHERE key = 'telegram';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user