Compare commits
3 Commits
473e849dfa
...
e8bb014874
| Author | SHA1 | Date | |
|---|---|---|---|
| e8bb014874 | |||
| 4c75e08056 | |||
| f1b3aec94a |
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
93
apps/backoffice/app/components/MobileNav.tsx
Normal file
93
apps/backoffice/app/components/MobileNav.tsx
Normal 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)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user