Sidebar mobile improvement

The sidebar vanished on a too small display. Button to reopen and close
are rendered
This commit is contained in:
2026-05-29 09:45:34 +02:00
parent bec2a8a451
commit 26384d2849
3 changed files with 226 additions and 51 deletions

View File

@@ -84,9 +84,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>

View File

@@ -337,8 +337,16 @@ 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`).
#[derive(Properties, PartialEq)]
pub struct SidebarComponentProps {
#[prop_or_default]
pub is_open: bool,
#[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,56 +390,78 @@ 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) => {
<SidebarStateProvider> let on_close = props.on_close.clone();
<nav class="sidebar user"> html! {
<ul> <SidebarStateProvider>
<Link<crate::Route> to={crate::Route::Home} classes="home"> <nav class={if props.is_open { "sidebar user open" } else { "sidebar user" }}>
<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"> <ul>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/> <li class="sidebar-header">
<polyline points="9 22 9 12 15 12 15 22"/> <Link<crate::Route> to={crate::Route::Home} classes="home">
</svg> <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">
</Link<crate::Route>> <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<TicketMenu/> <polyline points="9 22 9 12 15 12 15 22"/>
<li class="logout-item"> </svg>
<button </Link<crate::Route>>
class="logout-button" <button class="sidebar-close" onclick={move |_| on_close.emit(())}>
onclick={on_logout.clone()} <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"/>
{ "Abmelden" } <line x1="6" y1="6" x2="18" y2="18"/>
</button> </svg>
</li> </button>
</ul> </li>
</nav> <TicketMenu/>
</SidebarStateProvider> <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 // Admin: full sidebar wrapped in provider so submenu state persists
Some(true) => html! { Some(true) => {
<SidebarStateProvider> let on_close = props.on_close.clone();
<nav class="sidebar admin"> html! {
<ul> <SidebarStateProvider>
<Link<crate::Route> to={crate::Route::Home} classes="home"> <nav class={if props.is_open { "sidebar admin open" } else { "sidebar admin" }}>
<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"> <ul>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/> <li class="sidebar-header">
<polyline points="9 22 9 12 15 12 15 22"/> <Link<crate::Route> to={crate::Route::Home} classes="home">
</svg> <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">
</Link<crate::Route>> <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<TicketMenu/> <polyline points="9 22 9 12 15 12 15 22"/>
<UsersMenu/> </svg>
<Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>> </Link<crate::Route>>
<Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>> <button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<li class="logout-item"> <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">
<button <line x1="18" y1="6" x2="6" y2="18"/>
class="logout-button" <line x1="6" y1="6" x2="18" y2="18"/>
onclick={on_logout.clone()} </svg>
> </button>
{ "Abmelden" } </li>
</button> <TicketMenu/>
</li> <UsersMenu/>
</ul> <Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>>
</nav> <Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>>
</SidebarStateProvider> <li class="logout-item">
<button
class="logout-button"
onclick={on_logout.clone()}
>
{ "Abmelden" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
}
}, },
} }
} }

View File

@@ -49,9 +49,50 @@
text-align: left; text-align: left;
} }
.home { .sidebar-header {
display: flex; display: flex;
justify-content: center; 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 { .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;
}
}
}