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