Compare commits
31 Commits
323661f770
...
feat/thumb
| Author | SHA1 | Date | |
|---|---|---|---|
| e64848a216 | |||
| c93a7d5d29 | |||
| 360d6e85de | |||
| 162b4712e7 | |||
| 217919fa77 | |||
| ee0235b824 | |||
| f721b248f3 | |||
| 292c61566c | |||
| 9141edfaa9 | |||
| f0a967515b | |||
| d5d582db57 | |||
| ee76090265 | |||
| d86301919d | |||
| 47e53a19b9 | |||
| d0a29196dd | |||
| 19ef4d592b | |||
| a31c524c32 | |||
| d3e2147982 | |||
| 4f6833b42b | |||
| 7cdc72b6e1 | |||
| 2b30ae47de | |||
| 1dca1099cf | |||
| ff34b2bbf4 | |||
| a7fe565a1f | |||
| 8a9a8634f8 | |||
| 5683fb8d25 | |||
| fa574586ed | |||
| c421f427b0 | |||
| 5d7524f52e | |||
| 762587dcb3 | |||
| b6cd8a895d |
62
.env.example
62
.env.example
@@ -1,9 +1,63 @@
|
|||||||
|
# Stripstream Librarian - Environment Configuration
|
||||||
|
#
|
||||||
|
# HOW TO USE:
|
||||||
|
# 1. Copy this file to .env: cp .env.example .env
|
||||||
|
# 2. Change the REQUIRED values below
|
||||||
|
# 3. Run: docker-compose up --build
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REQUIRED - Change these values in production!
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Master key for Meilisearch authentication (required)
|
||||||
|
MEILI_MASTER_KEY=change-me-in-production
|
||||||
|
|
||||||
|
# Bootstrap token for initial API admin access (required)
|
||||||
|
# Use this token for the first API calls before creating proper API tokens
|
||||||
|
API_BOOTSTRAP_TOKEN=change-me-in-production
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Service Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# API Service
|
||||||
API_LISTEN_ADDR=0.0.0.0:8080
|
API_LISTEN_ADDR=0.0.0.0:8080
|
||||||
BACKOFFICE_PORT=8082
|
|
||||||
API_BASE_URL=http://api:8080
|
API_BASE_URL=http://api:8080
|
||||||
|
|
||||||
|
# Indexer Service
|
||||||
INDEXER_LISTEN_ADDR=0.0.0.0:8081
|
INDEXER_LISTEN_ADDR=0.0.0.0:8081
|
||||||
INDEXER_SCAN_INTERVAL_SECONDS=5
|
INDEXER_SCAN_INTERVAL_SECONDS=5
|
||||||
DATABASE_URL=postgres://stripstream:stripstream@postgres:5432/stripstream
|
|
||||||
|
# Meilisearch Search Engine
|
||||||
MEILI_URL=http://meilisearch:7700
|
MEILI_URL=http://meilisearch:7700
|
||||||
MEILI_MASTER_KEY=change-me
|
|
||||||
API_BOOTSTRAP_TOKEN=change-me-bootstrap-token
|
# PostgreSQL Database
|
||||||
|
DATABASE_URL=postgres://stripstream:stripstream@postgres:5432/stripstream
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Storage Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Path to libraries directory inside container
|
||||||
|
# In Docker: leave as default /libraries
|
||||||
|
# For local dev: set to your local libraries folder path
|
||||||
|
LIBRARIES_ROOT_PATH=/libraries
|
||||||
|
|
||||||
|
# Path to libraries directory on host machine (for Docker volume mount)
|
||||||
|
# Default: ../libraries (relative to infra/docker-compose.yml)
|
||||||
|
# You can change this to an absolute path on your machine
|
||||||
|
LIBRARIES_HOST_PATH=../libraries
|
||||||
|
|
||||||
|
# Path to thumbnails directory on host machine (for Docker volume mount)
|
||||||
|
# Default: ../data/thumbnails (relative to infra/docker-compose.yml)
|
||||||
|
THUMBNAILS_HOST_PATH=../data/thumbnails
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Port Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# To change ports, edit docker-compose.yml directly:
|
||||||
|
# - API: change "7080:8080" to "YOUR_PORT:8080"
|
||||||
|
# - Indexer: change "7081:8081" to "YOUR_PORT:8081"
|
||||||
|
# - Backoffice: change "7082:8082" to "YOUR_PORT:8082"
|
||||||
|
# - Meilisearch: change "7700:7700" to "YOUR_PORT:7700"
|
||||||
|
# - PostgreSQL: change "6432:5432" to "YOUR_PORT:5432"
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ tmp/
|
|||||||
libraries/
|
libraries/
|
||||||
node_modules/
|
node_modules/
|
||||||
.next/
|
.next/
|
||||||
|
data/thumbnails
|
||||||
|
|||||||
301
AGENTS.md
Normal file
301
AGENTS.md
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
# AGENTS.md - Agent Coding Guidelines for Stripstream Librarian
|
||||||
|
|
||||||
|
This file provides guidelines for agentic coding agents operating in this repository.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Build, Lint, and Test Commands
|
||||||
|
|
||||||
|
### Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build debug version (fastest for development)
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Build release version (optimized)
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Build specific crate
|
||||||
|
cargo build -p api
|
||||||
|
cargo build -p indexer
|
||||||
|
|
||||||
|
# Watch mode for development (requires cargo-watch)
|
||||||
|
cargo watch -x build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint & Format Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run clippy lints
|
||||||
|
cargo clippy
|
||||||
|
|
||||||
|
# Fix auto-fixable clippy warnings
|
||||||
|
cargo clippy --fix
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
cargo fmt
|
||||||
|
|
||||||
|
# Check formatting without making changes
|
||||||
|
cargo fmt -- --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Run tests for specific crate
|
||||||
|
cargo test -p api
|
||||||
|
cargo test -p indexer
|
||||||
|
cargo test -p parsers
|
||||||
|
|
||||||
|
# Run a single test by name
|
||||||
|
cargo test test_name_here
|
||||||
|
|
||||||
|
# Run tests with output display
|
||||||
|
cargo test -- --nocapture
|
||||||
|
|
||||||
|
# Run doc tests
|
||||||
|
cargo test --doc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run migrations manually (via sqlx CLI)
|
||||||
|
# Ensure DATABASE_URL is set, then:
|
||||||
|
sqlx migrate run
|
||||||
|
|
||||||
|
# Create new migration
|
||||||
|
sqlx migrate add -r migration_name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start infrastructure only
|
||||||
|
cd infra && docker compose up -d postgres meilisearch
|
||||||
|
|
||||||
|
# Start full stack
|
||||||
|
cd infra && docker compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f api
|
||||||
|
docker compose logs -f indexer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Code Style Guidelines
|
||||||
|
|
||||||
|
### General Principles
|
||||||
|
|
||||||
|
- **Conciseness**: Keep responses short and direct. Avoid unnecessary preamble or explanation.
|
||||||
|
- **Idiomatic Rust**: Follow Rust best practices and ecosystem conventions.
|
||||||
|
- **Error Handling**: Use `anyhow::Result<T>` for application code, `std::io::Result<T>` for simple file operations.
|
||||||
|
- **Async**: Use `tokio` for async runtime. Prefer `#[tokio::main]` over manual runtime.
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
| Element | Convention | Example |
|
||||||
|
|---------|------------|---------|
|
||||||
|
| Variables | snake_case | `let book_id = ...` |
|
||||||
|
| Functions | snake_case | `fn get_book(...)` |
|
||||||
|
| Structs/Enums | PascalCase | `struct BookItem` |
|
||||||
|
| Modules | snake_case | `mod books;` |
|
||||||
|
| Constants | SCREAMING_SNAKE_CASE | `const BATCH_SIZE: usize = 100;` |
|
||||||
|
| Types | PascalCase | `type MyResult<T> = Result<T, Error>;` |
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
|
||||||
|
- **Absolute imports** for workspace crates: `use parsers::{detect_format, parse_metadata};`
|
||||||
|
- **Standard library** imports: `use std::path::Path;`
|
||||||
|
- **External crates**: `use sqlx::{postgres::PgPoolOptions, Row};`
|
||||||
|
- **Group by**: std → external → workspace → local (with blank lines between)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::Row;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::AppState;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Use `anyhow` for application-level error handling with context
|
||||||
|
- Use `with_context()` for adding context to errors
|
||||||
|
- Return `Result<T, ApiError>` in API handlers
|
||||||
|
- Use `?` operator instead of manual match/unwrap where possible
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Good
|
||||||
|
fn process_book(path: &Path) -> anyhow::Result<Book> {
|
||||||
|
let file = std::fs::File::open(path)
|
||||||
|
.with_context(|| format!("cannot open file: {}", path.display()))?;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good - API error handling
|
||||||
|
async fn get_book(State(state): State<AppState>, Path(id): Path<Uuid>)
|
||||||
|
-> Result<Json<Book>, ApiError> {
|
||||||
|
let row = sqlx::query("SELECT * FROM books WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database (sqlx)
|
||||||
|
|
||||||
|
- Use **raw SQL queries** with `sqlx::query()` and `sqlx::query_scalar()`
|
||||||
|
- Prefer **batch operations** using `UNNEST` for bulk inserts/updates
|
||||||
|
- Always use **parameterized queries** (`$1`, `$2`, etc.) - never string interpolation
|
||||||
|
- Follow existing patterns for transactions:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
// ... queries ...
|
||||||
|
tx.commit().await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async/Tokio
|
||||||
|
|
||||||
|
- Use `tokio::spawn` for background tasks
|
||||||
|
- Use `spawn_blocking` for CPU-bound work (image processing, file I/O)
|
||||||
|
- Keep async handlers non-blocking
|
||||||
|
- Use `tokio::time::timeout` for operations with timeouts
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let bytes = tokio::time::timeout(
|
||||||
|
Duration::from_secs(60),
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
render_page(&abs_path_clone, n)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::internal("timeout"))?
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Structs and Serialization
|
||||||
|
|
||||||
|
- Use `#[derive(Serialize, Deserialize, ToSchema)]` for API types
|
||||||
|
- Add `utoipa` schemas for OpenAPI documentation
|
||||||
|
- Use `Option<T>` for nullable fields
|
||||||
|
- Document public structs briefly
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct BookItem {
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub author: Option<String>,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
|
||||||
|
- Use **batch operations** for database inserts/updates (100 items recommended)
|
||||||
|
- Use **parallel iterators** (`rayon::par_iter()`) for CPU-intensive scans
|
||||||
|
- Implement **caching** for expensive operations (see `pages.rs` for disk/memory cache examples)
|
||||||
|
- Use **streaming** for large data where applicable
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Currently there are no test files - consider adding unit tests for:
|
||||||
|
- Parser functions
|
||||||
|
- Thumbnail generation
|
||||||
|
- Configuration parsing
|
||||||
|
- Use `#[cfg(test)]` modules for integration tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
stripstream-librarian/
|
||||||
|
├── apps/
|
||||||
|
│ ├── api/ # REST API (axum)
|
||||||
|
│ │ └── src/
|
||||||
|
│ │ ├── main.rs
|
||||||
|
│ │ ├── books.rs
|
||||||
|
│ │ ├── pages.rs
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── indexer/ # Background indexing service
|
||||||
|
│ │ └── src/
|
||||||
|
│ │ └── main.rs
|
||||||
|
│ └── backoffice/ # Next.js admin UI
|
||||||
|
├── crates/
|
||||||
|
│ ├── core/ # Shared config
|
||||||
|
│ │ └── src/config.rs
|
||||||
|
│ └── parsers/ # Book parsing (CBZ, CBR, PDF)
|
||||||
|
├── infra/
|
||||||
|
│ ├── migrations/ # SQL migrations
|
||||||
|
│ └── docker-compose.yml
|
||||||
|
└── libraries/ # Book storage (mounted volume)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `apps/api/src/books.rs` | Book CRUD endpoints |
|
||||||
|
| `apps/api/src/pages.rs` | Page rendering & caching |
|
||||||
|
| `apps/indexer/src/main.rs` | Indexing logic, batch processing |
|
||||||
|
| `crates/parsers/src/lib.rs` | Format detection, metadata parsing |
|
||||||
|
| `crates/core/src/config.rs` | Configuration from environment |
|
||||||
|
| `infra/migrations/*.sql` | Database schema |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Common Patterns
|
||||||
|
|
||||||
|
### Configuration from Environment
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In crates/core/src/config.rs
|
||||||
|
impl IndexerConfig {
|
||||||
|
pub fn from_env() -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
listen_addr: std::env::var("INDEXER_LISTEN_ADDR")
|
||||||
|
.unwrap_or_else(|_| "0.0.0.0:8081".to_string()),
|
||||||
|
database_url: std::env::var("DATABASE_URL")
|
||||||
|
.context("DATABASE_URL is required")?,
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path Remapping
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn remap_libraries_path(path: &str) -> String {
|
||||||
|
if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") {
|
||||||
|
if path.starts_with("/libraries/") {
|
||||||
|
return path.replacen("/libraries", &root, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.to_string()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Important Notes
|
||||||
|
|
||||||
|
- **Workspace**: This is a Cargo workspace. Always specify the package when building specific apps.
|
||||||
|
- **Dependencies**: External crates are defined in workspace `Cargo.toml`, not individual `Cargo.toml`.
|
||||||
|
- **Database**: PostgreSQL is required. Run migrations before starting services.
|
||||||
|
- **External Tools**: The indexer relies on `unar` (for CBR) and `pdftoppm` (for PDF) being installed on the system.
|
||||||
60
Cargo.lock
generated
60
Cargo.lock
generated
@@ -8,22 +8,6 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "admin-ui"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"axum",
|
|
||||||
"reqwest",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"stripstream-core",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"uuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aes"
|
name = "aes"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -94,6 +78,8 @@ dependencies = [
|
|||||||
"utoipa",
|
"utoipa",
|
||||||
"utoipa-swagger-ui",
|
"utoipa-swagger-ui",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"walkdir",
|
||||||
|
"webp",
|
||||||
"zip 2.4.2",
|
"zip 2.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -333,6 +319,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -815,6 +803,12 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glob"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -1154,6 +1148,8 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"notify",
|
"notify",
|
||||||
"parsers",
|
"parsers",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"rayon",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1231,6 +1227,16 @@ version = "1.0.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.91"
|
version = "0.3.91"
|
||||||
@@ -1310,6 +1316,16 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libwebp-sys"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"glob",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -1609,6 +1625,8 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"lopdf",
|
"lopdf",
|
||||||
"regex",
|
"regex",
|
||||||
|
"uuid",
|
||||||
|
"walkdir",
|
||||||
"zip 2.4.2",
|
"zip 2.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3168,6 +3186,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webp"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1"
|
||||||
|
dependencies = [
|
||||||
|
"image",
|
||||||
|
"libwebp-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "0.26.11"
|
version = "0.26.11"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
members = [
|
members = [
|
||||||
"apps/api",
|
"apps/api",
|
||||||
"apps/indexer",
|
"apps/indexer",
|
||||||
"apps/admin-ui",
|
|
||||||
"crates/core",
|
"crates/core",
|
||||||
"crates/parsers",
|
"crates/parsers",
|
||||||
]
|
]
|
||||||
@@ -21,6 +20,7 @@ base64 = "0.22"
|
|||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||||
lru = "0.12"
|
lru = "0.12"
|
||||||
|
rayon = "1.10"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
|||||||
141
PLAN_THUMBNAILS.md
Normal file
141
PLAN_THUMBNAILS.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Plan: Génération des vignettes à l'index
|
||||||
|
|
||||||
|
## 1. Base de données
|
||||||
|
|
||||||
|
### Migration SQL (`0010_add_thumbnails.sql`)
|
||||||
|
- [x] Ajouter `thumbnail_path TEXT` à la table `books` (nullable)
|
||||||
|
- [x] Ajouter settings pour thumbnails dans `app_settings`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"thumbnail": {
|
||||||
|
"enabled": true,
|
||||||
|
"width": 300,
|
||||||
|
"height": 400,
|
||||||
|
"quality": 80,
|
||||||
|
"format": "webp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Configuration
|
||||||
|
|
||||||
|
### `crates/core/src/config.rs`
|
||||||
|
- [x] Ajouter `ThumbnailConfig` struct
|
||||||
|
- [x] Ajouter champs dans `IndexerConfig`:
|
||||||
|
- `thumbnail_width: u32` (défaut: 300)
|
||||||
|
- `thumbnail_height: u32` (défaut: 400)
|
||||||
|
- `thumbnail_quality: u8` (défaut: 80)
|
||||||
|
- `thumbnail_dir: String` (défaut: `/data/thumbnails`)
|
||||||
|
- [x] Ajouter getter depuis env vars
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Indexer - Extraction de la 1ère page
|
||||||
|
|
||||||
|
### Fonction à créer dans `crates/parsers/src/lib.rs`
|
||||||
|
- [x] `extract_first_page(path: &Path, format: BookFormat) -> Result<Vec<u8>>`
|
||||||
|
- Réutiliser logique de `pages.rs:extract_cbz_page`
|
||||||
|
- Réutiliser logique de `pages.rs:extract_cbr_page`
|
||||||
|
- Réutiliser logique de `pages.rs:render_pdf_page`
|
||||||
|
|
||||||
|
### Fonction de génération vignette dans `apps/indexer/src/main.rs`
|
||||||
|
- [x] `generate_thumbnail(image_bytes: &[u8], config: &ThumbnailConfig) -> Result<Vec<u8>>`
|
||||||
|
- Load image avec `image::load_from_memory`
|
||||||
|
- Resize avec `image::resize` (ratio kept)
|
||||||
|
- Encode en WebP avec `webp::Encoder`
|
||||||
|
|
||||||
|
- [x] `save_thumbnail(book_id: Uuid, thumbnail_bytes: &[u8], config: &ThumbnailConfig) -> Result<String>`
|
||||||
|
|
||||||
|
### Intégration dans `scan_library`
|
||||||
|
- [x] Après parsing metadata, extraire 1ère page
|
||||||
|
- [x] Générer vignette et sauvegarder
|
||||||
|
- [x] Stocker chemin en DB (via batch insert)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Indexer - WalkDir parallèle
|
||||||
|
|
||||||
|
### Remplacement de `WalkDir` séquentiel
|
||||||
|
- [x] Utiliser `rayon` pour paralléliser le scan:
|
||||||
|
```rust
|
||||||
|
let total_files: usize = library_paths.par_iter()
|
||||||
|
.map(|root_path| { ... })
|
||||||
|
.sum();
|
||||||
|
```
|
||||||
|
- [x] Ajouter `rayon = "1.10"` dans workspace dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API - Service des vignettes
|
||||||
|
|
||||||
|
### Mise à jour models dans `apps/api/src/books.rs`
|
||||||
|
- [x] Ajouter `thumbnail_url: Option<String>` à `BookItem`
|
||||||
|
- [x] Ajouter `thumbnail_url: Option<String>` à `BookDetails`
|
||||||
|
- [x] Mise à jour des requêtes SQL pour récupérer `thumbnail_path`
|
||||||
|
|
||||||
|
### Nouvelle route dans `apps/api/src/main.rs`
|
||||||
|
- [x] Route `/books/:id/thumbnail` (GET)
|
||||||
|
- Retourne fichier statique depuis `thumbnail_path`
|
||||||
|
- Content-Type: image/webp
|
||||||
|
- Cache-Control: public, max-age=31536000
|
||||||
|
|
||||||
|
### Suppression cache 1ère page (optionnel)
|
||||||
|
- [ ] Optionnel: simplifier `pages.rs` car thumbnail pré-générée
|
||||||
|
- [ ] Garder render pour pages > 1
|
||||||
|
|
||||||
|
### Adapter backoffice
|
||||||
|
|
||||||
|
La recupération des thumbnail est fait par une route page/1.
|
||||||
|
- [x] Passer par la nouvelle route avec une route clean /thumbnail pour chaque cover.
|
||||||
|
|
||||||
|
### refacto code entre api et indexer
|
||||||
|
|
||||||
|
En fait l'indexer pourrait appeler l'api pour qu'il fasse les vignettes et c'est l'api qui est responsable des images et des lectures ebooks. Je préfère que chaque domaine soit bien respecté. A la fin d'une build, on appelle l'api pour faire le checkup des thumbnails.
|
||||||
|
Il faudra que coté backoffice on voit partout ou on peut voir le traitement live des jobs, une phase ou on voit en sse le traitement des thumbnails. Coté api, si on a pas de thumbnail on passe par le code actuel de pages.
|
||||||
|
|
||||||
|
- [x] Migration `0010_index_job_thumbnails_phase.sql`: status `generating_thumbnails` dans index_jobs
|
||||||
|
- [x] API: `get_thumbnail` fallback sur page 1 si pas de thumbnail_path (via `pages::render_book_page_1`)
|
||||||
|
- [x] API: module `thumbnails.rs`, POST `/index/jobs/:id/thumbnails/checkup` (admin), lance la génération en tâche de fond et met à jour la job
|
||||||
|
- [x] Indexer: plus de génération de thumbnails; en fin de build: status = `generating_thumbnails`, puis appel API checkup; config `api_base_url` + `api_bootstrap_token` (core)
|
||||||
|
- [x] Backoffice: StatusBadge "Thumbnails" pour `generating_thumbnails`; JobProgress/JobRow/JobsIndicator/page job détail: phase thumbnails visible en SSE (X/Y thumbnails, barre de progression)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Settings API
|
||||||
|
|
||||||
|
### Endpoint settings existant
|
||||||
|
- [ ] Vérifier que `/settings` expose thumbnail config
|
||||||
|
- [ ] Ajouter endpoint PUT pour mettre à jour thumbnail settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Taches diverses
|
||||||
|
|
||||||
|
- [x] Ajouter dependency `image` et `webp` dans indexer `Cargo.toml`
|
||||||
|
- [x] Build release vérifié
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordre d'implémentation suggéré
|
||||||
|
|
||||||
|
1. [x] Migration DB + settings
|
||||||
|
2. [x] Config + parsers (extract first page)
|
||||||
|
3. [x] Indexer thumbnail generation + save to disk
|
||||||
|
4. [x] API serve thumbnail
|
||||||
|
5. [x] Parallel walkdir
|
||||||
|
6. [ ] Tests & polish (à faire)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-déploiement
|
||||||
|
|
||||||
|
- [ ] Appliquer migration SQL: `psql -f infra/migrations/0009_add_thumbnails.sql`
|
||||||
|
- [ ] Créer dossier thumbnails: `mkdir -p /data/thumbnails`
|
||||||
|
- [ ] Configurer env vars si besoin:
|
||||||
|
- `THUMBNAIL_ENABLED=true`
|
||||||
|
- `THUMBNAIL_WIDTH=300`
|
||||||
|
- `THUMBNAIL_HEIGHT=400`
|
||||||
|
- `THUMBNAIL_QUALITY=80`
|
||||||
|
- `THUMBNAIL_DIRECTORY=/data/thumbnails`
|
||||||
151
README.md
Normal file
151
README.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Stripstream Librarian
|
||||||
|
|
||||||
|
A comprehensive comic book and e-book management system with automatic indexing, full-text search, and a modern web interface.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The project consists of the following components:
|
||||||
|
|
||||||
|
- **API** (`apps/api/`) - Rust-based REST API service
|
||||||
|
- **Indexer** (`apps/indexer/`) - Rust-based background indexing service
|
||||||
|
- **Backoffice** (`apps/backoffice/`) - Next.js web administration interface
|
||||||
|
- **Infrastructure** (`infra/`) - Docker Compose setup with PostgreSQL and Meilisearch
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- (Optional) Rust toolchain for local development
|
||||||
|
- (Optional) Node.js 18+ for backoffice development
|
||||||
|
|
||||||
|
### Environment Setup
|
||||||
|
|
||||||
|
1. Copy the example environment file:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `.env` and set secure values for:
|
||||||
|
- `MEILI_MASTER_KEY` - Master key for Meilisearch
|
||||||
|
- `API_BOOTSTRAP_TOKEN` - Bootstrap token for initial API authentication
|
||||||
|
|
||||||
|
### Running with Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start:
|
||||||
|
- PostgreSQL (port 5432)
|
||||||
|
- Meilisearch (port 7700)
|
||||||
|
- API service (port 8080)
|
||||||
|
- Indexer service (port 8081)
|
||||||
|
- Backoffice web UI (port 8082)
|
||||||
|
|
||||||
|
### Accessing the Application
|
||||||
|
|
||||||
|
- **Backoffice**: http://localhost:8082
|
||||||
|
- **API**: http://localhost:8080
|
||||||
|
- **Meilisearch**: http://localhost:7700
|
||||||
|
|
||||||
|
### Default Credentials
|
||||||
|
|
||||||
|
The default bootstrap token is configured in your `.env` file. Use this for initial API authentication.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Local Development (without Docker)
|
||||||
|
|
||||||
|
#### API & Indexer (Rust)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start dependencies
|
||||||
|
cd infra
|
||||||
|
docker compose up -d postgres meilisearch
|
||||||
|
|
||||||
|
# Run API
|
||||||
|
cd apps/api
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
# Run Indexer (in another terminal)
|
||||||
|
cd apps/indexer
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Backoffice (Next.js)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/backoffice
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The backoffice will be available at http://localhost:3000
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Libraries Management
|
||||||
|
- Create and manage multiple libraries
|
||||||
|
- Configure automatic scanning schedules (hourly, daily, weekly)
|
||||||
|
- Real-time file watcher for instant indexing
|
||||||
|
- Full and incremental rebuild options
|
||||||
|
|
||||||
|
### Books Management
|
||||||
|
- Support for CBZ, CBR, and PDF formats
|
||||||
|
- Automatic metadata extraction
|
||||||
|
- Series and volume detection
|
||||||
|
- Full-text search with Meilisearch
|
||||||
|
|
||||||
|
### Jobs Monitoring
|
||||||
|
- Real-time job progress tracking
|
||||||
|
- Detailed statistics (scanned, indexed, removed, errors)
|
||||||
|
- Job history and logs
|
||||||
|
- Cancel pending jobs
|
||||||
|
|
||||||
|
### Search
|
||||||
|
- Full-text search across titles, authors, and series
|
||||||
|
- Library filtering
|
||||||
|
- Real-time suggestions
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `API_LISTEN_ADDR` | API service bind address | `0.0.0.0:8080` |
|
||||||
|
| `INDEXER_LISTEN_ADDR` | Indexer service bind address | `0.0.0.0:8081` |
|
||||||
|
| `BACKOFFICE_PORT` | Backoffice web UI port | `8082` |
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://stripstream:stripstream@postgres:5432/stripstream` |
|
||||||
|
| `MEILI_URL` | Meilisearch connection URL | `http://meilisearch:7700` |
|
||||||
|
| `MEILI_MASTER_KEY` | Meilisearch master key (required) | - |
|
||||||
|
| `API_BOOTSTRAP_TOKEN` | Initial API admin token (required) | - |
|
||||||
|
| `INDEXER_SCAN_INTERVAL_SECONDS` | Watcher scan interval | `5` |
|
||||||
|
| `LIBRARIES_ROOT_PATH` | Path to libraries directory | `/libraries` |
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
The API is documented with OpenAPI/Swagger. When running locally, access the docs at:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:8080/api-docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
stripstream-librarian/
|
||||||
|
├── apps/
|
||||||
|
│ ├── api/ # Rust REST API
|
||||||
|
│ ├── indexer/ # Rust background indexer
|
||||||
|
│ └── backoffice/ # Next.js web UI
|
||||||
|
├── infra/
|
||||||
|
│ ├── docker-compose.yml
|
||||||
|
│ └── migrations/ # SQL database migrations
|
||||||
|
├── libraries/ # Book storage (mounted volume)
|
||||||
|
└── .env # Environment configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[Your License Here]
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "admin-ui"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow.workspace = true
|
|
||||||
axum.workspace = true
|
|
||||||
reqwest.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
stripstream-core = { path = "../../crates/core" }
|
|
||||||
tokio.workspace = true
|
|
||||||
tracing.workspace = true
|
|
||||||
tracing-subscriber.workspace = true
|
|
||||||
uuid.workspace = true
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
FROM rust:1-bookworm AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY Cargo.toml ./
|
|
||||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
|
||||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
|
||||||
COPY apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
|
|
||||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
|
||||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
|
||||||
COPY apps/api/src apps/api/src
|
|
||||||
COPY apps/indexer/src apps/indexer/src
|
|
||||||
COPY apps/admin-ui/src apps/admin-ui/src
|
|
||||||
COPY crates/core/src crates/core/src
|
|
||||||
COPY crates/parsers/src crates/parsers/src
|
|
||||||
|
|
||||||
RUN cargo build --release -p admin-ui
|
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY --from=builder /app/target/release/admin-ui /usr/local/bin/admin-ui
|
|
||||||
EXPOSE 8082
|
|
||||||
CMD ["/usr/local/bin/admin-ui"]
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
extract::{Form, State},
|
|
||||||
response::{Html, Redirect},
|
|
||||||
routing::{get, post},
|
|
||||||
Router,
|
|
||||||
};
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use stripstream_core::config::AdminUiConfig;
|
|
||||||
use tracing::info;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct AppState {
|
|
||||||
api_base_url: String,
|
|
||||||
api_token: String,
|
|
||||||
client: Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct LibraryDto {
|
|
||||||
id: Uuid,
|
|
||||||
name: String,
|
|
||||||
root_path: String,
|
|
||||||
enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct IndexJobDto {
|
|
||||||
id: Uuid,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
kind: String,
|
|
||||||
status: String,
|
|
||||||
created_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct TokenDto {
|
|
||||||
id: Uuid,
|
|
||||||
name: String,
|
|
||||||
scope: String,
|
|
||||||
prefix: String,
|
|
||||||
revoked_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CreatedToken {
|
|
||||||
token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct AddLibraryForm {
|
|
||||||
name: String,
|
|
||||||
root_path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct DeleteLibraryForm {
|
|
||||||
id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct RebuildForm {
|
|
||||||
library_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CreateTokenForm {
|
|
||||||
name: String,
|
|
||||||
scope: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct RevokeTokenForm {
|
|
||||||
id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> anyhow::Result<()> {
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(
|
|
||||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "admin_ui=info,axum=info".to_string()),
|
|
||||||
)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
let config = AdminUiConfig::from_env()?;
|
|
||||||
let state = AppState {
|
|
||||||
api_base_url: config.api_base_url,
|
|
||||||
api_token: config.api_token,
|
|
||||||
client: Client::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/health", get(health))
|
|
||||||
.route("/", get(index))
|
|
||||||
.route("/libraries", get(libraries_page))
|
|
||||||
.route("/libraries/add", post(add_library))
|
|
||||||
.route("/libraries/delete", post(delete_library))
|
|
||||||
.route("/jobs", get(jobs_page))
|
|
||||||
.route("/jobs/rebuild", post(rebuild_jobs))
|
|
||||||
.route("/tokens", get(tokens_page))
|
|
||||||
.route("/tokens/create", post(create_token))
|
|
||||||
.route("/tokens/revoke", post(revoke_token))
|
|
||||||
.with_state(state);
|
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
|
|
||||||
info!(addr = %config.listen_addr, "admin ui listening");
|
|
||||||
axum::serve(listener, app).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn health() -> &'static str {
|
|
||||||
"ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn index() -> Html<String> {
|
|
||||||
Html(layout(
|
|
||||||
"Dashboard",
|
|
||||||
"<h1>Stripstream Admin</h1><p>Gestion des librairies, jobs d'indexation et tokens API.</p><p><a href='/libraries'>Libraries</a> | <a href='/jobs'>Jobs</a> | <a href='/tokens'>Tokens</a></p>",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn libraries_page(State(state): State<AppState>) -> Html<String> {
|
|
||||||
let libraries = fetch_libraries(&state).await.unwrap_or_default();
|
|
||||||
let mut rows = String::new();
|
|
||||||
for lib in libraries {
|
|
||||||
rows.push_str(&format!(
|
|
||||||
"<tr><td>{}</td><td><code>{}</code></td><td>{}</td><td><form method='post' action='/libraries/delete'><input type='hidden' name='id' value='{}'/><button type='submit'>Delete</button></form></td></tr>",
|
|
||||||
html_escape(&lib.name),
|
|
||||||
html_escape(&lib.root_path),
|
|
||||||
if lib.enabled { "yes" } else { "no" },
|
|
||||||
lib.id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = format!(
|
|
||||||
"<h1>Libraries</h1>
|
|
||||||
<form method='post' action='/libraries/add'>
|
|
||||||
<input name='name' placeholder='Name' required />
|
|
||||||
<input name='root_path' placeholder='/libraries/demo' required />
|
|
||||||
<button type='submit'>Add</button>
|
|
||||||
</form>
|
|
||||||
<table border='1' cellpadding='6'>
|
|
||||||
<tr><th>Name</th><th>Root Path</th><th>Enabled</th><th>Actions</th></tr>
|
|
||||||
{}
|
|
||||||
</table>",
|
|
||||||
rows
|
|
||||||
);
|
|
||||||
|
|
||||||
Html(layout("Libraries", &content))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn add_library(State(state): State<AppState>, Form(form): Form<AddLibraryForm>) -> Redirect {
|
|
||||||
let _ = state
|
|
||||||
.client
|
|
||||||
.post(format!("{}/libraries", state.api_base_url))
|
|
||||||
.bearer_auth(&state.api_token)
|
|
||||||
.json(&serde_json::json!({"name": form.name, "root_path": form.root_path}))
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
Redirect::to("/libraries")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_library(State(state): State<AppState>, Form(form): Form<DeleteLibraryForm>) -> Redirect {
|
|
||||||
let _ = state
|
|
||||||
.client
|
|
||||||
.delete(format!("{}/libraries/{}", state.api_base_url, form.id))
|
|
||||||
.bearer_auth(&state.api_token)
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
Redirect::to("/libraries")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn jobs_page(State(state): State<AppState>) -> Html<String> {
|
|
||||||
let jobs = fetch_jobs(&state).await.unwrap_or_default();
|
|
||||||
let mut rows = String::new();
|
|
||||||
for job in jobs {
|
|
||||||
rows.push_str(&format!(
|
|
||||||
"<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
|
||||||
job.id,
|
|
||||||
html_escape(&job.kind),
|
|
||||||
html_escape(&job.status),
|
|
||||||
html_escape(&job.created_at)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = format!(
|
|
||||||
"<h1>Index Jobs</h1>
|
|
||||||
<form method='post' action='/jobs/rebuild'>
|
|
||||||
<input name='library_id' placeholder='optional library UUID' />
|
|
||||||
<button type='submit'>Queue Rebuild</button>
|
|
||||||
</form>
|
|
||||||
<table border='1' cellpadding='6'>
|
|
||||||
<tr><th>ID</th><th>Type</th><th>Status</th><th>Created</th></tr>
|
|
||||||
{}
|
|
||||||
</table>",
|
|
||||||
rows
|
|
||||||
);
|
|
||||||
|
|
||||||
Html(layout("Jobs", &content))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn rebuild_jobs(State(state): State<AppState>, Form(form): Form<RebuildForm>) -> Redirect {
|
|
||||||
let body = if form.library_id.trim().is_empty() {
|
|
||||||
serde_json::json!({})
|
|
||||||
} else {
|
|
||||||
serde_json::json!({"library_id": form.library_id.trim()})
|
|
||||||
};
|
|
||||||
let _ = state
|
|
||||||
.client
|
|
||||||
.post(format!("{}/index/rebuild", state.api_base_url))
|
|
||||||
.bearer_auth(&state.api_token)
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
Redirect::to("/jobs")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn tokens_page(State(state): State<AppState>) -> Html<String> {
|
|
||||||
let tokens = fetch_tokens(&state).await.unwrap_or_default();
|
|
||||||
let mut rows = String::new();
|
|
||||||
for token in tokens {
|
|
||||||
rows.push_str(&format!(
|
|
||||||
"<tr><td>{}</td><td>{}</td><td><code>{}</code></td><td>{}</td><td><form method='post' action='/tokens/revoke'><input type='hidden' name='id' value='{}'/><button type='submit'>Revoke</button></form></td></tr>",
|
|
||||||
html_escape(&token.name),
|
|
||||||
html_escape(&token.scope),
|
|
||||||
html_escape(&token.prefix),
|
|
||||||
if token.revoked_at.is_some() { "yes" } else { "no" },
|
|
||||||
token.id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = format!(
|
|
||||||
"<h1>API Tokens</h1>
|
|
||||||
<form method='post' action='/tokens/create'>
|
|
||||||
<input name='name' placeholder='token name' required />
|
|
||||||
<select name='scope'>
|
|
||||||
<option value='read'>read</option>
|
|
||||||
<option value='admin'>admin</option>
|
|
||||||
</select>
|
|
||||||
<button type='submit'>Create Token</button>
|
|
||||||
</form>
|
|
||||||
<table border='1' cellpadding='6'>
|
|
||||||
<tr><th>Name</th><th>Scope</th><th>Prefix</th><th>Revoked</th><th>Actions</th></tr>
|
|
||||||
{}
|
|
||||||
</table>",
|
|
||||||
rows
|
|
||||||
);
|
|
||||||
|
|
||||||
Html(layout("Tokens", &content))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_token(State(state): State<AppState>, Form(form): Form<CreateTokenForm>) -> Html<String> {
|
|
||||||
let response = state
|
|
||||||
.client
|
|
||||||
.post(format!("{}/admin/tokens", state.api_base_url))
|
|
||||||
.bearer_auth(&state.api_token)
|
|
||||||
.json(&serde_json::json!({"name": form.name, "scope": form.scope}))
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match response {
|
|
||||||
Ok(resp) if resp.status().is_success() => {
|
|
||||||
let created: Result<CreatedToken, _> = resp.json().await;
|
|
||||||
match created {
|
|
||||||
Ok(token) => Html(layout(
|
|
||||||
"Token Created",
|
|
||||||
&format!(
|
|
||||||
"<h1>Token Created</h1><p>Copie ce token maintenant (il ne sera plus affiche):</p><pre>{}</pre><p><a href='/tokens'>Back to tokens</a></p>",
|
|
||||||
html_escape(&token.token)
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
Err(_) => Html(layout("Error", "<p>Token created but response parse failed.</p><p><a href='/tokens'>Back</a></p>")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Html(layout("Error", "<p>Token creation failed.</p><p><a href='/tokens'>Back</a></p>")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn revoke_token(State(state): State<AppState>, Form(form): Form<RevokeTokenForm>) -> Redirect {
|
|
||||||
let _ = state
|
|
||||||
.client
|
|
||||||
.delete(format!("{}/admin/tokens/{}", state.api_base_url, form.id))
|
|
||||||
.bearer_auth(&state.api_token)
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
Redirect::to("/tokens")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_libraries(state: &AppState) -> Result<Vec<LibraryDto>, reqwest::Error> {
|
|
||||||
state
|
|
||||||
.client
|
|
||||||
.get(format!("{}/libraries", state.api_base_url))
|
|
||||||
.bearer_auth(&state.api_token)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.json::<Vec<LibraryDto>>()
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_jobs(state: &AppState) -> Result<Vec<IndexJobDto>, reqwest::Error> {
|
|
||||||
state
|
|
||||||
.client
|
|
||||||
.get(format!("{}/index/status", state.api_base_url))
|
|
||||||
.bearer_auth(&state.api_token)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.json::<Vec<IndexJobDto>>()
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_tokens(state: &AppState) -> Result<Vec<TokenDto>, reqwest::Error> {
|
|
||||||
state
|
|
||||||
.client
|
|
||||||
.get(format!("{}/admin/tokens", state.api_base_url))
|
|
||||||
.bearer_auth(&state.api_token)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.json::<Vec<TokenDto>>()
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layout(title: &str, content: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>{}</title><style>body{{font-family:ui-sans-serif,system-ui;margin:2rem;line-height:1.4}}table{{width:100%;border-collapse:collapse;margin-top:1rem}}th,td{{text-align:left}}input,select,button{{padding:.45rem;margin:.25rem}}</style></head><body><nav><a href='/'>Home</a> | <a href='/libraries'>Libraries</a> | <a href='/jobs'>Jobs</a> | <a href='/tokens'>Tokens</a></nav><hr />{}</body></html>",
|
|
||||||
html_escape(title),
|
|
||||||
content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn html_escape(value: &str) -> String {
|
|
||||||
value
|
|
||||||
.replace('&', "&")
|
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
|
||||||
.replace('"', """)
|
|
||||||
}
|
|
||||||
@@ -31,3 +31,5 @@ uuid.workspace = true
|
|||||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||||
utoipa.workspace = true
|
utoipa.workspace = true
|
||||||
utoipa-swagger-ui = { workspace = true, features = ["axum"] }
|
utoipa-swagger-ui = { workspace = true, features = ["axum"] }
|
||||||
|
webp = "0.3"
|
||||||
|
walkdir = "2"
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
FROM rust:1-bookworm AS builder
|
FROM rust:1-bookworm AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install sccache for faster builds
|
||||||
|
RUN cargo install sccache --locked
|
||||||
|
ENV RUSTC_WRAPPER=sccache
|
||||||
|
ENV SCCACHE_DIR=/sccache
|
||||||
|
|
||||||
COPY Cargo.toml ./
|
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 apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
|
|
||||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||||
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 apps/admin-ui/src apps/admin-ui/src
|
|
||||||
COPY crates/core/src crates/core/src
|
COPY crates/core/src crates/core/src
|
||||||
COPY crates/parsers/src crates/parsers/src
|
COPY crates/parsers/src crates/parsers/src
|
||||||
|
|
||||||
RUN cargo build --release -p api
|
# Build with sccache (cache persisted between builds via Docker cache mount)
|
||||||
|
RUN --mount=type=cache,target=/sccache \
|
||||||
|
cargo build --release -p api
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget unrar-free poppler-utils && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget unar poppler-utils locales && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||||
|
ENV LANG=en_US.UTF-8
|
||||||
|
ENV LC_ALL=en_US.UTF-8
|
||||||
COPY --from=builder /app/target/release/api /usr/local/bin/api
|
COPY --from=builder /app/target/release/api /usr/local/bin/api
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["/usr/local/bin/api"]
|
CMD ["/usr/local/bin/api"]
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub struct BookItem {
|
|||||||
pub volume: Option<i32>,
|
pub volume: Option<i32>,
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
pub page_count: Option<i32>,
|
pub page_count: Option<i32>,
|
||||||
|
pub thumbnail_url: Option<String>,
|
||||||
#[schema(value_type = String)]
|
#[schema(value_type = String)]
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,7 @@ pub struct BookDetails {
|
|||||||
pub volume: Option<i32>,
|
pub volume: Option<i32>,
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
pub page_count: Option<i32>,
|
pub page_count: Option<i32>,
|
||||||
|
pub thumbnail_url: Option<String>,
|
||||||
pub file_path: Option<String>,
|
pub file_path: Option<String>,
|
||||||
pub file_format: Option<String>,
|
pub file_format: Option<String>,
|
||||||
pub file_parse_status: Option<String>,
|
pub file_parse_status: Option<String>,
|
||||||
@@ -96,7 +98,7 @@ pub async fn list_books(
|
|||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, library_id, kind, title, author, series, volume, language, page_count, updated_at
|
SELECT id, library_id, kind, title, author, series, volume, language, page_count, thumbnail_path, updated_at
|
||||||
FROM books
|
FROM books
|
||||||
WHERE ($1::uuid IS NULL OR library_id = $1)
|
WHERE ($1::uuid IS NULL OR library_id = $1)
|
||||||
AND ($2::text IS NULL OR kind = $2)
|
AND ($2::text IS NULL OR kind = $2)
|
||||||
@@ -107,7 +109,7 @@ pub async fn list_books(
|
|||||||
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
||||||
-- Extract first number group and convert to integer for numeric sort
|
-- Extract first number group and convert to integer for numeric sort
|
||||||
COALESCE(
|
COALESCE(
|
||||||
NULLIF(REGEXP_REPLACE(LOWER(title), '^[^0-9]*', '', 'g'), '')::int,
|
(REGEXP_MATCH(LOWER(title), '\d+'))[1]::int,
|
||||||
0
|
0
|
||||||
),
|
),
|
||||||
-- Then by full title as fallback
|
-- Then by full title as fallback
|
||||||
@@ -135,7 +137,9 @@ pub async fn list_books(
|
|||||||
let mut items: Vec<BookItem> = rows
|
let mut items: Vec<BookItem> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.take(limit as usize)
|
.take(limit as usize)
|
||||||
.map(|row| BookItem {
|
.map(|row| {
|
||||||
|
let thumbnail_path: Option<String> = row.get("thumbnail_path");
|
||||||
|
BookItem {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
library_id: row.get("library_id"),
|
library_id: row.get("library_id"),
|
||||||
kind: row.get("kind"),
|
kind: row.get("kind"),
|
||||||
@@ -145,7 +149,9 @@ pub async fn list_books(
|
|||||||
volume: row.get("volume"),
|
volume: row.get("volume"),
|
||||||
language: row.get("language"),
|
language: row.get("language"),
|
||||||
page_count: row.get("page_count"),
|
page_count: row.get("page_count"),
|
||||||
|
thumbnail_url: thumbnail_path.map(|_p| format!("/books/{}/thumbnail", row.get::<Uuid, _>("id"))),
|
||||||
updated_at: row.get("updated_at"),
|
updated_at: row.get("updated_at"),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -182,7 +188,7 @@ pub async fn get_book(
|
|||||||
) -> Result<Json<BookDetails>, ApiError> {
|
) -> Result<Json<BookDetails>, ApiError> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.series, b.volume, b.language, b.page_count,
|
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.series, b.volume, b.language, b.page_count, b.thumbnail_path,
|
||||||
bf.abs_path, bf.format, bf.parse_status
|
bf.abs_path, bf.format, bf.parse_status
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -200,6 +206,7 @@ pub async fn get_book(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let row = row.ok_or_else(|| ApiError::not_found("book not found"))?;
|
let row = row.ok_or_else(|| ApiError::not_found("book not found"))?;
|
||||||
|
let thumbnail_path: Option<String> = row.get("thumbnail_path");
|
||||||
Ok(Json(BookDetails {
|
Ok(Json(BookDetails {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
library_id: row.get("library_id"),
|
library_id: row.get("library_id"),
|
||||||
@@ -210,6 +217,7 @@ pub async fn get_book(
|
|||||||
volume: row.get("volume"),
|
volume: row.get("volume"),
|
||||||
language: row.get("language"),
|
language: row.get("language"),
|
||||||
page_count: row.get("page_count"),
|
page_count: row.get("page_count"),
|
||||||
|
thumbnail_url: thumbnail_path.map(|_| format!("/books/{}/thumbnail", id)),
|
||||||
file_path: row.get("abs_path"),
|
file_path: row.get("abs_path"),
|
||||||
file_format: row.get("format"),
|
file_format: row.get("format"),
|
||||||
file_parse_status: row.get("parse_status"),
|
file_parse_status: row.get("parse_status"),
|
||||||
@@ -224,16 +232,33 @@ pub struct SeriesItem {
|
|||||||
pub first_book_id: Uuid,
|
pub first_book_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all series in a library
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct SeriesPage {
|
||||||
|
pub items: Vec<SeriesItem>,
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct ListSeriesQuery {
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
#[schema(value_type = Option<i64>, example = 50)]
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all series in a library with pagination
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/libraries/{library_id}/series",
|
path = "/libraries/{library_id}/series",
|
||||||
tag = "books",
|
tag = "books",
|
||||||
params(
|
params(
|
||||||
("library_id" = String, Path, description = "Library UUID"),
|
("library_id" = String, Path, description = "Library UUID"),
|
||||||
|
("cursor" = Option<String>, Query, description = "Cursor for pagination (series name)"),
|
||||||
|
("limit" = Option<i64>, Query, description = "Max items to return (max 200)"),
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = Vec<SeriesItem>),
|
(status = 200, body = SeriesPage),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
),
|
),
|
||||||
security(("Bearer" = []))
|
security(("Bearer" = []))
|
||||||
@@ -241,7 +266,10 @@ pub struct SeriesItem {
|
|||||||
pub async fn list_series(
|
pub async fn list_series(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(library_id): Path<Uuid>,
|
Path(library_id): Path<Uuid>,
|
||||||
) -> Result<Json<Vec<SeriesItem>>, ApiError> {
|
Query(query): Query<ListSeriesQuery>,
|
||||||
|
) -> Result<Json<SeriesPage>, ApiError> {
|
||||||
|
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
WITH sorted_books AS (
|
WITH sorted_books AS (
|
||||||
@@ -253,7 +281,7 @@ pub async fn list_series(
|
|||||||
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
||||||
COALESCE(NULLIF(REGEXP_REPLACE(LOWER(title), '^[^0-9]*', '', 'g'), '')::int, 0),
|
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
||||||
title ASC
|
title ASC
|
||||||
) as rn
|
) as rn
|
||||||
FROM books
|
FROM books
|
||||||
@@ -272,23 +300,28 @@ pub async fn list_series(
|
|||||||
sb.id as first_book_id
|
sb.id as first_book_id
|
||||||
FROM series_counts sc
|
FROM series_counts sc
|
||||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
||||||
|
WHERE ($2::text IS NULL OR sc.name > $2)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
-- Natural sort: extract text part before numbers
|
-- Natural sort: extract text part before numbers
|
||||||
REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'),
|
REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'),
|
||||||
-- Extract first number group and convert to integer
|
-- Extract first number group and convert to integer
|
||||||
COALESCE(
|
COALESCE(
|
||||||
NULLIF(REGEXP_REPLACE(LOWER(sc.name), '^[^0-9]*', '', 'g'), '')::int,
|
(REGEXP_MATCH(LOWER(sc.name), '\d+'))[1]::int,
|
||||||
0
|
0
|
||||||
),
|
),
|
||||||
sc.name ASC
|
sc.name ASC
|
||||||
|
LIMIT $3
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
|
.bind(query.cursor.as_deref())
|
||||||
|
.bind(limit + 1)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let series: Vec<SeriesItem> = rows
|
let mut items: Vec<SeriesItem> = rows
|
||||||
.iter()
|
.iter()
|
||||||
|
.take(limit as usize)
|
||||||
.map(|row| SeriesItem {
|
.map(|row| SeriesItem {
|
||||||
name: row.get("name"),
|
name: row.get("name"),
|
||||||
book_count: row.get("book_count"),
|
book_count: row.get("book_count"),
|
||||||
@@ -296,5 +329,51 @@ pub async fn list_series(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(series))
|
let next_cursor = if rows.len() > limit as usize {
|
||||||
|
items.last().map(|s| s.name.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(SeriesPage {
|
||||||
|
items: std::mem::take(&mut items),
|
||||||
|
next_cursor,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get_thumbnail(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(book_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let row = sqlx::query("SELECT thumbnail_path FROM books WHERE id = $1")
|
||||||
|
.bind(book_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let row = row.ok_or_else(|| ApiError::not_found("book not found"))?;
|
||||||
|
let thumbnail_path: Option<String> = row.get("thumbnail_path");
|
||||||
|
|
||||||
|
let data = if let Some(ref path) = thumbnail_path {
|
||||||
|
std::fs::read(path)
|
||||||
|
.map_err(|e| ApiError::internal(format!("cannot read thumbnail: {}", e)))?
|
||||||
|
} else {
|
||||||
|
// Fallback: render page 1 on the fly (same as pages logic)
|
||||||
|
crate::pages::render_book_page_1(&state, book_id, 300, 80).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/webp"));
|
||||||
|
headers.insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((StatusCode::OK, headers, Body::from(data)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -51,7 +55,13 @@ impl ApiError {
|
|||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
(self.status, Json(ErrorBody { error: &self.message })).into_response()
|
(
|
||||||
|
self.status,
|
||||||
|
Json(ErrorBody {
|
||||||
|
error: &self.message,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,3 +70,9 @@ impl From<sqlx::Error> for ApiError {
|
|||||||
Self::internal(format!("database error: {err}"))
|
Self::internal(format!("database error: {err}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for ApiError {
|
||||||
|
fn from(err: std::io::Error) -> Self {
|
||||||
|
Self::internal(format!("IO error: {err}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,12 +34,17 @@ pub struct IndexJobResponse {
|
|||||||
pub error_opt: Option<String>,
|
pub error_opt: Option<String>,
|
||||||
#[schema(value_type = String)]
|
#[schema(value_type = String)]
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub progress_percent: Option<i32>,
|
||||||
|
pub processed_files: Option<i32>,
|
||||||
|
pub total_files: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct FolderItem {
|
pub struct FolderItem {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
pub depth: usize,
|
||||||
|
pub has_children: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -140,7 +145,7 @@ pub async fn enqueue_rebuild(
|
|||||||
)]
|
)]
|
||||||
pub async fn list_index_jobs(State(state): State<AppState>) -> Result<Json<Vec<IndexJobResponse>>, ApiError> {
|
pub async fn list_index_jobs(State(state): State<AppState>) -> Result<Json<Vec<IndexJobResponse>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at FROM index_jobs ORDER BY created_at DESC LIMIT 100",
|
"SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, progress_percent, processed_files, total_files FROM index_jobs ORDER BY created_at DESC LIMIT 100",
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -169,7 +174,7 @@ pub async fn cancel_job(
|
|||||||
id: axum::extract::Path<Uuid>,
|
id: axum::extract::Path<Uuid>,
|
||||||
) -> Result<Json<IndexJobResponse>, ApiError> {
|
) -> Result<Json<IndexJobResponse>, ApiError> {
|
||||||
let rows_affected = sqlx::query(
|
let rows_affected = sqlx::query(
|
||||||
"UPDATE index_jobs SET status = 'cancelled' WHERE id = $1 AND status IN ('pending', 'running')",
|
"UPDATE index_jobs SET status = 'cancelled' WHERE id = $1 AND status IN ('pending', 'running', 'generating_thumbnails')",
|
||||||
)
|
)
|
||||||
.bind(id.0)
|
.bind(id.0)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
@@ -180,7 +185,7 @@ pub async fn cancel_job(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at FROM index_jobs WHERE id = $1",
|
"SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, progress_percent, processed_files, total_files FROM index_jobs WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(id.0)
|
.bind(id.0)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
@@ -194,10 +199,14 @@ fn get_libraries_root() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// List available folders in /libraries for library creation
|
/// List available folders in /libraries for library creation
|
||||||
|
/// Supports browsing subdirectories via optional path parameter
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/folders",
|
path = "/folders",
|
||||||
tag = "indexing",
|
tag = "indexing",
|
||||||
|
params(
|
||||||
|
("path" = Option<String>, Query, description = "Optional subdirectory path to browse (e.g., '/libraries/manga/action')"),
|
||||||
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = Vec<FolderItem>),
|
(status = 200, body = Vec<FolderItem>),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
@@ -205,18 +214,73 @@ fn get_libraries_root() -> String {
|
|||||||
),
|
),
|
||||||
security(("Bearer" = []))
|
security(("Bearer" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn list_folders(State(_state): State<AppState>) -> Result<Json<Vec<FolderItem>>, ApiError> {
|
pub async fn list_folders(
|
||||||
|
State(_state): State<AppState>,
|
||||||
|
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||||
|
) -> Result<Json<Vec<FolderItem>>, ApiError> {
|
||||||
let libraries_root = get_libraries_root();
|
let libraries_root = get_libraries_root();
|
||||||
let libraries_path = std::path::Path::new(&libraries_root);
|
let base_path = std::path::Path::new(&libraries_root);
|
||||||
let mut folders = Vec::new();
|
|
||||||
|
|
||||||
if let Ok(entries) = std::fs::read_dir(libraries_path) {
|
// Determine which path to browse
|
||||||
|
let target_path = if let Some(sub_path) = params.get("path") {
|
||||||
|
// Validate the path to prevent directory traversal attacks
|
||||||
|
if sub_path.contains("..") || sub_path.contains("~") {
|
||||||
|
return Err(ApiError::bad_request("Invalid path"));
|
||||||
|
}
|
||||||
|
// Remove /libraries/ prefix if present since base_path is already /libraries
|
||||||
|
let cleaned_path = sub_path.trim_start_matches("/libraries/").trim_start_matches('/');
|
||||||
|
if cleaned_path.is_empty() {
|
||||||
|
base_path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
base_path.join(cleaned_path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
base_path.to_path_buf()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure the path is within the libraries root
|
||||||
|
let canonical_target = target_path.canonicalize().unwrap_or(target_path.clone());
|
||||||
|
let canonical_base = base_path.canonicalize().unwrap_or(base_path.to_path_buf());
|
||||||
|
|
||||||
|
if !canonical_target.starts_with(&canonical_base) {
|
||||||
|
return Err(ApiError::bad_request("Path is outside libraries root"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut folders = Vec::new();
|
||||||
|
let depth = if params.get("path").is_some() {
|
||||||
|
canonical_target.strip_prefix(&canonical_base)
|
||||||
|
.map(|p| p.components().count())
|
||||||
|
.unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&canonical_target) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
|
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Check if this folder has children
|
||||||
|
let has_children = if let Ok(sub_entries) = std::fs::read_dir(entry.path()) {
|
||||||
|
sub_entries.flatten().any(|e| {
|
||||||
|
e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate the full path relative to libraries root
|
||||||
|
let full_path = if let Ok(relative) = entry.path().strip_prefix(&canonical_base) {
|
||||||
|
format!("/libraries/{}", relative.to_string_lossy())
|
||||||
|
} else {
|
||||||
|
format!("/libraries/{}", name)
|
||||||
|
};
|
||||||
|
|
||||||
folders.push(FolderItem {
|
folders.push(FolderItem {
|
||||||
name: name.clone(),
|
name,
|
||||||
path: format!("/libraries/{}", name),
|
path: full_path,
|
||||||
|
depth,
|
||||||
|
has_children,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,6 +301,9 @@ pub fn map_row(row: sqlx::postgres::PgRow) -> IndexJobResponse {
|
|||||||
stats_json: row.get("stats_json"),
|
stats_json: row.get("stats_json"),
|
||||||
error_opt: row.get("error_opt"),
|
error_opt: row.get("error_opt"),
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
|
progress_percent: row.try_get("progress_percent").ok(),
|
||||||
|
processed_files: row.try_get("processed_files").ok(),
|
||||||
|
total_files: row.try_get("total_files").ok(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,9 +339,9 @@ fn map_row_detail(row: sqlx::postgres::PgRow) -> IndexJobDetailResponse {
|
|||||||
)]
|
)]
|
||||||
pub async fn get_active_jobs(State(state): State<AppState>) -> Result<Json<Vec<IndexJobResponse>>, ApiError> {
|
pub async fn get_active_jobs(State(state): State<AppState>) -> Result<Json<Vec<IndexJobResponse>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at
|
"SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, progress_percent, processed_files, total_files
|
||||||
FROM index_jobs
|
FROM index_jobs
|
||||||
WHERE status IN ('pending', 'running')
|
WHERE status IN ('pending', 'running', 'generating_thumbnails')
|
||||||
ORDER BY created_at ASC"
|
ORDER BY created_at ASC"
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ mod libraries;
|
|||||||
mod openapi;
|
mod openapi;
|
||||||
mod pages;
|
mod pages;
|
||||||
mod search;
|
mod search;
|
||||||
|
mod settings;
|
||||||
|
mod thumbnails;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
@@ -84,7 +86,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
meili_url: Arc::from(config.meili_url),
|
meili_url: Arc::from(config.meili_url),
|
||||||
meili_master_key: Arc::from(config.meili_master_key),
|
meili_master_key: Arc::from(config.meili_master_key),
|
||||||
page_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(512).expect("non-zero")))),
|
page_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(512).expect("non-zero")))),
|
||||||
page_render_limit: Arc::new(Semaphore::new(4)),
|
page_render_limit: Arc::new(Semaphore::new(8)),
|
||||||
metrics: Arc::new(Metrics::new()),
|
metrics: Arc::new(Metrics::new()),
|
||||||
read_rate_limit: Arc::new(Mutex::new(ReadRateLimit {
|
read_rate_limit: Arc::new(Mutex::new(ReadRateLimit {
|
||||||
window_started_at: Instant::now(),
|
window_started_at: Instant::now(),
|
||||||
@@ -98,15 +100,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/libraries/:id/scan", axum::routing::post(libraries::scan_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("/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/regenerate", axum::routing::post(thumbnails::start_thumbnails_regenerate))
|
||||||
.route("/index/status", get(index_jobs::list_index_jobs))
|
.route("/index/status", get(index_jobs::list_index_jobs))
|
||||||
.route("/index/jobs/active", get(index_jobs::get_active_jobs))
|
.route("/index/jobs/active", get(index_jobs::get_active_jobs))
|
||||||
.route("/index/jobs/:id", get(index_jobs::get_job_details))
|
.route("/index/jobs/:id", get(index_jobs::get_job_details))
|
||||||
.route("/index/jobs/:id/stream", get(index_jobs::stream_job_progress))
|
.route("/index/jobs/:id/stream", get(index_jobs::stream_job_progress))
|
||||||
|
.route("/index/jobs/:id/thumbnails/checkup", axum::routing::post(thumbnails::start_checkup))
|
||||||
.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/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))
|
||||||
|
.merge(settings::settings_routes())
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
auth::require_admin,
|
auth::require_admin,
|
||||||
@@ -115,6 +121,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let read_routes = Router::new()
|
let read_routes = Router::new()
|
||||||
.route("/books", get(books::list_books))
|
.route("/books", get(books::list_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/pages/:n", get(pages::get_page))
|
.route("/books/:id/pages/:n", get(pages::get_page))
|
||||||
.route("/libraries/:library_id/series", get(books::list_series))
|
.route("/libraries/:library_id/series", get(books::list_series))
|
||||||
.route("/search", get(search::search_books))
|
.route("/search", get(search::search_books))
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use utoipa::OpenApi;
|
|||||||
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,
|
||||||
|
crate::thumbnails::start_thumbnails_rebuild,
|
||||||
|
crate::thumbnails::start_thumbnails_regenerate,
|
||||||
crate::index_jobs::list_index_jobs,
|
crate::index_jobs::list_index_jobs,
|
||||||
crate::index_jobs::get_active_jobs,
|
crate::index_jobs::get_active_jobs,
|
||||||
crate::index_jobs::get_job_details,
|
crate::index_jobs::get_job_details,
|
||||||
@@ -37,6 +39,7 @@ use utoipa::OpenApi;
|
|||||||
crate::search::SearchQuery,
|
crate::search::SearchQuery,
|
||||||
crate::search::SearchResponse,
|
crate::search::SearchResponse,
|
||||||
crate::index_jobs::RebuildRequest,
|
crate::index_jobs::RebuildRequest,
|
||||||
|
crate::thumbnails::ThumbnailsRebuildRequest,
|
||||||
crate::index_jobs::IndexJobResponse,
|
crate::index_jobs::IndexJobResponse,
|
||||||
crate::index_jobs::IndexJobDetailResponse,
|
crate::index_jobs::IndexJobDetailResponse,
|
||||||
crate::index_jobs::JobErrorResponse,
|
crate::index_jobs::JobErrorResponse,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
io::Read,
|
io::{Read, Write},
|
||||||
path::Path,
|
path::{Path, PathBuf},
|
||||||
sync::{atomic::Ordering, Arc},
|
sync::{atomic::Ordering, Arc},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
@@ -11,12 +11,14 @@ use axum::{
|
|||||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use image::{codecs::jpeg::JpegEncoder, codecs::png::PngEncoder, codecs::webp::WebPEncoder, ColorType, ImageEncoder};
|
use image::{codecs::jpeg::JpegEncoder, codecs::png::PngEncoder, ColorType, ImageEncoder, ImageFormat};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
use tracing::{debug, error, info, instrument, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::{error::ApiError, AppState};
|
use crate::{error::ApiError, AppState};
|
||||||
|
|
||||||
@@ -29,7 +31,44 @@ fn remap_libraries_path(path: &str) -> String {
|
|||||||
path.to_string()
|
path.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
fn get_image_cache_dir() -> PathBuf {
|
||||||
|
std::env::var("IMAGE_CACHE_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("/tmp/stripstream-image-cache"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_cache_key(abs_path: &str, page: u32, format: &str, quality: u8, width: u32) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(abs_path.as_bytes());
|
||||||
|
hasher.update(page.to_le_bytes());
|
||||||
|
hasher.update(format.as_bytes());
|
||||||
|
hasher.update(quality.to_le_bytes());
|
||||||
|
hasher.update(width.to_le_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_cache_path(cache_key: &str, format: &OutputFormat) -> PathBuf {
|
||||||
|
let cache_dir = get_image_cache_dir();
|
||||||
|
let prefix = &cache_key[..2];
|
||||||
|
let ext = format.extension();
|
||||||
|
cache_dir.join(prefix).join(format!("{}.{}", cache_key, ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_from_disk_cache(cache_path: &Path) -> Option<Vec<u8>> {
|
||||||
|
std::fs::read(cache_path).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_to_disk_cache(cache_path: &Path, data: &[u8]) -> Result<(), std::io::Error> {
|
||||||
|
if let Some(parent) = cache_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut file = std::fs::File::create(cache_path)?;
|
||||||
|
file.write_all(data)?;
|
||||||
|
file.sync_data()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema, Debug)]
|
||||||
pub struct PageQuery {
|
pub struct PageQuery {
|
||||||
#[schema(value_type = Option<String>, example = "webp")]
|
#[schema(value_type = Option<String>, example = "webp")]
|
||||||
pub format: Option<String>,
|
pub format: Option<String>,
|
||||||
@@ -39,7 +78,7 @@ pub struct PageQuery {
|
|||||||
pub width: Option<u32>,
|
pub width: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
enum OutputFormat {
|
enum OutputFormat {
|
||||||
Jpeg,
|
Jpeg,
|
||||||
Png,
|
Png,
|
||||||
@@ -93,12 +132,16 @@ impl OutputFormat {
|
|||||||
),
|
),
|
||||||
security(("Bearer" = []))
|
security(("Bearer" = []))
|
||||||
)]
|
)]
|
||||||
|
#[instrument(skip(state), fields(book_id = %book_id, page = n))]
|
||||||
pub async fn get_page(
|
pub async fn get_page(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath((book_id, n)): AxumPath<(Uuid, u32)>,
|
AxumPath((book_id, n)): AxumPath<(Uuid, u32)>,
|
||||||
Query(query): Query<PageQuery>,
|
Query(query): Query<PageQuery>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
|
info!("Processing image request");
|
||||||
|
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
|
warn!("Invalid page number: 0");
|
||||||
return Err(ApiError::bad_request("page index starts at 1"));
|
return Err(ApiError::bad_request("page index starts at 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,15 +149,19 @@ pub async fn get_page(
|
|||||||
let quality = query.quality.unwrap_or(80).clamp(1, 100);
|
let quality = query.quality.unwrap_or(80).clamp(1, 100);
|
||||||
let width = query.width.unwrap_or(0);
|
let width = query.width.unwrap_or(0);
|
||||||
if width > 2160 {
|
if width > 2160 {
|
||||||
|
warn!("Invalid width: {}", width);
|
||||||
return Err(ApiError::bad_request("width must be <= 2160"));
|
return Err(ApiError::bad_request("width must be <= 2160"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let cache_key = format!("{book_id}:{n}:{}:{quality}:{width}", format.extension());
|
let memory_cache_key = format!("{book_id}:{n}:{}:{quality}:{width}", format.extension());
|
||||||
if let Some(cached) = state.page_cache.lock().await.get(&cache_key).cloned() {
|
|
||||||
|
if let Some(cached) = state.page_cache.lock().await.get(&memory_cache_key).cloned() {
|
||||||
state.metrics.page_cache_hits.fetch_add(1, Ordering::Relaxed);
|
state.metrics.page_cache_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
return Ok(image_response(cached, format.content_type()));
|
debug!("Memory cache hit for key: {}", memory_cache_key);
|
||||||
|
return Ok(image_response(cached, format.content_type(), None));
|
||||||
}
|
}
|
||||||
state.metrics.page_cache_misses.fetch_add(1, Ordering::Relaxed);
|
state.metrics.page_cache_misses.fetch_add(1, Ordering::Relaxed);
|
||||||
|
debug!("Memory cache miss for key: {}", memory_cache_key);
|
||||||
|
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -127,11 +174,128 @@ pub async fn get_page(
|
|||||||
)
|
)
|
||||||
.bind(book_id)
|
.bind(book_id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Database error fetching book file for book_id {}: {}", book_id, e);
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let row = match row {
|
||||||
|
Some(r) => r,
|
||||||
|
None => {
|
||||||
|
error!("Book file not found for book_id: {}", book_id);
|
||||||
|
return Err(ApiError::not_found("book file not found"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let abs_path: String = row.get("abs_path");
|
||||||
|
let abs_path = remap_libraries_path(&abs_path);
|
||||||
|
let input_format: String = row.get("format");
|
||||||
|
|
||||||
|
info!("Processing book file: {} (format: {})", abs_path, input_format);
|
||||||
|
|
||||||
|
let disk_cache_key = get_cache_key(&abs_path, n, format.extension(), quality, width);
|
||||||
|
let cache_path = get_cache_path(&disk_cache_key, &format);
|
||||||
|
|
||||||
|
if let Some(cached_bytes) = read_from_disk_cache(&cache_path) {
|
||||||
|
info!("Disk cache hit for: {}", cache_path.display());
|
||||||
|
let bytes = Arc::new(cached_bytes);
|
||||||
|
state.page_cache.lock().await.put(memory_cache_key, bytes.clone());
|
||||||
|
return Ok(image_response(bytes, format.content_type(), Some(&disk_cache_key)));
|
||||||
|
}
|
||||||
|
debug!("Disk cache miss for: {}", cache_path.display());
|
||||||
|
|
||||||
|
let _permit = state
|
||||||
|
.page_render_limit
|
||||||
|
.clone()
|
||||||
|
.acquire_owned()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Failed to acquire render permit: {}", e);
|
||||||
|
ApiError::internal("render limiter unavailable")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!("Rendering page {} from {}", n, abs_path);
|
||||||
|
let abs_path_clone = abs_path.clone();
|
||||||
|
let format_clone = format;
|
||||||
|
let start_time = std::time::Instant::now();
|
||||||
|
|
||||||
|
let bytes = tokio::time::timeout(
|
||||||
|
Duration::from_secs(60),
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
render_page(&abs_path_clone, &input_format, n, &format_clone, quality, width)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
error!("Page rendering timeout for {} page {}", abs_path, n);
|
||||||
|
ApiError::internal("page rendering timeout")
|
||||||
|
})?
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Render task panicked for {} page {}: {}", abs_path, n, e);
|
||||||
|
ApiError::internal(format!("render task failed: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let duration = start_time.elapsed();
|
||||||
|
|
||||||
|
match bytes {
|
||||||
|
Ok(data) => {
|
||||||
|
info!("Successfully rendered page {} in {:?}", n, duration);
|
||||||
|
|
||||||
|
if let Err(e) = write_to_disk_cache(&cache_path, &data) {
|
||||||
|
warn!("Failed to write to disk cache: {}", e);
|
||||||
|
} else {
|
||||||
|
info!("Cached rendered image to: {}", cache_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = Arc::new(data);
|
||||||
|
state.page_cache.lock().await.put(memory_cache_key, bytes.clone());
|
||||||
|
|
||||||
|
Ok(image_response(bytes, format.content_type(), Some(&disk_cache_key)))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to render page {} from {}: {:?}", n, abs_path, e);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_response(bytes: Arc<Vec<u8>>, content_type: &str, etag_suffix: Option<&str>) -> Response {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::CONTENT_TYPE, HeaderValue::from_str(content_type).unwrap_or(HeaderValue::from_static("application/octet-stream")));
|
||||||
|
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=31536000, immutable"));
|
||||||
|
|
||||||
|
let etag = if let Some(suffix) = etag_suffix {
|
||||||
|
format!("\"{}\"", suffix)
|
||||||
|
} else {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&*bytes);
|
||||||
|
format!("\"{:x}\"", hasher.finalize())
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(v) = HeaderValue::from_str(&etag) {
|
||||||
|
headers.insert(header::ETAG, v);
|
||||||
|
}
|
||||||
|
(StatusCode::OK, headers, Body::from((*bytes).clone())).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render page 1 of a book (for thumbnail fallback or thumbnail checkup). Uses thumbnail dimensions by default.
|
||||||
|
pub async fn render_book_page_1(
|
||||||
|
state: &AppState,
|
||||||
|
book_id: Uuid,
|
||||||
|
width: u32,
|
||||||
|
quality: u8,
|
||||||
|
) -> Result<Vec<u8>, ApiError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"SELECT abs_path, format FROM book_files WHERE book_id = $1 ORDER BY updated_at DESC LIMIT 1"#,
|
||||||
|
)
|
||||||
|
.bind(book_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(e.to_string()))?;
|
||||||
|
|
||||||
let row = row.ok_or_else(|| ApiError::not_found("book file not found"))?;
|
let row = row.ok_or_else(|| ApiError::not_found("book file not found"))?;
|
||||||
let abs_path: String = row.get("abs_path");
|
let abs_path: String = row.get("abs_path");
|
||||||
// Remap /libraries to LIBRARIES_ROOT_PATH for local development
|
|
||||||
let abs_path = remap_libraries_path(&abs_path);
|
let abs_path = remap_libraries_path(&abs_path);
|
||||||
let input_format: String = row.get("format");
|
let input_format: String = row.get("format");
|
||||||
|
|
||||||
@@ -142,31 +306,25 @@ pub async fn get_page(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::internal("render limiter unavailable"))?;
|
.map_err(|_| ApiError::internal("render limiter unavailable"))?;
|
||||||
|
|
||||||
|
let abs_path_clone = abs_path.clone();
|
||||||
let bytes = tokio::time::timeout(
|
let bytes = tokio::time::timeout(
|
||||||
Duration::from_secs(12),
|
Duration::from_secs(60),
|
||||||
tokio::task::spawn_blocking(move || render_page(&abs_path, &input_format, n, &format, quality, width)),
|
tokio::task::spawn_blocking(move || {
|
||||||
|
render_page(
|
||||||
|
&abs_path_clone,
|
||||||
|
&input_format,
|
||||||
|
1,
|
||||||
|
&OutputFormat::Webp,
|
||||||
|
quality,
|
||||||
|
width,
|
||||||
|
)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::internal("page rendering timeout"))?
|
.map_err(|_| ApiError::internal("page rendering timeout"))?
|
||||||
.map_err(|e| ApiError::internal(format!("render task failed: {e}")))??;
|
.map_err(|e| ApiError::internal(format!("render task failed: {e}")))?;
|
||||||
|
|
||||||
let bytes = Arc::new(bytes);
|
bytes
|
||||||
state.page_cache.lock().await.put(cache_key, bytes.clone());
|
|
||||||
|
|
||||||
Ok(image_response(bytes, format.content_type()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn image_response(bytes: Arc<Vec<u8>>, content_type: &str) -> Response {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(header::CONTENT_TYPE, HeaderValue::from_str(content_type).unwrap_or(HeaderValue::from_static("application/octet-stream")));
|
|
||||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300"));
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(&*bytes);
|
|
||||||
let etag = format!("\"{:x}\"", hasher.finalize());
|
|
||||||
if let Ok(v) = HeaderValue::from_str(&etag) {
|
|
||||||
headers.insert(header::ETAG, v);
|
|
||||||
}
|
|
||||||
(StatusCode::OK, headers, Body::from((*bytes).clone())).into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_page(
|
fn render_page(
|
||||||
@@ -188,62 +346,118 @@ fn render_page(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn extract_cbz_page(abs_path: &str, page_number: u32) -> Result<Vec<u8>, ApiError> {
|
fn extract_cbz_page(abs_path: &str, page_number: u32) -> Result<Vec<u8>, ApiError> {
|
||||||
let file = std::fs::File::open(abs_path).map_err(|e| ApiError::internal(format!("cannot open cbz: {e}")))?;
|
debug!("Opening CBZ archive: {}", abs_path);
|
||||||
let mut archive = zip::ZipArchive::new(file).map_err(|e| ApiError::internal(format!("invalid cbz: {e}")))?;
|
let file = std::fs::File::open(abs_path).map_err(|e| {
|
||||||
|
error!("Cannot open CBZ file {}: {}", abs_path, e);
|
||||||
|
ApiError::internal(format!("cannot open cbz: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut archive = zip::ZipArchive::new(file).map_err(|e| {
|
||||||
|
error!("Invalid CBZ archive {}: {}", abs_path, e);
|
||||||
|
ApiError::internal(format!("invalid cbz: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
let mut image_names: Vec<String> = Vec::new();
|
let mut image_names: Vec<String> = Vec::new();
|
||||||
for i in 0..archive.len() {
|
for i in 0..archive.len() {
|
||||||
let entry = archive.by_index(i).map_err(|e| ApiError::internal(format!("cbz entry read failed: {e}")))?;
|
let entry = archive.by_index(i).map_err(|e| {
|
||||||
|
error!("Failed to read CBZ entry {} in {}: {}", i, abs_path, e);
|
||||||
|
ApiError::internal(format!("cbz entry read failed: {e}"))
|
||||||
|
})?;
|
||||||
let name = entry.name().to_ascii_lowercase();
|
let name = entry.name().to_ascii_lowercase();
|
||||||
if is_image_name(&name) {
|
if is_image_name(&name) {
|
||||||
image_names.push(entry.name().to_string());
|
image_names.push(entry.name().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
image_names.sort();
|
image_names.sort();
|
||||||
|
debug!("Found {} images in CBZ {}", image_names.len(), abs_path);
|
||||||
|
|
||||||
let index = page_number as usize - 1;
|
let index = page_number as usize - 1;
|
||||||
let selected = image_names.get(index).ok_or_else(|| ApiError::not_found("page out of range"))?;
|
let selected = image_names.get(index).ok_or_else(|| {
|
||||||
let mut entry = archive.by_name(selected).map_err(|e| ApiError::internal(format!("cbz page read failed: {e}")))?;
|
error!("Page {} out of range in {} (total: {})", page_number, abs_path, image_names.len());
|
||||||
|
ApiError::not_found("page out of range")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
debug!("Extracting page {} ({}) from {}", page_number, selected, abs_path);
|
||||||
|
let mut entry = archive.by_name(selected).map_err(|e| {
|
||||||
|
error!("Failed to read CBZ page {} from {}: {}", selected, abs_path, e);
|
||||||
|
ApiError::internal(format!("cbz page read failed: {e}"))
|
||||||
|
})?;
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
entry.read_to_end(&mut buf).map_err(|e| ApiError::internal(format!("cbz page load failed: {e}")))?;
|
entry.read_to_end(&mut buf).map_err(|e| {
|
||||||
|
error!("Failed to load CBZ page {} from {}: {}", selected, abs_path, e);
|
||||||
|
ApiError::internal(format!("cbz page load failed: {e}"))
|
||||||
|
})?;
|
||||||
Ok(buf)
|
Ok(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_cbr_page(abs_path: &str, page_number: u32) -> Result<Vec<u8>, ApiError> {
|
fn extract_cbr_page(abs_path: &str, page_number: u32) -> Result<Vec<u8>, ApiError> {
|
||||||
let list_output = std::process::Command::new("unrar")
|
info!("Opening CBR archive: {}", abs_path);
|
||||||
.arg("lb")
|
|
||||||
.arg(abs_path)
|
|
||||||
.output()
|
|
||||||
.map_err(|e| ApiError::internal(format!("unrar list failed: {e}")))?;
|
|
||||||
if !list_output.status.success() {
|
|
||||||
return Err(ApiError::internal("unrar could not list archive"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut entries: Vec<String> = String::from_utf8_lossy(&list_output.stdout)
|
|
||||||
.lines()
|
|
||||||
.filter(|line| is_image_name(&line.to_ascii_lowercase()))
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect();
|
|
||||||
entries.sort();
|
|
||||||
let index = page_number as usize - 1;
|
let index = page_number as usize - 1;
|
||||||
let selected = entries.get(index).ok_or_else(|| ApiError::not_found("page out of range"))?;
|
let tmp_dir = std::env::temp_dir().join(format!("stripstream-cbr-{}", Uuid::new_v4()));
|
||||||
|
debug!("Creating temp dir for CBR extraction: {}", tmp_dir.display());
|
||||||
|
|
||||||
let page_output = std::process::Command::new("unrar")
|
std::fs::create_dir_all(&tmp_dir).map_err(|e| {
|
||||||
.arg("p")
|
error!("Cannot create temp dir: {}", e);
|
||||||
.arg("-inul")
|
ApiError::internal(format!("temp dir error: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Extract directly - skip listing which fails on UTF-16 encoded filenames
|
||||||
|
let extract_output = std::process::Command::new("env")
|
||||||
|
.args(["LC_ALL=en_US.UTF-8", "LANG=en_US.UTF-8", "unar", "-o"])
|
||||||
|
.arg(&tmp_dir)
|
||||||
.arg(abs_path)
|
.arg(abs_path)
|
||||||
.arg(selected)
|
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| ApiError::internal(format!("unrar extract failed: {e}")))?;
|
.map_err(|e| {
|
||||||
if !page_output.status.success() {
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
return Err(ApiError::internal("unrar could not extract page"));
|
error!("unar extract failed: {}", e);
|
||||||
|
ApiError::internal(format!("unar extract failed: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !extract_output.status.success() {
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
let stderr = String::from_utf8_lossy(&extract_output.stderr);
|
||||||
|
error!("unar extract failed {}: {}", abs_path, stderr);
|
||||||
|
return Err(ApiError::internal("unar extract failed"));
|
||||||
}
|
}
|
||||||
Ok(page_output.stdout)
|
|
||||||
|
// Find and read the requested image (recursive search for CBR files with subdirectories)
|
||||||
|
let mut image_files: Vec<_> = WalkDir::new(&tmp_dir)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| {
|
||||||
|
let name = e.file_name().to_string_lossy().to_lowercase();
|
||||||
|
is_image_name(&name)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
image_files.sort_by_key(|e| e.path().to_string_lossy().to_lowercase());
|
||||||
|
|
||||||
|
let selected = image_files.get(index).ok_or_else(|| {
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
error!("Page {} not found (total: {})", page_number, image_files.len());
|
||||||
|
ApiError::not_found("page out of range")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let data = std::fs::read(selected.path()).map_err(|e| {
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
error!("read failed: {}", e);
|
||||||
|
ApiError::internal(format!("read error: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
|
||||||
|
info!("Successfully extracted CBR page {} ({} bytes)", page_number, data.len());
|
||||||
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_pdf_page(abs_path: &str, page_number: u32, width: u32) -> Result<Vec<u8>, ApiError> {
|
fn render_pdf_page(abs_path: &str, page_number: u32, width: u32) -> Result<Vec<u8>, ApiError> {
|
||||||
let tmp_dir = std::env::temp_dir().join(format!("stripstream-pdf-{}", Uuid::new_v4()));
|
let tmp_dir = std::env::temp_dir().join(format!("stripstream-pdf-{}", Uuid::new_v4()));
|
||||||
std::fs::create_dir_all(&tmp_dir).map_err(|e| ApiError::internal(format!("cannot create temp dir: {e}")))?;
|
debug!("Creating temp dir for PDF rendering: {}", tmp_dir.display());
|
||||||
|
std::fs::create_dir_all(&tmp_dir).map_err(|e| {
|
||||||
|
error!("Cannot create temp dir {}: {}", tmp_dir.display(), e);
|
||||||
|
ApiError::internal(format!("cannot create temp dir: {e}"))
|
||||||
|
})?;
|
||||||
let output_prefix = tmp_dir.join("page");
|
let output_prefix = tmp_dir.join("page");
|
||||||
|
|
||||||
let mut cmd = std::process::Command::new("pdftoppm");
|
let mut cmd = std::process::Command::new("pdftoppm");
|
||||||
@@ -256,28 +470,58 @@ fn render_pdf_page(abs_path: &str, page_number: u32, width: u32) -> Result<Vec<u
|
|||||||
}
|
}
|
||||||
cmd.arg(abs_path).arg(&output_prefix);
|
cmd.arg(abs_path).arg(&output_prefix);
|
||||||
|
|
||||||
|
debug!("Running pdftoppm for page {} of {} (width: {})", page_number, abs_path, width);
|
||||||
let output = cmd
|
let output = cmd
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| ApiError::internal(format!("pdf render failed: {e}")))?;
|
.map_err(|e| {
|
||||||
|
error!("pdftoppm command failed for {} page {}: {}", abs_path, page_number, e);
|
||||||
|
ApiError::internal(format!("pdf render failed: {e}"))
|
||||||
|
})?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
error!("pdftoppm failed for {} page {}: {}", abs_path, page_number, stderr);
|
||||||
return Err(ApiError::internal("pdf render command failed"));
|
return Err(ApiError::internal("pdf render command failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let image_path = output_prefix.with_extension("png");
|
let image_path = output_prefix.with_extension("png");
|
||||||
let bytes = std::fs::read(&image_path).map_err(|e| ApiError::internal(format!("render output missing: {e}")))?;
|
debug!("Reading rendered PDF page from: {}", image_path.display());
|
||||||
|
let bytes = std::fs::read(&image_path).map_err(|e| {
|
||||||
|
error!("Failed to read rendered PDF output {}: {}", image_path.display(), e);
|
||||||
|
ApiError::internal(format!("render output missing: {e}"))
|
||||||
|
})?;
|
||||||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
debug!("Successfully rendered PDF page {} to {} bytes", page_number, bytes.len());
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transcode_image(input: &[u8], out_format: &OutputFormat, quality: u8, width: u32) -> Result<Vec<u8>, ApiError> {
|
fn transcode_image(input: &[u8], out_format: &OutputFormat, quality: u8, width: u32) -> Result<Vec<u8>, ApiError> {
|
||||||
let mut image = image::load_from_memory(input).map_err(|e| ApiError::internal(format!("invalid source image: {e}")))?;
|
debug!("Transcoding image: {} bytes, format: {:?}, quality: {}, width: {}", input.len(), out_format, quality, width);
|
||||||
|
let source_format = image::guess_format(input).ok();
|
||||||
|
debug!("Source format detected: {:?}", source_format);
|
||||||
|
let needs_transcode = source_format.map(|f| !format_matches(&f, out_format)).unwrap_or(true);
|
||||||
|
|
||||||
|
if width == 0 && !needs_transcode {
|
||||||
|
debug!("No transcoding needed, returning original");
|
||||||
|
return Ok(input.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Loading image from memory...");
|
||||||
|
let mut image = image::load_from_memory(input).map_err(|e| {
|
||||||
|
error!("Failed to load image from memory: {} (input size: {} bytes)", e, input.len());
|
||||||
|
ApiError::internal(format!("invalid source image: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
if width > 0 {
|
if width > 0 {
|
||||||
|
debug!("Resizing image to width: {}", width);
|
||||||
image = image.resize(width, u32::MAX, image::imageops::FilterType::Lanczos3);
|
image = image.resize(width, u32::MAX, image::imageops::FilterType::Lanczos3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug!("Converting to RGBA...");
|
||||||
let rgba = image.to_rgba8();
|
let rgba = image.to_rgba8();
|
||||||
let (w, h) = rgba.dimensions();
|
let (w, h) = rgba.dimensions();
|
||||||
|
debug!("Image dimensions: {}x{}", w, h);
|
||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
match out_format {
|
match out_format {
|
||||||
OutputFormat::Jpeg => {
|
OutputFormat::Jpeg => {
|
||||||
@@ -293,21 +537,38 @@ fn transcode_image(input: &[u8], out_format: &OutputFormat, quality: u8, width:
|
|||||||
.map_err(|e| ApiError::internal(format!("png encode failed: {e}")))?;
|
.map_err(|e| ApiError::internal(format!("png encode failed: {e}")))?;
|
||||||
}
|
}
|
||||||
OutputFormat::Webp => {
|
OutputFormat::Webp => {
|
||||||
let encoder = WebPEncoder::new_lossless(&mut out);
|
let rgb_data: Vec<u8> = rgba
|
||||||
encoder
|
.pixels()
|
||||||
.write_image(&rgba, w, h, ColorType::Rgba8.into())
|
.flat_map(|p| [p[0], p[1], p[2]])
|
||||||
.map_err(|e| ApiError::internal(format!("webp encode failed: {e}")))?;
|
.collect();
|
||||||
|
let webp_data = webp::Encoder::new(&rgb_data, webp::PixelLayout::Rgb, w, h)
|
||||||
|
.encode(f32::max(quality as f32, 85.0));
|
||||||
|
out.extend_from_slice(&webp_data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_matches(source: &ImageFormat, target: &OutputFormat) -> bool {
|
||||||
|
match (source, target) {
|
||||||
|
(ImageFormat::Jpeg, OutputFormat::Jpeg) => true,
|
||||||
|
(ImageFormat::Png, OutputFormat::Png) => true,
|
||||||
|
(ImageFormat::WebP, OutputFormat::Webp) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_image_name(name: &str) -> bool {
|
fn is_image_name(name: &str) -> bool {
|
||||||
name.ends_with(".jpg")
|
let lower = name.to_lowercase();
|
||||||
|| name.ends_with(".jpeg")
|
lower.ends_with(".jpg")
|
||||||
|| name.ends_with(".png")
|
|| lower.ends_with(".jpeg")
|
||||||
|| name.ends_with(".webp")
|
|| lower.ends_with(".png")
|
||||||
|| name.ends_with(".avif")
|
|| lower.ends_with(".webp")
|
||||||
|
|| lower.ends_with(".avif")
|
||||||
|
|| lower.ends_with(".gif")
|
||||||
|
|| lower.ends_with(".tif")
|
||||||
|
|| lower.ends_with(".tiff")
|
||||||
|
|| lower.ends_with(".bmp")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
250
apps/api/src/settings.rs
Normal file
250
apps/api/src/settings.rs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::{error::ApiError, AppState};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateSettingRequest {
|
||||||
|
pub value: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ClearCacheResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CacheStats {
|
||||||
|
pub total_size_mb: f64,
|
||||||
|
pub file_count: u64,
|
||||||
|
pub directory: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ThumbnailStats {
|
||||||
|
pub total_size_mb: f64,
|
||||||
|
pub file_count: u64,
|
||||||
|
pub directory: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn settings_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/settings", get(get_settings))
|
||||||
|
.route("/settings/:key", get(get_setting).post(update_setting))
|
||||||
|
.route("/settings/cache/clear", post(clear_cache))
|
||||||
|
.route("/settings/cache/stats", get(get_cache_stats))
|
||||||
|
.route("/settings/thumbnail/stats", get(get_thumbnail_stats))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
||||||
|
let rows = sqlx::query(r#"SELECT key, value FROM app_settings"#)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut settings = serde_json::Map::new();
|
||||||
|
for row in rows {
|
||||||
|
let key: String = row.get("key");
|
||||||
|
let value: Value = row.get("value");
|
||||||
|
settings.insert(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(Value::Object(settings)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_setting(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
axum::extract::Path(key): axum::extract::Path<String>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let row = sqlx::query(r#"SELECT value FROM app_settings WHERE key = $1"#)
|
||||||
|
.bind(&key)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(row) => {
|
||||||
|
let value: Value = row.get("value");
|
||||||
|
Ok(Json(value))
|
||||||
|
}
|
||||||
|
None => Err(ApiError::not_found(format!("setting '{}' not found", key))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_setting(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
axum::extract::Path(key): axum::extract::Path<String>,
|
||||||
|
Json(body): Json<UpdateSettingRequest>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO app_settings (key, value, updated_at)
|
||||||
|
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (key)
|
||||||
|
DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING value
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&key)
|
||||||
|
.bind(&body.value)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let value: Value = row.get("value");
|
||||||
|
Ok(Json(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_cache(State(_state): State<AppState>) -> Result<Json<ClearCacheResponse>, ApiError> {
|
||||||
|
let cache_dir = std::env::var("IMAGE_CACHE_DIR")
|
||||||
|
.unwrap_or_else(|_| "/tmp/stripstream-image-cache".to_string());
|
||||||
|
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
if std::path::Path::new(&cache_dir).exists() {
|
||||||
|
match std::fs::remove_dir_all(&cache_dir) {
|
||||||
|
Ok(_) => ClearCacheResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("Cache directory '{}' cleared successfully", cache_dir),
|
||||||
|
},
|
||||||
|
Err(e) => ClearCacheResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("Failed to clear cache: {}", e),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ClearCacheResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("Cache directory '{}' does not exist, nothing to clear", cache_dir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("cache clear failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_cache_stats(State(_state): State<AppState>) -> Result<Json<CacheStats>, ApiError> {
|
||||||
|
let cache_dir = std::env::var("IMAGE_CACHE_DIR")
|
||||||
|
.unwrap_or_else(|_| "/tmp/stripstream-image-cache".to_string());
|
||||||
|
|
||||||
|
let cache_dir_clone = cache_dir.clone();
|
||||||
|
let stats = tokio::task::spawn_blocking(move || {
|
||||||
|
let path = std::path::Path::new(&cache_dir_clone);
|
||||||
|
if !path.exists() {
|
||||||
|
return CacheStats {
|
||||||
|
total_size_mb: 0.0,
|
||||||
|
file_count: 0,
|
||||||
|
directory: cache_dir_clone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut total_size: u64 = 0;
|
||||||
|
let mut file_count: u64 = 0;
|
||||||
|
|
||||||
|
fn visit_dirs(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
total_size: &mut u64,
|
||||||
|
file_count: &mut u64,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
if dir.is_dir() {
|
||||||
|
for entry in std::fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
visit_dirs(&path, total_size, file_count)?;
|
||||||
|
} else {
|
||||||
|
*total_size += entry.metadata()?.len();
|
||||||
|
*file_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = visit_dirs(path, &mut total_size, &mut file_count);
|
||||||
|
|
||||||
|
CacheStats {
|
||||||
|
total_size_mb: total_size as f64 / 1024.0 / 1024.0,
|
||||||
|
file_count,
|
||||||
|
directory: cache_dir_clone,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("cache stats failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(stats))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_dir_stats(path: &std::path::Path) -> (u64, u64) {
|
||||||
|
let mut total_size: u64 = 0;
|
||||||
|
let mut file_count: u64 = 0;
|
||||||
|
|
||||||
|
fn visit_dirs(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
total_size: &mut u64,
|
||||||
|
file_count: &mut u64,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
if dir.is_dir() {
|
||||||
|
for entry in std::fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
visit_dirs(&path, total_size, file_count)?;
|
||||||
|
} else {
|
||||||
|
*total_size += entry.metadata()?.len();
|
||||||
|
*file_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = visit_dirs(path, &mut total_size, &mut file_count);
|
||||||
|
(total_size, file_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<ThumbnailStats>, ApiError> {
|
||||||
|
let settings = sqlx::query(r#"SELECT value FROM app_settings WHERE key = 'thumbnail'"#)
|
||||||
|
.fetch_optional(&_state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let directory = match settings {
|
||||||
|
Some(row) => {
|
||||||
|
let value: serde_json::Value = row.get("value");
|
||||||
|
value.get("directory")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("/data/thumbnails")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
None => "/data/thumbnails".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let directory_clone = directory.clone();
|
||||||
|
let stats = tokio::task::spawn_blocking(move || {
|
||||||
|
let path = std::path::Path::new(&directory_clone);
|
||||||
|
if !path.exists() {
|
||||||
|
return ThumbnailStats {
|
||||||
|
total_size_mb: 0.0,
|
||||||
|
file_count: 0,
|
||||||
|
directory: directory_clone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let (total_size, file_count) = compute_dir_stats(path);
|
||||||
|
|
||||||
|
ThumbnailStats {
|
||||||
|
total_size_mb: total_size as f64 / 1024.0 / 1024.0,
|
||||||
|
file_count,
|
||||||
|
directory: directory_clone,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("thumbnail stats failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(stats))
|
||||||
|
}
|
||||||
284
apps/api/src/thumbnails.rs
Normal file
284
apps/api/src/thumbnails.rs
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path as AxumPath, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use image::GenericImageView;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::Row;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{error::ApiError, index_jobs, pages, AppState};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ThumbnailConfig {
|
||||||
|
enabled: bool,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
quality: u8,
|
||||||
|
directory: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_thumbnail_config(pool: &sqlx::PgPool) -> ThumbnailConfig {
|
||||||
|
let fallback = ThumbnailConfig {
|
||||||
|
enabled: true,
|
||||||
|
width: 300,
|
||||||
|
height: 400,
|
||||||
|
quality: 80,
|
||||||
|
directory: "/data/thumbnails".to_string(),
|
||||||
|
};
|
||||||
|
let row = sqlx::query(r#"SELECT value FROM app_settings WHERE key = 'thumbnail'"#)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Ok(Some(row)) => {
|
||||||
|
let value: serde_json::Value = row.get("value");
|
||||||
|
ThumbnailConfig {
|
||||||
|
enabled: value
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(fallback.enabled),
|
||||||
|
width: value
|
||||||
|
.get("width")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.map(|v| v as u32)
|
||||||
|
.unwrap_or(fallback.width),
|
||||||
|
height: value
|
||||||
|
.get("height")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.map(|v| v as u32)
|
||||||
|
.unwrap_or(fallback.height),
|
||||||
|
quality: value
|
||||||
|
.get("quality")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.map(|v| v as u8)
|
||||||
|
.unwrap_or(fallback.quality),
|
||||||
|
directory: value
|
||||||
|
.get("directory")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| fallback.directory.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => fallback,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_thumbnail(image_bytes: &[u8], config: &ThumbnailConfig) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let img = image::load_from_memory(image_bytes).context("failed to load image")?;
|
||||||
|
let (orig_w, orig_h) = img.dimensions();
|
||||||
|
let ratio_w = config.width as f32 / orig_w as f32;
|
||||||
|
let ratio_h = config.height as f32 / orig_h as f32;
|
||||||
|
let ratio = ratio_w.min(ratio_h);
|
||||||
|
let new_w = (orig_w as f32 * ratio) as u32;
|
||||||
|
let new_h = (orig_h as f32 * ratio) as u32;
|
||||||
|
let resized = img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3);
|
||||||
|
let rgba = resized.to_rgba8();
|
||||||
|
let (w, h) = rgba.dimensions();
|
||||||
|
let rgb_data: Vec<u8> = rgba.pixels().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||||
|
let quality = f32::max(config.quality as f32, 85.0);
|
||||||
|
let webp_data =
|
||||||
|
webp::Encoder::new(&rgb_data, webp::PixelLayout::Rgb, w, h).encode(quality);
|
||||||
|
Ok(webp_data.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_thumbnail(book_id: Uuid, thumbnail_bytes: &[u8], config: &ThumbnailConfig) -> anyhow::Result<String> {
|
||||||
|
let dir = Path::new(&config.directory);
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
let filename = format!("{}.webp", book_id);
|
||||||
|
let path = dir.join(&filename);
|
||||||
|
std::fs::write(&path, thumbnail_bytes)?;
|
||||||
|
Ok(path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_checkup(state: AppState, job_id: Uuid) {
|
||||||
|
let pool = &state.pool;
|
||||||
|
let row = sqlx::query("SELECT library_id, type FROM index_jobs WHERE id = $1")
|
||||||
|
.bind(job_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (library_id, job_type) = match row {
|
||||||
|
Ok(Some(r)) => (
|
||||||
|
r.get::<Option<Uuid>, _>("library_id"),
|
||||||
|
r.get::<String, _>("type"),
|
||||||
|
),
|
||||||
|
_ => {
|
||||||
|
warn!("thumbnails checkup: job {} not found", job_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Regenerate: clear existing thumbnails in scope so they get regenerated
|
||||||
|
if job_type == "thumbnail_regenerate" {
|
||||||
|
let cleared = sqlx::query(
|
||||||
|
r#"UPDATE books SET thumbnail_path = NULL WHERE (library_id = $1 OR $1 IS NULL)"#,
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
if let Ok(res) = cleared {
|
||||||
|
info!("thumbnails regenerate: cleared {} books", res.rows_affected());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let book_ids: Vec<Uuid> = sqlx::query_scalar(
|
||||||
|
r#"SELECT id FROM books WHERE (library_id = $1 OR $1 IS NULL) AND thumbnail_path IS NULL"#,
|
||||||
|
)
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let config = load_thumbnail_config(pool).await;
|
||||||
|
if !config.enabled || book_ids.is_empty() {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, current_file = NULL WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = book_ids.len() as i32;
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE index_jobs SET status = 'generating_thumbnails', total_files = $2, processed_files = 0, current_file = NULL WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.bind(total)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
for (i, &book_id) in book_ids.iter().enumerate() {
|
||||||
|
match pages::render_book_page_1(&state, book_id, config.width, config.quality).await {
|
||||||
|
Ok(page_bytes) => {
|
||||||
|
match generate_thumbnail(&page_bytes, &config) {
|
||||||
|
Ok(thumb_bytes) => {
|
||||||
|
if let Ok(path) = save_thumbnail(book_id, &thumb_bytes, &config) {
|
||||||
|
if sqlx::query("UPDATE books SET thumbnail_path = $1 WHERE id = $2")
|
||||||
|
.bind(&path)
|
||||||
|
.bind(book_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let processed = (i + 1) as i32;
|
||||||
|
let percent = ((i + 1) as f64 / total as f64 * 100.0) as i32;
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE index_jobs SET processed_files = $2, progress_percent = $3 WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.bind(processed)
|
||||||
|
.bind(percent)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!("thumbnail generate failed for book {}: {:?}", book_id, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!("render page 1 failed for book {}: {:?}", book_id, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, current_file = NULL WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
info!("thumbnails checkup finished for job {} ({} books)", job_id, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct ThumbnailsRebuildRequest {
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub library_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /index/thumbnails/rebuild — create a job and generate thumbnails for books that don't have one (optional library scope).
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/index/thumbnails/rebuild",
|
||||||
|
tag = "indexing",
|
||||||
|
request_body = Option<ThumbnailsRebuildRequest>,
|
||||||
|
responses(
|
||||||
|
(status = 200, body = index_jobs::IndexJobResponse),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin scope required"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn start_thumbnails_rebuild(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
payload: Option<Json<ThumbnailsRebuildRequest>>,
|
||||||
|
) -> Result<Json<index_jobs::IndexJobResponse>, ApiError> {
|
||||||
|
let library_id = payload.as_ref().and_then(|p| p.0.library_id);
|
||||||
|
let job_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"INSERT INTO index_jobs (id, library_id, type, status)
|
||||||
|
VALUES ($1, $2, 'thumbnail_rebuild', 'pending')
|
||||||
|
RETURNING id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at"#,
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(index_jobs::map_row(row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /index/thumbnails/regenerate — create a job and regenerate all thumbnails in scope (clears then regenerates).
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/index/thumbnails/regenerate",
|
||||||
|
tag = "indexing",
|
||||||
|
request_body = Option<ThumbnailsRebuildRequest>,
|
||||||
|
responses(
|
||||||
|
(status = 200, body = index_jobs::IndexJobResponse),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin scope required"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn start_thumbnails_regenerate(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
payload: Option<Json<ThumbnailsRebuildRequest>>,
|
||||||
|
) -> Result<Json<index_jobs::IndexJobResponse>, ApiError> {
|
||||||
|
let library_id = payload.as_ref().and_then(|p| p.0.library_id);
|
||||||
|
let job_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"INSERT INTO index_jobs (id, library_id, type, status)
|
||||||
|
VALUES ($1, $2, 'thumbnail_regenerate', 'pending')
|
||||||
|
RETURNING id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at"#,
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.bind(library_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(index_jobs::map_row(row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /index/jobs/:id/thumbnails/checkup — start thumbnail generation for books missing thumbnails (called by indexer at end of build).
|
||||||
|
pub async fn start_checkup(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(job_id): AxumPath<Uuid>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let state = state.clone();
|
||||||
|
tokio::spawn(async move { run_checkup(state, job_id).await });
|
||||||
|
Ok(StatusCode::ACCEPTED)
|
||||||
|
}
|
||||||
43
apps/backoffice/app/api/books/[bookId]/thumbnail/route.ts
Normal file
43
apps/backoffice/app/api/books/[bookId]/thumbnail/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ bookId: string }> }
|
||||||
|
) {
|
||||||
|
const { bookId } = await params;
|
||||||
|
|
||||||
|
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||||
|
const apiUrl = `${apiBaseUrl}/books/${bookId}/thumbnail`;
|
||||||
|
|
||||||
|
const token = process.env.API_BOOTSTRAP_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
return new NextResponse("API token not configured", { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
|
||||||
|
status: response.status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") || "image/webp";
|
||||||
|
const imageBuffer = await response.arrayBuffer();
|
||||||
|
|
||||||
|
return new NextResponse(imageBuffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching thumbnail:", error);
|
||||||
|
return new NextResponse("Failed to fetch thumbnail", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/backoffice/app/api/folders/route.ts
Normal file
39
apps/backoffice/app/api/folders/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||||
|
const apiToken = process.env.API_BOOTSTRAP_TOKEN;
|
||||||
|
|
||||||
|
if (!apiToken) {
|
||||||
|
return NextResponse.json({ error: "API token not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const path = searchParams.get("path");
|
||||||
|
|
||||||
|
let apiUrl = `${apiBaseUrl}/folders`;
|
||||||
|
if (path) {
|
||||||
|
apiUrl += `?path=${encodeURIComponent(path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `API error: ${response.status}` },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Proxy error:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch folders" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,27 +33,39 @@ export async function GET(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok && isActive) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const dataStr = JSON.stringify(data);
|
const dataStr = JSON.stringify(data);
|
||||||
|
|
||||||
// Only send if data changed
|
// Only send if data changed
|
||||||
if (dataStr !== lastData) {
|
if (dataStr !== lastData && isActive) {
|
||||||
lastData = dataStr;
|
lastData = dataStr;
|
||||||
|
try {
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
||||||
);
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Controller closed, ignore
|
||||||
|
isActive = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Stop polling if job is complete
|
// Stop polling if job is complete
|
||||||
if (data.status === "success" || data.status === "failed" || data.status === "cancelled") {
|
if (data.status === "success" || data.status === "failed" || data.status === "cancelled") {
|
||||||
isActive = false;
|
isActive = false;
|
||||||
|
try {
|
||||||
controller.close();
|
controller.close();
|
||||||
|
} catch (err) {
|
||||||
|
// Already closed, ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isActive) {
|
||||||
console.error("SSE fetch error:", error);
|
console.error("SSE fetch error:", error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
|
|||||||
@@ -28,21 +28,28 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok && isActive) {
|
||||||
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 if data changed
|
||||||
if (dataStr !== lastData) {
|
if (dataStr !== lastData && isActive) {
|
||||||
lastData = dataStr;
|
lastData = dataStr;
|
||||||
|
try {
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
new TextEncoder().encode(`data: ${dataStr}\n\n`)
|
||||||
);
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Controller closed, ignore
|
||||||
|
isActive = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isActive) {
|
||||||
console.error("SSE fetch error:", error);
|
console.error("SSE fetch error:", error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
|
|||||||
59
apps/backoffice/app/api/settings/[key]/route.ts
Normal file
59
apps/backoffice/app/api/settings/[key]/route.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ key: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { key } = await params;
|
||||||
|
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||||
|
const token = process.env.API_BOOTSTRAP_TOKEN;
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/settings/${key}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: "Failed to fetch setting" }, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ key: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { key } = await params;
|
||||||
|
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||||
|
const token = process.env.API_BOOTSTRAP_TOKEN;
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/settings/${key}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: "Failed to update setting" }, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/backoffice/app/api/settings/cache/clear/route.ts
vendored
Normal file
25
apps/backoffice/app/api/settings/cache/clear/route.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||||
|
const token = process.env.API_BOOTSTRAP_TOKEN;
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/settings/cache/clear`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: "Failed to clear cache" }, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/backoffice/app/api/settings/cache/stats/route.ts
vendored
Normal file
24
apps/backoffice/app/api/settings/cache/stats/route.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||||
|
const token = process.env.API_BOOTSTRAP_TOKEN;
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/settings/cache/stats`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: "Failed to fetch cache stats" }, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/backoffice/app/api/settings/route.ts
Normal file
24
apps/backoffice/app/api/settings/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const baseUrl = process.env.API_BASE_URL || "http://api:8080";
|
||||||
|
const token = process.env.API_BOOTSTRAP_TOKEN;
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/settings`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: "Failed to fetch settings" }, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,14 +33,14 @@ export default async function BookDetailPage({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link href="/books" className="inline-flex items-center text-sm text-muted hover:text-primary transition-colors">
|
<Link href="/books" className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||||
← Back to books
|
← Back to books
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="bg-card rounded-xl shadow-card border border-line p-4 inline-block">
|
<div className="bg-card rounded-xl shadow-card border border-border p-4 inline-block">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(book.id)}
|
src={getBookCoverUrl(book.id)}
|
||||||
alt={`Cover of ${book.title}`}
|
alt={`Cover of ${book.title}`}
|
||||||
@@ -54,76 +54,76 @@ export default async function BookDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="bg-card rounded-xl shadow-soft border border-line p-6">
|
<div className="bg-card rounded-xl shadow-sm border border-border p-6">
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-2">{book.title}</h1>
|
<h1 className="text-3xl font-bold text-foreground mb-2">{book.title}</h1>
|
||||||
|
|
||||||
{book.author && (
|
{book.author && (
|
||||||
<p className="text-lg text-muted mb-4">by {book.author}</p>
|
<p className="text-lg text-muted-foreground mb-4">by {book.author}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.series && (
|
{book.series && (
|
||||||
<p className="text-sm text-muted mb-6">
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
{book.series}
|
{book.series}
|
||||||
{book.volume && <span className="ml-2 px-2 py-1 bg-primary-soft text-primary rounded text-xs">Volume {book.volume}</span>}
|
{book.volume && <span className="ml-2 px-2 py-1 bg-primary/10 text-primary rounded text-xs">Volume {book.volume}</span>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Format:</span>
|
<span className="text-sm text-muted-foreground">Format:</span>
|
||||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||||
book.kind === 'epub' ? 'bg-primary-soft text-primary' : 'bg-muted/20 text-muted'
|
book.kind === 'epub' ? 'bg-primary/10 text-primary' : 'bg-muted/50 text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{book.kind.toUpperCase()}
|
{book.kind.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{book.volume && (
|
{book.volume && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Volume:</span>
|
<span className="text-sm text-muted-foreground">Volume:</span>
|
||||||
<span className="text-sm text-foreground">{book.volume}</span>
|
<span className="text-sm text-foreground">{book.volume}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.language && (
|
{book.language && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Language:</span>
|
<span className="text-sm text-muted-foreground">Language:</span>
|
||||||
<span className="text-sm text-foreground">{book.language.toUpperCase()}</span>
|
<span className="text-sm text-foreground">{book.language.toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.page_count && (
|
{book.page_count && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Pages:</span>
|
<span className="text-sm text-muted-foreground">Pages:</span>
|
||||||
<span className="text-sm text-foreground">{book.page_count}</span>
|
<span className="text-sm text-foreground">{book.page_count}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Library:</span>
|
<span className="text-sm text-muted-foreground">Library:</span>
|
||||||
<span className="text-sm text-foreground">{library?.name || book.library_id}</span>
|
<span className="text-sm text-foreground">{library?.name || book.library_id}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{book.series && (
|
{book.series && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Series:</span>
|
<span className="text-sm text-muted-foreground">Series:</span>
|
||||||
<span className="text-sm text-foreground">{book.series}</span>
|
<span className="text-sm text-foreground">{book.series}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.file_format && (
|
{book.file_format && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">File Format:</span>
|
<span className="text-sm text-muted-foreground">File Format:</span>
|
||||||
<span className="text-sm text-foreground">{book.file_format.toUpperCase()}</span>
|
<span className="text-sm text-foreground">{book.file_format.toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.file_parse_status && (
|
{book.file_parse_status && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted">Parse Status:</span>
|
<span className="text-sm text-muted-foreground">Parse Status:</span>
|
||||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||||
book.file_parse_status === 'success' ? 'bg-success-soft text-success' :
|
book.file_parse_status === 'success' ? 'bg-success/10 text-success' :
|
||||||
book.file_parse_status === 'failed' ? 'bg-error-soft text-error' : 'bg-muted/20 text-muted'
|
book.file_parse_status === 'failed' ? 'bg-destructive/10 text-error' : 'bg-muted/50 text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{book.file_parse_status}
|
{book.file_parse_status}
|
||||||
</span>
|
</span>
|
||||||
@@ -131,25 +131,25 @@ export default async function BookDetailPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{book.file_path && (
|
{book.file_path && (
|
||||||
<div className="flex flex-col py-2 border-b border-line">
|
<div className="flex flex-col py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted mb-1">File Path:</span>
|
<span className="text-sm text-muted-foreground mb-1">File Path:</span>
|
||||||
<code className="text-xs font-mono text-foreground break-all">{book.file_path}</code>
|
<code className="text-xs font-mono text-foreground break-all">{book.file_path}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col py-2 border-b border-line">
|
<div className="flex flex-col py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted mb-1">Book ID:</span>
|
<span className="text-sm text-muted-foreground mb-1">Book ID:</span>
|
||||||
<code className="text-xs font-mono text-foreground break-all">{book.id}</code>
|
<code className="text-xs font-mono text-foreground break-all">{book.id}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col py-2 border-b border-line">
|
<div className="flex flex-col py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted mb-1">Library ID:</span>
|
<span className="text-sm text-muted-foreground mb-1">Library ID:</span>
|
||||||
<code className="text-xs font-mono text-foreground break-all">{book.library_id}</code>
|
<code className="text-xs font-mono text-foreground break-all">{book.library_id}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{book.updated_at && (
|
{book.updated_at && (
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<span className="text-sm text-muted">Updated:</span>
|
<span className="text-sm text-muted-foreground">Updated:</span>
|
||||||
<span className="text-sm text-foreground">{new Date(book.updated_at).toLocaleString()}</span>
|
<span className="text-sm text-foreground">{new Date(book.updated_at).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api";
|
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api";
|
||||||
import { BooksGrid, EmptyState } from "../components/BookCard";
|
import { BooksGrid, EmptyState } from "../components/BookCard";
|
||||||
import { Card, Button, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
|
import { Card, CardContent, Button, FormField, FormInput, FormSelect, FormRow, CursorPagination } from "../components/ui";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -13,6 +13,8 @@ export default async function BooksPage({
|
|||||||
const searchParamsAwaited = await searchParams;
|
const searchParamsAwaited = await searchParams;
|
||||||
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
||||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||||
|
const cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined;
|
||||||
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||||
|
|
||||||
const [libraries] = await Promise.all([
|
const [libraries] = await Promise.all([
|
||||||
fetchLibraries().catch(() => [] as LibraryDto[])
|
fetchLibraries().catch(() => [] as LibraryDto[])
|
||||||
@@ -24,8 +26,7 @@ export default async function BooksPage({
|
|||||||
let totalHits: number | null = null;
|
let totalHits: number | null = null;
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
// Mode recherche
|
const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null);
|
||||||
const searchResponse = await searchBooks(searchQuery, libraryId).catch(() => null);
|
|
||||||
if (searchResponse) {
|
if (searchResponse) {
|
||||||
searchResults = searchResponse.hits.map(hit => ({
|
searchResults = searchResponse.hits.map(hit => ({
|
||||||
id: hit.id,
|
id: hit.id,
|
||||||
@@ -45,8 +46,11 @@ export default async function BooksPage({
|
|||||||
totalHits = searchResponse.estimated_total_hits;
|
totalHits = searchResponse.estimated_total_hits;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Mode liste
|
const booksPage = await fetchBooks(libraryId, undefined, cursor, limit).catch(() => ({
|
||||||
const booksPage = await fetchBooks(libraryId).catch(() => ({ items: [] as BookDto[], next_cursor: null }));
|
items: [] as BookDto[],
|
||||||
|
next_cursor: null,
|
||||||
|
prev_cursor: null
|
||||||
|
}));
|
||||||
books = booksPage.items;
|
books = booksPage.items;
|
||||||
nextCursor = booksPage.next_cursor;
|
nextCursor = booksPage.next_cursor;
|
||||||
}
|
}
|
||||||
@@ -56,25 +60,35 @@ export default async function BooksPage({
|
|||||||
coverUrl: getBookCoverUrl(book.id)
|
coverUrl: getBookCoverUrl(book.id)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const hasNextPage = !!nextCursor;
|
||||||
|
const hasPrevPage = !!cursor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
|
<div className="mb-6">
|
||||||
<svg className="w-8 h-8 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
|
<svg className="w-8 h-8 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
Books
|
Books
|
||||||
</h1>
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filtres et recherche */}
|
{/* Search Bar - Style compact et propre */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<form>
|
<CardContent className="pt-6">
|
||||||
<FormRow>
|
<form className="flex flex-col sm:flex-row gap-3 items-start sm:items-end">
|
||||||
<FormField>
|
<FormField className="flex-1 w-full">
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">Search</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
name="q"
|
name="q"
|
||||||
placeholder="Search books..."
|
placeholder="Search by title, author, series..."
|
||||||
defaultValue={searchQuery}
|
defaultValue={searchQuery}
|
||||||
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField>
|
<FormField className="w-full sm:w-48">
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">Library</label>
|
||||||
<FormSelect name="library" defaultValue={libraryId || ""}>
|
<FormSelect name="library" defaultValue={libraryId || ""}>
|
||||||
<option value="">All libraries</option>
|
<option value="">All libraries</option>
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => (
|
||||||
@@ -84,22 +98,40 @@ export default async function BooksPage({
|
|||||||
))}
|
))}
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
</FormField>
|
</FormField>
|
||||||
<Button type="submit">🔍 Search</Button>
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
|
<Button type="submit" className="flex-1 sm:flex-none">
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<Link
|
<Link
|
||||||
href="/books"
|
href="/books"
|
||||||
className="px-4 py-2.5 border border-line text-muted font-medium rounded-lg hover:bg-muted/5 transition-colors"
|
className="
|
||||||
|
inline-flex items-center justify-center
|
||||||
|
h-10 px-4
|
||||||
|
border border-input
|
||||||
|
text-sm font-medium
|
||||||
|
text-muted-foreground
|
||||||
|
bg-background
|
||||||
|
rounded-md
|
||||||
|
hover:bg-accent hover:text-accent-foreground
|
||||||
|
transition-colors duration-200
|
||||||
|
flex-1 sm:flex-none
|
||||||
|
"
|
||||||
>
|
>
|
||||||
✕ Clear
|
Clear
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</FormRow>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Résultats de recherche */}
|
{/* Résultats */}
|
||||||
{searchQuery && totalHits !== null && (
|
{searchQuery && totalHits !== null && (
|
||||||
<p className="text-sm text-muted mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}"
|
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}"
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -109,20 +141,14 @@ export default async function BooksPage({
|
|||||||
<>
|
<>
|
||||||
<BooksGrid books={displayBooks} />
|
<BooksGrid books={displayBooks} />
|
||||||
|
|
||||||
{/* Pagination */}
|
{!searchQuery && (
|
||||||
{!searchQuery && nextCursor && (
|
<CursorPagination
|
||||||
<div className="flex justify-center mt-8">
|
hasNextPage={hasNextPage}
|
||||||
<form>
|
hasPrevPage={hasPrevPage}
|
||||||
<input type="hidden" name="library" value={libraryId || ""} />
|
pageSize={limit}
|
||||||
<input type="hidden" name="cursor" value={nextCursor} />
|
currentCount={displayBooks.length}
|
||||||
<button
|
nextCursor={nextCursor}
|
||||||
type="submit"
|
/>
|
||||||
className="px-6 py-3 bg-primary text-white font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
|
||||||
>
|
|
||||||
📥 Load more
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -13,22 +13,20 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
|
|||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative aspect-[2/3] overflow-hidden bg-gradient-to-br from-line/50 to-line">
|
<div className="relative aspect-[2/3] overflow-hidden bg-muted">
|
||||||
{/* Skeleton */}
|
{/* Skeleton */}
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 bg-muted/10 animate-pulse transition-opacity duration-300 ${
|
className={`absolute inset-0 bg-muted/50 animate-pulse transition-opacity duration-300 ${
|
||||||
isLoaded ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
isLoaded ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-muted/20 to-transparent shimmer" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<Image
|
<Image
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
fill
|
fill
|
||||||
className={`object-cover group-hover:scale-105 transition-all duration-300 ${
|
className={`object-cover group-hover:scale-105 transition-transform duration-300 ${
|
||||||
isLoaded ? 'opacity-100' : 'opacity-0'
|
isLoaded ? 'opacity-100' : 'opacity-0'
|
||||||
}`}
|
}`}
|
||||||
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"
|
||||||
@@ -40,12 +38,12 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BookCard({ book }: BookCardProps) {
|
export function BookCard({ book }: BookCardProps) {
|
||||||
const coverUrl = book.coverUrl || `/api/books/${book.id}/pages/1?format=webp&width=200`;
|
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/books/${book.id}`}
|
href={`/books/${book.id}`}
|
||||||
className="group block bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all duration-200 overflow-hidden"
|
className="group block bg-card rounded-xl border border-border/60 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-200 overflow-hidden"
|
||||||
>
|
>
|
||||||
<BookImage
|
<BookImage
|
||||||
src={coverUrl}
|
src={coverUrl}
|
||||||
@@ -62,11 +60,11 @@ export function BookCard({ book }: BookCardProps) {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{book.author && (
|
{book.author && (
|
||||||
<p className="text-sm text-muted mb-1 truncate">{book.author}</p>
|
<p className="text-sm text-muted-foreground mb-1 truncate">{book.author}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{book.series && (
|
{book.series && (
|
||||||
<p className="text-xs text-muted/80 truncate mb-2">
|
<p className="text-xs text-muted-foreground/80 truncate mb-2">
|
||||||
{book.series}
|
{book.series}
|
||||||
{book.volume && <span className="text-primary font-medium"> #{book.volume}</span>}
|
{book.volume && <span className="text-primary font-medium"> #{book.volume}</span>}
|
||||||
</p>
|
</p>
|
||||||
@@ -76,14 +74,14 @@ export function BookCard({ book }: BookCardProps) {
|
|||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<span className={`
|
<span className={`
|
||||||
px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full
|
px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full
|
||||||
${book.kind === 'cbz' ? 'bg-success-soft text-success' : ''}
|
${book.kind === 'cbz' ? 'bg-success/10 text-success' : ''}
|
||||||
${book.kind === 'cbr' ? 'bg-warning-soft text-warning' : ''}
|
${book.kind === 'cbr' ? 'bg-warning/10 text-warning' : ''}
|
||||||
${book.kind === 'pdf' ? 'bg-error-soft text-error' : ''}
|
${book.kind === 'pdf' ? 'bg-destructive/10 text-destructive' : ''}
|
||||||
`}>
|
`}>
|
||||||
{book.kind}
|
{book.kind}
|
||||||
</span>
|
</span>
|
||||||
{book.language && (
|
{book.language && (
|
||||||
<span className="px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider rounded-full bg-primary-soft text-primary">
|
<span className="px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider rounded-full bg-primary/10 text-primary">
|
||||||
{book.language}
|
{book.language}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -114,12 +112,12 @@ interface EmptyStateProps {
|
|||||||
export function EmptyState({ message }: EmptyStateProps) {
|
export function EmptyState({ message }: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<div className="w-16 h-16 mb-4 text-muted/30">
|
<div className="w-16 h-16 mb-4 text-muted-foreground/30">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted text-lg">{message}</p>
|
<p className="text-muted-foreground text-lg">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
184
apps/backoffice/app/components/FolderBrowser.tsx
Normal file
184
apps/backoffice/app/components/FolderBrowser.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { FolderItem } from "../../lib/api";
|
||||||
|
|
||||||
|
interface TreeNode extends FolderItem {
|
||||||
|
children?: TreeNode[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderBrowserProps {
|
||||||
|
initialFolders: FolderItem[];
|
||||||
|
selectedPath: string;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderBrowser({ initialFolders, selectedPath, onSelect }: FolderBrowserProps) {
|
||||||
|
// Convert initial folders to tree structure
|
||||||
|
const [tree, setTree] = useState<TreeNode[]>(
|
||||||
|
initialFolders.map(f => ({ ...f, children: f.has_children ? [] : undefined }))
|
||||||
|
);
|
||||||
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const loadChildren = useCallback(async (parentPath: string): Promise<FolderItem[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/folders?path=${encodeURIComponent(parentPath)}`);
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load folders:", error);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const findAndUpdateNode = (
|
||||||
|
nodes: TreeNode[],
|
||||||
|
targetPath: string,
|
||||||
|
updateFn: (node: TreeNode) => TreeNode
|
||||||
|
): TreeNode[] => {
|
||||||
|
return nodes.map(node => {
|
||||||
|
if (node.path === targetPath) {
|
||||||
|
return updateFn(node);
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
return { ...node, children: findAndUpdateNode(node.children, targetPath, updateFn) };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = useCallback(async (node: TreeNode) => {
|
||||||
|
if (!node.has_children) {
|
||||||
|
onSelect(node.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpanded = expandedPaths.has(node.path);
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
// Collapse
|
||||||
|
setExpandedPaths(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(node.path);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Expand
|
||||||
|
setExpandedPaths(prev => new Set(prev).add(node.path));
|
||||||
|
|
||||||
|
// Load children if not already loaded
|
||||||
|
if (!node.children || node.children.length === 0) {
|
||||||
|
setTree(prev => findAndUpdateNode(prev, node.path, n => ({ ...n, isLoading: true })));
|
||||||
|
|
||||||
|
const children = await loadChildren(node.path);
|
||||||
|
const childNodes = children.map(f => ({ ...f, children: f.has_children ? [] : undefined }));
|
||||||
|
|
||||||
|
setTree(prev => findAndUpdateNode(prev, node.path, n => ({
|
||||||
|
...n,
|
||||||
|
children: childNodes,
|
||||||
|
isLoading: false
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [expandedPaths, loadChildren, onSelect]);
|
||||||
|
|
||||||
|
const renderNode = (node: TreeNode, level: number = 0) => {
|
||||||
|
const isExpanded = expandedPaths.has(node.path);
|
||||||
|
const isSelected = selectedPath === node.path;
|
||||||
|
const hasChildren = node.has_children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={node.path}>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer border-b border-border/10 last:border-b-0 ${
|
||||||
|
isSelected ? 'bg-primary/10 hover:bg-primary/15' : ''
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${12 + level * 20}px` }}
|
||||||
|
onClick={() => onSelect(node.path)}
|
||||||
|
>
|
||||||
|
{/* Expand/Collapse button */}
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleExpand(node);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center w-5 h-5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{node.isLoading ? (
|
||||||
|
<svg className="animate-spin w-3 h-3" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-5" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Folder icon */}
|
||||||
|
<svg
|
||||||
|
className={`w-5 h-5 flex-shrink-0 ${isSelected ? 'text-primary' : 'text-warning'}`}
|
||||||
|
fill={hasChildren ? "currentColor" : "none"}
|
||||||
|
fillOpacity={hasChildren ? 0.2 : undefined}
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Folder name */}
|
||||||
|
<span className={`flex-1 text-sm truncate ${isSelected ? 'font-medium text-primary' : 'text-foreground'}`}>
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Selected indicator */}
|
||||||
|
{isSelected && (
|
||||||
|
<svg className="w-4 h-4 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Render children if expanded */}
|
||||||
|
{isExpanded && node.children && node.children.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{node.children.map(child => renderNode(child, level + 1))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card overflow-hidden">
|
||||||
|
{/* Folder tree */}
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
{tree.length === 0 ? (
|
||||||
|
<div className="px-3 py-8 text-sm text-muted-foreground text-center">
|
||||||
|
No folders found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tree.map(node => renderNode(node))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
apps/backoffice/app/components/FolderPicker.tsx
Normal file
126
apps/backoffice/app/components/FolderPicker.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FolderBrowser } from "./FolderBrowser";
|
||||||
|
import { FolderItem } from "../../lib/api";
|
||||||
|
import { Button } from "./ui";
|
||||||
|
|
||||||
|
interface FolderPickerProps {
|
||||||
|
initialFolders: FolderItem[];
|
||||||
|
selectedPath: string;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderPickerProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleSelect = (path: string) => {
|
||||||
|
onSelect(path);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Input avec bouton browse */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={selectedPath || "Select a folder..."}
|
||||||
|
className={`
|
||||||
|
w-full px-3 py-2 rounded-lg border bg-card
|
||||||
|
text-sm font-mono
|
||||||
|
${selectedPath ? 'text-foreground' : 'text-muted-foreground italic'}
|
||||||
|
border-border/50 focus:border-primary/50 focus:ring-2 focus:ring-primary/20
|
||||||
|
transition-all duration-200
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
{selectedPath && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect("")}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Popup Modal */}
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">Select Folder</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors p-1 hover:bg-accent rounded"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Folder Browser */}
|
||||||
|
<div className="p-0">
|
||||||
|
<FolderBrowser
|
||||||
|
initialFolders={initialFolders}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-muted/30">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Click a folder to select it
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-error-soft text-error rounded-lg text-sm">
|
<div className="p-4 bg-destructive/10 text-error rounded-lg text-sm">
|
||||||
Error: {error}
|
Error: {error}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -78,7 +78,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
|
|
||||||
if (!progress) {
|
if (!progress) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-muted text-sm">
|
<div className="p-4 text-muted-foreground text-sm">
|
||||||
Loading progress...
|
Loading progress...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -87,9 +87,11 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
const percent = progress.progress_percent ?? 0;
|
const percent = progress.progress_percent ?? 0;
|
||||||
const processed = progress.processed_files ?? 0;
|
const processed = progress.processed_files ?? 0;
|
||||||
const total = progress.total_files ?? 0;
|
const total = progress.total_files ?? 0;
|
||||||
|
const isThumbnailsPhase = progress.status === "generating_thumbnails";
|
||||||
|
const unitLabel = isThumbnailsPhase ? "thumbnails" : "files";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-card rounded-lg border border-line">
|
<div className="p-4 bg-card rounded-lg border border-border">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<StatusBadge status={progress.status} />
|
<StatusBadge status={progress.status} />
|
||||||
{isComplete && (
|
{isComplete && (
|
||||||
@@ -97,10 +99,10 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProgressBar value={percent} showLabel size="md" className="mb-3" />
|
<ProgressBar value={percent} showLabel size="lg" className="mb-3" />
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted mb-3">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
|
||||||
<span>{processed} / {total} files</span>
|
<span>{processed} / {total} {unitLabel}</span>
|
||||||
{progress.current_file && (
|
{progress.current_file && (
|
||||||
<span className="truncate max-w-md" title={progress.current_file}>
|
<span className="truncate max-w-md" title={progress.current_file}>
|
||||||
Current: {progress.current_file.length > 40
|
Current: {progress.current_file.length > 40
|
||||||
@@ -110,7 +112,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{progress.stats_json && (
|
{progress.stats_json && !isThumbnailsPhase && (
|
||||||
<div className="flex flex-wrap gap-3 text-xs">
|
<div className="flex flex-wrap gap-3 text-xs">
|
||||||
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
|
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
|
||||||
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>
|
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { JobProgress } from "./JobProgress";
|
import { JobProgress } from "./JobProgress";
|
||||||
import { StatusBadge, Button } from "./ui";
|
import { StatusBadge, Button, MiniProgressBar } from "./ui";
|
||||||
|
|
||||||
interface JobRowProps {
|
interface JobRowProps {
|
||||||
job: {
|
job: {
|
||||||
@@ -12,26 +12,76 @@ interface JobRowProps {
|
|||||||
type: string;
|
type: string;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
error_opt: string | null;
|
error_opt: string | null;
|
||||||
|
stats_json: {
|
||||||
|
scanned_files: number;
|
||||||
|
indexed_files: number;
|
||||||
|
removed_files: number;
|
||||||
|
errors: number;
|
||||||
|
} | null;
|
||||||
|
progress_percent: number | null;
|
||||||
|
processed_files: number | null;
|
||||||
|
total_files: number | null;
|
||||||
};
|
};
|
||||||
libraryName: string | undefined;
|
libraryName: string | undefined;
|
||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
onCancel: (id: string) => void;
|
onCancel: (id: string) => void;
|
||||||
|
formatDate: (date: string) => string;
|
||||||
|
formatDuration: (start: string, end: string | null) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps) {
|
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
|
||||||
const [showProgress, setShowProgress] = useState(
|
const isActive = job.status === "running" || job.status === "pending" || job.status === "generating_thumbnails";
|
||||||
highlighted || job.status === "running" || job.status === "pending"
|
const [showProgress, setShowProgress] = useState(highlighted || isActive);
|
||||||
);
|
|
||||||
|
|
||||||
const handleComplete = () => {
|
const handleComplete = () => {
|
||||||
setShowProgress(false);
|
setShowProgress(false);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate duration
|
||||||
|
const duration = job.started_at
|
||||||
|
? formatDuration(job.started_at, job.finished_at)
|
||||||
|
: "-";
|
||||||
|
|
||||||
|
// Get file stats
|
||||||
|
const scanned = job.stats_json?.scanned_files ?? 0;
|
||||||
|
const indexed = job.stats_json?.indexed_files ?? 0;
|
||||||
|
const removed = job.stats_json?.removed_files ?? 0;
|
||||||
|
const errors = job.stats_json?.errors ?? 0;
|
||||||
|
|
||||||
|
const isThumbnailPhase = job.status === "generating_thumbnails";
|
||||||
|
const isThumbnailJob = job.type === "thumbnail_rebuild" || job.type === "thumbnail_regenerate";
|
||||||
|
const hasThumbnailPhase = isThumbnailPhase || isThumbnailJob;
|
||||||
|
|
||||||
|
// Files column: index-phase stats only
|
||||||
|
const filesDisplay =
|
||||||
|
job.status === "running" && !isThumbnailPhase
|
||||||
|
? job.total_files != null
|
||||||
|
? `${job.processed_files ?? 0}/${job.total_files}`
|
||||||
|
: scanned > 0
|
||||||
|
? `${scanned} scanned`
|
||||||
|
: "-"
|
||||||
|
: job.status === "success" && (indexed > 0 || removed > 0 || errors > 0)
|
||||||
|
? null // rendered below as ✓ / − / ⚠
|
||||||
|
: scanned > 0
|
||||||
|
? `${scanned} scanned`
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
// Thumbnails column
|
||||||
|
const thumbInProgress = hasThumbnailPhase && (job.status === "running" || isThumbnailPhase);
|
||||||
|
const thumbDisplay =
|
||||||
|
thumbInProgress && job.total_files != null
|
||||||
|
? `${job.processed_files ?? 0}/${job.total_files}`
|
||||||
|
: job.status === "success" && job.total_files != null && hasThumbnailPhase
|
||||||
|
? `✓ ${job.total_files}`
|
||||||
|
: "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr className={highlighted ? 'bg-primary-soft/50' : 'hover:bg-muted/5'}>
|
<tr className={highlighted ? 'bg-primary/10' : 'hover:bg-muted/50'}>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Link
|
<Link
|
||||||
href={`/jobs/${job.id}`}
|
href={`/jobs/${job.id}`}
|
||||||
@@ -55,7 +105,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
|
|||||||
!
|
!
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(job.status === "running" || job.status === "pending") && (
|
{isActive && (
|
||||||
<button
|
<button
|
||||||
className="text-xs text-primary hover:text-primary/80 hover:underline"
|
className="text-xs text-primary hover:text-primary/80 hover:underline"
|
||||||
onClick={() => setShowProgress(!showProgress)}
|
onClick={() => setShowProgress(!showProgress)}
|
||||||
@@ -65,8 +115,35 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-muted">
|
<td className="px-4 py-3">
|
||||||
{new Date(job.created_at).toLocaleString()}
|
<div className="flex flex-col gap-1">
|
||||||
|
{filesDisplay !== null ? (
|
||||||
|
<span className="text-sm text-foreground">{filesDisplay}</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-success">✓ {indexed}</span>
|
||||||
|
{removed > 0 && <span className="text-warning">− {removed}</span>}
|
||||||
|
{errors > 0 && <span className="text-error">⚠ {errors}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{job.status === "running" && !isThumbnailPhase && job.total_files != null && (
|
||||||
|
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm text-foreground">{thumbDisplay}</span>
|
||||||
|
{thumbInProgress && job.total_files != null && (
|
||||||
|
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{duration}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{formatDate(job.created_at)}
|
||||||
</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">
|
||||||
@@ -76,7 +153,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
|
|||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Link>
|
</Link>
|
||||||
{(job.status === "pending" || job.status === "running") && (
|
{(job.status === "pending" || job.status === "running" || job.status === "generating_thumbnails") && (
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -88,9 +165,9 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{showProgress && (job.status === "running" || job.status === "pending") && (
|
{showProgress && isActive && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-4 py-3 bg-muted/5">
|
<td colSpan={9} className="px-4 py-3 bg-muted/50">
|
||||||
<JobProgress
|
<JobProgress
|
||||||
jobId={job.id}
|
jobId={job.id}
|
||||||
onComplete={handleComplete}
|
onComplete={handleComplete}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import { Badge } from "./ui/Badge";
|
||||||
|
import { ProgressBar } from "./ui/ProgressBar";
|
||||||
|
|
||||||
interface Job {
|
interface Job {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +22,27 @@ interface Job {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const JobsIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<rect x="2" y="3" width="20" height="18" rx="2" />
|
||||||
|
<path d="M6 8h12M6 12h12M6 16h8" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SpinnerIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChevronIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export function JobsIndicator() {
|
export function JobsIndicator() {
|
||||||
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
|
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -54,7 +78,7 @@ export function JobsIndicator() {
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runningJobs = activeJobs.filter(j => j.status === "running");
|
const runningJobs = activeJobs.filter(j => j.status === "running" || j.status === "generating_thumbnails");
|
||||||
const pendingJobs = activeJobs.filter(j => j.status === "pending");
|
const pendingJobs = activeJobs.filter(j => j.status === "pending");
|
||||||
const totalCount = activeJobs.length;
|
const totalCount = activeJobs.length;
|
||||||
|
|
||||||
@@ -67,13 +91,18 @@ export function JobsIndicator() {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="/jobs"
|
href="/jobs"
|
||||||
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted transition-all duration-200 hover:text-foreground hover:bg-primary-soft"
|
className="
|
||||||
|
flex items-center justify-center
|
||||||
|
w-9 h-9
|
||||||
|
rounded-md
|
||||||
|
text-muted-foreground
|
||||||
|
hover:text-foreground
|
||||||
|
hover:bg-accent
|
||||||
|
transition-colors duration-200
|
||||||
|
"
|
||||||
title="View all jobs"
|
title="View all jobs"
|
||||||
>
|
>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<JobsIcon className="w-[18px] h-[18px]" />
|
||||||
<rect x="2" y="3" width="20" height="18" rx="2" />
|
|
||||||
<path d="M6 8h12M6 12h12M6 16h8" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -81,56 +110,61 @@ export function JobsIndicator() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg font-medium text-sm transition-all duration-200 ${
|
className={`
|
||||||
runningJobs.length > 0
|
flex items-center gap-2
|
||||||
? 'bg-success-soft text-success'
|
px-3 py-2
|
||||||
: 'bg-warning-soft text-warning'
|
rounded-md
|
||||||
} ${isOpen ? 'ring-2 ring-primary' : ''}`}
|
font-medium text-sm
|
||||||
|
transition-all duration-200
|
||||||
|
${runningJobs.length > 0
|
||||||
|
? 'bg-success/10 text-success hover:bg-success/20'
|
||||||
|
: 'bg-warning/10 text-warning hover:bg-warning/20'
|
||||||
|
}
|
||||||
|
${isOpen ? 'ring-2 ring-ring ring-offset-2 ring-offset-background' : ''}
|
||||||
|
`}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
|
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
|
||||||
>
|
>
|
||||||
{/* Animated spinner for running jobs */}
|
{/* Animated spinner for running jobs */}
|
||||||
{runningJobs.length > 0 && (
|
{runningJobs.length > 0 && (
|
||||||
<div className="w-4 h-4 animate-spin">
|
<div className="w-4 h-4 animate-spin">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<SpinnerIcon className="w-4 h-4" />
|
||||||
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
|
||||||
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<JobsIcon className="w-4 h-4" />
|
||||||
<rect x="2" y="3" width="20" height="18" rx="2" />
|
|
||||||
<path d="M6 8h12M6 12h12M6 16h8" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Badge with count */}
|
{/* Badge with count */}
|
||||||
<span className="flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-bold text-white bg-current rounded-full">
|
<span className="flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-bold bg-current rounded-full">
|
||||||
<span className="text-background">{totalCount > 99 ? "99+" : totalCount}</span>
|
<span className="text-background">{totalCount > 99 ? "99+" : totalCount}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Chevron */}
|
{/* Chevron */}
|
||||||
<svg
|
<ChevronIcon
|
||||||
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||||
viewBox="0 0 24 24"
|
/>
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Popin/Dropdown */}
|
{/* Popin/Dropdown with glassmorphism */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute right-0 top-full mt-2 w-96 bg-card rounded-xl shadow-card border border-line overflow-hidden z-50">
|
<div className="
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-line bg-muted/5">
|
absolute right-0 top-full mt-2 w-96
|
||||||
|
bg-popover/95 backdrop-blur-md
|
||||||
|
rounded-xl
|
||||||
|
shadow-elevation-2
|
||||||
|
border border-border/60
|
||||||
|
overflow-hidden
|
||||||
|
z-50
|
||||||
|
animate-scale-in
|
||||||
|
">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border/60 bg-muted/50">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl">📊</span>
|
<span className="text-xl">📊</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-foreground">Active Jobs</h3>
|
<h3 className="font-semibold text-foreground">Active Jobs</h3>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted-foreground">
|
||||||
{runningJobs.length > 0
|
{runningJobs.length > 0
|
||||||
? `${runningJobs.length} running, ${pendingJobs.length} pending`
|
? `${runningJobs.length} running, ${pendingJobs.length} pending`
|
||||||
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
|
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
|
||||||
@@ -149,74 +183,63 @@ export function JobsIndicator() {
|
|||||||
|
|
||||||
{/* Overall progress bar if running */}
|
{/* Overall progress bar if running */}
|
||||||
{runningJobs.length > 0 && (
|
{runningJobs.length > 0 && (
|
||||||
<div className="px-4 py-3 border-b border-line">
|
<div className="px-4 py-3 border-b border-border/60">
|
||||||
<div className="flex items-center justify-between text-sm mb-2">
|
<div className="flex items-center justify-between text-sm mb-2">
|
||||||
<span className="text-muted">Overall Progress</span>
|
<span className="text-muted-foreground">Overall Progress</span>
|
||||||
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
|
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-line rounded-full overflow-hidden">
|
<ProgressBar value={totalProgress} size="sm" variant="success" />
|
||||||
<div
|
|
||||||
className="h-full bg-success rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${totalProgress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-h-80 overflow-y-auto">
|
{/* Job List */}
|
||||||
|
<div className="max-h-80 overflow-y-auto scrollbar-hide">
|
||||||
{activeJobs.length === 0 ? (
|
{activeJobs.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-muted">
|
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||||
<span className="text-4xl mb-2">✅</span>
|
<span className="text-4xl mb-2">✅</span>
|
||||||
<p>No active jobs</p>
|
<p>No active jobs</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-line">
|
<ul className="divide-y divide-border/60">
|
||||||
{activeJobs.map(job => (
|
{activeJobs.map(job => (
|
||||||
<li key={job.id}>
|
<li key={job.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`/jobs/${job.id}`}
|
href={`/jobs/${job.id}`}
|
||||||
className="block px-4 py-3 hover:bg-muted/5 transition-colors"
|
className="block px-4 py-3 hover:bg-accent/50 transition-colors duration-200"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5">
|
<div className="mt-0.5">
|
||||||
{job.status === "running" && <span className="animate-spin inline-block">⏳</span>}
|
{(job.status === "running" || job.status === "generating_thumbnails") && <span className="animate-spin inline-block">⏳</span>}
|
||||||
{job.status === "pending" && <span>⏸</span>}
|
{job.status === "pending" && <span>⏸</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<code className="text-xs px-1.5 py-0.5 bg-line/50 rounded font-mono">{job.id.slice(0, 8)}</code>
|
<code className="text-xs px-1.5 py-0.5 bg-muted rounded font-mono">{job.id.slice(0, 8)}</code>
|
||||||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
|
<Badge variant={job.type === 'rebuild' ? 'primary' : job.type === 'thumbnail_regenerate' ? 'warning' : 'secondary'} className="text-[10px]">
|
||||||
job.type === 'rebuild' ? 'bg-primary-soft text-primary' : 'bg-muted/20 text-muted'
|
{job.type === 'thumbnail_rebuild' ? 'Thumbnails' : job.type === 'thumbnail_regenerate' ? 'Regenerate' : job.type}
|
||||||
}`}>
|
</Badge>
|
||||||
{job.type}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{job.status === "running" && job.progress_percent !== null && (
|
{(job.status === "running" || job.status === "generating_thumbnails") && job.progress_percent != null && (
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<div className="flex-1 h-1.5 bg-line rounded-full overflow-hidden">
|
<MiniProgressBar value={job.progress_percent} />
|
||||||
<div
|
<span className="text-xs font-medium text-muted-foreground">{job.progress_percent}%</span>
|
||||||
className="h-full bg-success rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${job.progress_percent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-medium text-muted">{job.progress_percent}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{job.current_file && (
|
{job.current_file && (
|
||||||
<p className="text-xs text-muted mt-1.5 truncate" title={job.current_file}>
|
<p className="text-xs text-muted-foreground mt-1.5 truncate" title={job.current_file}>
|
||||||
📄 {job.current_file}
|
📄 {job.current_file}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{job.stats_json && (
|
{job.stats_json && (
|
||||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted">
|
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
|
||||||
<span>✓ {job.stats_json.indexed_files}</span>
|
<span>✓ {job.stats_json.indexed_files}</span>
|
||||||
{job.stats_json.errors > 0 && (
|
{job.stats_json.errors > 0 && (
|
||||||
<span className="text-error">⚠ {job.stats_json.errors}</span>
|
<span className="text-destructive">⚠ {job.stats_json.errors}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -230,11 +253,23 @@ export function JobsIndicator() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-4 py-2 border-t border-line bg-muted/5">
|
<div className="px-4 py-2 border-t border-border/60 bg-muted/50">
|
||||||
<p className="text-xs text-muted text-center">Auto-refreshing every 2s</p>
|
<p className="text-xs text-muted-foreground text-center">Auto-refreshing every 2s</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mini progress bar for dropdown
|
||||||
|
function MiniProgressBar({ value }: { value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-success rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${value}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,18 @@ interface Job {
|
|||||||
type: string;
|
type: string;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
error_opt: string | null;
|
error_opt: string | null;
|
||||||
|
stats_json: {
|
||||||
|
scanned_files: number;
|
||||||
|
indexed_files: number;
|
||||||
|
removed_files: number;
|
||||||
|
errors: number;
|
||||||
|
} | null;
|
||||||
|
progress_percent: number | null;
|
||||||
|
processed_files: number | null;
|
||||||
|
total_files: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JobsListProps {
|
interface JobsListProps {
|
||||||
@@ -18,6 +29,33 @@ interface JobsListProps {
|
|||||||
highlightJobId?: string;
|
highlightJobId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(start: string, end: string | null): string {
|
||||||
|
const startDate = new Date(start);
|
||||||
|
const endDate = end ? new Date(end) : new Date();
|
||||||
|
const diff = endDate.getTime() - startDate.getTime();
|
||||||
|
|
||||||
|
if (diff < 60000) return `${Math.floor(diff / 1000)}s`;
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ${Math.floor((diff % 60000) / 1000)}s`;
|
||||||
|
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
|
||||||
|
if (diff < 3600000) {
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return "Just now";
|
||||||
|
return `${mins}m ago`;
|
||||||
|
}
|
||||||
|
if (diff < 86400000) {
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
|
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
|
||||||
const [jobs, setJobs] = useState(initialJobs);
|
const [jobs, setJobs] = useState(initialJobs);
|
||||||
|
|
||||||
@@ -53,7 +91,6 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Update local state to reflect cancellation
|
|
||||||
setJobs(jobs.map(job =>
|
setJobs(jobs.map(job =>
|
||||||
job.id === id ? { ...job, status: "cancelled" } : job
|
job.id === id ? { ...job, status: "cancelled" } : job
|
||||||
));
|
));
|
||||||
@@ -64,20 +101,23 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-xl shadow-soft border border-line overflow-hidden">
|
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line bg-muted/5">
|
<tr className="border-b border-border/60 bg-muted/50">
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">ID</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">ID</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Library</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Library</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Type</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Type</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Status</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Status</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Created</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Files</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Thumbnails</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Duration</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Created</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-line">
|
<tbody className="divide-y divide-border/60">
|
||||||
{jobs.map((job) => (
|
{jobs.map((job) => (
|
||||||
<JobRow
|
<JobRow
|
||||||
key={job.id}
|
key={job.id}
|
||||||
@@ -85,6 +125,8 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
|||||||
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
|
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
|
||||||
highlighted={job.id === highlightJobId}
|
highlighted={job.id === highlightJobId}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
|
formatDate={formatDate}
|
||||||
|
formatDuration={formatDuration}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useTransition } from "react";
|
import { useState, useRef, useEffect, useTransition } from "react";
|
||||||
import Link from "next/link";
|
import { Button } from "../components/ui";
|
||||||
import { Button, Badge } from "../components/ui";
|
|
||||||
|
|
||||||
interface LibraryActionsProps {
|
interface LibraryActionsProps {
|
||||||
libraryId: string;
|
libraryId: string;
|
||||||
@@ -70,13 +69,16 @@ export function LibraryActions({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className={isOpen ? "bg-muted/10" : ""}
|
className={isOpen ? "bg-accent" : ""}
|
||||||
>
|
>
|
||||||
⚙️
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute right-0 top-full mt-2 w-72 bg-card rounded-xl shadow-card border border-line p-4 z-50">
|
<div className="absolute right-0 top-full mt-2 w-72 bg-card rounded-xl shadow-md border border-border/60 p-4 z-50">
|
||||||
<form action={handleSubmit}>
|
<form action={handleSubmit}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -86,7 +88,7 @@ export function LibraryActions({
|
|||||||
name="monitor_enabled"
|
name="monitor_enabled"
|
||||||
value="true"
|
value="true"
|
||||||
defaultChecked={monitorEnabled}
|
defaultChecked={monitorEnabled}
|
||||||
className="w-4 h-4 rounded border-line text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
Auto Scan
|
Auto Scan
|
||||||
</label>
|
</label>
|
||||||
@@ -99,7 +101,7 @@ export function LibraryActions({
|
|||||||
name="watcher_enabled"
|
name="watcher_enabled"
|
||||||
value="true"
|
value="true"
|
||||||
defaultChecked={watcherEnabled}
|
defaultChecked={watcherEnabled}
|
||||||
className="w-4 h-4 rounded border-line text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-border text-primary focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
File Watcher ⚡
|
File Watcher ⚡
|
||||||
</label>
|
</label>
|
||||||
@@ -110,7 +112,7 @@ export function LibraryActions({
|
|||||||
<select
|
<select
|
||||||
name="scan_mode"
|
name="scan_mode"
|
||||||
defaultValue={scanMode}
|
defaultValue={scanMode}
|
||||||
className="text-sm border border-line rounded-lg px-2 py-1 bg-background"
|
className="text-sm border border-border rounded-lg px-2 py-1 bg-background"
|
||||||
>
|
>
|
||||||
<option value="manual">Manual</option>
|
<option value="manual">Manual</option>
|
||||||
<option value="hourly">Hourly</option>
|
<option value="hourly">Hourly</option>
|
||||||
|
|||||||
38
apps/backoffice/app/components/LibraryForm.tsx
Normal file
38
apps/backoffice/app/components/LibraryForm.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FolderPicker } from "./FolderPicker";
|
||||||
|
import { FolderItem } from "../../lib/api";
|
||||||
|
import { Button, FormField, FormInput, FormRow } from "./ui";
|
||||||
|
|
||||||
|
interface LibraryFormProps {
|
||||||
|
initialFolders: FolderItem[];
|
||||||
|
action: (formData: FormData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
|
||||||
|
const [selectedPath, setSelectedPath] = useState<string>("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action}>
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1 min-w-48">
|
||||||
|
<FormInput name="name" placeholder="Library name" required />
|
||||||
|
</FormField>
|
||||||
|
<FormField className="flex-1 min-w-64">
|
||||||
|
<input type="hidden" name="root_path" value={selectedPath} />
|
||||||
|
<FolderPicker
|
||||||
|
initialFolders={initialFolders}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={setSelectedPath}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Button type="submit" disabled={!selectedPath}>
|
||||||
|
Add Library
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
apps/backoffice/app/components/LibrarySubPageHeader.tsx
Normal file
111
apps/backoffice/app/components/LibrarySubPageHeader.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Card, Badge } from "./ui";
|
||||||
|
|
||||||
|
interface LibrarySubPageHeaderProps {
|
||||||
|
library: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
root_path: string;
|
||||||
|
book_count: number;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
iconColor?: string;
|
||||||
|
filterInfo?: {
|
||||||
|
label: string;
|
||||||
|
clearHref: string;
|
||||||
|
clearLabel: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibrarySubPageHeader({
|
||||||
|
library,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
iconColor = "text-primary",
|
||||||
|
filterInfo
|
||||||
|
}: LibrarySubPageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header avec breadcrumb intégré */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Link
|
||||||
|
href="/libraries"
|
||||||
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<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="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Libraries
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-sm text-foreground font-medium">{library.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
|
<span className={iconColor}>{icon}</span>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Bar - Version améliorée */}
|
||||||
|
<Card className="bg-muted/30 border-border/40">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
||||||
|
{/* Path */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<code className="text-xs font-mono text-muted-foreground bg-background px-2 py-1 rounded border border-border/60">
|
||||||
|
{library.root_path}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<span className="hidden sm:block w-px h-4 bg-border" />
|
||||||
|
|
||||||
|
{/* Book count */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-foreground">
|
||||||
|
<span className="font-semibold">{library.book_count}</span>
|
||||||
|
<span className="text-muted-foreground ml-1">book{library.book_count !== 1 ? 's' : ''}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<span className="hidden sm:block w-px h-4 bg-border" />
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Badge
|
||||||
|
variant={library.enabled ? "success" : "muted"}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{library.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Filter Info (optionnel) */}
|
||||||
|
{filterInfo && (
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{filterInfo.label}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={filterInfo.clearHref as `/libraries/${string}/books`}
|
||||||
|
className="text-sm text-primary hover:text-primary/80 font-medium"
|
||||||
|
>
|
||||||
|
{filterInfo.clearLabel}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,14 +42,14 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
|||||||
isPending
|
isPending
|
||||||
? 'opacity-50 cursor-not-allowed'
|
? 'opacity-50 cursor-not-allowed'
|
||||||
: 'hover:border-primary'
|
: 'hover:border-primary'
|
||||||
} ${monitorEnabled ? 'bg-primary-soft border-primary text-primary' : 'bg-card border-line text-muted'}`}>
|
} ${monitorEnabled ? 'bg-primary/10 border-primary text-primary' : 'bg-card border-border text-muted-foreground'}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="monitor_enabled"
|
name="monitor_enabled"
|
||||||
value="true"
|
value="true"
|
||||||
defaultChecked={monitorEnabled}
|
defaultChecked={monitorEnabled}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="w-3.5 h-3.5 rounded border-line text-primary focus:ring-primary"
|
className="w-3.5 h-3.5 rounded border-border text-primary focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<span>Auto</span>
|
<span>Auto</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -58,14 +58,14 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
|||||||
isPending
|
isPending
|
||||||
? 'opacity-50 cursor-not-allowed'
|
? 'opacity-50 cursor-not-allowed'
|
||||||
: 'hover:border-primary'
|
: 'hover:border-primary'
|
||||||
} ${watcherEnabled ? 'bg-warning-soft border-warning text-warning' : 'bg-card border-line text-muted'}`}>
|
} ${watcherEnabled ? 'bg-warning/10 border-warning text-warning' : 'bg-card border-border text-muted-foreground'}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="watcher_enabled"
|
name="watcher_enabled"
|
||||||
value="true"
|
value="true"
|
||||||
defaultChecked={watcherEnabled}
|
defaultChecked={watcherEnabled}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="w-3.5 h-3.5 rounded border-line text-warning focus:ring-warning"
|
className="w-3.5 h-3.5 rounded border-border text-warning focus:ring-warning"
|
||||||
/>
|
/>
|
||||||
<span title="Real-time file watcher">⚡</span>
|
<span title="Real-time file watcher">⚡</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -74,7 +74,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
|
|||||||
name="scan_mode"
|
name="scan_mode"
|
||||||
defaultValue={scanMode}
|
defaultValue={scanMode}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="px-3 py-1.5 text-sm rounded-lg border border-line bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
|
className="px-3 py-1.5 text-sm rounded-lg border border-border bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="manual">Manual</option>
|
<option value="manual">Manual</option>
|
||||||
<option value="hourly">Hourly</option>
|
<option value="hourly">Hourly</option>
|
||||||
|
|||||||
@@ -1,61 +1,129 @@
|
|||||||
type BadgeVariant = "default" | "primary" | "success" | "warning" | "error" | "muted";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
type BadgeVariant =
|
||||||
|
| "default"
|
||||||
|
| "primary"
|
||||||
|
| "secondary"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "success"
|
||||||
|
| "warning"
|
||||||
|
| "error"
|
||||||
|
| "muted"
|
||||||
|
| "unread"
|
||||||
|
| "in-progress"
|
||||||
|
| "completed";
|
||||||
|
|
||||||
interface BadgeProps {
|
interface BadgeProps {
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
variant?: BadgeVariant;
|
variant?: BadgeVariant;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles: Record<BadgeVariant, string> = {
|
const variantStyles: Record<BadgeVariant, string> = {
|
||||||
default: "bg-muted/20 text-muted",
|
// shadcn/ui compatible
|
||||||
primary: "bg-primary-soft text-primary",
|
default: "bg-primary/90 text-primary-foreground border-transparent hover:bg-primary/80 backdrop-blur-md",
|
||||||
success: "bg-success-soft text-success",
|
secondary: "bg-secondary/80 text-secondary-foreground border-transparent hover:bg-secondary/60 backdrop-blur-md",
|
||||||
warning: "bg-warning-soft text-warning",
|
destructive: "bg-destructive/90 text-destructive-foreground border-transparent hover:bg-destructive/80 backdrop-blur-md",
|
||||||
error: "bg-error-soft text-error",
|
outline: "text-foreground border-border bg-background/50",
|
||||||
muted: "bg-muted/10 text-muted",
|
|
||||||
|
// Legacy + Additional variants
|
||||||
|
primary: "bg-primary/90 text-primary-foreground backdrop-blur-md",
|
||||||
|
success: "bg-success/90 text-success-foreground backdrop-blur-md",
|
||||||
|
warning: "bg-warning/90 text-white backdrop-blur-md",
|
||||||
|
error: "bg-destructive/90 text-destructive-foreground backdrop-blur-md",
|
||||||
|
muted: "bg-muted/60 text-muted-foreground backdrop-blur-md",
|
||||||
|
|
||||||
|
// Status badges from StripStream
|
||||||
|
unread: "badge-unread backdrop-blur-md",
|
||||||
|
"in-progress": "badge-in-progress backdrop-blur-md",
|
||||||
|
completed: "badge-completed backdrop-blur-md",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Badge({ children, variant = "default", className = "" }: BadgeProps) {
|
export function Badge({ children, variant = "default", className = "" }: BadgeProps) {
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${variantStyles[variant]} ${className}`}>
|
<span className={`
|
||||||
|
inline-flex items-center
|
||||||
|
px-2.5 py-0.5
|
||||||
|
rounded-full
|
||||||
|
text-xs font-semibold
|
||||||
|
border
|
||||||
|
transition-colors duration-200
|
||||||
|
${variantStyles[variant]}
|
||||||
|
${className}
|
||||||
|
`}>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusVariant = "running" | "success" | "failed" | "cancelled" | "pending";
|
// Status badge for jobs/tasks
|
||||||
|
const statusVariants: Record<string, BadgeVariant> = {
|
||||||
|
running: "in-progress",
|
||||||
|
generating_thumbnails: "in-progress",
|
||||||
|
success: "completed",
|
||||||
|
completed: "completed",
|
||||||
|
failed: "error",
|
||||||
|
cancelled: "muted",
|
||||||
|
pending: "warning",
|
||||||
|
unread: "unread",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
generating_thumbnails: "Thumbnails",
|
||||||
|
};
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
interface StatusBadgeProps {
|
||||||
status: string;
|
status: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusVariants: Record<StatusVariant, BadgeVariant> = {
|
|
||||||
running: "primary",
|
|
||||||
success: "success",
|
|
||||||
failed: "error",
|
|
||||||
cancelled: "muted",
|
|
||||||
pending: "warning",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
|
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
|
||||||
const variant = statusVariants[status as StatusVariant] || "default";
|
const key = status.toLowerCase();
|
||||||
return <Badge variant={variant} className={className}>{status}</Badge>;
|
const variant = statusVariants[key] || "default";
|
||||||
|
const label = statusLabels[key] ?? status;
|
||||||
|
return <Badge variant={variant} className={className}>{label}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobTypeVariant = "rebuild" | "full_rebuild";
|
// Job type badge
|
||||||
|
const jobTypeVariants: Record<string, BadgeVariant> = {
|
||||||
|
rebuild: "primary",
|
||||||
|
full_rebuild: "warning",
|
||||||
|
thumbnail_rebuild: "secondary",
|
||||||
|
thumbnail_regenerate: "warning",
|
||||||
|
};
|
||||||
|
|
||||||
|
const jobTypeLabels: Record<string, string> = {
|
||||||
|
thumbnail_rebuild: "Thumbnails",
|
||||||
|
thumbnail_regenerate: "Regenerate",
|
||||||
|
};
|
||||||
|
|
||||||
interface JobTypeBadgeProps {
|
interface JobTypeBadgeProps {
|
||||||
type: string;
|
type: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobTypeVariants: Record<JobTypeVariant, BadgeVariant> = {
|
|
||||||
rebuild: "primary",
|
|
||||||
full_rebuild: "warning",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
|
export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
|
||||||
const variant = jobTypeVariants[type as JobTypeVariant] || "default";
|
const key = type.toLowerCase();
|
||||||
return <Badge variant={variant} className={className}>{type}</Badge>;
|
const variant = jobTypeVariants[key] || "default";
|
||||||
|
const label = jobTypeLabels[key] ?? type;
|
||||||
|
return <Badge variant={variant} className={className}>{label}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress badge (shows percentage)
|
||||||
|
interface ProgressBadgeProps {
|
||||||
|
progress: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBadge({ progress, className = "" }: ProgressBadgeProps) {
|
||||||
|
let variant: BadgeVariant = "unread";
|
||||||
|
if (progress === 100) variant = "completed";
|
||||||
|
else if (progress > 0) variant = "in-progress";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={variant} className={className}>
|
||||||
|
{progress}%
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { ButtonHTMLAttributes, ReactNode } from "react";
|
import { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
type ButtonVariant = "primary" | "secondary" | "danger" | "warning" | "ghost";
|
type ButtonVariant =
|
||||||
|
| "default"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "secondary"
|
||||||
|
| "ghost"
|
||||||
|
| "link"
|
||||||
|
| "primary"
|
||||||
|
| "danger"
|
||||||
|
| "warning";
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -9,22 +18,29 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles: Record<ButtonVariant, string> = {
|
const variantStyles: Record<ButtonVariant, string> = {
|
||||||
primary: "bg-primary text-white hover:bg-primary/90",
|
// shadcn/ui compatible variants
|
||||||
secondary: "border border-line text-muted hover:bg-muted/5",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md",
|
||||||
danger: "bg-error text-white hover:bg-error/90",
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
|
||||||
warning: "bg-warning text-white hover:bg-warning/90",
|
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
ghost: "text-muted hover:text-foreground hover:bg-muted/5",
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/85 shadow-sm",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
|
||||||
|
// Legacy variants (mapped to new ones for compatibility)
|
||||||
|
primary: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md",
|
||||||
|
danger: "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
|
||||||
|
warning: "bg-warning text-white hover:bg-warning/90 shadow-sm",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles: Record<string, string> = {
|
const sizeStyles: Record<string, string> = {
|
||||||
sm: "h-8 px-3 text-xs",
|
sm: "h-9 px-3 text-xs rounded-md",
|
||||||
md: "h-10 px-4 text-sm",
|
md: "h-10 px-4 py-2 text-sm rounded-md",
|
||||||
lg: "h-12 px-6 text-base",
|
lg: "h-11 px-8 text-base rounded-md",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
children,
|
children,
|
||||||
variant = "primary",
|
variant = "default",
|
||||||
size = "md",
|
size = "md",
|
||||||
className = "",
|
className = "",
|
||||||
disabled,
|
disabled,
|
||||||
@@ -33,8 +49,12 @@ export function Button({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`
|
className={`
|
||||||
inline-flex items-center justify-center font-medium rounded-lg transition-colors
|
inline-flex items-center justify-center
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
font-medium
|
||||||
|
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]
|
||||||
${variantStyles[variant]}
|
${variantStyles[variant]}
|
||||||
${sizeStyles[size]}
|
${sizeStyles[size]}
|
||||||
${className}
|
${className}
|
||||||
@@ -46,3 +66,46 @@ export function Button({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Icon Button variant
|
||||||
|
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconSizeStyles: Record<string, string> = {
|
||||||
|
sm: "h-8 w-8",
|
||||||
|
md: "h-9 w-9",
|
||||||
|
lg: "h-10 w-10",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IconButton({
|
||||||
|
children,
|
||||||
|
size = "md",
|
||||||
|
variant = "ghost",
|
||||||
|
className = "",
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
title={title}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center justify-center
|
||||||
|
rounded-md
|
||||||
|
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
|
||||||
|
hover:bg-accent hover:text-accent-foreground
|
||||||
|
active:scale-[0.96]
|
||||||
|
${iconSizeStyles[size]}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,25 +3,146 @@ import { ReactNode } from "react";
|
|||||||
interface CardProps {
|
interface CardProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
hover?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ children, className = "" }: CardProps) {
|
export function Card({ children, className = "", hover = true }: CardProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`bg-card rounded-xl shadow-soft border border-line p-6 ${className}`}>
|
<div
|
||||||
|
className={`
|
||||||
|
bg-card text-card-foreground
|
||||||
|
rounded-lg border border-border/60
|
||||||
|
shadow-sm
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
${hover ? "hover:shadow-md hover:-translate-y-0.5" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CardHeaderProps {
|
interface CardHeaderProps {
|
||||||
title: string;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardHeader({ title, className = "" }: CardHeaderProps) {
|
export function CardHeader({ children, className = "" }: CardHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<h2 className={`text-lg font-semibold text-foreground mb-4 ${className}`}>
|
<div className={`flex flex-col space-y-1.5 p-6 ${className}`}>
|
||||||
{title}
|
{children}
|
||||||
</h2>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardTitleProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({ children, className = "" }: CardTitleProps) {
|
||||||
|
return (
|
||||||
|
<h3 className={`text-2xl font-semibold leading-none tracking-tight ${className}`}>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardDescriptionProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardDescription({ children, className = "" }: CardDescriptionProps) {
|
||||||
|
return (
|
||||||
|
<p className={`text-sm text-muted-foreground ${className}`}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardContentProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ children, className = "" }: CardContentProps) {
|
||||||
|
return (
|
||||||
|
<div className={`p-6 pt-0 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardFooterProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardFooter({ children, className = "" }: CardFooterProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center p-6 pt-0 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glass Card variant for special sections
|
||||||
|
interface GlassCardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassCard({ children, className = "" }: GlassCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
glass-card
|
||||||
|
rounded-xl
|
||||||
|
p-6
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
hover:shadow-elevation-2
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple card with header shortcut
|
||||||
|
interface SimpleCardProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
footer
|
||||||
|
}: SimpleCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
{(title || description) && (
|
||||||
|
<CardHeader>
|
||||||
|
{title && <CardTitle>{title}</CardTitle>}
|
||||||
|
{description && <CardDescription>{description}</CardDescription>}
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent>
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
{footer && (
|
||||||
|
<CardFooter>
|
||||||
|
{footer}
|
||||||
|
</CardFooter>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,81 @@
|
|||||||
import { ReactNode, LabelHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes } from "react";
|
import { ReactNode, LabelHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
// Form Field Container
|
||||||
interface FormFieldProps {
|
interface FormFieldProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormField({ children, className = "" }: FormFieldProps) {
|
export function FormField({ children, className = "" }: FormFieldProps) {
|
||||||
return <div className={`flex-1 min-w-48 ${className}`}>{children}</div>;
|
return <div className={`flex flex-col space-y-1.5 ${className}`}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Form Label
|
||||||
interface FormLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
interface FormLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormLabel({ children, className = "", ...props }: FormLabelProps) {
|
export function FormLabel({ children, required, className = "", ...props }: FormLabelProps) {
|
||||||
return (
|
return (
|
||||||
<label className={`block text-sm font-medium text-foreground mb-1.5 ${className}`} {...props}>
|
<label
|
||||||
|
className={`text-sm font-medium text-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
|
{required && <span className="text-destructive ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {}
|
// Form Input
|
||||||
|
interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function FormInput({ className = "", ...props }: FormInputProps) {
|
export function FormInput({ className = "", error, ...props }: FormInputProps) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className={`w-full h-10 px-3 rounded-lg border border-line bg-background text-foreground placeholder-muted focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm ${className}`}
|
className={`
|
||||||
|
flex h-10 w-full
|
||||||
|
rounded-md border border-input
|
||||||
|
bg-background px-3 py-2
|
||||||
|
text-sm
|
||||||
|
shadow-sm
|
||||||
|
transition-colors duration-200
|
||||||
|
file:border-0 file:bg-transparent file:text-sm file:font-medium
|
||||||
|
placeholder:text-muted-foreground/90
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
${error ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Form Select
|
||||||
interface FormSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
interface FormSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormSelect({ children, className = "", ...props }: FormSelectProps) {
|
export function FormSelect({ children, className = "", error, ...props }: FormSelectProps) {
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className={`w-full h-10 px-3 rounded-lg border border-line bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm ${className}`}
|
className={`
|
||||||
|
flex h-10 w-full
|
||||||
|
rounded-md border border-input
|
||||||
|
bg-background px-3 py-2
|
||||||
|
text-sm
|
||||||
|
shadow-sm
|
||||||
|
transition-colors duration-200
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
${error ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -47,11 +83,64 @@ export function FormSelect({ children, className = "", ...props }: FormSelectPro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Form Row (horizontal layout)
|
||||||
interface FormRowProps {
|
interface FormRowProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormRow({ children, className = "" }: FormRowProps) {
|
export function FormRow({ children, className = "" }: FormRowProps) {
|
||||||
return <div className={`flex items-end gap-3 flex-wrap ${className}`}>{children}</div>;
|
return <div className={`flex flex-wrap items-end gap-4 ${className}`}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form Section
|
||||||
|
interface FormSectionProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormSection({ title, description, children, className = "" }: FormSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className={`space-y-4 ${className}`}>
|
||||||
|
{(title || description) && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{title && <h3 className="text-lg font-medium text-foreground">{title}</h3>}
|
||||||
|
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form Error Message
|
||||||
|
interface FormErrorProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormError({ children, className = "" }: FormErrorProps) {
|
||||||
|
return (
|
||||||
|
<p className={`text-xs text-destructive ${className}`}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form Description
|
||||||
|
interface FormDescriptionProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormDescription({ children, className = "" }: FormDescriptionProps) {
|
||||||
|
return (
|
||||||
|
<p className={`text-xs text-muted-foreground ${className}`}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +1,123 @@
|
|||||||
type IconName = "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "series";
|
type IconName =
|
||||||
|
| "dashboard"
|
||||||
|
| "books"
|
||||||
|
| "libraries"
|
||||||
|
| "jobs"
|
||||||
|
| "tokens"
|
||||||
|
| "series"
|
||||||
|
| "settings"
|
||||||
|
| "image"
|
||||||
|
| "cache"
|
||||||
|
| "performance"
|
||||||
|
| "folder"
|
||||||
|
| "folderOpen"
|
||||||
|
| "search"
|
||||||
|
| "plus"
|
||||||
|
| "edit"
|
||||||
|
| "trash"
|
||||||
|
| "check"
|
||||||
|
| "x"
|
||||||
|
| "chevronLeft"
|
||||||
|
| "chevronRight"
|
||||||
|
| "chevronUp"
|
||||||
|
| "chevronDown"
|
||||||
|
| "arrowLeft"
|
||||||
|
| "arrowRight"
|
||||||
|
| "refresh"
|
||||||
|
| "sun"
|
||||||
|
| "moon"
|
||||||
|
| "externalLink"
|
||||||
|
| "key"
|
||||||
|
| "play"
|
||||||
|
| "stop"
|
||||||
|
| "spinner"
|
||||||
|
| "warning";
|
||||||
|
|
||||||
interface PageIconProps {
|
type IconSize = "sm" | "md" | "lg" | "xl";
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
name: IconName;
|
name: IconName;
|
||||||
|
size?: IconSize;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const icons: Record<IconName, React.ReactNode> = {
|
const sizeClasses: Record<IconSize, string> = {
|
||||||
dashboard: (
|
sm: "w-4 h-4",
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
md: "w-5 h-5",
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
lg: "w-6 h-6",
|
||||||
</svg>
|
xl: "w-8 h-8",
|
||||||
),
|
|
||||||
books: (
|
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
libraries: (
|
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
jobs: (
|
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
tokens: (
|
|
||||||
<svg className="w-8 h-8" 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>
|
|
||||||
),
|
|
||||||
series: (
|
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const colors: Record<IconName, string> = {
|
const icons: Record<IconName, string> = {
|
||||||
|
dashboard: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
|
||||||
|
books: "M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253",
|
||||||
|
libraries: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z",
|
||||||
|
jobs: "M13 10V3L4 14h7v7l9-11h-7z",
|
||||||
|
tokens: "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",
|
||||||
|
series: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10",
|
||||||
|
settings: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z",
|
||||||
|
image: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z",
|
||||||
|
cache: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10",
|
||||||
|
performance: "M13 10V3L4 14h7v7l9-11h-7z",
|
||||||
|
folder: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z",
|
||||||
|
folderOpen: "M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z",
|
||||||
|
search: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z",
|
||||||
|
plus: "M12 4v16m8-8H4",
|
||||||
|
edit: "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",
|
||||||
|
trash: "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",
|
||||||
|
check: "M5 13l4 4L19 7",
|
||||||
|
x: "M6 18L18 6M6 6l12 12",
|
||||||
|
chevronLeft: "M15 19l-7-7 7-7",
|
||||||
|
chevronRight: "M9 5l7 7-7 7",
|
||||||
|
chevronUp: "M5 15l7-7 7 7",
|
||||||
|
chevronDown: "M19 9l-7 7-7-7",
|
||||||
|
arrowLeft: "M10 19l-7-7m0 0l7-7m-7 7h18",
|
||||||
|
arrowRight: "M14 5l7 7m0 0l-7 7m7-7H3",
|
||||||
|
refresh: "M4 4v5h.582m15.582 0A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15",
|
||||||
|
sun: "M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z",
|
||||||
|
moon: "M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z",
|
||||||
|
externalLink: "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14",
|
||||||
|
key: "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",
|
||||||
|
play: "M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||||
|
stop: "M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z",
|
||||||
|
spinner: "M4 4v5h.582m15.582 0A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15",
|
||||||
|
warning: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorClasses: Partial<Record<IconName, string>> = {
|
||||||
dashboard: "text-primary",
|
dashboard: "text-primary",
|
||||||
books: "text-success",
|
books: "text-success",
|
||||||
libraries: "text-primary",
|
libraries: "text-primary",
|
||||||
jobs: "text-warning",
|
jobs: "text-warning",
|
||||||
tokens: "text-error",
|
tokens: "text-error",
|
||||||
series: "text-primary",
|
series: "text-primary",
|
||||||
|
settings: "text-muted-foreground",
|
||||||
|
image: "text-primary",
|
||||||
|
cache: "text-warning",
|
||||||
|
performance: "text-success",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PageIcon({ name, className = "" }: PageIconProps) {
|
export function Icon({ name, size = "md", className = "" }: IconProps) {
|
||||||
|
const sizeClass = sizeClasses[size];
|
||||||
|
const colorClass = colorClasses[name];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`${colors[name]} ${className}`}>
|
<svg
|
||||||
{icons[name]}
|
className={`${sizeClass} ${colorClass || ""} ${className}`}
|
||||||
</span>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={icons[name]} />
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nav icons (smaller)
|
// Backwards compatibility aliases
|
||||||
export function NavIcon({ name, className = "" }: { name: IconName; className?: string }) {
|
export function PageIcon({ name, className = "" }: { name: IconName; className?: string }) {
|
||||||
const navIcons: Record<IconName, React.ReactNode> = {
|
return <Icon name={name} size="xl" className={className} />;
|
||||||
dashboard: (
|
}
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
export function NavIcon({ name, className = "" }: { name: IconName; className?: string }) {
|
||||||
</svg>
|
return <Icon name={name} size="sm" className={className} />;
|
||||||
),
|
|
||||||
books: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
libraries: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
jobs: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
tokens: (
|
|
||||||
<svg className="w-4 h-4" 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>
|
|
||||||
),
|
|
||||||
series: (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
return <span className={className}>{navIcons[name]}</span>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,168 @@
|
|||||||
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from "react";
|
import { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes, ReactNode, forwardRef } from "react";
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
// Input Component
|
||||||
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
label?: string;
|
label?: string;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Input({ label, className = "", ...props }: InputProps) {
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ label, error, className = "", ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
className={`px-4 py-2.5 rounded-lg border border-line bg-background text-foreground placeholder-muted focus:ring-2 focus:ring-primary focus:border-primary ${className}`}
|
ref={ref}
|
||||||
|
className={`
|
||||||
|
flex w-full
|
||||||
|
h-10 px-3 py-2
|
||||||
|
rounded-md border border-input
|
||||||
|
bg-background
|
||||||
|
text-sm text-foreground
|
||||||
|
shadow-sm
|
||||||
|
transition-colors duration-200
|
||||||
|
file:border-0 file:bg-transparent file:text-sm file:font-medium
|
||||||
|
placeholder:text-muted-foreground/90
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
${error ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
// Select Component
|
||||||
|
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
label?: string;
|
label?: string;
|
||||||
|
error?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Select({ label, children, className = "", ...props }: SelectProps) {
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
({ label, error, children, className = "", ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<select
|
<select
|
||||||
className={`px-4 py-2.5 rounded-lg border border-line bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary ${className}`}
|
ref={ref}
|
||||||
|
className={`
|
||||||
|
flex w-full
|
||||||
|
h-10 px-3 py-2
|
||||||
|
rounded-md border border-input
|
||||||
|
bg-background
|
||||||
|
text-sm text-foreground
|
||||||
|
shadow-sm
|
||||||
|
transition-colors duration-200
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
${error ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</select>
|
</select>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
Select.displayName = "Select";
|
||||||
|
|
||||||
|
// Textarea Component
|
||||||
|
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ label, error, className = "", ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={`
|
||||||
|
flex w-full
|
||||||
|
min-h-[80px] px-3 py-2
|
||||||
|
rounded-md border border-input
|
||||||
|
bg-background
|
||||||
|
text-sm text-foreground
|
||||||
|
shadow-sm
|
||||||
|
transition-colors duration-200
|
||||||
|
placeholder:text-muted-foreground/90
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
resize-vertical
|
||||||
|
${error ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
// Search Input with Icon
|
||||||
|
interface SearchInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||||
|
({ icon, className = "", ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{icon && (
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={`
|
||||||
|
flex w-full
|
||||||
|
h-10 pl-10 pr-4 py-2
|
||||||
|
rounded-md border border-input
|
||||||
|
bg-background
|
||||||
|
text-sm text-foreground
|
||||||
|
shadow-sm
|
||||||
|
transition-colors duration-200
|
||||||
|
placeholder:text-muted-foreground/90
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
SearchInput.displayName = "SearchInput";
|
||||||
|
|||||||
236
apps/backoffice/app/components/ui/Pagination.tsx
Normal file
236
apps/backoffice/app/components/ui/Pagination.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { IconButton } from "./Button";
|
||||||
|
|
||||||
|
interface CursorPaginationProps {
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPrevPage: boolean;
|
||||||
|
pageSize: number;
|
||||||
|
currentCount: number;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
nextCursor?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CursorPagination({
|
||||||
|
hasNextPage,
|
||||||
|
hasPrevPage,
|
||||||
|
pageSize,
|
||||||
|
currentCount,
|
||||||
|
pageSizeOptions = [20, 50, 100],
|
||||||
|
nextCursor,
|
||||||
|
}: CursorPaginationProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const goToNext = () => {
|
||||||
|
if (!nextCursor) return;
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set("cursor", nextCursor);
|
||||||
|
router.push(`?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToFirst = () => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.delete("cursor");
|
||||||
|
router.push(`?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePageSize = (size: number) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set("limit", size.toString());
|
||||||
|
params.delete("cursor");
|
||||||
|
router.push(`?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
|
||||||
|
{/* Page size selector */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">Show</span>
|
||||||
|
<select
|
||||||
|
value={pageSize.toString()}
|
||||||
|
onChange={(e) => changePageSize(Number(e.target.value))}
|
||||||
|
className="w-20 px-3 py-2 text-sm rounded-md border border-input bg-background text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-sm text-muted-foreground">per page</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Count info */}
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Showing {currentCount} items
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToFirst}
|
||||||
|
disabled={!hasPrevPage}
|
||||||
|
>
|
||||||
|
<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="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
First
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToNext}
|
||||||
|
disabled={!hasNextPage}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OffsetPaginationProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OffsetPagination({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
pageSize,
|
||||||
|
totalItems,
|
||||||
|
pageSizeOptions = [20, 50, 100],
|
||||||
|
}: OffsetPaginationProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const goToPage = (page: number) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set("page", page.toString());
|
||||||
|
router.push(`?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePageSize = (size: number) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set("limit", size.toString());
|
||||||
|
params.set("page", "1");
|
||||||
|
router.push(`?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startItem = (currentPage - 1) * pageSize + 1;
|
||||||
|
const endItem = Math.min(currentPage * pageSize, totalItems);
|
||||||
|
|
||||||
|
const getPageNumbers = () => {
|
||||||
|
const pages: (number | string)[] = [];
|
||||||
|
const maxVisiblePages = 5;
|
||||||
|
|
||||||
|
if (totalPages <= maxVisiblePages) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentPage <= 3) {
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
pages.push("...");
|
||||||
|
pages.push(totalPages);
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
pages.push(1);
|
||||||
|
pages.push("...");
|
||||||
|
for (let i = totalPages - 3; i <= totalPages; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pages.push(1);
|
||||||
|
pages.push("...");
|
||||||
|
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
pages.push("...");
|
||||||
|
pages.push(totalPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
|
||||||
|
{/* Page size selector */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">Show</span>
|
||||||
|
<select
|
||||||
|
value={pageSize.toString()}
|
||||||
|
onChange={(e) => changePageSize(Number(e.target.value))}
|
||||||
|
className="w-20 px-3 py-2 text-sm rounded-md border border-input bg-background text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-sm text-muted-foreground">per page</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page info */}
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{startItem}-{endItem} of {totalItems}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page navigation */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
onClick={() => goToPage(currentPage - 1)}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
title="Previous page"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
{getPageNumbers().map((page, index) => (
|
||||||
|
<span key={index}>
|
||||||
|
{page === "..." ? (
|
||||||
|
<span className="px-3 py-2 text-sm text-muted-foreground">...</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant={currentPage === page ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => goToPage(page as number)}
|
||||||
|
className="min-w-[2.5rem]"
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
onClick={() => goToPage(currentPage + 1)}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
title="Next page"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,13 +3,21 @@ interface ProgressBarProps {
|
|||||||
max?: number;
|
max?: number;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
|
variant?: "default" | "success" | "warning" | "error";
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeStyles = {
|
const sizeStyles = {
|
||||||
sm: "h-1.5",
|
sm: "h-1.5",
|
||||||
md: "h-2",
|
md: "h-2",
|
||||||
lg: "h-8",
|
lg: "h-4",
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
default: "bg-primary",
|
||||||
|
success: "bg-success",
|
||||||
|
warning: "bg-warning",
|
||||||
|
error: "bg-destructive",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProgressBar({
|
export function ProgressBar({
|
||||||
@@ -17,18 +25,19 @@ export function ProgressBar({
|
|||||||
max = 100,
|
max = 100,
|
||||||
showLabel = false,
|
showLabel = false,
|
||||||
size = "md",
|
size = "md",
|
||||||
|
variant = "default",
|
||||||
className = ""
|
className = ""
|
||||||
}: ProgressBarProps) {
|
}: ProgressBarProps) {
|
||||||
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${sizeStyles[size]} bg-line rounded-full overflow-hidden ${className}`}>
|
<div className={`relative ${sizeStyles[size]} bg-muted/50 rounded-full overflow-hidden ${className}`}>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 left-0 bg-success rounded-full transition-all duration-300"
|
className={`absolute inset-y-0 left-0 rounded-full transition-all duration-500 ease-out ${variantStyles[variant]}`}
|
||||||
style={{ width: `${percent}%` }}
|
style={{ width: `${percent}%` }}
|
||||||
/>
|
/>
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<span className="absolute inset-0 flex items-center justify-center text-sm font-semibold text-foreground">
|
<span className="absolute inset-0 flex items-center justify-center text-xs font-semibold text-foreground">
|
||||||
{Math.round(percent)}%
|
{Math.round(percent)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -36,21 +45,112 @@ export function ProgressBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mini Progress Bar (for compact displays)
|
||||||
interface MiniProgressBarProps {
|
interface MiniProgressBarProps {
|
||||||
value: number;
|
value: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
|
variant?: "default" | "success" | "warning" | "error";
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MiniProgressBar({ value, max = 100, className = "" }: MiniProgressBarProps) {
|
export function MiniProgressBar({
|
||||||
|
value,
|
||||||
|
max = 100,
|
||||||
|
variant = "default",
|
||||||
|
className = ""
|
||||||
|
}: MiniProgressBarProps) {
|
||||||
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex-1 h-1.5 bg-line rounded-full overflow-hidden ${className}`}>
|
<div className={`flex-1 h-1.5 bg-muted/50 rounded-full overflow-hidden ${className}`}>
|
||||||
<div
|
<div
|
||||||
className="h-full bg-success rounded-full transition-all duration-300"
|
className={`h-full rounded-full transition-all duration-500 ease-out ${variantStyles[variant]}`}
|
||||||
style={{ width: `${percent}%` }}
|
style={{ width: `${percent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Progress indicator with status colors based on percentage
|
||||||
|
interface SmartProgressBarProps {
|
||||||
|
value: number;
|
||||||
|
max?: number;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SmartProgressBar({
|
||||||
|
value,
|
||||||
|
max = 100,
|
||||||
|
size = "md",
|
||||||
|
className = ""
|
||||||
|
}: SmartProgressBarProps) {
|
||||||
|
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
||||||
|
|
||||||
|
// Determine variant based on percentage
|
||||||
|
let variant: "default" | "success" | "warning" | "error" = "default";
|
||||||
|
if (percent === 100) variant = "success";
|
||||||
|
else if (percent < 25) variant = "error";
|
||||||
|
else if (percent < 50) variant = "warning";
|
||||||
|
|
||||||
|
return <ProgressBar value={value} max={max} size={size} variant={variant} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circular Progress (for special use cases)
|
||||||
|
interface CircularProgressProps {
|
||||||
|
value: number;
|
||||||
|
max?: number;
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CircularProgress({
|
||||||
|
value,
|
||||||
|
max = 100,
|
||||||
|
size = 40,
|
||||||
|
strokeWidth = 4,
|
||||||
|
className = ""
|
||||||
|
}: CircularProgressProps) {
|
||||||
|
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = radius * 2 * Math.PI;
|
||||||
|
const offset = circumference - (percent / 100) * circumference;
|
||||||
|
|
||||||
|
// Determine color based on percentage
|
||||||
|
let color = "hsl(var(--color-primary))";
|
||||||
|
if (percent === 100) color = "hsl(var(--color-success))";
|
||||||
|
else if (percent < 25) color = "hsl(var(--color-destructive))";
|
||||||
|
else if (percent < 50) color = "hsl(var(--color-warning))";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative inline-flex items-center justify-center ${className}`} style={{ width: size, height: size }}>
|
||||||
|
<svg className="transform -rotate-90" width={size} height={size}>
|
||||||
|
<circle
|
||||||
|
className="text-muted-foreground"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="transparent"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
r={radius}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
stroke={color}
|
||||||
|
fill="transparent"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
r={radius}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
className="transition-all duration-500 ease-out"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="absolute text-xs font-semibold text-foreground">
|
||||||
|
{Math.round(percent)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ interface StatBoxProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles: Record<string, string> = {
|
const variantStyles: Record<string, string> = {
|
||||||
default: "bg-muted/5",
|
default: "bg-muted/50",
|
||||||
primary: "bg-primary-soft",
|
primary: "bg-primary/10",
|
||||||
success: "bg-success-soft",
|
success: "bg-success/10",
|
||||||
warning: "bg-warning-soft",
|
warning: "bg-warning/10",
|
||||||
error: "bg-error-soft",
|
error: "bg-destructive/10",
|
||||||
};
|
};
|
||||||
|
|
||||||
const valueVariantStyles: Record<string, string> = {
|
const valueVariantStyles: Record<string, string> = {
|
||||||
@@ -20,14 +20,14 @@ const valueVariantStyles: Record<string, string> = {
|
|||||||
primary: "text-primary",
|
primary: "text-primary",
|
||||||
success: "text-success",
|
success: "text-success",
|
||||||
warning: "text-warning",
|
warning: "text-warning",
|
||||||
error: "text-error",
|
error: "text-destructive",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
|
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`text-center p-4 rounded-lg ${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>
|
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span>
|
||||||
<span className={`text-xs ${valueVariantStyles[variant]}/80`}>{label}</span>
|
<span className={`text-xs text-muted-foreground`}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
export { Card, CardHeader } from "./Card";
|
export {
|
||||||
export { Badge, StatusBadge, JobTypeBadge } from "./Badge";
|
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
|
||||||
|
GlassCard, SimpleCard
|
||||||
|
} from "./Card";
|
||||||
|
export {
|
||||||
|
Badge, StatusBadge, JobTypeBadge, ProgressBadge
|
||||||
|
} from "./Badge";
|
||||||
export { StatBox } from "./StatBox";
|
export { StatBox } from "./StatBox";
|
||||||
export { ProgressBar, MiniProgressBar } from "./ProgressBar";
|
export {
|
||||||
export { Button } from "./Button";
|
ProgressBar, MiniProgressBar, SmartProgressBar, CircularProgress
|
||||||
export { Input, Select } from "./Input";
|
} from "./ProgressBar";
|
||||||
export { FormField, FormLabel, FormInput, FormSelect, FormRow } from "./Form";
|
export { Button, IconButton } from "./Button";
|
||||||
export { PageIcon, NavIcon } from "./Icon";
|
export {
|
||||||
|
Input, Select, Textarea, SearchInput
|
||||||
|
} from "./Input";
|
||||||
|
export {
|
||||||
|
FormField, FormLabel, FormInput, FormSelect, FormRow,
|
||||||
|
FormSection, FormError, FormDescription
|
||||||
|
} from "./Form";
|
||||||
|
export { PageIcon, NavIcon, Icon } from "./Icon";
|
||||||
|
export { CursorPagination, OffsetPagination } from "./Pagination";
|
||||||
|
|||||||
@@ -1,14 +1,43 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
/* Core Colors - Light Theme */
|
||||||
--color-background: hsl(36 33% 97%);
|
--color-background: hsl(36 33% 97%);
|
||||||
--color-foreground: hsl(222 33% 15%);
|
--color-foreground: hsl(222 33% 15%);
|
||||||
|
|
||||||
|
/* Card & Surfaces */
|
||||||
--color-card: hsl(0 0% 100%);
|
--color-card: hsl(0 0% 100%);
|
||||||
--color-line: hsl(32 18% 84%);
|
--color-card-foreground: hsl(222 33% 15%);
|
||||||
--color-line-strong: hsl(32 18% 76%);
|
--color-popover: hsl(0 0% 100%);
|
||||||
|
--color-popover-foreground: hsl(222 33% 15%);
|
||||||
|
|
||||||
|
/* Primary - Cyan/Teal */
|
||||||
--color-primary: hsl(198 78% 37%);
|
--color-primary: hsl(198 78% 37%);
|
||||||
|
--color-primary-foreground: hsl(210 40% 98%);
|
||||||
--color-primary-soft: hsl(198 52% 90%);
|
--color-primary-soft: hsl(198 52% 90%);
|
||||||
--color-muted: hsl(220 13% 40%);
|
|
||||||
|
/* Secondary - Warm Gray */
|
||||||
|
--color-secondary: hsl(36 30% 92%);
|
||||||
|
--color-secondary-foreground: hsl(222 33% 15%);
|
||||||
|
|
||||||
|
/* Muted */
|
||||||
|
--color-muted: hsl(36 24% 90%);
|
||||||
|
--color-muted-foreground: hsl(220 13% 40%);
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--color-accent: hsl(198 52% 90%);
|
||||||
|
--color-accent-foreground: hsl(222 33% 15%);
|
||||||
|
|
||||||
|
/* Destructive - Red */
|
||||||
|
--color-destructive: hsl(2 72% 48%);
|
||||||
|
--color-destructive-foreground: hsl(210 40% 98%);
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--color-border: hsl(32 18% 84%);
|
||||||
|
--color-input: hsl(32 18% 84%);
|
||||||
|
--color-ring: hsl(198 78% 37%);
|
||||||
|
|
||||||
|
/* Status Colors */
|
||||||
--color-success: hsl(142 60% 45%);
|
--color-success: hsl(142 60% 45%);
|
||||||
--color-success-soft: hsl(142 60% 90%);
|
--color-success-soft: hsl(142 60% 90%);
|
||||||
--color-warning: hsl(45 93% 47%);
|
--color-warning: hsl(45 93% 47%);
|
||||||
@@ -16,51 +45,159 @@
|
|||||||
--color-error: hsl(2 72% 48%);
|
--color-error: hsl(2 72% 48%);
|
||||||
--color-error-soft: hsl(2 72% 90%);
|
--color-error-soft: hsl(2 72% 90%);
|
||||||
|
|
||||||
--font-sans: "Avenir Next", "Segoe UI", "Noto Sans", system-ui, sans-serif;
|
/* Typography */
|
||||||
|
--font-sans: "Inter", "Avenir Next", "Segoe UI", "Noto Sans", system-ui, sans-serif;
|
||||||
|
--font-display: "Baskerville", "Times New Roman", serif;
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius: 0.75rem;
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
--shadow-soft: 0 1px 2px 0 rgb(23 32 46 / 0.06);
|
--shadow-soft: 0 1px 2px 0 rgb(23 32 46 / 0.06);
|
||||||
--shadow-card: 0 12px 30px -12px rgb(23 32 46 / 0.22);
|
--shadow-card: 0 12px 30px -12px rgb(23 32 46 / 0.22);
|
||||||
|
--shadow-elevation-1: 0 1px 2px 0 rgb(23 32 46 / 0.06);
|
||||||
|
--shadow-elevation-2: 0 8px 24px -8px rgb(23 32 46 / 0.18);
|
||||||
|
|
||||||
|
/* Animation Timing */
|
||||||
|
--duration-fast: 120ms;
|
||||||
|
--duration-base: 200ms;
|
||||||
|
--duration-slow: 320ms;
|
||||||
|
--ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark Theme */
|
||||||
.dark {
|
.dark {
|
||||||
--color-background: hsl(222 35% 10%);
|
--color-background: hsl(222 35% 10%);
|
||||||
--color-foreground: hsl(38 20% 92%);
|
--color-foreground: hsl(38 20% 92%);
|
||||||
--color-card: hsl(221 31% 13%);
|
--color-card: hsl(221 31% 13%);
|
||||||
--color-line: hsl(219 18% 25%);
|
--color-card-foreground: hsl(38 20% 92%);
|
||||||
--color-line-strong: hsl(219 18% 33%);
|
--color-popover: hsl(221 31% 13%);
|
||||||
|
--color-popover-foreground: hsl(38 20% 92%);
|
||||||
--color-primary: hsl(194 76% 62%);
|
--color-primary: hsl(194 76% 62%);
|
||||||
|
--color-primary-foreground: hsl(222 35% 10%);
|
||||||
--color-primary-soft: hsl(210 34% 24%);
|
--color-primary-soft: hsl(210 34% 24%);
|
||||||
--color-muted: hsl(218 17% 72%);
|
--color-secondary: hsl(221 22% 20%);
|
||||||
|
--color-secondary-foreground: hsl(38 20% 92%);
|
||||||
|
--color-muted: hsl(220 17% 24%);
|
||||||
|
--color-muted-foreground: hsl(218 17% 72%);
|
||||||
|
--color-accent: hsl(210 34% 24%);
|
||||||
|
--color-accent-foreground: hsl(38 20% 92%);
|
||||||
|
--color-destructive: hsl(2 80% 65%);
|
||||||
|
--color-destructive-foreground: hsl(210 40% 98%);
|
||||||
|
--color-border: hsl(219 18% 25%);
|
||||||
|
--color-input: hsl(219 18% 25%);
|
||||||
|
--color-ring: hsl(194 76% 62%);
|
||||||
|
--color-success: hsl(142 70% 55%);
|
||||||
|
--color-success-soft: hsl(142 30% 20%);
|
||||||
|
--color-warning: hsl(45 90% 55%);
|
||||||
|
--color-warning-soft: hsl(45 30% 20%);
|
||||||
|
--color-error: hsl(2 80% 65%);
|
||||||
|
--color-error-soft: hsl(2 30% 20%);
|
||||||
|
--shadow-soft: 0 1px 2px 0 rgb(2 8 18 / 0.35);
|
||||||
|
--shadow-card: 0 12px 30px -12px rgb(2 8 18 / 0.55);
|
||||||
|
--shadow-elevation-1: 0 1px 2px 0 rgb(2 8 18 / 0.35);
|
||||||
|
--shadow-elevation-2: 0 12px 30px -12px rgb(2 8 18 / 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base styles */
|
/* Base Styles */
|
||||||
* {
|
* {
|
||||||
border-color: var(--color-line);
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
/* Fond décoratif avec dégradés marqués */
|
||||||
::-webkit-scrollbar {
|
body::before {
|
||||||
width: 8px;
|
content: '';
|
||||||
height: 8px;
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 100% 60% at 50% -10%, hsl(198 78% 37% / 0.35), transparent 60%),
|
||||||
|
radial-gradient(ellipse 80% 50% at 90% 100%, hsl(142 60% 45% / 0.25), transparent 50%),
|
||||||
|
radial-gradient(ellipse 70% 40% at 10% 90%, hsl(45 93% 47% / 0.2), transparent 50%),
|
||||||
|
radial-gradient(ellipse 60% 30% at 80% 50%, hsl(280 60% 50% / 0.12), transparent 50%);
|
||||||
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
/* Dark mode - fond plus profond et marqué */
|
||||||
background: transparent;
|
.dark body::before {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 100% 60% at 50% -10%, hsl(194 76% 62% / 0.3), transparent 60%),
|
||||||
|
radial-gradient(ellipse 80% 50% at 90% 100%, hsl(142 70% 55% / 0.22), transparent 50%),
|
||||||
|
radial-gradient(ellipse 70% 40% at 10% 90%, hsl(45 90% 55% / 0.18), transparent 50%),
|
||||||
|
radial-gradient(ellipse 60% 30% at 80% 50%, hsl(280 60% 65% / 0.1), transparent 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
/* Cercles décoratifs floutés plus visibles */
|
||||||
background: var(--color-line);
|
body::after {
|
||||||
border-radius: 4px;
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -2;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle 500px at 5% 15%, hsl(198 78% 37% / 0.15) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 400px at 95% 85%, hsl(142 60% 45% / 0.12) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 350px at 25% 95%, hsl(45 93% 47% / 0.1) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 450px at 85% 5%, hsl(198 78% 37% / 0.12) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 300px at 50% 50%, hsl(280 60% 50% / 0.08) 0%, transparent 70%);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
.dark body::after {
|
||||||
background: var(--color-line-strong);
|
background:
|
||||||
|
radial-gradient(circle 500px at 5% 15%, hsl(194 76% 62% / 0.12) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 400px at 95% 85%, hsl(142 70% 55% / 0.1) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 350px at 25% 95%, hsl(45 90% 55% / 0.08) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 450px at 85% 5%, hsl(194 76% 62% / 0.1) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle 300px at 50% 50%, hsl(280 60% 65% / 0.06) 0%, transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Texture subtile de grain */
|
||||||
|
.bg-grain {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-grain::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
right: -50%;
|
||||||
|
bottom: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.06;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-grain::before {
|
||||||
|
opacity: 0.04;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Selection */
|
/* Selection */
|
||||||
@@ -69,40 +206,54 @@ body {
|
|||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus visible */
|
.dark ::selection {
|
||||||
|
background: hsl(194 76% 62% / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus Visible */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-primary);
|
outline: 2px solid var(--color-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth scrolling */
|
/* Animations */
|
||||||
html {
|
@keyframes fade-in {
|
||||||
scroll-behavior: smooth;
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduced motion */
|
.animate-fade-in {
|
||||||
@media (prefers-reduced-motion: reduce) {
|
animation: fade-in 0.3s ease-in;
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom utilities - use directly in components */
|
/* Line clamp utilities */
|
||||||
.shadow-soft {
|
.line-clamp-1 {
|
||||||
box-shadow: var(--shadow-soft);
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow-card {
|
.line-clamp-2 {
|
||||||
box-shadow: var(--shadow-card);
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode overrides */
|
.line-clamp-3 {
|
||||||
.dark {
|
display: -webkit-box;
|
||||||
--shadow-soft: 0 1px 2px 0 rgb(2 8 18 / 0.35);
|
-webkit-line-clamp: 3;
|
||||||
--shadow-card: 0 12px 30px -12px rgb(2 8 18 / 0.55);
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { apiFetch } from "../../../lib/api";
|
import { apiFetch } from "../../../lib/api";
|
||||||
import { Card, CardHeader, StatusBadge, JobTypeBadge, StatBox, ProgressBar } from "../../components/ui";
|
import {
|
||||||
|
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||||
|
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
||||||
|
} from "../../components/ui";
|
||||||
|
|
||||||
interface JobDetailPageProps {
|
interface JobDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -85,8 +88,14 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link href="/jobs" className="inline-flex items-center text-sm text-muted hover:text-primary transition-colors">
|
<Link
|
||||||
← Back to jobs
|
href="/jobs"
|
||||||
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<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="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to jobs
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
|
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,43 +103,47 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Overview Card */}
|
{/* Overview Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Overview" />
|
<CardHeader>
|
||||||
<div className="space-y-3">
|
<CardTitle>Overview</CardTitle>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
</CardHeader>
|
||||||
<span className="text-sm text-muted">ID</span>
|
<CardContent className="space-y-3">
|
||||||
<code className="px-2 py-1 bg-muted/10 rounded font-mono text-sm text-foreground">{job.id}</code>
|
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||||
|
<span className="text-sm text-muted-foreground">ID</span>
|
||||||
|
<code className="px-2 py-1 bg-muted rounded font-mono text-sm text-foreground">{job.id}</code>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||||
<span className="text-sm text-muted">Type</span>
|
<span className="text-sm text-muted-foreground">Type</span>
|
||||||
<JobTypeBadge type={job.type} />
|
<JobTypeBadge type={job.type} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-line">
|
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||||
<span className="text-sm text-muted">Status</span>
|
<span className="text-sm text-muted-foreground">Status</span>
|
||||||
<StatusBadge status={job.status} />
|
<StatusBadge status={job.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<span className="text-sm text-muted">Library</span>
|
<span className="text-sm text-muted-foreground">Library</span>
|
||||||
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
|
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Timeline Card */}
|
{/* Timeline Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Timeline" />
|
<CardHeader>
|
||||||
<div className="space-y-4">
|
<CardTitle>Timeline</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className={`w-2 h-2 rounded-full mt-2 ${job.created_at ? 'bg-success' : 'bg-muted'}`} />
|
<div className={`w-2 h-2 rounded-full mt-2 ${job.created_at ? 'bg-success' : 'bg-muted'}`} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="text-sm font-medium text-foreground">Created</span>
|
<span className="text-sm font-medium text-foreground">Created</span>
|
||||||
<p className="text-sm text-muted">{new Date(job.created_at).toLocaleString()}</p>
|
<p className="text-sm text-muted-foreground">{new Date(job.created_at).toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className={`w-2 h-2 rounded-full mt-2 ${job.started_at ? 'bg-success' : job.created_at ? 'bg-warning' : 'bg-muted'}`} />
|
<div className={`w-2 h-2 rounded-full mt-2 ${job.started_at ? 'bg-success' : job.created_at ? 'bg-warning' : 'bg-muted'}`} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="text-sm font-medium text-foreground">Started</span>
|
<span className="text-sm font-medium text-foreground">Started</span>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted-foreground">
|
||||||
{job.started_at ? new Date(job.started_at).toLocaleString() : "Pending..."}
|
{job.started_at ? new Date(job.started_at).toLocaleString() : "Pending..."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +152,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<div className={`w-2 h-2 rounded-full mt-2 ${job.finished_at ? 'bg-success' : job.started_at ? 'bg-primary animate-pulse' : 'bg-muted'}`} />
|
<div className={`w-2 h-2 rounded-full mt-2 ${job.finished_at ? 'bg-success' : job.started_at ? 'bg-primary animate-pulse' : 'bg-muted'}`} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="text-sm font-medium text-foreground">Finished</span>
|
<span className="text-sm font-medium text-foreground">Finished</span>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted-foreground">
|
||||||
{job.finished_at
|
{job.finished_at
|
||||||
? new Date(job.finished_at).toLocaleString()
|
? new Date(job.finished_at).toLocaleString()
|
||||||
: job.started_at
|
: job.started_at
|
||||||
@@ -149,41 +162,48 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{job.started_at && (
|
{job.started_at && (
|
||||||
<div className="mt-4 inline-flex items-center px-3 py-1.5 bg-primary-soft text-primary rounded-lg text-sm font-medium">
|
<div className="mt-4 inline-flex items-center px-3 py-1.5 bg-primary/10 text-primary rounded-lg text-sm font-medium">
|
||||||
Duration: {formatDuration(job.started_at, job.finished_at)}
|
Duration: {formatDuration(job.started_at, job.finished_at)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Progress Card */}
|
{/* Progress Card */}
|
||||||
{(job.status === "running" || job.status === "success" || job.status === "failed") && (
|
{(job.status === "running" || job.status === "generating_thumbnails" || job.status === "success" || job.status === "failed") && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Progress" />
|
<CardHeader>
|
||||||
{job.total_files && job.total_files > 0 && (
|
<CardTitle>{job.status === "generating_thumbnails" ? "Thumbnails" : "Progress"}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{job.total_files != null && job.total_files > 0 && (
|
||||||
<>
|
<>
|
||||||
<ProgressBar value={job.progress_percent || 0} showLabel size="lg" className="mb-4" />
|
<ProgressBar value={job.progress_percent || 0} showLabel size="lg" className="mb-4" />
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<StatBox value={job.processed_files || 0} label="Processed" variant="primary" />
|
<StatBox value={job.processed_files ?? 0} label="Processed" variant="primary" />
|
||||||
<StatBox value={job.total_files} label="Total" />
|
<StatBox value={job.total_files} label={job.status === "generating_thumbnails" ? "Total thumbnails" : "Total"} />
|
||||||
<StatBox value={job.total_files - (job.processed_files || 0)} label="Remaining" variant="warning" />
|
<StatBox value={job.total_files - (job.processed_files ?? 0)} label="Remaining" variant="warning" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{job.current_file && (
|
{job.current_file && (
|
||||||
<div className="mt-4 p-3 bg-muted/5 rounded-lg">
|
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||||
<span className="text-sm text-muted">Current file:</span>
|
<span className="text-sm text-muted-foreground">Current file:</span>
|
||||||
<code className="block mt-1 text-xs font-mono text-foreground truncate">{job.current_file}</code>
|
<code className="block mt-1 text-xs font-mono text-foreground truncate">{job.current_file}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Statistics Card */}
|
{/* Statistics Card */}
|
||||||
{job.stats_json && (
|
{job.stats_json && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Statistics" />
|
<CardHeader>
|
||||||
|
<CardTitle>Statistics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
|
||||||
<StatBox value={job.stats_json.scanned_files} label="Scanned" variant="success" />
|
<StatBox value={job.stats_json.scanned_files} label="Scanned" variant="success" />
|
||||||
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
|
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
|
||||||
@@ -191,35 +211,44 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||||
</div>
|
</div>
|
||||||
{job.started_at && (
|
{job.started_at && (
|
||||||
<div className="flex items-center justify-between py-2 border-t border-line">
|
<div className="flex items-center justify-between py-2 border-t border-border/60">
|
||||||
<span className="text-sm text-muted">Speed:</span>
|
<span className="text-sm text-muted-foreground">Speed:</span>
|
||||||
<span className="text-sm font-medium text-foreground">{formatSpeed(job.stats_json, duration)}</span>
|
<span className="text-sm font-medium text-foreground">{formatSpeed(job.stats_json, duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Errors Card */}
|
{/* Errors Card */}
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader title={`Errors (${errors.length})`} />
|
<CardHeader>
|
||||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
<CardTitle>Errors ({errors.length})</CardTitle>
|
||||||
|
<CardDescription>Errors encountered during job execution</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
|
||||||
{errors.map((error) => (
|
{errors.map((error) => (
|
||||||
<div key={error.id} className="p-3 bg-error-soft rounded-lg">
|
<div key={error.id} className="p-3 bg-destructive/10 rounded-lg border border-destructive/20">
|
||||||
<code className="block text-sm font-mono text-error mb-1">{error.file_path}</code>
|
<code className="block text-sm font-mono text-destructive mb-1">{error.file_path}</code>
|
||||||
<p className="text-sm text-error/80">{error.error_message}</p>
|
<p className="text-sm text-destructive/80">{error.error_message}</p>
|
||||||
<span className="text-xs text-muted">{new Date(error.created_at).toLocaleString()}</span>
|
<span className="text-xs text-muted-foreground">{new Date(error.created_at).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{job.error_opt && (
|
{job.error_opt && (
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader title="Error" />
|
<CardHeader>
|
||||||
<pre className="p-4 bg-error-soft rounded-lg text-sm text-error overflow-x-auto">{job.error_opt}</pre>
|
<CardTitle>Error</CardTitle>
|
||||||
|
<CardDescription>Job failed with error</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre className="p-4 bg-destructive/10 rounded-lg text-sm text-destructive overflow-x-auto border border-destructive/20">{job.error_opt}</pre>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { listJobs, fetchLibraries, rebuildIndex, IndexJobDto, LibraryDto } from "../../lib/api";
|
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, IndexJobDto, LibraryDto } from "../../lib/api";
|
||||||
import { JobsList } from "../components/JobsList";
|
import { JobsList } from "../components/JobsList";
|
||||||
import { Card, CardHeader, Button, FormField, FormSelect, FormRow } from "../components/ui";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormSelect, FormRow } from "../components/ui";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -31,17 +31,42 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
redirect(`/jobs?highlight=${result.id}`);
|
redirect(`/jobs?highlight=${result.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function triggerThumbnailsRebuild(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const libraryId = formData.get("library_id") as string;
|
||||||
|
const result = await rebuildThumbnails(libraryId || undefined);
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
redirect(`/jobs?highlight=${result.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerThumbnailsRegenerate(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const libraryId = formData.get("library_id") as string;
|
||||||
|
const result = await regenerateThumbnails(libraryId || undefined);
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
redirect(`/jobs?highlight=${result.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
|
<div className="mb-6">
|
||||||
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
|
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
Index Jobs
|
Index Jobs
|
||||||
</h1>
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Queue New Job</CardTitle>
|
||||||
|
<CardDescription>Rebuild index, full rebuild, generate missing thumbnails, or regenerate all thumbnails</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
<form action={triggerRebuild}>
|
<form action={triggerRebuild}>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<FormField>
|
<FormField className="flex-1">
|
||||||
<FormSelect name="library_id" defaultValue="">
|
<FormSelect name="library_id" defaultValue="">
|
||||||
<option value="">All libraries</option>
|
<option value="">All libraries</option>
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => (
|
||||||
@@ -51,13 +76,18 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
))}
|
))}
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
</FormField>
|
</FormField>
|
||||||
<Button type="submit">🔄 Queue Rebuild</Button>
|
<Button type="submit">
|
||||||
|
<svg className="w-4 h-4 mr-2" 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>
|
||||||
|
Queue Rebuild
|
||||||
|
</Button>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form action={triggerFullRebuild} className="mt-3">
|
<form action={triggerFullRebuild}>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<FormField>
|
<FormField className="flex-1">
|
||||||
<FormSelect name="library_id" defaultValue="">
|
<FormSelect name="library_id" defaultValue="">
|
||||||
<option value="">All libraries</option>
|
<option value="">All libraries</option>
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => (
|
||||||
@@ -67,9 +97,57 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
))}
|
))}
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
</FormField>
|
</FormField>
|
||||||
<Button type="submit" variant="warning">🔁 Full Rebuild</Button>
|
<Button type="submit" variant="warning">
|
||||||
|
<svg className="w-4 h-4 mr-2" 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>
|
||||||
|
Full Rebuild
|
||||||
|
</Button>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form action={triggerThumbnailsRebuild}>
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<FormSelect name="library_id" defaultValue="">
|
||||||
|
<option value="">All libraries</option>
|
||||||
|
{libraries.map((lib) => (
|
||||||
|
<option key={lib.id} value={lib.id}>
|
||||||
|
{lib.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit" variant="secondary">
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
Generate thumbnails
|
||||||
|
</Button>
|
||||||
|
</FormRow>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action={triggerThumbnailsRegenerate}>
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<FormSelect name="library_id" defaultValue="">
|
||||||
|
<option value="">All libraries</option>
|
||||||
|
{libraries.map((lib) => (
|
||||||
|
<option key={lib.id} value={lib.id}>
|
||||||
|
{lib.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit" variant="warning">
|
||||||
|
<svg className="w-4 h-4 mr-2" 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>
|
||||||
|
Regenerate thumbnails
|
||||||
|
</Button>
|
||||||
|
</FormRow>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<JobsList
|
<JobsList
|
||||||
|
|||||||
@@ -6,66 +6,86 @@ import "./globals.css";
|
|||||||
import { ThemeProvider } from "./theme-provider";
|
import { ThemeProvider } from "./theme-provider";
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
import { JobsIndicator } from "./components/JobsIndicator";
|
import { JobsIndicator } from "./components/JobsIndicator";
|
||||||
import { NavIcon } from "./components/ui";
|
import { NavIcon, Icon } from "./components/ui";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Stripstream Backoffice",
|
title: "StripStream Backoffice",
|
||||||
description: "Backoffice administration for Stripstream Librarian"
|
description: "Backoffice administration for StripStream Librarian"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||||
|
label: string;
|
||||||
|
icon: "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "settings";
|
||||||
|
};
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ href: "/", label: "Dashboard", icon: "dashboard" },
|
||||||
|
{ href: "/books", label: "Books", icon: "books" },
|
||||||
|
{ href: "/libraries", label: "Libraries", icon: "libraries" },
|
||||||
|
{ href: "/jobs", label: "Jobs", icon: "jobs" },
|
||||||
|
{ href: "/tokens", label: "Tokens", icon: "tokens" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className="min-h-screen bg-background text-foreground font-sans antialiased">
|
<body className="min-h-screen bg-background text-foreground font-sans antialiased bg-grain">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{/* Navigation */}
|
{/* Header avec effet glassmorphism */}
|
||||||
<nav className="sticky top-0 z-50 w-full border-b border-line bg-card/80 backdrop-blur-md">
|
<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">
|
||||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
<Link href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="Stripstream"
|
alt="StripStream"
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-xl font-bold tracking-tight">StripStream</span>
|
<span className="text-xl font-bold tracking-tight text-foreground">
|
||||||
<span className="text-sm text-muted font-medium">backoffice</span>
|
StripStream
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground font-medium">
|
||||||
|
backoffice
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Navigation Links */}
|
{/* Navigation Links */}
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-2">
|
||||||
<div className="hidden md:flex items-center gap-1">
|
<div className="hidden md:flex items-center gap-1">
|
||||||
<NavLink href="/">
|
{navItems.map((item) => (
|
||||||
<NavIcon name="dashboard" /> Dashboard
|
<NavLink key={item.href} href={item.href}>
|
||||||
</NavLink>
|
<NavIcon name={item.icon} />
|
||||||
<NavLink href="/books">
|
<span className="ml-2">{item.label}</span>
|
||||||
<NavIcon name="books" /> Books
|
|
||||||
</NavLink>
|
|
||||||
<NavLink href="/libraries">
|
|
||||||
<NavIcon name="libraries" /> Libraries
|
|
||||||
</NavLink>
|
|
||||||
<NavLink href="/jobs">
|
|
||||||
<NavIcon name="jobs" /> Jobs
|
|
||||||
</NavLink>
|
|
||||||
<NavLink href="/tokens">
|
|
||||||
<NavIcon name="tokens" /> Tokens
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pl-6 border-l border-line">
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
|
||||||
<JobsIndicator />
|
<JobsIndicator />
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Icon name="settings" size="md" />
|
||||||
|
</Link>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
@@ -75,11 +95,21 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Navigation Link Component
|
// Navigation Link Component
|
||||||
function NavLink({ href, children }: { href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens"; children: React.ReactNode }) {
|
function NavLink({ href, children }: { href: NavItem["href"]; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-foreground/80 hover:text-foreground hover:bg-primary-soft transition-colors"
|
className="
|
||||||
|
flex items-center
|
||||||
|
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}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
79
apps/backoffice/app/libraries/[id]/books/page.tsx
Normal file
79
apps/backoffice/app/libraries/[id]/books/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
|
||||||
|
import { BooksGrid, EmptyState } from "../../../components/BookCard";
|
||||||
|
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
||||||
|
import { CursorPagination } from "../../../components/ui";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function LibraryBooksPage({
|
||||||
|
params,
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const searchParamsAwaited = await searchParams;
|
||||||
|
const cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined;
|
||||||
|
const series = typeof searchParamsAwaited.series === "string" ? searchParamsAwaited.series : undefined;
|
||||||
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||||
|
|
||||||
|
const [library, booksPage] = await Promise.all([
|
||||||
|
fetchLibraries().then(libs => libs.find(l => l.id === id)),
|
||||||
|
fetchBooks(id, series, cursor, limit).catch(() => ({
|
||||||
|
items: [] as BookDto[],
|
||||||
|
next_cursor: null
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!library) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const books = booksPage.items.map(book => ({
|
||||||
|
...book,
|
||||||
|
coverUrl: getBookCoverUrl(book.id)
|
||||||
|
}));
|
||||||
|
const nextCursor = booksPage.next_cursor;
|
||||||
|
|
||||||
|
const seriesDisplayName = series === "unclassified" ? "Unclassified" : series;
|
||||||
|
const hasNextPage = !!nextCursor;
|
||||||
|
const hasPrevPage = !!cursor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<LibrarySubPageHeader
|
||||||
|
library={library}
|
||||||
|
title={series ? `Books in "${seriesDisplayName}"` : "All Books"}
|
||||||
|
icon={
|
||||||
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
iconColor="text-success"
|
||||||
|
filterInfo={series ? {
|
||||||
|
label: `Showing books from series "${seriesDisplayName}"`,
|
||||||
|
clearHref: `/libraries/${id}/books`,
|
||||||
|
clearLabel: "View all books"
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{books.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<BooksGrid books={books} />
|
||||||
|
|
||||||
|
<CursorPagination
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
hasPrevPage={hasPrevPage}
|
||||||
|
pageSize={limit}
|
||||||
|
currentCount={books.length}
|
||||||
|
nextCursor={nextCursor}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState message={series ? `No books in series "${seriesDisplayName}"` : "No books in this library yet"} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
apps/backoffice/app/libraries/[id]/series/page.tsx
Normal file
96
apps/backoffice/app/libraries/[id]/series/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
|
||||||
|
import { CursorPagination } from "../../../components/ui";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function LibrarySeriesPage({
|
||||||
|
params,
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const searchParamsAwaited = await searchParams;
|
||||||
|
const cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined;
|
||||||
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||||
|
|
||||||
|
const [library, seriesPage] = await Promise.all([
|
||||||
|
fetchLibraries().then(libs => libs.find(l => l.id === id)),
|
||||||
|
fetchSeries(id, cursor, limit).catch(() => ({ items: [] as SeriesDto[], next_cursor: null }) as SeriesPageDto)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!library) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = seriesPage.items;
|
||||||
|
const nextCursor = seriesPage.next_cursor;
|
||||||
|
const hasNextPage = !!nextCursor;
|
||||||
|
const hasPrevPage = !!cursor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<LibrarySubPageHeader
|
||||||
|
library={library}
|
||||||
|
title="Series"
|
||||||
|
icon={
|
||||||
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
iconColor="text-primary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{series.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
||||||
|
{series.map((s) => (
|
||||||
|
<Link
|
||||||
|
key={s.name}
|
||||||
|
href={`/libraries/${id}/books?series=${encodeURIComponent(s.name)}`}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200">
|
||||||
|
<div className="aspect-[2/3] relative bg-muted/50">
|
||||||
|
<Image
|
||||||
|
src={getBookCoverUrl(s.first_book_id)}
|
||||||
|
alt={`Cover of ${s.name}`}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||||
|
{s.name === "unclassified" ? "Unclassified" : s.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CursorPagination
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
hasPrevPage={hasPrevPage}
|
||||||
|
pageSize={limit}
|
||||||
|
currentCount={series.length}
|
||||||
|
nextCursor={nextCursor}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<p>No series found in this library</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
apps/backoffice/app/libraries/page.tsx
Normal file
193
apps/backoffice/app/libraries/page.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, LibraryDto, FolderItem } from "../../lib/api";
|
||||||
|
import { LibraryActions } from "../components/LibraryActions";
|
||||||
|
import { LibraryForm } from "../components/LibraryForm";
|
||||||
|
import {
|
||||||
|
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||||
|
Button, Badge
|
||||||
|
} from "../components/ui";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function formatNextScan(nextScanAt: string | null): string {
|
||||||
|
if (!nextScanAt) return "-";
|
||||||
|
const date = new Date(nextScanAt);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = date.getTime() - now.getTime();
|
||||||
|
|
||||||
|
if (diff < 0) return "Due now";
|
||||||
|
if (diff < 60000) return "< 1 min";
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
|
||||||
|
return `${Math.floor(diff / 86400000)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LibrariesPage() {
|
||||||
|
const [libraries, folders] = await Promise.all([
|
||||||
|
fetchLibraries().catch(() => [] as LibraryDto[]),
|
||||||
|
listFolders().catch(() => [] as FolderItem[])
|
||||||
|
]);
|
||||||
|
|
||||||
|
const seriesCounts = await Promise.all(
|
||||||
|
libraries.map(async (lib) => {
|
||||||
|
try {
|
||||||
|
const seriesPage = await fetchSeries(lib.id);
|
||||||
|
return { id: lib.id, count: seriesPage.items.length };
|
||||||
|
} catch {
|
||||||
|
return { id: lib.id, count: 0 };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const seriesCountMap = new Map(seriesCounts.map(s => [s.id, s.count]));
|
||||||
|
|
||||||
|
async function addLibrary(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const name = formData.get("name") as string;
|
||||||
|
const rootPath = formData.get("root_path") as string;
|
||||||
|
if (name && rootPath) {
|
||||||
|
await createLibrary(name, rootPath);
|
||||||
|
revalidatePath("/libraries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLibrary(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
await deleteLibrary(id);
|
||||||
|
revalidatePath("/libraries");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanLibraryAction(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
await scanLibrary(id);
|
||||||
|
revalidatePath("/libraries");
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanLibraryFullAction(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
await scanLibrary(id, true);
|
||||||
|
revalidatePath("/libraries");
|
||||||
|
revalidatePath("/jobs");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
|
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
Libraries
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Library Form */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add New Library</CardTitle>
|
||||||
|
<CardDescription>Create a new library from an existing folder</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<LibraryForm initialFolders={folders} action={addLibrary} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Libraries Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{libraries.map((lib) => {
|
||||||
|
const seriesCount = seriesCountMap.get(lib.id) || 0;
|
||||||
|
return (
|
||||||
|
<Card key={lib.id} className="flex flex-col">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{lib.name}</CardTitle>
|
||||||
|
{!lib.enabled && <Badge variant="muted" className="mt-1">Disabled</Badge>}
|
||||||
|
</div>
|
||||||
|
<LibraryActions
|
||||||
|
libraryId={lib.id}
|
||||||
|
monitorEnabled={lib.monitor_enabled}
|
||||||
|
scanMode={lib.scan_mode}
|
||||||
|
watcherEnabled={lib.watcher_enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 pt-0">
|
||||||
|
{/* Path */}
|
||||||
|
<code className="text-xs font-mono text-muted-foreground mb-4 break-all block">{lib.root_path}</code>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
|
<Link
|
||||||
|
href={`/libraries/${lib.id}/books`}
|
||||||
|
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Books</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/libraries/${lib.id}/series`}
|
||||||
|
className="text-center p-3 bg-muted/50 rounded-lg hover:bg-accent transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<span className="block text-2xl font-bold text-foreground">{seriesCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Series</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="flex items-center gap-3 mb-4 text-sm">
|
||||||
|
<span className={`flex items-center gap-1 ${lib.monitor_enabled ? 'text-success' : 'text-muted-foreground'}`}>
|
||||||
|
{lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? 'Auto' : 'Manual'}
|
||||||
|
</span>
|
||||||
|
{lib.watcher_enabled && (
|
||||||
|
<span className="text-warning" title="File watcher active">⚡</span>
|
||||||
|
)}
|
||||||
|
{lib.monitor_enabled && lib.next_scan_at && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto">
|
||||||
|
Next: {formatNextScan(lib.next_scan_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<form className="flex-1">
|
||||||
|
<input type="hidden" name="id" value={lib.id} />
|
||||||
|
<Button type="submit" variant="default" size="sm" className="w-full" formAction={scanLibraryAction}>
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
Index
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<form className="flex-1">
|
||||||
|
<input type="hidden" name="id" value={lib.id} />
|
||||||
|
<Button type="submit" variant="secondary" size="sm" className="w-full" formAction={scanLibraryFullAction}>
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
Full
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<form>
|
||||||
|
<input type="hidden" name="id" value={lib.id} />
|
||||||
|
<Button type="submit" variant="destructive" size="sm" formAction={removeLibrary}>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,76 +1,76 @@
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-4xl font-bold tracking-tight mb-4">
|
<h1 className="text-4xl font-bold tracking-tight mb-4 text-foreground">
|
||||||
Stripstream Backoffice
|
StripStream Backoffice
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted max-w-2xl mx-auto">
|
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||||
Manage libraries, indexing jobs, and API tokens from a modern admin interface.
|
Manage libraries, indexing jobs, and API tokens from a modern admin interface.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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-4 gap-6">
|
||||||
{/* Libraries Card */}
|
{/* Libraries Card */}
|
||||||
<a
|
<a
|
||||||
href="/libraries"
|
href="/libraries"
|
||||||
className="group p-6 bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all"
|
className="group p-6 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-lg hover:-translate-y-1 hover:bg-card/95 hover:border-border/80 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 bg-primary-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-primary transition-colors">
|
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-primary transition-colors duration-200">
|
||||||
<svg className="w-6 h-6 text-primary group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6 text-primary group-hover:text-primary-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-2">Libraries</h2>
|
<h2 className="text-xl font-semibold mb-2 text-foreground">Libraries</h2>
|
||||||
<p className="text-muted text-sm">Manage your comic libraries and folders</p>
|
<p className="text-muted-foreground text-sm leading-relaxed">Manage your comic libraries and folders</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Books Card */}
|
{/* Books Card */}
|
||||||
<a
|
<a
|
||||||
href="/books"
|
href="/books"
|
||||||
className="group p-6 bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all"
|
className="group p-6 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-lg hover:-translate-y-1 hover:bg-card/95 hover:border-border/80 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 bg-success-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-success transition-colors">
|
<div className="w-12 h-12 bg-success/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-success transition-colors duration-200">
|
||||||
<svg className="w-6 h-6 text-success group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6 text-success group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-2">Books</h2>
|
<h2 className="text-xl font-semibold mb-2 text-foreground">Books</h2>
|
||||||
<p className="text-muted text-sm">Browse and search your comic collection</p>
|
<p className="text-muted-foreground text-sm leading-relaxed">Browse and search your comic collection</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Jobs Card */}
|
{/* Jobs Card */}
|
||||||
<a
|
<a
|
||||||
href="/jobs"
|
href="/jobs"
|
||||||
className="group p-6 bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all"
|
className="group p-6 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-lg hover:-translate-y-1 hover:bg-card/95 hover:border-border/80 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 bg-warning-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-warning transition-colors">
|
<div className="w-12 h-12 bg-warning/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-warning transition-colors duration-200">
|
||||||
<svg className="w-6 h-6 text-warning group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6 text-warning group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-2">Jobs</h2>
|
<h2 className="text-xl font-semibold mb-2 text-foreground">Jobs</h2>
|
||||||
<p className="text-muted text-sm">Monitor indexing jobs and progress</p>
|
<p className="text-muted-foreground text-sm leading-relaxed">Monitor indexing jobs and progress</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Tokens Card */}
|
{/* Tokens Card */}
|
||||||
<a
|
<a
|
||||||
href="/tokens"
|
href="/tokens"
|
||||||
className="group p-6 bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all"
|
className="group p-6 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-lg hover:-translate-y-1 hover:bg-card/95 hover:border-border/80 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 bg-error-soft rounded-lg flex items-center justify-center mb-4 group-hover:bg-error transition-colors">
|
<div className="w-12 h-12 bg-destructive/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-destructive transition-colors duration-200">
|
||||||
<svg className="w-6 h-6 text-error group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6 text-destructive group-hover:text-destructive-foreground" 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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-2">Tokens</h2>
|
<h2 className="text-xl font-semibold mb-2 text-foreground">Tokens</h2>
|
||||||
<p className="text-muted text-sm">Manage API authentication tokens</p>
|
<p className="text-muted-foreground text-sm leading-relaxed">Manage API authentication tokens</p>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 p-6 bg-primary-soft rounded-xl border border-primary/20">
|
<div className="mt-12 p-6 bg-primary/5 backdrop-blur-sm rounded-xl border border-primary/20 hover:bg-primary/8 hover:border-primary/30 transition-all duration-300">
|
||||||
<h2 className="text-lg font-semibold mb-2 text-primary">Getting Started</h2>
|
<h2 className="text-lg font-semibold mb-2 text-primary">Getting Started</h2>
|
||||||
<p className="text-muted">
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
Start by creating a library from your comic folders, then trigger an index job to scan your collection.
|
Start by creating a library from your comic folders, then trigger an index job to scan your collection.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
434
apps/backoffice/app/settings/SettingsPage.tsx
Normal file
434
apps/backoffice/app/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
|
||||||
|
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats } from "../../lib/api";
|
||||||
|
|
||||||
|
interface SettingsPageProps {
|
||||||
|
initialSettings: Settings;
|
||||||
|
initialCacheStats: CacheStats;
|
||||||
|
initialThumbnailStats: ThumbnailStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage({ initialSettings, initialCacheStats, initialThumbnailStats }: SettingsPageProps) {
|
||||||
|
const [settings, setSettings] = useState<Settings>({
|
||||||
|
...initialSettings,
|
||||||
|
thumbnail: initialSettings.thumbnail || { enabled: true, width: 300, height: 400, quality: 80, format: "webp", directory: "/data/thumbnails" }
|
||||||
|
});
|
||||||
|
const [cacheStats, setCacheStats] = useState<CacheStats>(initialCacheStats);
|
||||||
|
const [thumbnailStats, setThumbnailStats] = useState<ThumbnailStats>(initialThumbnailStats);
|
||||||
|
const [isClearing, setIsClearing] = useState(false);
|
||||||
|
const [clearResult, setClearResult] = useState<ClearCacheResponse | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleUpdateSetting(key: string, value: unknown) {
|
||||||
|
setIsSaving(true);
|
||||||
|
setSaveMessage(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/settings/${key}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ value })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
setSaveMessage("Settings saved successfully");
|
||||||
|
setTimeout(() => setSaveMessage(null), 3000);
|
||||||
|
} else {
|
||||||
|
setSaveMessage("Failed to save settings");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSaveMessage("Error saving settings");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClearCache() {
|
||||||
|
setIsClearing(true);
|
||||||
|
setClearResult(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/settings/cache/clear", { method: "POST" });
|
||||||
|
const result = await response.json();
|
||||||
|
setClearResult(result);
|
||||||
|
// Refresh cache stats
|
||||||
|
const statsResponse = await fetch("/api/settings/cache/stats");
|
||||||
|
if (statsResponse.ok) {
|
||||||
|
const stats = await statsResponse.json();
|
||||||
|
setCacheStats(stats);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setClearResult({ success: false, message: "Failed to clear cache" });
|
||||||
|
} finally {
|
||||||
|
setIsClearing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
|
<Icon name="settings" size="xl" />
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveMessage && (
|
||||||
|
<Card className="mb-6 border-success/50 bg-success/5">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-success">{saveMessage}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image Processing Settings */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icon name="image" size="md" />
|
||||||
|
Image Processing
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Configure how images are processed and compressed</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Output Format</label>
|
||||||
|
<FormSelect
|
||||||
|
value={settings.image_processing.format}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSettings = { ...settings, image_processing: { ...settings.image_processing, format: e.target.value } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
handleUpdateSetting("image_processing", newSettings.image_processing);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="webp">WebP (Recommended)</option>
|
||||||
|
<option value="jpeg">JPEG</option>
|
||||||
|
<option value="png">PNG</option>
|
||||||
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Quality (1-100)</label>
|
||||||
|
<FormInput
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={settings.image_processing.quality}
|
||||||
|
onChange={(e) => {
|
||||||
|
const quality = parseInt(e.target.value) || 85;
|
||||||
|
const newSettings = { ...settings, image_processing: { ...settings.image_processing, quality } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateSetting("image_processing", settings.image_processing)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Resize Filter</label>
|
||||||
|
<FormSelect
|
||||||
|
value={settings.image_processing.filter}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSettings = { ...settings, image_processing: { ...settings.image_processing, filter: e.target.value } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
handleUpdateSetting("image_processing", newSettings.image_processing);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="lanczos3">Lanczos3 (Best Quality)</option>
|
||||||
|
<option value="triangle">Triangle (Faster)</option>
|
||||||
|
<option value="nearest">Nearest (Fastest)</option>
|
||||||
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Max Width (px)</label>
|
||||||
|
<FormInput
|
||||||
|
type="number"
|
||||||
|
min={100}
|
||||||
|
max={2160}
|
||||||
|
value={settings.image_processing.max_width}
|
||||||
|
onChange={(e) => {
|
||||||
|
const max_width = parseInt(e.target.value) || 2160;
|
||||||
|
const newSettings = { ...settings, image_processing: { ...settings.image_processing, max_width } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateSetting("image_processing", settings.image_processing)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Cache Settings */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icon name="cache" size="md" />
|
||||||
|
Cache
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Manage the image cache and storage</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Cache Size</p>
|
||||||
|
<p className="text-2xl font-semibold">{cacheStats.total_size_mb.toFixed(2)} MB</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Files</p>
|
||||||
|
<p className="text-2xl font-semibold">{cacheStats.file_count}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Directory</p>
|
||||||
|
<p className="text-sm font-mono truncate" title={cacheStats.directory}>{cacheStats.directory}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{clearResult && (
|
||||||
|
<div className={`p-3 rounded-lg ${clearResult.success ? 'bg-success/10 text-success' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{clearResult.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Cache Directory</label>
|
||||||
|
<FormInput
|
||||||
|
value={settings.cache.directory}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSettings = { ...settings, cache: { ...settings.cache, directory: e.target.value } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateSetting("cache", settings.cache)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField className="w-32">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Max Size (MB)</label>
|
||||||
|
<FormInput
|
||||||
|
type="number"
|
||||||
|
value={settings.cache.max_size_mb}
|
||||||
|
onChange={(e) => {
|
||||||
|
const max_size_mb = parseInt(e.target.value) || 10000;
|
||||||
|
const newSettings = { ...settings, cache: { ...settings.cache, max_size_mb } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateSetting("cache", settings.cache)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleClearCache}
|
||||||
|
disabled={isClearing}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{isClearing ? (
|
||||||
|
<>
|
||||||
|
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
|
||||||
|
Clearing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="trash" size="sm" className="mr-2" />
|
||||||
|
Clear Cache
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Limits Settings */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icon name="performance" size="md" />
|
||||||
|
Performance Limits
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Configure API performance and rate limiting</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Concurrent Renders</label>
|
||||||
|
<FormInput
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={settings.limits.concurrent_renders}
|
||||||
|
onChange={(e) => {
|
||||||
|
const concurrent_renders = parseInt(e.target.value) || 4;
|
||||||
|
const newSettings = { ...settings, limits: { ...settings.limits, concurrent_renders } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateSetting("limits", settings.limits)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Timeout (seconds)</label>
|
||||||
|
<FormInput
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
max={60}
|
||||||
|
value={settings.limits.timeout_seconds}
|
||||||
|
onChange={(e) => {
|
||||||
|
const timeout_seconds = parseInt(e.target.value) || 12;
|
||||||
|
const newSettings = { ...settings, limits: { ...settings.limits, timeout_seconds } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateSetting("limits", settings.limits)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Rate Limit (req/s)</label>
|
||||||
|
<FormInput
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={1000}
|
||||||
|
value={settings.limits.rate_limit_per_second}
|
||||||
|
onChange={(e) => {
|
||||||
|
const rate_limit_per_second = parseInt(e.target.value) || 120;
|
||||||
|
const newSettings = { ...settings, limits: { ...settings.limits, rate_limit_per_second } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateSetting("limits", settings.limits)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Note: Changes to limits require a server restart to take effect.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Thumbnail Settings */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icon name="image" size="md" />
|
||||||
|
Thumbnails
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Configure thumbnail generation during indexing</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Enable Thumbnails</label>
|
||||||
|
<FormSelect
|
||||||
|
value={settings.thumbnail.enabled ? "true" : "false"}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, enabled: e.target.value === "true" } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
handleUpdateSetting("thumbnail", newSettings.thumbnail);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="true">Enabled</option>
|
||||||
|
<option value="false">Disabled</option>
|
||||||
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Output Format</label>
|
||||||
|
<FormSelect
|
||||||
|
value={settings.thumbnail.format}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, format: e.target.value } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
handleUpdateSetting("thumbnail", newSettings.thumbnail);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="webp">WebP (Recommended)</option>
|
||||||
|
<option value="jpeg">JPEG</option>
|
||||||
|
<option value="png">PNG</option>
|
||||||
|
</FormSelect>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Width (px)</label>
|
||||||
|
<FormInput
|
||||||
|
type="number"
|
||||||
|
min={50}
|
||||||
|
max={600}
|
||||||
|
value={settings.thumbnail.width}
|
||||||
|
onChange={(e) => {
|
||||||
|
const width = parseInt(e.target.value) || 300;
|
||||||
|
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, width } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Height (px)</label>
|
||||||
|
<FormInput
|
||||||
|
type="number"
|
||||||
|
min={50}
|
||||||
|
max={800}
|
||||||
|
value={settings.thumbnail.height}
|
||||||
|
onChange={(e) => {
|
||||||
|
const height = parseInt(e.target.value) || 400;
|
||||||
|
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, height } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Quality (1-100)</label>
|
||||||
|
<FormInput
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={settings.thumbnail.quality}
|
||||||
|
onChange={(e) => {
|
||||||
|
const quality = parseInt(e.target.value) || 80;
|
||||||
|
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, quality } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<FormField className="flex-1">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground mb-1 block">Thumbnail Directory</label>
|
||||||
|
<FormInput
|
||||||
|
value={settings.thumbnail.directory}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSettings = { ...settings, thumbnail: { ...settings.thumbnail, directory: e.target.value } };
|
||||||
|
setSettings(newSettings);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateSetting("thumbnail", settings.thumbnail)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4 p-4 bg-muted/30 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Size</p>
|
||||||
|
<p className="text-2xl font-semibold">{thumbnailStats.total_size_mb.toFixed(2)} MB</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Files</p>
|
||||||
|
<p className="text-2xl font-semibold">{thumbnailStats.file_count}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Directory</p>
|
||||||
|
<p className="text-sm font-mono truncate" title={thumbnailStats.directory}>{thumbnailStats.directory}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Note: Thumbnail settings are used during indexing. Existing thumbnails will not be regenerated automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/backoffice/app/settings/page.tsx
Normal file
27
apps/backoffice/app/settings/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { getSettings, getCacheStats, getThumbnailStats } from "../../lib/api";
|
||||||
|
import SettingsPage from "./SettingsPage";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function SettingsPageWrapper() {
|
||||||
|
const settings = await getSettings().catch(() => ({
|
||||||
|
image_processing: { format: "webp", quality: 85, filter: "lanczos3", max_width: 2160 },
|
||||||
|
cache: { enabled: true, directory: "/tmp/stripstream-image-cache", max_size_mb: 10000 },
|
||||||
|
limits: { concurrent_renders: 4, timeout_seconds: 12, rate_limit_per_second: 120 },
|
||||||
|
thumbnail: { enabled: true, width: 300, height: 400, quality: 80, format: "webp", directory: "/data/thumbnails" }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cacheStats = await getCacheStats().catch(() => ({
|
||||||
|
total_size_mb: 0,
|
||||||
|
file_count: 0,
|
||||||
|
directory: "/tmp/stripstream-image-cache"
|
||||||
|
}));
|
||||||
|
|
||||||
|
const thumbnailStats = await getThumbnailStats().catch(() => ({
|
||||||
|
total_size_mb: 0,
|
||||||
|
file_count: 0,
|
||||||
|
directory: "/data/thumbnails"
|
||||||
|
}));
|
||||||
|
|
||||||
|
return <SettingsPage initialSettings={settings} initialCacheStats={cacheStats} initialThumbnailStats={thumbnailStats} />;
|
||||||
|
}
|
||||||
@@ -3,26 +3,127 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
// Sun Icon
|
||||||
|
const SunIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="4" strokeWidth="2" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Moon Icon
|
||||||
|
const MoonIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Monitor Icon (for system)
|
||||||
|
const MonitorIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" strokeWidth="2" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 21h8m-4-4v4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
const { theme, setTheme, resolvedTheme, systemTheme } = useTheme();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-9 w-9 rounded-md flex items-center justify-center text-muted-foreground"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<SunIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const activeTheme = theme === "system" ? resolvedTheme : theme;
|
const activeTheme = theme === "system" ? resolvedTheme : theme;
|
||||||
const nextTheme = activeTheme === "dark" ? "light" : "dark";
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
if (theme === "system") {
|
||||||
|
setTheme(systemTheme === "dark" ? "light" : "dark");
|
||||||
|
} else {
|
||||||
|
setTheme(theme === "dark" ? "light" : "dark");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="theme-toggle"
|
onClick={toggleTheme}
|
||||||
onClick={() => setTheme(nextTheme)}
|
className="h-9 w-9 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent transition-colors duration-200"
|
||||||
aria-label="Toggle color theme"
|
title={activeTheme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
disabled={!mounted}
|
aria-label={activeTheme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
>
|
>
|
||||||
{mounted ? (activeTheme === "dark" ? "Dark" : "Light") : "Theme"}
|
<div className="relative h-4 w-4">
|
||||||
|
<SunIcon
|
||||||
|
className={`absolute inset-0 h-4 w-4 transition-all duration-300 rotate-0 scale-100 ${
|
||||||
|
activeTheme === "dark" ? "rotate-90 scale-0 opacity-0" : "rotate-0 scale-100 opacity-100"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<MoonIcon
|
||||||
|
className={`absolute inset-0 h-4 w-4 transition-all duration-300 -rotate-90 scale-0 ${
|
||||||
|
activeTheme === "dark" ? "rotate-0 scale-100 opacity-100" : "-rotate-90 scale-0 opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full theme selector with dropdown
|
||||||
|
export function ThemeSelector() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<SunIcon className="h-4 w-4" />
|
||||||
|
<span>Theme</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const themes = [
|
||||||
|
{ value: "light", label: "Light", icon: SunIcon },
|
||||||
|
{ value: "dark", label: "Dark", icon: MoonIcon },
|
||||||
|
{ value: "system", label: "System", icon: MonitorIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border border-border bg-background p-1">
|
||||||
|
{themes.map(({ value, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setTheme(value)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-sm font-medium
|
||||||
|
transition-colors duration-200
|
||||||
|
${theme === value
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { listTokens, createToken, revokeToken, TokenDto } from "../../lib/api";
|
import { listTokens, createToken, revokeToken, TokenDto } from "../../lib/api";
|
||||||
import { Card, CardHeader, Button, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -33,68 +33,90 @@ export default async function TokensPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
|
<div className="mb-6">
|
||||||
<svg className="w-8 h-8 text-error" 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>
|
<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>
|
||||||
API Tokens
|
API Tokens
|
||||||
</h1>
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
{params.created ? (
|
{params.created ? (
|
||||||
<Card className="mb-6">
|
<Card className="mb-6 border-success/50 bg-success/5">
|
||||||
<strong className="text-foreground block mb-2">Token created (copy it now, it won't be shown again):</strong>
|
<CardHeader>
|
||||||
<pre className="p-4 bg-muted/10 rounded-lg text-sm font-mono text-foreground overflow-x-auto">{params.created}</pre>
|
<CardTitle className="text-success">Token Created</CardTitle>
|
||||||
|
<CardDescription>Copy it now, it won't be shown again</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>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create New Token</CardTitle>
|
||||||
|
<CardDescription>Generate a new API token with the desired scope</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<form action={createTokenAction}>
|
<form action={createTokenAction}>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<FormField>
|
<FormField className="flex-1 min-w-48">
|
||||||
<FormInput name="name" placeholder="token name" required />
|
<FormInput name="name" placeholder="Token name" required />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField>
|
<FormField className="w-32">
|
||||||
<FormSelect name="scope" defaultValue="read">
|
<FormSelect name="scope" defaultValue="read">
|
||||||
<option value="read">read</option>
|
<option value="read">Read</option>
|
||||||
<option value="admin">admin</option>
|
<option value="admin">Admin</option>
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
</FormField>
|
</FormField>
|
||||||
<Button type="submit">➕ Create Token</Button>
|
<Button type="submit">Create Token</Button>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
</form>
|
</form>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line bg-muted/5">
|
<tr className="border-b border-border/60 bg-muted/50">
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Name</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Name</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Scope</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Scope</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Prefix</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Prefix</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Revoked</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Status</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-line">
|
<tbody className="divide-y divide-border/60">
|
||||||
{tokens.map((token) => (
|
{tokens.map((token) => (
|
||||||
<tr key={token.id} className="hover:bg-muted/5">
|
<tr key={token.id} className="hover:bg-accent/50 transition-colors">
|
||||||
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
|
<td className="px-4 py-3 text-sm text-foreground">{token.name}</td>
|
||||||
<td className="px-4 py-3 text-sm text-foreground">{token.scope}</td>
|
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
<code className="px-2 py-1 bg-muted/10 rounded font-mono text-foreground">{token.prefix}</code>
|
<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>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
{token.revoked_at ? (
|
{token.revoked_at ? (
|
||||||
<span className="inline-flex px-2 py-1 rounded-full text-xs font-semibold bg-error-soft text-error">yes</span>
|
<Badge variant="error">Revoked</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex px-2 py-1 rounded-full text-xs font-semibold bg-success-soft text-success">no</span>
|
<Badge variant="success">Active</Badge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{!token.revoked_at && (
|
{!token.revoked_at && (
|
||||||
<form action={revokeTokenAction}>
|
<form action={revokeTokenAction}>
|
||||||
<input type="hidden" name="id" value={token.id} />
|
<input type="hidden" name="id" value={token.id} />
|
||||||
<Button type="submit" variant="danger" size="sm">
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
🚫 Revoke
|
<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>
|
||||||
|
Revoke
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ export type IndexJobDto = {
|
|||||||
finished_at: string | null;
|
finished_at: string | null;
|
||||||
error_opt: string | null;
|
error_opt: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
stats_json: {
|
||||||
|
scanned_files: number;
|
||||||
|
indexed_files: number;
|
||||||
|
removed_files: number;
|
||||||
|
errors: number;
|
||||||
|
} | null;
|
||||||
|
progress_percent: number | null;
|
||||||
|
processed_files: number | null;
|
||||||
|
total_files: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TokenDto = {
|
export type TokenDto = {
|
||||||
@@ -32,6 +41,8 @@ export type TokenDto = {
|
|||||||
export type FolderItem = {
|
export type FolderItem = {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
depth: number;
|
||||||
|
has_children: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BookDto = {
|
export type BookDto = {
|
||||||
@@ -87,7 +98,10 @@ function config() {
|
|||||||
return { baseUrl: baseUrl.replace(/\/$/, ""), token };
|
return { baseUrl: baseUrl.replace(/\/$/, ""), token };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
export async function apiFetch<T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
const { baseUrl, token } = config();
|
const { baseUrl, token } = config();
|
||||||
const headers = new Headers(init?.headers || {});
|
const headers = new Headers(init?.headers || {});
|
||||||
headers.set("Authorization", `Bearer ${token}`);
|
headers.set("Authorization", `Bearer ${token}`);
|
||||||
@@ -98,7 +112,7 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
|
|||||||
const res = await fetch(`${baseUrl}${path}`, {
|
const res = await fetch(`${baseUrl}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers,
|
headers,
|
||||||
cache: "no-store"
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -119,7 +133,7 @@ export async function fetchLibraries() {
|
|||||||
export async function createLibrary(name: string, rootPath: string) {
|
export async function createLibrary(name: string, rootPath: string) {
|
||||||
return apiFetch<LibraryDto>("/libraries", {
|
return apiFetch<LibraryDto>("/libraries", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ name, root_path: rootPath })
|
body: JSON.stringify({ name, root_path: rootPath }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,12 +146,21 @@ export async function scanLibrary(libraryId: string, full?: boolean) {
|
|||||||
if (full) body.full = true;
|
if (full) body.full = true;
|
||||||
return apiFetch<IndexJobDto>(`/libraries/${libraryId}/scan`, {
|
return apiFetch<IndexJobDto>(`/libraries/${libraryId}/scan`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateLibraryMonitoring(libraryId: string, monitorEnabled: boolean, scanMode: string, watcherEnabled?: boolean) {
|
export async function updateLibraryMonitoring(
|
||||||
const body: { monitor_enabled: boolean; scan_mode: string; watcher_enabled?: boolean } = {
|
libraryId: string,
|
||||||
|
monitorEnabled: boolean,
|
||||||
|
scanMode: string,
|
||||||
|
watcherEnabled?: boolean,
|
||||||
|
) {
|
||||||
|
const body: {
|
||||||
|
monitor_enabled: boolean;
|
||||||
|
scan_mode: string;
|
||||||
|
watcher_enabled?: boolean;
|
||||||
|
} = {
|
||||||
monitor_enabled: monitorEnabled,
|
monitor_enabled: monitorEnabled,
|
||||||
scan_mode: scanMode,
|
scan_mode: scanMode,
|
||||||
};
|
};
|
||||||
@@ -146,7 +169,7 @@ export async function updateLibraryMonitoring(libraryId: string, monitorEnabled:
|
|||||||
}
|
}
|
||||||
return apiFetch<LibraryDto>(`/libraries/${libraryId}/monitoring`, {
|
return apiFetch<LibraryDto>(`/libraries/${libraryId}/monitoring`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +183,25 @@ export async function rebuildIndex(libraryId?: string, full?: boolean) {
|
|||||||
if (full) body.full = true;
|
if (full) body.full = true;
|
||||||
return apiFetch<IndexJobDto>("/index/rebuild", {
|
return apiFetch<IndexJobDto>("/index/rebuild", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rebuildThumbnails(libraryId?: string) {
|
||||||
|
const body: { library_id?: string } = {};
|
||||||
|
if (libraryId) body.library_id = libraryId;
|
||||||
|
return apiFetch<IndexJobDto>("/index/thumbnails/rebuild", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function regenerateThumbnails(libraryId?: string) {
|
||||||
|
const body: { library_id?: string } = {};
|
||||||
|
if (libraryId) body.library_id = libraryId;
|
||||||
|
return apiFetch<IndexJobDto>("/index/thumbnails/regenerate", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +209,9 @@ export async function cancelJob(id: string) {
|
|||||||
return apiFetch<IndexJobDto>(`/index/cancel/${id}`, { method: "POST" });
|
return apiFetch<IndexJobDto>(`/index/cancel/${id}`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listFolders() {
|
export async function listFolders(path?: string) {
|
||||||
return apiFetch<FolderItem[]>("/folders");
|
const url = path ? `/folders?path=${encodeURIComponent(path)}` : "/folders";
|
||||||
|
return apiFetch<FolderItem[]>(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listTokens() {
|
export async function listTokens() {
|
||||||
@@ -179,7 +221,7 @@ export async function listTokens() {
|
|||||||
export async function createToken(name: string, scope: string) {
|
export async function createToken(name: string, scope: 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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +229,12 @@ export async function revokeToken(id: string) {
|
|||||||
return apiFetch<void>(`/admin/tokens/${id}`, { method: "DELETE" });
|
return apiFetch<void>(`/admin/tokens/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchBooks(libraryId?: string, series?: string, cursor?: string, limit: number = 50): Promise<BooksPageDto> {
|
export async function fetchBooks(
|
||||||
|
libraryId?: string,
|
||||||
|
series?: string,
|
||||||
|
cursor?: string,
|
||||||
|
limit: number = 50,
|
||||||
|
): Promise<BooksPageDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (libraryId) params.set("library_id", libraryId);
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
if (series) params.set("series", series);
|
if (series) params.set("series", series);
|
||||||
@@ -197,11 +244,30 @@ export async function fetchBooks(libraryId?: string, series?: string, cursor?: s
|
|||||||
return apiFetch<BooksPageDto>(`/books?${params.toString()}`);
|
return apiFetch<BooksPageDto>(`/books?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSeries(libraryId: string): Promise<SeriesDto[]> {
|
export type SeriesPageDto = {
|
||||||
return apiFetch<SeriesDto[]>(`/libraries/${libraryId}/series`);
|
items: SeriesDto[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchSeries(
|
||||||
|
libraryId: string,
|
||||||
|
cursor?: string,
|
||||||
|
limit: number = 50,
|
||||||
|
): Promise<SeriesPageDto> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (cursor) params.set("cursor", cursor);
|
||||||
|
params.set("limit", limit.toString());
|
||||||
|
|
||||||
|
return apiFetch<SeriesPageDto>(
|
||||||
|
`/libraries/${libraryId}/series?${params.toString()}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchBooks(query: string, libraryId?: string, limit: number = 20): Promise<SearchResponseDto> {
|
export async function searchBooks(
|
||||||
|
query: string,
|
||||||
|
libraryId?: string,
|
||||||
|
limit: number = 20,
|
||||||
|
): Promise<SearchResponseDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("q", query);
|
params.set("q", query);
|
||||||
if (libraryId) params.set("library_id", libraryId);
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
@@ -211,7 +277,74 @@ export async function searchBooks(query: string, libraryId?: string, limit: numb
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getBookCoverUrl(bookId: string): string {
|
export function getBookCoverUrl(bookId: string): string {
|
||||||
// Utiliser une route API locale pour éviter les problèmes CORS
|
return `/api/books/${bookId}/thumbnail`;
|
||||||
// Le navigateur ne peut pas accéder à http://api:8080 (hostname Docker interne)
|
}
|
||||||
return `/api/books/${bookId}/pages/1?format=webp&width=200`;
|
|
||||||
|
export type Settings = {
|
||||||
|
image_processing: {
|
||||||
|
format: string;
|
||||||
|
quality: number;
|
||||||
|
filter: string;
|
||||||
|
max_width: number;
|
||||||
|
};
|
||||||
|
cache: {
|
||||||
|
enabled: boolean;
|
||||||
|
directory: string;
|
||||||
|
max_size_mb: number;
|
||||||
|
};
|
||||||
|
limits: {
|
||||||
|
concurrent_renders: number;
|
||||||
|
timeout_seconds: number;
|
||||||
|
rate_limit_per_second: number;
|
||||||
|
};
|
||||||
|
thumbnail: {
|
||||||
|
enabled: boolean;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
quality: number;
|
||||||
|
format: string;
|
||||||
|
directory: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CacheStats = {
|
||||||
|
total_size_mb: number;
|
||||||
|
file_count: number;
|
||||||
|
directory: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClearCacheResponse = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThumbnailStats = {
|
||||||
|
total_size_mb: number;
|
||||||
|
file_count: number;
|
||||||
|
directory: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getSettings() {
|
||||||
|
return apiFetch<Settings>("/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSetting(key: string, value: unknown) {
|
||||||
|
return apiFetch<unknown>(`/settings/${key}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ value }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCacheStats() {
|
||||||
|
return apiFetch<CacheStats>("/settings/cache/stats");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCache() {
|
||||||
|
return apiFetch<ClearCacheResponse>("/settings/cache/clear", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getThumbnailStats() {
|
||||||
|
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats");
|
||||||
}
|
}
|
||||||
|
|||||||
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/dev/types/routes.d.ts";
|
import "./.next/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.
|
||||||
|
|||||||
677
apps/backoffice/package-lock.json
generated
677
apps/backoffice/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@types/node": "22.13.14",
|
"@types/node": "22.13.14",
|
||||||
"@types/react": "19.0.12",
|
"@types/react": "19.0.12",
|
||||||
"@types/react-dom": "19.0.5",
|
"@types/react-dom": "19.0.5",
|
||||||
@@ -23,6 +24,19 @@
|
|||||||
"typescript": "5.8.2"
|
"typescript": "5.8.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||||
@@ -547,6 +561,56 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
|
"version": "0.3.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/remapping": {
|
||||||
|
"version": "2.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.1.6",
|
"version": "16.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
|
||||||
@@ -702,6 +766,289 @@
|
|||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/node": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
|
"enhanced-resolve": "^5.19.0",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"lightningcss": "1.31.1",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"source-map-js": "^1.2.1",
|
||||||
|
"tailwindcss": "4.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-android-arm64": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-darwin-x64": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
|
||||||
|
"bundleDependencies": [
|
||||||
|
"@napi-rs/wasm-runtime",
|
||||||
|
"@emnapi/core",
|
||||||
|
"@emnapi/runtime",
|
||||||
|
"@tybys/wasm-util",
|
||||||
|
"@emnapi/wasi-threads",
|
||||||
|
"tslib"
|
||||||
|
],
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.8.1",
|
||||||
|
"@emnapi/runtime": "^1.8.1",
|
||||||
|
"@emnapi/wasi-threads": "^1.1.0",
|
||||||
|
"@napi-rs/wasm-runtime": "^1.1.1",
|
||||||
|
"@tybys/wasm-util": "^0.10.1",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/postcss": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
|
"@tailwindcss/node": "4.2.1",
|
||||||
|
"@tailwindcss/oxide": "4.2.1",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "4.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -852,8 +1199,8 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -865,6 +1212,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/enhanced-resolve": {
|
||||||
|
"version": "5.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||||
|
"integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.4",
|
||||||
|
"tapable": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -889,6 +1250,306 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-android-arm64": "1.31.1",
|
||||||
|
"lightningcss-darwin-arm64": "1.31.1",
|
||||||
|
"lightningcss-darwin-x64": "1.31.1",
|
||||||
|
"lightningcss-freebsd-x64": "1.31.1",
|
||||||
|
"lightningcss-linux-arm-gnueabihf": "1.31.1",
|
||||||
|
"lightningcss-linux-arm64-gnu": "1.31.1",
|
||||||
|
"lightningcss-linux-arm64-musl": "1.31.1",
|
||||||
|
"lightningcss-linux-x64-gnu": "1.31.1",
|
||||||
|
"lightningcss-linux-x64-musl": "1.31.1",
|
||||||
|
"lightningcss-win32-arm64-msvc": "1.31.1",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.31.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
|
||||||
|
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -1171,6 +1832,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tapable": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/webpack"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 8082",
|
"dev": "next dev -p 7082",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 8082"
|
"start": "next start -p 7082"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@types/node": "22.13.14",
|
"@types/node": "22.13.14",
|
||||||
"@types/react": "19.0.12",
|
"@types/react": "19.0.12",
|
||||||
"@types/react-dom": "19.0.5",
|
"@types/react-dom": "19.0.5",
|
||||||
|
|||||||
1
apps/backoffice/tsconfig.tsbuildinfo
Normal file
1
apps/backoffice/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@@ -10,6 +10,8 @@ axum.workspace = true
|
|||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
notify = "6.1"
|
notify = "6.1"
|
||||||
parsers = { path = "../../crates/parsers" }
|
parsers = { path = "../../crates/parsers" }
|
||||||
|
rand.workspace = true
|
||||||
|
rayon.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
FROM rust:1-bookworm AS builder
|
FROM rust:1-bookworm AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install sccache for faster builds
|
||||||
|
RUN cargo install sccache --locked
|
||||||
|
ENV RUSTC_WRAPPER=sccache
|
||||||
|
ENV SCCACHE_DIR=/sccache
|
||||||
|
|
||||||
COPY Cargo.toml ./
|
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 apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
|
|
||||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||||
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 apps/admin-ui/src apps/admin-ui/src
|
|
||||||
COPY crates/core/src crates/core/src
|
COPY crates/core/src crates/core/src
|
||||||
COPY crates/parsers/src crates/parsers/src
|
COPY crates/parsers/src crates/parsers/src
|
||||||
|
|
||||||
RUN cargo build --release -p indexer
|
# Build with sccache (cache persisted between builds via Docker cache mount)
|
||||||
|
RUN --mount=type=cache,target=/sccache \
|
||||||
|
cargo build --release -p indexer
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget unrar-free && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget unrar-free && rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,10 +12,12 @@ pub struct ApiConfig {
|
|||||||
impl ApiConfig {
|
impl ApiConfig {
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
listen_addr: std::env::var("API_LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()),
|
listen_addr: std::env::var("API_LISTEN_ADDR")
|
||||||
|
.unwrap_or_else(|_| "0.0.0.0:8080".to_string()),
|
||||||
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL is required")?,
|
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL is required")?,
|
||||||
meili_url: std::env::var("MEILI_URL").context("MEILI_URL is required")?,
|
meili_url: std::env::var("MEILI_URL").context("MEILI_URL is required")?,
|
||||||
meili_master_key: std::env::var("MEILI_MASTER_KEY").context("MEILI_MASTER_KEY is required")?,
|
meili_master_key: std::env::var("MEILI_MASTER_KEY")
|
||||||
|
.context("MEILI_MASTER_KEY is required")?,
|
||||||
api_bootstrap_token: std::env::var("API_BOOTSTRAP_TOKEN")
|
api_bootstrap_token: std::env::var("API_BOOTSTRAP_TOKEN")
|
||||||
.context("API_BOOTSTRAP_TOKEN is required")?,
|
.context("API_BOOTSTRAP_TOKEN is required")?,
|
||||||
})
|
})
|
||||||
@@ -29,20 +31,76 @@ pub struct IndexerConfig {
|
|||||||
pub meili_url: String,
|
pub meili_url: String,
|
||||||
pub meili_master_key: String,
|
pub meili_master_key: String,
|
||||||
pub scan_interval_seconds: u64,
|
pub scan_interval_seconds: u64,
|
||||||
|
pub thumbnail_config: ThumbnailConfig,
|
||||||
|
/// API base URL for thumbnail checkup at end of build (e.g. http://api:8080)
|
||||||
|
pub api_base_url: String,
|
||||||
|
/// Token to call API (e.g. API_BOOTSTRAP_TOKEN)
|
||||||
|
pub api_bootstrap_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ThumbnailConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub quality: u8,
|
||||||
|
pub format: String,
|
||||||
|
pub directory: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ThumbnailConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
width: 300,
|
||||||
|
height: 400,
|
||||||
|
quality: 80,
|
||||||
|
format: "webp".to_string(),
|
||||||
|
directory: "/data/thumbnails".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IndexerConfig {
|
impl IndexerConfig {
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
|
let thumbnail_config = ThumbnailConfig {
|
||||||
|
enabled: std::env::var("THUMBNAIL_ENABLED")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse::<bool>().ok())
|
||||||
|
.unwrap_or(true),
|
||||||
|
width: std::env::var("THUMBNAIL_WIDTH")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse::<u32>().ok())
|
||||||
|
.unwrap_or(300),
|
||||||
|
height: std::env::var("THUMBNAIL_HEIGHT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse::<u32>().ok())
|
||||||
|
.unwrap_or(400),
|
||||||
|
quality: std::env::var("THUMBNAIL_QUALITY")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse::<u8>().ok())
|
||||||
|
.unwrap_or(80),
|
||||||
|
format: std::env::var("THUMBNAIL_FORMAT").unwrap_or_else(|_| "webp".to_string()),
|
||||||
|
directory: std::env::var("THUMBNAIL_DIRECTORY")
|
||||||
|
.unwrap_or_else(|_| "/data/thumbnails".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
listen_addr: std::env::var("INDEXER_LISTEN_ADDR")
|
listen_addr: std::env::var("INDEXER_LISTEN_ADDR")
|
||||||
.unwrap_or_else(|_| "0.0.0.0:8081".to_string()),
|
.unwrap_or_else(|_| "0.0.0.0:8081".to_string()),
|
||||||
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL is required")?,
|
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL is required")?,
|
||||||
meili_url: std::env::var("MEILI_URL").context("MEILI_URL is required")?,
|
meili_url: std::env::var("MEILI_URL").context("MEILI_URL is required")?,
|
||||||
meili_master_key: std::env::var("MEILI_MASTER_KEY").context("MEILI_MASTER_KEY is required")?,
|
meili_master_key: std::env::var("MEILI_MASTER_KEY")
|
||||||
|
.context("MEILI_MASTER_KEY is required")?,
|
||||||
scan_interval_seconds: std::env::var("INDEXER_SCAN_INTERVAL_SECONDS")
|
scan_interval_seconds: std::env::var("INDEXER_SCAN_INTERVAL_SECONDS")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
.unwrap_or(5),
|
.unwrap_or(5),
|
||||||
|
thumbnail_config,
|
||||||
|
api_base_url: std::env::var("API_BASE_URL")
|
||||||
|
.unwrap_or_else(|_| "http://api:8080".to_string()),
|
||||||
|
api_bootstrap_token: std::env::var("API_BOOTSTRAP_TOKEN")
|
||||||
|
.context("API_BOOTSTRAP_TOKEN is required for thumbnail checkup")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,8 +117,10 @@ impl AdminUiConfig {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
listen_addr: std::env::var("ADMIN_UI_LISTEN_ADDR")
|
listen_addr: std::env::var("ADMIN_UI_LISTEN_ADDR")
|
||||||
.unwrap_or_else(|_| "0.0.0.0:8082".to_string()),
|
.unwrap_or_else(|_| "0.0.0.0:8082".to_string()),
|
||||||
api_base_url: std::env::var("API_BASE_URL").unwrap_or_else(|_| "http://api:8080".to_string()),
|
api_base_url: std::env::var("API_BASE_URL")
|
||||||
api_token: std::env::var("API_BOOTSTRAP_TOKEN").context("API_BOOTSTRAP_TOKEN is required")?,
|
.unwrap_or_else(|_| "http://api:8080".to_string()),
|
||||||
|
api_token: std::env::var("API_BOOTSTRAP_TOKEN")
|
||||||
|
.context("API_BOOTSTRAP_TOKEN is required")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ license.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
lopdf = "0.35"
|
lopdf = "0.35"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
uuid.workspace = true
|
||||||
|
walkdir.workspace = true
|
||||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use std::io::Read;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum BookFormat {
|
pub enum BookFormat {
|
||||||
@@ -54,16 +58,47 @@ pub fn parse_metadata(
|
|||||||
|
|
||||||
// Determine series from parent folder relative to library root
|
// Determine series from parent folder relative to library root
|
||||||
let series = path.parent().and_then(|parent| {
|
let series = path.parent().and_then(|parent| {
|
||||||
// Get the relative path from library root to parent
|
// Normalize paths for comparison (handle different separators, etc.)
|
||||||
let relative = parent.strip_prefix(library_root).ok()?;
|
let parent_str = parent.to_string_lossy().to_string();
|
||||||
// If relative path is not empty, use first component as series
|
let root_str = library_root.to_string_lossy().to_string();
|
||||||
let first_component = relative.components().next()?;
|
|
||||||
let series_name = first_component.as_os_str().to_string_lossy().to_string();
|
// Try to find the library root in the parent path
|
||||||
// Only if series_name is not empty
|
let relative = if let Some(idx) = parent_str.find(&root_str) {
|
||||||
|
// Found root in parent, extract what comes after
|
||||||
|
let after_root = &parent_str[idx + root_str.len()..];
|
||||||
|
Path::new(after_root)
|
||||||
|
} else if let Some(relative) = parent.strip_prefix(library_root).ok() {
|
||||||
|
// Standard approach works
|
||||||
|
relative
|
||||||
|
} else {
|
||||||
|
// Log for diagnostic on server
|
||||||
|
eprintln!(
|
||||||
|
"[PARSER] Cannot determine series: parent '{}' doesn't start with root '{}'",
|
||||||
|
parent.display(),
|
||||||
|
library_root.display()
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove leading separators
|
||||||
|
let relative_str = relative.to_string_lossy().to_string();
|
||||||
|
let relative_clean = relative_str.trim_start_matches(|c| c == '/' || c == '\\');
|
||||||
|
|
||||||
|
if relative_clean.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first component as series
|
||||||
|
let first_sep = relative_clean.find(|c| c == '/' || c == '\\');
|
||||||
|
let series_name = match first_sep {
|
||||||
|
Some(idx) => &relative_clean[..idx],
|
||||||
|
None => relative_clean,
|
||||||
|
};
|
||||||
|
|
||||||
if series_name.is_empty() {
|
if series_name.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(series_name)
|
Some(series_name.to_string())
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,3 +244,105 @@ fn is_image_name(name: &str) -> bool {
|
|||||||
|| name.ends_with(".webp")
|
|| name.ends_with(".webp")
|
||||||
|| name.ends_with(".avif")
|
|| name.ends_with(".avif")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn extract_first_page(path: &Path, format: BookFormat) -> Result<Vec<u8>> {
|
||||||
|
match format {
|
||||||
|
BookFormat::Cbz => extract_cbz_first_page(path),
|
||||||
|
BookFormat::Cbr => extract_cbr_first_page(path),
|
||||||
|
BookFormat::Pdf => extract_pdf_first_page(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_cbz_first_page(path: &Path) -> Result<Vec<u8>> {
|
||||||
|
let file = std::fs::File::open(path)
|
||||||
|
.with_context(|| format!("cannot open cbz: {}", path.display()))?;
|
||||||
|
let mut archive = zip::ZipArchive::new(file).context("invalid cbz archive")?;
|
||||||
|
|
||||||
|
let mut image_names: Vec<String> = Vec::new();
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let entry = archive.by_index(i).context("cannot read cbz entry")?;
|
||||||
|
let name = entry.name().to_ascii_lowercase();
|
||||||
|
if is_image_name(&name) {
|
||||||
|
image_names.push(entry.name().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
image_names.sort();
|
||||||
|
|
||||||
|
let first_image = image_names.first().context("no images found in cbz")?;
|
||||||
|
|
||||||
|
let mut entry = archive
|
||||||
|
.by_name(first_image)
|
||||||
|
.context("cannot read first image")?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
entry.read_to_end(&mut buf)?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_cbr_first_page(path: &Path) -> Result<Vec<u8>> {
|
||||||
|
let tmp_dir = std::env::temp_dir().join(format!("stripstream-cbr-thumb-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&tmp_dir).context("cannot create temp dir")?;
|
||||||
|
|
||||||
|
// Use env command like the API does
|
||||||
|
let output = std::process::Command::new("env")
|
||||||
|
.args(["LC_ALL=en_US.UTF-8", "LANG=en_US.UTF-8", "unar", "-o"])
|
||||||
|
.arg(&tmp_dir)
|
||||||
|
.arg(path)
|
||||||
|
.output()
|
||||||
|
.context("unar failed")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"unar extract failed: {:?}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use WalkDir for recursive search (CBR can have subdirectories)
|
||||||
|
let mut image_files: Vec<_> = WalkDir::new(&tmp_dir)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| {
|
||||||
|
let name = e.file_name().to_string_lossy().to_lowercase();
|
||||||
|
is_image_name(&name)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
image_files.sort_by_key(|e| e.path().to_string_lossy().to_lowercase());
|
||||||
|
|
||||||
|
let first_image = image_files.first().context("no images found in cbr")?;
|
||||||
|
|
||||||
|
let data = std::fs::read(first_image.path())?;
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_pdf_first_page(path: &Path) -> Result<Vec<u8>> {
|
||||||
|
let tmp_dir = std::env::temp_dir().join(format!("stripstream-pdf-thumb-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&tmp_dir)?;
|
||||||
|
let output_prefix = tmp_dir.join("page");
|
||||||
|
|
||||||
|
let output = Command::new("pdftoppm")
|
||||||
|
.args([
|
||||||
|
"-f",
|
||||||
|
"1",
|
||||||
|
"-singlefile",
|
||||||
|
"-png",
|
||||||
|
"-scale-to",
|
||||||
|
"800",
|
||||||
|
path.to_str().unwrap(),
|
||||||
|
output_prefix.to_str().unwrap(),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.context("pdftoppm failed")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
return Err(anyhow::anyhow!("pdftoppm failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let image_path = output_prefix.with_extension("png");
|
||||||
|
let data = std::fs::read(&image_path)?;
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
POSTGRES_USER: stripstream
|
POSTGRES_USER: stripstream
|
||||||
POSTGRES_PASSWORD: stripstream
|
POSTGRES_PASSWORD: stripstream
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "6432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -34,15 +34,17 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
env_file:
|
environment:
|
||||||
- ../.env
|
POSTGRES_USER: stripstream
|
||||||
|
POSTGRES_PASSWORD: stripstream
|
||||||
|
POSTGRES_DB: stripstream
|
||||||
volumes:
|
volumes:
|
||||||
- ./migrations:/migrations:ro
|
- ./migrations:/migrations:ro
|
||||||
command:
|
command:
|
||||||
[
|
[
|
||||||
"sh",
|
"sh",
|
||||||
"-c",
|
"-c",
|
||||||
"for f in /migrations/*.sql; do echo \"Applying migration: $f\"; psql -h postgres -U \"$${POSTGRES_USER:-stripstream}\" -d \"$${POSTGRES_DB:-stripstream}\" -f \"$f\" || exit 1; done",
|
"export PGPASSWORD=$$POSTGRES_PASSWORD; for f in /migrations/*.sql; do echo \"Applying migration: $$f\"; psql -h postgres -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\" -f \"$$f\" || exit 1; done",
|
||||||
]
|
]
|
||||||
|
|
||||||
api:
|
api:
|
||||||
@@ -52,9 +54,10 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "7080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ../libraries:/libraries
|
- ${LIBRARIES_HOST_PATH:-../libraries}:/libraries
|
||||||
|
- ${THUMBNAILS_HOST_PATH:-../data/thumbnails}:/data/thumbnails
|
||||||
depends_on:
|
depends_on:
|
||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
@@ -75,9 +78,10 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
ports:
|
ports:
|
||||||
- "8081:8081"
|
- "7081:8081"
|
||||||
volumes:
|
volumes:
|
||||||
- ../libraries:/libraries
|
- ${LIBRARIES_HOST_PATH:-../libraries}:/libraries
|
||||||
|
- ${THUMBNAILS_HOST_PATH:-../data/thumbnails}:/data/thumbnails
|
||||||
depends_on:
|
depends_on:
|
||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
@@ -101,12 +105,12 @@ services:
|
|||||||
- PORT=8082
|
- PORT=8082
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
ports:
|
ports:
|
||||||
- "${BACKOFFICE_PORT:-8082}:8082"
|
- "7082:8082"
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "-O", "-", "http://host.docker.internal:8082/health"]
|
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8082/health"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
6
infra/migrations/0007_add_sync_metadata.sql
Normal file
6
infra/migrations/0007_add_sync_metadata.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS sync_metadata (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
last_meili_sync TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sync_metadata (id, last_meili_sync) VALUES (1, NULL) ON CONFLICT DO NOTHING;
|
||||||
11
infra/migrations/0008_add_settings.sql
Normal file
11
infra/migrations/0008_add_settings.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO app_settings (key, value) VALUES
|
||||||
|
('image_processing', '{"format": "webp", "quality": 85, "filter": "lanczos3", "max_width": 2160}'),
|
||||||
|
('cache', '{"enabled": true, "directory": "/tmp/stripstream-image-cache", "max_size_mb": 10000}'),
|
||||||
|
('limits', '{"concurrent_renders": 4, "timeout_seconds": 12, "rate_limit_per_second": 120}')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
5
infra/migrations/0009_add_thumbnails.sql
Normal file
5
infra/migrations/0009_add_thumbnails.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE books ADD COLUMN IF NOT EXISTS thumbnail_path TEXT;
|
||||||
|
|
||||||
|
INSERT INTO app_settings (key, value) VALUES
|
||||||
|
('thumbnail', '{"enabled": true, "width": 300, "height": 400, "quality": 80, "format": "webp", "directory": "/data/thumbnails"}')
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = '{"enabled": true, "width": 300, "height": 400, "quality": 80, "format": "webp", "directory": "/data/thumbnails"}'::jsonb;
|
||||||
6
infra/migrations/0010_index_job_thumbnails_phase.sql
Normal file
6
infra/migrations/0010_index_job_thumbnails_phase.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Migration: Add status 'generating_thumbnails' for thumbnail phase after indexing
|
||||||
|
|
||||||
|
ALTER TABLE index_jobs
|
||||||
|
DROP CONSTRAINT IF EXISTS index_jobs_status_check,
|
||||||
|
ADD CONSTRAINT index_jobs_status_check
|
||||||
|
CHECK (status IN ('pending', 'running', 'generating_thumbnails', 'success', 'failed'));
|
||||||
6
infra/migrations/0011_thumbnail_rebuild_type.sql
Normal file
6
infra/migrations/0011_thumbnail_rebuild_type.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Migration: Add job type 'thumbnail_rebuild' for manual thumbnail generation
|
||||||
|
|
||||||
|
ALTER TABLE index_jobs
|
||||||
|
DROP CONSTRAINT IF EXISTS index_jobs_type_check,
|
||||||
|
ADD CONSTRAINT index_jobs_type_check
|
||||||
|
CHECK (type IN ('scan', 'rebuild', 'full_rebuild', 'thumbnail_rebuild', 'thumbnail_regenerate'));
|
||||||
Reference in New Issue
Block a user