diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 23fe9ad..9892451 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -11,6 +11,7 @@ mod reading_progress; mod search; mod settings; mod state; +mod stats; mod thumbnails; mod tokens; @@ -114,6 +115,7 @@ async fn main() -> anyhow::Result<()> { .route("/series", get(books::list_all_series)) .route("/series/ongoing", get(books::ongoing_series)) .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_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit)) .route_layer(middleware::from_fn_with_state( diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index 5ee8282..b090549 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -36,6 +36,7 @@ use utoipa::OpenApi; crate::tokens::create_token, crate::tokens::revoke_token, crate::tokens::delete_token, + crate::stats::get_stats, crate::settings::get_settings, crate::settings::get_setting, crate::settings::update_setting, @@ -78,6 +79,14 @@ use utoipa::OpenApi; crate::settings::ClearCacheResponse, crate::settings::CacheStats, 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, ) ), diff --git a/apps/api/src/stats.rs b/apps/api/src/stats.rs new file mode 100644 index 0000000..0bb7dad --- /dev/null +++ b/apps/api/src/stats.rs @@ -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, + 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, + pub by_language: Vec, + pub by_library: Vec, + pub top_series: Vec, + pub additions_over_time: Vec, +} + +/// 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, +) -> Result, 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 = format_rows + .iter() + .map(|r| FormatCount { + format: r.get::, _>("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 = 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 = 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 = 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 = 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, + })) +} diff --git a/apps/backoffice/app/components/ui/Icon.tsx b/apps/backoffice/app/components/ui/Icon.tsx index e49b281..09c6125 100644 --- a/apps/backoffice/app/components/ui/Icon.tsx +++ b/apps/backoffice/app/components/ui/Icon.tsx @@ -90,7 +90,7 @@ const colorClasses: Partial> = { libraries: "text-primary", jobs: "text-warning", tokens: "text-error", - series: "text-primary", + series: "text-warning", settings: "text-muted-foreground", image: "text-primary", cache: "text-warning", diff --git a/apps/backoffice/app/page.tsx b/apps/backoffice/app/page.tsx index 9dbc998..5f13911 100644 --- a/apps/backoffice/app/page.tsx +++ b/apps/backoffice/app/page.tsx @@ -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

No data

; + + const radius = 40; + const circumference = 2 * Math.PI * radius; + let offset = 0; + return ( -
-
-

- StripStream Backoffice -

-

- Manage libraries, indexing jobs, and API tokens from a modern admin interface. -

-
- -
- {/* Libraries Card */} - -
- - - + ); } + +// 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

No data

; + + return ( +
+ {data.map((d, i) => ( +
+ {d.value || ""} +
+ + {d.label} + +
+ ))} +
+ ); +} + +// 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 ( +
+
+ {label} + {subLabel || formatNumber(value)} +
+
+
+
+
+ ); +} + +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 ( +
+
+

StripStream Backoffice

+

Unable to load statistics. Make sure the API is running.

+
+ +
+ ); + } + + 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 ( +
+ {/* Header */} +
+

+ + + + Dashboard +

+

+ Overview of your comic collection. Manage your libraries, track your reading progress, and explore your books and series. +

+
+ + {/* Overview stat cards */} +
+ + + + + + +
+ + {/* Charts row */} +
+ {/* Reading status donut */} + + + Reading Status + + + + + + + {/* By format donut */} + + + By Format + + + ({ + label: (f.format || "Unknown").toUpperCase(), + value: f.count, + color: formatColors[i % formatColors.length], + }))} + /> + + + + {/* By library donut */} + + + By Library + + + ({ + label: l.library_name, + value: l.book_count, + color: formatColors[i % formatColors.length], + }))} + /> + + +
+ + {/* Second row */} +
+ {/* Monthly additions bar chart */} + + + Books Added (Last 12 Months) + + + ({ + label: m.month.slice(5), // "MM" from "YYYY-MM" + value: m.books_added, + }))} + color="hsl(198 78% 37%)" + /> + + + + {/* Top series */} + + + Top Series + + +
+ {top_series.slice(0, 8).map((s, i) => ( + + ))} + {top_series.length === 0 && ( +

No series yet

+ )} +
+
+
+
+ + {/* Libraries breakdown */} + {by_library.length > 0 && ( + + + Libraries + + +
+ {by_library.map((lib, i) => ( +
+
+ {lib.library_name} + {formatBytes(lib.size_bytes)} +
+
+
+
+
+
+
+ {lib.book_count} books + {lib.read_count} read + {lib.reading_count} in progress +
+
+ ))} +
+ + + )} + + {/* Quick links */} + +
+ ); +} + +function StatCard({ icon, label, value, color }: { icon: string; label: string; value: string; color: string }) { + const icons: Record = { + book: , + series: , + library: , + pages: , + author: , + size: , + }; + + const colorClasses: Record = { + primary: "bg-primary/10 text-primary", + success: "bg-success/10 text-success", + warning: "bg-warning/10 text-warning", + }; + + return ( + +
+
+ + {icons[icon]} + +
+
+

{value}

+

{label}

+
+
+
+ ); +} + +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: }, + { href: "/books", label: "Books", bg: "bg-success/10", text: "text-success", hoverBg: "group-hover:bg-success", hoverText: "group-hover:text-white", icon: }, + { href: "/series", label: "Series", bg: "bg-warning/10", text: "text-warning", hoverBg: "group-hover:bg-warning", hoverText: "group-hover:text-white", icon: }, + { href: "/jobs", label: "Jobs", bg: "bg-destructive/10", text: "text-destructive", hoverBg: "group-hover:bg-destructive", hoverText: "group-hover:text-destructive-foreground", icon: }, + ]; + + return ( +
+ {links.map((l) => ( + +
+ + {l.icon} + +
+ {l.label} + + ))} +
+ ); +} diff --git a/apps/backoffice/app/series/page.tsx b/apps/backoffice/app/series/page.tsx index 05afc9a..ff0599f 100644 --- a/apps/backoffice/app/series/page.tsx +++ b/apps/backoffice/app/series/page.tsx @@ -46,7 +46,7 @@ export default async function SeriesPage({ <>

- + Series diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index a3658c6..840bc79 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -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("/stats"); +} + export async function markSeriesRead(seriesName: string, status: "read" | "unread" = "read") { return apiFetch<{ updated: number }>("/series/mark-read", { method: "POST",