Sidebar improvement

It now works on mobile
This commit is contained in:
2026-06-07 12:11:32 +02:00
parent 8fd35f3e82
commit 159202a7af
4 changed files with 283 additions and 81 deletions
+39 -1
View File
@@ -90,9 +90,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>
@@ -100,6 +137,7 @@ fn sidebar_shell(props: &SidebarShellProps) -> Html {
}
}
/// Props for the AdminCheckWrapper component.
#[derive(Properties, PartialEq)]
pub struct AdminCheckWrapperProps {
+96 -55
View File
@@ -133,7 +133,7 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
Callback::from(move |v: bool| {
state.set(SidebarExpandState {
ticket_open: v,
users_open: (*state).users_open,
users_open: state.users_open,
})
})
};
@@ -141,10 +141,10 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let toggle_tickets = {
let state = state.clone();
Callback::from(move |_| {
let current = (*state).ticket_open;
let current = state.ticket_open;
state.set(SidebarExpandState {
ticket_open: !current,
users_open: (*state).users_open,
users_open: state.users_open,
});
})
};
@@ -153,7 +153,7 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let state = state.clone();
Callback::from(move |v: bool| {
state.set(SidebarExpandState {
ticket_open: (*state).ticket_open,
ticket_open: state.ticket_open,
users_open: v,
})
})
@@ -162,9 +162,9 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let toggle_users = {
let state = state.clone();
Callback::from(move |_| {
let current = (*state).users_open;
let current = state.users_open;
state.set(SidebarExpandState {
ticket_open: (*state).ticket_open,
ticket_open: state.ticket_open,
users_open: !current,
});
})
@@ -319,6 +319,11 @@ pub fn users_menu() -> Html {
/// 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.
@@ -337,8 +342,22 @@ pub fn users_menu() -> Html {
/// # 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() -> Html {
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");
@@ -379,59 +398,81 @@ pub fn sidebar() -> Html {
};
match *is_admin {
None => html! { <div class="sidebar-loading">{ "Loading..." }</div> },
None => html! { <div class="sidebar-loading">{ "Lade..." }</div> },
// Non-admin: render a condensed user sidebar (no diagnostics, limited links)
Some(false) => html! {
<SidebarStateProvider>
<nav class="sidebar user">
<ul>
<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>>
<TicketMenu/>
<li class="logout-item">
<button
class="logout-button"
onclick={on_logout.clone()}
>
{ "Logout" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
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">
<button
class="logout-button"
onclick={on_logout.clone()}
>
{ "Abmelden" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
}
},
// Admin: full sidebar wrapped in provider so submenu state persists
Some(true) => html! {
<SidebarStateProvider>
<nav class="sidebar admin">
<ul>
<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>>
<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">
<button
class="logout-button"
onclick={on_logout.clone()}
>
{ "Logout" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
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">
<button
class="logout-button"
onclick={on_logout.clone()}
>
{ "Abmelden" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
}
},
}
}
+127 -22
View File
@@ -4,31 +4,30 @@
.sidebar {
width: 260px;
background: $color-sidebar;
color: #fffefe;
color: #fff;
min-height: 100%;
padding: $spacing-md;
display: flex;
flex-direction: column;
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $spacing-sm; flex: 1; }
a, .menu-toggle {
color: #ffffff;
color: #fff;
text-decoration: none;
display: block;
padding: 8px 12px;
border-radius: 4px;
&:hover { background: rgba(158, 45, 1, 0.842); }
border-radius: $border-radius;
transition: background 0.2s ease-in-out;
&:hover { background: rgba(255,255,255,0.1); }
}
.menu-toggle { background: #ff5a316c; border: none; text-align: left; width: 100%; cursor: pointer; }
.menu-toggle { background: transparent; border: none; text-align: left; width: 100%; cursor: pointer; }
.submenu {
margin-left: 8px;
background: rgba(255,255,255,0.02);
border-radius: 4px;
background: rgba(255,255,255,0.05);
border-radius: $border-radius;
padding: 6px;
li { padding: 4px 0; }
}
@@ -40,29 +39,135 @@
}
.logout-button {
background: rgba(255, 93, 43, 0.527);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.801);
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 8px 12px;
border-radius: 4px;
width: 100%;
cursor: pointer;
text-align: left;
&:hover { background: rgba(255,255,255,0.15); }
&:active { background: rgba(255,255,255,0.2); }
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: $spacing-md;
.home {
flex-grow: 1;
display: flex;
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 {
width: 24px;
height: 24px;
vertical-align: middle;
margin-right: 8px;
}
&.user { width: 220px; background: darken($color-sidebar, 6%); }
}
.home-svg {
width: 24px;
height: 24px;
vertical-align: middle;
margin: 0 auto;
}
.home {
// 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;
}
}
}
+21 -3
View File
@@ -8,6 +8,20 @@
@use "components/home";
@use "components/icon";
:root {
--color-bg: #ffffff;
--color-container: #ffffff;
--color-text: #111827;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #121212;
--color-container: #333333;
--color-text: #e2e2e2;
}
}
body {
background: variables.$color-bg;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
@@ -27,7 +41,10 @@ body {
.sidebar {
width: 260px;
flex-shrink: 0;
height: 100%;
position: fixed;
left: 0;
top: 0;
height: 100vh;
overflow-y: auto;
box-sizing: border-box;
}
@@ -35,6 +52,7 @@ body {
.content {
flex: 1;
min-width: 0;
margin-left: 260px;
height: 100%;
overflow-y: auto;
padding: variables.$spacing-md;
@@ -46,8 +64,8 @@ body {
position: fixed;
top: 0;
bottom: 0;
left: -100%;
transition: left .2s;
left: -260px;
transition: left .3s ease-in-out;
z-index: 1000;
height: 100%;
box-sizing: border-box;