6 Commits
Author SHA1 Message Date
schn33fuchs 7b26155118 README change 2026-06-07 09:39:09 +02:00
schn33fuchs a653bf3f45 README 2026-05-29 10:05:20 +02:00
schn33fuchs fb30d4ab83 Documentation
The docs now include dark mode, the sidebar on mobile and a expanded
ticket lifecycle
2026-05-29 10:03:58 +02:00
schn33fuchs 26384d2849 Sidebar mobile improvement
The sidebar vanished on a too small display. Button to reopen and close
are rendered
2026-05-29 09:45:34 +02:00
schn33fuchs bec2a8a451 Style fix
Login block now centered on page instead of same place as  with sidebar
2026-05-29 09:37:25 +02:00
schn33fuchs f96db06a33 Better error message
German now
2026-05-29 09:36:56 +02:00
9 changed files with 295 additions and 73 deletions
+8 -3
View File
@@ -45,14 +45,19 @@ location /api/ {
``` ```
## Usage of AI ## Usage of AI
Github Copilot CLI was used with the model Claude Haiku 4.5 to generate most of the documentation: Github Copilot CLI was used with the model Claude Haiku 4.5 to generate most of the documentation
Google Antigravity generated the Sequence Diagrams
### Prompt ### Prompt
Generate comments for cargo doc describing the indivilual components and create links to relevant structs, functions etc. Generate comments for cargo doc describing the indivilual components and create links to relevant structs, functions etc.
Generate a sequence diagramm in @[README.md] behind the class diagramms
### Output ### Output
The comments with `///` or `//!` The comments with `///` or `//!`
I've gone over most of it and modified it to my needs and opinions I've gone over it and modified it to my needs and opinions
The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
+16 -2
View File
@@ -248,6 +248,10 @@ classDiagram
class SidebarShellProps { class SidebarShellProps {
+children: Children +children: Children
} }
class SidebarComponentProps {
+is_open: bool
+on_close: Callback~()~
}
class AdminCheckWrapperProps { class AdminCheckWrapperProps {
+children: Children +children: Children
} }
@@ -255,6 +259,7 @@ classDiagram
RoomTotalsProps --> TicketPartial RoomTotalsProps --> TicketPartial
UserTotalProps --> UserPartial UserTotalProps --> UserPartial
UserTotalProps --> TicketPartial UserTotalProps --> TicketPartial
SidebarShellProps ..> SidebarComponentProps
``` ```
@@ -351,6 +356,15 @@ sequenceDiagram
DB-->>BE: Success DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"} BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>Admin: Update ticket status in UI FE-->>Admin: Update ticket status in UI
Note over Admin, DB: Ticket Archiving Flow (Admin Only Route)
Admin->>FE: Open Archived Tickets page
FE->>BE: GET /api/tickets/archive (Includes 'token' cookie)
Note over BE: validate_admin middleware verifies token & admin role
BE->>DB: SELECT * FROM tickets WHERE status = 'Archived'
DB-->>BE: Return archived tickets
BE-->>FE: HTTP 200 OK [Archived Tickets]
FE-->>Admin: Render historical archive board
``` ```
## Usage of AI ## Usage of AI
@@ -366,6 +380,6 @@ Generate a sequence diagramm in @[README.md] behind the class diagramms
### Output ### Output
The comments with `///` or `//!` The comments with `///` or `//!`
I've gone over most of it and modified it to my needs and opinions I've gone over it and modified it to my needs and opinions
The sequence Diagrams above The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
+5 -10
View File
@@ -145,7 +145,7 @@ pub async fn login(
.ok_or_else(|| { .ok_or_else(|| {
( (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(json!({"status": "error", "message": "Invalid username"})), Json(json!({"status": "error", "message": "Ungültiger Benutzername"})),
) )
})?; })?;
@@ -157,7 +157,7 @@ pub async fn login(
if !valid_pwd { if !valid_pwd {
let error_response = serde_json::json!({ let error_response = serde_json::json!({
"status": "error", "status": "error",
"message": "Invalid password" "message": "Ungültiges passwort"
}); });
return Err((StatusCode::BAD_REQUEST, Json(error_response))); return Err((StatusCode::BAD_REQUEST, Json(error_response)));
} }
@@ -338,10 +338,7 @@ pub async fn get_users(
(StatusCode::INTERNAL_SERVER_ERROR, Json(error)) (StatusCode::INTERNAL_SERVER_ERROR, Json(error))
})?; })?;
let response = users let response = users.iter().map(filter_user).collect::<Vec<FilteredUser>>();
.iter()
.map(filter_user)
.collect::<Vec<FilteredUser>>();
let json_respnse = json!(response); let json_respnse = json!(response);
Ok(Json(json_respnse)) Ok(Json(json_respnse))
} }
@@ -381,12 +378,10 @@ pub async fn get_user_by_id(
}); });
Err((StatusCode::NOT_FOUND, Json(error_response))) Err((StatusCode::NOT_FOUND, Json(error_response)))
} }
Err(e) => { Err(e) => Err((
Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})), Json(json!({"status": "error", "message": format!("{:?}", e)})),
)) )),
}
} }
} }
+9
View File
@@ -2,6 +2,15 @@ use gloo_storage::{LocalStorage, Storage};
use web_sys::window; use web_sys::window;
use yew::prelude::*; use yew::prelude::*;
/// A persistent, floating toggle button to switch the user interface theme between Dark Mode and Light Mode.
///
/// # Functionality
/// 1. **System Preference Check**: If the user hasn't explicitly set a preference, detects the operating system theme
/// preference using browser `match_media("(prefers-color-scheme: dark)")` query.
/// 2. **Session Persistence**: Caches the selected theme choice under the `"dark-mode"` key in the browser's `LocalStorage`
/// so that user preferences persist across page reloads.
/// 3. **Dynamic Style Swapping**: Injects or removes the `.theme-dark` / `.theme-light` classes directly on the document
/// `body` element, triggering the theme change via globally defined CSS variables.
#[function_component(DarkModeToggle)] #[function_component(DarkModeToggle)]
pub fn dark_mode_toggle() -> Html { pub fn dark_mode_toggle() -> Html {
// 1. Initialize state from LocalStorage or system preference // 1. Initialize state from LocalStorage or system preference
+46 -2
View File
@@ -70,8 +70,15 @@ pub struct SidebarShellProps {
/// This component is designed to wrap page-specific content, ensuring that the sidebar /// This component is designed to wrap page-specific content, ensuring that the sidebar
/// is always present for navigation. Integrates with [`crate::pages::sidebar::Sidebar`] 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 /// # Components
/// - [`crate::pages::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. /// - Main content area: Renders the `children` passed to this component.
/// ///
/// # Example /// # Example
@@ -84,9 +91,46 @@ pub struct SidebarShellProps {
/// ``` /// ```
#[component(SidebarShell)] #[component(SidebarShell)]
fn sidebar_shell(props: &SidebarShellProps) -> Html { 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! { html! {
<div class="layout"> <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"> <main class="content">
{ for props.children.iter() } { for props.children.iter() }
</main> </main>
+46 -5
View File
@@ -319,6 +319,11 @@ pub fn users_menu() -> Html {
/// and administrative status. It fetches the current user's details via `/api/users/current` /// and administrative status. It fetches the current user's details via `/api/users/current`
/// to determine what menu items to display. /// 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 /// # Structure
/// - Wraps its content in a [`SidebarStateProvider`] to allow nested menus to manage their state. /// - 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. /// - Contains a navigation (`<nav>`) element with an unordered list (`<ul>`) of menu items.
@@ -337,8 +342,22 @@ pub fn users_menu() -> Html {
/// # Logout Functionality /// # Logout Functionality
/// The "Logout" button sends a GET request to `/api/logout`, clears the user's session, /// 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`). /// 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)] #[component(Sidebar)]
pub fn sidebar() -> Html { pub fn sidebar(props: &SidebarComponentProps) -> Html {
let is_admin = use_state(|| None::<bool>); let is_admin = use_state(|| None::<bool>);
let navigator = use_navigator().expect("Sidebar must be used within a Router"); let navigator = use_navigator().expect("Sidebar must be used within a Router");
@@ -382,16 +401,26 @@ pub fn sidebar() -> Html {
None => html! { <div class="sidebar-loading">{ "Lade..." }</div> }, None => html! { <div class="sidebar-loading">{ "Lade..." }</div> },
// Non-admin: render a condensed user sidebar (no diagnostics, limited links) // Non-admin: render a condensed user sidebar (no diagnostics, limited links)
Some(false) => html! { Some(false) => {
let on_close = props.on_close.clone();
html! {
<SidebarStateProvider> <SidebarStateProvider>
<nav class="sidebar user"> <nav class={if props.is_open { "sidebar user open" } else { "sidebar user" }}>
<ul> <ul>
<li class="sidebar-header">
<Link<crate::Route> to={crate::Route::Home} classes="home"> <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"> <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"/> <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"/> <polyline points="9 22 9 12 15 12 15 22"/>
</svg> </svg>
</Link<crate::Route>> </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/> <TicketMenu/>
<li class="logout-item"> <li class="logout-item">
<button <button
@@ -404,19 +433,30 @@ pub fn sidebar() -> Html {
</ul> </ul>
</nav> </nav>
</SidebarStateProvider> </SidebarStateProvider>
}
}, },
// Admin: full sidebar wrapped in provider so submenu state persists // Admin: full sidebar wrapped in provider so submenu state persists
Some(true) => html! { Some(true) => {
let on_close = props.on_close.clone();
html! {
<SidebarStateProvider> <SidebarStateProvider>
<nav class="sidebar admin"> <nav class={if props.is_open { "sidebar admin open" } else { "sidebar admin" }}>
<ul> <ul>
<li class="sidebar-header">
<Link<crate::Route> to={crate::Route::Home} classes="home"> <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"> <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"/> <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"/> <polyline points="9 22 9 12 15 12 15 22"/>
</svg> </svg>
</Link<crate::Route>> </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/> <TicketMenu/>
<UsersMenu/> <UsersMenu/>
<Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>> <Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>>
@@ -432,6 +472,7 @@ pub fn sidebar() -> Html {
</ul> </ul>
</nav> </nav>
</SidebarStateProvider> </SidebarStateProvider>
}
}, },
} }
} }
+5 -3
View File
@@ -1,3 +1,4 @@
use crate::dequote;
use gloo_net::http::Request; use gloo_net::http::Request;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
@@ -333,8 +334,9 @@ pub fn login_component() -> Html {
navigator.push(&crate::Route::Home); navigator.push(&crate::Route::Home);
} }
Ok(r) => { Ok(r) => {
let text = r.text().await.unwrap_or_else(|_| "unbekannt".into()); let text: serde_json::Value =
error.set(format!("HTTP {}: {}", r.status(), text)); r.json().await.unwrap_or_else(|_| "unbekannt".into());
error.set(dequote!(format!("{}", text["message"].to_string())));
} }
Err(err) => error.set(format!("Netzwerkfehler: {}", err)), Err(err) => error.set(format!("Netzwerkfehler: {}", err)),
} }
@@ -343,7 +345,7 @@ pub fn login_component() -> Html {
}; };
html! { html! {
<main class="content"> <main class="content login">
<div class="form-container"> <div class="form-container">
<div class="page-header"> <div class="page-header">
<h1>{ "Anmelden" }</h1> <h1>{ "Anmelden" }</h1>
@@ -37,3 +37,7 @@
transform: scale(1.05); transform: scale(1.05);
} }
} }
.content.login {
margin: 0;
}
@@ -49,9 +49,50 @@
text-align: left; text-align: left;
} }
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: $spacing-md;
.home { .home {
flex-grow: 1;
display: flex; display: flex;
justify-content: center; justify-content: center;
@media (max-width: 768px) {
padding-left: 36px;
}
}
.sidebar-close {
display: none;
background: transparent;
border: none;
color: #fff;
cursor: pointer;
padding: 8px;
border-radius: 50%;
align-items: center;
justify-content: center;
transition: background 0.2s ease-in-out;
margin-bottom: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
svg {
width: 20px;
height: 20px;
stroke: #fff;
}
@media (max-width: 768px) {
display: flex;
}
}
} }
.home-svg { .home-svg {
@@ -64,3 +105,70 @@
&.user { width: 220px; background: darken($color-sidebar, 6%); } &.user { width: 220px; background: darken($color-sidebar, 6%); }
} }
// Floating mobile menu toggle button
.mobile-menu-toggle {
display: none;
@media (max-width: 768px) {
display: flex;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 998;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid rgba(128, 128, 128, 0.2);
background-color: var(--color-container);
color: var(--color-text);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.2s ease-in-out;
svg {
width: 24px;
height: 24px;
stroke: var(--color-text);
transition: stroke 0.2s ease-in-out;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
background-color: var(--color-bg);
}
&:active {
transform: translateY(0);
}
}
}
// Overlay backdrop that dims the screen and allows dismissals on mobile
.sidebar-overlay {
display: none;
@media (max-width: 768px) {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 999;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
&.open {
opacity: 1;
pointer-events: auto;
}
}
}