Compare commits
44 Commits
b9e54cbfd8
...
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 |
@@ -13,6 +13,12 @@
|
|||||||
# Use this token for the first API calls before creating proper API tokens
|
# Use this token for the first API calls before creating proper API tokens
|
||||||
API_BOOTSTRAP_TOKEN=change-me-in-production
|
API_BOOTSTRAP_TOKEN=change-me-in-production
|
||||||
|
|
||||||
|
# Backoffice admin credentials (required)
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=change-me-in-production
|
||||||
|
# Secret for signing session JWTs (min 32 chars, required)
|
||||||
|
SESSION_SECRET=change-me-in-production-use-32-chars-min
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Service Configuration
|
# Service Configuration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
25
Cargo.lock
generated
25
Cargo.lock
generated
@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "1.19.1"
|
version = "2.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -76,6 +76,7 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"jpeg-decoder",
|
"jpeg-decoder",
|
||||||
"lru",
|
"lru",
|
||||||
|
"notifications",
|
||||||
"parsers",
|
"parsers",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -1232,7 +1233,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexer"
|
name = "indexer"
|
||||||
version = "1.19.1"
|
version = "2.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1240,6 +1241,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"image",
|
"image",
|
||||||
"jpeg-decoder",
|
"jpeg-decoder",
|
||||||
|
"notifications",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
"parsers",
|
"parsers",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -1663,6 +1665,19 @@ dependencies = [
|
|||||||
"nom",
|
"nom",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notifications"
|
||||||
|
version = "2.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -1771,7 +1786,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parsers"
|
name = "parsers"
|
||||||
version = "1.19.1"
|
version = "2.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -2270,6 +2285,7 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -2278,6 +2294,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -2906,7 +2923,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stripstream-core"
|
name = "stripstream-core"
|
||||||
version = "1.19.1"
|
version = "2.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ members = [
|
|||||||
"apps/api",
|
"apps/api",
|
||||||
"apps/indexer",
|
"apps/indexer",
|
||||||
"crates/core",
|
"crates/core",
|
||||||
|
"crates/notifications",
|
||||||
"crates/parsers",
|
"crates/parsers",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "1.19.1"
|
version = "2.0.0"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -22,7 +23,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
|
|||||||
jpeg-decoder = "0.3"
|
jpeg-decoder = "0.3"
|
||||||
lru = "0.12"
|
lru = "0.12"
|
||||||
rayon = "1.10"
|
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"
|
rand = "0.8"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
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
|
## Features
|
||||||
|
|
||||||
### Libraries Management
|
> For the full feature list, business rules, and API details, see [docs/FEATURES.md](docs/FEATURES.md).
|
||||||
- Create and manage multiple libraries
|
|
||||||
- Configure automatic scanning schedules (hourly, daily, weekly)
|
|
||||||
- Real-time file watcher for instant indexing
|
|
||||||
- Full and incremental rebuild options
|
|
||||||
|
|
||||||
### Books Management
|
### Libraries
|
||||||
- Support for CBZ, CBR, and PDF formats
|
- Multi-library management with per-library configuration
|
||||||
- Automatic metadata extraction
|
- Incremental and full scanning, real-time filesystem watcher
|
||||||
- Series and volume detection
|
- Per-library metadata provider selection (Google Books, ComicVine, BedéThèque, AniList, Open Library)
|
||||||
- Full-text search powered by PostgreSQL
|
|
||||||
|
|
||||||
### Jobs Monitoring
|
### Books & Series
|
||||||
- Real-time job progress tracking
|
- **Formats**: CBZ, CBR, PDF, EPUB
|
||||||
- Detailed statistics (scanned, indexed, removed, errors)
|
- Automatic metadata extraction (title, series, volume, authors, page count) from filenames and directory structure
|
||||||
- Job history and logs
|
- Series aggregation with missing volume detection
|
||||||
- Cancel pending jobs
|
- Thumbnail generation (WebP/JPEG/PNG) with lazy generation and bulk rebuild
|
||||||
|
- CBR → CBZ conversion
|
||||||
|
|
||||||
### Search
|
### Reading Progress
|
||||||
- Full-text search across titles, authors, and series
|
- Per-book tracking: unread / reading / read with current page
|
||||||
- Library filtering
|
- Series-level aggregated reading status
|
||||||
- Real-time suggestions
|
- 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
|
## Environment Variables
|
||||||
|
|
||||||
@@ -249,4 +287,4 @@ volumes:
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[Your License Here]
|
This project is licensed under the [MIT License](LICENSE).
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ futures = "0.3"
|
|||||||
image.workspace = true
|
image.workspace = true
|
||||||
jpeg-decoder.workspace = true
|
jpeg-decoder.workspace = true
|
||||||
lru.workspace = true
|
lru.workspace = true
|
||||||
|
notifications = { path = "../../crates/notifications" }
|
||||||
stripstream-core = { path = "../../crates/core" }
|
stripstream-core = { path = "../../crates/core" }
|
||||||
parsers = { path = "../../crates/parsers" }
|
parsers = { path = "../../crates/parsers" }
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ COPY Cargo.toml ./
|
|||||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||||
COPY crates/core/Cargo.toml crates/core/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
|
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/api/src/main.rs && \
|
||||||
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
||||||
echo "" > apps/indexer/src/lib.rs && \
|
echo "" > apps/indexer/src/lib.rs && \
|
||||||
echo "" > crates/core/src/lib.rs && \
|
echo "" > crates/core/src/lib.rs && \
|
||||||
|
echo "" > crates/notifications/src/lib.rs && \
|
||||||
echo "" > crates/parsers/src/lib.rs
|
echo "" > crates/parsers/src/lib.rs
|
||||||
|
|
||||||
# Build dependencies only (cached as long as Cargo.toml files don't change)
|
# 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/api/src apps/api/src
|
||||||
COPY apps/indexer/src apps/indexer/src
|
COPY apps/indexer/src apps/indexer/src
|
||||||
COPY crates/core/src crates/core/src
|
COPY crates/core/src crates/core/src
|
||||||
|
COPY crates/notifications/src crates/notifications/src
|
||||||
COPY crates/parsers/src crates/parsers/src
|
COPY crates/parsers/src crates/parsers/src
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,target=/app/target \
|
--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 && \
|
cargo build --release -p api && \
|
||||||
cp /app/target/release/api /usr/local/bin/api
|
cp /app/target/release/api /usr/local/bin/api
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ use sqlx::Row;
|
|||||||
|
|
||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AuthUser {
|
||||||
|
pub user_id: uuid::Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum Scope {
|
pub enum Scope {
|
||||||
Admin,
|
Admin,
|
||||||
Read,
|
Read { user_id: uuid::Uuid },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn require_admin(
|
pub async fn require_admin(
|
||||||
@@ -40,6 +45,20 @@ pub async fn require_read(
|
|||||||
let token = bearer_token(&req).ok_or_else(|| ApiError::unauthorized("missing bearer token"))?;
|
let token = bearer_token(&req).ok_or_else(|| ApiError::unauthorized("missing bearer token"))?;
|
||||||
let scope = authenticate(&state, token).await?;
|
let scope = authenticate(&state, token).await?;
|
||||||
|
|
||||||
|
if let Scope::Read { user_id } = &scope {
|
||||||
|
req.extensions_mut().insert(AuthUser { user_id: *user_id });
|
||||||
|
} else if matches!(scope, Scope::Admin) {
|
||||||
|
// Admin peut s'impersonifier via le header X-As-User
|
||||||
|
if let Some(as_user_id) = req
|
||||||
|
.headers()
|
||||||
|
.get("X-As-User")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| uuid::Uuid::parse_str(v).ok())
|
||||||
|
{
|
||||||
|
req.extensions_mut().insert(AuthUser { user_id: as_user_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
req.extensions_mut().insert(scope);
|
req.extensions_mut().insert(scope);
|
||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
||||||
}
|
}
|
||||||
@@ -60,8 +79,7 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
|
|||||||
|
|
||||||
let maybe_row = sqlx::query(
|
let maybe_row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, token_hash, scope
|
SELECT id, token_hash, scope, user_id FROM api_tokens
|
||||||
FROM api_tokens
|
|
||||||
WHERE prefix = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())
|
WHERE prefix = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -88,7 +106,12 @@ async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError>
|
|||||||
let scope: String = row.try_get("scope").map_err(|_| ApiError::unauthorized("invalid token"))?;
|
let scope: String = row.try_get("scope").map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||||
match scope.as_str() {
|
match scope.as_str() {
|
||||||
"admin" => Ok(Scope::Admin),
|
"admin" => Ok(Scope::Admin),
|
||||||
"read" => Ok(Scope::Read),
|
"read" => {
|
||||||
|
let user_id: uuid::Uuid = row
|
||||||
|
.try_get("user_id")
|
||||||
|
.map_err(|_| ApiError::unauthorized("read token missing user_id"))?;
|
||||||
|
Ok(Scope::Read { user_id })
|
||||||
|
}
|
||||||
_ => Err(ApiError::unauthorized("invalid token scope")),
|
_ => Err(ApiError::unauthorized("invalid token scope")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ pub async fn list_authors(
|
|||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.map(|s| format!("%{s}%"));
|
.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!(
|
let sql = format!(
|
||||||
r#"
|
r#"
|
||||||
WITH all_authors AS (
|
WITH all_authors AS (
|
||||||
@@ -79,18 +79,21 @@ pub async fn list_authors(
|
|||||||
)
|
)
|
||||||
) AS name
|
) AS name
|
||||||
FROM books
|
FROM books
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT UNNEST(authors) AS name
|
||||||
|
FROM series_metadata
|
||||||
|
WHERE authors != '{{}}'
|
||||||
),
|
),
|
||||||
filtered AS (
|
filtered AS (
|
||||||
SELECT name FROM all_authors
|
SELECT name FROM all_authors
|
||||||
WHERE ($1::text IS NULL OR name ILIKE $1)
|
WHERE ($1::text IS NULL OR name ILIKE $1)
|
||||||
),
|
),
|
||||||
counted AS (
|
book_counts AS (
|
||||||
SELECT
|
SELECT
|
||||||
f.name,
|
f.name AS author_name,
|
||||||
COUNT(DISTINCT b.id) AS book_count,
|
COUNT(DISTINCT b.id) AS book_count
|
||||||
COUNT(DISTINCT NULLIF(b.series, '')) AS series_count
|
|
||||||
FROM filtered f
|
FROM filtered f
|
||||||
JOIN books b ON (
|
LEFT JOIN books b ON (
|
||||||
f.name = ANY(
|
f.name = ANY(
|
||||||
COALESCE(
|
COALESCE(
|
||||||
NULLIF(b.authors, '{{}}'),
|
NULLIF(b.authors, '{{}}'),
|
||||||
@@ -99,9 +102,24 @@ pub async fn list_authors(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
GROUP BY f.name
|
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 != '{{}}'
|
||||||
|
)
|
||||||
|
GROUP BY f.name
|
||||||
)
|
)
|
||||||
SELECT name, book_count, series_count
|
SELECT
|
||||||
FROM counted
|
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}
|
ORDER BY {order_clause}
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#
|
"#
|
||||||
@@ -116,6 +134,10 @@ pub async fn list_authors(
|
|||||||
)
|
)
|
||||||
) AS name
|
) AS name
|
||||||
FROM books
|
FROM books
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT UNNEST(authors) AS name
|
||||||
|
FROM series_metadata
|
||||||
|
WHERE authors != '{}'
|
||||||
)
|
)
|
||||||
SELECT COUNT(*) AS total
|
SELECT COUNT(*) AS total
|
||||||
FROM all_authors
|
FROM all_authors
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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 url: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub user_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -45,6 +47,8 @@ pub struct KomgaSyncResponse {
|
|||||||
#[schema(value_type = String)]
|
#[schema(value_type = String)]
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub komga_url: String,
|
pub komga_url: String,
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
pub total_komga_read: i64,
|
pub total_komga_read: i64,
|
||||||
pub matched: i64,
|
pub matched: i64,
|
||||||
pub already_read: i64,
|
pub already_read: i64,
|
||||||
@@ -61,6 +65,8 @@ pub struct KomgaSyncReportSummary {
|
|||||||
#[schema(value_type = String)]
|
#[schema(value_type = String)]
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub komga_url: String,
|
pub komga_url: String,
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
pub total_komga_read: i64,
|
pub total_komga_read: i64,
|
||||||
pub matched: i64,
|
pub matched: i64,
|
||||||
pub already_read: i64,
|
pub already_read: i64,
|
||||||
@@ -215,11 +221,12 @@ pub async fn sync_komga_read_books(
|
|||||||
let mut already_read_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
|
let mut already_read_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
|
||||||
|
|
||||||
if !matched_ids.is_empty() {
|
if !matched_ids.is_empty() {
|
||||||
// Get already-read book IDs
|
// Get already-read book IDs for this user
|
||||||
let ar_rows = sqlx::query(
|
let ar_rows = sqlx::query(
|
||||||
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND status = 'read'",
|
"SELECT book_id FROM book_reading_progress WHERE book_id = ANY($1) AND user_id = $2 AND status = 'read'",
|
||||||
)
|
)
|
||||||
.bind(&matched_ids)
|
.bind(&matched_ids)
|
||||||
|
.bind(body.user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -228,12 +235,12 @@ pub async fn sync_komga_read_books(
|
|||||||
}
|
}
|
||||||
already_read = already_read_ids.len() as i64;
|
already_read = already_read_ids.len() as i64;
|
||||||
|
|
||||||
// Bulk upsert all matched books as read
|
// Bulk upsert all matched books as read for this user
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||||
SELECT unnest($1::uuid[]), 'read', NULL, NOW(), NOW()
|
SELECT unnest($1::uuid[]), $2, 'read', NULL, NOW(), NOW()
|
||||||
ON CONFLICT (book_id) DO UPDATE
|
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||||
SET status = 'read',
|
SET status = 'read',
|
||||||
current_page = NULL,
|
current_page = NULL,
|
||||||
last_read_at = NOW(),
|
last_read_at = NOW(),
|
||||||
@@ -242,6 +249,7 @@ pub async fn sync_komga_read_books(
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&matched_ids)
|
.bind(&matched_ids)
|
||||||
|
.bind(body.user_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -273,12 +281,13 @@ pub async fn sync_komga_read_books(
|
|||||||
let newly_marked_books_json = serde_json::to_value(&newly_marked_books).unwrap_or_default();
|
let newly_marked_books_json = serde_json::to_value(&newly_marked_books).unwrap_or_default();
|
||||||
let report_row = sqlx::query(
|
let report_row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO komga_sync_reports (komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
|
INSERT INTO komga_sync_reports (komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING id, created_at
|
RETURNING id, created_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&url)
|
.bind(&url)
|
||||||
|
.bind(body.user_id)
|
||||||
.bind(total_komga_read)
|
.bind(total_komga_read)
|
||||||
.bind(matched)
|
.bind(matched)
|
||||||
.bind(already_read)
|
.bind(already_read)
|
||||||
@@ -292,6 +301,7 @@ pub async fn sync_komga_read_books(
|
|||||||
Ok(Json(KomgaSyncResponse {
|
Ok(Json(KomgaSyncResponse {
|
||||||
id: report_row.get("id"),
|
id: report_row.get("id"),
|
||||||
komga_url: url,
|
komga_url: url,
|
||||||
|
user_id: Some(body.user_id),
|
||||||
total_komga_read,
|
total_komga_read,
|
||||||
matched,
|
matched,
|
||||||
already_read,
|
already_read,
|
||||||
@@ -319,7 +329,7 @@ pub async fn list_sync_reports(
|
|||||||
) -> Result<Json<Vec<KomgaSyncReportSummary>>, ApiError> {
|
) -> Result<Json<Vec<KomgaSyncReportSummary>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked,
|
SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked,
|
||||||
jsonb_array_length(unmatched) as unmatched_count, created_at
|
jsonb_array_length(unmatched) as unmatched_count, created_at
|
||||||
FROM komga_sync_reports
|
FROM komga_sync_reports
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -334,6 +344,7 @@ pub async fn list_sync_reports(
|
|||||||
.map(|row| KomgaSyncReportSummary {
|
.map(|row| KomgaSyncReportSummary {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
komga_url: row.get("komga_url"),
|
komga_url: row.get("komga_url"),
|
||||||
|
user_id: row.get("user_id"),
|
||||||
total_komga_read: row.get("total_komga_read"),
|
total_komga_read: row.get("total_komga_read"),
|
||||||
matched: row.get("matched"),
|
matched: row.get("matched"),
|
||||||
already_read: row.get("already_read"),
|
already_read: row.get("already_read"),
|
||||||
@@ -365,7 +376,7 @@ pub async fn get_sync_report(
|
|||||||
) -> Result<Json<KomgaSyncResponse>, ApiError> {
|
) -> Result<Json<KomgaSyncResponse>, ApiError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, komga_url, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
|
SELECT id, komga_url, user_id, total_komga_read, matched, already_read, newly_marked, matched_books, newly_marked_books, unmatched, created_at
|
||||||
FROM komga_sync_reports
|
FROM komga_sync_reports
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -386,6 +397,7 @@ pub async fn get_sync_report(
|
|||||||
Ok(Json(KomgaSyncResponse {
|
Ok(Json(KomgaSyncResponse {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
komga_url: row.get("komga_url"),
|
komga_url: row.get("komga_url"),
|
||||||
|
user_id: row.get("user_id"),
|
||||||
total_komga_read: row.get("total_komga_read"),
|
total_komga_read: row.get("total_komga_read"),
|
||||||
matched: row.get("matched"),
|
matched: row.get("matched"),
|
||||||
already_read: row.get("already_read"),
|
already_read: row.get("already_read"),
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ pub struct CreateLibraryRequest {
|
|||||||
responses(
|
responses(
|
||||||
(status = 200, body = Vec<LibraryResponse>),
|
(status = 200, body = Vec<LibraryResponse>),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
(status = 403, description = "Forbidden - Admin scope required"),
|
|
||||||
),
|
),
|
||||||
security(("Bearer" = []))
|
security(("Bearer" = []))
|
||||||
)]
|
)]
|
||||||
@@ -221,7 +220,6 @@ use crate::index_jobs::{IndexJobResponse, RebuildRequest};
|
|||||||
(status = 200, body = IndexJobResponse),
|
(status = 200, body = IndexJobResponse),
|
||||||
(status = 404, description = "Library not found"),
|
(status = 404, description = "Library not found"),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
(status = 403, description = "Forbidden - Admin scope required"),
|
|
||||||
),
|
),
|
||||||
security(("Bearer" = []))
|
security(("Bearer" = []))
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod books;
|
|||||||
mod error;
|
mod error;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod index_jobs;
|
mod index_jobs;
|
||||||
|
mod job_poller;
|
||||||
mod komga;
|
mod komga;
|
||||||
mod libraries;
|
mod libraries;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
@@ -17,11 +18,14 @@ mod prowlarr;
|
|||||||
mod qbittorrent;
|
mod qbittorrent;
|
||||||
mod reading_progress;
|
mod reading_progress;
|
||||||
mod search;
|
mod search;
|
||||||
|
mod series;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod state;
|
mod state;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
mod telegram;
|
||||||
mod thumbnails;
|
mod thumbnails;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
|
mod users;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
@@ -86,14 +90,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let admin_routes = Router::new()
|
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", 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/monitoring", axum::routing::patch(libraries::update_monitoring))
|
||||||
.route("/libraries/:id/metadata-provider", axum::routing::patch(libraries::update_metadata_provider))
|
.route("/libraries/:id/metadata-provider", axum::routing::patch(libraries::update_metadata_provider))
|
||||||
.route("/books/:id", axum::routing::patch(books::update_book))
|
.route("/books/:id", axum::routing::patch(books::update_book))
|
||||||
.route("/books/:id/convert", axum::routing::post(books::convert_book))
|
.route("/books/:id/convert", axum::routing::post(books::convert_book))
|
||||||
.route("/libraries/:library_id/series/:name", axum::routing::patch(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/rebuild", axum::routing::post(index_jobs::enqueue_rebuild))
|
||||||
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
|
.route("/index/thumbnails/rebuild", axum::routing::post(thumbnails::start_thumbnails_rebuild))
|
||||||
.route("/index/thumbnails/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate))
|
.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/jobs/:id/errors", get(index_jobs::get_job_errors))
|
||||||
.route("/index/cancel/:id", axum::routing::post(index_jobs::cancel_job))
|
.route("/index/cancel/:id", axum::routing::post(index_jobs::cancel_job))
|
||||||
.route("/folders", get(index_jobs::list_folders))
|
.route("/folders", get(index_jobs::list_folders))
|
||||||
|
.route("/admin/users", get(users::list_users).post(users::create_user))
|
||||||
|
.route("/admin/users/:id", delete(users::delete_user).patch(users::update_user))
|
||||||
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
||||||
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
.route("/admin/tokens/:id", delete(tokens::revoke_token).patch(tokens::update_token))
|
||||||
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
||||||
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
|
.route("/prowlarr/search", axum::routing::post(prowlarr::search_prowlarr))
|
||||||
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
|
.route("/prowlarr/test", get(prowlarr::test_prowlarr))
|
||||||
.route("/qbittorrent/add", axum::routing::post(qbittorrent::add_torrent))
|
.route("/qbittorrent/add", axum::routing::post(qbittorrent::add_torrent))
|
||||||
.route("/qbittorrent/test", get(qbittorrent::test_qbittorrent))
|
.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/sync", axum::routing::post(komga::sync_komga_read_books))
|
||||||
.route("/komga/reports", get(komga::list_sync_reports))
|
.route("/komga/reports", get(komga::list_sync_reports))
|
||||||
.route("/komga/reports/:id", get(komga::get_sync_report))
|
.route("/komga/reports/:id", get(komga::get_sync_report))
|
||||||
@@ -133,18 +139,20 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let read_routes = Router::new()
|
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", 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", get(books::get_book))
|
||||||
.route("/books/:id/thumbnail", get(books::get_thumbnail))
|
.route("/books/:id/thumbnail", get(books::get_thumbnail))
|
||||||
.route("/books/:id/pages/:n", get(pages::get_page))
|
.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("/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", get(series::list_series))
|
||||||
.route("/libraries/:library_id/series/:name/metadata", get(books::get_series_metadata))
|
.route("/libraries/:library_id/series/:name/metadata", get(series::get_series_metadata))
|
||||||
.route("/series", get(books::list_all_series))
|
.route("/series", get(series::list_all_series))
|
||||||
.route("/series/ongoing", get(books::ongoing_series))
|
.route("/series/ongoing", get(series::ongoing_series))
|
||||||
.route("/series/statuses", get(books::series_statuses))
|
.route("/series/statuses", get(series::series_statuses))
|
||||||
.route("/series/provider-statuses", get(books::provider_statuses))
|
.route("/series/provider-statuses", get(series::provider_statuses))
|
||||||
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
||||||
.route("/authors", get(authors::list_authors))
|
.route("/authors", get(authors::list_authors))
|
||||||
.route("/stats", get(stats::get_stats))
|
.route("/stats", get(stats::get_stats))
|
||||||
@@ -155,6 +163,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
auth::require_read,
|
auth::require_read,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Clone pool before state is moved into the router
|
||||||
|
let poller_pool = state.pool.clone();
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/health", get(handlers::health))
|
.route("/health", get(handlers::health))
|
||||||
.route("/ready", get(handlers::ready))
|
.route("/ready", get(handlers::ready))
|
||||||
@@ -166,6 +177,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.layer(middleware::from_fn_with_state(state.clone(), api_middleware::request_counter))
|
.layer(middleware::from_fn_with_state(state.clone(), api_middleware::request_counter))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
// Start background poller for API-only jobs (metadata_batch, metadata_refresh)
|
||||||
|
tokio::spawn(async move {
|
||||||
|
job_poller::run_job_poller(poller_pool, 5).await;
|
||||||
|
});
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
|
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
|
||||||
info!(addr = %config.listen_addr, "api listening");
|
info!(addr = %config.listen_addr, "api listening");
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|||||||
@@ -369,6 +369,26 @@ pub async fn approve_metadata(
|
|||||||
.await?;
|
.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 {
|
Ok(Json(ApproveResponse {
|
||||||
status: "approved".to_string(),
|
status: "approved".to_string(),
|
||||||
report,
|
report,
|
||||||
|
|||||||
@@ -115,15 +115,21 @@ pub async fn start_batch(
|
|||||||
|
|
||||||
let job_id = Uuid::new_v4();
|
let job_id = Uuid::new_v4();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_batch', 'pending')",
|
"INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'metadata_batch', 'running', NOW())",
|
||||||
)
|
)
|
||||||
.bind(job_id)
|
.bind(job_id)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Spawn the background processing task
|
// Spawn the background processing task (status already 'running' to avoid poller race)
|
||||||
let pool = state.pool.clone();
|
let pool = state.pool.clone();
|
||||||
|
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = process_metadata_batch(&pool, job_id, library_id).await {
|
if let Err(e) = process_metadata_batch(&pool, job_id, library_id).await {
|
||||||
warn!("[METADATA_BATCH] job {job_id} failed: {e}");
|
warn!("[METADATA_BATCH] job {job_id} failed: {e}");
|
||||||
@@ -134,6 +140,13 @@ pub async fn start_batch(
|
|||||||
.bind(e.to_string())
|
.bind(e.to_string())
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await;
|
.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
|
// Background processing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn process_metadata_batch(
|
pub(crate) async fn process_metadata_batch(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
job_id: Uuid,
|
job_id: Uuid,
|
||||||
library_id: Uuid,
|
library_id: Uuid,
|
||||||
@@ -621,6 +634,21 @@ async fn process_metadata_batch(
|
|||||||
|
|
||||||
info!("[METADATA_BATCH] job={job_id} completed: {processed}/{total} series processed");
|
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(())
|
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(
|
let link_count: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM external_metadata_links WHERE library_id = $1 AND status = 'approved'",
|
r#"
|
||||||
|
SELECT COUNT(*) FROM external_metadata_links eml
|
||||||
|
LEFT JOIN series_metadata sm
|
||||||
|
ON sm.library_id = eml.library_id AND sm.name = eml.series_name
|
||||||
|
WHERE eml.library_id = $1
|
||||||
|
AND eml.status = 'approved'
|
||||||
|
AND COALESCE(sm.status, 'ongoing') NOT IN ('ended', 'cancelled')
|
||||||
|
"#,
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
@@ -124,15 +131,21 @@ pub async fn start_refresh(
|
|||||||
|
|
||||||
let job_id = Uuid::new_v4();
|
let job_id = Uuid::new_v4();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'metadata_refresh', 'pending')",
|
"INSERT INTO index_jobs (id, library_id, type, status, started_at) VALUES ($1, $2, 'metadata_refresh', 'running', NOW())",
|
||||||
)
|
)
|
||||||
.bind(job_id)
|
.bind(job_id)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Spawn the background processing task
|
// Spawn the background processing task (status already 'running' to avoid poller race)
|
||||||
let pool = state.pool.clone();
|
let pool = state.pool.clone();
|
||||||
|
let library_name: Option<String> = sqlx::query_scalar("SELECT name FROM libraries WHERE id = $1")
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = process_metadata_refresh(&pool, job_id, library_id).await {
|
if let Err(e) = process_metadata_refresh(&pool, job_id, library_id).await {
|
||||||
warn!("[METADATA_REFRESH] job {job_id} failed: {e}");
|
warn!("[METADATA_REFRESH] job {job_id} failed: {e}");
|
||||||
@@ -143,6 +156,13 @@ pub async fn start_refresh(
|
|||||||
.bind(e.to_string())
|
.bind(e.to_string())
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await;
|
.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
|
// Background processing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn process_metadata_refresh(
|
pub(crate) async fn process_metadata_refresh(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
job_id: Uuid,
|
job_id: Uuid,
|
||||||
library_id: Uuid,
|
library_id: Uuid,
|
||||||
@@ -221,13 +241,17 @@ async fn process_metadata_refresh(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Get all approved links for this library
|
// Get approved links for this library, only for ongoing series (not ended/cancelled)
|
||||||
let links: Vec<(Uuid, String, String, String)> = sqlx::query_as(
|
let links: Vec<(Uuid, String, String, String)> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, series_name, provider, external_id
|
SELECT eml.id, eml.series_name, eml.provider, eml.external_id
|
||||||
FROM external_metadata_links
|
FROM external_metadata_links eml
|
||||||
WHERE library_id = $1 AND status = 'approved'
|
LEFT JOIN series_metadata sm
|
||||||
ORDER BY series_name
|
ON sm.library_id = eml.library_id AND sm.name = eml.series_name
|
||||||
|
WHERE eml.library_id = $1
|
||||||
|
AND eml.status = 'approved'
|
||||||
|
AND COALESCE(sm.status, 'ongoing') NOT IN ('ended', 'cancelled')
|
||||||
|
ORDER BY eml.series_name
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
@@ -319,6 +343,22 @@ async fn process_metadata_refresh(
|
|||||||
|
|
||||||
info!("[METADATA_REFRESH] job={job_id} completed: {refreshed} updated, {unchanged} unchanged, {errors} errors");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ use utoipa::OpenApi;
|
|||||||
crate::reading_progress::update_reading_progress,
|
crate::reading_progress::update_reading_progress,
|
||||||
crate::reading_progress::mark_series_read,
|
crate::reading_progress::mark_series_read,
|
||||||
crate::books::get_thumbnail,
|
crate::books::get_thumbnail,
|
||||||
crate::books::list_series,
|
crate::series::list_series,
|
||||||
crate::books::list_all_series,
|
crate::series::list_all_series,
|
||||||
crate::books::ongoing_series,
|
crate::series::ongoing_series,
|
||||||
crate::books::ongoing_books,
|
crate::series::ongoing_books,
|
||||||
crate::books::convert_book,
|
crate::books::convert_book,
|
||||||
crate::books::update_book,
|
crate::books::update_book,
|
||||||
crate::books::get_series_metadata,
|
crate::series::get_series_metadata,
|
||||||
crate::books::update_series,
|
crate::series::update_series,
|
||||||
crate::pages::get_page,
|
crate::pages::get_page,
|
||||||
crate::search::search_books,
|
crate::search::search_books,
|
||||||
crate::index_jobs::enqueue_rebuild,
|
crate::index_jobs::enqueue_rebuild,
|
||||||
@@ -35,6 +35,7 @@ use utoipa::OpenApi;
|
|||||||
crate::libraries::delete_library,
|
crate::libraries::delete_library,
|
||||||
crate::libraries::scan_library,
|
crate::libraries::scan_library,
|
||||||
crate::libraries::update_monitoring,
|
crate::libraries::update_monitoring,
|
||||||
|
crate::libraries::update_metadata_provider,
|
||||||
crate::tokens::list_tokens,
|
crate::tokens::list_tokens,
|
||||||
crate::tokens::create_token,
|
crate::tokens::create_token,
|
||||||
crate::tokens::revoke_token,
|
crate::tokens::revoke_token,
|
||||||
@@ -54,8 +55,8 @@ use utoipa::OpenApi;
|
|||||||
crate::metadata::get_metadata_links,
|
crate::metadata::get_metadata_links,
|
||||||
crate::metadata::get_missing_books,
|
crate::metadata::get_missing_books,
|
||||||
crate::metadata::delete_metadata_link,
|
crate::metadata::delete_metadata_link,
|
||||||
crate::books::series_statuses,
|
crate::series::series_statuses,
|
||||||
crate::books::provider_statuses,
|
crate::series::provider_statuses,
|
||||||
crate::settings::list_status_mappings,
|
crate::settings::list_status_mappings,
|
||||||
crate::settings::upsert_status_mapping,
|
crate::settings::upsert_status_mapping,
|
||||||
crate::settings::delete_status_mapping,
|
crate::settings::delete_status_mapping,
|
||||||
@@ -63,6 +64,14 @@ use utoipa::OpenApi;
|
|||||||
crate::prowlarr::test_prowlarr,
|
crate::prowlarr::test_prowlarr,
|
||||||
crate::qbittorrent::add_torrent,
|
crate::qbittorrent::add_torrent,
|
||||||
crate::qbittorrent::test_qbittorrent,
|
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(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
@@ -74,14 +83,14 @@ use utoipa::OpenApi;
|
|||||||
crate::reading_progress::UpdateReadingProgressRequest,
|
crate::reading_progress::UpdateReadingProgressRequest,
|
||||||
crate::reading_progress::MarkSeriesReadRequest,
|
crate::reading_progress::MarkSeriesReadRequest,
|
||||||
crate::reading_progress::MarkSeriesReadResponse,
|
crate::reading_progress::MarkSeriesReadResponse,
|
||||||
crate::books::SeriesItem,
|
crate::series::SeriesItem,
|
||||||
crate::books::SeriesPage,
|
crate::series::SeriesPage,
|
||||||
crate::books::ListAllSeriesQuery,
|
crate::series::ListAllSeriesQuery,
|
||||||
crate::books::OngoingQuery,
|
crate::series::OngoingQuery,
|
||||||
crate::books::UpdateBookRequest,
|
crate::books::UpdateBookRequest,
|
||||||
crate::books::SeriesMetadata,
|
crate::series::SeriesMetadata,
|
||||||
crate::books::UpdateSeriesRequest,
|
crate::series::UpdateSeriesRequest,
|
||||||
crate::books::UpdateSeriesResponse,
|
crate::series::UpdateSeriesResponse,
|
||||||
crate::pages::PageQuery,
|
crate::pages::PageQuery,
|
||||||
crate::search::SearchQuery,
|
crate::search::SearchQuery,
|
||||||
crate::search::SearchResponse,
|
crate::search::SearchResponse,
|
||||||
@@ -96,6 +105,7 @@ use utoipa::OpenApi;
|
|||||||
crate::libraries::LibraryResponse,
|
crate::libraries::LibraryResponse,
|
||||||
crate::libraries::CreateLibraryRequest,
|
crate::libraries::CreateLibraryRequest,
|
||||||
crate::libraries::UpdateMonitoringRequest,
|
crate::libraries::UpdateMonitoringRequest,
|
||||||
|
crate::libraries::UpdateMetadataProviderRequest,
|
||||||
crate::tokens::CreateTokenRequest,
|
crate::tokens::CreateTokenRequest,
|
||||||
crate::tokens::TokenResponse,
|
crate::tokens::TokenResponse,
|
||||||
crate::tokens::CreatedTokenResponse,
|
crate::tokens::CreatedTokenResponse,
|
||||||
@@ -137,7 +147,16 @@ use utoipa::OpenApi;
|
|||||||
crate::prowlarr::ProwlarrRelease,
|
crate::prowlarr::ProwlarrRelease,
|
||||||
crate::prowlarr::ProwlarrCategory,
|
crate::prowlarr::ProwlarrCategory,
|
||||||
crate::prowlarr::ProwlarrSearchResponse,
|
crate::prowlarr::ProwlarrSearchResponse,
|
||||||
|
crate::prowlarr::MissingVolumeInput,
|
||||||
crate::prowlarr::ProwlarrTestResponse,
|
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,
|
ErrorResponse,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -145,11 +164,16 @@ use utoipa::OpenApi;
|
|||||||
("Bearer" = [])
|
("Bearer" = [])
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
(name = "authors", description = "Author browsing and listing"),
|
(name = "books", description = "Book browsing, details and management"),
|
||||||
(name = "books", description = "Read-only endpoints for browsing and searching books"),
|
(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 = "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 = "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 = "tokens", description = "API token management (Admin only)"),
|
||||||
(name = "settings", description = "Application settings and cache management (Admin only)"),
|
(name = "settings", description = "Application settings and cache management (Admin only)"),
|
||||||
(name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"),
|
(name = "prowlarr", description = "Prowlarr indexer integration (Admin only)"),
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use axum::{extract::{Path, State}, Json};
|
use axum::{extract::{Extension, Path, State}, Json};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{auth::AuthUser, error::ApiError, state::AppState};
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct ReadingProgressResponse {
|
pub struct ReadingProgressResponse {
|
||||||
@@ -42,8 +42,10 @@ pub struct UpdateReadingProgressRequest {
|
|||||||
)]
|
)]
|
||||||
pub async fn get_reading_progress(
|
pub async fn get_reading_progress(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
||||||
|
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
|
||||||
// Verify book exists
|
// Verify book exists
|
||||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
|
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -55,9 +57,10 @@ pub async fn get_reading_progress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1",
|
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1 AND user_id = $2",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(auth_user.user_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -96,9 +99,11 @@ pub async fn get_reading_progress(
|
|||||||
)]
|
)]
|
||||||
pub async fn update_reading_progress(
|
pub async fn update_reading_progress(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<UpdateReadingProgressRequest>,
|
Json(body): Json<UpdateReadingProgressRequest>,
|
||||||
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
||||||
|
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
|
||||||
// Validate status value
|
// Validate status value
|
||||||
if !["unread", "reading", "read"].contains(&body.status.as_str()) {
|
if !["unread", "reading", "read"].contains(&body.status.as_str()) {
|
||||||
return Err(ApiError::bad_request(format!(
|
return Err(ApiError::bad_request(format!(
|
||||||
@@ -143,9 +148,9 @@ pub async fn update_reading_progress(
|
|||||||
|
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||||
VALUES ($1, $2, $3, NOW(), NOW())
|
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||||
ON CONFLICT (book_id) DO UPDATE
|
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||||
SET status = EXCLUDED.status,
|
SET status = EXCLUDED.status,
|
||||||
current_page = EXCLUDED.current_page,
|
current_page = EXCLUDED.current_page,
|
||||||
last_read_at = NOW(),
|
last_read_at = NOW(),
|
||||||
@@ -154,6 +159,7 @@ pub async fn update_reading_progress(
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(auth_user.user_id)
|
||||||
.bind(&body.status)
|
.bind(&body.status)
|
||||||
.bind(current_page)
|
.bind(current_page)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
@@ -194,8 +200,10 @@ pub struct MarkSeriesReadResponse {
|
|||||||
)]
|
)]
|
||||||
pub async fn mark_series_read(
|
pub async fn mark_series_read(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
Json(body): Json<MarkSeriesReadRequest>,
|
Json(body): Json<MarkSeriesReadRequest>,
|
||||||
) -> Result<Json<MarkSeriesReadResponse>, ApiError> {
|
) -> Result<Json<MarkSeriesReadResponse>, ApiError> {
|
||||||
|
let auth_user = user.ok_or_else(|| ApiError::bad_request("admin tokens cannot track reading progress"))?.0;
|
||||||
if !["read", "unread"].contains(&body.status.as_str()) {
|
if !["read", "unread"].contains(&body.status.as_str()) {
|
||||||
return Err(ApiError::bad_request(
|
return Err(ApiError::bad_request(
|
||||||
"status must be 'read' or 'unread'",
|
"status must be 'read' or 'unread'",
|
||||||
@@ -209,24 +217,50 @@ pub async fn mark_series_read(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let sql = if body.status == "unread" {
|
let sql = if body.status == "unread" {
|
||||||
// Delete progress records to reset to unread
|
// Delete progress records to reset to unread (scoped to this user)
|
||||||
|
if body.series == "unclassified" {
|
||||||
|
format!(
|
||||||
|
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) AND user_id = $1
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
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) AND user_id = $2
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if body.series == "unclassified" {
|
||||||
format!(
|
format!(
|
||||||
r#"
|
r#"
|
||||||
WITH target_books AS (
|
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||||
SELECT id FROM books WHERE {series_filter}
|
SELECT id, $1, 'read', NULL, NOW(), NOW()
|
||||||
)
|
FROM books
|
||||||
DELETE FROM book_reading_progress
|
WHERE {series_filter}
|
||||||
WHERE book_id IN (SELECT id FROM target_books)
|
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||||
|
SET status = 'read',
|
||||||
|
current_page = NULL,
|
||||||
|
last_read_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
INSERT INTO book_reading_progress (book_id, user_id, status, current_page, last_read_at, updated_at)
|
||||||
SELECT id, 'read', NULL, NOW(), NOW()
|
SELECT id, $2, 'read', NULL, NOW(), NOW()
|
||||||
FROM books
|
FROM books
|
||||||
WHERE {series_filter}
|
WHERE {series_filter}
|
||||||
ON CONFLICT (book_id) DO UPDATE
|
ON CONFLICT (book_id, user_id) DO UPDATE
|
||||||
SET status = 'read',
|
SET status = 'read',
|
||||||
current_page = NULL,
|
current_page = NULL,
|
||||||
last_read_at = NOW(),
|
last_read_at = NOW(),
|
||||||
@@ -236,9 +270,18 @@ pub async fn mark_series_read(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let result = if body.series == "unclassified" {
|
let result = if body.series == "unclassified" {
|
||||||
sqlx::query(&sql).execute(&state.pool).await?
|
// $1 = user_id (no series bind needed)
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(auth_user.user_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query(&sql).bind(&body.series).execute(&state.pool).await?
|
// $1 = series, $2 = user_id
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(&body.series)
|
||||||
|
.bind(auth_user.user_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(MarkSeriesReadResponse {
|
Ok(Json(MarkSeriesReadResponse {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub struct SearchResponse {
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/search",
|
path = "/search",
|
||||||
tag = "books",
|
tag = "search",
|
||||||
params(
|
params(
|
||||||
("q" = String, Query, description = "Search query (books + series via PostgreSQL full-text)"),
|
("q" = String, Query, description = "Search query (books + series via PostgreSQL full-text)"),
|
||||||
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
||||||
|
|||||||
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 axum::{
|
||||||
use serde::Serialize;
|
extract::{Extension, Query, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use utoipa::ToSchema;
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
|
||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{auth::AuthUser, error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
#[derive(Deserialize, IntoParams)]
|
||||||
|
pub struct StatsQuery {
|
||||||
|
/// Granularity: "day", "week" or "month" (default: "month")
|
||||||
|
pub period: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct StatsOverview {
|
pub struct StatsOverview {
|
||||||
@@ -74,23 +83,70 @@ pub struct ProviderCount {
|
|||||||
pub count: i64,
|
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)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct StatsResponse {
|
pub struct StatsResponse {
|
||||||
pub overview: StatsOverview,
|
pub overview: StatsOverview,
|
||||||
pub reading_status: ReadingStatusStats,
|
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_format: Vec<FormatCount>,
|
||||||
pub by_language: Vec<LanguageCount>,
|
pub by_language: Vec<LanguageCount>,
|
||||||
pub by_library: Vec<LibraryStats>,
|
pub by_library: Vec<LibraryStats>,
|
||||||
pub top_series: Vec<TopSeries>,
|
pub top_series: Vec<TopSeries>,
|
||||||
pub additions_over_time: Vec<MonthlyAdditions>,
|
pub additions_over_time: Vec<MonthlyAdditions>,
|
||||||
|
pub jobs_over_time: Vec<JobTimePoint>,
|
||||||
pub metadata: MetadataStats,
|
pub metadata: MetadataStats,
|
||||||
|
pub users_reading_over_time: Vec<UserMonthlyReading>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get collection statistics for the dashboard
|
/// Get collection statistics for the dashboard
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/stats",
|
path = "/stats",
|
||||||
tag = "books",
|
tag = "stats",
|
||||||
|
params(StatsQuery),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = StatsResponse),
|
(status = 200, body = StatsResponse),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
@@ -99,7 +155,11 @@ pub struct StatsResponse {
|
|||||||
)]
|
)]
|
||||||
pub async fn get_stats(
|
pub async fn get_stats(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<StatsQuery>,
|
||||||
|
user: Option<Extension<AuthUser>>,
|
||||||
) -> Result<Json<StatsResponse>, ApiError> {
|
) -> Result<Json<StatsResponse>, ApiError> {
|
||||||
|
let user_id: Option<uuid::Uuid> = user.map(|u| u.0.user_id);
|
||||||
|
let period = query.period.as_deref().unwrap_or("month");
|
||||||
// Overview + reading status in one query
|
// Overview + reading status in one query
|
||||||
let overview_row = sqlx::query(
|
let overview_row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -117,9 +177,10 @@ pub async fn get_stats(
|
|||||||
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading,
|
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading,
|
||||||
COUNT(*) FILTER (WHERE brp.status = 'read') AS read
|
COUNT(*) FILTER (WHERE brp.status = 'read') AS read
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -207,7 +268,7 @@ pub async fn get_stats(
|
|||||||
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread_count
|
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread_count
|
||||||
FROM libraries l
|
FROM libraries l
|
||||||
LEFT JOIN books b ON b.library_id = l.id
|
LEFT JOIN books b ON b.library_id = l.id
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT size_bytes FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
|
SELECT size_bytes FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
|
||||||
) bf ON TRUE
|
) bf ON TRUE
|
||||||
@@ -215,6 +276,7 @@ pub async fn get_stats(
|
|||||||
ORDER BY book_count DESC
|
ORDER BY book_count DESC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -239,13 +301,14 @@ pub async fn get_stats(
|
|||||||
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
|
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
|
||||||
COALESCE(SUM(b.page_count), 0)::BIGINT AS total_pages
|
COALESCE(SUM(b.page_count), 0)::BIGINT AS total_pages
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
WHERE b.series IS NOT NULL AND b.series != ''
|
WHERE b.series IS NOT NULL AND b.series != ''
|
||||||
GROUP BY b.series
|
GROUP BY b.series
|
||||||
ORDER BY book_count DESC
|
ORDER BY book_count DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -259,20 +322,74 @@ pub async fn get_stats(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Additions over time (last 12 months)
|
// Additions over time (with gap filling)
|
||||||
let additions_rows = sqlx::query(
|
let additions_rows = match period {
|
||||||
r#"
|
"day" => {
|
||||||
SELECT
|
sqlx::query(
|
||||||
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month,
|
r#"
|
||||||
COUNT(*) AS books_added
|
SELECT
|
||||||
FROM books
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
COALESCE(cnt.books_added, 0) AS books_added
|
||||||
GROUP BY DATE_TRUNC('month', created_at)
|
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||||
ORDER BY month ASC
|
LEFT JOIN (
|
||||||
"#,
|
SELECT created_at::date AS dt, COUNT(*) AS books_added
|
||||||
)
|
FROM books
|
||||||
.fetch_all(&state.pool)
|
WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||||
.await?;
|
GROUP BY created_at::date
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
"week" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
|
COALESCE(cnt.books_added, 0) AS books_added
|
||||||
|
FROM generate_series(
|
||||||
|
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||||
|
DATE_TRUNC('week', NOW()),
|
||||||
|
'1 week'
|
||||||
|
) AS d(dt)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DATE_TRUNC('week', created_at) AS dt, COUNT(*) AS books_added
|
||||||
|
FROM books
|
||||||
|
WHERE created_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||||
|
GROUP BY DATE_TRUNC('week', created_at)
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||||
|
COALESCE(cnt.books_added, 0) AS books_added
|
||||||
|
FROM generate_series(
|
||||||
|
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||||
|
DATE_TRUNC('month', NOW()),
|
||||||
|
'1 month'
|
||||||
|
) AS d(dt)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DATE_TRUNC('month', created_at) AS dt, COUNT(*) AS books_added
|
||||||
|
FROM books
|
||||||
|
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', created_at)
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
|
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -327,14 +444,376 @@ pub async fn get_stats(
|
|||||||
by_provider,
|
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 {
|
Ok(Json(StatsResponse {
|
||||||
overview,
|
overview,
|
||||||
reading_status,
|
reading_status,
|
||||||
|
currently_reading,
|
||||||
|
recently_read,
|
||||||
|
reading_over_time,
|
||||||
by_format,
|
by_format,
|
||||||
by_language,
|
by_language,
|
||||||
by_library,
|
by_library,
|
||||||
top_series,
|
top_series,
|
||||||
additions_over_time,
|
additions_over_time,
|
||||||
|
jobs_over_time,
|
||||||
metadata,
|
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,
|
pub name: String,
|
||||||
#[schema(value_type = Option<String>, example = "read")]
|
#[schema(value_type = Option<String>, example = "read")]
|
||||||
pub scope: Option<String>,
|
pub scope: Option<String>,
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -26,6 +28,9 @@ pub struct TokenResponse {
|
|||||||
pub scope: String,
|
pub scope: String,
|
||||||
pub prefix: String,
|
pub prefix: String,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
pub username: Option<String>,
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
pub last_used_at: Option<DateTime<Utc>>,
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
pub revoked_at: Option<DateTime<Utc>>,
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
@@ -71,6 +76,10 @@ pub async fn create_token(
|
|||||||
_ => return Err(ApiError::bad_request("scope must be 'admin' or 'read'")),
|
_ => return Err(ApiError::bad_request("scope must be 'admin' or 'read'")),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if scope == "read" && input.user_id.is_none() {
|
||||||
|
return Err(ApiError::bad_request("user_id is required for read-scoped tokens"));
|
||||||
|
}
|
||||||
|
|
||||||
let mut random = [0u8; 24];
|
let mut random = [0u8; 24];
|
||||||
OsRng.fill_bytes(&mut random);
|
OsRng.fill_bytes(&mut random);
|
||||||
let secret = URL_SAFE_NO_PAD.encode(random);
|
let secret = URL_SAFE_NO_PAD.encode(random);
|
||||||
@@ -85,13 +94,14 @@ pub async fn create_token(
|
|||||||
|
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope) VALUES ($1, $2, $3, $4, $5)",
|
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope, user_id) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(input.name.trim())
|
.bind(input.name.trim())
|
||||||
.bind(&prefix)
|
.bind(&prefix)
|
||||||
.bind(token_hash)
|
.bind(token_hash)
|
||||||
.bind(scope)
|
.bind(scope)
|
||||||
|
.bind(input.user_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -118,7 +128,13 @@ pub async fn create_token(
|
|||||||
)]
|
)]
|
||||||
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenResponse>>, ApiError> {
|
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenResponse>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, name, scope, prefix, last_used_at, revoked_at, created_at FROM api_tokens ORDER BY created_at DESC",
|
r#"
|
||||||
|
SELECT t.id, t.name, t.scope, t.prefix, t.user_id, u.username,
|
||||||
|
t.last_used_at, t.revoked_at, t.created_at
|
||||||
|
FROM api_tokens t
|
||||||
|
LEFT JOIN users u ON u.id = t.user_id
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -130,6 +146,8 @@ pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<Token
|
|||||||
name: row.get("name"),
|
name: row.get("name"),
|
||||||
scope: row.get("scope"),
|
scope: row.get("scope"),
|
||||||
prefix: row.get("prefix"),
|
prefix: row.get("prefix"),
|
||||||
|
user_id: row.get("user_id"),
|
||||||
|
username: row.get("username"),
|
||||||
last_used_at: row.get("last_used_at"),
|
last_used_at: row.get("last_used_at"),
|
||||||
revoked_at: row.get("revoked_at"),
|
revoked_at: row.get("revoked_at"),
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
@@ -171,6 +189,47 @@ pub async fn revoke_token(
|
|||||||
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
|
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateTokenRequest {
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a token's assigned user
|
||||||
|
#[utoipa::path(
|
||||||
|
patch,
|
||||||
|
path = "/admin/tokens/{id}",
|
||||||
|
tag = "tokens",
|
||||||
|
params(
|
||||||
|
("id" = String, Path, description = "Token UUID"),
|
||||||
|
),
|
||||||
|
request_body = UpdateTokenRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Token updated"),
|
||||||
|
(status = 404, description = "Token not found"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin scope required"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn update_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(input): Json<UpdateTokenRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let result = sqlx::query("UPDATE api_tokens SET user_id = $1 WHERE id = $2")
|
||||||
|
.bind(input.user_id)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(ApiError::not_found("token not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"updated": true, "id": id})))
|
||||||
|
}
|
||||||
|
|
||||||
/// Permanently delete a revoked API token
|
/// Permanently delete a revoked API token
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
|
|||||||
195
apps/api/src/users.rs
Normal file
195
apps/api/src/users.rs
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
use axum::{extract::{Path, State}, Json};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::Row;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct UserResponse {
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub token_count: i64,
|
||||||
|
pub books_read: i64,
|
||||||
|
pub books_reading: i64,
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct CreateUserRequest {
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all reader users with their associated token count
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/admin/users",
|
||||||
|
tag = "users",
|
||||||
|
responses(
|
||||||
|
(status = 200, body = Vec<UserResponse>),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin scope required"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn list_users(State(state): State<AppState>) -> Result<Json<Vec<UserResponse>>, ApiError> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT u.id, u.username, u.created_at,
|
||||||
|
COUNT(DISTINCT t.id) AS token_count,
|
||||||
|
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'read') AS books_read,
|
||||||
|
COUNT(DISTINCT brp.book_id) FILTER (WHERE brp.status = 'reading') AS books_reading
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN api_tokens t ON t.user_id = u.id AND t.revoked_at IS NULL
|
||||||
|
LEFT JOIN book_reading_progress brp ON brp.user_id = u.id
|
||||||
|
GROUP BY u.id, u.username, u.created_at
|
||||||
|
ORDER BY u.created_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let items = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| UserResponse {
|
||||||
|
id: row.get("id"),
|
||||||
|
username: row.get("username"),
|
||||||
|
token_count: row.get("token_count"),
|
||||||
|
books_read: row.get("books_read"),
|
||||||
|
books_reading: row.get("books_reading"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new reader user
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/admin/users",
|
||||||
|
tag = "users",
|
||||||
|
request_body = CreateUserRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, body = UserResponse, description = "User created"),
|
||||||
|
(status = 400, description = "Invalid input"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin scope required"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn create_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(input): Json<CreateUserRequest>,
|
||||||
|
) -> Result<Json<UserResponse>, ApiError> {
|
||||||
|
if input.username.trim().is_empty() {
|
||||||
|
return Err(ApiError::bad_request("username is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let row = sqlx::query(
|
||||||
|
"INSERT INTO users (id, username) VALUES ($1, $2) RETURNING id, username, created_at",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(input.username.trim())
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if let sqlx::Error::Database(ref db_err) = e {
|
||||||
|
if db_err.constraint() == Some("users_username_key") {
|
||||||
|
return ApiError::bad_request("username already exists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApiError::from(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(UserResponse {
|
||||||
|
id: row.get("id"),
|
||||||
|
username: row.get("username"),
|
||||||
|
token_count: 0,
|
||||||
|
books_read: 0,
|
||||||
|
books_reading: 0,
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a reader user's username
|
||||||
|
#[utoipa::path(
|
||||||
|
patch,
|
||||||
|
path = "/admin/users/{id}",
|
||||||
|
tag = "users",
|
||||||
|
request_body = CreateUserRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, body = UserResponse, description = "User updated"),
|
||||||
|
(status = 400, description = "Invalid input"),
|
||||||
|
(status = 404, description = "User not found"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin scope required"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn update_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(input): Json<CreateUserRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
if input.username.trim().is_empty() {
|
||||||
|
return Err(ApiError::bad_request("username is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = sqlx::query("UPDATE users SET username = $1 WHERE id = $2")
|
||||||
|
.bind(input.username.trim())
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if let sqlx::Error::Database(ref db_err) = e {
|
||||||
|
if db_err.constraint() == Some("users_username_key") {
|
||||||
|
return ApiError::bad_request("username already exists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApiError::from(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(ApiError::not_found("user not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"updated": true, "id": id})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a reader user (cascades on tokens and reading progress)
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/admin/users/{id}",
|
||||||
|
tag = "users",
|
||||||
|
params(
|
||||||
|
("id" = String, Path, description = "User UUID"),
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "User deleted"),
|
||||||
|
(status = 404, description = "User not found"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin scope required"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn delete_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let result = sqlx::query("DELETE FROM users WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(ApiError::not_found("user not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "../../../lib/api";
|
import { fetchBooks, fetchAllSeries, BooksPageDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
|
||||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
import { BooksGrid } from "../../components/BookCard";
|
import { BooksGrid } from "@/app/components/BookCard";
|
||||||
import { OffsetPagination } from "../../components/ui";
|
import { OffsetPagination } from "@/app/components/ui";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@@ -21,26 +21,19 @@ export default async function AuthorDetailPage({
|
|||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
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([
|
const [booksPage, seriesPage] = await Promise.all([
|
||||||
fetchBooks(undefined, undefined, page, limit, undefined, undefined, authorName).catch(
|
fetchBooks(undefined, undefined, page, limit, undefined, undefined, authorName).catch(
|
||||||
() => ({ items: [], total: 0, page: 1, limit }) as BooksPageDto
|
() => ({ 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
|
() => ({ items: [], total: 0, page: 1, limit: 200 }) as SeriesPageDto
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(booksPage.total / limit);
|
const totalPages = Math.ceil(booksPage.total / limit);
|
||||||
|
|
||||||
// Extract unique series names from this author's books
|
const authorSeries = seriesPage.items;
|
||||||
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));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -95,7 +88,7 @@ export default async function AuthorDetailPage({
|
|||||||
alt={s.name}
|
alt={s.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fetchAuthors, AuthorsPageDto } from "../../lib/api";
|
import { fetchAuthors, AuthorsPageDto } from "@/lib/api";
|
||||||
import { getServerTranslations } from "../../lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
||||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api";
|
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "@/lib/api";
|
||||||
import { BookPreview } from "../../components/BookPreview";
|
import { BookPreview } from "@/app/components/BookPreview";
|
||||||
import { ConvertButton } from "../../components/ConvertButton";
|
import { ConvertButton } from "@/app/components/ConvertButton";
|
||||||
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
|
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
|
||||||
import { EditBookForm } from "../../components/EditBookForm";
|
import nextDynamic from "next/dynamic";
|
||||||
import { SafeHtml } from "../../components/SafeHtml";
|
import { SafeHtml } from "@/app/components/SafeHtml";
|
||||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const EditBookForm = nextDynamic(
|
||||||
|
() => import("@/app/components/EditBookForm").then(m => m.EditBookForm)
|
||||||
|
);
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -95,7 +99,7 @@ export default async function BookDetailPage({
|
|||||||
alt={t("bookDetail.coverOf", { title: book.title })}
|
alt={t("bookDetail.coverOf", { title: book.title })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="192px"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "../../lib/api";
|
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "@/lib/api";
|
||||||
import { BooksGrid, EmptyState } from "../components/BookCard";
|
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
|
||||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
||||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { getServerTranslations } from "../../lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ export default async function BooksPage({
|
|||||||
alt={t("books.coverOf", { name: s.name })}
|
alt={t("books.coverOf", { name: s.name })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
@@ -2,13 +2,13 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "../../../lib/api";
|
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||||
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
||||||
} from "../../components/ui";
|
} from "@/app/components/ui";
|
||||||
import { JobDetailLive } from "../../components/JobDetailLive";
|
import { JobDetailLive } from "@/app/components/JobDetailLive";
|
||||||
import { getServerTranslations } from "../../../lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
|
|
||||||
interface JobDetailPageProps {
|
interface JobDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -587,7 +587,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
<StatBox value={refreshReport.refreshed} label={t("jobDetail.refreshed")} variant="success" />
|
<StatBox
|
||||||
|
value={refreshReport.refreshed}
|
||||||
|
label={t("jobDetail.refreshed")}
|
||||||
|
variant="success"
|
||||||
|
icon={
|
||||||
|
<svg className="w-6 h-6 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<StatBox value={refreshReport.unchanged} label={t("jobDetail.unchanged")} />
|
<StatBox value={refreshReport.unchanged} label={t("jobDetail.unchanged")} />
|
||||||
<StatBox value={refreshReport.errors} label={t("jobDetail.errors")} variant={refreshReport.errors > 0 ? "error" : "default"} />
|
<StatBox value={refreshReport.errors} label={t("jobDetail.errors")} variant={refreshReport.errors > 0 ? "error" : "default"} />
|
||||||
<StatBox value={refreshReport.total_links} label={t("jobDetail.total")} />
|
<StatBox value={refreshReport.total_links} label={t("jobDetail.total")} />
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "../../lib/api";
|
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "@/lib/api";
|
||||||
import { JobsList } from "../components/JobsList";
|
import { JobsList } from "@/app/components/JobsList";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "../components/ui";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "@/app/components/ui";
|
||||||
import { getServerTranslations } from "../../lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -60,30 +60,62 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
async function triggerMetadataBatch(formData: FormData) {
|
async function triggerMetadataBatch(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
const libraryId = formData.get("library_id") as string;
|
const libraryId = formData.get("library_id") as string;
|
||||||
if (!libraryId) return;
|
if (libraryId) {
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await startMetadataBatch(libraryId);
|
result = await startMetadataBatch(libraryId);
|
||||||
} catch {
|
} catch {
|
||||||
// Library may have metadata disabled — ignore silently
|
// Library may have metadata disabled — ignore silently
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
revalidatePath("/jobs");
|
|
||||||
redirect(`/jobs?highlight=${result.id}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function triggerMetadataRefresh(formData: FormData) {
|
async function triggerMetadataRefresh(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
const libraryId = formData.get("library_id") as string;
|
const libraryId = formData.get("library_id") as string;
|
||||||
if (!libraryId) return;
|
if (libraryId) {
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await startMetadataRefresh(libraryId);
|
result = await startMetadataRefresh(libraryId);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
revalidatePath("/jobs");
|
|
||||||
redirect(`/jobs?highlight=${result.id}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -197,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" />
|
<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>
|
</svg>
|
||||||
{t("jobs.groupMetadata")}
|
{t("jobs.groupMetadata")}
|
||||||
<span className="text-xs font-normal text-muted-foreground">({t("jobs.requiresLibrary")})</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button type="submit" formAction={triggerMetadataBatch}
|
<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 { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "@/lib/api";
|
||||||
import { BooksGrid, EmptyState } from "../../../components/BookCard";
|
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
|
||||||
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
|
||||||
import { OffsetPagination } from "../../../components/ui";
|
import { OffsetPagination } from "@/app/components/ui";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getServerTranslations } from "../../../../lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "../../../../../lib/api";
|
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "@/lib/api";
|
||||||
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
|
import { BooksGrid, EmptyState } from "@/app/components/BookCard";
|
||||||
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
|
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
|
||||||
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
import { MarkBookReadButton } from "@/app/components/MarkBookReadButton";
|
||||||
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
|
import nextDynamic from "next/dynamic";
|
||||||
import { MetadataSearchModal } from "../../../../components/MetadataSearchModal";
|
import { OffsetPagination } from "@/app/components/ui";
|
||||||
import { ProwlarrSearchModal } from "../../../../components/ProwlarrSearchModal";
|
import { SafeHtml } from "@/app/components/SafeHtml";
|
||||||
import { OffsetPagination } from "../../../../components/ui";
|
|
||||||
import { SafeHtml } from "../../../../components/SafeHtml";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const EditSeriesForm = nextDynamic(
|
||||||
|
() => 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 { notFound } from "next/navigation";
|
||||||
import { getServerTranslations } from "../../../../../lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -94,7 +102,7 @@ export default async function SeriesDetailPage({
|
|||||||
alt={t("books.coverOf", { name: displayName })}
|
alt={t("books.coverOf", { name: displayName })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="160px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
|
import { fetchLibraries, fetchSeries, fetchSeriesStatuses, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "@/lib/api";
|
||||||
import { OffsetPagination } from "../../../components/ui";
|
import { OffsetPagination } from "@/app/components/ui";
|
||||||
import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton";
|
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
|
||||||
import { SeriesFilters } from "../../../components/SeriesFilters";
|
import { SeriesFilters } from "@/app/components/SeriesFilters";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
import { LibrarySubPageHeader } from "@/app/components/LibrarySubPageHeader";
|
||||||
import { getServerTranslations } from "../../../../lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export default async function LibrarySeriesPage({
|
|||||||
alt={t("books.coverOf", { name: s.name })}
|
alt={t("books.coverOf", { name: s.name })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "../../lib/api";
|
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, getBookCoverUrl, LibraryDto, FolderItem } from "@/lib/api";
|
||||||
import type { TranslationKey } from "../../lib/i18n/fr";
|
import type { TranslationKey } from "@/lib/i18n/fr";
|
||||||
import { getServerTranslations } from "../../lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
import { LibraryActions } from "../components/LibraryActions";
|
import { LibraryActions } from "@/app/components/LibraryActions";
|
||||||
import { LibraryForm } from "../components/LibraryForm";
|
import { LibraryForm } from "@/app/components/LibraryForm";
|
||||||
import { ProviderIcon } from "../components/ProviderIcon";
|
import { ProviderIcon } from "@/app/components/ProviderIcon";
|
||||||
import {
|
import {
|
||||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||||
Button, Badge
|
Button, Badge
|
||||||
} from "../components/ui";
|
} from "@/app/components/ui";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -88,10 +89,12 @@ export default async function LibrariesPage() {
|
|||||||
{/* Thumbnail fan */}
|
{/* Thumbnail fan */}
|
||||||
{thumbnails.length > 0 ? (
|
{thumbnails.length > 0 ? (
|
||||||
<Link href={`/libraries/${lib.id}/series`} className="block relative h-48 overflow-hidden bg-muted/10">
|
<Link href={`/libraries/${lib.id}/series`} className="block relative h-48 overflow-hidden bg-muted/10">
|
||||||
<img
|
<Image
|
||||||
src={thumbnails[0]}
|
src={thumbnails[0]}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute inset-0 w-full h-full object-cover blur-xl scale-110 opacity-40"
|
fill
|
||||||
|
className="object-cover blur-xl scale-110 opacity-40"
|
||||||
|
sizes="(max-width: 768px) 100vw, 33vw"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex items-end justify-center">
|
<div className="absolute inset-0 flex items-end justify-center">
|
||||||
@@ -104,17 +107,20 @@ export default async function LibrariesPage() {
|
|||||||
const cx = Math.cos(rad) * radius;
|
const cx = Math.cos(rad) * radius;
|
||||||
const cy = Math.sin(rad) * radius;
|
const cy = Math.sin(rad) * radius;
|
||||||
return (
|
return (
|
||||||
<img
|
<Image
|
||||||
key={i}
|
key={i}
|
||||||
src={url}
|
src={url}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute w-24 h-36 object-cover shadow-lg"
|
width={96}
|
||||||
|
height={144}
|
||||||
|
className="absolute object-cover shadow-lg"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${cx}px, ${cy}px) rotate(${angle}deg)`,
|
transform: `translate(${cx}px, ${cy}px) rotate(${angle}deg)`,
|
||||||
transformOrigin: 'bottom center',
|
transformOrigin: 'bottom center',
|
||||||
zIndex: count - Math.abs(Math.round(i - mid)),
|
zIndex: count - Math.abs(Math.round(i - mid)),
|
||||||
bottom: '-185px',
|
bottom: '-185px',
|
||||||
}}
|
}}
|
||||||
|
sizes="96px"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { fetchStats, StatsResponse } from "../lib/api";
|
import { fetchStats, fetchUsers, StatsResponse, UserDto } from "@/lib/api";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui";
|
||||||
|
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts";
|
||||||
|
import { PeriodToggle } from "@/app/components/PeriodToggle";
|
||||||
|
import { CurrentlyReadingList, RecentlyReadList } from "@/app/components/ReadingUserFilter";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getServerTranslations } from "../lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
import type { TranslateFunction } from "../lib/i18n/dictionaries";
|
import type { TranslateFunction } from "@/lib/i18n/dictionaries";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -19,84 +22,25 @@ function formatNumber(n: number, locale: string): string {
|
|||||||
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
|
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Donut chart via SVG
|
function formatChartLabel(raw: string, period: "day" | "week" | "month", locale: string): string {
|
||||||
function DonutChart({ data, colors, noDataLabel, locale = "fr" }: { data: { label: string; value: number; color: string }[]; colors?: string[]; noDataLabel?: string; locale?: string }) {
|
const loc = locale === "fr" ? "fr-FR" : "en-US";
|
||||||
const total = data.reduce((sum, d) => sum + d.value, 0);
|
if (period === "month") {
|
||||||
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
// raw = "YYYY-MM"
|
||||||
|
const [y, m] = raw.split("-");
|
||||||
const radius = 40;
|
const d = new Date(Number(y), Number(m) - 1, 1);
|
||||||
const circumference = 2 * Math.PI * radius;
|
return d.toLocaleDateString(loc, { month: "short" });
|
||||||
let offset = 0;
|
}
|
||||||
|
if (period === "week") {
|
||||||
return (
|
// raw = "YYYY-MM-DD" (Monday of the week)
|
||||||
<div className="flex items-center gap-6">
|
const d = new Date(raw + "T00:00:00");
|
||||||
<svg viewBox="0 0 100 100" className="w-32 h-32 shrink-0">
|
return d.toLocaleDateString(loc, { day: "numeric", month: "short" });
|
||||||
{data.map((d, i) => {
|
}
|
||||||
const pct = d.value / total;
|
// day: raw = "YYYY-MM-DD"
|
||||||
const dashLength = pct * circumference;
|
const d = new Date(raw + "T00:00:00");
|
||||||
const currentOffset = offset;
|
return d.toLocaleDateString(loc, { weekday: "short", day: "numeric" });
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bar chart via pure CSS
|
// Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed)
|
||||||
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
|
|
||||||
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
|
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
|
||||||
const pct = max > 0 ? (value / max) * 100 : 0;
|
const pct = max > 0 ? (value / max) * 100 : 0;
|
||||||
return (
|
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();
|
const { t, locale } = await getServerTranslations();
|
||||||
|
|
||||||
let stats: StatsResponse | null = null;
|
let stats: StatsResponse | null = null;
|
||||||
|
let users: UserDto[] = [];
|
||||||
try {
|
try {
|
||||||
stats = await fetchStats();
|
[stats, users] = await Promise.all([
|
||||||
|
fetchStats(period),
|
||||||
|
fetchUsers().catch(() => []),
|
||||||
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch stats:", e);
|
console.error("Failed to fetch stats:", e);
|
||||||
}
|
}
|
||||||
@@ -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 readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
|
||||||
const formatColors = [
|
const formatColors = [
|
||||||
@@ -146,7 +114,6 @@ export default async function DashboardPage() {
|
|||||||
"hsl(170 60% 45%)", "hsl(220 60% 50%)",
|
"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");
|
const noDataLabel = t("common.noData");
|
||||||
|
|
||||||
return (
|
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" />
|
<StatCard icon="size" label={t("dashboard.totalSize")} value={formatBytes(overview.total_size_bytes)} color="warning" />
|
||||||
</div>
|
</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 */}
|
{/* Charts row */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{/* Reading status donut */}
|
{/* Reading status par lecteur */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">{t("dashboard.readingStatus")}</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.readingStatus")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DonutChart
|
{users.length === 0 ? (
|
||||||
locale={locale}
|
<RcDonutChart
|
||||||
noDataLabel={noDataLabel}
|
noDataLabel={noDataLabel}
|
||||||
data={[
|
data={[
|
||||||
{ label: t("status.unread"), value: reading_status.unread, color: readingColors[0] },
|
{ name: t("status.unread"), value: reading_status.unread, color: readingColors[0] },
|
||||||
{ label: t("status.reading"), value: reading_status.reading, color: readingColors[1] },
|
{ name: t("status.reading"), value: reading_status.reading, color: readingColors[1] },
|
||||||
{ label: t("status.read"), value: reading_status.read, color: readingColors[2] },
|
{ name: t("status.read"), value: reading_status.read, color: readingColors[2] },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{users.map((user) => {
|
||||||
|
const total = overview.total_books;
|
||||||
|
const read = user.books_read;
|
||||||
|
const reading = user.books_reading;
|
||||||
|
const unread = Math.max(0, total - read - reading);
|
||||||
|
const readPct = total > 0 ? (read / total) * 100 : 0;
|
||||||
|
const readingPct = total > 0 ? (reading / total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={user.id} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium text-foreground truncate">{user.username}</span>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0 ml-2">
|
||||||
|
<span className="text-success font-medium">{read}</span>
|
||||||
|
{reading > 0 && <span className="text-amber-500 font-medium"> · {reading}</span>}
|
||||||
|
<span className="text-muted-foreground/60"> / {total}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-muted rounded-full overflow-hidden flex">
|
||||||
|
<div className="h-full bg-success transition-all duration-500" style={{ width: `${readPct}%` }} />
|
||||||
|
<div className="h-full bg-amber-500 transition-all duration-500" style={{ width: `${readingPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -200,11 +269,10 @@ export default async function DashboardPage() {
|
|||||||
<CardTitle className="text-base">{t("dashboard.byFormat")}</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.byFormat")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DonutChart
|
<RcDonutChart
|
||||||
locale={locale}
|
|
||||||
noDataLabel={noDataLabel}
|
noDataLabel={noDataLabel}
|
||||||
data={by_format.slice(0, 6).map((f, i) => ({
|
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,
|
value: f.count,
|
||||||
color: formatColors[i % formatColors.length],
|
color: formatColors[i % formatColors.length],
|
||||||
}))}
|
}))}
|
||||||
@@ -218,11 +286,10 @@ export default async function DashboardPage() {
|
|||||||
<CardTitle className="text-base">{t("dashboard.byLibrary")}</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.byLibrary")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DonutChart
|
<RcDonutChart
|
||||||
locale={locale}
|
|
||||||
noDataLabel={noDataLabel}
|
noDataLabel={noDataLabel}
|
||||||
data={by_library.slice(0, 6).map((l, i) => ({
|
data={by_library.slice(0, 6).map((l, i) => ({
|
||||||
label: l.library_name,
|
name: l.library_name,
|
||||||
value: l.book_count,
|
value: l.book_count,
|
||||||
color: formatColors[i % formatColors.length],
|
color: formatColors[i % formatColors.length],
|
||||||
}))}
|
}))}
|
||||||
@@ -239,12 +306,11 @@ export default async function DashboardPage() {
|
|||||||
<CardTitle className="text-base">{t("dashboard.metadataCoverage")}</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.metadataCoverage")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DonutChart
|
<RcDonutChart
|
||||||
locale={locale}
|
|
||||||
noDataLabel={noDataLabel}
|
noDataLabel={noDataLabel}
|
||||||
data={[
|
data={[
|
||||||
{ label: t("dashboard.seriesLinked"), value: metadata.series_linked, color: "hsl(142 60% 45%)" },
|
{ name: 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.seriesUnlinked"), value: metadata.series_unlinked, color: "hsl(220 13% 70%)" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -256,11 +322,10 @@ export default async function DashboardPage() {
|
|||||||
<CardTitle className="text-base">{t("dashboard.byProvider")}</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.byProvider")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DonutChart
|
<RcDonutChart
|
||||||
locale={locale}
|
|
||||||
noDataLabel={noDataLabel}
|
noDataLabel={noDataLabel}
|
||||||
data={metadata.by_provider.map((p, i) => ({
|
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,
|
value: p.count,
|
||||||
color: formatColors[i % formatColors.length],
|
color: formatColors[i % formatColors.length],
|
||||||
}))}
|
}))}
|
||||||
@@ -294,24 +359,32 @@ export default async function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Second row */}
|
{/* Libraries breakdown + Top series */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Monthly additions bar chart */}
|
{by_library.length > 0 && (
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.libraries")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<BarChart
|
<RcStackedBar
|
||||||
noDataLabel={noDataLabel}
|
data={by_library.map((lib) => ({
|
||||||
data={additions_over_time.map((m) => ({
|
name: lib.library_name,
|
||||||
label: m.month.slice(5), // "MM" from "YYYY-MM"
|
read: lib.read_count,
|
||||||
value: m.books_added,
|
reading: lib.reading_count,
|
||||||
}))}
|
unread: lib.unread_count,
|
||||||
color="hsl(198 78% 37%)"
|
sizeLabel: formatBytes(lib.size_bytes),
|
||||||
/>
|
}))}
|
||||||
</CardContent>
|
labels={{
|
||||||
</Card>
|
read: t("status.read"),
|
||||||
|
reading: t("status.reading"),
|
||||||
|
unread: t("status.unread"),
|
||||||
|
books: t("dashboard.books"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Top series */}
|
{/* Top series */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
@@ -319,67 +392,59 @@ export default async function DashboardPage() {
|
|||||||
<CardTitle className="text-base">{t("dashboard.popularSeries")}</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.popularSeries")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<RcHorizontalBar
|
||||||
{top_series.slice(0, 8).map((s, i) => (
|
noDataLabel={t("dashboard.noSeries")}
|
||||||
<HorizontalBar
|
data={top_series.slice(0, 8).map((s) => ({
|
||||||
key={i}
|
name: s.series,
|
||||||
label={s.series}
|
value: s.book_count,
|
||||||
value={s.book_count}
|
subLabel: t("dashboard.readCount", { read: s.read_count, total: s.book_count }),
|
||||||
max={top_series[0]?.book_count || 1}
|
}))}
|
||||||
subLabel={t("dashboard.readCount", { read: s.read_count, total: s.book_count })}
|
color="hsl(142 60% 45%)"
|
||||||
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Libraries breakdown */}
|
{/* Additions line chart – full width */}
|
||||||
{by_library.length > 0 && (
|
<Card hover={false}>
|
||||||
<Card hover={false}>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<CardHeader>
|
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
|
||||||
<CardTitle className="text-base">{t("dashboard.libraries")}</CardTitle>
|
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
|
<RcAreaChart
|
||||||
{by_library.map((lib, i) => (
|
noDataLabel={noDataLabel}
|
||||||
<div key={i} className="space-y-2">
|
data={additions_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_added }))}
|
||||||
<div className="flex justify-between items-baseline">
|
color="hsl(198 78% 37%)"
|
||||||
<span className="font-medium text-foreground text-sm">{lib.library_name}</span>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">{formatBytes(lib.size_bytes)}</span>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<div className="h-3 bg-muted rounded-full overflow-hidden flex">
|
|
||||||
<div
|
{/* Jobs over time – multi-line chart */}
|
||||||
className="h-full transition-all duration-500"
|
<Card hover={false}>
|
||||||
style={{ width: `${(lib.read_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(142 60% 45%)" }}
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
title={`${t("status.read")} : ${lib.read_count}`}
|
<CardTitle className="text-base">{t("dashboard.jobsOverTime")}</CardTitle>
|
||||||
/>
|
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||||
<div
|
</CardHeader>
|
||||||
className="h-full transition-all duration-500"
|
<CardContent>
|
||||||
style={{ width: `${(lib.reading_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(45 93% 47%)" }}
|
<RcMultiLineChart
|
||||||
title={`${t("status.reading")} : ${lib.reading_count}`}
|
noDataLabel={noDataLabel}
|
||||||
/>
|
data={jobs_over_time.map((j) => ({
|
||||||
<div
|
label: formatChartLabel(j.label, period, locale),
|
||||||
className="h-full transition-all duration-500"
|
scan: j.scan,
|
||||||
style={{ width: `${(lib.unread_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(220 13% 70%)" }}
|
rebuild: j.rebuild,
|
||||||
title={`${t("status.unread")} : ${lib.unread_count}`}
|
thumbnail: j.thumbnail,
|
||||||
/>
|
other: j.other,
|
||||||
</div>
|
}))}
|
||||||
<div className="flex gap-3 text-[11px] text-muted-foreground">
|
lines={[
|
||||||
<span>{lib.book_count} {t("dashboard.books").toLowerCase()}</span>
|
{ key: "scan", label: t("dashboard.jobScan"), color: "hsl(198 78% 37%)" },
|
||||||
<span className="text-success">{lib.read_count} {t("status.read").toLowerCase()}</span>
|
{ key: "rebuild", label: t("dashboard.jobRebuild"), color: "hsl(142 60% 45%)" },
|
||||||
<span className="text-warning">{lib.reading_count} {t("status.reading").toLowerCase()}</span>
|
{ key: "thumbnail", label: t("dashboard.jobThumbnail"), color: "hsl(45 93% 47%)" },
|
||||||
</div>
|
{ key: "other", label: t("dashboard.jobOther"), color: "hsl(280 60% 50%)" },
|
||||||
</div>
|
]}
|
||||||
))}
|
/>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick links */}
|
{/* Quick links */}
|
||||||
<QuickLinks t={t} />
|
<QuickLinks t={t} />
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
|
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
|
||||||
import { getServerTranslations } from "../../lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton";
|
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
|
||||||
import { LiveSearchForm } from "../components/LiveSearchForm";
|
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
||||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ProviderIcon } from "../components/ProviderIcon";
|
import { ProviderIcon } from "@/app/components/ProviderIcon";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ export default async function SeriesPage({
|
|||||||
alt={t("books.coverOf", { name: s.name })}
|
alt={t("books.coverOf", { name: s.name })}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "@/app/components/ui";
|
||||||
import { ProviderIcon } from "../components/ProviderIcon";
|
import { ProviderIcon } from "@/app/components/ProviderIcon";
|
||||||
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto } from "../../lib/api";
|
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary, StatusMappingDto, UserDto } from "@/lib/api";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "@/lib/i18n/context";
|
||||||
import type { Locale } from "../../lib/i18n/types";
|
import type { Locale } from "@/lib/i18n/types";
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
initialSettings: Settings;
|
initialSettings: Settings;
|
||||||
initialCacheStats: CacheStats;
|
initialCacheStats: CacheStats;
|
||||||
initialThumbnailStats: ThumbnailStats;
|
initialThumbnailStats: ThumbnailStats;
|
||||||
|
users: UserDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats }: SettingsPageProps) {
|
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats, users }: SettingsPageProps) {
|
||||||
const { t, locale, setLocale } = useTranslation();
|
const { t, locale, setLocale } = useTranslation();
|
||||||
const [settings, setSettings] = useState<Settings>({
|
const [settings, setSettings] = useState<Settings>({
|
||||||
...initialSettings,
|
...initialSettings,
|
||||||
@@ -29,6 +30,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
const [komgaUrl, setKomgaUrl] = useState("");
|
const [komgaUrl, setKomgaUrl] = useState("");
|
||||||
const [komgaUsername, setKomgaUsername] = useState("");
|
const [komgaUsername, setKomgaUsername] = useState("");
|
||||||
const [komgaPassword, setKomgaPassword] = useState("");
|
const [komgaPassword, setKomgaPassword] = useState("");
|
||||||
|
const [komgaUserId, setKomgaUserId] = useState(users[0]?.id ?? "");
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [syncResult, setSyncResult] = useState<KomgaSyncResponse | null>(null);
|
const [syncResult, setSyncResult] = useState<KomgaSyncResponse | null>(null);
|
||||||
const [syncError, setSyncError] = useState<string | null>(null);
|
const [syncError, setSyncError] = useState<string | null>(null);
|
||||||
@@ -104,6 +106,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
if (data) {
|
if (data) {
|
||||||
if (data.url) setKomgaUrl(data.url);
|
if (data.url) setKomgaUrl(data.url);
|
||||||
if (data.username) setKomgaUsername(data.username);
|
if (data.username) setKomgaUsername(data.username);
|
||||||
|
if (data.user_id) setKomgaUserId(data.user_id);
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, [fetchReports]);
|
}, [fetchReports]);
|
||||||
@@ -128,7 +131,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
const response = await fetch("/api/komga/sync", {
|
const response = await fetch("/api/komga/sync", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword }),
|
body: JSON.stringify({ url: komgaUrl, username: komgaUsername, password: komgaPassword, user_id: komgaUserId }),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -140,7 +143,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
fetch("/api/settings/komga", {
|
fetch("/api/settings/komga", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername } }),
|
body: JSON.stringify({ value: { url: komgaUrl, username: komgaUsername, user_id: komgaUserId } }),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -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 = [
|
const tabs = [
|
||||||
{ id: "general" as const, label: t("settings.general"), icon: "settings" as const },
|
{ id: "general" as const, label: t("settings.general"), icon: "settings" as const },
|
||||||
{ id: "integrations" as const, label: t("settings.integrations"), icon: "refresh" as const },
|
{ id: "integrations" as const, label: t("settings.integrations"), icon: "refresh" as const },
|
||||||
|
{ id: "notifications" as const, label: t("settings.notifications"), icon: "bell" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -619,16 +623,29 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
<FormField className="flex-1">
|
<FormField className="flex-1">
|
||||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.password")}</label>
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.password")}</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="password"
|
type="password" autoComplete="off"
|
||||||
value={komgaPassword}
|
value={komgaPassword}
|
||||||
onChange={(e) => setKomgaPassword(e.target.value)}
|
onChange={(e) => setKomgaPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
|
|
||||||
|
{users.length > 0 && (
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("users.title")}</label>
|
||||||
|
<FormSelect value={komgaUserId} onChange={(e) => setKomgaUserId(e.target.value)}>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>{u.username}</option>
|
||||||
|
))}
|
||||||
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleKomgaSync}
|
onClick={handleKomgaSync}
|
||||||
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword}
|
disabled={isSyncing || !komgaUrl || !komgaUsername || !komgaPassword || !komgaUserId}
|
||||||
>
|
>
|
||||||
{isSyncing ? (
|
{isSyncing ? (
|
||||||
<>
|
<>
|
||||||
@@ -734,7 +751,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{new Date(r.created_at).toLocaleString()}
|
{new Date(r.created_at).toLocaleString(locale)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground truncate ml-2" title={r.komga_url}>
|
<span className="text-xs text-muted-foreground truncate ml-2" title={r.komga_url}>
|
||||||
{r.komga_url}
|
{r.komga_url}
|
||||||
@@ -826,6 +843,11 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
{activeTab === "notifications" && (<>
|
||||||
|
{/* Telegram Notifications */}
|
||||||
|
<TelegramCard handleUpdateSetting={handleUpdateSetting} />
|
||||||
|
</>)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -949,7 +971,7 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
|
|||||||
{t("settings.googleBooksKey")}
|
{t("settings.googleBooksKey")}
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="password"
|
type="password" autoComplete="off"
|
||||||
placeholder={t("settings.googleBooksPlaceholder")}
|
placeholder={t("settings.googleBooksPlaceholder")}
|
||||||
value={apiKeys.google_books || ""}
|
value={apiKeys.google_books || ""}
|
||||||
onChange={(e) => setApiKeys({ ...apiKeys, google_books: e.target.value })}
|
onChange={(e) => setApiKeys({ ...apiKeys, google_books: e.target.value })}
|
||||||
@@ -964,7 +986,7 @@ function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (
|
|||||||
{t("settings.comicvineKey")}
|
{t("settings.comicvineKey")}
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="password"
|
type="password" autoComplete="off"
|
||||||
placeholder={t("settings.comicvinePlaceholder")}
|
placeholder={t("settings.comicvinePlaceholder")}
|
||||||
value={apiKeys.comicvine || ""}
|
value={apiKeys.comicvine || ""}
|
||||||
onChange={(e) => setApiKeys({ ...apiKeys, comicvine: e.target.value })}
|
onChange={(e) => setApiKeys({ ...apiKeys, comicvine: e.target.value })}
|
||||||
@@ -1306,7 +1328,7 @@ function ProwlarrCard({ handleUpdateSetting }: { handleUpdateSetting: (key: stri
|
|||||||
<FormField className="flex-1">
|
<FormField className="flex-1">
|
||||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrApiKey")}</label>
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.prowlarrApiKey")}</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="password"
|
type="password" autoComplete="off"
|
||||||
placeholder={t("settings.prowlarrApiKeyPlaceholder")}
|
placeholder={t("settings.prowlarrApiKeyPlaceholder")}
|
||||||
value={prowlarrApiKey}
|
value={prowlarrApiKey}
|
||||||
onChange={(e) => setProwlarrApiKey(e.target.value)}
|
onChange={(e) => setProwlarrApiKey(e.target.value)}
|
||||||
@@ -1444,7 +1466,7 @@ function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: s
|
|||||||
<FormField className="flex-1">
|
<FormField className="flex-1">
|
||||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.qbittorrentPassword")}</label>
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">{t("settings.qbittorrentPassword")}</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
type="password"
|
type="password" autoComplete="off"
|
||||||
value={qbPassword}
|
value={qbPassword}
|
||||||
onChange={(e) => setQbPassword(e.target.value)}
|
onChange={(e) => setQbPassword(e.target.value)}
|
||||||
onBlur={() => saveQbittorrent()}
|
onBlur={() => saveQbittorrent()}
|
||||||
@@ -1480,3 +1502,254 @@ function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: s
|
|||||||
</Card>
|
</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";
|
import SettingsPage from "./SettingsPage";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -23,5 +23,7 @@ export default async function SettingsPageWrapper() {
|
|||||||
directory: "/data/thumbnails"
|
directory: "/data/thumbnails"
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} />;
|
const users = await fetchUsers().catch(() => []);
|
||||||
|
|
||||||
|
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} users={users} />;
|
||||||
}
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -21,19 +21,16 @@ export async function GET(
|
|||||||
const response = await fetch(apiUrl.toString(), {
|
const response = await fetch(apiUrl.toString(), {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return new NextResponse(`Failed to fetch image: ${response.status}`, {
|
return new NextResponse(`Failed to fetch image: ${response.status}`, {
|
||||||
status: response.status
|
status: response.status
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer le content-type et les données
|
|
||||||
const contentType = response.headers.get("content-type") || "image/webp";
|
const contentType = response.headers.get("content-type") || "image/webp";
|
||||||
const imageBuffer = await response.arrayBuffer();
|
|
||||||
|
return new NextResponse(response.body, {
|
||||||
// Retourner l'image avec le bon content-type
|
|
||||||
return new NextResponse(imageBuffer, {
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Cache-Control": "public, max-age=300",
|
"Cache-Control": "public, max-age=300",
|
||||||
|
|||||||
@@ -6,28 +6,46 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ bookId: string }> }
|
{ params }: { params: Promise<{ bookId: string }> }
|
||||||
) {
|
) {
|
||||||
const { bookId } = await params;
|
const { bookId } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { baseUrl, token } = config();
|
const { baseUrl, token } = config();
|
||||||
|
const ifNoneMatch = request.headers.get("if-none-match");
|
||||||
|
|
||||||
|
const fetchHeaders: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
if (ifNoneMatch) {
|
||||||
|
fetchHeaders["If-None-Match"] = ifNoneMatch;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, {
|
const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: fetchHeaders,
|
||||||
|
next: { revalidate: 86400 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Forward 304 Not Modified as-is
|
||||||
|
if (response.status === 304) {
|
||||||
|
return new NextResponse(null, { status: 304 });
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
|
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
|
||||||
status: response.status
|
status: response.status
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type") || "image/webp";
|
const contentType = response.headers.get("content-type") || "image/webp";
|
||||||
const imageBuffer = await response.arrayBuffer();
|
const etag = response.headers.get("etag");
|
||||||
|
|
||||||
return new NextResponse(imageBuffer, {
|
const headers: Record<string, string> = {
|
||||||
headers: {
|
"Content-Type": contentType,
|
||||||
"Content-Type": contentType,
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
};
|
||||||
},
|
if (etag) {
|
||||||
});
|
headers["ETag"] = etag;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(response.body, { headers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching thumbnail:", error);
|
console.error("Error fetching thumbnail:", error);
|
||||||
return new NextResponse("Failed to fetch thumbnail", { status: 500 });
|
return new NextResponse("Failed to fetch thumbnail", { status: 500 });
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ export async function GET(request: NextRequest) {
|
|||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
controller.enqueue(new TextEncoder().encode(""));
|
controller.enqueue(new TextEncoder().encode(""));
|
||||||
|
|
||||||
let lastData: string | null = null;
|
let lastData: string | null = null;
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
let consecutiveErrors = 0;
|
let consecutiveErrors = 0;
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const fetchJobs = async () => {
|
const fetchJobs = async () => {
|
||||||
if (!isActive) return;
|
if (!isActive) return;
|
||||||
@@ -25,51 +26,52 @@ export async function GET(request: NextRequest) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const dataStr = JSON.stringify(data);
|
const dataStr = JSON.stringify(data);
|
||||||
|
|
||||||
// Send if data changed
|
// Send only if data changed
|
||||||
if (dataStr !== lastData && isActive) {
|
if (dataStr !== lastData && isActive) {
|
||||||
lastData = dataStr;
|
lastData = dataStr;
|
||||||
try {
|
try {
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Controller closed, ignore
|
|
||||||
isActive = false;
|
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) {
|
} catch (error) {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
consecutiveErrors++;
|
consecutiveErrors++;
|
||||||
// Only log first failure and every 30th to avoid spam
|
|
||||||
if (consecutiveErrors === 1 || consecutiveErrors % 30 === 0) {
|
if (consecutiveErrors === 1 || consecutiveErrors % 30 === 0) {
|
||||||
console.warn(`SSE fetch error (${consecutiveErrors} consecutive):`, error);
|
console.warn(`SSE fetch error (${consecutiveErrors} consecutive):`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial fetch
|
const restartInterval = (ms: number) => {
|
||||||
|
if (intervalId !== null) clearInterval(intervalId);
|
||||||
|
intervalId = setInterval(fetchJobs, ms);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial fetch + start polling
|
||||||
await fetchJobs();
|
await fetchJobs();
|
||||||
|
|
||||||
// Poll every 2 seconds
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
if (!isActive) {
|
|
||||||
clearInterval(interval);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fetchJobs();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
request.signal.addEventListener("abort", () => {
|
request.signal.addEventListener("abort", () => {
|
||||||
isActive = false;
|
isActive = false;
|
||||||
clearInterval(interval);
|
if (intervalId !== null) clearInterval(intervalId);
|
||||||
controller.close();
|
controller.close();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(stream, {
|
return new Response(stream, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { memo, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BookDto, ReadingStatus } from "../../lib/api";
|
import { BookDto, ReadingStatus } from "../../lib/api";
|
||||||
@@ -17,7 +17,7 @@ interface BookCardProps {
|
|||||||
readingStatus?: ReadingStatus;
|
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 [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [hasError, setHasError] = 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"
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||||
onLoad={() => setIsLoaded(true)}
|
onLoad={() => setIsLoaded(true)}
|
||||||
onError={() => setHasError(true)}
|
onError={() => setHasError(true)}
|
||||||
unoptimized
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function BookCard({ book, readingStatus }: BookCardProps) {
|
export const BookCard = memo(function BookCard({ book, readingStatus }: BookCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
|
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
|
||||||
const status = readingStatus ?? book.reading_status;
|
const status = readingStatus ?? book.reading_status;
|
||||||
@@ -129,7 +128,7 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
interface BooksGridProps {
|
interface BooksGridProps {
|
||||||
books: (BookDto & { coverUrl?: string })[];
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
import { JobProgress } from "./JobProgress";
|
import { JobProgress } from "./JobProgress";
|
||||||
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon } from "./ui";
|
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon, Tooltip } from "./ui";
|
||||||
|
|
||||||
interface JobRowProps {
|
interface JobRowProps {
|
||||||
job: {
|
job: {
|
||||||
@@ -21,6 +21,7 @@ interface JobRowProps {
|
|||||||
indexed_files: number;
|
indexed_files: number;
|
||||||
removed_files: number;
|
removed_files: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
|
refreshed?: number;
|
||||||
} | null;
|
} | null;
|
||||||
progress_percent: number | null;
|
progress_percent: number | null;
|
||||||
processed_files: number | null;
|
processed_files: number | null;
|
||||||
@@ -117,49 +118,74 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
<div className="flex items-center gap-3 text-xs">
|
<div className="flex items-center gap-3 text-xs">
|
||||||
{/* Files: indexed count */}
|
{/* Files: indexed count */}
|
||||||
{indexed > 0 && (
|
{indexed > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 text-success" title={t("jobRow.filesIndexed", { count: indexed })}>
|
<Tooltip label={t("jobRow.filesIndexed", { count: indexed })}>
|
||||||
<Icon name="document" size="sm" />
|
<span className="inline-flex items-center gap-1 text-success">
|
||||||
{indexed}
|
<Icon name="document" size="sm" />
|
||||||
</span>
|
{indexed}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Removed files */}
|
{/* Removed files */}
|
||||||
{removed > 0 && (
|
{removed > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 text-warning" title={t("jobRow.filesRemoved", { count: removed })}>
|
<Tooltip label={t("jobRow.filesRemoved", { count: removed })}>
|
||||||
<Icon name="trash" size="sm" />
|
<span className="inline-flex items-center gap-1 text-warning">
|
||||||
{removed}
|
<Icon name="trash" size="sm" />
|
||||||
</span>
|
{removed}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Thumbnails */}
|
{/* Thumbnails */}
|
||||||
{hasThumbnailPhase && job.total_files != null && job.total_files > 0 && (
|
{hasThumbnailPhase && job.total_files != null && job.total_files > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 text-primary" title={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
|
<Tooltip label={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
|
||||||
<Icon name="image" size="sm" />
|
<span className="inline-flex items-center gap-1 text-primary">
|
||||||
{job.total_files}
|
<Icon name="image" size="sm" />
|
||||||
</span>
|
{job.total_files}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Metadata batch: series processed */}
|
{/* Metadata batch: series processed */}
|
||||||
{isMetadataBatch && job.total_files != null && job.total_files > 0 && (
|
{isMetadataBatch && job.total_files != null && job.total_files > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataProcessed", { count: job.total_files })}>
|
<Tooltip label={t("jobRow.metadataProcessed", { count: job.total_files })}>
|
||||||
<Icon name="tag" size="sm" />
|
<span className="inline-flex items-center gap-1 text-info">
|
||||||
{job.total_files}
|
<Icon name="tag" size="sm" />
|
||||||
</span>
|
{job.total_files}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Metadata refresh: links refreshed */}
|
{/* Metadata refresh: total links + refreshed count */}
|
||||||
{isMetadataRefresh && job.total_files != null && job.total_files > 0 && (
|
{isMetadataRefresh && job.total_files != null && job.total_files > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataRefreshed", { count: job.total_files })}>
|
<Tooltip label={t("jobRow.metadataLinks", { count: job.total_files })}>
|
||||||
<Icon name="tag" size="sm" />
|
<span className="inline-flex items-center gap-1 text-info">
|
||||||
{job.total_files}
|
<Icon name="tag" size="sm" />
|
||||||
</span>
|
{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 */}
|
||||||
{errors > 0 && (
|
{errors > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 text-error" title={t("jobRow.errors", { count: errors })}>
|
<Tooltip label={t("jobRow.errors", { count: errors })}>
|
||||||
<Icon name="warning" size="sm" />
|
<span className="inline-flex items-center gap-1 text-error">
|
||||||
{errors}
|
<Icon name="warning" size="sm" />
|
||||||
</span>
|
{errors}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Scanned only (no other stats) */}
|
{/* Scanned only (no other stats) */}
|
||||||
{indexed === 0 && removed === 0 && errors === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && scanned > 0 && (
|
{indexed === 0 && removed === 0 && errors === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && scanned > 0 && (
|
||||||
<span className="text-sm text-muted-foreground">{t("jobRow.scanned", { count: scanned })}</span>
|
<Tooltip label={t("jobRow.scanned", { count: scanned })}>
|
||||||
|
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Icon name="search" size="sm" />
|
||||||
|
{scanned}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Nothing to show */}
|
{/* Nothing to show */}
|
||||||
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && (
|
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && (
|
||||||
@@ -177,18 +203,25 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/jobs/${job.id}`}
|
href={`/jobs/${job.id}`}
|
||||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
className="inline-flex items-center justify-center gap-1.5 h-7 px-2.5 text-xs font-medium rounded-md bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
{t("jobRow.view")}
|
{t("jobRow.view")}
|
||||||
</Link>
|
</Link>
|
||||||
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
|
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="xs"
|
||||||
onClick={() => onCancel(job.id)}
|
onClick={() => onCancel(job.id)}
|
||||||
>
|
>
|
||||||
|
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -54,21 +54,62 @@ export function JobsIndicator() {
|
|||||||
const [popinStyle, setPopinStyle] = useState<React.CSSProperties>({});
|
const [popinStyle, setPopinStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchActiveJobs = async () => {
|
let eventSource: EventSource | null = null;
|
||||||
try {
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
const response = await fetch("/api/jobs/active");
|
|
||||||
if (response.ok) {
|
const connect = () => {
|
||||||
const jobs = await response.json();
|
if (eventSource) {
|
||||||
setActiveJobs(jobs);
|
eventSource.close();
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
} catch (error) {
|
};
|
||||||
console.error("Failed to fetch jobs:", error);
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchActiveJobs();
|
const handleVisibilityChange = () => {
|
||||||
const interval = setInterval(fetchActiveJobs, 2000);
|
if (document.hidden) {
|
||||||
return () => clearInterval(interval);
|
disconnect();
|
||||||
|
} else {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Position the popin relative to the button
|
// Position the popin relative to the button
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface Job {
|
|||||||
indexed_files: number;
|
indexed_files: number;
|
||||||
removed_files: number;
|
removed_files: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
|
refreshed?: number;
|
||||||
} | null;
|
} | null;
|
||||||
progress_percent: number | null;
|
progress_percent: number | null;
|
||||||
processed_files: number | null;
|
processed_files: number | null;
|
||||||
@@ -40,34 +41,21 @@ function formatDuration(start: string, end: string | null): string {
|
|||||||
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
|
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) {
|
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
|
||||||
const { t } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
const [jobs, setJobs] = useState(initialJobs);
|
const [jobs, setJobs] = useState(initialJobs);
|
||||||
|
|
||||||
const formatDate = (dateStr: string): string => {
|
const formatDate = (dateStr: string): string => {
|
||||||
const parts = getDateParts(dateStr);
|
const date = new Date(dateStr);
|
||||||
if (parts.useDate) {
|
if (isNaN(date.getTime())) return dateStr;
|
||||||
return parts.date.toLocaleDateString();
|
const loc = locale === "fr" ? "fr-FR" : "en-US";
|
||||||
}
|
return date.toLocaleString(loc, {
|
||||||
if (parts.mins < 1) return t("time.justNow");
|
day: "2-digit",
|
||||||
if (parts.hours > 0) return t("time.hoursAgo", { count: parts.hours });
|
month: "2-digit",
|
||||||
return t("time.minutesAgo", { count: parts.mins });
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Refresh jobs list via SSE
|
// Refresh jobs list via SSE
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ type ButtonVariant =
|
|||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "xs" | "sm" | "md" | "lg";
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles: Record<ButtonVariant, string> = {
|
const variantStyles: Record<ButtonVariant, string> = {
|
||||||
@@ -33,6 +33,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles: Record<string, string> = {
|
const sizeStyles: Record<string, string> = {
|
||||||
|
xs: "h-7 px-2.5 text-xs rounded-md",
|
||||||
sm: "h-9 px-3 text-xs rounded-md",
|
sm: "h-9 px-3 text-xs rounded-md",
|
||||||
md: "h-10 px-4 py-2 text-sm rounded-md",
|
md: "h-10 px-4 py-2 text-sm rounded-md",
|
||||||
lg: "h-11 px-8 text-base rounded-md",
|
lg: "h-11 px-8 text-base rounded-md",
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ type IconName =
|
|||||||
| "warning"
|
| "warning"
|
||||||
| "tag"
|
| "tag"
|
||||||
| "document"
|
| "document"
|
||||||
| "authors";
|
| "authors"
|
||||||
|
| "bell";
|
||||||
|
|
||||||
type IconSize = "sm" | "md" | "lg" | "xl";
|
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",
|
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",
|
document: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
|
||||||
authors: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z",
|
authors: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z",
|
||||||
|
bell: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9",
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorClasses: Partial<Record<IconName, string>> = {
|
const colorClasses: Partial<Record<IconName, string>> = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ interface StatBoxProps {
|
|||||||
value: ReactNode;
|
value: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
variant?: "default" | "primary" | "success" | "warning" | "error";
|
variant?: "default" | "primary" | "success" | "warning" | "error";
|
||||||
|
icon?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,10 +24,13 @@ const valueVariantStyles: Record<string, string> = {
|
|||||||
error: "text-destructive",
|
error: "text-destructive",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
|
export function StatBox({ value, label, variant = "default", icon, className = "" }: StatBoxProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`text-center p-4 rounded-lg transition-colors duration-200 ${variantStyles[variant]} ${className}`}>
|
<div className={`text-center p-4 rounded-lg transition-colors duration-200 ${variantStyles[variant]} ${className}`}>
|
||||||
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span>
|
<div className={`flex items-center justify-center gap-1.5 ${valueVariantStyles[variant]}`}>
|
||||||
|
{icon && <span className="text-xl">{icon}</span>}
|
||||||
|
<span className="text-3xl font-bold">{value}</span>
|
||||||
|
</div>
|
||||||
<span className={`text-xs text-muted-foreground`}>{label}</span>
|
<span className={`text-xs text-muted-foreground`}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
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";
|
} from "./Form";
|
||||||
export { PageIcon, NavIcon, Icon } from "./Icon";
|
export { PageIcon, NavIcon, Icon } from "./Icon";
|
||||||
export { CursorPagination, OffsetPagination } from "./Pagination";
|
export { CursorPagination, OffsetPagination } from "./Pagination";
|
||||||
|
export { Tooltip } from "./Tooltip";
|
||||||
|
|||||||
@@ -1,130 +1,27 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "./theme-provider";
|
import { ThemeProvider } from "./theme-provider";
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
import { LocaleProvider } from "@/lib/i18n/context";
|
||||||
import { JobsIndicator } from "./components/JobsIndicator";
|
import { getServerLocale } from "@/lib/i18n/server";
|
||||||
import { NavIcon, Icon } from "./components/ui";
|
|
||||||
import { MobileNav } from "./components/MobileNav";
|
|
||||||
import { LocaleProvider } from "../lib/i18n/context";
|
|
||||||
import { getServerLocale, getServerTranslations } from "../lib/i18n/server";
|
|
||||||
import type { TranslationKey } from "../lib/i18n/fr";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "StripStream Backoffice",
|
title: "StripStream Backoffice",
|
||||||
description: "Administration backoffice pour StripStream Librarian"
|
description: "Administration backoffice pour StripStream Librarian"
|
||||||
};
|
};
|
||||||
|
|
||||||
type NavItem = {
|
|
||||||
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
|
||||||
labelKey: TranslationKey;
|
|
||||||
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
|
|
||||||
};
|
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
|
||||||
{ href: "/", labelKey: "nav.dashboard", icon: "dashboard" },
|
|
||||||
{ href: "/books", labelKey: "nav.books", icon: "books" },
|
|
||||||
{ href: "/series", labelKey: "nav.series", icon: "series" },
|
|
||||||
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
|
|
||||||
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
|
|
||||||
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
|
|
||||||
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||||
const locale = await getServerLocale();
|
const locale = await getServerLocale();
|
||||||
const { t } = await getServerTranslations();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
|
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<LocaleProvider initialLocale={locale}>
|
<LocaleProvider initialLocale={locale}>
|
||||||
{/* Header avec effet glassmorphism */}
|
|
||||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
|
|
||||||
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
|
|
||||||
{/* Brand */}
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/logo.png"
|
|
||||||
alt="StripStream"
|
|
||||||
width={36}
|
|
||||||
height={36}
|
|
||||||
className="rounded-lg"
|
|
||||||
/>
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-xl font-bold tracking-tight text-foreground">
|
|
||||||
StripStream
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground font-medium hidden md:inline">
|
|
||||||
{t("common.backoffice")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Navigation Links */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="hidden md:flex items-center gap-1">
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<NavLink key={item.href} href={item.href} title={t(item.labelKey)}>
|
|
||||||
<NavIcon name={item.icon} />
|
|
||||||
<span className="ml-2 hidden lg:inline">{t(item.labelKey)}</span>
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
|
|
||||||
<JobsIndicator />
|
|
||||||
<Link
|
|
||||||
href="/settings"
|
|
||||||
className="hidden md:flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
|
||||||
title={t("nav.settings")}
|
|
||||||
>
|
|
||||||
<Icon name="settings" size="md" />
|
|
||||||
</Link>
|
|
||||||
<ThemeToggle />
|
|
||||||
<MobileNav navItems={navItems.map(item => ({ ...item, label: t(item.labelKey) }))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
|
||||||
{children}
|
{children}
|
||||||
</main>
|
|
||||||
</LocaleProvider>
|
</LocaleProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation Link Component
|
|
||||||
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
title={title}
|
|
||||||
className="
|
|
||||||
flex items-center
|
|
||||||
px-2 lg:px-3 py-2
|
|
||||||
rounded-lg
|
|
||||||
text-sm font-medium
|
|
||||||
text-muted-foreground
|
|
||||||
hover:text-foreground
|
|
||||||
hover:bg-accent
|
|
||||||
transition-colors duration-200
|
|
||||||
active:scale-[0.98]
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -32,6 +32,7 @@ export type IndexJobDto = {
|
|||||||
removed_files: number;
|
removed_files: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
warnings: number;
|
warnings: number;
|
||||||
|
refreshed?: number;
|
||||||
} | null;
|
} | null;
|
||||||
progress_percent: number | null;
|
progress_percent: number | null;
|
||||||
processed_files: number | null;
|
processed_files: number | null;
|
||||||
@@ -44,6 +45,17 @@ export type TokenDto = {
|
|||||||
scope: string;
|
scope: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
revoked_at: string | null;
|
revoked_at: string | null;
|
||||||
|
user_id?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserDto = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
token_count: number;
|
||||||
|
books_read: number;
|
||||||
|
books_reading: number;
|
||||||
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FolderItem = {
|
export type FolderItem = {
|
||||||
@@ -141,7 +153,7 @@ export function config() {
|
|||||||
|
|
||||||
export async function apiFetch<T>(
|
export async function apiFetch<T>(
|
||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit & { next?: { revalidate?: number; tags?: string[] } },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { baseUrl, token } = config();
|
const { baseUrl, token } = config();
|
||||||
const headers = new Headers(init?.headers || {});
|
const headers = new Headers(init?.headers || {});
|
||||||
@@ -150,10 +162,22 @@ export async function apiFetch<T>(
|
|||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Impersonation : injecte X-As-User si un user est sélectionné dans le backoffice
|
||||||
|
try {
|
||||||
|
const { cookies } = await import("next/headers");
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const asUserId = cookieStore.get("as_user_id")?.value;
|
||||||
|
if (asUserId) headers.set("X-As-User", asUserId);
|
||||||
|
} catch {
|
||||||
|
// Hors contexte Next.js (tests, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { next: nextOptions, ...restInit } = init ?? {};
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}${path}`, {
|
const res = await fetch(`${baseUrl}${path}`, {
|
||||||
...init,
|
...restInit,
|
||||||
headers,
|
headers,
|
||||||
cache: "no-store",
|
...(nextOptions ? { next: nextOptions } : { cache: "no-store" as const }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -168,7 +192,7 @@ export async function apiFetch<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLibraries() {
|
export async function fetchLibraries() {
|
||||||
return apiFetch<LibraryDto[]>("/libraries");
|
return apiFetch<LibraryDto[]>("/libraries", { next: { revalidate: 30 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createLibrary(name: string, rootPath: string) {
|
export async function createLibrary(name: string, rootPath: string) {
|
||||||
@@ -265,10 +289,32 @@ export async function listTokens() {
|
|||||||
return apiFetch<TokenDto[]>("/admin/tokens");
|
return apiFetch<TokenDto[]>("/admin/tokens");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createToken(name: string, scope: string) {
|
export async function createToken(name: string, scope: string, userId?: string) {
|
||||||
return apiFetch<{ token: string }>("/admin/tokens", {
|
return apiFetch<{ token: string }>("/admin/tokens", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ name, scope }),
|
body: JSON.stringify({ name, scope, ...(userId ? { user_id: userId } : {}) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUsers(): Promise<UserDto[]> {
|
||||||
|
return apiFetch<UserDto[]>("/admin/users");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(username: string): Promise<UserDto> {
|
||||||
|
return apiFetch<UserDto>("/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: string): Promise<void> {
|
||||||
|
return apiFetch<void>(`/admin/users/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: string, username: string): Promise<void> {
|
||||||
|
return apiFetch<void>(`/admin/users/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +326,13 @@ export async function deleteToken(id: string) {
|
|||||||
return apiFetch<void>(`/admin/tokens/${id}/delete`, { method: "POST" });
|
return apiFetch<void>(`/admin/tokens/${id}/delete`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateToken(id: string, userId: string | null) {
|
||||||
|
return apiFetch<void>(`/admin/tokens/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ user_id: userId || null }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchBooks(
|
export async function fetchBooks(
|
||||||
libraryId?: string,
|
libraryId?: string,
|
||||||
series?: string,
|
series?: string,
|
||||||
@@ -340,6 +393,7 @@ export async function fetchAllSeries(
|
|||||||
seriesStatus?: string,
|
seriesStatus?: string,
|
||||||
hasMissing?: boolean,
|
hasMissing?: boolean,
|
||||||
metadataProvider?: string,
|
metadataProvider?: string,
|
||||||
|
author?: string,
|
||||||
): Promise<SeriesPageDto> {
|
): Promise<SeriesPageDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (libraryId) params.set("library_id", libraryId);
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
@@ -349,6 +403,7 @@ export async function fetchAllSeries(
|
|||||||
if (seriesStatus) params.set("series_status", seriesStatus);
|
if (seriesStatus) params.set("series_status", seriesStatus);
|
||||||
if (hasMissing) params.set("has_missing", "true");
|
if (hasMissing) params.set("has_missing", "true");
|
||||||
if (metadataProvider) params.set("metadata_provider", metadataProvider);
|
if (metadataProvider) params.set("metadata_provider", metadataProvider);
|
||||||
|
if (author) params.set("author", author);
|
||||||
params.set("page", page.toString());
|
params.set("page", page.toString());
|
||||||
params.set("limit", limit.toString());
|
params.set("limit", limit.toString());
|
||||||
|
|
||||||
@@ -356,7 +411,7 @@ export async function fetchAllSeries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSeriesStatuses(): Promise<string[]> {
|
export async function fetchSeriesStatuses(): Promise<string[]> {
|
||||||
return apiFetch<string[]>("/series/statuses");
|
return apiFetch<string[]>("/series/statuses", { next: { revalidate: 300 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchBooks(
|
export async function searchBooks(
|
||||||
@@ -421,7 +476,7 @@ export type ThumbnailStats = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getSettings() {
|
export async function getSettings() {
|
||||||
return apiFetch<Settings>("/settings");
|
return apiFetch<Settings>("/settings", { next: { revalidate: 60 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSetting(key: string, value: unknown) {
|
export async function updateSetting(key: string, value: unknown) {
|
||||||
@@ -432,7 +487,7 @@ export async function updateSetting(key: string, value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCacheStats() {
|
export async function getCacheStats() {
|
||||||
return apiFetch<CacheStats>("/settings/cache/stats");
|
return apiFetch<CacheStats>("/settings/cache/stats", { next: { revalidate: 30 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearCache() {
|
export async function clearCache() {
|
||||||
@@ -442,7 +497,7 @@ export async function clearCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getThumbnailStats() {
|
export async function getThumbnailStats() {
|
||||||
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats");
|
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats", { next: { revalidate: 30 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status mappings
|
// Status mappings
|
||||||
@@ -453,7 +508,7 @@ export type StatusMappingDto = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchStatusMappings(): Promise<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> {
|
export async function upsertStatusMapping(provider_status: string, mapped_status: string): Promise<StatusMappingDto> {
|
||||||
@@ -546,19 +601,61 @@ export type MetadataStats = {
|
|||||||
by_provider: ProviderCount[];
|
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 = {
|
export type StatsResponse = {
|
||||||
overview: StatsOverview;
|
overview: StatsOverview;
|
||||||
reading_status: ReadingStatusStats;
|
reading_status: ReadingStatusStats;
|
||||||
|
currently_reading: CurrentlyReadingItem[];
|
||||||
|
recently_read: RecentlyReadItem[];
|
||||||
|
reading_over_time: MonthlyReading[];
|
||||||
|
users_reading_over_time: UserMonthlyReading[];
|
||||||
by_format: FormatCount[];
|
by_format: FormatCount[];
|
||||||
by_language: LanguageCount[];
|
by_language: LanguageCount[];
|
||||||
by_library: LibraryStatsItem[];
|
by_library: LibraryStatsItem[];
|
||||||
top_series: TopSeriesItem[];
|
top_series: TopSeriesItem[];
|
||||||
additions_over_time: MonthlyAdditions[];
|
additions_over_time: MonthlyAdditions[];
|
||||||
|
jobs_over_time: JobTimePoint[];
|
||||||
metadata: MetadataStats;
|
metadata: MetadataStats;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchStats() {
|
export async function fetchStats(period?: "day" | "week" | "month") {
|
||||||
return apiFetch<StatsResponse>("/stats");
|
const params = period && period !== "month" ? `?period=${period}` : "";
|
||||||
|
return apiFetch<StatsResponse>(`/stats${params}`, { next: { revalidate: 30 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -661,11 +758,13 @@ export type KomgaSyncRequest = {
|
|||||||
url: string;
|
url: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
user_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type KomgaSyncResponse = {
|
export type KomgaSyncResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
komga_url: string;
|
komga_url: string;
|
||||||
|
user_id?: string;
|
||||||
total_komga_read: number;
|
total_komga_read: number;
|
||||||
matched: number;
|
matched: number;
|
||||||
already_read: number;
|
already_read: number;
|
||||||
@@ -679,6 +778,7 @@ export type KomgaSyncResponse = {
|
|||||||
export type KomgaSyncReportSummary = {
|
export type KomgaSyncReportSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
komga_url: string;
|
komga_url: string;
|
||||||
|
user_id?: string;
|
||||||
total_komga_read: number;
|
total_komga_read: number;
|
||||||
matched: number;
|
matched: number;
|
||||||
already_read: number;
|
already_read: number;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"nav.libraries": "Libraries",
|
"nav.libraries": "Libraries",
|
||||||
"nav.jobs": "Jobs",
|
"nav.jobs": "Jobs",
|
||||||
"nav.tokens": "Tokens",
|
"nav.tokens": "Tokens",
|
||||||
|
"nav.users": "Users",
|
||||||
"nav.settings": "Settings",
|
"nav.settings": "Settings",
|
||||||
"nav.navigation": "Navigation",
|
"nav.navigation": "Navigation",
|
||||||
"nav.closeMenu": "Close menu",
|
"nav.closeMenu": "Close menu",
|
||||||
@@ -70,7 +71,15 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"dashboard.readingStatus": "Reading status",
|
"dashboard.readingStatus": "Reading status",
|
||||||
"dashboard.byFormat": "By format",
|
"dashboard.byFormat": "By format",
|
||||||
"dashboard.byLibrary": "By library",
|
"dashboard.byLibrary": "By library",
|
||||||
"dashboard.booksAdded": "Books added (last 12 months)",
|
"dashboard.booksAdded": "Books added",
|
||||||
|
"dashboard.jobsOverTime": "Job runs",
|
||||||
|
"dashboard.jobScan": "Scan",
|
||||||
|
"dashboard.jobRebuild": "Rebuild",
|
||||||
|
"dashboard.jobThumbnail": "Thumbnails",
|
||||||
|
"dashboard.jobOther": "Other",
|
||||||
|
"dashboard.periodDay": "Day",
|
||||||
|
"dashboard.periodWeek": "Week",
|
||||||
|
"dashboard.periodMonth": "Month",
|
||||||
"dashboard.popularSeries": "Popular series",
|
"dashboard.popularSeries": "Popular series",
|
||||||
"dashboard.noSeries": "No series yet",
|
"dashboard.noSeries": "No series yet",
|
||||||
"dashboard.unknown": "Unknown",
|
"dashboard.unknown": "Unknown",
|
||||||
@@ -82,6 +91,13 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"dashboard.bookMetadata": "Book metadata",
|
"dashboard.bookMetadata": "Book metadata",
|
||||||
"dashboard.withSummary": "With summary",
|
"dashboard.withSummary": "With summary",
|
||||||
"dashboard.withIsbn": "With ISBN",
|
"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 page
|
||||||
"books.title": "Books",
|
"books.title": "Books",
|
||||||
@@ -244,6 +260,7 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"jobRow.thumbnailsGenerated": "{{count}} thumbnails generated",
|
"jobRow.thumbnailsGenerated": "{{count}} thumbnails generated",
|
||||||
"jobRow.metadataProcessed": "{{count}} series processed",
|
"jobRow.metadataProcessed": "{{count}} series processed",
|
||||||
"jobRow.metadataRefreshed": "{{count}} series refreshed",
|
"jobRow.metadataRefreshed": "{{count}} series refreshed",
|
||||||
|
"jobRow.metadataLinks": "{{count}} links analyzed",
|
||||||
"jobRow.errors": "{{count}} errors",
|
"jobRow.errors": "{{count}} errors",
|
||||||
"jobRow.view": "View",
|
"jobRow.view": "View",
|
||||||
|
|
||||||
@@ -390,6 +407,21 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"tokens.revoked": "Revoked",
|
"tokens.revoked": "Revoked",
|
||||||
"tokens.active": "Active",
|
"tokens.active": "Active",
|
||||||
"tokens.revoke": "Revoke",
|
"tokens.revoke": "Revoke",
|
||||||
|
"tokens.user": "User",
|
||||||
|
"tokens.noUser": "None (admin)",
|
||||||
|
"tokens.apiTokens": "API Tokens",
|
||||||
|
|
||||||
|
// Users page
|
||||||
|
"users.title": "Users",
|
||||||
|
"users.createNew": "Create a user",
|
||||||
|
"users.createDescription": "Create a user account for read access",
|
||||||
|
"users.username": "Username",
|
||||||
|
"users.createButton": "Create",
|
||||||
|
"users.name": "Username",
|
||||||
|
"users.tokenCount": "Tokens",
|
||||||
|
"users.createdAt": "Created",
|
||||||
|
"users.actions": "Actions",
|
||||||
|
"users.noUsers": "No users",
|
||||||
|
|
||||||
// Settings page
|
// Settings page
|
||||||
"settings.title": "Settings",
|
"settings.title": "Settings",
|
||||||
@@ -543,6 +575,33 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"settings.qbittorrentUsername": "Username",
|
"settings.qbittorrentUsername": "Username",
|
||||||
"settings.qbittorrentPassword": "Password",
|
"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
|
||||||
"settings.language": "Language",
|
"settings.language": "Language",
|
||||||
"settings.languageDesc": "Choose the interface language",
|
"settings.languageDesc": "Choose the interface language",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const fr = {
|
|||||||
"nav.libraries": "Bibliothèques",
|
"nav.libraries": "Bibliothèques",
|
||||||
"nav.jobs": "Tâches",
|
"nav.jobs": "Tâches",
|
||||||
"nav.tokens": "Jetons",
|
"nav.tokens": "Jetons",
|
||||||
|
"nav.users": "Utilisateurs",
|
||||||
"nav.settings": "Paramètres",
|
"nav.settings": "Paramètres",
|
||||||
"nav.navigation": "Navigation",
|
"nav.navigation": "Navigation",
|
||||||
"nav.closeMenu": "Fermer le menu",
|
"nav.closeMenu": "Fermer le menu",
|
||||||
@@ -68,7 +69,15 @@ const fr = {
|
|||||||
"dashboard.readingStatus": "Statut de lecture",
|
"dashboard.readingStatus": "Statut de lecture",
|
||||||
"dashboard.byFormat": "Par format",
|
"dashboard.byFormat": "Par format",
|
||||||
"dashboard.byLibrary": "Par bibliothèque",
|
"dashboard.byLibrary": "Par bibliothèque",
|
||||||
"dashboard.booksAdded": "Livres ajoutés (12 derniers mois)",
|
"dashboard.booksAdded": "Livres ajoutés",
|
||||||
|
"dashboard.jobsOverTime": "Exécutions de jobs",
|
||||||
|
"dashboard.jobScan": "Scan",
|
||||||
|
"dashboard.jobRebuild": "Rebuild",
|
||||||
|
"dashboard.jobThumbnail": "Thumbnails",
|
||||||
|
"dashboard.jobOther": "Autre",
|
||||||
|
"dashboard.periodDay": "Jour",
|
||||||
|
"dashboard.periodWeek": "Semaine",
|
||||||
|
"dashboard.periodMonth": "Mois",
|
||||||
"dashboard.popularSeries": "Séries populaires",
|
"dashboard.popularSeries": "Séries populaires",
|
||||||
"dashboard.noSeries": "Aucune série pour le moment",
|
"dashboard.noSeries": "Aucune série pour le moment",
|
||||||
"dashboard.unknown": "Inconnu",
|
"dashboard.unknown": "Inconnu",
|
||||||
@@ -80,6 +89,13 @@ const fr = {
|
|||||||
"dashboard.bookMetadata": "Métadonnées livres",
|
"dashboard.bookMetadata": "Métadonnées livres",
|
||||||
"dashboard.withSummary": "Avec résumé",
|
"dashboard.withSummary": "Avec résumé",
|
||||||
"dashboard.withIsbn": "Avec ISBN",
|
"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 page
|
||||||
"books.title": "Livres",
|
"books.title": "Livres",
|
||||||
@@ -242,6 +258,7 @@ const fr = {
|
|||||||
"jobRow.thumbnailsGenerated": "{{count}} miniatures générées",
|
"jobRow.thumbnailsGenerated": "{{count}} miniatures générées",
|
||||||
"jobRow.metadataProcessed": "{{count}} séries traitées",
|
"jobRow.metadataProcessed": "{{count}} séries traitées",
|
||||||
"jobRow.metadataRefreshed": "{{count}} séries rafraîchies",
|
"jobRow.metadataRefreshed": "{{count}} séries rafraîchies",
|
||||||
|
"jobRow.metadataLinks": "{{count}} liens analysés",
|
||||||
"jobRow.errors": "{{count}} erreurs",
|
"jobRow.errors": "{{count}} erreurs",
|
||||||
"jobRow.view": "Voir",
|
"jobRow.view": "Voir",
|
||||||
|
|
||||||
@@ -388,6 +405,21 @@ const fr = {
|
|||||||
"tokens.revoked": "Révoqué",
|
"tokens.revoked": "Révoqué",
|
||||||
"tokens.active": "Actif",
|
"tokens.active": "Actif",
|
||||||
"tokens.revoke": "Révoquer",
|
"tokens.revoke": "Révoquer",
|
||||||
|
"tokens.user": "Utilisateur",
|
||||||
|
"tokens.noUser": "Aucun (admin)",
|
||||||
|
"tokens.apiTokens": "Tokens API",
|
||||||
|
|
||||||
|
// Users page
|
||||||
|
"users.title": "Utilisateurs",
|
||||||
|
"users.createNew": "Créer un utilisateur",
|
||||||
|
"users.createDescription": "Créer un compte utilisateur pour accès lecture",
|
||||||
|
"users.username": "Nom d'utilisateur",
|
||||||
|
"users.createButton": "Créer",
|
||||||
|
"users.name": "Nom d'utilisateur",
|
||||||
|
"users.tokenCount": "Nb de jetons",
|
||||||
|
"users.createdAt": "Créé le",
|
||||||
|
"users.actions": "Actions",
|
||||||
|
"users.noUsers": "Aucun utilisateur",
|
||||||
|
|
||||||
// Settings page
|
// Settings page
|
||||||
"settings.title": "Paramètres",
|
"settings.title": "Paramètres",
|
||||||
@@ -541,6 +573,33 @@ const fr = {
|
|||||||
"settings.qbittorrentUsername": "Nom d'utilisateur",
|
"settings.qbittorrentUsername": "Nom d'utilisateur",
|
||||||
"settings.qbittorrentPassword": "Mot de passe",
|
"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
|
||||||
"settings.language": "Langue",
|
"settings.language": "Langue",
|
||||||
"settings.languageDesc": "Choisir la langue de l'interface",
|
"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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
typedRoutes: true
|
typedRoutes: true,
|
||||||
|
images: {
|
||||||
|
minimumCacheTTL: 86400,
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
516
apps/backoffice/package-lock.json
generated
516
apps/backoffice/package-lock.json
generated
@@ -1,17 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "stripstream-backoffice",
|
"name": "stripstream-backoffice",
|
||||||
"version": "1.4.0",
|
"version": "1.28.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "stripstream-backoffice",
|
"name": "stripstream-backoffice",
|
||||||
"version": "1.4.0",
|
"version": "1.28.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"jose": "^6.2.2",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
"sanitize-html": "^2.17.1"
|
"sanitize-html": "^2.17.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -142,9 +144,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -161,9 +160,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -180,9 +176,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -199,9 +192,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -218,9 +208,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -237,9 +224,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -256,9 +240,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -275,9 +256,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -294,9 +272,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -319,9 +294,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -344,9 +316,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -369,9 +338,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -394,9 +360,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -419,9 +382,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -444,9 +404,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -469,9 +426,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -658,9 +612,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -677,9 +628,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -696,9 +644,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -715,9 +660,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -759,6 +701,54 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -901,9 +891,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -921,9 +908,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -941,9 +925,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -961,9 +942,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1051,6 +1029,69 @@
|
|||||||
"tailwindcss": "4.2.1"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.13.14",
|
"version": "22.13.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
|
||||||
@@ -1065,8 +1106,9 @@
|
|||||||
"version": "19.0.12",
|
"version": "19.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz",
|
||||||
"integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==",
|
"integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -1124,6 +1166,12 @@
|
|||||||
"entities": "^7.0.1"
|
"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": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.27",
|
"version": "10.4.27",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||||
@@ -1193,6 +1241,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -1233,11 +1282,147 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/deepmerge": {
|
"node_modules/deepmerge": {
|
||||||
@@ -1347,6 +1532,16 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -1369,6 +1564,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@@ -1409,6 +1610,25 @@
|
|||||||
"entities": "^4.4.0"
|
"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": {
|
"node_modules/is-plain-object": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||||
@@ -1428,6 +1648,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.31.1",
|
"version": "1.31.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||||
@@ -1571,9 +1800,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1595,9 +1821,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1619,9 +1842,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1643,9 +1863,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1858,6 +2075,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -1879,6 +2097,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
||||||
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
|
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -1888,6 +2107,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
|
||||||
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
|
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.25.0"
|
"scheduler": "^0.25.0"
|
||||||
},
|
},
|
||||||
@@ -1895,6 +2115,89 @@
|
|||||||
"react": "^19.0.0"
|
"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": {
|
"node_modules/sanitize-html": {
|
||||||
"version": "2.17.1",
|
"version": "2.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.1.tgz",
|
||||||
@@ -2026,6 +2329,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@@ -2083,6 +2392,37 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"browserslist": ">= 4.21.0"
|
"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",
|
"name": "stripstream-backoffice",
|
||||||
"version": "1.19.1",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 7082",
|
"dev": "next dev -p 7082",
|
||||||
@@ -8,10 +8,12 @@
|
|||||||
"start": "next start -p 7082"
|
"start": "next start -p 7082"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"jose": "^6.2.2",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
"sanitize-html": "^2.17.1"
|
"sanitize-html": "^2.17.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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
|
image.workspace = true
|
||||||
jpeg-decoder.workspace = true
|
jpeg-decoder.workspace = true
|
||||||
num_cpus.workspace = true
|
num_cpus.workspace = true
|
||||||
|
notifications = { path = "../../crates/notifications" }
|
||||||
parsers = { path = "../../crates/parsers" }
|
parsers = { path = "../../crates/parsers" }
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ COPY Cargo.toml ./
|
|||||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||||
COPY crates/core/Cargo.toml crates/core/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
|
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/api/src/main.rs && \
|
||||||
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
echo "fn main() {}" > apps/indexer/src/main.rs && \
|
||||||
echo "" > apps/indexer/src/lib.rs && \
|
echo "" > apps/indexer/src/lib.rs && \
|
||||||
echo "" > crates/core/src/lib.rs && \
|
echo "" > crates/core/src/lib.rs && \
|
||||||
|
echo "" > crates/notifications/src/lib.rs && \
|
||||||
echo "" > crates/parsers/src/lib.rs
|
echo "" > crates/parsers/src/lib.rs
|
||||||
|
|
||||||
# Build dependencies only (cached as long as Cargo.toml files don't change)
|
# 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/api/src apps/api/src
|
||||||
COPY apps/indexer/src apps/indexer/src
|
COPY apps/indexer/src apps/indexer/src
|
||||||
COPY crates/core/src crates/core/src
|
COPY crates/core/src crates/core/src
|
||||||
|
COPY crates/notifications/src crates/notifications/src
|
||||||
COPY crates/parsers/src crates/parsers/src
|
COPY crates/parsers/src crates/parsers/src
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,target=/app/target \
|
--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 && \
|
cargo build --release -p indexer && \
|
||||||
cp /app/target/release/indexer /usr/local/bin/indexer
|
cp /app/target/release/indexer /usr/local/bin/indexer
|
||||||
|
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ pub async fn process_job(
|
|||||||
removed_files: 0,
|
removed_files: 0,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
warnings: 0,
|
warnings: 0,
|
||||||
|
new_series: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut total_processed_count = 0i32;
|
let mut total_processed_count = 0i32;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use crate::{
|
|||||||
utils,
|
utils,
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct JobStats {
|
pub struct JobStats {
|
||||||
@@ -22,6 +23,7 @@ pub struct JobStats {
|
|||||||
pub removed_files: usize,
|
pub removed_files: usize,
|
||||||
pub errors: usize,
|
pub errors: usize,
|
||||||
pub warnings: usize,
|
pub warnings: usize,
|
||||||
|
pub new_series: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
const BATCH_SIZE: usize = 100;
|
const BATCH_SIZE: usize = 100;
|
||||||
@@ -106,6 +108,18 @@ pub async fn scan_library_discovery(
|
|||||||
HashMap::new()
|
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 seen: HashMap<String, bool> = HashMap::new();
|
||||||
let mut library_processed_count = 0i32;
|
let mut library_processed_count = 0i32;
|
||||||
let mut last_progress_update = std::time::Instant::now();
|
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 book_id = Uuid::new_v4();
|
||||||
let file_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 {
|
books_to_insert.push(BookInsert {
|
||||||
book_id,
|
book_id,
|
||||||
library_id,
|
library_id,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use sqlx::Row;
|
||||||
use tracing::{error, info, trace};
|
use tracing::{error, info, trace};
|
||||||
|
use uuid::Uuid;
|
||||||
use crate::{job, scheduler, watcher, AppState};
|
use crate::{job, scheduler, watcher, AppState};
|
||||||
|
|
||||||
pub async fn run_worker(state: AppState, interval_seconds: u64) {
|
pub async fn run_worker(state: AppState, interval_seconds: u64) {
|
||||||
let wait = Duration::from_secs(interval_seconds.max(1));
|
let wait = Duration::from_secs(interval_seconds.max(1));
|
||||||
|
|
||||||
// Cleanup stale jobs from previous runs
|
// Cleanup stale jobs from previous runs
|
||||||
if let Err(err) = job::cleanup_stale_jobs(&state.pool).await {
|
if let Err(err) = job::cleanup_stale_jobs(&state.pool).await {
|
||||||
error!("[CLEANUP] Failed to cleanup stale jobs: {}", err);
|
error!("[CLEANUP] Failed to cleanup stale jobs: {}", err);
|
||||||
@@ -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 {
|
loop {
|
||||||
match job::claim_next_job(&state.pool).await {
|
match job::claim_next_job(&state.pool).await {
|
||||||
Ok(Some((job_id, library_id))) => {
|
Ok(Some((job_id, library_id))) => {
|
||||||
info!("[INDEXER] Starting job {} library={:?}", 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 {
|
if let Err(err) = job::process_job(&state, job_id, library_id).await {
|
||||||
let err_str = err.to_string();
|
let err_str = err.to_string();
|
||||||
if err_str.contains("cancelled") || err_str.contains("Cancelled") {
|
if err_str.contains("cancelled") || err_str.contains("Cancelled") {
|
||||||
info!("[INDEXER] Job {} was cancelled by user", job_id);
|
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 {
|
} else {
|
||||||
error!("[INDEXER] Job {} failed: {}", job_id, err);
|
error!("[INDEXER] Job {} failed: {}", job_id, err);
|
||||||
let _ = job::fail_job(&state.pool, job_id, &err_str).await;
|
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 {
|
} else {
|
||||||
info!("[INDEXER] Job {} completed", job_id);
|
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) => {
|
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}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
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
|
||||||
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';
|
||||||
1
infra/migrations/0051_add_user_to_komga_sync.sql
Normal file
1
infra/migrations/0051_add_user_to_komga_sync.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE komga_sync_reports ADD COLUMN user_id UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||||
Reference in New Issue
Block a user