Sidebar logic

This commit is contained in:
2026-05-02 12:26:59 +02:00
parent 3d1366e704
commit fa77190555
7 changed files with 1621 additions and 0 deletions

1
frontend/Cargo.lock generated
View File

@@ -129,6 +129,7 @@ dependencies = [
"chrono-tz", "chrono-tz",
"gloo 0.12.0", "gloo 0.12.0",
"gloo-net 0.7.0", "gloo-net 0.7.0",
"gloo-storage 0.4.0",
"serde", "serde",
"serde_json", "serde_json",
"wasm-bindgen", "wasm-bindgen",

View File

@@ -20,6 +20,7 @@ web-sys = { version = "0.3.95", features = [
"SubmitEvent","InputEvent","HtmlInputElement","Event", "HtmlFormElement", "MouseEvent" "SubmitEvent","InputEvent","HtmlInputElement","Event", "HtmlFormElement", "MouseEvent"
] } ] }
gloo-net = "0.7.0" gloo-net = "0.7.0"
gloo-storage = "0.4.0"
yew-router = "0.20.0" yew-router = "0.20.0"
serde_json = "1.0.149" serde_json = "1.0.149"
gloo = "0.12.0" gloo = "0.12.0"

1330
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
frontend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build:scss": "sass src/styles/main.scss static/main.css --no-source-map --style=compressed",
"watch:scss": "sass --watch src/styles/main.scss:static/main.css",
"start": "npm-run-all --parallel watch:scss "trunk serve""
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"autoprefixer": "^10.5.0",
"postcss-cli": "^11.0.1",
"sass": "^1.99.0"
}
}

View File

@@ -77,6 +77,7 @@ fn switch(route: Route) -> Html {
pub fn app() -> Html { pub fn app() -> Html {
html! { html! {
<BrowserRouter> <BrowserRouter>
<sidebar::Sidebar/>
<Switch<Route> render={switch} /> <Switch<Route> render={switch} />
</BrowserRouter> </BrowserRouter>
} }

View File

@@ -1,3 +1,4 @@
pub mod basic_pages; pub mod basic_pages;
pub mod sidebar;
pub mod ticket; pub mod ticket;
pub mod user; pub mod user;

View File

@@ -0,0 +1,267 @@
use gloo_net::http::Request;
use gloo_storage::{LocalStorage, Storage};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::rc::Rc;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
const STORAGE_KEY: &str = "sidebar_state";
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct SidebarExpandState {
pub ticket_open: bool,
pub users_open: bool,
}
impl Default for SidebarExpandState {
fn default() -> Self {
Self {
ticket_open: false,
users_open: false,
}
}
}
#[derive(Clone, PartialEq)]
pub struct SidebarState {
pub expand: SidebarExpandState,
pub set_tickets_open: Callback<bool>,
pub toggle_tickets: Callback<()>,
pub set_users_open: Callback<bool>,
pub toggle_users: Callback<()>,
}
impl SidebarState {
fn new(
expand: SidebarExpandState,
set_tickets_open: Callback<bool>,
toggle_tickets: Callback<()>,
set_users_open: Callback<bool>,
toggle_users: Callback<()>,
) -> Self {
Self {
expand,
set_tickets_open,
toggle_tickets,
set_users_open,
toggle_users,
}
}
}
#[derive(Properties, PartialEq)]
pub struct SidebarProps {
pub children: Children,
}
#[component(SidebarStateProvider)]
pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let default = LocalStorage::get(STORAGE_KEY).unwrap_or_else(|_| SidebarExpandState::default());
let state = use_state(|| default);
{
let state = state.clone();
use_effect_with(state, move |s| {
LocalStorage::set(STORAGE_KEY, &**s).ok();
|| ()
});
}
let set_tickets_open = {
let state = state.clone();
Callback::from(move |v: bool| {
state.set(SidebarExpandState {
ticket_open: v,
users_open: (*state).users_open,
})
})
};
let toggle_tickets = {
let state = state.clone();
Callback::from(move |_| {
let current = (*state).ticket_open;
state.set(SidebarExpandState {
ticket_open: !current,
users_open: (*state).users_open,
});
})
};
let set_users_open = {
let state = state.clone();
Callback::from(move |v: bool| {
state.set(SidebarExpandState {
ticket_open: (*state).ticket_open,
users_open: v,
})
})
};
let toggle_users = {
let state = state.clone();
Callback::from(move |_| {
let current = (*state).users_open;
state.set(SidebarExpandState {
ticket_open: (*state).ticket_open,
users_open: !current,
});
})
};
let ctx = SidebarState::new(
(*state).clone(),
set_tickets_open,
toggle_tickets,
set_users_open,
toggle_users,
);
html! {
<ContextProvider<Rc<SidebarState>> context={Rc::new(ctx)}>
{ for props.children.iter() }
</ContextProvider<Rc<SidebarState>>>
}
}
#[component(TicketMenu)]
pub fn ticket_menu() -> Html {
let ctx =
use_context::<Rc<SidebarState>>().expect("TicketsMenu must be inside SidebarStateProvider");
let on_toggle = {
let cb = ctx.toggle_tickets.clone();
Callback::from(move |_| cb.emit(()))
};
let open = ctx.expand.ticket_open;
html! {
<li class={ if open { "menu open" } else { "menu" } }>
<button
class="menu-toggle"
onclick={on_toggle}
aria-expanded={open.to_string()}
>
{ "Tickets" }
{ if open { "" } else { "" } }
</button>
{
if open {
html! {
<ul class="submenu" role="menu">
<li role="none">
<Link<crate::Route> to={crate::Route::Ticket}><span role="menuitem">{ "Submit Ticket" }</span></Link<crate::Route>>
</li>
<li role="none">
<Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "View Tickets" }</span></Link<crate::Route>>
</li>
</ul>
}
} else {
html!{}
}
}
</li>
}
}
#[component(UsersMenu)]
pub fn users_menu() -> Html {
let ctx =
use_context::<Rc<SidebarState>>().expect("UsersMenu must be inside SidebarStateProvider");
let on_toggle = {
let cb = ctx.toggle_users.clone();
Callback::from(move |_| cb.emit(()))
};
let open = ctx.expand.users_open;
html! {
<li class={ if open { "menu open" } else { "menu" } }>
<button
class="menu-toggle"
onclick={on_toggle}
aria-expanded={open.to_string()}
>
{ "Users" }
{ if open { "" } else { "" } }
</button>
{
if open {
html! {
<ul class="submenu" role="menu">
<li role="none">
<Link<crate::Route> to={crate::Route::Register}><span role="menuitem">{ "Create User" }</span></Link<crate::Route>>
</li>
<li role="none">
<Link<crate::Route> to={crate::Route::AllUsers}><span role="menuitem">{ "View Users" }</span></Link<crate::Route>>
</li>
</ul>
}
} else {
html!{}
}
}
</li>
}
}
#[component(Sidebar)]
pub fn sidebar() -> Html {
let is_admin = use_state(|| None::<bool>);
{
let is_admin = is_admin.clone();
use_effect_with((), move |_| {
spawn_local(async move {
let response = Request::get("/api/users/current")
.credentials(web_sys::RequestCredentials::Include)
.send()
.await;
match response {
Ok(resp) if resp.status() == 200 => {
let user_data: Value = resp.json().await.unwrap_or_default();
let admin_value = user_data["data"]["is_admin"].as_bool();
is_admin.set(admin_value);
}
_ => is_admin.set(Some(false)),
}
});
|| ()
});
}
match *is_admin {
None => html! { <div class="sidebar-loading">{ "Loading..." }</div> },
// Non-admin: render a condensed user sidebar (no diagnostics, limited links)
Some(false) => html! {
<SidebarStateProvider>
<nav class="sidebar user">
<ul>
<TicketMenu/>
</ul>
</nav>
</SidebarStateProvider>
},
// Admin: full sidebar wrapped in provider so submenu state persists
Some(true) => html! {
<SidebarStateProvider>
<nav class="sidebar admin">
<ul>
<TicketMenu/>
<UsersMenu/>
</ul>
</nav>
</SidebarStateProvider>
},
}
}