Compare commits

..

3 Commits

Author SHA1 Message Date
e8bb014874 feat(backoffice): amélioration navigation mobile et tablette
- Ajout d'un menu hamburger mobile (MobileNav) avec drawer animé via React Portal (évite le piège du backdrop-filter du header)
- Popin JobsIndicator adaptée mobile : positionnement fixed plein-écran sur petit écran, backdrop semi-transparent
- Navigation tablette (md→lg) : icônes seules avec tooltip natif, labels visibles uniquement sur lg+

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 22:44:33 +01:00
4c75e08056 fix(api): resolve all OpenAPI schema reference errors
- Add #[schema(value_type = Option<String>)] on chrono::DateTime fields
- Register SeriesPage in openapi.rs components
- Fix module-prefixed ref (index_jobs::IndexJobResponse -> IndexJobResponse)
- Strengthen test: assert all $ref targets exist in components/schemas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 22:27:52 +01:00
f1b3aec94a docs(api): complete OpenAPI coverage for all routes
Add missing utoipa annotations:
- GET /books/{id}/thumbnail
- GET/POST /settings, /settings/{key}
- POST /settings/cache/clear
- GET /settings/cache/stats, /settings/thumbnail/stats
Add 'settings' tag and register all new schemas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 22:23:28 +01:00
8 changed files with 258 additions and 41 deletions

View File

@@ -347,6 +347,21 @@ use axum::{
response::IntoResponse, response::IntoResponse,
}; };
/// Get book thumbnail image
#[utoipa::path(
get,
path = "/books/{id}/thumbnail",
tag = "books",
params(
("id" = String, Path, description = "Book UUID"),
),
responses(
(status = 200, description = "WebP thumbnail image", content_type = "image/webp"),
(status = 404, description = "Book not found or thumbnail not available"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn get_thumbnail( pub async fn get_thumbnail(
State(state): State<AppState>, State(state): State<AppState>,
Path(book_id): Path<Uuid>, Path(book_id): Path<Uuid>,

View File

@@ -18,6 +18,7 @@ pub struct LibraryResponse {
pub book_count: i64, pub book_count: i64,
pub monitor_enabled: bool, pub monitor_enabled: bool,
pub scan_mode: String, pub scan_mode: String,
#[schema(value_type = Option<String>)]
pub next_scan_at: Option<chrono::DateTime<chrono::Utc>>, pub next_scan_at: Option<chrono::DateTime<chrono::Utc>>,
pub watcher_enabled: bool, pub watcher_enabled: bool,
} }

View File

@@ -6,6 +6,7 @@ use utoipa::OpenApi;
paths( paths(
crate::books::list_books, crate::books::list_books,
crate::books::get_book, crate::books::get_book,
crate::books::get_thumbnail,
crate::books::list_series, crate::books::list_series,
crate::pages::get_page, crate::pages::get_page,
crate::search::search_books, crate::search::search_books,
@@ -27,6 +28,12 @@ use utoipa::OpenApi;
crate::tokens::list_tokens, crate::tokens::list_tokens,
crate::tokens::create_token, crate::tokens::create_token,
crate::tokens::revoke_token, crate::tokens::revoke_token,
crate::settings::get_settings,
crate::settings::get_setting,
crate::settings::update_setting,
crate::settings::clear_cache,
crate::settings::get_cache_stats,
crate::settings::get_thumbnail_stats,
), ),
components( components(
schemas( schemas(
@@ -35,6 +42,7 @@ use utoipa::OpenApi;
crate::books::BooksPage, crate::books::BooksPage,
crate::books::BookDetails, crate::books::BookDetails,
crate::books::SeriesItem, crate::books::SeriesItem,
crate::books::SeriesPage,
crate::pages::PageQuery, crate::pages::PageQuery,
crate::search::SearchQuery, crate::search::SearchQuery,
crate::search::SearchResponse, crate::search::SearchResponse,
@@ -51,6 +59,10 @@ use utoipa::OpenApi;
crate::tokens::CreateTokenRequest, crate::tokens::CreateTokenRequest,
crate::tokens::TokenResponse, crate::tokens::TokenResponse,
crate::tokens::CreatedTokenResponse, crate::tokens::CreatedTokenResponse,
crate::settings::UpdateSettingRequest,
crate::settings::ClearCacheResponse,
crate::settings::CacheStats,
crate::settings::ThumbnailStats,
ErrorResponse, ErrorResponse,
) )
), ),
@@ -62,6 +74,7 @@ use utoipa::OpenApi;
(name = "libraries", description = "Library management endpoints (Admin only)"), (name = "libraries", description = "Library management endpoints (Admin only)"),
(name = "indexing", description = "Search index management and job control (Admin only)"), (name = "indexing", description = "Search index management and job control (Admin only)"),
(name = "tokens", description = "API token management (Admin only)"), (name = "tokens", description = "API token management (Admin only)"),
(name = "settings", description = "Application settings and cache management (Admin only)"),
), ),
modifiers(&SecurityAddon) modifiers(&SecurityAddon)
)] )]
@@ -106,15 +119,24 @@ mod tests {
.to_pretty_json() .to_pretty_json()
.expect("Failed to serialize OpenAPI"); .expect("Failed to serialize OpenAPI");
// Check that there are no references to non-existent schemas // Check that all $ref targets exist in components/schemas
assert!( let doc: serde_json::Value =
!json.contains("\"/components/schemas/Uuid\""), serde_json::from_str(&json).expect("OpenAPI JSON should be valid");
"Uuid schema should not be referenced" let empty = serde_json::Map::new();
); let schemas = doc["components"]["schemas"]
assert!( .as_object()
!json.contains("\"/components/schemas/DateTime\""), .unwrap_or(&empty);
"DateTime schema should not be referenced" let prefix = "#/components/schemas/";
); let mut broken: Vec<String> = Vec::new();
for part in json.split(prefix).skip(1) {
if let Some(name) = part.split('"').next() {
if !schemas.contains_key(name) {
broken.push(name.to_string());
}
}
}
broken.dedup();
assert!(broken.is_empty(), "Unresolved schema refs: {:?}", broken);
// Save to file for inspection // Save to file for inspection
std::fs::write("/tmp/openapi.json", &json).expect("Failed to write file"); std::fs::write("/tmp/openapi.json", &json).expect("Failed to write file");

View File

@@ -6,28 +6,29 @@ use axum::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use sqlx::Row; use sqlx::Row;
use utoipa::ToSchema;
use crate::{error::ApiError, state::AppState}; use crate::{error::ApiError, state::AppState};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateSettingRequest { pub struct UpdateSettingRequest {
pub value: Value, pub value: Value,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ClearCacheResponse { pub struct ClearCacheResponse {
pub success: bool, pub success: bool,
pub message: String, pub message: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CacheStats { pub struct CacheStats {
pub total_size_mb: f64, pub total_size_mb: f64,
pub file_count: u64, pub file_count: u64,
pub directory: String, pub directory: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ThumbnailStats { pub struct ThumbnailStats {
pub total_size_mb: f64, pub total_size_mb: f64,
pub file_count: u64, pub file_count: u64,
@@ -43,7 +44,18 @@ pub fn settings_routes() -> Router<AppState> {
.route("/settings/thumbnail/stats", get(get_thumbnail_stats)) .route("/settings/thumbnail/stats", get(get_thumbnail_stats))
} }
async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, ApiError> { /// List all settings
#[utoipa::path(
get,
path = "/settings",
tag = "settings",
responses(
(status = 200, description = "All settings as key/value object"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
let rows = sqlx::query(r#"SELECT key, value FROM app_settings"#) let rows = sqlx::query(r#"SELECT key, value FROM app_settings"#)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
@@ -58,7 +70,20 @@ async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, ApiE
Ok(Json(Value::Object(settings))) Ok(Json(Value::Object(settings)))
} }
async fn get_setting( /// Get a single setting by key
#[utoipa::path(
get,
path = "/settings/{key}",
tag = "settings",
params(("key" = String, Path, description = "Setting key")),
responses(
(status = 200, description = "Setting value"),
(status = 404, description = "Setting not found"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn get_setting(
State(state): State<AppState>, State(state): State<AppState>,
axum::extract::Path(key): axum::extract::Path<String>, axum::extract::Path(key): axum::extract::Path<String>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
@@ -76,7 +101,20 @@ async fn get_setting(
} }
} }
async fn update_setting( /// Create or update a setting
#[utoipa::path(
post,
path = "/settings/{key}",
tag = "settings",
params(("key" = String, Path, description = "Setting key")),
request_body = UpdateSettingRequest,
responses(
(status = 200, description = "Updated setting value"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn update_setting(
State(state): State<AppState>, State(state): State<AppState>,
axum::extract::Path(key): axum::extract::Path<String>, axum::extract::Path(key): axum::extract::Path<String>,
Json(body): Json<UpdateSettingRequest>, Json(body): Json<UpdateSettingRequest>,
@@ -99,7 +137,18 @@ async fn update_setting(
Ok(Json(value)) Ok(Json(value))
} }
async fn clear_cache(State(_state): State<AppState>) -> Result<Json<ClearCacheResponse>, ApiError> { /// Clear the image page cache
#[utoipa::path(
post,
path = "/settings/cache/clear",
tag = "settings",
responses(
(status = 200, body = ClearCacheResponse),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn clear_cache(State(_state): State<AppState>) -> Result<Json<ClearCacheResponse>, ApiError> {
let cache_dir = std::env::var("IMAGE_CACHE_DIR") let cache_dir = std::env::var("IMAGE_CACHE_DIR")
.unwrap_or_else(|_| "/tmp/stripstream-image-cache".to_string()); .unwrap_or_else(|_| "/tmp/stripstream-image-cache".to_string());
@@ -128,7 +177,18 @@ async fn clear_cache(State(_state): State<AppState>) -> Result<Json<ClearCacheRe
Ok(Json(result)) Ok(Json(result))
} }
async fn get_cache_stats(State(_state): State<AppState>) -> Result<Json<CacheStats>, ApiError> { /// Get image page cache statistics
#[utoipa::path(
get,
path = "/settings/cache/stats",
tag = "settings",
responses(
(status = 200, body = CacheStats),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn get_cache_stats(State(_state): State<AppState>) -> Result<Json<CacheStats>, ApiError> {
let cache_dir = std::env::var("IMAGE_CACHE_DIR") let cache_dir = std::env::var("IMAGE_CACHE_DIR")
.unwrap_or_else(|_| "/tmp/stripstream-image-cache".to_string()); .unwrap_or_else(|_| "/tmp/stripstream-image-cache".to_string());
@@ -208,7 +268,18 @@ fn compute_dir_stats(path: &std::path::Path) -> (u64, u64) {
(total_size, file_count) (total_size, file_count)
} }
async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<ThumbnailStats>, ApiError> { /// Get thumbnail storage statistics
#[utoipa::path(
get,
path = "/settings/thumbnail/stats",
tag = "settings",
responses(
(status = 200, body = ThumbnailStats),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<ThumbnailStats>, ApiError> {
let settings = sqlx::query(r#"SELECT value FROM app_settings WHERE key = 'thumbnail'"#) let settings = sqlx::query(r#"SELECT value FROM app_settings WHERE key = 'thumbnail'"#)
.fetch_optional(&_state.pool) .fetch_optional(&_state.pool)
.await?; .await?;

View File

@@ -6,7 +6,7 @@ use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::{error::ApiError, index_jobs, state::AppState}; use crate::{error::ApiError, index_jobs::{self, IndexJobResponse}, state::AppState};
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
pub struct ThumbnailsRebuildRequest { pub struct ThumbnailsRebuildRequest {
@@ -21,7 +21,7 @@ pub struct ThumbnailsRebuildRequest {
tag = "indexing", tag = "indexing",
request_body = Option<ThumbnailsRebuildRequest>, request_body = Option<ThumbnailsRebuildRequest>,
responses( responses(
(status = 200, body = index_jobs::IndexJobResponse), (status = 200, body = IndexJobResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"), (status = 403, description = "Forbidden - Admin scope required"),
), ),
@@ -55,7 +55,7 @@ pub async fn start_thumbnails_rebuild(
tag = "indexing", tag = "indexing",
request_body = Option<ThumbnailsRebuildRequest>, request_body = Option<ThumbnailsRebuildRequest>,
responses( responses(
(status = 200, body = index_jobs::IndexJobResponse), (status = 200, body = IndexJobResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"), (status = 403, description = "Forbidden - Admin scope required"),
), ),

View File

@@ -146,10 +146,22 @@ export function JobsIndicator() {
/> />
</button> </button>
{/* Backdrop mobile */}
{isOpen && (
<div
className="fixed inset-0 z-40 sm:hidden bg-background/60 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
)}
{/* Popin/Dropdown with glassmorphism */} {/* Popin/Dropdown with glassmorphism */}
{isOpen && ( {isOpen && (
<div className=" <div className="
absolute right-0 top-full mt-2 w-96 fixed sm:absolute
inset-x-3 sm:inset-x-auto
top-[4.5rem] sm:top-full sm:mt-2
sm:w-96
bg-popover/95 backdrop-blur-md bg-popover/95 backdrop-blur-md
rounded-xl rounded-xl
shadow-elevation-2 shadow-elevation-2

View File

@@ -0,0 +1,93 @@
"use client";
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { NavIcon } from "./ui";
type NavItem = {
href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens" | "/settings";
label: string;
icon: "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "settings";
};
const HamburgerIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-5 h-5">
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" />
</svg>
);
const XIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-5 h-5">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" />
</svg>
);
export function MobileNav({ navItems }: { navItems: NavItem[] }) {
const [isOpen, setIsOpen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const overlay = (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 z-[60] bg-background/80 backdrop-blur-sm md:hidden transition-opacity duration-300 ${isOpen ? "opacity-100" : "opacity-0 pointer-events-none"}`}
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
{/* Drawer */}
<div
className={`
fixed inset-y-0 left-0 z-[70] w-64
bg-background/95 backdrop-blur-xl
border-r border-border/60
flex flex-col
transform transition-transform duration-300 ease-in-out
md:hidden
${isOpen ? "translate-x-0" : "-translate-x-full"}
`}
>
<div className="h-16 border-b border-border/40 flex items-center px-4">
<span className="text-sm font-semibold text-muted-foreground tracking-wide uppercase">Navigation</span>
</div>
<nav className="flex flex-col gap-1 p-3 flex-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-3 px-3 py-3 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors duration-200 active:scale-[0.98]"
onClick={() => setIsOpen(false)}
>
<NavIcon name={item.icon} />
<span className="font-medium">{item.label}</span>
</Link>
))}
</nav>
</div>
</>
);
return (
<>
{/* Hamburger button — reste dans le header */}
<button
className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
onClick={() => setIsOpen(!isOpen)}
aria-label={isOpen ? "Close menu" : "Open menu"}
aria-expanded={isOpen}
>
{isOpen ? <XIcon /> : <HamburgerIcon />}
</button>
{/* Backdrop + Drawer portés directement sur document.body,
hors du header et de son backdrop-filter */}
{mounted && createPortal(overlay, document.body)}
</>
);
}

View File

@@ -7,6 +7,7 @@ import { ThemeProvider } from "./theme-provider";
import { ThemeToggle } from "./theme-toggle"; import { ThemeToggle } from "./theme-toggle";
import { JobsIndicator } from "./components/JobsIndicator"; import { JobsIndicator } from "./components/JobsIndicator";
import { NavIcon, Icon } from "./components/ui"; import { NavIcon, Icon } from "./components/ui";
import { MobileNav } from "./components/MobileNav";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "StripStream Backoffice", title: "StripStream Backoffice",
@@ -61,9 +62,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="hidden md:flex items-center gap-1"> <div className="hidden md:flex items-center gap-1">
{navItems.map((item) => ( {navItems.map((item) => (
<NavLink key={item.href} href={item.href}> <NavLink key={item.href} href={item.href} title={item.label}>
<NavIcon name={item.icon} /> <NavIcon name={item.icon} />
<span className="ml-2">{item.label}</span> <span className="ml-2 hidden lg:inline">{item.label}</span>
</NavLink> </NavLink>
))} ))}
</div> </div>
@@ -79,6 +80,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<Icon name="settings" size="md" /> <Icon name="settings" size="md" />
</Link> </Link>
<ThemeToggle /> <ThemeToggle />
<MobileNav navItems={navItems} />
</div> </div>
</div> </div>
</nav> </nav>
@@ -95,13 +97,14 @@ export default function RootLayout({ children }: { children: ReactNode }) {
} }
// Navigation Link Component // Navigation Link Component
function NavLink({ href, children }: { href: NavItem["href"]; children: React.ReactNode }) { function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
return ( return (
<Link <Link
href={href} href={href}
title={title}
className=" className="
flex items-center flex items-center
px-3 py-2 px-2 lg:px-3 py-2
rounded-lg rounded-lg
text-sm font-medium text-sm font-medium
text-muted-foreground text-muted-foreground