Compare commits
10 Commits
323661f770
...
1dca1099cf
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dca1099cf | |||
| ff34b2bbf4 | |||
| a7fe565a1f | |||
| 8a9a8634f8 | |||
| 5683fb8d25 | |||
| fa574586ed | |||
| c421f427b0 | |||
| 5d7524f52e | |||
| 762587dcb3 | |||
| b6cd8a895d |
47
.env.example
47
.env.example
@@ -1,9 +1,50 @@
|
||||
# Stripstream Librarian - Environment Configuration
|
||||
|
||||
# =============================================================================
|
||||
# REQUIRED - Change these values in production!
|
||||
# =============================================================================
|
||||
|
||||
# Master key for Meilisearch authentication (required)
|
||||
MEILI_MASTER_KEY=change-me-in-production
|
||||
|
||||
# Bootstrap token for initial API admin access (required)
|
||||
# Use this token for the first API calls before creating proper API tokens
|
||||
API_BOOTSTRAP_TOKEN=change-me-in-production
|
||||
|
||||
# =============================================================================
|
||||
# Services Configuration
|
||||
# =============================================================================
|
||||
|
||||
# API Service
|
||||
API_LISTEN_ADDR=0.0.0.0:8080
|
||||
BACKOFFICE_PORT=8082
|
||||
API_BASE_URL=http://api:8080
|
||||
|
||||
# Indexer Service
|
||||
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
|
||||
MEILI_URL=http://meilisearch:7700
|
||||
MEILI_MASTER_KEY=change-me
|
||||
API_BOOTSTRAP_TOKEN=change-me-bootstrap-token
|
||||
|
||||
# =============================================================================
|
||||
# Storage Configuration
|
||||
# =============================================================================
|
||||
|
||||
# Path to libraries directory
|
||||
# In Docker: leave as default /libraries
|
||||
# For local dev: set to your local libraries folder path
|
||||
LIBRARIES_ROOT_PATH=/libraries
|
||||
|
||||
151
README.md
Normal file
151
README.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Stripstream Librarian
|
||||
|
||||
A comprehensive comic book and e-book management system with automatic indexing, full-text search, and a modern web interface.
|
||||
|
||||
## Architecture
|
||||
|
||||
The project consists of the following components:
|
||||
|
||||
- **API** (`apps/api/`) - Rust-based REST API service
|
||||
- **Indexer** (`apps/indexer/`) - Rust-based background indexing service
|
||||
- **Backoffice** (`apps/backoffice/`) - Next.js web administration interface
|
||||
- **Infrastructure** (`infra/`) - Docker Compose setup with PostgreSQL and Meilisearch
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker and Docker Compose
|
||||
- (Optional) Rust toolchain for local development
|
||||
- (Optional) Node.js 18+ for backoffice development
|
||||
|
||||
### Environment Setup
|
||||
|
||||
1. Copy the example environment file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edit `.env` and set secure values for:
|
||||
- `MEILI_MASTER_KEY` - Master key for Meilisearch
|
||||
- `API_BOOTSTRAP_TOKEN` - Bootstrap token for initial API authentication
|
||||
|
||||
### Running with Docker
|
||||
|
||||
```bash
|
||||
cd infra
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This will start:
|
||||
- PostgreSQL (port 5432)
|
||||
- Meilisearch (port 7700)
|
||||
- API service (port 8080)
|
||||
- Indexer service (port 8081)
|
||||
- Backoffice web UI (port 8082)
|
||||
|
||||
### Accessing the Application
|
||||
|
||||
- **Backoffice**: http://localhost:8082
|
||||
- **API**: http://localhost:8080
|
||||
- **Meilisearch**: http://localhost:7700
|
||||
|
||||
### Default Credentials
|
||||
|
||||
The default bootstrap token is configured in your `.env` file. Use this for initial API authentication.
|
||||
|
||||
## Development
|
||||
|
||||
### Local Development (without Docker)
|
||||
|
||||
#### API & Indexer (Rust)
|
||||
|
||||
```bash
|
||||
# Start dependencies
|
||||
cd infra
|
||||
docker compose up -d postgres meilisearch
|
||||
|
||||
# Run API
|
||||
cd apps/api
|
||||
cargo run
|
||||
|
||||
# Run Indexer (in another terminal)
|
||||
cd apps/indexer
|
||||
cargo run
|
||||
```
|
||||
|
||||
#### Backoffice (Next.js)
|
||||
|
||||
```bash
|
||||
cd apps/backoffice
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The backoffice will be available at http://localhost:3000
|
||||
|
||||
## Features
|
||||
|
||||
### Libraries Management
|
||||
- Create and manage multiple libraries
|
||||
- Configure automatic scanning schedules (hourly, daily, weekly)
|
||||
- Real-time file watcher for instant indexing
|
||||
- Full and incremental rebuild options
|
||||
|
||||
### Books Management
|
||||
- Support for CBZ, CBR, and PDF formats
|
||||
- Automatic metadata extraction
|
||||
- Series and volume detection
|
||||
- Full-text search with Meilisearch
|
||||
|
||||
### Jobs Monitoring
|
||||
- Real-time job progress tracking
|
||||
- Detailed statistics (scanned, indexed, removed, errors)
|
||||
- Job history and logs
|
||||
- Cancel pending jobs
|
||||
|
||||
### Search
|
||||
- Full-text search across titles, authors, and series
|
||||
- Library filtering
|
||||
- Real-time suggestions
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `API_LISTEN_ADDR` | API service bind address | `0.0.0.0:8080` |
|
||||
| `INDEXER_LISTEN_ADDR` | Indexer service bind address | `0.0.0.0:8081` |
|
||||
| `BACKOFFICE_PORT` | Backoffice web UI port | `8082` |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgres://stripstream:stripstream@postgres:5432/stripstream` |
|
||||
| `MEILI_URL` | Meilisearch connection URL | `http://meilisearch:7700` |
|
||||
| `MEILI_MASTER_KEY` | Meilisearch master key (required) | - |
|
||||
| `API_BOOTSTRAP_TOKEN` | Initial API admin token (required) | - |
|
||||
| `INDEXER_SCAN_INTERVAL_SECONDS` | Watcher scan interval | `5` |
|
||||
| `LIBRARIES_ROOT_PATH` | Path to libraries directory | `/libraries` |
|
||||
|
||||
## API Documentation
|
||||
|
||||
The API is documented with OpenAPI/Swagger. When running locally, access the docs at:
|
||||
|
||||
```
|
||||
http://localhost:8080/api-docs
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
stripstream-librarian/
|
||||
├── apps/
|
||||
│ ├── api/ # Rust REST API
|
||||
│ ├── indexer/ # Rust background indexer
|
||||
│ └── backoffice/ # Next.js web UI
|
||||
├── infra/
|
||||
│ ├── docker-compose.yml
|
||||
│ └── migrations/ # SQL database migrations
|
||||
├── libraries/ # Book storage (mounted volume)
|
||||
└── .env # Environment configuration
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[Your License Here]
|
||||
@@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "admin-ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
stripstream-core = { path = "../../crates/core" }
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
@@ -1,22 +0,0 @@
|
||||
FROM rust:1-bookworm AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml ./
|
||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||
COPY apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
|
||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||
COPY apps/api/src apps/api/src
|
||||
COPY apps/indexer/src apps/indexer/src
|
||||
COPY apps/admin-ui/src apps/admin-ui/src
|
||||
COPY crates/core/src crates/core/src
|
||||
COPY crates/parsers/src crates/parsers/src
|
||||
|
||||
RUN cargo build --release -p admin-ui
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/target/release/admin-ui /usr/local/bin/admin-ui
|
||||
EXPOSE 8082
|
||||
CMD ["/usr/local/bin/admin-ui"]
|
||||
@@ -1,337 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Form, State},
|
||||
response::{Html, Redirect},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use stripstream_core::config::AdminUiConfig;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
api_base_url: String,
|
||||
api_token: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LibraryDto {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
root_path: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IndexJobDto {
|
||||
id: Uuid,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
status: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenDto {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
scope: String,
|
||||
prefix: String,
|
||||
revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreatedToken {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AddLibraryForm {
|
||||
name: String,
|
||||
root_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeleteLibraryForm {
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RebuildForm {
|
||||
library_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateTokenForm {
|
||||
name: String,
|
||||
scope: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RevokeTokenForm {
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "admin_ui=info,axum=info".to_string()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = AdminUiConfig::from_env()?;
|
||||
let state = AppState {
|
||||
api_base_url: config.api_base_url,
|
||||
api_token: config.api_token,
|
||||
client: Client::new(),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/", get(index))
|
||||
.route("/libraries", get(libraries_page))
|
||||
.route("/libraries/add", post(add_library))
|
||||
.route("/libraries/delete", post(delete_library))
|
||||
.route("/jobs", get(jobs_page))
|
||||
.route("/jobs/rebuild", post(rebuild_jobs))
|
||||
.route("/tokens", get(tokens_page))
|
||||
.route("/tokens/create", post(create_token))
|
||||
.route("/tokens/revoke", post(revoke_token))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
|
||||
info!(addr = %config.listen_addr, "admin ui listening");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
async fn index() -> Html<String> {
|
||||
Html(layout(
|
||||
"Dashboard",
|
||||
"<h1>Stripstream Admin</h1><p>Gestion des librairies, jobs d'indexation et tokens API.</p><p><a href='/libraries'>Libraries</a> | <a href='/jobs'>Jobs</a> | <a href='/tokens'>Tokens</a></p>",
|
||||
))
|
||||
}
|
||||
|
||||
async fn libraries_page(State(state): State<AppState>) -> Html<String> {
|
||||
let libraries = fetch_libraries(&state).await.unwrap_or_default();
|
||||
let mut rows = String::new();
|
||||
for lib in libraries {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>{}</td><td><code>{}</code></td><td>{}</td><td><form method='post' action='/libraries/delete'><input type='hidden' name='id' value='{}'/><button type='submit'>Delete</button></form></td></tr>",
|
||||
html_escape(&lib.name),
|
||||
html_escape(&lib.root_path),
|
||||
if lib.enabled { "yes" } else { "no" },
|
||||
lib.id
|
||||
));
|
||||
}
|
||||
|
||||
let content = format!(
|
||||
"<h1>Libraries</h1>
|
||||
<form method='post' action='/libraries/add'>
|
||||
<input name='name' placeholder='Name' required />
|
||||
<input name='root_path' placeholder='/libraries/demo' required />
|
||||
<button type='submit'>Add</button>
|
||||
</form>
|
||||
<table border='1' cellpadding='6'>
|
||||
<tr><th>Name</th><th>Root Path</th><th>Enabled</th><th>Actions</th></tr>
|
||||
{}
|
||||
</table>",
|
||||
rows
|
||||
);
|
||||
|
||||
Html(layout("Libraries", &content))
|
||||
}
|
||||
|
||||
async fn add_library(State(state): State<AppState>, Form(form): Form<AddLibraryForm>) -> Redirect {
|
||||
let _ = state
|
||||
.client
|
||||
.post(format!("{}/libraries", state.api_base_url))
|
||||
.bearer_auth(&state.api_token)
|
||||
.json(&serde_json::json!({"name": form.name, "root_path": form.root_path}))
|
||||
.send()
|
||||
.await;
|
||||
Redirect::to("/libraries")
|
||||
}
|
||||
|
||||
async fn delete_library(State(state): State<AppState>, Form(form): Form<DeleteLibraryForm>) -> Redirect {
|
||||
let _ = state
|
||||
.client
|
||||
.delete(format!("{}/libraries/{}", state.api_base_url, form.id))
|
||||
.bearer_auth(&state.api_token)
|
||||
.send()
|
||||
.await;
|
||||
Redirect::to("/libraries")
|
||||
}
|
||||
|
||||
async fn jobs_page(State(state): State<AppState>) -> Html<String> {
|
||||
let jobs = fetch_jobs(&state).await.unwrap_or_default();
|
||||
let mut rows = String::new();
|
||||
for job in jobs {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
||||
job.id,
|
||||
html_escape(&job.kind),
|
||||
html_escape(&job.status),
|
||||
html_escape(&job.created_at)
|
||||
));
|
||||
}
|
||||
|
||||
let content = format!(
|
||||
"<h1>Index Jobs</h1>
|
||||
<form method='post' action='/jobs/rebuild'>
|
||||
<input name='library_id' placeholder='optional library UUID' />
|
||||
<button type='submit'>Queue Rebuild</button>
|
||||
</form>
|
||||
<table border='1' cellpadding='6'>
|
||||
<tr><th>ID</th><th>Type</th><th>Status</th><th>Created</th></tr>
|
||||
{}
|
||||
</table>",
|
||||
rows
|
||||
);
|
||||
|
||||
Html(layout("Jobs", &content))
|
||||
}
|
||||
|
||||
async fn rebuild_jobs(State(state): State<AppState>, Form(form): Form<RebuildForm>) -> Redirect {
|
||||
let body = if form.library_id.trim().is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::json!({"library_id": form.library_id.trim()})
|
||||
};
|
||||
let _ = state
|
||||
.client
|
||||
.post(format!("{}/index/rebuild", state.api_base_url))
|
||||
.bearer_auth(&state.api_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
Redirect::to("/jobs")
|
||||
}
|
||||
|
||||
async fn tokens_page(State(state): State<AppState>) -> Html<String> {
|
||||
let tokens = fetch_tokens(&state).await.unwrap_or_default();
|
||||
let mut rows = String::new();
|
||||
for token in tokens {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>{}</td><td>{}</td><td><code>{}</code></td><td>{}</td><td><form method='post' action='/tokens/revoke'><input type='hidden' name='id' value='{}'/><button type='submit'>Revoke</button></form></td></tr>",
|
||||
html_escape(&token.name),
|
||||
html_escape(&token.scope),
|
||||
html_escape(&token.prefix),
|
||||
if token.revoked_at.is_some() { "yes" } else { "no" },
|
||||
token.id
|
||||
));
|
||||
}
|
||||
|
||||
let content = format!(
|
||||
"<h1>API Tokens</h1>
|
||||
<form method='post' action='/tokens/create'>
|
||||
<input name='name' placeholder='token name' required />
|
||||
<select name='scope'>
|
||||
<option value='read'>read</option>
|
||||
<option value='admin'>admin</option>
|
||||
</select>
|
||||
<button type='submit'>Create Token</button>
|
||||
</form>
|
||||
<table border='1' cellpadding='6'>
|
||||
<tr><th>Name</th><th>Scope</th><th>Prefix</th><th>Revoked</th><th>Actions</th></tr>
|
||||
{}
|
||||
</table>",
|
||||
rows
|
||||
);
|
||||
|
||||
Html(layout("Tokens", &content))
|
||||
}
|
||||
|
||||
async fn create_token(State(state): State<AppState>, Form(form): Form<CreateTokenForm>) -> Html<String> {
|
||||
let response = state
|
||||
.client
|
||||
.post(format!("{}/admin/tokens", state.api_base_url))
|
||||
.bearer_auth(&state.api_token)
|
||||
.json(&serde_json::json!({"name": form.name, "scope": form.scope}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let created: Result<CreatedToken, _> = resp.json().await;
|
||||
match created {
|
||||
Ok(token) => Html(layout(
|
||||
"Token Created",
|
||||
&format!(
|
||||
"<h1>Token Created</h1><p>Copie ce token maintenant (il ne sera plus affiche):</p><pre>{}</pre><p><a href='/tokens'>Back to tokens</a></p>",
|
||||
html_escape(&token.token)
|
||||
),
|
||||
)),
|
||||
Err(_) => Html(layout("Error", "<p>Token created but response parse failed.</p><p><a href='/tokens'>Back</a></p>")),
|
||||
}
|
||||
}
|
||||
_ => Html(layout("Error", "<p>Token creation failed.</p><p><a href='/tokens'>Back</a></p>")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn revoke_token(State(state): State<AppState>, Form(form): Form<RevokeTokenForm>) -> Redirect {
|
||||
let _ = state
|
||||
.client
|
||||
.delete(format!("{}/admin/tokens/{}", state.api_base_url, form.id))
|
||||
.bearer_auth(&state.api_token)
|
||||
.send()
|
||||
.await;
|
||||
Redirect::to("/tokens")
|
||||
}
|
||||
|
||||
async fn fetch_libraries(state: &AppState) -> Result<Vec<LibraryDto>, reqwest::Error> {
|
||||
state
|
||||
.client
|
||||
.get(format!("{}/libraries", state.api_base_url))
|
||||
.bearer_auth(&state.api_token)
|
||||
.send()
|
||||
.await?
|
||||
.json::<Vec<LibraryDto>>()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_jobs(state: &AppState) -> Result<Vec<IndexJobDto>, reqwest::Error> {
|
||||
state
|
||||
.client
|
||||
.get(format!("{}/index/status", state.api_base_url))
|
||||
.bearer_auth(&state.api_token)
|
||||
.send()
|
||||
.await?
|
||||
.json::<Vec<IndexJobDto>>()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_tokens(state: &AppState) -> Result<Vec<TokenDto>, reqwest::Error> {
|
||||
state
|
||||
.client
|
||||
.get(format!("{}/admin/tokens", state.api_base_url))
|
||||
.bearer_auth(&state.api_token)
|
||||
.send()
|
||||
.await?
|
||||
.json::<Vec<TokenDto>>()
|
||||
.await
|
||||
}
|
||||
|
||||
fn layout(title: &str, content: &str) -> String {
|
||||
format!(
|
||||
"<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>{}</title><style>body{{font-family:ui-sans-serif,system-ui;margin:2rem;line-height:1.4}}table{{width:100%;border-collapse:collapse;margin-top:1rem}}th,td{{text-align:left}}input,select,button{{padding:.45rem;margin:.25rem}}</style></head><body><nav><a href='/'>Home</a> | <a href='/libraries'>Libraries</a> | <a href='/jobs'>Jobs</a> | <a href='/tokens'>Tokens</a></nav><hr />{}</body></html>",
|
||||
html_escape(title),
|
||||
content
|
||||
)
|
||||
}
|
||||
|
||||
fn html_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
@@ -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 } from "../components/ui";
|
||||
import { Card, Button, FormField, FormInput, FormSelect, FormRow, CursorPagination } from "../components/ui";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -13,6 +13,8 @@ export default async function BooksPage({
|
||||
const searchParamsAwaited = await searchParams;
|
||||
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||
const cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined;
|
||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||
|
||||
const [libraries] = await Promise.all([
|
||||
fetchLibraries().catch(() => [] as LibraryDto[])
|
||||
@@ -25,7 +27,7 @@ export default async function BooksPage({
|
||||
|
||||
if (searchQuery) {
|
||||
// Mode recherche
|
||||
const searchResponse = await searchBooks(searchQuery, libraryId).catch(() => null);
|
||||
const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null);
|
||||
if (searchResponse) {
|
||||
searchResults = searchResponse.hits.map(hit => ({
|
||||
id: hit.id,
|
||||
@@ -45,10 +47,15 @@ export default async function BooksPage({
|
||||
totalHits = searchResponse.estimated_total_hits;
|
||||
}
|
||||
} else {
|
||||
// Mode liste
|
||||
const booksPage = await fetchBooks(libraryId).catch(() => ({ items: [] as BookDto[], next_cursor: null }));
|
||||
// Mode liste avec pagination
|
||||
const booksPage = await fetchBooks(libraryId, undefined, cursor, limit).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
next_cursor: null,
|
||||
prev_cursor: null
|
||||
}));
|
||||
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 => ({
|
||||
@@ -56,6 +63,9 @@ export default async function BooksPage({
|
||||
coverUrl: getBookCoverUrl(book.id)
|
||||
}));
|
||||
|
||||
const hasNextPage = !!nextCursor;
|
||||
const hasPrevPage = !!cursor; // Si on a un cursor, on peut revenir en arrière (simplifié)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
|
||||
@@ -110,19 +120,14 @@ export default async function BooksPage({
|
||||
<BooksGrid books={displayBooks} />
|
||||
|
||||
{/* Pagination */}
|
||||
{!searchQuery && nextCursor && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<form>
|
||||
<input type="hidden" name="library" value={libraryId || ""} />
|
||||
<input type="hidden" name="cursor" value={nextCursor} />
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 bg-primary text-white font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
📥 Load more
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{!searchQuery && (
|
||||
<CursorPagination
|
||||
hasNextPage={hasNextPage}
|
||||
hasPrevPage={hasPrevPage}
|
||||
pageSize={limit}
|
||||
currentCount={displayBooks.length}
|
||||
nextCursor={nextCursor}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -97,7 +97,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProgressBar value={percent} showLabel size="md" className="mb-3" />
|
||||
<ProgressBar value={percent} showLabel size="lg" className="mb-3" />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted mb-3">
|
||||
<span>{processed} / {total} files</span>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { JobProgress } from "./JobProgress";
|
||||
import { StatusBadge, Button } from "./ui";
|
||||
import { StatusBadge, Button, MiniProgressBar } from "./ui";
|
||||
|
||||
interface JobRowProps {
|
||||
job: {
|
||||
@@ -12,14 +12,27 @@ interface JobRowProps {
|
||||
type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
error_opt: string | null;
|
||||
stats_json: {
|
||||
scanned_files: number;
|
||||
indexed_files: number;
|
||||
removed_files: number;
|
||||
errors: number;
|
||||
} | null;
|
||||
progress_percent: number | null;
|
||||
processed_files: number | null;
|
||||
total_files: number | null;
|
||||
};
|
||||
libraryName: string | undefined;
|
||||
highlighted?: boolean;
|
||||
onCancel: (id: string) => void;
|
||||
formatDate: (date: string) => string;
|
||||
formatDuration: (start: string, end: string | null) => string;
|
||||
}
|
||||
|
||||
export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps) {
|
||||
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
|
||||
const [showProgress, setShowProgress] = useState(
|
||||
highlighted || job.status === "running" || job.status === "pending"
|
||||
);
|
||||
@@ -29,6 +42,24 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// Calculate duration
|
||||
const duration = job.started_at
|
||||
? formatDuration(job.started_at, job.finished_at)
|
||||
: "-";
|
||||
|
||||
// Get file stats
|
||||
const scanned = job.stats_json?.scanned_files ?? 0;
|
||||
const indexed = job.stats_json?.indexed_files ?? 0;
|
||||
const removed = job.stats_json?.removed_files ?? 0;
|
||||
const errors = job.stats_json?.errors ?? 0;
|
||||
|
||||
// Format files display
|
||||
const filesDisplay = job.status === "running" && job.total_files
|
||||
? `${job.processed_files || 0}/${job.total_files}`
|
||||
: scanned > 0
|
||||
? `${scanned} scanned`
|
||||
: "-";
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={highlighted ? 'bg-primary-soft/50' : 'hover:bg-muted/5'}>
|
||||
@@ -65,8 +96,30 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted">
|
||||
{new Date(job.created_at).toLocaleString()}
|
||||
{duration}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted">
|
||||
{formatDate(job.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -90,7 +143,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
|
||||
</tr>
|
||||
{showProgress && (job.status === "running" || job.status === "pending") && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-3 bg-muted/5">
|
||||
<td colSpan={8} className="px-4 py-3 bg-muted/5">
|
||||
<JobProgress
|
||||
jobId={job.id}
|
||||
onComplete={handleComplete}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { JobRow } from "./JobRow";
|
||||
import { MiniProgressBar } from "./ui";
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
@@ -9,7 +10,18 @@ interface Job {
|
||||
type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
error_opt: string | null;
|
||||
stats_json: {
|
||||
scanned_files: number;
|
||||
indexed_files: number;
|
||||
removed_files: number;
|
||||
errors: number;
|
||||
} | null;
|
||||
progress_percent: number | null;
|
||||
processed_files: number | null;
|
||||
total_files: number | null;
|
||||
}
|
||||
|
||||
interface JobsListProps {
|
||||
@@ -18,6 +30,36 @@ interface JobsListProps {
|
||||
highlightJobId?: string;
|
||||
}
|
||||
|
||||
function formatDuration(start: string, end: string | null): string {
|
||||
const startDate = new Date(start);
|
||||
const endDate = end ? new Date(end) : new Date();
|
||||
const diff = endDate.getTime() - startDate.getTime();
|
||||
|
||||
if (diff < 60000) return `${Math.floor(diff / 1000)}s`;
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ${Math.floor((diff % 60000) / 1000)}s`;
|
||||
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
|
||||
const [jobs, setJobs] = useState(initialJobs);
|
||||
|
||||
@@ -53,7 +95,6 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Update local state to reflect cancellation
|
||||
setJobs(jobs.map(job =>
|
||||
job.id === id ? { ...job, status: "cancelled" } : job
|
||||
));
|
||||
@@ -73,6 +114,8 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
||||
<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>
|
||||
@@ -85,6 +128,8 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
||||
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
|
||||
highlighted={job.id === highlightJobId}
|
||||
onCancel={handleCancel}
|
||||
formatDate={formatDate}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
225
apps/backoffice/app/components/ui/Pagination.tsx
Normal file
225
apps/backoffice/app/components/ui/Pagination.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface CursorPaginationProps {
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
pageSize: number;
|
||||
currentCount: number;
|
||||
pageSizeOptions?: number[];
|
||||
nextCursor?: string | null;
|
||||
}
|
||||
|
||||
export function CursorPagination({
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
pageSize,
|
||||
currentCount,
|
||||
pageSizeOptions = [20, 50, 100],
|
||||
nextCursor,
|
||||
}: CursorPaginationProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const goToNext = () => {
|
||||
if (!nextCursor) return;
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("cursor", nextCursor);
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
const goToFirst = () => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.delete("cursor");
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
const changePageSize = (size: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("limit", size.toString());
|
||||
params.delete("cursor");
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-line">
|
||||
{/* Page size selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted">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"
|
||||
>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm text-muted">per page</span>
|
||||
</div>
|
||||
|
||||
{/* Count info */}
|
||||
<div className="text-sm text-muted">
|
||||
Showing {currentCount} items
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center gap-3">
|
||||
{hasPrevPage && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={goToFirst}
|
||||
>
|
||||
← First
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={goToNext}
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
Next →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OffsetPaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
pageSizeOptions?: number[];
|
||||
}
|
||||
|
||||
export function OffsetPagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
pageSize,
|
||||
totalItems,
|
||||
pageSizeOptions = [20, 50, 100],
|
||||
}: OffsetPaginationProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", page.toString());
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
const changePageSize = (size: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("limit", size.toString());
|
||||
params.set("page", "1");
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
const startItem = (currentPage - 1) * pageSize + 1;
|
||||
const endItem = Math.min(currentPage * pageSize, totalItems);
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const maxVisiblePages = 5;
|
||||
|
||||
if (totalPages <= maxVisiblePages) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
if (currentPage <= 3) {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
pages.push("...");
|
||||
pages.push(totalPages);
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pages.push(1);
|
||||
pages.push("...");
|
||||
for (let i = totalPages - 3; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
pages.push(1);
|
||||
pages.push("...");
|
||||
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
pages.push("...");
|
||||
pages.push(totalPages);
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-line">
|
||||
{/* Page size selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted">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"
|
||||
>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm text-muted">per page</span>
|
||||
</div>
|
||||
|
||||
{/* Page info */}
|
||||
<div className="text-sm text-muted">
|
||||
{startItem}-{endItem} of {totalItems}
|
||||
</div>
|
||||
|
||||
{/* Page navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
|
||||
{getPageNumbers().map((page, index) => (
|
||||
<span key={index}>
|
||||
{page === "..." ? (
|
||||
<span className="px-3 py-2 text-sm text-muted">...</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === page ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => goToPage(page as number)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export { Button } from "./Button";
|
||||
export { Input, Select } from "./Input";
|
||||
export { FormField, FormLabel, FormInput, FormSelect, FormRow } from "./Form";
|
||||
export { PageIcon, NavIcon } from "./Icon";
|
||||
export { CursorPagination, OffsetPagination } from "./Pagination";
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
--color-primary: hsl(194 76% 62%);
|
||||
--color-primary-soft: hsl(210 34% 24%);
|
||||
--color-muted: hsl(218 17% 72%);
|
||||
--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%);
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
|
||||
{children}
|
||||
</main>
|
||||
</ThemeProvider>
|
||||
|
||||
95
apps/backoffice/app/libraries/[id]/books/page.tsx
Normal file
95
apps/backoffice/app/libraries/[id]/books/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
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 { notFound } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LibraryBooksPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const searchParamsAwaited = await searchParams;
|
||||
const cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined;
|
||||
const series = typeof searchParamsAwaited.series === "string" ? searchParamsAwaited.series : undefined;
|
||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||
|
||||
const [library, booksPage] = await Promise.all([
|
||||
fetchLibraries().then(libs => libs.find(l => l.id === id)),
|
||||
fetchBooks(id, series, cursor, limit).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
next_cursor: null
|
||||
}))
|
||||
]);
|
||||
|
||||
if (!library) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const books = booksPage.items.map(book => ({
|
||||
...book,
|
||||
coverUrl: getBookCoverUrl(book.id)
|
||||
}));
|
||||
const nextCursor = booksPage.next_cursor;
|
||||
|
||||
const seriesDisplayName = series === "unclassified" ? "Unclassified" : series;
|
||||
const hasNextPage = !!nextCursor;
|
||||
const hasPrevPage = !!cursor;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="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>
|
||||
|
||||
{books.length > 0 ? (
|
||||
<>
|
||||
<BooksGrid books={books} />
|
||||
|
||||
<CursorPagination
|
||||
hasNextPage={hasNextPage}
|
||||
hasPrevPage={hasPrevPage}
|
||||
pageSize={limit}
|
||||
currentCount={books.length}
|
||||
nextCursor={nextCursor}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState message={series ? `No books in series "${seriesDisplayName}"` : "No books in this library yet"} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
apps/backoffice/app/libraries/[id]/series/page.tsx
Normal file
87
apps/backoffice/app/libraries/[id]/series/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto } from "../../../../lib/api";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, Badge } from "../../../components/ui";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LibrarySeriesPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
const [library, series] = await Promise.all([
|
||||
fetchLibraries().then(libs => libs.find(l => l.id === id)),
|
||||
fetchSeries(id).catch(() => [] as SeriesDto[])
|
||||
]);
|
||||
|
||||
if (!library) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{series.length > 0 ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
||||
{series.map((s) => (
|
||||
<Link
|
||||
key={s.name}
|
||||
href={`/libraries/${id}/books?series=${encodeURIComponent(s.name)}`}
|
||||
className="group"
|
||||
>
|
||||
<div className="bg-card rounded-xl shadow-soft border border-line overflow-hidden hover:shadow-card transition-shadow">
|
||||
<div className="aspect-[2/3] relative bg-muted/10">
|
||||
<Image
|
||||
src={getBookCoverUrl(s.first_book_id)}
|
||||
alt={`Cover of ${s.name}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||
{s.name === "unclassified" ? "Unclassified" : s.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted mt-1">
|
||||
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted">
|
||||
<p>No series found in this library</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
181
apps/backoffice/app/libraries/page.tsx
Normal file
181
apps/backoffice/app/libraries/page.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatNextScan(nextScanAt: string | null): string {
|
||||
if (!nextScanAt) return "-";
|
||||
const date = new Date(nextScanAt);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 0) return "Due now";
|
||||
if (diff < 60000) return "< 1 min";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
|
||||
return `${Math.floor(diff / 86400000)}d`;
|
||||
}
|
||||
|
||||
export default async function LibrariesPage() {
|
||||
const [libraries, folders] = await Promise.all([
|
||||
fetchLibraries().catch(() => [] as LibraryDto[]),
|
||||
listFolders().catch(() => [] as FolderItem[])
|
||||
]);
|
||||
|
||||
const seriesCounts = await Promise.all(
|
||||
libraries.map(async (lib) => {
|
||||
try {
|
||||
const series = await fetchSeries(lib.id);
|
||||
return { id: lib.id, count: series.length };
|
||||
} catch {
|
||||
return { id: lib.id, count: 0 };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const seriesCountMap = new Map(seriesCounts.map(s => [s.id, s.count]));
|
||||
|
||||
async function addLibrary(formData: FormData) {
|
||||
"use server";
|
||||
const name = formData.get("name") as string;
|
||||
const rootPath = formData.get("root_path") as string;
|
||||
if (name && rootPath) {
|
||||
await createLibrary(name, rootPath);
|
||||
revalidatePath("/libraries");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLibrary(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await deleteLibrary(id);
|
||||
revalidatePath("/libraries");
|
||||
}
|
||||
|
||||
async function scanLibraryAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await scanLibrary(id);
|
||||
revalidatePath("/libraries");
|
||||
revalidatePath("/jobs");
|
||||
}
|
||||
|
||||
async function scanLibraryFullAction(formData: FormData) {
|
||||
"use server";
|
||||
const id = formData.get("id") as string;
|
||||
await scanLibrary(id, true);
|
||||
revalidatePath("/libraries");
|
||||
revalidatePath("/jobs");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
Libraries
|
||||
</h1>
|
||||
|
||||
{/* 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>
|
||||
</Card>
|
||||
|
||||
{/* Libraries Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{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">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{lib.name}</h3>
|
||||
{!lib.enabled && <Badge variant="muted" className="mt-1">Disabled</Badge>}
|
||||
</div>
|
||||
<LibraryActions
|
||||
libraryId={lib.id}
|
||||
monitorEnabled={lib.monitor_enabled}
|
||||
scanMode={lib.scan_mode}
|
||||
watcherEnabled={lib.watcher_enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Path */}
|
||||
<code className="text-xs font-mono text-muted mb-4 break-all">{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">
|
||||
<span className="block text-2xl font-bold text-primary">{lib.book_count}</span>
|
||||
<span className="text-xs text-muted">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">
|
||||
<span className="block text-2xl font-bold text-foreground">{seriesCount}</span>
|
||||
<span className="text-xs text-muted">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'}`}>
|
||||
{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">
|
||||
Next: {formatNextScan(lib.next_scan_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 mt-auto">
|
||||
<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>
|
||||
</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
|
||||
</Button>
|
||||
</form>
|
||||
<form>
|
||||
<input type="hidden" name="id" value={lib.id} />
|
||||
<Button type="submit" variant="danger" size="sm" formAction={removeLibrary}>
|
||||
🗑
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,15 @@ export type IndexJobDto = {
|
||||
finished_at: string | null;
|
||||
error_opt: string | null;
|
||||
created_at: string;
|
||||
stats_json: {
|
||||
scanned_files: number;
|
||||
indexed_files: number;
|
||||
removed_files: number;
|
||||
errors: number;
|
||||
} | null;
|
||||
progress_percent: number | null;
|
||||
processed_files: number | null;
|
||||
total_files: number | null;
|
||||
};
|
||||
|
||||
export type TokenDto = {
|
||||
|
||||
2
apps/backoffice/next-env.d.ts
vendored
2
apps/backoffice/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -250,12 +250,14 @@ fn setup_watcher(
|
||||
libraries: HashMap<Uuid, String>,
|
||||
tx: mpsc::Sender<(Uuid, String)>,
|
||||
) -> anyhow::Result<RecommendedWatcher> {
|
||||
let watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
|
||||
let libraries_for_closure = libraries.clone();
|
||||
|
||||
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
if event.kind.is_modify() || event.kind.is_create() || event.kind.is_remove() {
|
||||
for path in event.paths {
|
||||
if let Some((library_id, _)) = libraries.iter().find(|(_, root)| {
|
||||
if let Some((library_id, _)) = libraries_for_closure.iter().find(|(_, root)| {
|
||||
path.starts_with(root)
|
||||
}) {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
@@ -270,6 +272,12 @@ fn setup_watcher(
|
||||
}
|
||||
})?;
|
||||
|
||||
// Actually watch the library directories
|
||||
for (_, root_path) in &libraries {
|
||||
info!("[WATCHER] Watching directory: {}", root_path);
|
||||
watcher.watch(std::path::Path::new(root_path), RecursiveMode::Recursive)?;
|
||||
}
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
@@ -437,12 +445,15 @@ async fn process_job(state: &AppState, job_id: Uuid, target_library_id: Option<U
|
||||
removed_files: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
// Track processed files across all libraries for accurate progress
|
||||
let mut total_processed_count = 0i32;
|
||||
|
||||
for library in libraries {
|
||||
let library_id: Uuid = library.get("id");
|
||||
let root_path: String = library.get("root_path");
|
||||
let root_path = remap_libraries_path(&root_path);
|
||||
match scan_library(state, job_id, library_id, Path::new(&root_path), &mut stats, total_files, is_full_rebuild).await {
|
||||
match scan_library(state, job_id, library_id, Path::new(&root_path), &mut stats, &mut total_processed_count, total_files, is_full_rebuild).await {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
stats.errors += 1;
|
||||
@@ -453,9 +464,10 @@ async fn process_job(state: &AppState, job_id: Uuid, target_library_id: Option<U
|
||||
|
||||
sync_meili(&state.pool, &state.meili_url, &state.meili_master_key).await?;
|
||||
|
||||
sqlx::query("UPDATE index_jobs SET status = 'success', finished_at = NOW(), stats_json = $2, current_file = NULL WHERE id = $1")
|
||||
sqlx::query("UPDATE index_jobs SET status = 'success', finished_at = NOW(), stats_json = $2, current_file = NULL, progress_percent = 100, processed_files = $3 WHERE id = $1")
|
||||
.bind(job_id)
|
||||
.bind(serde_json::to_value(&stats)?)
|
||||
.bind(total_processed_count)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
@@ -477,6 +489,7 @@ async fn scan_library(
|
||||
library_id: Uuid,
|
||||
root: &Path,
|
||||
stats: &mut JobStats,
|
||||
total_processed_count: &mut i32,
|
||||
total_files: usize,
|
||||
is_full_rebuild: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
@@ -507,7 +520,7 @@ async fn scan_library(
|
||||
}
|
||||
|
||||
let mut seen: HashMap<String, bool> = HashMap::new();
|
||||
let mut processed_count = 0i32;
|
||||
let mut library_processed_count = 0i32;
|
||||
|
||||
for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) {
|
||||
if !entry.file_type().is_file() {
|
||||
@@ -520,7 +533,8 @@ async fn scan_library(
|
||||
};
|
||||
|
||||
stats.scanned_files += 1;
|
||||
processed_count += 1;
|
||||
library_processed_count += 1;
|
||||
*total_processed_count += 1;
|
||||
let abs_path_local = path.to_string_lossy().to_string();
|
||||
// Convert local path to /libraries format for DB storage
|
||||
let abs_path = unmap_libraries_path(&abs_path_local);
|
||||
@@ -528,12 +542,12 @@ async fn scan_library(
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| abs_path.clone());
|
||||
|
||||
info!("[SCAN] Job {} processing file {}/{}: {}", job_id, processed_count, total_files, file_name);
|
||||
info!("[SCAN] Job {} processing file {}/{} (library: {}): {}", job_id, total_processed_count, total_files, library_processed_count, file_name);
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// Update progress in DB
|
||||
// Update progress in DB using the global processed count
|
||||
let progress_percent = if total_files > 0 {
|
||||
((processed_count as f64 / total_files as f64) * 100.0) as i32
|
||||
((*total_processed_count as f64 / total_files as f64) * 100.0) as i32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@@ -544,7 +558,7 @@ async fn scan_library(
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(&file_name)
|
||||
.bind(processed_count)
|
||||
.bind(*total_processed_count)
|
||||
.bind(progress_percent)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
@@ -554,7 +568,9 @@ async fn scan_library(
|
||||
})?;
|
||||
info!("[BDD] Progress update took {:?}", db_start.elapsed());
|
||||
|
||||
seen.insert(abs_path.clone(), true);
|
||||
// Use local path for seen tracking to match existing keys
|
||||
let seen_key = remap_libraries_path(&abs_path);
|
||||
seen.insert(seen_key, true);
|
||||
|
||||
let meta_start = std::time::Instant::now();
|
||||
let metadata = std::fs::metadata(path)
|
||||
@@ -566,7 +582,9 @@ async fn scan_library(
|
||||
let fingerprint = compute_fingerprint(path, metadata.len(), &mtime)?;
|
||||
info!("[META] Metadata+fingerprint took {:?}", meta_start.elapsed());
|
||||
|
||||
if let Some((file_id, book_id, old_fingerprint)) = existing.get(&abs_path).cloned() {
|
||||
// Use local path to lookup in existing (which has local paths as keys)
|
||||
let lookup_path = remap_libraries_path(&abs_path);
|
||||
if let Some((file_id, book_id, old_fingerprint)) = existing.get(&lookup_path).cloned() {
|
||||
// Skip fingerprint check for full rebuilds - always reindex
|
||||
if !is_full_rebuild && old_fingerprint == fingerprint {
|
||||
info!("[SKIP] File unchanged, skipping: {} (total time: {:?})", file_name, start_time.elapsed());
|
||||
|
||||
@@ -34,15 +34,17 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
POSTGRES_USER: stripstream
|
||||
POSTGRES_PASSWORD: stripstream
|
||||
POSTGRES_DB: stripstream
|
||||
volumes:
|
||||
- ./migrations:/migrations:ro
|
||||
command:
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
"for f in /migrations/*.sql; do echo \"Applying migration: $f\"; psql -h postgres -U \"$${POSTGRES_USER:-stripstream}\" -d \"$${POSTGRES_DB:-stripstream}\" -f \"$f\" || exit 1; done",
|
||||
"export PGPASSWORD=$$POSTGRES_PASSWORD; for f in /migrations/*.sql; do echo \"Applying migration: $$f\"; psql -h postgres -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\" -f \"$$f\" || exit 1; done",
|
||||
]
|
||||
|
||||
api:
|
||||
|
||||
Reference in New Issue
Block a user