Compare commits

...

21 Commits

Author SHA1 Message Date
e64848a216 feat: implement thumbnail generation and management
- Remove unused image dependencies from Cargo.lock.
- Update API to handle thumbnail generation and checkup processes.
- Introduce new routes for rebuilding and regenerating thumbnails.
- Enhance job tracking with progress indicators for thumbnail jobs.
- Update front-end components to display thumbnail job status and progress.
- Add backend logic for managing thumbnail jobs and integrating with the API.
- Refactor existing code to accommodate new thumbnail functionalities.
2026-03-08 20:55:12 +01:00
c93a7d5d29 feat: thumbnails : part1 2026-03-08 17:54:47 +01:00
360d6e85de feat: review cbr and unraring for image on api 2026-03-07 15:47:46 +01:00
162b4712e7 fix: improve CBR extraction with fallback and increase timeout
- Try multiple entries in CBR archive until finding valid image
- Increase timeout from 12s to 30s for large files
- Better error messages for debugging
2026-03-07 15:02:55 +01:00
217919fa77 perf: increase concurrent renders limit from 4 to 8 2026-03-07 12:16:23 +01:00
ee0235b824 fix: improve CBR extraction logging and remove dead code
- Add magic bytes validation for extracted CBR images
- Add hex dump for debugging invalid images
- Show first entries when listing CBR archive
- Remove unused structs and functions from settings.rs
- Add -y flag to unrar for auto-confirm
2026-03-07 12:13:55 +01:00
f721b248f3 feat: add image rendering logs and refactor Icon component
- Add detailed tracing logs for image processing (CBZ, CBR, PDF)
- Add cache hit/miss logging with timing info
- Centralize all SVG icons into reusable Icon component
- Add Settings icon to header navigation
- Add icons for Image Processing, Cache, and Performance Limits sections
2026-03-07 10:44:38 +01:00
292c61566c feat: add image optimization and settings page
- Add persistent disk cache for processed images
- Optimize image processing with short-circuit and quality settings
- Add WebP lossy encoding with configurable quality
- Add settings API endpoints (GET/POST /settings, cache management)
- Add database table for app configuration
- Add /settings page in backoffice for image/cache/limits config
- Add cache stats and clear functionality
- Update navigation with settings link
2026-03-07 09:12:06 +01:00
9141edfaa9 fix: handle SSE controller errors gracefully
- Add isActive checks before writing to SSE controller
- Wrap controller operations in try/catch to prevent 'already closed' errors
- Fix race condition when client disconnects during SSE streaming
2026-03-06 22:40:57 +01:00
f0a967515b fix: improve series detection and add detailed indexing logs
- Fix series detection to handle path variations (symlinks, separators)
- Add comprehensive logging for job processing and file scanning
- Better error handling for path prefix stripping
- Track files scanned, indexed, and errors per library
2026-03-06 22:35:11 +01:00
d5d582db57 perf: optimize indexing speed with batching and incremental sync
- Batching BDD: group INSERT/UPDATE operations in batches of 100 using UNNEST
- Incremental MeiliSearch: only sync books modified since last sync
- Optimized fingerprint: use only size+mtime+filename (100x faster)
- Increased DB connections from 5 to 20
- Reduced progress update frequency (every 1s or 10 files)
- Add sync_metadata table to track last MeiliSearch sync
2026-03-06 22:09:37 +01:00
ee76090265 fix: natural sort regex to handle titles with letters after numbers
Replace REGEXP_REPLACE with REGEXP_MATCH to extract only digits
Fixes 'invalid input syntax for type integer' error when titles
contain letters after numbers like '20th century boys'
2026-03-06 21:59:19 +01:00
d86301919d refactor: hardcode ports in docker-compose.yml for server deployment
- Remove env variable substitution from docker-compose.yml ports
- Hardcode custom ports directly in compose file: 7080, 7081, 7082, 7700, 6432
- Remove PORT variables from .env.example (now configured in docker-compose.yml)
- Add documentation on how to change ports (edit docker-compose.yml directly)
- Simplifies server deployment - no need to export env variables before docker-compose up

Note: To change ports, edit infra/docker-compose.yml directly
2026-03-06 21:24:45 +01:00
47e53a19b9 fix: correct healthchecks and env variable handling for custom ports
- Fix healthchecks to use internal ports (8080, 8081, 8082, 7700) instead of external ports
- Remove variable expansion from healthcheck URLs (not supported in compose)
- Update .env.example to clarify internal vs external ports
- Ensure DATABASE_URL always uses internal PostgreSQL port (5432)
- Services now correctly start with custom external port mappings
2026-03-06 21:23:20 +01:00
d0a29196dd perf: add sccache to Docker builds for faster compilation
- Install sccache in builder stage of both api and indexer Dockerfiles
- Configure RUSTC_WRAPPER to use sccache
- Use Docker cache mount (--mount=type=cache,target=/sccache) to persist cache
- Reduces build time significantly on subsequent builds by caching compiled artifacts
- Requires Docker BuildKit (enabled by default in Docker 23.0+)

Note: First build will still be slow (installs sccache + populates cache)
Subsequent builds will be much faster as dependencies are cached
2026-03-06 21:04:40 +01:00
19ef4d592b refactor: simplify env configuration with single port definitions
- Update docker-compose.yml healthchecks to use env variables instead of hardcoded ports
- Restructure .env.example to define each port only once
- Auto-generate service URLs from port variables using ${PORT} syntax
- Document the configuration structure with clear sections
- Makes it easier to change ports without updating multiple variables
2026-03-06 21:00:59 +01:00
a31c524c32 feat: make all service ports configurable via env variables
- Add API_PORT, INDEXER_PORT, MEILI_PORT, POSTGRES_PORT env variables
- Update docker-compose.yml to use ${VAR:-default} syntax for all ports
- Document new port variables in .env.example
- Allows users to customize ports to avoid conflicts with other services
2026-03-06 20:55:23 +01:00
d3e2147982 feat: make libraries volume path configurable via env variable
- Add LIBRARIES_HOST_PATH env variable for Docker volume mount path
- Update docker-compose.yml to use ${LIBRARIES_HOST_PATH:-../libraries}
- Update .env.example with new variable and documentation
- Allows users to customize the host path for their libraries folder
2026-03-06 20:49:12 +01:00
4f6833b42b feat: add hierarchical folder browser for library creation
- Extend API /folders endpoint to support browsing subdirectories with path parameter
- Add depth and has_children fields to FolderItem
- Create FolderBrowser component with tree view navigation
- Create FolderPicker component with input, browse button and popup modal
- Add API proxy route for /api/folders
- Update LibraryForm to use new FolderPicker component
- Fix path handling to correctly resolve /libraries/ subdirectories
2026-03-06 18:03:09 +01:00
7cdc72b6e1 feat(backoffice): redesign UI with enhanced background and glassmorphism effects
- Add vibrant radial gradient backgrounds with multiple color zones
- Implement glassmorphism effects on header and cards
- Add subtle grain texture overlay
- Update card hover effects with smooth transitions
- Improve dark mode background visibility
2026-03-06 16:21:48 +01:00
2b30ae47de build: Docker compose build successful with all services
- Fixed Dockerfiles (removed admin-ui references)
- Updated Cargo.toml workspace
- Added @tailwindcss/postcss dependency
- All services building and running correctly
2026-03-06 15:18:03 +01:00
75 changed files with 6481 additions and 1223 deletions

View File

@@ -1,4 +1,9 @@
# 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!
@@ -12,7 +17,7 @@ MEILI_MASTER_KEY=change-me-in-production
API_BOOTSTRAP_TOKEN=change-me-in-production
# =============================================================================
# Services Configuration
# Service Configuration
# =============================================================================
# API Service
@@ -23,28 +28,36 @@ API_BASE_URL=http://api:8080
INDEXER_LISTEN_ADDR=0.0.0.0:8081
INDEXER_SCAN_INTERVAL_SECONDS=5
# Backoffice Web UI
BACKOFFICE_PORT=8082
# =============================================================================
# Database Configuration
# =============================================================================
# PostgreSQL connection string
DATABASE_URL=postgres://stripstream:stripstream@postgres:5432/stripstream
# =============================================================================
# Search Configuration
# =============================================================================
# Meilisearch connection URL
# Meilisearch Search Engine
MEILI_URL=http://meilisearch:7700
# PostgreSQL Database
DATABASE_URL=postgres://stripstream:stripstream@postgres:5432/stripstream
# =============================================================================
# Storage Configuration
# =============================================================================
# Path to libraries directory
# 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
View File

@@ -5,3 +5,4 @@ tmp/
libraries/
node_modules/
.next/
data/thumbnails

301
AGENTS.md Normal file
View 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
View File

@@ -8,22 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "aes"
version = "0.8.4"
@@ -94,6 +78,8 @@ dependencies = [
"utoipa",
"utoipa-swagger-ui",
"uuid",
"walkdir",
"webp",
"zip 2.4.2",
]
@@ -333,6 +319,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -815,6 +803,12 @@ dependencies = [
"wasip3",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1154,6 +1148,8 @@ dependencies = [
"chrono",
"notify",
"parsers",
"rand 0.8.5",
"rayon",
"reqwest",
"serde",
"serde_json",
@@ -1231,6 +1227,16 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "js-sys"
version = "0.3.91"
@@ -1310,6 +1316,16 @@ dependencies = [
"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]]
name = "litemap"
version = "0.8.1"
@@ -1609,6 +1625,8 @@ dependencies = [
"anyhow",
"lopdf",
"regex",
"uuid",
"walkdir",
"zip 2.4.2",
]
@@ -3168,6 +3186,16 @@ dependencies = [
"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]]
name = "webpki-roots"
version = "0.26.11"

View File

@@ -2,7 +2,6 @@
members = [
"apps/api",
"apps/indexer",
"apps/admin-ui",
"crates/core",
"crates/parsers",
]
@@ -21,6 +20,7 @@ base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
lru = "0.12"
rayon = "1.10"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rand = "0.8"
serde = { version = "1.0", features = ["derive"] }

141
PLAN_THUMBNAILS.md Normal file
View 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`

View File

@@ -31,3 +31,5 @@ uuid.workspace = true
zip = { version = "2.2", default-features = false, features = ["deflate"] }
utoipa.workspace = true
utoipa-swagger-ui = { workspace = true, features = ["axum"] }
webp = "0.3"
walkdir = "2"

View File

@@ -1,22 +1,30 @@
FROM rust:1-bookworm AS builder
WORKDIR /app
# Install sccache for faster builds
RUN cargo install sccache --locked
ENV RUSTC_WRAPPER=sccache
ENV SCCACHE_DIR=/sccache
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 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
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
EXPOSE 8080
CMD ["/usr/local/bin/api"]

View File

@@ -34,6 +34,7 @@ pub struct BookItem {
pub volume: Option<i32>,
pub language: Option<String>,
pub page_count: Option<i32>,
pub thumbnail_url: Option<String>,
#[schema(value_type = String)]
pub updated_at: DateTime<Utc>,
}
@@ -58,6 +59,7 @@ pub struct BookDetails {
pub volume: Option<i32>,
pub language: Option<String>,
pub page_count: Option<i32>,
pub thumbnail_url: Option<String>,
pub file_path: Option<String>,
pub file_format: Option<String>,
pub file_parse_status: Option<String>,
@@ -96,7 +98,7 @@ pub async fn list_books(
let sql = format!(
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
WHERE ($1::uuid IS NULL OR library_id = $1)
AND ($2::text IS NULL OR kind = $2)
@@ -107,7 +109,7 @@ pub async fn list_books(
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
-- Extract first number group and convert to integer for numeric sort
COALESCE(
NULLIF(REGEXP_REPLACE(LOWER(title), '^[^0-9]*', '', 'g'), '')::int,
(REGEXP_MATCH(LOWER(title), '\d+'))[1]::int,
0
),
-- Then by full title as fallback
@@ -135,7 +137,9 @@ pub async fn list_books(
let mut items: Vec<BookItem> = rows
.iter()
.take(limit as usize)
.map(|row| BookItem {
.map(|row| {
let thumbnail_path: Option<String> = row.get("thumbnail_path");
BookItem {
id: row.get("id"),
library_id: row.get("library_id"),
kind: row.get("kind"),
@@ -145,7 +149,9 @@ pub async fn list_books(
volume: row.get("volume"),
language: row.get("language"),
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"),
}
})
.collect();
@@ -182,7 +188,7 @@ pub async fn get_book(
) -> Result<Json<BookDetails>, ApiError> {
let row = sqlx::query(
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
FROM books b
LEFT JOIN LATERAL (
@@ -200,6 +206,7 @@ pub async fn get_book(
.await?;
let row = row.ok_or_else(|| ApiError::not_found("book not found"))?;
let thumbnail_path: Option<String> = row.get("thumbnail_path");
Ok(Json(BookDetails {
id: row.get("id"),
library_id: row.get("library_id"),
@@ -210,6 +217,7 @@ pub async fn get_book(
volume: row.get("volume"),
language: row.get("language"),
page_count: row.get("page_count"),
thumbnail_url: thumbnail_path.map(|_| format!("/books/{}/thumbnail", id)),
file_path: row.get("abs_path"),
file_format: row.get("format"),
file_parse_status: row.get("parse_status"),
@@ -224,16 +232,33 @@ pub struct SeriesItem {
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(
get,
path = "/libraries/{library_id}/series",
tag = "books",
params(
("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(
(status = 200, body = Vec<SeriesItem>),
(status = 200, body = SeriesPage),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
@@ -241,7 +266,10 @@ pub struct SeriesItem {
pub async fn list_series(
State(state): State<AppState>,
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(
r#"
WITH sorted_books AS (
@@ -253,7 +281,7 @@ pub async fn list_series(
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
ORDER BY
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
) as rn
FROM books
@@ -272,23 +300,28 @@ pub async fn list_series(
sb.id as first_book_id
FROM series_counts sc
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
WHERE ($2::text IS NULL OR sc.name > $2)
ORDER BY
-- Natural sort: extract text part before numbers
REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'),
-- Extract first number group and convert to integer
COALESCE(
NULLIF(REGEXP_REPLACE(LOWER(sc.name), '^[^0-9]*', '', 'g'), '')::int,
(REGEXP_MATCH(LOWER(sc.name), '\d+'))[1]::int,
0
),
sc.name ASC
LIMIT $3
"#,
)
.bind(library_id)
.bind(query.cursor.as_deref())
.bind(limit + 1)
.fetch_all(&state.pool)
.await?;
let series: Vec<SeriesItem> = rows
let mut items: Vec<SeriesItem> = rows
.iter()
.take(limit as usize)
.map(|row| SeriesItem {
name: row.get("name"),
book_count: row.get("book_count"),
@@ -296,5 +329,51 @@ pub async fn list_series(
})
.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)))
}

View File

@@ -1,4 +1,8 @@
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
#[derive(Debug)]
@@ -51,7 +55,13 @@ impl ApiError {
impl IntoResponse for ApiError {
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}"))
}
}
impl From<std::io::Error> for ApiError {
fn from(err: std::io::Error) -> Self {
Self::internal(format!("IO error: {err}"))
}
}

View File

@@ -34,12 +34,17 @@ pub struct IndexJobResponse {
pub error_opt: Option<String>,
#[schema(value_type = String)]
pub created_at: DateTime<Utc>,
pub progress_percent: Option<i32>,
pub processed_files: Option<i32>,
pub total_files: Option<i32>,
}
#[derive(Serialize, ToSchema)]
pub struct FolderItem {
pub name: String,
pub path: String,
pub depth: usize,
pub has_children: bool,
}
#[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> {
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)
.await?;
@@ -169,7 +174,7 @@ pub async fn cancel_job(
id: axum::extract::Path<Uuid>,
) -> Result<Json<IndexJobResponse>, ApiError> {
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)
.execute(&state.pool)
@@ -180,7 +185,7 @@ pub async fn cancel_job(
}
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)
.fetch_one(&state.pool)
@@ -194,10 +199,14 @@ fn get_libraries_root() -> String {
}
/// List available folders in /libraries for library creation
/// Supports browsing subdirectories via optional path parameter
#[utoipa::path(
get,
path = "/folders",
tag = "indexing",
params(
("path" = Option<String>, Query, description = "Optional subdirectory path to browse (e.g., '/libraries/manga/action')"),
),
responses(
(status = 200, body = Vec<FolderItem>),
(status = 401, description = "Unauthorized"),
@@ -205,18 +214,73 @@ fn get_libraries_root() -> String {
),
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_path = std::path::Path::new(&libraries_root);
let mut folders = Vec::new();
let base_path = std::path::Path::new(&libraries_root);
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() {
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
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 {
name: name.clone(),
path: format!("/libraries/{}", name),
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"),
error_opt: row.get("error_opt"),
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> {
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
WHERE status IN ('pending', 'running')
WHERE status IN ('pending', 'running', 'generating_thumbnails')
ORDER BY created_at ASC"
)
.fetch_all(&state.pool)

View File

@@ -6,6 +6,8 @@ mod libraries;
mod openapi;
mod pages;
mod search;
mod settings;
mod thumbnails;
mod tokens;
use std::{
@@ -84,7 +86,7 @@ async fn main() -> anyhow::Result<()> {
meili_url: Arc::from(config.meili_url),
meili_master_key: Arc::from(config.meili_master_key),
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()),
read_rate_limit: Arc::new(Mutex::new(ReadRateLimit {
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/monitoring", axum::routing::patch(libraries::update_monitoring))
.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/jobs/active", get(index_jobs::get_active_jobs))
.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/thumbnails/checkup", axum::routing::post(thumbnails::start_checkup))
.route("/index/jobs/:id/errors", get(index_jobs::get_job_errors))
.route("/index/cancel/:id", axum::routing::post(index_jobs::cancel_job))
.route("/folders", get(index_jobs::list_folders))
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
.route("/admin/tokens/:id", delete(tokens::revoke_token))
.merge(settings::settings_routes())
.route_layer(middleware::from_fn_with_state(
state.clone(),
auth::require_admin,
@@ -115,6 +121,7 @@ async fn main() -> anyhow::Result<()> {
let read_routes = Router::new()
.route("/books", get(books::list_books))
.route("/books/:id", get(books::get_book))
.route("/books/:id/thumbnail", get(books::get_thumbnail))
.route("/books/:id/pages/:n", get(pages::get_page))
.route("/libraries/:library_id/series", get(books::list_series))
.route("/search", get(search::search_books))

View File

@@ -10,6 +10,8 @@ use utoipa::OpenApi;
crate::pages::get_page,
crate::search::search_books,
crate::index_jobs::enqueue_rebuild,
crate::thumbnails::start_thumbnails_rebuild,
crate::thumbnails::start_thumbnails_regenerate,
crate::index_jobs::list_index_jobs,
crate::index_jobs::get_active_jobs,
crate::index_jobs::get_job_details,
@@ -37,6 +39,7 @@ use utoipa::OpenApi;
crate::search::SearchQuery,
crate::search::SearchResponse,
crate::index_jobs::RebuildRequest,
crate::thumbnails::ThumbnailsRebuildRequest,
crate::index_jobs::IndexJobResponse,
crate::index_jobs::IndexJobDetailResponse,
crate::index_jobs::JobErrorResponse,

View File

@@ -1,6 +1,6 @@
use std::{
io::Read,
path::Path,
io::{Read, Write},
path::{Path, PathBuf},
sync::{atomic::Ordering, Arc},
time::Duration,
};
@@ -11,12 +11,14 @@ use axum::{
http::{header, HeaderMap, HeaderValue, StatusCode},
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 utoipa::ToSchema;
use sha2::{Digest, Sha256};
use sqlx::Row;
use tracing::{debug, error, info, instrument, warn};
use uuid::Uuid;
use walkdir::WalkDir;
use crate::{error::ApiError, AppState};
@@ -29,7 +31,44 @@ fn remap_libraries_path(path: &str) -> 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 {
#[schema(value_type = Option<String>, example = "webp")]
pub format: Option<String>,
@@ -39,7 +78,7 @@ pub struct PageQuery {
pub width: Option<u32>,
}
#[derive(Clone, Copy)]
#[derive(Clone, Copy, Debug)]
enum OutputFormat {
Jpeg,
Png,
@@ -93,12 +132,16 @@ impl OutputFormat {
),
security(("Bearer" = []))
)]
#[instrument(skip(state), fields(book_id = %book_id, page = n))]
pub async fn get_page(
State(state): State<AppState>,
AxumPath((book_id, n)): AxumPath<(Uuid, u32)>,
Query(query): Query<PageQuery>,
) -> Result<Response, ApiError> {
info!("Processing image request");
if n == 0 {
warn!("Invalid page number: 0");
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 width = query.width.unwrap_or(0);
if width > 2160 {
warn!("Invalid width: {}", width);
return Err(ApiError::bad_request("width must be <= 2160"));
}
let cache_key = format!("{book_id}:{n}:{}:{quality}:{width}", format.extension());
if let Some(cached) = state.page_cache.lock().await.get(&cache_key).cloned() {
let memory_cache_key = format!("{book_id}:{n}:{}:{quality}:{width}", format.extension());
if let Some(cached) = state.page_cache.lock().await.get(&memory_cache_key).cloned() {
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);
debug!("Memory cache miss for key: {}", memory_cache_key);
let row = sqlx::query(
r#"
@@ -127,11 +174,128 @@ pub async fn get_page(
)
.bind(book_id)
.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 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 input_format: String = row.get("format");
@@ -142,31 +306,25 @@ pub async fn get_page(
.await
.map_err(|_| ApiError::internal("render limiter unavailable"))?;
let abs_path_clone = abs_path.clone();
let bytes = tokio::time::timeout(
Duration::from_secs(12),
tokio::task::spawn_blocking(move || render_page(&abs_path, &input_format, n, &format, quality, width)),
Duration::from_secs(60),
tokio::task::spawn_blocking(move || {
render_page(
&abs_path_clone,
&input_format,
1,
&OutputFormat::Webp,
quality,
width,
)
}),
)
.await
.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);
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()
bytes
}
fn render_page(
@@ -188,62 +346,118 @@ fn render_page(
}
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}")))?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| ApiError::internal(format!("invalid cbz: {e}")))?;
debug!("Opening CBZ archive: {}", abs_path);
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();
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();
if is_image_name(&name) {
image_names.push(entry.name().to_string());
}
}
image_names.sort();
debug!("Found {} images in CBZ {}", image_names.len(), abs_path);
let index = page_number as usize - 1;
let selected = image_names.get(index).ok_or_else(|| ApiError::not_found("page out of range"))?;
let mut entry = archive.by_name(selected).map_err(|e| ApiError::internal(format!("cbz page read failed: {e}")))?;
let selected = image_names.get(index).ok_or_else(|| {
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();
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)
}
fn extract_cbr_page(abs_path: &str, page_number: u32) -> Result<Vec<u8>, ApiError> {
let list_output = std::process::Command::new("unrar")
.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"));
}
info!("Opening CBR archive: {}", abs_path);
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 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")
.arg("p")
.arg("-inul")
std::fs::create_dir_all(&tmp_dir).map_err(|e| {
error!("Cannot create temp dir: {}", e);
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(selected)
.output()
.map_err(|e| ApiError::internal(format!("unrar extract failed: {e}")))?;
if !page_output.status.success() {
return Err(ApiError::internal("unrar could not extract page"));
.map_err(|e| {
let _ = std::fs::remove_dir_all(&tmp_dir);
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> {
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 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);
debug!("Running pdftoppm for page {} of {} (width: {})", page_number, abs_path, width);
let output = cmd
.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() {
let stderr = String::from_utf8_lossy(&output.stderr);
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"));
}
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);
debug!("Successfully rendered PDF page {} to {} bytes", page_number, bytes.len());
Ok(bytes)
}
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 {
debug!("Resizing image to width: {}", width);
image = image.resize(width, u32::MAX, image::imageops::FilterType::Lanczos3);
}
debug!("Converting to RGBA...");
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
debug!("Image dimensions: {}x{}", w, h);
let mut out = Vec::new();
match out_format {
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}")))?;
}
OutputFormat::Webp => {
let encoder = WebPEncoder::new_lossless(&mut out);
encoder
.write_image(&rgba, w, h, ColorType::Rgba8.into())
.map_err(|e| ApiError::internal(format!("webp encode failed: {e}")))?;
let rgb_data: Vec<u8> = rgba
.pixels()
.flat_map(|p| [p[0], p[1], p[2]])
.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)
}
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 {
name.ends_with(".jpg")
|| name.ends_with(".jpeg")
|| name.ends_with(".png")
|| name.ends_with(".webp")
|| name.ends_with(".avif")
let lower = name.to_lowercase();
lower.ends_with(".jpg")
|| lower.ends_with(".jpeg")
|| lower.ends_with(".png")
|| 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)]

250
apps/api/src/settings.rs Normal file
View 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
View 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)
}

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

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

View File

@@ -33,27 +33,39 @@ export async function GET(
},
});
if (response.ok) {
if (response.ok && isActive) {
const data = await response.json();
const dataStr = JSON.stringify(data);
// Only send if data changed
if (dataStr !== lastData) {
if (dataStr !== lastData && isActive) {
lastData = dataStr;
try {
controller.enqueue(
new TextEncoder().encode(`data: ${dataStr}\n\n`)
);
} catch (err) {
// Controller closed, ignore
isActive = false;
return;
}
// Stop polling if job is complete
if (data.status === "success" || data.status === "failed" || data.status === "cancelled") {
isActive = false;
try {
controller.close();
} catch (err) {
// Already closed, ignore
}
}
}
}
} catch (error) {
if (isActive) {
console.error("SSE fetch error:", error);
}
}
};
// Initial fetch

View File

@@ -28,21 +28,28 @@ export async function GET(request: NextRequest) {
},
});
if (response.ok) {
if (response.ok && isActive) {
const data = await response.json();
const dataStr = JSON.stringify(data);
// Send if data changed
if (dataStr !== lastData) {
if (dataStr !== lastData && isActive) {
lastData = dataStr;
try {
controller.enqueue(
new TextEncoder().encode(`data: ${dataStr}\n\n`)
);
} catch (err) {
// Controller closed, ignore
isActive = false;
}
}
}
} catch (error) {
if (isActive) {
console.error("SSE fetch error:", error);
}
}
};
// Initial fetch

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

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

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

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

View File

@@ -33,14 +33,14 @@ export default async function BookDetailPage({
return (
<>
<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
</Link>
</div>
<div className="flex flex-col lg:flex-row gap-8">
<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
src={getBookCoverUrl(book.id)}
alt={`Cover of ${book.title}`}
@@ -54,76 +54,76 @@ export default async function BookDetailPage({
</div>
<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>
{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 && (
<p className="text-sm text-muted mb-6">
<p className="text-sm text-muted-foreground mb-6">
{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>
)}
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Format:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Format:</span>
<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()}
</span>
</div>
{book.volume && (
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Volume:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Volume:</span>
<span className="text-sm text-foreground">{book.volume}</span>
</div>
)}
{book.language && (
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Language:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Language:</span>
<span className="text-sm text-foreground">{book.language.toUpperCase()}</span>
</div>
)}
{book.page_count && (
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Pages:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Pages:</span>
<span className="text-sm text-foreground">{book.page_count}</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Library:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Library:</span>
<span className="text-sm text-foreground">{library?.name || book.library_id}</span>
</div>
{book.series && (
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Series:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Series:</span>
<span className="text-sm text-foreground">{book.series}</span>
</div>
)}
{book.file_format && (
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">File Format:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">File Format:</span>
<span className="text-sm text-foreground">{book.file_format.toUpperCase()}</span>
</div>
)}
{book.file_parse_status && (
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Parse Status:</span>
<div className="flex items-center justify-between py-2 border-b border-border">
<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 ${
book.file_parse_status === 'success' ? 'bg-success-soft text-success' :
book.file_parse_status === 'failed' ? 'bg-error-soft text-error' : 'bg-muted/20 text-muted'
book.file_parse_status === 'success' ? 'bg-success/10 text-success' :
book.file_parse_status === 'failed' ? 'bg-destructive/10 text-error' : 'bg-muted/50 text-muted-foreground'
}`}>
{book.file_parse_status}
</span>
@@ -131,25 +131,25 @@ export default async function BookDetailPage({
)}
{book.file_path && (
<div className="flex flex-col py-2 border-b border-line">
<span className="text-sm text-muted mb-1">File Path:</span>
<div className="flex flex-col py-2 border-b border-border">
<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>
</div>
)}
<div className="flex flex-col py-2 border-b border-line">
<span className="text-sm text-muted mb-1">Book ID:</span>
<div className="flex flex-col py-2 border-b border-border">
<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>
</div>
<div className="flex flex-col py-2 border-b border-line">
<span className="text-sm text-muted mb-1">Library ID:</span>
<div className="flex flex-col py-2 border-b border-border">
<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>
</div>
{book.updated_at && (
<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>
</div>
)}

View File

@@ -1,6 +1,6 @@
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api";
import { BooksGrid, EmptyState } from "../components/BookCard";
import { Card, Button, FormField, FormInput, FormSelect, FormRow, CursorPagination } from "../components/ui";
import { Card, CardContent, Button, FormField, FormInput, FormSelect, FormRow, CursorPagination } from "../components/ui";
import Link from "next/link";
export const dynamic = "force-dynamic";
@@ -26,7 +26,6 @@ export default async function BooksPage({
let totalHits: number | null = null;
if (searchQuery) {
// Mode recherche
const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null);
if (searchResponse) {
searchResults = searchResponse.hits.map(hit => ({
@@ -47,7 +46,6 @@ export default async function BooksPage({
totalHits = searchResponse.estimated_total_hits;
}
} else {
// Mode liste avec pagination
const booksPage = await fetchBooks(libraryId, undefined, cursor, limit).catch(() => ({
items: [] as BookDto[],
next_cursor: null,
@@ -55,7 +53,6 @@ export default async function BooksPage({
}));
books = booksPage.items;
nextCursor = booksPage.next_cursor;
// Note: L'API ne supporte pas encore prev_cursor, on gère ça côté UI
}
const displayBooks = (searchResults || books).map(book => ({
@@ -64,27 +61,34 @@ export default async function BooksPage({
}));
const hasNextPage = !!nextCursor;
const hasPrevPage = !!cursor; // Si on a un cursor, on peut revenir en arrière (simplifié)
const hasPrevPage = !!cursor;
return (
<>
<h1 className="text-3xl font-bold text-foreground mb-6 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>
<div className="mb-6">
<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
</h1>
</div>
{/* Filtres et recherche */}
{/* Search Bar - Style compact et propre */}
<Card className="mb-6">
<form>
<FormRow>
<FormField>
<CardContent className="pt-6">
<form className="flex flex-col sm:flex-row gap-3 items-start sm:items-end">
<FormField className="flex-1 w-full">
<label className="block text-sm font-medium text-foreground mb-1.5">Search</label>
<FormInput
name="q"
placeholder="Search books..."
placeholder="Search by title, author, series..."
defaultValue={searchQuery}
className="w-full"
/>
</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 || ""}>
<option value="">All libraries</option>
{libraries.map((lib) => (
@@ -94,22 +98,40 @@ export default async function BooksPage({
))}
</FormSelect>
</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 && (
<Link
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>
)}
</FormRow>
</div>
</form>
</CardContent>
</Card>
{/* Résultats de recherche */}
{/* Résultats */}
{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 &quot;{searchQuery}&quot;
</p>
)}
@@ -119,7 +141,6 @@ export default async function BooksPage({
<>
<BooksGrid books={displayBooks} />
{/* Pagination */}
{!searchQuery && (
<CursorPagination
hasNextPage={hasNextPage}

View File

@@ -13,22 +13,20 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
const [isLoaded, setIsLoaded] = useState(false);
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 */}
<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'
}`}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-muted/20 to-transparent shimmer" />
</div>
/>
{/* Image */}
<Image
src={src}
alt={alt}
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'
}`}
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) {
const coverUrl = book.coverUrl || `/api/books/${book.id}/pages/1?format=webp&width=200`;
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
return (
<Link
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
src={coverUrl}
@@ -62,11 +60,11 @@ export function BookCard({ book }: BookCardProps) {
</h3>
{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 && (
<p className="text-xs text-muted/80 truncate mb-2">
<p className="text-xs text-muted-foreground/80 truncate mb-2">
{book.series}
{book.volume && <span className="text-primary font-medium"> #{book.volume}</span>}
</p>
@@ -76,14 +74,14 @@ export function BookCard({ book }: BookCardProps) {
<div className="flex items-center gap-2 mt-2">
<span className={`
px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full
${book.kind === 'cbz' ? 'bg-success-soft text-success' : ''}
${book.kind === 'cbr' ? 'bg-warning-soft text-warning' : ''}
${book.kind === 'pdf' ? 'bg-error-soft text-error' : ''}
${book.kind === 'cbz' ? 'bg-success/10 text-success' : ''}
${book.kind === 'cbr' ? 'bg-warning/10 text-warning' : ''}
${book.kind === 'pdf' ? 'bg-destructive/10 text-destructive' : ''}
`}>
{book.kind}
</span>
{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}
</span>
)}
@@ -114,12 +112,12 @@ interface EmptyStateProps {
export function EmptyState({ message }: EmptyStateProps) {
return (
<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">
<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>
</div>
<p className="text-muted text-lg">{message}</p>
<p className="text-muted-foreground text-lg">{message}</p>
</div>
);
}

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

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

View File

@@ -70,7 +70,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
if (error) {
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}
</div>
);
@@ -78,7 +78,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
if (!progress) {
return (
<div className="p-4 text-muted text-sm">
<div className="p-4 text-muted-foreground text-sm">
Loading progress...
</div>
);
@@ -87,9 +87,11 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
const percent = progress.progress_percent ?? 0;
const processed = progress.processed_files ?? 0;
const total = progress.total_files ?? 0;
const isThumbnailsPhase = progress.status === "generating_thumbnails";
const unitLabel = isThumbnailsPhase ? "thumbnails" : "files";
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">
<StatusBadge status={progress.status} />
{isComplete && (
@@ -99,8 +101,8 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
<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">
<span>{processed} / {total} files</span>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
<span>{processed} / {total} {unitLabel}</span>
{progress.current_file && (
<span className="truncate max-w-md" title={progress.current_file}>
Current: {progress.current_file.length > 40
@@ -110,7 +112,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
)}
</div>
{progress.stats_json && (
{progress.stats_json && !isThumbnailsPhase && (
<div className="flex flex-wrap gap-3 text-xs">
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>

View File

@@ -33,9 +33,8 @@ interface JobRowProps {
}
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
const [showProgress, setShowProgress] = useState(
highlighted || job.status === "running" || job.status === "pending"
);
const isActive = job.status === "running" || job.status === "pending" || job.status === "generating_thumbnails";
const [showProgress, setShowProgress] = useState(highlighted || isActive);
const handleComplete = () => {
setShowProgress(false);
@@ -53,16 +52,36 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
const removed = job.stats_json?.removed_files ?? 0;
const errors = job.stats_json?.errors ?? 0;
// Format files display
const filesDisplay = job.status === "running" && job.total_files
? `${job.processed_files || 0}/${job.total_files}`
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 (
<>
<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">
<Link
href={`/jobs/${job.id}`}
@@ -86,7 +105,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
!
</span>
)}
{(job.status === "running" || job.status === "pending") && (
{isActive && (
<button
className="text-xs text-primary hover:text-primary/80 hover:underline"
onClick={() => setShowProgress(!showProgress)}
@@ -98,27 +117,32 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
{filesDisplay !== null ? (
<span className="text-sm text-foreground">{filesDisplay}</span>
{job.status === "running" && job.total_files && (
<MiniProgressBar
value={job.processed_files || 0}
max={job.total_files}
className="w-24"
/>
)}
{job.status === "success" && (
) : (
<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 text-sm text-muted">
<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">
<td className="px-4 py-3 text-sm text-muted-foreground">
{formatDate(job.created_at)}
</td>
<td className="px-4 py-3">
@@ -129,7 +153,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
>
View
</Link>
{(job.status === "pending" || job.status === "running") && (
{(job.status === "pending" || job.status === "running" || job.status === "generating_thumbnails") && (
<Button
variant="danger"
size="sm"
@@ -141,9 +165,9 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
</div>
</td>
</tr>
{showProgress && (job.status === "running" || job.status === "pending") && (
{showProgress && isActive && (
<tr>
<td colSpan={8} className="px-4 py-3 bg-muted/5">
<td colSpan={9} className="px-4 py-3 bg-muted/50">
<JobProgress
jobId={job.id}
onComplete={handleComplete}

View File

@@ -2,6 +2,9 @@
import { useEffect, useState, useRef } from "react";
import Link from "next/link";
import { Button } from "./ui/Button";
import { Badge } from "./ui/Badge";
import { ProgressBar } from "./ui/ProgressBar";
interface Job {
id: string;
@@ -19,6 +22,27 @@ interface Job {
} | 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() {
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
const [isOpen, setIsOpen] = useState(false);
@@ -54,7 +78,7 @@ export function JobsIndicator() {
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 totalCount = activeJobs.length;
@@ -67,13 +91,18 @@ export function JobsIndicator() {
return (
<Link
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"
>
<svg width="22" height="22" 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" />
</svg>
<JobsIcon className="w-[18px] h-[18px]" />
</Link>
);
}
@@ -81,56 +110,61 @@ export function JobsIndicator() {
return (
<div className="relative" ref={dropdownRef}>
<button
className={`flex items-center gap-2 px-3 py-2 rounded-lg font-medium text-sm transition-all duration-200 ${
runningJobs.length > 0
? 'bg-success-soft text-success'
: 'bg-warning-soft text-warning'
} ${isOpen ? 'ring-2 ring-primary' : ''}`}
className={`
flex items-center gap-2
px-3 py-2
rounded-md
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)}
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
>
{/* Animated spinner for running jobs */}
{runningJobs.length > 0 && (
<div className="w-4 h-4 animate-spin">
<svg 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>
<SpinnerIcon className="w-4 h-4" />
</div>
)}
{/* Icon */}
<svg className="w-5 h-5" 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" />
</svg>
<JobsIcon className="w-4 h-4" />
{/* 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>
{/* Chevron */}
<svg
<ChevronIcon
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>
{/* Popin/Dropdown */}
{/* Popin/Dropdown with glassmorphism */}
{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="flex items-center justify-between px-4 py-3 border-b border-line bg-muted/5">
<div className="
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">
<span className="text-2xl">📊</span>
<span className="text-xl">📊</span>
<div>
<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} running, ${pendingJobs.length} pending`
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
@@ -149,74 +183,63 @@ export function JobsIndicator() {
{/* Overall progress bar if running */}
{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">
<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>
</div>
<div className="h-2 bg-line rounded-full overflow-hidden">
<div
className="h-full bg-success rounded-full transition-all duration-500"
style={{ width: `${totalProgress}%` }}
/>
</div>
<ProgressBar value={totalProgress} size="sm" variant="success" />
</div>
)}
<div className="max-h-80 overflow-y-auto">
{/* Job List */}
<div className="max-h-80 overflow-y-auto scrollbar-hide">
{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>
<p>No active jobs</p>
</div>
) : (
<ul className="divide-y divide-line">
<ul className="divide-y divide-border/60">
{activeJobs.map(job => (
<li key={job.id}>
<Link
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)}
>
<div className="flex items-start gap-3">
<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>}
</div>
<div className="flex-1 min-w-0">
<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>
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
job.type === 'rebuild' ? 'bg-primary-soft text-primary' : 'bg-muted/20 text-muted'
}`}>
{job.type}
</span>
<code className="text-xs px-1.5 py-0.5 bg-muted rounded font-mono">{job.id.slice(0, 8)}</code>
<Badge variant={job.type === 'rebuild' ? 'primary' : job.type === 'thumbnail_regenerate' ? 'warning' : 'secondary'} className="text-[10px]">
{job.type === 'thumbnail_rebuild' ? 'Thumbnails' : job.type === 'thumbnail_regenerate' ? 'Regenerate' : job.type}
</Badge>
</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-1 h-1.5 bg-line rounded-full overflow-hidden">
<div
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>
<MiniProgressBar value={job.progress_percent} />
<span className="text-xs font-medium text-muted-foreground">{job.progress_percent}%</span>
</div>
)}
{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}
</p>
)}
{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>
{job.stats_json.errors > 0 && (
<span className="text-error"> {job.stats_json.errors}</span>
<span className="text-destructive"> {job.stats_json.errors}</span>
)}
</div>
)}
@@ -230,11 +253,23 @@ export function JobsIndicator() {
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-line bg-muted/5">
<p className="text-xs text-muted text-center">Auto-refreshing every 2s</p>
<div className="px-4 py-2 border-t border-border/60 bg-muted/50">
<p className="text-xs text-muted-foreground text-center">Auto-refreshing every 2s</p>
</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>
);
}

View File

@@ -2,7 +2,6 @@
import { useState, useEffect } from "react";
import { JobRow } from "./JobRow";
import { MiniProgressBar } from "./ui";
interface Job {
id: string;
@@ -45,18 +44,15 @@ function formatDate(dateStr: string): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
// Less than 1 hour: show relative
if (diff < 3600000) {
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
return `${mins}m ago`;
}
// Less than 24 hours: show hours
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours}h ago`;
}
// Otherwise: show date
return date.toLocaleDateString();
}
@@ -105,22 +101,23 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
};
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">
<table className="w-full">
<thead>
<tr className="border-b border-line bg-muted/5">
<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 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 uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Files</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Duration</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 uppercase tracking-wider">Actions</th>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">ID</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-foreground uppercase tracking-wider">Type</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-foreground uppercase tracking-wider">Files</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>
</thead>
<tbody className="divide-y divide-line">
<tbody className="divide-y divide-border/60">
{jobs.map((job) => (
<JobRow
key={job.id}

View File

@@ -1,8 +1,7 @@
"use client";
import { useState, useRef, useEffect, useTransition } from "react";
import Link from "next/link";
import { Button, Badge } from "../components/ui";
import { Button } from "../components/ui";
interface LibraryActionsProps {
libraryId: string;
@@ -70,13 +69,16 @@ export function LibraryActions({
variant="ghost"
size="sm"
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>
{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}>
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -86,7 +88,7 @@ export function LibraryActions({
name="monitor_enabled"
value="true"
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
</label>
@@ -99,7 +101,7 @@ export function LibraryActions({
name="watcher_enabled"
value="true"
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
</label>
@@ -110,7 +112,7 @@ export function LibraryActions({
<select
name="scan_mode"
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="hourly">Hourly</option>

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

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

View File

@@ -42,14 +42,14 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
isPending
? 'opacity-50 cursor-not-allowed'
: '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
type="checkbox"
name="monitor_enabled"
value="true"
defaultChecked={monitorEnabled}
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>
</label>
@@ -58,14 +58,14 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
isPending
? 'opacity-50 cursor-not-allowed'
: '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
type="checkbox"
name="watcher_enabled"
value="true"
defaultChecked={watcherEnabled}
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>
</label>
@@ -74,7 +74,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
name="scan_mode"
defaultValue={scanMode}
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="hourly">Hourly</option>

View File

@@ -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 {
children: React.ReactNode;
children: ReactNode;
variant?: BadgeVariant;
className?: string;
}
const variantStyles: Record<BadgeVariant, string> = {
default: "bg-muted/20 text-muted",
primary: "bg-primary-soft text-primary",
success: "bg-success-soft text-success",
warning: "bg-warning-soft text-warning",
error: "bg-error-soft text-error",
muted: "bg-muted/10 text-muted",
// shadcn/ui compatible
default: "bg-primary/90 text-primary-foreground border-transparent hover:bg-primary/80 backdrop-blur-md",
secondary: "bg-secondary/80 text-secondary-foreground border-transparent hover:bg-secondary/60 backdrop-blur-md",
destructive: "bg-destructive/90 text-destructive-foreground border-transparent hover:bg-destructive/80 backdrop-blur-md",
outline: "text-foreground border-border bg-background/50",
// 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) {
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}
</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 {
status: string;
className?: string;
}
const statusVariants: Record<StatusVariant, BadgeVariant> = {
running: "primary",
success: "success",
failed: "error",
cancelled: "muted",
pending: "warning",
};
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
const variant = statusVariants[status as StatusVariant] || "default";
return <Badge variant={variant} className={className}>{status}</Badge>;
const key = status.toLowerCase();
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 {
type: string;
className?: string;
}
const jobTypeVariants: Record<JobTypeVariant, BadgeVariant> = {
rebuild: "primary",
full_rebuild: "warning",
};
export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
const variant = jobTypeVariants[type as JobTypeVariant] || "default";
return <Badge variant={variant} className={className}>{type}</Badge>;
const key = type.toLowerCase();
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>
);
}

View File

@@ -1,6 +1,15 @@
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> {
children: ReactNode;
@@ -9,22 +18,29 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
}
const variantStyles: Record<ButtonVariant, string> = {
primary: "bg-primary text-white hover:bg-primary/90",
secondary: "border border-line text-muted hover:bg-muted/5",
danger: "bg-error text-white hover:bg-error/90",
warning: "bg-warning text-white hover:bg-warning/90",
ghost: "text-muted hover:text-foreground hover:bg-muted/5",
// shadcn/ui compatible variants
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
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> = {
sm: "h-8 px-3 text-xs",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
sm: "h-9 px-3 text-xs rounded-md",
md: "h-10 px-4 py-2 text-sm rounded-md",
lg: "h-11 px-8 text-base rounded-md",
};
export function Button({
children,
variant = "primary",
variant = "default",
size = "md",
className = "",
disabled,
@@ -33,8 +49,12 @@ export function Button({
return (
<button
className={`
inline-flex items-center justify-center font-medium rounded-lg transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
inline-flex items-center justify-center
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]}
${sizeStyles[size]}
${className}
@@ -46,3 +66,46 @@ export function 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>
);
}

View File

@@ -3,25 +3,146 @@ import { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
hover?: boolean;
}
export function Card({ children, className = "" }: CardProps) {
export function Card({ children, className = "", hover = true }: CardProps) {
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}
</div>
);
}
interface CardHeaderProps {
title: string;
children: ReactNode;
className?: string;
}
export function CardHeader({ title, className = "" }: CardHeaderProps) {
export function CardHeader({ children, className = "" }: CardHeaderProps) {
return (
<h2 className={`text-lg font-semibold text-foreground mb-4 ${className}`}>
{title}
</h2>
<div className={`flex flex-col space-y-1.5 p-6 ${className}`}>
{children}
</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>
);
}

View File

@@ -1,45 +1,81 @@
import { ReactNode, LabelHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes } from "react";
// Form Field Container
interface FormFieldProps {
children: ReactNode;
className?: string;
}
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> {
children: ReactNode;
required?: boolean;
}
export function FormLabel({ children, className = "", ...props }: FormLabelProps) {
export function FormLabel({ children, required, className = "", ...props }: FormLabelProps) {
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}
{required && <span className="text-destructive ml-1">*</span>}
</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 (
<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}
/>
);
}
// Form Select
interface FormSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
children: ReactNode;
error?: string;
}
export function FormSelect({ children, className = "", ...props }: FormSelectProps) {
export function FormSelect({ children, className = "", error, ...props }: FormSelectProps) {
return (
<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}
>
{children}
@@ -47,11 +83,64 @@ export function FormSelect({ children, className = "", ...props }: FormSelectPro
);
}
// Form Row (horizontal layout)
interface FormRowProps {
children: ReactNode;
className?: string;
}
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>
);
}

View File

@@ -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;
size?: IconSize;
className?: string;
}
const icons: Record<IconName, React.ReactNode> = {
dashboard: (
<svg className="w-8 h-8" 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" />
</svg>
),
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 sizeClasses: Record<IconSize, string> = {
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
xl: "w-8 h-8",
};
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",
books: "text-success",
libraries: "text-primary",
jobs: "text-warning",
tokens: "text-error",
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 (
<span className={`${colors[name]} ${className}`}>
{icons[name]}
</span>
<svg
className={`${sizeClass} ${colorClass || ""} ${className}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={icons[name]} />
</svg>
);
}
// Nav icons (smaller)
export function NavIcon({ name, className = "" }: { name: IconName; className?: string }) {
const navIcons: Record<IconName, React.ReactNode> = {
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" />
</svg>
),
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>;
// Backwards compatibility aliases
export function PageIcon({ name, className = "" }: { name: IconName; className?: string }) {
return <Icon name={name} size="xl" className={className} />;
}
export function NavIcon({ name, className = "" }: { name: IconName; className?: string }) {
return <Icon name={name} size="sm" className={className} />;
}

View File

@@ -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;
error?: string;
}
export function Input({ label, className = "", ...props }: InputProps) {
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ 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>
)}
<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}
/>
{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;
error?: string;
children: ReactNode;
}
export function Select({ label, children, className = "", ...props }: SelectProps) {
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ label, error, children, className = "", ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-foreground mb-1.5">
{label}
</label>
)}
<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}
>
{children}
</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";

View File

@@ -2,6 +2,7 @@
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "./Button";
import { IconButton } from "./Button";
interface CursorPaginationProps {
hasNextPage: boolean;
@@ -44,14 +45,14 @@ export function CursorPagination({
};
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-line">
<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">Show</span>
<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 border border-line rounded-lg bg-background text-foreground"
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}>
@@ -59,33 +60,38 @@ export function CursorPagination({
</option>
))}
</select>
<span className="text-sm text-muted">per page</span>
<span className="text-sm text-muted-foreground">per page</span>
</div>
{/* Count info */}
<div className="text-sm text-muted">
<div className="text-sm text-muted-foreground">
Showing {currentCount} items
</div>
{/* Navigation */}
<div className="flex items-center gap-3">
{hasPrevPage && (
<div className="flex items-center gap-2">
<Button
variant="secondary"
variant="outline"
size="sm"
onClick={goToFirst}
disabled={!hasPrevPage}
>
First
<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="secondary"
variant="outline"
size="sm"
onClick={goToNext}
disabled={!hasNextPage}
>
Next
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>
@@ -161,14 +167,14 @@ export function OffsetPagination({
};
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-line">
<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">Show</span>
<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 border border-line rounded-lg bg-background text-foreground"
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}>
@@ -176,34 +182,37 @@ export function OffsetPagination({
</option>
))}
</select>
<span className="text-sm text-muted">per page</span>
<span className="text-sm text-muted-foreground">per page</span>
</div>
{/* Page info */}
<div className="text-sm text-muted">
<div className="text-sm text-muted-foreground">
{startItem}-{endItem} of {totalItems}
</div>
{/* Page navigation */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
<div className="flex items-center gap-1">
<IconButton
size="sm"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
title="Previous page"
>
</Button>
<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">...</span>
<span className="px-3 py-2 text-sm text-muted-foreground">...</span>
) : (
<Button
variant={currentPage === page ? "primary" : "ghost"}
variant={currentPage === page ? "default" : "ghost"}
size="sm"
onClick={() => goToPage(page as number)}
className="min-w-[2.5rem]"
>
{page}
</Button>
@@ -211,14 +220,16 @@ export function OffsetPagination({
</span>
))}
<Button
variant="ghost"
<IconButton
size="sm"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages}
title="Next page"
>
</Button>
<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>
);

View File

@@ -3,13 +3,21 @@ interface ProgressBarProps {
max?: number;
showLabel?: boolean;
size?: "sm" | "md" | "lg";
variant?: "default" | "success" | "warning" | "error";
className?: string;
}
const sizeStyles = {
sm: "h-1.5",
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({
@@ -17,18 +25,19 @@ export function ProgressBar({
max = 100,
showLabel = false,
size = "md",
variant = "default",
className = ""
}: ProgressBarProps) {
const percent = Math.min(100, Math.max(0, (value / max) * 100));
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
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}%` }}
/>
{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)}%
</span>
)}
@@ -36,21 +45,112 @@ export function ProgressBar({
);
}
// Mini Progress Bar (for compact displays)
interface MiniProgressBarProps {
value: number;
max?: number;
variant?: "default" | "success" | "warning" | "error";
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));
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
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}%` }}
/>
</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>
);
}

View File

@@ -8,11 +8,11 @@ interface StatBoxProps {
}
const variantStyles: Record<string, string> = {
default: "bg-muted/5",
primary: "bg-primary-soft",
success: "bg-success-soft",
warning: "bg-warning-soft",
error: "bg-error-soft",
default: "bg-muted/50",
primary: "bg-primary/10",
success: "bg-success/10",
warning: "bg-warning/10",
error: "bg-destructive/10",
};
const valueVariantStyles: Record<string, string> = {
@@ -20,14 +20,14 @@ const valueVariantStyles: Record<string, string> = {
primary: "text-primary",
success: "text-success",
warning: "text-warning",
error: "text-error",
error: "text-destructive",
};
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
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={`text-xs ${valueVariantStyles[variant]}/80`}>{label}</span>
<span className={`text-xs text-muted-foreground`}>{label}</span>
</div>
);
}

View File

@@ -1,9 +1,21 @@
export { Card, CardHeader } from "./Card";
export { Badge, StatusBadge, JobTypeBadge } from "./Badge";
export {
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
GlassCard, SimpleCard
} from "./Card";
export {
Badge, StatusBadge, JobTypeBadge, ProgressBadge
} from "./Badge";
export { StatBox } from "./StatBox";
export { ProgressBar, MiniProgressBar } from "./ProgressBar";
export { Button } from "./Button";
export { Input, Select } from "./Input";
export { FormField, FormLabel, FormInput, FormSelect, FormRow } from "./Form";
export { PageIcon, NavIcon } from "./Icon";
export {
ProgressBar, MiniProgressBar, SmartProgressBar, CircularProgress
} from "./ProgressBar";
export { Button, IconButton } from "./Button";
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";

View File

@@ -1,14 +1,43 @@
@import "tailwindcss";
@theme {
/* Core Colors - Light Theme */
--color-background: hsl(36 33% 97%);
--color-foreground: hsl(222 33% 15%);
/* Card & Surfaces */
--color-card: hsl(0 0% 100%);
--color-line: hsl(32 18% 84%);
--color-line-strong: hsl(32 18% 76%);
--color-card-foreground: hsl(222 33% 15%);
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(222 33% 15%);
/* Primary - Cyan/Teal */
--color-primary: hsl(198 78% 37%);
--color-primary-foreground: hsl(210 40% 98%);
--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-soft: hsl(142 60% 90%);
--color-warning: hsl(45 93% 47%);
@@ -16,57 +45,159 @@
--color-error: hsl(2 72% 48%);
--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-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 {
--color-background: hsl(222 35% 10%);
--color-foreground: hsl(38 20% 92%);
--color-card: hsl(221 31% 13%);
--color-line: hsl(219 18% 25%);
--color-line-strong: hsl(219 18% 33%);
--color-card-foreground: hsl(38 20% 92%);
--color-popover: hsl(221 31% 13%);
--color-popover-foreground: hsl(38 20% 92%);
--color-primary: hsl(194 76% 62%);
--color-primary-foreground: hsl(222 35% 10%);
--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 {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
position: relative;
min-height: 100vh;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
/* Fond décoratif avec dégradés marqués */
body::before {
content: '';
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 {
background: transparent;
/* Dark mode - fond plus profond et marqué */
.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 {
background: var(--color-line);
border-radius: 4px;
/* Cercles décoratifs floutés plus visibles */
body::after {
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 {
background: var(--color-line-strong);
.dark body::after {
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 */
@@ -75,40 +206,54 @@ body {
color: var(--color-foreground);
}
/* Focus visible */
.dark ::selection {
background: hsl(194 76% 62% / 0.2);
}
/* Focus Visible */
:focus-visible {
outline: 2px solid var(--color-primary);
outline: 2px solid var(--color-ring);
outline-offset: 2px;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
/* Animations */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.animate-fade-in {
animation: fade-in 0.3s ease-in;
}
/* Custom utilities - use directly in components */
.shadow-soft {
box-shadow: var(--shadow-soft);
/* Line clamp utilities */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.shadow-card {
box-shadow: var(--shadow-card);
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Dark mode overrides */
.dark {
--shadow-soft: 0 1px 2px 0 rgb(2 8 18 / 0.35);
--shadow-card: 0 12px 30px -12px rgb(2 8 18 / 0.55);
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Hide scrollbar */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}

View File

@@ -1,7 +1,10 @@
import { notFound } from "next/navigation";
import Link from "next/link";
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 {
params: Promise<{ id: string }>;
@@ -85,8 +88,14 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
return (
<>
<div className="mb-6">
<Link href="/jobs" className="inline-flex items-center text-sm text-muted hover:text-primary transition-colors">
Back to jobs
<Link
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>
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
</div>
@@ -94,43 +103,47 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Overview Card */}
<Card>
<CardHeader title="Overview" />
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">ID</span>
<code className="px-2 py-1 bg-muted/10 rounded font-mono text-sm text-foreground">{job.id}</code>
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<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 className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Type</span>
<div className="flex items-center justify-between py-2 border-b border-border/60">
<span className="text-sm text-muted-foreground">Type</span>
<JobTypeBadge type={job.type} />
</div>
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Status</span>
<div className="flex items-center justify-between py-2 border-b border-border/60">
<span className="text-sm text-muted-foreground">Status</span>
<StatusBadge status={job.status} />
</div>
<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>
</div>
</div>
</CardContent>
</Card>
{/* Timeline Card */}
<Card>
<CardHeader title="Timeline" />
<div className="space-y-4">
<CardHeader>
<CardTitle>Timeline</CardTitle>
</CardHeader>
<CardContent className="space-y-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="flex-1">
<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 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="flex-1">
<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..."}
</p>
</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="flex-1">
<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
? new Date(job.finished_at).toLocaleString()
: job.started_at
@@ -149,41 +162,48 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
</p>
</div>
</div>
</div>
{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)}
</div>
)}
</CardContent>
</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>
<CardHeader title="Progress" />
{job.total_files && job.total_files > 0 && (
<CardHeader>
<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" />
<div className="grid grid-cols-3 gap-4">
<StatBox value={job.processed_files || 0} label="Processed" variant="primary" />
<StatBox value={job.total_files} label="Total" />
<StatBox value={job.total_files - (job.processed_files || 0)} label="Remaining" variant="warning" />
<StatBox value={job.processed_files ?? 0} label="Processed" variant="primary" />
<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" />
</div>
</>
)}
{job.current_file && (
<div className="mt-4 p-3 bg-muted/5 rounded-lg">
<span className="text-sm text-muted">Current file:</span>
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
<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>
</div>
)}
</CardContent>
</Card>
)}
{/* Statistics Card */}
{job.stats_json && (
<Card>
<CardHeader title="Statistics" />
<CardHeader>
<CardTitle>Statistics</CardTitle>
</CardHeader>
<CardContent>
<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.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"} />
</div>
{job.started_at && (
<div className="flex items-center justify-between py-2 border-t border-line">
<span className="text-sm text-muted">Speed:</span>
<div className="flex items-center justify-between py-2 border-t border-border/60">
<span className="text-sm text-muted-foreground">Speed:</span>
<span className="text-sm font-medium text-foreground">{formatSpeed(job.stats_json, duration)}</span>
</div>
)}
</CardContent>
</Card>
)}
{/* Errors Card */}
{errors.length > 0 && (
<Card className="lg:col-span-2">
<CardHeader title={`Errors (${errors.length})`} />
<div className="space-y-2 max-h-80 overflow-y-auto">
<CardHeader>
<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) => (
<div key={error.id} className="p-3 bg-error-soft rounded-lg">
<code className="block text-sm font-mono text-error mb-1">{error.file_path}</code>
<p className="text-sm text-error/80">{error.error_message}</p>
<span className="text-xs text-muted">{new Date(error.created_at).toLocaleString()}</span>
<div key={error.id} className="p-3 bg-destructive/10 rounded-lg border border-destructive/20">
<code className="block text-sm font-mono text-destructive mb-1">{error.file_path}</code>
<p className="text-sm text-destructive/80">{error.error_message}</p>
<span className="text-xs text-muted-foreground">{new Date(error.created_at).toLocaleString()}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Error Message */}
{job.error_opt && (
<Card className="lg:col-span-2">
<CardHeader title="Error" />
<pre className="p-4 bg-error-soft rounded-lg text-sm text-error overflow-x-auto">{job.error_opt}</pre>
<CardHeader>
<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>
)}
</div>

View File

@@ -1,8 +1,8 @@
import { revalidatePath } from "next/cache";
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 { 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";
@@ -31,17 +31,42 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
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 (
<>
<h1 className="text-3xl font-bold text-foreground mb-6 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>
<div className="mb-6">
<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
</h1>
</div>
<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}>
<FormRow>
<FormField>
<FormField className="flex-1">
<FormSelect name="library_id" defaultValue="">
<option value="">All libraries</option>
{libraries.map((lib) => (
@@ -51,13 +76,18 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
))}
</FormSelect>
</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>
</form>
<form action={triggerFullRebuild} className="mt-3">
<form action={triggerFullRebuild}>
<FormRow>
<FormField>
<FormField className="flex-1">
<FormSelect name="library_id" defaultValue="">
<option value="">All libraries</option>
{libraries.map((lib) => (
@@ -67,9 +97,57 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
))}
</FormSelect>
</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>
</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>
<JobsList

View File

@@ -6,63 +6,83 @@ import "./globals.css";
import { ThemeProvider } from "./theme-provider";
import { ThemeToggle } from "./theme-toggle";
import { JobsIndicator } from "./components/JobsIndicator";
import { NavIcon } from "./components/ui";
import { NavIcon, Icon } from "./components/ui";
export const metadata: Metadata = {
title: "Stripstream Backoffice",
description: "Backoffice administration for Stripstream Librarian"
title: "StripStream Backoffice",
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 }) {
return (
<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>
{/* Navigation */}
<nav className="sticky top-0 z-50 w-full border-b border-line bg-card/80 backdrop-blur-md">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
{/* Header avec effet glassmorphism */}
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-background/60">
<nav className="container mx-auto flex h-16 items-center justify-between px-4">
{/* Brand */}
<Link href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity duration-200"
>
<Image
src="/logo.png"
alt="Stripstream"
alt="StripStream"
width={36}
height={36}
className="rounded-lg"
/>
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tracking-tight">StripStream</span>
<span className="text-sm text-muted font-medium">backoffice</span>
<span className="text-xl font-bold tracking-tight text-foreground">
StripStream
</span>
<span className="text-sm text-muted-foreground font-medium">
backoffice
</span>
</div>
</Link>
{/* 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">
<NavLink href="/">
<NavIcon name="dashboard" /> Dashboard
</NavLink>
<NavLink href="/books">
<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
{navItems.map((item) => (
<NavLink key={item.href} href={item.href}>
<NavIcon name={item.icon} />
<span className="ml-2">{item.label}</span>
</NavLink>
))}
</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 />
<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 />
</div>
</div>
</div>
</nav>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
@@ -75,11 +95,21 @@ export default function RootLayout({ children }: { children: ReactNode }) {
}
// 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 (
<Link
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}
</Link>

View File

@@ -1,7 +1,7 @@
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
import { BooksGrid, EmptyState } from "../../../components/BookCard";
import { Card, Badge, Button, CursorPagination } from "../../../components/ui";
import Link from "next/link";
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
import { CursorPagination } from "../../../components/ui";
import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
@@ -42,38 +42,22 @@ export default async function LibraryBooksPage({
const hasPrevPage = !!cursor;
return (
<>
<div className="mb-6">
<Link href="/libraries" className="text-sm text-muted hover:text-primary transition-colors"> Back to libraries</Link>
</div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3 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>
{library.name}
</h1>
<Card className="mb-6">
<div className="flex flex-wrap items-center gap-3 text-sm">
<code className="text-xs font-mono text-muted bg-muted/10 px-2 py-1 rounded">{library.root_path}</code>
<span className="text-muted">|</span>
<span className="text-foreground">{library.book_count} book{library.book_count !== 1 ? 's' : ''}</span>
<span className="text-muted">|</span>
<Badge variant={library.enabled ? "success" : "muted"}>
{library.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</Card>
<div className="flex items-center gap-4 mb-6">
<h2 className="text-xl font-semibold text-foreground">
{series ? `Books in "${seriesDisplayName}"` : "All Books"}
</h2>
{series && (
<Link href={`/libraries/${id}/books`} className="text-sm text-primary hover:text-primary/80">
View all
</Link>
)}
</div>
<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 ? (
<>
@@ -90,6 +74,6 @@ export default async function LibraryBooksPage({
) : (
<EmptyState message={series ? `No books in series "${seriesDisplayName}"` : "No books in this library yet"} />
)}
</>
</div>
);
}

View File

@@ -1,53 +1,53 @@
import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto } from "../../../../lib/api";
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 { Card, Badge } from "../../../components/ui";
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
export const dynamic = "force-dynamic";
export default async function LibrarySeriesPage({
params
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, series] = await Promise.all([
const [library, seriesPage] = await Promise.all([
fetchLibraries().then(libs => libs.find(l => l.id === id)),
fetchSeries(id).catch(() => [] as SeriesDto[])
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="mb-6">
<Link href="/libraries" className="text-sm text-muted hover:text-primary transition-colors"> Back to libraries</Link>
</div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3 mb-6">
<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="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>
{library.name}
</h1>
<Card className="mb-6">
<div className="flex flex-wrap items-center gap-3 text-sm">
<code className="text-xs font-mono text-muted bg-muted/10 px-2 py-1 rounded">{library.root_path}</code>
<span className="text-muted">|</span>
<span className="text-foreground">{library.book_count} book{library.book_count !== 1 ? 's' : ''}</span>
<span className="text-muted">|</span>
<Badge variant={library.enabled ? "success" : "muted"}>
{library.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</Card>
<h2 className="text-xl font-semibold text-foreground mb-6">Series ({series.length})</h2>
<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
@@ -55,8 +55,8 @@ export default async function LibrarySeriesPage({
href={`/libraries/${id}/books?series=${encodeURIComponent(s.name)}`}
className="group"
>
<div className="bg-card rounded-xl shadow-soft border border-line overflow-hidden hover:shadow-card transition-shadow">
<div className="aspect-[2/3] relative bg-muted/10">
<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}`}
@@ -69,7 +69,7 @@ export default async function LibrarySeriesPage({
<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 mt-1">
<p className="text-xs text-muted-foreground mt-1">
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
</p>
</div>
@@ -77,11 +77,20 @@ export default async function LibrarySeriesPage({
</Link>
))}
</div>
<CursorPagination
hasNextPage={hasNextPage}
hasPrevPage={hasPrevPage}
pageSize={limit}
currentCount={series.length}
nextCursor={nextCursor}
/>
</>
) : (
<div className="text-center py-12 text-muted">
<div className="text-center py-12 text-muted-foreground">
<p>No series found in this library</p>
</div>
)}
</>
</div>
);
}

View File

@@ -2,7 +2,11 @@ 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 { Card, CardHeader, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
import { LibraryForm } from "../components/LibraryForm";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
Button, Badge
} from "../components/ui";
export const dynamic = "force-dynamic";
@@ -28,8 +32,8 @@ export default async function LibrariesPage() {
const seriesCounts = await Promise.all(
libraries.map(async (lib) => {
try {
const series = await fetchSeries(lib.id);
return { id: lib.id, count: series.length };
const seriesPage = await fetchSeries(lib.id);
return { id: lib.id, count: seriesPage.items.length };
} catch {
return { id: lib.id, count: 0 };
}
@@ -73,44 +77,36 @@ export default async function LibrariesPage() {
return (
<>
<h1 className="text-3xl font-bold text-foreground mb-6 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>
<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 title="Add New Library" />
<form action={addLibrary}>
<FormRow>
<FormField>
<FormInput name="name" placeholder="Library name" required />
</FormField>
<FormField>
<FormSelect name="root_path" required defaultValue="">
<option value="" disabled>Select folder...</option>
{folders.map((folder) => (
<option key={folder.path} value={folder.path}>
{folder.name}
</option>
))}
</FormSelect>
</FormField>
<Button type="submit"> Add Library</Button>
</FormRow>
</form>
<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-6">
<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">
{/* Header with settings */}
<div className="flex items-start justify-between mb-3">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold text-foreground">{lib.name}</h3>
<CardTitle className="text-lg">{lib.name}</CardTitle>
{!lib.enabled && <Badge variant="muted" className="mt-1">Disabled</Badge>}
</div>
<LibraryActions
@@ -120,58 +116,74 @@ export default async function LibrariesPage() {
watcherEnabled={lib.watcher_enabled}
/>
</div>
</CardHeader>
<CardContent className="flex-1 pt-0">
{/* Path */}
<code className="text-xs font-mono text-muted mb-4 break-all">{lib.root_path}</code>
<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/5 rounded-lg hover:bg-muted/10 transition-colors">
<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">Books</span>
<span className="text-xs text-muted-foreground">Books</span>
</Link>
<Link href={`/libraries/${lib.id}/series`} className="text-center p-3 bg-muted/5 rounded-lg hover:bg-muted/10 transition-colors">
<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">Series</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'}`}>
<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 ml-auto">
<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 mt-auto">
<div className="flex items-center gap-2">
<form className="flex-1">
<input type="hidden" name="id" value={lib.id} />
<Button type="submit" variant="primary" size="sm" className="w-full" formAction={scanLibraryAction}>
🔄 Index
<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}>
🔁 Full
<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="danger" size="sm" formAction={removeLibrary}>
🗑
<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>
);
})}

View File

@@ -1,76 +1,76 @@
export default function DashboardPage() {
return (
<div className="max-w-4xl mx-auto">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold tracking-tight mb-4">
Stripstream Backoffice
<h1 className="text-4xl font-bold tracking-tight mb-4 text-foreground">
StripStream Backoffice
</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.
</p>
</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 */}
<a
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">
<svg className="w-6 h-6 text-primary group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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-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" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">Libraries</h2>
<p className="text-muted text-sm">Manage your comic libraries and folders</p>
<h2 className="text-xl font-semibold mb-2 text-foreground">Libraries</h2>
<p className="text-muted-foreground text-sm leading-relaxed">Manage your comic libraries and folders</p>
</a>
{/* Books Card */}
<a
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">
<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>
</div>
<h2 className="text-xl font-semibold mb-2">Books</h2>
<p className="text-muted text-sm">Browse and search your comic collection</p>
<h2 className="text-xl font-semibold mb-2 text-foreground">Books</h2>
<p className="text-muted-foreground text-sm leading-relaxed">Browse and search your comic collection</p>
</a>
{/* Jobs Card */}
<a
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">Jobs</h2>
<p className="text-muted text-sm">Monitor indexing jobs and progress</p>
<h2 className="text-xl font-semibold mb-2 text-foreground">Jobs</h2>
<p className="text-muted-foreground text-sm leading-relaxed">Monitor indexing jobs and progress</p>
</a>
{/* Tokens Card */}
<a
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">
<svg className="w-6 h-6 text-error group-hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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-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" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">Tokens</h2>
<p className="text-muted text-sm">Manage API authentication tokens</p>
<h2 className="text-xl font-semibold mb-2 text-foreground">Tokens</h2>
<p className="text-muted-foreground text-sm leading-relaxed">Manage API authentication tokens</p>
</a>
</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>
<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.
</p>
</div>

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

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

View File

@@ -3,26 +3,127 @@
import { useEffect, useState } from "react";
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() {
const { theme, setTheme, resolvedTheme } = useTheme();
const { theme, setTheme, resolvedTheme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
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 nextTheme = activeTheme === "dark" ? "light" : "dark";
const toggleTheme = () => {
if (theme === "system") {
setTheme(systemTheme === "dark" ? "light" : "dark");
} else {
setTheme(theme === "dark" ? "light" : "dark");
}
};
return (
<button
type="button"
className="theme-toggle"
onClick={() => setTheme(nextTheme)}
aria-label="Toggle color theme"
disabled={!mounted}
onClick={toggleTheme}
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"
title={activeTheme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
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>
);
}
// 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>
);
}

View File

@@ -1,7 +1,7 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
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";
@@ -33,68 +33,90 @@ export default async function TokensPage({
return (
<>
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
<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>
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<svg className="w-8 h-8 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
API Tokens
</h1>
</div>
{params.created ? (
<Card className="mb-6">
<strong className="text-foreground block mb-2">Token created (copy it now, it won't be shown again):</strong>
<pre className="p-4 bg-muted/10 rounded-lg text-sm font-mono text-foreground overflow-x-auto">{params.created}</pre>
<Card className="mb-6 border-success/50 bg-success/5">
<CardHeader>
<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>
) : null}
<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}>
<FormRow>
<FormField>
<FormInput name="name" placeholder="token name" required />
<FormField className="flex-1 min-w-48">
<FormInput name="name" placeholder="Token name" required />
</FormField>
<FormField>
<FormField className="w-32">
<FormSelect name="scope" defaultValue="read">
<option value="read">read</option>
<option value="admin">admin</option>
<option value="read">Read</option>
<option value="admin">Admin</option>
</FormSelect>
</FormField>
<Button type="submit"> Create Token</Button>
<Button type="submit">Create Token</Button>
</FormRow>
</form>
</CardContent>
</Card>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-line bg-muted/5">
<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 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 uppercase tracking-wider">Revoked</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th>
<tr className="border-b border-border/60 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Name</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-foreground uppercase tracking-wider">Prefix</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-foreground uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
<tbody className="divide-y divide-border/60">
{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.scope}</td>
<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 className="px-4 py-3 text-sm">
{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 className="px-4 py-3">
{!token.revoked_at && (
<form action={revokeTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="danger" size="sm">
🚫 Revoke
<Button type="submit" variant="destructive" size="sm">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Revoke
</Button>
</form>
)}

View File

@@ -41,6 +41,8 @@ export type TokenDto = {
export type FolderItem = {
name: string;
path: string;
depth: number;
has_children: boolean;
};
export type BookDto = {
@@ -96,7 +98,10 @@ function config() {
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 headers = new Headers(init?.headers || {});
headers.set("Authorization", `Bearer ${token}`);
@@ -107,7 +112,7 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
const res = await fetch(`${baseUrl}${path}`, {
...init,
headers,
cache: "no-store"
cache: "no-store",
});
if (!res.ok) {
@@ -128,7 +133,7 @@ export async function fetchLibraries() {
export async function createLibrary(name: string, rootPath: string) {
return apiFetch<LibraryDto>("/libraries", {
method: "POST",
body: JSON.stringify({ name, root_path: rootPath })
body: JSON.stringify({ name, root_path: rootPath }),
});
}
@@ -141,12 +146,21 @@ export async function scanLibrary(libraryId: string, full?: boolean) {
if (full) body.full = true;
return apiFetch<IndexJobDto>(`/libraries/${libraryId}/scan`, {
method: "POST",
body: JSON.stringify(body)
body: JSON.stringify(body),
});
}
export async function updateLibraryMonitoring(libraryId: string, monitorEnabled: boolean, scanMode: string, watcherEnabled?: boolean) {
const body: { monitor_enabled: boolean; scan_mode: string; watcher_enabled?: boolean } = {
export async function updateLibraryMonitoring(
libraryId: string,
monitorEnabled: boolean,
scanMode: string,
watcherEnabled?: boolean,
) {
const body: {
monitor_enabled: boolean;
scan_mode: string;
watcher_enabled?: boolean;
} = {
monitor_enabled: monitorEnabled,
scan_mode: scanMode,
};
@@ -155,7 +169,7 @@ export async function updateLibraryMonitoring(libraryId: string, monitorEnabled:
}
return apiFetch<LibraryDto>(`/libraries/${libraryId}/monitoring`, {
method: "PATCH",
body: JSON.stringify(body)
body: JSON.stringify(body),
});
}
@@ -169,7 +183,25 @@ export async function rebuildIndex(libraryId?: string, full?: boolean) {
if (full) body.full = true;
return apiFetch<IndexJobDto>("/index/rebuild", {
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),
});
}
@@ -177,8 +209,9 @@ export async function cancelJob(id: string) {
return apiFetch<IndexJobDto>(`/index/cancel/${id}`, { method: "POST" });
}
export async function listFolders() {
return apiFetch<FolderItem[]>("/folders");
export async function listFolders(path?: string) {
const url = path ? `/folders?path=${encodeURIComponent(path)}` : "/folders";
return apiFetch<FolderItem[]>(url);
}
export async function listTokens() {
@@ -188,7 +221,7 @@ export async function listTokens() {
export async function createToken(name: string, scope: string) {
return apiFetch<{ token: string }>("/admin/tokens", {
method: "POST",
body: JSON.stringify({ name, scope })
body: JSON.stringify({ name, scope }),
});
}
@@ -196,7 +229,12 @@ export async function revokeToken(id: string) {
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();
if (libraryId) params.set("library_id", libraryId);
if (series) params.set("series", series);
@@ -206,11 +244,30 @@ export async function fetchBooks(libraryId?: string, series?: string, cursor?: s
return apiFetch<BooksPageDto>(`/books?${params.toString()}`);
}
export async function fetchSeries(libraryId: string): Promise<SeriesDto[]> {
return apiFetch<SeriesDto[]>(`/libraries/${libraryId}/series`);
export type SeriesPageDto = {
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();
params.set("q", query);
if (libraryId) params.set("library_id", libraryId);
@@ -220,7 +277,74 @@ export async function searchBooks(query: string, libraryId?: string, limit: numb
}
export function getBookCoverUrl(bookId: string): string {
// Utiliser une route API locale pour éviter les problèmes CORS
// Le navigateur ne peut pas accéder à http://api:8080 (hostname Docker interne)
return `/api/books/${bookId}/pages/1?format=webp&width=200`;
return `/api/books/${bookId}/thumbnail`;
}
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");
}

View File

@@ -14,6 +14,7 @@
"react-dom": "19.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "22.13.14",
"@types/react": "19.0.12",
"@types/react-dom": "19.0.5",
@@ -23,6 +24,19 @@
"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": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
@@ -547,6 +561,56 @@
"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": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
@@ -702,6 +766,289 @@
"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": {
"version": "22.13.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
@@ -852,8 +1199,8 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -865,6 +1212,20 @@
"dev": true,
"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": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -889,6 +1250,306 @@
"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": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1171,6 +1832,20 @@
"dev": true,
"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": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@@ -3,9 +3,9 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 8082",
"dev": "next dev -p 7082",
"build": "next build",
"start": "next start -p 8082"
"start": "next start -p 7082"
},
"dependencies": {
"next": "^16.1.6",
@@ -14,6 +14,7 @@
"react-dom": "19.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "22.13.14",
"@types/react": "19.0.12",
"@types/react-dom": "19.0.5",

File diff suppressed because one or more lines are too long

View File

@@ -10,6 +10,8 @@ axum.workspace = true
chrono.workspace = true
notify = "6.1"
parsers = { path = "../../crates/parsers" }
rand.workspace = true
rayon.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -1,19 +1,24 @@
FROM rust:1-bookworm AS builder
WORKDIR /app
# Install sccache for faster builds
RUN cargo install sccache --locked
ENV RUSTC_WRAPPER=sccache
ENV SCCACHE_DIR=/sccache
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 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
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

View File

@@ -12,10 +12,12 @@ pub struct ApiConfig {
impl ApiConfig {
pub fn from_env() -> Result<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")?,
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")
.context("API_BOOTSTRAP_TOKEN is required")?,
})
@@ -29,20 +31,76 @@ pub struct IndexerConfig {
pub meili_url: String,
pub meili_master_key: String,
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 {
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 {
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")?,
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")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.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 {
listen_addr: std::env::var("ADMIN_UI_LISTEN_ADDR")
.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_token: std::env::var("API_BOOTSTRAP_TOKEN").context("API_BOOTSTRAP_TOKEN is required")?,
api_base_url: std::env::var("API_BASE_URL")
.unwrap_or_else(|_| "http://api:8080".to_string()),
api_token: std::env::var("API_BOOTSTRAP_TOKEN")
.context("API_BOOTSTRAP_TOKEN is required")?,
})
}
}

View File

@@ -8,4 +8,6 @@ license.workspace = true
anyhow.workspace = true
lopdf = "0.35"
regex = "1"
uuid.workspace = true
walkdir.workspace = true
zip = { version = "2.2", default-features = false, features = ["deflate"] }

View File

@@ -1,5 +1,9 @@
use anyhow::{Context, Result};
use std::io::Read;
use std::path::Path;
use std::process::Command;
use uuid::Uuid;
use walkdir::WalkDir;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BookFormat {
@@ -54,16 +58,47 @@ pub fn parse_metadata(
// Determine series from parent folder relative to library root
let series = path.parent().and_then(|parent| {
// Get the relative path from library root to parent
let relative = parent.strip_prefix(library_root).ok()?;
// If relative path is not empty, use first component as series
let first_component = relative.components().next()?;
let series_name = first_component.as_os_str().to_string_lossy().to_string();
// Only if series_name is not empty
// Normalize paths for comparison (handle different separators, etc.)
let parent_str = parent.to_string_lossy().to_string();
let root_str = library_root.to_string_lossy().to_string();
// Try to find the library root in the parent path
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() {
None
} 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(".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)
}

View File

@@ -6,7 +6,7 @@ services:
POSTGRES_USER: stripstream
POSTGRES_PASSWORD: stripstream
ports:
- "5432:5432"
- "6432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -54,9 +54,10 @@ services:
env_file:
- ../.env
ports:
- "8080:8080"
- "7080:8080"
volumes:
- ../libraries:/libraries
- ${LIBRARIES_HOST_PATH:-../libraries}:/libraries
- ${THUMBNAILS_HOST_PATH:-../data/thumbnails}:/data/thumbnails
depends_on:
migrate:
condition: service_completed_successfully
@@ -77,9 +78,10 @@ services:
env_file:
- ../.env
ports:
- "8081:8081"
- "7081:8081"
volumes:
- ../libraries:/libraries
- ${LIBRARIES_HOST_PATH:-../libraries}:/libraries
- ${THUMBNAILS_HOST_PATH:-../data/thumbnails}:/data/thumbnails
depends_on:
migrate:
condition: service_completed_successfully
@@ -103,12 +105,12 @@ services:
- PORT=8082
- HOST=0.0.0.0
ports:
- "${BACKOFFICE_PORT:-8082}:8082"
- "7082:8082"
depends_on:
api:
condition: service_healthy
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
timeout: 5s
retries: 5

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

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

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

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

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