Merge branch 'Silas'

This commit is contained in:
2026-06-17 13:32:56 +02:00
21 changed files with 965 additions and 242 deletions
+75 -28
View File
@@ -11,7 +11,9 @@ use yew_router::prelude::*;
/// Defines the application's various routes and their corresponding paths.
///
/// This enum is used by `yew-router` to map URLs to specific components,
/// enabling navigation within the single-page application.
/// enabling navigation within the single-page application. Each route is protected
/// by [`ProtectedRoute`] middleware where appropriate to enforce authentication and authorization.
/// See [`switch`] for the routing logic.
#[derive(Clone, PartialEq, Routable)]
enum Route {
/// The application's home page.
@@ -66,10 +68,17 @@ pub struct SidebarShellProps {
/// A shell component that provides a consistent layout with a sidebar and a main content area.
///
/// This component is designed to wrap page-specific content, ensuring that the sidebar
/// is always present for navigation.
/// is always present for navigation. Integrates with [`crate::pages::sidebar::Sidebar`] for navigation.
///
/// # Mobile Support
/// On mobile displays, the sidebar is hidden by default and can be toggled:
/// - **Menu Toggle Button**: Renders a floating menu button to slide the sidebar open.
/// - **Dark Mode Toggle**: Floating button to change theme.
/// - **Overlay Backdrop**: Dims the screen when the sidebar is open. Clicking it closes the sidebar.
/// - **Auto-Close on Navigation**: Automatically closes the sidebar when navigating to a new route.
///
/// # Components
/// - [`sidebar::Sidebar`]: The navigation sidebar component.
/// - [`crate::pages::sidebar::Sidebar`]: The navigation sidebar component, accepting mobile open state.
/// - Main content area: Renders the `children` passed to this component.
///
/// # Example
@@ -82,9 +91,46 @@ pub struct SidebarShellProps {
/// ```
#[component(SidebarShell)]
fn sidebar_shell(props: &SidebarShellProps) -> Html {
let route = use_route::<Route>();
let mobile_sidebar_open = use_state(|| false);
// Close mobile sidebar automatically on any route transition
{
let mobile_sidebar_open = mobile_sidebar_open.clone();
use_effect_with(route, move |_| {
mobile_sidebar_open.set(false);
|| ()
});
}
let on_open = {
let mobile_sidebar_open = mobile_sidebar_open.clone();
Callback::from(move |_: MouseEvent| mobile_sidebar_open.set(true))
};
let on_close = {
let mobile_sidebar_open = mobile_sidebar_open.clone();
Callback::from(move |_: ()| mobile_sidebar_open.set(false))
};
let on_close_click = {
let on_close = on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
};
html! {
<div class="layout">
<sidebar::Sidebar/>
<button class="mobile-menu-toggle" onclick={on_open}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="12" y2="12"/>
<line x1="4" x2="20" y1="6" y2="6"/>
<line x1="4" x2="20" y1="18" y2="18"/>
</svg>
</button>
<div class={if *mobile_sidebar_open { "sidebar-overlay open" } else { "sidebar-overlay" }} onclick={on_close_click}></div>
<sidebar::Sidebar is_open={*mobile_sidebar_open} on_close={on_close} />
<main class="content">
{ for props.children.iter() }
</main>
@@ -92,6 +138,7 @@ fn sidebar_shell(props: &SidebarShellProps) -> Html {
}
}
/// Props for the AdminCheckWrapper component.
#[derive(Properties, PartialEq)]
pub struct AdminCheckWrapperProps {
@@ -102,11 +149,11 @@ pub struct AdminCheckWrapperProps {
///
/// This component is used to gate access to pages that should only be accessible before
/// system initialization (e.g., login page). It performs an asynchronous check to the
/// `/api/check-admin` endpoint to determine system state.
/// backend's `/api/check-admin` endpoint (via `crate::handlers::auth::check_admin_exists`) to determine system state.
///
/// # Behavior
/// - **Loading**: Displays "Loading..." while checking admin status
/// - **No Admin**: Automatically redirects to `/setup` page to initialize
/// - **Loading**: Displays "Loading..." while checking admin status from the backend
/// - **No Admin**: Automatically redirects to [`Route::Setup`] page for initialization
/// - **Admin Exists**: Renders the wrapped children (e.g., login page)
///
/// # Example Usage
@@ -116,19 +163,9 @@ pub struct AdminCheckWrapperProps {
/// </AdminCheckWrapper>
/// ```
///
/// # Implementation Detail
/// The check happens in `use_effect_with` on component mount:
/// ```ignore
/// spawn_local(async move {
/// match Request::get("/api/check-admin").send().await {
/// Ok(resp) if resp.status() == 200 => {
/// let has_admin = data["has_admin"].as_bool().unwrap_or(false);
/// admin_exists.set(Some(has_admin));
/// }
/// _ => admin_exists.set(Some(false))
/// }
/// });
/// ```
/// # Backend Integration
/// The check queries the backend's `/api/check-admin` endpoint which returns `{"has_admin": bool}`.
/// This allows the frontend to determine if initial admin setup is required.
#[component(AdminCheckWrapper)]
fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html {
let admin_exists = use_state(|| None::<bool>);
@@ -167,18 +204,24 @@ fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html {
/// The main routing logic for the application.
///
/// This function takes a `Route` enum variant and returns the corresponding HTML
/// This function takes a [`Route`] enum variant and returns the corresponding HTML
/// content to be rendered. It acts as a central dispatcher for the application's
/// navigation.
/// navigation and authentication flow.
///
/// Many routes are wrapped in a [`ProtectedRoute`] to enforce authentication
/// and authorization, and in a [`SidebarShell`] to maintain consistent layout.
/// Most routes are wrapped in [`ProtectedRoute`] to enforce authentication
/// and authorization based on the `admin_page` flag, and in [`SidebarShell`] to maintain consistent layout.
/// Login and Setup routes use [`AdminCheckWrapper`] instead to handle pre-authentication states.
///
/// # Arguments
/// - `route`: The [`Route`] enum variant representing the current URL path.
///
/// # Returns
/// An `Html` component that should be rendered for the given route.
///
/// # Route Protection
/// - **Admin-required routes** (`admin_page={true}`): Require both authentication and admin privileges
/// - **Public routes** (`admin_page={false}`): Require only authentication
/// - **Pre-auth routes** (`AdminCheckWrapper`): Used before admin creation or login
fn switch(route: Route) -> Html {
match route {
Route::Home => html! {
@@ -260,12 +303,16 @@ fn switch(route: Route) -> Html {
/// The root component of the Yew application.
///
/// This component sets up the application's routing using `yew-router`'s
/// [`BrowserRouter`] and [`Switch`] components. All other application content
/// is rendered based on the current route.
/// `BrowserRouter` and `Switch` components. All other application content
/// is rendered based on the current [`Route`] matched by the `switch` function.
///
/// Uses [`switch`] as the routing dispatcher to handle all route-specific rendering,
/// which applies appropriate middleware like [`ProtectedRoute`] and [`AdminCheckWrapper`].
///
/// # Structure
/// - [`BrowserRouter`]: Enables client-side routing.
/// - [`Switch`]: Renders components based on the matched [`Route`].
/// - `BrowserRouter`: Enables client-side routing.
/// - `Switch`: Renders components based on the matched [`Route`].
/// - `switch` function: Determines which component to render for each route.
#[component(App)]
pub fn app() -> Html {
html! {