Files
ticketsystem/frontend/src/pages/sidebar.rs
T
2026-06-17 15:47:34 +02:00

482 lines
18 KiB
Rust

use gloo_net::http::Request;
use gloo_storage::{LocalStorage, Storage};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::rc::Rc;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
/// The key used to store and retrieve the sidebar's expansion state in `LocalStorage`.
///
/// This constant ensures consistency when accessing the stored state across different
/// parts of the application. The value associated with this key in `LocalStorage`
/// will be a serialized [`SidebarExpandState`] object.
const STORAGE_KEY: &str = "sidebar_state";
/// Represents the expansion state of collapsible menus within the sidebar.
///
/// This struct is used to persist the open/closed state of sidebar submenus,
/// improving user experience by remembering their last interaction.
/// The state is stored in and retrieved from `LocalStorage`.
///
/// # Fields
/// - `ticket_open`: A boolean indicating if the "Tickets" submenu is expanded (`true`) or collapsed (`false`).
/// - `users_open`: A boolean indicating if the "Users" submenu is expanded (`true`) or collapsed (`false`).
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct SidebarExpandState {
pub ticket_open: bool,
pub users_open: bool,
}
impl Default for SidebarExpandState {
/// Provides the default expansion state, where all submenus are collapsed.
fn default() -> Self {
Self {
ticket_open: false,
users_open: false,
}
}
}
/// Represents the shared state for the sidebar's expandable menus.
///
/// This context struct is provided via Yew's `ContextProvider` and allows child components
/// within the sidebar to read and modify the expansion state of the "Tickets" and "Users" menus.
///
/// # Fields
/// - `expand`: The current [`SidebarExpandState`] holding whether each menu is open or closed.
/// - `set_tickets_open`: A `Callback<bool>` to explicitly set the open state of the "Tickets" menu.
/// - `toggle_tickets`: A `Callback<()>` to toggle the open state of the "Tickets" menu.
/// - `set_users_open`: A `Callback<bool>` to explicitly set the open state of the "Users" menu.
/// - `toggle_users`: A `Callback<()>` to toggle the open state of the "Users" menu.
#[derive(Clone, PartialEq)]
pub struct SidebarState {
pub expand: SidebarExpandState,
pub set_tickets_open: Callback<bool>,
pub toggle_tickets: Callback<()>,
pub set_users_open: Callback<bool>,
pub toggle_users: Callback<()>,
}
impl SidebarState {
/// Creates a new `SidebarState` instance.
///
/// This constructor is typically used within the [`SidebarStateProvider`] to
/// bundle the current expansion state and its associated callbacks for context sharing.
fn new(
expand: SidebarExpandState,
set_tickets_open: Callback<bool>,
toggle_tickets: Callback<()>,
set_users_open: Callback<bool>,
toggle_users: Callback<()>,
) -> Self {
Self {
expand,
set_tickets_open,
toggle_tickets,
set_users_open,
toggle_users,
}
}
}
/// Properties for components that provide sidebar state.
///
/// This struct is typically used by context providers that wrap child components
/// and supply them with shared sidebar-related state or functionality.
///
/// # Fields
/// - `children`: The child components that will have access to the provided sidebar state.
#[derive(Properties, PartialEq)]
pub struct SidebarProps {
pub children: Children,
}
/// A Yew context provider component that manages and supplies the sidebar's expansion state.
///
/// This component is responsible for:
/// 1. Loading the initial `SidebarExpandState` from browser `LocalStorage` (or using defaults).
/// 2. Providing a `SidebarState` context (`Rc<SidebarState>`) to its children, which includes
/// the current expansion state and callbacks to modify it.
/// 3. Persisting any changes to the `SidebarExpandState` back to `LocalStorage`.
///
/// Child components (like [`TicketMenu`] and [`UsersMenu`]) can consume this context
/// to react to and control the sidebar's collapsible sections.
///
/// # LocalStorage Key
/// The state is stored under the key `STORAGE_KEY` ("sidebar_state").
///
/// # Example Usage
/// ```rust
/// html! {
/// <SidebarStateProvider>
/// <Sidebar /> // Sidebar and its sub-components will have access to the state
/// </SidebarStateProvider>
/// }
/// ```
#[component(SidebarStateProvider)]
pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let default = LocalStorage::get(STORAGE_KEY).unwrap_or_else(|_| SidebarExpandState::default());
let state = use_state(|| default);
{
let state = state.clone();
use_effect_with(state, move |s| {
LocalStorage::set(STORAGE_KEY, &**s).ok();
|| ()
});
}
let set_tickets_open = {
let state = state.clone();
Callback::from(move |v: bool| {
state.set(SidebarExpandState {
ticket_open: v,
users_open: state.users_open,
})
})
};
let toggle_tickets = {
let state = state.clone();
Callback::from(move |_| {
let current = state.ticket_open;
state.set(SidebarExpandState {
ticket_open: !current,
users_open: state.users_open,
});
})
};
let set_users_open = {
let state = state.clone();
Callback::from(move |v: bool| {
state.set(SidebarExpandState {
ticket_open: state.ticket_open,
users_open: v,
})
})
};
let toggle_users = {
let state = state.clone();
Callback::from(move |_| {
let current = state.users_open;
state.set(SidebarExpandState {
ticket_open: state.ticket_open,
users_open: !current,
});
})
};
let ctx = SidebarState::new(
(*state).clone(),
set_tickets_open,
toggle_tickets,
set_users_open,
toggle_users,
);
html! {
<ContextProvider<Rc<SidebarState>> context={Rc::new(ctx)}>
{ for props.children.iter() }
</ContextProvider<Rc<SidebarState>>>
}
}
/// A collapsible menu component for "Tickets" within the sidebar.
///
/// This component consumes the [`SidebarState`] context to manage its expanded/collapsed state.
/// It displays a button to toggle its visibility and, when expanded, reveals links
/// for "Submit Ticket" and "View Tickets".
///
/// # Context
/// Requires [`SidebarStateProvider`] as an ancestor to provide the necessary context.
///
/// # Functionality
/// - **Toggle Button**: Clicking the button toggles the `ticket_open` state in the `SidebarState`.
/// - **Submenu Links**:
/// - [`crate::Route::Ticket`]: Link to submit a new ticket.
/// - [`crate::Route::AllTickets`]: Link to view all tickets.
///
/// # Example
/// ```rust
/// html! {
/// <TicketMenu />
/// }
/// ```
#[component(TicketMenu)]
pub fn ticket_menu() -> Html {
let ctx =
use_context::<Rc<SidebarState>>().expect("TicketsMenu must be inside SidebarStateProvider");
let on_toggle = {
let cb = ctx.toggle_tickets.clone();
Callback::from(move |_| cb.emit(()))
};
let open = ctx.expand.ticket_open;
html! {
<li class={ if open { "menu open" } else { "menu" } }>
<button
class="menu-toggle"
onclick={on_toggle}
aria-expanded={open.to_string()}
>
{ "Tickets" }
{ if open { " ▾" } else { " ▸" } }
</button>
{
if open {
html! {
<ul class="submenu" role="menu">
<li role="none">
<Link<crate::Route> to={crate::Route::Ticket}><span role="menuitem">{ "Ticket einreichen" }</span></Link<crate::Route>>
</li>
<li role="none">
<Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "Tickets anzeigen" }</span></Link<crate::Route>>
</li>
</ul>
}
} else {
html!{}
}
}
</li>
}
}
/// A collapsible menu component for "Users" within the sidebar.
///
/// This component consumes the [`SidebarState`] context to manage its expanded/collapsed state.
/// It displays a button to toggle its visibility and, when expanded, reveals links
/// for "Create User" and "View Users". This menu is typically only visible to administrators.
///
/// # Context
/// Requires [`SidebarStateProvider`] as an ancestor to provide the necessary context.
///
/// # Functionality
/// - **Toggle Button**: Clicking the button toggles the `users_open` state in the `SidebarState`.
/// - **Submenu Links**:
/// - [`crate::Route::Register`]: Link to create a new user account.
/// - [`crate::Route::AllUsers`]: Link to view all registered users.
///
/// # Example
/// ```rust
/// html! {
/// <UsersMenu />
/// }
/// ```
#[component(UsersMenu)]
pub fn users_menu() -> Html {
let ctx =
use_context::<Rc<SidebarState>>().expect("UsersMenu must be inside SidebarStateProvider");
let on_toggle = {
let cb = ctx.toggle_users.clone();
Callback::from(move |_| cb.emit(()))
};
let open = ctx.expand.users_open;
html! {
<li class={ if open { "menu open" } else { "menu" } }>
<button
class="menu-toggle"
onclick={on_toggle}
aria-expanded={open.to_string()}
>
{ "Benutzer" }
{ if open { " ▾" } else { " ▸" } }
</button>
{
if open {
html! {
<ul class="submenu" role="menu">
<li role="none">
<Link<crate::Route> to={crate::Route::Register}><span role="menuitem">{ "Benutzer erstellen" }</span></Link<crate::Route>>
</li>
<li role="none">
<Link<crate::Route> to={crate::Route::AllUsers}><span role="menuitem">{ "Benutzer anzeigen" }</span></Link<crate::Route>>
</li>
</ul>
}
} else {
html!{}
}
}
</li>
}
}
/// The main sidebar component of the application.
///
/// This component dynamically renders its content based on the user's authentication
/// and administrative status. It fetches the current user's details via `/api/users/current`
/// to determine what menu items to display.
///
/// # Mobile Support
/// On small screens:
/// - Slides into view from the left when `props.is_open` is `true`.
/// - Renders a close button (`✕`) in the header that emits `props.on_close`.
///
/// # Structure
/// - Wraps its content in a [`SidebarStateProvider`] to allow nested menus to manage their state.
/// - Contains a navigation (`<nav>`) element with an unordered list (`<ul>`) of menu items.
///
/// # Conditional Rendering
/// - **Loading**: Displays "Loading..." while fetching user data.
/// - **Non-Admin User**: Renders a condensed sidebar including:
/// - [`TicketMenu`]: For managing tickets.
/// - A "Logout" button.
/// - **Admin User**: Renders a full sidebar including:
/// - [`TicketMenu`]: For managing tickets.
/// - [`UsersMenu`]: For managing user accounts.
/// - A direct link to [`crate::Route::Diagnostics`] (Statistiken).
/// - A "Logout" button.
///
/// # Logout Functionality
/// The "Logout" button sends a GET request to `/api/logout`, clears the user's session,
/// and then redirects the user to the login page (`crate::Route::Login`).
/// Properties for the [`Sidebar`] component.
///
/// These properties enable managing and controlling the responsive mobile sidebar
/// layout and its visibility settings.
#[derive(Properties, PartialEq)]
pub struct SidebarComponentProps {
/// A boolean flag indicating whether the mobile sidebar is currently slid open (`true`) or hidden (`false`).
#[prop_or_default]
pub is_open: bool,
/// A callback emitted when the user requests to close/hide the mobile sidebar.
#[prop_or_default]
pub on_close: Callback<()>,
}
#[component(Sidebar)]
pub fn sidebar(props: &SidebarComponentProps) -> Html {
let is_admin = use_state(|| None::<bool>);
let navigator = use_navigator().expect("Sidebar must be used within a Router");
{
let is_admin = is_admin.clone();
use_effect_with((), move |_| {
spawn_local(async move {
let response = Request::get("/api/users/current")
.credentials(web_sys::RequestCredentials::Include)
.send()
.await;
match response {
Ok(resp) if resp.status() == 200 => {
let user_data: Value = resp.json().await.unwrap_or_default();
let admin_value = user_data["data"]["is_admin"].as_bool();
is_admin.set(admin_value);
}
_ => is_admin.set(Some(false)),
}
});
|| ()
});
}
let on_logout = {
let navigator = navigator.clone();
Callback::from(move |_| {
let navigator = navigator.clone();
spawn_local(async move {
let _ = Request::get("/api/logout")
.credentials(web_sys::RequestCredentials::Include)
.send()
.await;
navigator.push(&crate::Route::Login);
});
})
};
match *is_admin {
None => html! { <div class="sidebar-loading">{ "Wird geladen..." }</div> },
// Non-admin: render a condensed user sidebar (no diagnostics, limited links)
Some(false) => {
let on_close = props.on_close.clone();
html! {
<SidebarStateProvider>
<nav class={if props.is_open { "sidebar user open" } else { "sidebar user" }}>
<ul>
<li class="sidebar-header">
<Link<crate::Route> to={crate::Route::Home} classes="home">
<svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</Link<crate::Route>>
<button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</li>
<TicketMenu/>
<li class="logout-item">
<crate::darkmode::ThemeToggle/>
<button
class="sidebar-button"
onclick={on_logout.clone()}
>
{ "Abmelden" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
}
}
// Admin: full sidebar wrapped in provider so submenu state persists
Some(true) => {
let on_close = props.on_close.clone();
html! {
<SidebarStateProvider>
<nav class={if props.is_open { "sidebar admin open" } else { "sidebar admin" }}>
<ul>
<li class="sidebar-header">
<Link<crate::Route> to={crate::Route::Home} classes="home">
<svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</Link<crate::Route>>
<button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</li>
<TicketMenu/>
<UsersMenu/>
<Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>>
<li class="logout-item">
<crate::darkmode::ThemeToggle/>
<button
class="sidebar-button"
onclick={on_logout.clone()}
>
{ "Abmelden" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
}
}
}
}