frontend/pages/sidebar.rs
1use gloo_net::http::Request;
2use gloo_storage::{LocalStorage, Storage};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::rc::Rc;
6use wasm_bindgen_futures::spawn_local;
7use yew::prelude::*;
8use yew_router::prelude::*;
9
10/// The key used to store and retrieve the sidebar's expansion state in `LocalStorage`.
11///
12/// This constant ensures consistency when accessing the stored state across different
13/// parts of the application. The value associated with this key in `LocalStorage`
14/// will be a serialized [`SidebarExpandState`] object.
15const STORAGE_KEY: &str = "sidebar_state";
16
17/// Represents the expansion state of collapsible menus within the sidebar.
18///
19/// This struct is used to persist the open/closed state of sidebar submenus,
20/// improving user experience by remembering their last interaction.
21/// The state is stored in and retrieved from `LocalStorage`.
22///
23/// # Fields
24/// - `ticket_open`: A boolean indicating if the "Tickets" submenu is expanded (`true`) or collapsed (`false`).
25/// - `users_open`: A boolean indicating if the "Users" submenu is expanded (`true`) or collapsed (`false`).
26#[derive(Clone, PartialEq, Serialize, Deserialize)]
27pub struct SidebarExpandState {
28 pub ticket_open: bool,
29 pub users_open: bool,
30}
31
32impl Default for SidebarExpandState {
33 /// Provides the default expansion state, where all submenus are collapsed.
34 fn default() -> Self {
35 Self {
36 ticket_open: false,
37 users_open: false,
38 }
39 }
40}
41
42/// Represents the shared state for the sidebar's expandable menus.
43///
44/// This context struct is provided via Yew's `ContextProvider` and allows child components
45/// within the sidebar to read and modify the expansion state of the "Tickets" and "Users" menus.
46///
47/// # Fields
48/// - `expand`: The current [`SidebarExpandState`] holding whether each menu is open or closed.
49/// - `set_tickets_open`: A `Callback<bool>` to explicitly set the open state of the "Tickets" menu.
50/// - `toggle_tickets`: A `Callback<()>` to toggle the open state of the "Tickets" menu.
51/// - `set_users_open`: A `Callback<bool>` to explicitly set the open state of the "Users" menu.
52/// - `toggle_users`: A `Callback<()>` to toggle the open state of the "Users" menu.
53#[derive(Clone, PartialEq)]
54pub struct SidebarState {
55 pub expand: SidebarExpandState,
56 pub set_tickets_open: Callback<bool>,
57 pub toggle_tickets: Callback<()>,
58 pub set_users_open: Callback<bool>,
59 pub toggle_users: Callback<()>,
60}
61
62impl SidebarState {
63 /// Creates a new `SidebarState` instance.
64 ///
65 /// This constructor is typically used within the [`SidebarStateProvider`] to
66 /// bundle the current expansion state and its associated callbacks for context sharing.
67 fn new(
68 expand: SidebarExpandState,
69 set_tickets_open: Callback<bool>,
70 toggle_tickets: Callback<()>,
71 set_users_open: Callback<bool>,
72 toggle_users: Callback<()>,
73 ) -> Self {
74 Self {
75 expand,
76 set_tickets_open,
77 toggle_tickets,
78 set_users_open,
79 toggle_users,
80 }
81 }
82}
83
84/// Properties for components that provide sidebar state.
85///
86/// This struct is typically used by context providers that wrap child components
87/// and supply them with shared sidebar-related state or functionality.
88///
89/// # Fields
90/// - `children`: The child components that will have access to the provided sidebar state.
91#[derive(Properties, PartialEq)]
92pub struct SidebarProps {
93 pub children: Children,
94}
95
96/// A Yew context provider component that manages and supplies the sidebar's expansion state.
97///
98/// This component is responsible for:
99/// 1. Loading the initial `SidebarExpandState` from browser `LocalStorage` (or using defaults).
100/// 2. Providing a `SidebarState` context (`Rc<SidebarState>`) to its children, which includes
101/// the current expansion state and callbacks to modify it.
102/// 3. Persisting any changes to the `SidebarExpandState` back to `LocalStorage`.
103///
104/// Child components (like [`TicketMenu`] and [`UsersMenu`]) can consume this context
105/// to react to and control the sidebar's collapsible sections.
106///
107/// # LocalStorage Key
108/// The state is stored under the key `STORAGE_KEY` ("sidebar_state").
109///
110/// # Example Usage
111/// ```rust
112/// html! {
113/// <SidebarStateProvider>
114/// <Sidebar /> // Sidebar and its sub-components will have access to the state
115/// </SidebarStateProvider>
116/// }
117/// ```
118#[component(SidebarStateProvider)]
119pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
120 let default = LocalStorage::get(STORAGE_KEY).unwrap_or_else(|_| SidebarExpandState::default());
121 let state = use_state(|| default);
122
123 {
124 let state = state.clone();
125 use_effect_with(state, move |s| {
126 LocalStorage::set(STORAGE_KEY, &**s).ok();
127 || ()
128 });
129 }
130
131 let set_tickets_open = {
132 let state = state.clone();
133 Callback::from(move |v: bool| {
134 state.set(SidebarExpandState {
135 ticket_open: v,
136 users_open: state.users_open,
137 })
138 })
139 };
140
141 let toggle_tickets = {
142 let state = state.clone();
143 Callback::from(move |_| {
144 let current = state.ticket_open;
145 state.set(SidebarExpandState {
146 ticket_open: !current,
147 users_open: state.users_open,
148 });
149 })
150 };
151
152 let set_users_open = {
153 let state = state.clone();
154 Callback::from(move |v: bool| {
155 state.set(SidebarExpandState {
156 ticket_open: state.ticket_open,
157 users_open: v,
158 })
159 })
160 };
161
162 let toggle_users = {
163 let state = state.clone();
164 Callback::from(move |_| {
165 let current = state.users_open;
166 state.set(SidebarExpandState {
167 ticket_open: state.ticket_open,
168 users_open: !current,
169 });
170 })
171 };
172
173 let ctx = SidebarState::new(
174 (*state).clone(),
175 set_tickets_open,
176 toggle_tickets,
177 set_users_open,
178 toggle_users,
179 );
180
181 html! {
182 <ContextProvider<Rc<SidebarState>> context={Rc::new(ctx)}>
183 { for props.children.iter() }
184 </ContextProvider<Rc<SidebarState>>>
185 }
186}
187
188/// A collapsible menu component for "Tickets" within the sidebar.
189///
190/// This component consumes the [`SidebarState`] context to manage its expanded/collapsed state.
191/// It displays a button to toggle its visibility and, when expanded, reveals links
192/// for "Submit Ticket" and "View Tickets".
193///
194/// # Context
195/// Requires [`SidebarStateProvider`] as an ancestor to provide the necessary context.
196///
197/// # Functionality
198/// - **Toggle Button**: Clicking the button toggles the `ticket_open` state in the `SidebarState`.
199/// - **Submenu Links**:
200/// - [`crate::Route::Ticket`]: Link to submit a new ticket.
201/// - [`crate::Route::AllTickets`]: Link to view all tickets.
202///
203/// # Example
204/// ```rust
205/// html! {
206/// <TicketMenu />
207/// }
208/// ```
209#[component(TicketMenu)]
210pub fn ticket_menu() -> Html {
211 let ctx =
212 use_context::<Rc<SidebarState>>().expect("TicketsMenu must be inside SidebarStateProvider");
213
214 let on_toggle = {
215 let cb = ctx.toggle_tickets.clone();
216 Callback::from(move |_| cb.emit(()))
217 };
218
219 let open = ctx.expand.ticket_open;
220
221 html! {
222 <li class={ if open { "menu open" } else { "menu" } }>
223 <button
224 class="menu-toggle"
225 onclick={on_toggle}
226 aria-expanded={open.to_string()}
227 >
228 { "Tickets" }
229 { if open { " ▾" } else { " ▸" } }
230 </button>
231
232 {
233 if open {
234 html! {
235 <ul class="submenu" role="menu">
236 <li role="none">
237 <Link<crate::Route> to={crate::Route::Ticket}><span role="menuitem">{ "Ticket einreichen" }</span></Link<crate::Route>>
238 </li>
239 <li role="none">
240 <Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "Tickets anzeigen" }</span></Link<crate::Route>>
241 </li>
242 </ul>
243 }
244 } else {
245 html!{}
246 }
247 }
248 </li>
249 }
250}
251
252/// A collapsible menu component for "Users" within the sidebar.
253///
254/// This component consumes the [`SidebarState`] context to manage its expanded/collapsed state.
255/// It displays a button to toggle its visibility and, when expanded, reveals links
256/// for "Create User" and "View Users". This menu is typically only visible to administrators.
257///
258/// # Context
259/// Requires [`SidebarStateProvider`] as an ancestor to provide the necessary context.
260///
261/// # Functionality
262/// - **Toggle Button**: Clicking the button toggles the `users_open` state in the `SidebarState`.
263/// - **Submenu Links**:
264/// - [`crate::Route::Register`]: Link to create a new user account.
265/// - [`crate::Route::AllUsers`]: Link to view all registered users.
266///
267/// # Example
268/// ```rust
269/// html! {
270/// <UsersMenu />
271/// }
272/// ```
273#[component(UsersMenu)]
274pub fn users_menu() -> Html {
275 let ctx =
276 use_context::<Rc<SidebarState>>().expect("UsersMenu must be inside SidebarStateProvider");
277
278 let on_toggle = {
279 let cb = ctx.toggle_users.clone();
280 Callback::from(move |_| cb.emit(()))
281 };
282
283 let open = ctx.expand.users_open;
284
285 html! {
286 <li class={ if open { "menu open" } else { "menu" } }>
287 <button
288 class="menu-toggle"
289 onclick={on_toggle}
290 aria-expanded={open.to_string()}
291 >
292 { "Benutzer" }
293 { if open { " ▾" } else { " ▸" } }
294 </button>
295
296 {
297 if open {
298 html! {
299 <ul class="submenu" role="menu">
300 <li role="none">
301 <Link<crate::Route> to={crate::Route::Register}><span role="menuitem">{ "Benutzer erstellen" }</span></Link<crate::Route>>
302 </li>
303 <li role="none">
304 <Link<crate::Route> to={crate::Route::AllUsers}><span role="menuitem">{ "Benutzer anzeigen" }</span></Link<crate::Route>>
305 </li>
306 </ul>
307 }
308 } else {
309 html!{}
310 }
311 }
312 </li>
313 }
314}
315
316/// The main sidebar component of the application.
317///
318/// This component dynamically renders its content based on the user's authentication
319/// and administrative status. It fetches the current user's details via `/api/users/current`
320/// to determine what menu items to display.
321///
322/// # Mobile Support
323/// On small screens:
324/// - Slides into view from the left when `props.is_open` is `true`.
325/// - Renders a close button (`✕`) in the header that emits `props.on_close`.
326///
327/// # Structure
328/// - Wraps its content in a [`SidebarStateProvider`] to allow nested menus to manage their state.
329/// - Contains a navigation (`<nav>`) element with an unordered list (`<ul>`) of menu items.
330///
331/// # Conditional Rendering
332/// - **Loading**: Displays "Loading..." while fetching user data.
333/// - **Non-Admin User**: Renders a condensed sidebar including:
334/// - [`TicketMenu`]: For managing tickets.
335/// - A "Logout" button.
336/// - **Admin User**: Renders a full sidebar including:
337/// - [`TicketMenu`]: For managing tickets.
338/// - [`UsersMenu`]: For managing user accounts.
339/// - A direct link to [`crate::Route::Diagnostics`] (Statistiken).
340/// - A "Logout" button.
341///
342/// # Logout Functionality
343/// The "Logout" button sends a GET request to `/api/logout`, clears the user's session,
344/// and then redirects the user to the login page (`crate::Route::Login`).
345/// Properties for the [`Sidebar`] component.
346///
347/// These properties enable managing and controlling the responsive mobile sidebar
348/// layout and its visibility settings.
349#[derive(Properties, PartialEq)]
350pub struct SidebarComponentProps {
351 /// A boolean flag indicating whether the mobile sidebar is currently slid open (`true`) or hidden (`false`).
352 #[prop_or_default]
353 pub is_open: bool,
354 /// A callback emitted when the user requests to close/hide the mobile sidebar.
355 #[prop_or_default]
356 pub on_close: Callback<()>,
357}
358
359#[component(Sidebar)]
360pub fn sidebar(props: &SidebarComponentProps) -> Html {
361 let is_admin = use_state(|| None::<bool>);
362 let navigator = use_navigator().expect("Sidebar must be used within a Router");
363
364 {
365 let is_admin = is_admin.clone();
366 use_effect_with((), move |_| {
367 spawn_local(async move {
368 let response = Request::get("/api/users/current")
369 .credentials(web_sys::RequestCredentials::Include)
370 .send()
371 .await;
372
373 match response {
374 Ok(resp) if resp.status() == 200 => {
375 let user_data: Value = resp.json().await.unwrap_or_default();
376 let admin_value = user_data["data"]["is_admin"].as_bool();
377 is_admin.set(admin_value);
378 }
379 _ => is_admin.set(Some(false)),
380 }
381 });
382 || ()
383 });
384 }
385
386 let on_logout = {
387 let navigator = navigator.clone();
388 Callback::from(move |_| {
389 let navigator = navigator.clone();
390 spawn_local(async move {
391 let _ = Request::get("/api/logout")
392 .credentials(web_sys::RequestCredentials::Include)
393 .send()
394 .await;
395 navigator.push(&crate::Route::Login);
396 });
397 })
398 };
399
400 match *is_admin {
401 None => html! { <div class="sidebar-loading">{ "Wird geladen..." }</div> },
402
403 // Non-admin: render a condensed user sidebar (no diagnostics, limited links)
404 Some(false) => {
405 let on_close = props.on_close.clone();
406 html! {
407 <SidebarStateProvider>
408 <nav class={if props.is_open { "sidebar user open" } else { "sidebar user" }}>
409 <ul>
410 <li class="sidebar-header">
411 <Link<crate::Route> to={crate::Route::Home} classes="home">
412 <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">
413 <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
414 <polyline points="9 22 9 12 15 12 15 22"/>
415 </svg>
416 </Link<crate::Route>>
417 <button class="sidebar-close" onclick={move |_| on_close.emit(())}>
418 <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">
419 <line x1="18" y1="6" x2="6" y2="18"/>
420 <line x1="6" y1="6" x2="18" y2="18"/>
421 </svg>
422 </button>
423 </li>
424 <TicketMenu/>
425 <li class="logout-item">
426 <crate::darkmode::ThemeToggle/>
427 <button
428 class="sidebar-button"
429 onclick={on_logout.clone()}
430 >
431 { "Abmelden" }
432 </button>
433 </li>
434 </ul>
435 </nav>
436 </SidebarStateProvider>
437 }
438 }
439
440 // Admin: full sidebar wrapped in provider so submenu state persists
441 Some(true) => {
442 let on_close = props.on_close.clone();
443 html! {
444 <SidebarStateProvider>
445 <nav class={if props.is_open { "sidebar admin open" } else { "sidebar admin" }}>
446 <ul>
447 <li class="sidebar-header">
448 <Link<crate::Route> to={crate::Route::Home} classes="home">
449 <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">
450 <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
451 <polyline points="9 22 9 12 15 12 15 22"/>
452 </svg>
453 </Link<crate::Route>>
454 <button class="sidebar-close" onclick={move |_| on_close.emit(())}>
455 <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">
456 <line x1="18" y1="6" x2="6" y2="18"/>
457 <line x1="6" y1="6" x2="18" y2="18"/>
458 </svg>
459 </button>
460 </li>
461 <TicketMenu/>
462 <UsersMenu/>
463 <Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>>
464 <Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>>
465 <li class="logout-item">
466 <crate::darkmode::ThemeToggle/>
467 <button
468 class="sidebar-button"
469 onclick={on_logout.clone()}
470 >
471 { "Abmelden" }
472 </button>
473 </li>
474 </ul>
475 </nav>
476 </SidebarStateProvider>
477 }
478 }
479 }
480}
481