Compare commits
2 Commits
1d25c8869f
...
cf2e7a0be7
| Author | SHA1 | Date | |
|---|---|---|---|
| cf2e7a0be7 | |||
| 82444cda02 |
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -51,7 +51,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -1122,7 +1122,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexer"
|
name = "indexer"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1624,7 +1624,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parsers"
|
name = "parsers"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stripstream-core"
|
name = "stripstream-core"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ resolver = "2"
|
|||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mod reading_progress;
|
|||||||
mod search;
|
mod search;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod stats;
|
||||||
mod thumbnails;
|
mod thumbnails;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
|
|
||||||
@@ -114,6 +115,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/series", get(books::list_all_series))
|
.route("/series", get(books::list_all_series))
|
||||||
.route("/series/ongoing", get(books::ongoing_series))
|
.route("/series/ongoing", get(books::ongoing_series))
|
||||||
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
||||||
|
.route("/stats", get(stats::get_stats))
|
||||||
.route("/search", get(search::search_books))
|
.route("/search", get(search::search_books))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
|
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ use utoipa::OpenApi;
|
|||||||
crate::tokens::create_token,
|
crate::tokens::create_token,
|
||||||
crate::tokens::revoke_token,
|
crate::tokens::revoke_token,
|
||||||
crate::tokens::delete_token,
|
crate::tokens::delete_token,
|
||||||
|
crate::stats::get_stats,
|
||||||
crate::settings::get_settings,
|
crate::settings::get_settings,
|
||||||
crate::settings::get_setting,
|
crate::settings::get_setting,
|
||||||
crate::settings::update_setting,
|
crate::settings::update_setting,
|
||||||
@@ -78,6 +79,14 @@ use utoipa::OpenApi;
|
|||||||
crate::settings::ClearCacheResponse,
|
crate::settings::ClearCacheResponse,
|
||||||
crate::settings::CacheStats,
|
crate::settings::CacheStats,
|
||||||
crate::settings::ThumbnailStats,
|
crate::settings::ThumbnailStats,
|
||||||
|
crate::stats::StatsResponse,
|
||||||
|
crate::stats::StatsOverview,
|
||||||
|
crate::stats::ReadingStatusStats,
|
||||||
|
crate::stats::FormatCount,
|
||||||
|
crate::stats::LanguageCount,
|
||||||
|
crate::stats::LibraryStats,
|
||||||
|
crate::stats::TopSeries,
|
||||||
|
crate::stats::MonthlyAdditions,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|||||||
273
apps/api/src/stats.rs
Normal file
273
apps/api/src/stats.rs
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::Row;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct StatsOverview {
|
||||||
|
pub total_books: i64,
|
||||||
|
pub total_series: i64,
|
||||||
|
pub total_libraries: i64,
|
||||||
|
pub total_pages: i64,
|
||||||
|
pub total_size_bytes: i64,
|
||||||
|
pub total_authors: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ReadingStatusStats {
|
||||||
|
pub unread: i64,
|
||||||
|
pub reading: i64,
|
||||||
|
pub read: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct FormatCount {
|
||||||
|
pub format: String,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct LanguageCount {
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct LibraryStats {
|
||||||
|
pub library_name: String,
|
||||||
|
pub book_count: i64,
|
||||||
|
pub size_bytes: i64,
|
||||||
|
pub read_count: i64,
|
||||||
|
pub reading_count: i64,
|
||||||
|
pub unread_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct TopSeries {
|
||||||
|
pub series: String,
|
||||||
|
pub book_count: i64,
|
||||||
|
pub read_count: i64,
|
||||||
|
pub total_pages: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct MonthlyAdditions {
|
||||||
|
pub month: String,
|
||||||
|
pub books_added: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct StatsResponse {
|
||||||
|
pub overview: StatsOverview,
|
||||||
|
pub reading_status: ReadingStatusStats,
|
||||||
|
pub by_format: Vec<FormatCount>,
|
||||||
|
pub by_language: Vec<LanguageCount>,
|
||||||
|
pub by_library: Vec<LibraryStats>,
|
||||||
|
pub top_series: Vec<TopSeries>,
|
||||||
|
pub additions_over_time: Vec<MonthlyAdditions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get collection statistics for the dashboard
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/stats",
|
||||||
|
tag = "books",
|
||||||
|
responses(
|
||||||
|
(status = 200, body = StatsResponse),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn get_stats(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<StatsResponse>, ApiError> {
|
||||||
|
// Overview + reading status in one query
|
||||||
|
let overview_row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_books,
|
||||||
|
COUNT(DISTINCT NULLIF(series, '')) AS total_series,
|
||||||
|
COUNT(DISTINCT library_id) AS total_libraries,
|
||||||
|
COALESCE(SUM(page_count), 0)::BIGINT AS total_pages,
|
||||||
|
COUNT(DISTINCT author) FILTER (WHERE author IS NOT NULL AND author != '') AS total_authors,
|
||||||
|
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread,
|
||||||
|
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading,
|
||||||
|
COUNT(*) FILTER (WHERE brp.status = 'read') AS read
|
||||||
|
FROM books b
|
||||||
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Total size from book_files
|
||||||
|
let size_row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT COALESCE(SUM(bf.size_bytes), 0)::BIGINT AS total_size_bytes
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (book_id) size_bytes
|
||||||
|
FROM book_files
|
||||||
|
ORDER BY book_id, updated_at DESC
|
||||||
|
) bf
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let overview = StatsOverview {
|
||||||
|
total_books: overview_row.get("total_books"),
|
||||||
|
total_series: overview_row.get("total_series"),
|
||||||
|
total_libraries: overview_row.get("total_libraries"),
|
||||||
|
total_pages: overview_row.get("total_pages"),
|
||||||
|
total_size_bytes: size_row.get("total_size_bytes"),
|
||||||
|
total_authors: overview_row.get("total_authors"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let reading_status = ReadingStatusStats {
|
||||||
|
unread: overview_row.get("unread"),
|
||||||
|
reading: overview_row.get("reading"),
|
||||||
|
read: overview_row.get("read"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// By format
|
||||||
|
let format_rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT COALESCE(bf.format, b.kind) AS fmt, COUNT(*) AS count
|
||||||
|
FROM books b
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT format FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
|
||||||
|
) bf ON TRUE
|
||||||
|
GROUP BY fmt
|
||||||
|
ORDER BY count DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let by_format: Vec<FormatCount> = format_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| FormatCount {
|
||||||
|
format: r.get::<Option<String>, _>("fmt").unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
count: r.get("count"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// By language
|
||||||
|
let lang_rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT language, COUNT(*) AS count
|
||||||
|
FROM books
|
||||||
|
GROUP BY language
|
||||||
|
ORDER BY count DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let by_language: Vec<LanguageCount> = lang_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| LanguageCount {
|
||||||
|
language: r.get("language"),
|
||||||
|
count: r.get("count"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// By library
|
||||||
|
let lib_rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
l.name AS library_name,
|
||||||
|
COUNT(b.id) AS book_count,
|
||||||
|
COALESCE(SUM(bf.size_bytes), 0)::BIGINT AS size_bytes,
|
||||||
|
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
|
||||||
|
COUNT(*) FILTER (WHERE brp.status = 'reading') AS reading_count,
|
||||||
|
COUNT(*) FILTER (WHERE COALESCE(brp.status, 'unread') = 'unread') AS unread_count
|
||||||
|
FROM libraries l
|
||||||
|
LEFT JOIN books b ON b.library_id = l.id
|
||||||
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT size_bytes FROM book_files WHERE book_id = b.id ORDER BY updated_at DESC LIMIT 1
|
||||||
|
) bf ON TRUE
|
||||||
|
GROUP BY l.id, l.name
|
||||||
|
ORDER BY book_count DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let by_library: Vec<LibraryStats> = lib_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| LibraryStats {
|
||||||
|
library_name: r.get("library_name"),
|
||||||
|
book_count: r.get("book_count"),
|
||||||
|
size_bytes: r.get("size_bytes"),
|
||||||
|
read_count: r.get("read_count"),
|
||||||
|
reading_count: r.get("reading_count"),
|
||||||
|
unread_count: r.get("unread_count"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Top series (by book count)
|
||||||
|
let series_rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
b.series,
|
||||||
|
COUNT(*) AS book_count,
|
||||||
|
COUNT(*) FILTER (WHERE brp.status = 'read') AS read_count,
|
||||||
|
COALESCE(SUM(b.page_count), 0)::BIGINT AS total_pages
|
||||||
|
FROM books b
|
||||||
|
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||||
|
WHERE b.series IS NOT NULL AND b.series != ''
|
||||||
|
GROUP BY b.series
|
||||||
|
ORDER BY book_count DESC
|
||||||
|
LIMIT 10
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let top_series: Vec<TopSeries> = series_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| TopSeries {
|
||||||
|
series: r.get("series"),
|
||||||
|
book_count: r.get("book_count"),
|
||||||
|
read_count: r.get("read_count"),
|
||||||
|
total_pages: r.get("total_pages"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Additions over time (last 12 months)
|
||||||
|
let additions_rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month,
|
||||||
|
COUNT(*) AS books_added
|
||||||
|
FROM books
|
||||||
|
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', created_at)
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| MonthlyAdditions {
|
||||||
|
month: r.get("month"),
|
||||||
|
books_added: r.get("books_added"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(StatsResponse {
|
||||||
|
overview,
|
||||||
|
reading_status,
|
||||||
|
by_format,
|
||||||
|
by_language,
|
||||||
|
by_library,
|
||||||
|
top_series,
|
||||||
|
additions_over_time,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ const colorClasses: Partial<Record<IconName, string>> = {
|
|||||||
libraries: "text-primary",
|
libraries: "text-primary",
|
||||||
jobs: "text-warning",
|
jobs: "text-warning",
|
||||||
tokens: "text-error",
|
tokens: "text-error",
|
||||||
series: "text-primary",
|
series: "text-warning",
|
||||||
settings: "text-muted-foreground",
|
settings: "text-muted-foreground",
|
||||||
image: "text-primary",
|
image: "text-primary",
|
||||||
cache: "text-warning",
|
cache: "text-warning",
|
||||||
|
|||||||
@@ -1,79 +1,374 @@
|
|||||||
export default function DashboardPage() {
|
import React from "react";
|
||||||
|
import { fetchStats, StatsResponse } from "../lib/api";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
return n.toLocaleString("fr-FR");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Donut chart via SVG
|
||||||
|
function DonutChart({ data, colors }: { data: { label: string; value: number; color: string }[]; colors?: string[] }) {
|
||||||
|
const total = data.reduce((sum, d) => sum + d.value, 0);
|
||||||
|
if (total === 0) return <p className="text-muted-foreground text-sm text-center py-8">No data</p>;
|
||||||
|
|
||||||
|
const radius = 40;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="flex items-center gap-6">
|
||||||
<div className="text-center mb-12">
|
<svg viewBox="0 0 100 100" className="w-32 h-32 shrink-0">
|
||||||
<h1 className="text-4xl font-bold tracking-tight mb-4 text-foreground">
|
{data.map((d, i) => {
|
||||||
StripStream Backoffice
|
const pct = d.value / total;
|
||||||
</h1>
|
const dashLength = pct * circumference;
|
||||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
const currentOffset = offset;
|
||||||
Manage libraries, indexing jobs, and API tokens from a modern admin interface.
|
offset += dashLength;
|
||||||
</p>
|
return (
|
||||||
</div>
|
<circle
|
||||||
|
key={i}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
cx="50"
|
||||||
{/* Libraries Card */}
|
cy="50"
|
||||||
<a
|
r={radius}
|
||||||
href="/libraries"
|
fill="none"
|
||||||
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"
|
stroke={d.color}
|
||||||
>
|
strokeWidth="16"
|
||||||
<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">
|
strokeDasharray={`${dashLength} ${circumference - dashLength}`}
|
||||||
<svg className="w-6 h-6 text-primary group-hover:text-primary-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
strokeDashoffset={-currentOffset}
|
||||||
<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" />
|
transform="rotate(-90 50 50)"
|
||||||
</svg>
|
className="transition-all duration-500"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<text x="50" y="50" textAnchor="middle" dominantBaseline="central" className="fill-foreground text-[10px] font-bold">
|
||||||
|
{formatNumber(total)}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: d.color }} />
|
||||||
|
<span className="text-muted-foreground truncate">{d.label}</span>
|
||||||
|
<span className="font-medium text-foreground ml-auto">{d.value}</span>
|
||||||
</div>
|
</div>
|
||||||
<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/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/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 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/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/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 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/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-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 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/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-foreground leading-relaxed">
|
|
||||||
Start by creating a library from your comic folders, then trigger an index job to scan your collection.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bar chart via pure CSS
|
||||||
|
function BarChart({ data, color = "var(--color-primary)" }: { data: { label: string; value: number }[]; color?: string }) {
|
||||||
|
const max = Math.max(...data.map((d) => d.value), 1);
|
||||||
|
if (data.length === 0) return <p className="text-muted-foreground text-sm text-center py-8">No data</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-1.5 h-40">
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<div key={i} className="flex-1 flex flex-col items-center gap-1 min-w-0">
|
||||||
|
<span className="text-[10px] text-muted-foreground font-medium">{d.value || ""}</span>
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t-sm transition-all duration-500 min-h-[2px]"
|
||||||
|
style={{
|
||||||
|
height: `${(d.value / max) * 100}%`,
|
||||||
|
backgroundColor: color,
|
||||||
|
opacity: d.value === 0 ? 0.2 : 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground truncate w-full text-center">
|
||||||
|
{d.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal progress bar for library breakdown
|
||||||
|
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
|
||||||
|
const pct = max > 0 ? (value / max) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="font-medium text-foreground truncate">{label}</span>
|
||||||
|
<span className="text-muted-foreground shrink-0 ml-2">{subLabel || formatNumber(value)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
let stats: StatsResponse | null = null;
|
||||||
|
try {
|
||||||
|
stats = await fetchStats();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch stats:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight mb-4 text-foreground">StripStream Backoffice</h1>
|
||||||
|
<p className="text-lg text-muted-foreground">Unable to load statistics. Make sure the API is running.</p>
|
||||||
|
</div>
|
||||||
|
<QuickLinks />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { overview, reading_status, by_format, by_language, by_library, top_series, additions_over_time } = stats;
|
||||||
|
|
||||||
|
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
|
||||||
|
const formatColors = [
|
||||||
|
"hsl(198 78% 37%)", "hsl(142 60% 45%)", "hsl(45 93% 47%)",
|
||||||
|
"hsl(2 72% 48%)", "hsl(280 60% 50%)", "hsl(32 80% 50%)",
|
||||||
|
"hsl(170 60% 45%)", "hsl(220 60% 50%)",
|
||||||
|
];
|
||||||
|
|
||||||
|
const maxLibBooks = Math.max(...by_library.map((l) => l.book_count), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2 max-w-2xl">
|
||||||
|
Overview of your comic collection. Manage your libraries, track your reading progress, and explore your books and series.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview stat cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
|
<StatCard icon="book" label="Books" value={formatNumber(overview.total_books)} color="success" />
|
||||||
|
<StatCard icon="series" label="Series" value={formatNumber(overview.total_series)} color="primary" />
|
||||||
|
<StatCard icon="library" label="Libraries" value={formatNumber(overview.total_libraries)} color="warning" />
|
||||||
|
<StatCard icon="pages" label="Pages" value={formatNumber(overview.total_pages)} color="primary" />
|
||||||
|
<StatCard icon="author" label="Authors" value={formatNumber(overview.total_authors)} color="success" />
|
||||||
|
<StatCard icon="size" label="Total Size" value={formatBytes(overview.total_size_bytes)} color="warning" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts row */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Reading status donut */}
|
||||||
|
<Card hover={false}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Reading Status</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DonutChart
|
||||||
|
data={[
|
||||||
|
{ label: "Unread", value: reading_status.unread, color: readingColors[0] },
|
||||||
|
{ label: "In Progress", value: reading_status.reading, color: readingColors[1] },
|
||||||
|
{ label: "Read", value: reading_status.read, color: readingColors[2] },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* By format donut */}
|
||||||
|
<Card hover={false}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">By Format</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DonutChart
|
||||||
|
data={by_format.slice(0, 6).map((f, i) => ({
|
||||||
|
label: (f.format || "Unknown").toUpperCase(),
|
||||||
|
value: f.count,
|
||||||
|
color: formatColors[i % formatColors.length],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* By library donut */}
|
||||||
|
<Card hover={false}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">By Library</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DonutChart
|
||||||
|
data={by_library.slice(0, 6).map((l, i) => ({
|
||||||
|
label: l.library_name,
|
||||||
|
value: l.book_count,
|
||||||
|
color: formatColors[i % formatColors.length],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Second row */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Monthly additions bar chart */}
|
||||||
|
<Card hover={false}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Books Added (Last 12 Months)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<BarChart
|
||||||
|
data={additions_over_time.map((m) => ({
|
||||||
|
label: m.month.slice(5), // "MM" from "YYYY-MM"
|
||||||
|
value: m.books_added,
|
||||||
|
}))}
|
||||||
|
color="hsl(198 78% 37%)"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top series */}
|
||||||
|
<Card hover={false}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Top Series</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{top_series.slice(0, 8).map((s, i) => (
|
||||||
|
<HorizontalBar
|
||||||
|
key={i}
|
||||||
|
label={s.series}
|
||||||
|
value={s.book_count}
|
||||||
|
max={top_series[0]?.book_count || 1}
|
||||||
|
subLabel={`${s.read_count}/${s.book_count} read`}
|
||||||
|
color="hsl(142 60% 45%)"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{top_series.length === 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm text-center py-4">No series yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Libraries breakdown */}
|
||||||
|
{by_library.length > 0 && (
|
||||||
|
<Card hover={false}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Libraries</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
|
||||||
|
{by_library.map((lib, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<div className="flex justify-between items-baseline">
|
||||||
|
<span className="font-medium text-foreground text-sm">{lib.library_name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{formatBytes(lib.size_bytes)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-muted rounded-full overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className="h-full transition-all duration-500"
|
||||||
|
style={{ width: `${(lib.read_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(142 60% 45%)" }}
|
||||||
|
title={`Read: ${lib.read_count}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full transition-all duration-500"
|
||||||
|
style={{ width: `${(lib.reading_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(45 93% 47%)" }}
|
||||||
|
title={`In progress: ${lib.reading_count}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full transition-all duration-500"
|
||||||
|
style={{ width: `${(lib.unread_count / Math.max(lib.book_count, 1)) * 100}%`, backgroundColor: "hsl(220 13% 70%)" }}
|
||||||
|
title={`Unread: ${lib.unread_count}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 text-[11px] text-muted-foreground">
|
||||||
|
<span>{lib.book_count} books</span>
|
||||||
|
<span className="text-success">{lib.read_count} read</span>
|
||||||
|
<span className="text-warning">{lib.reading_count} in progress</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick links */}
|
||||||
|
<QuickLinks />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon, label, value, color }: { icon: string; label: string; value: string; color: string }) {
|
||||||
|
const icons: Record<string, React.ReactNode> = {
|
||||||
|
book: <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" />,
|
||||||
|
series: <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" />,
|
||||||
|
library: <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" />,
|
||||||
|
pages: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />,
|
||||||
|
author: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />,
|
||||||
|
size: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorClasses: Record<string, string> = {
|
||||||
|
primary: "bg-primary/10 text-primary",
|
||||||
|
success: "bg-success/10 text-success",
|
||||||
|
warning: "bg-warning/10 text-warning",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card hover={false} className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${colorClasses[color]}`}>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{icons[icon]}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xl font-bold text-foreground leading-tight">{value}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickLinks() {
|
||||||
|
const links = [
|
||||||
|
{ href: "/libraries", label: "Libraries", bg: "bg-primary/10", text: "text-primary", hoverBg: "group-hover:bg-primary", hoverText: "group-hover:text-primary-foreground", icon: <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" /> },
|
||||||
|
{ href: "/books", label: "Books", bg: "bg-success/10", text: "text-success", hoverBg: "group-hover:bg-success", hoverText: "group-hover:text-white", icon: <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" /> },
|
||||||
|
{ href: "/series", label: "Series", bg: "bg-warning/10", text: "text-warning", hoverBg: "group-hover:bg-warning", hoverText: "group-hover:text-white", icon: <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" /> },
|
||||||
|
{ href: "/jobs", label: "Jobs", bg: "bg-destructive/10", text: "text-destructive", hoverBg: "group-hover:bg-destructive", hoverText: "group-hover:text-destructive-foreground", icon: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{links.map((l) => (
|
||||||
|
<Link
|
||||||
|
key={l.href}
|
||||||
|
href={l.href as any}
|
||||||
|
className="group p-4 bg-card/80 backdrop-blur-sm rounded-xl border border-border/50 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<div className={`w-9 h-9 rounded-lg flex items-center justify-center transition-colors duration-200 ${l.bg} ${l.hoverBg}`}>
|
||||||
|
<svg className={`w-5 h-5 ${l.text} ${l.hoverText}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{l.icon}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-foreground text-sm">{l.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default async function SeriesPage({
|
|||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
<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">
|
<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="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" />
|
<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>
|
</svg>
|
||||||
Series
|
Series
|
||||||
|
|||||||
@@ -419,6 +419,66 @@ export async function updateReadingProgress(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StatsOverview = {
|
||||||
|
total_books: number;
|
||||||
|
total_series: number;
|
||||||
|
total_libraries: number;
|
||||||
|
total_pages: number;
|
||||||
|
total_size_bytes: number;
|
||||||
|
total_authors: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadingStatusStats = {
|
||||||
|
unread: number;
|
||||||
|
reading: number;
|
||||||
|
read: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormatCount = {
|
||||||
|
format: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LanguageCount = {
|
||||||
|
language: string | null;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LibraryStatsItem = {
|
||||||
|
library_name: string;
|
||||||
|
book_count: number;
|
||||||
|
size_bytes: number;
|
||||||
|
read_count: number;
|
||||||
|
reading_count: number;
|
||||||
|
unread_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopSeriesItem = {
|
||||||
|
series: string;
|
||||||
|
book_count: number;
|
||||||
|
read_count: number;
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MonthlyAdditions = {
|
||||||
|
month: string;
|
||||||
|
books_added: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatsResponse = {
|
||||||
|
overview: StatsOverview;
|
||||||
|
reading_status: ReadingStatusStats;
|
||||||
|
by_format: FormatCount[];
|
||||||
|
by_language: LanguageCount[];
|
||||||
|
by_library: LibraryStatsItem[];
|
||||||
|
top_series: TopSeriesItem[];
|
||||||
|
additions_over_time: MonthlyAdditions[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchStats() {
|
||||||
|
return apiFetch<StatsResponse>("/stats");
|
||||||
|
}
|
||||||
|
|
||||||
export async function markSeriesRead(seriesName: string, status: "read" | "unread" = "read") {
|
export async function markSeriesRead(seriesName: string, status: "read" | "unread" = "read") {
|
||||||
return apiFetch<{ updated: number }>("/series/mark-read", {
|
return apiFetch<{ updated: number }>("/series/mark-read", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "stripstream-backoffice",
|
"name": "stripstream-backoffice",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 7082",
|
"dev": "next dev -p 7082",
|
||||||
|
|||||||
Reference in New Issue
Block a user