use api::RenameHouseholdRequest; use dioxus::prelude::*; use dioxus_router::prelude::*; use gloo_storage::{LocalStorage, Storage}; use uuid::Uuid; use crate::{ api, bootstrap::{bs, ConfirmDangerModal, FormModal}, do_add_user_to_household, do_resolve_user, full_context::FullContextRedirect, use_error, use_full_context, use_trimmed_context, ErrorView, HouseholdInfo, Route, }; #[derive(Clone, Copy, PartialEq)] pub enum Page { Home, Ingredients, RecipeCreator, RecipeList, } impl Page { pub fn to(&self) -> &'static str { match self { Page::Home => "/", Page::Ingredients => "/ingredients", Page::RecipeCreator => "/recipe_creator", Page::RecipeList => "/recipe", } } } impl From for Option { fn from(value: Route) -> Self { match value { Route::Index => Some(Page::Home), Route::Login => None, Route::OidcRedirect { .. } => None, Route::HouseholdSelection => None, Route::Ingredients => Some(Page::Ingredients), Route::RecipeCreator => Some(Page::RecipeCreator), Route::RecipeList => Some(Page::RecipeList), Route::RecipeView { .. } => Some(Page::RecipeList), } } } #[derive(PartialEq)] struct MenuEntry { icon: &'static str, label: &'static str, page: Page, } fn AddMemberModal(cx: Scope) -> Element { let error = use_error(cx); let (token, household) = use_trimmed_context(cx); let member = use_state(cx, String::new); let add_member = move || { to_owned![member, error, token, member]; cx.spawn(async move { match do_resolve_user(token.clone(), member.to_string()).await { Ok(Some(uid)) => match do_add_user_to_household(token, household, uid).await { Err(e) => { error.set(Some(format!("Could not add user: {e:?}"))); } Ok(_) => { error.set(None); let modal = bs::Modal::get_instance("#addMember"); modal.hide(); } }, Ok(None) => { error.set(Some(format!("User {member} does not exist"))); } Err(e) => { error.set(Some(format!("Could not resolve user: {e:?}"))); } } }) }; cx.render(rsx! { FormModal { id: "addMember", centered: true, submit_label: "Add", title: "Add a member", on_submit: move |_| add_member(), ErrorView { error: error } div { class: "form-floating", input { class: "form-control", id: "addMemberName", placeholder: "Member name", value: "{member}", oninput: move |e| member.set(e.value.to_string()) } label { "for": "addMemberName", "Member name" } } } }) } async fn do_rename_household(token: String, household: Uuid, name: String) -> anyhow::Result<()> { let rsp = gloo_net::http::Request::patch(api!("household/{household}")) .header("Authorization", &format!("Bearer {token}")) .json(&RenameHouseholdRequest { name: name.clone() })? .send() .await?; if !rsp.ok() { anyhow::bail!("Could not leave: {}", rsp.text().await?); } LocalStorage::set( "household", HouseholdInfo { id: household, name, }, ) .expect("Could not set household info"); Ok(()) } #[inline_props] fn RenameHousehold<'a>(cx: Scope<'a>, name: &'a str) -> Element { let error = use_error(cx); let ctx = use_full_context(cx); let name = use_state(cx, || name.to_string()); let rename_hs = move |_| { to_owned![name, error]; let (token, household) = { let h = ctx.read(); (h.login.token.clone(), h.household.id) }; let refresh = ctx.refresh_handle(); cx.spawn(async move { match do_rename_household(token, household, name.to_string()).await { Ok(_) => { error.set(None); refresh.refresh(); let modal = bs::Modal::get_instance("#renameHousehold"); modal.hide(); } Err(e) => { error.set(Some(format!("Could not rename household: {e:?}"))); } } }); }; cx.render(rsx! { FormModal { id: "renameHousehold", centered: true, submit_label: "Rename", title: "Rename household", on_submit: rename_hs, ErrorView { error: error } div { class: "form-floating", input { id: "householdRename", class: "form-control", placeholder: "New household name", value: "{name}", oninput: move |e| name.set(e.value.clone()) } label { "for": "householdRename", "New household name" } } } }) } #[derive(Props, PartialEq)] struct SidebarProps { entries: Vec, current: Page, } async fn do_leave(token: String, household: Uuid) -> anyhow::Result<()> { let rsp = gloo_net::http::Request::delete(api!("household/{household}")) .header("Authorization", &format!("Bearer {token}")) .send() .await?; if !rsp.ok() { let body = rsp.body(); match body { None => anyhow::bail!("Could not leave: {rsp:?}"), Some(s) => anyhow::bail!("Could not leave: {}", s.to_string()), } } LocalStorage::delete("household"); Ok(()) } fn SidebarDropdown(cx: Scope) -> Element { let ctx = use_full_context(cx); let navigator = use_navigator(cx); let leave = move || { let token = ctx.read().login.token.clone(); let household = ctx.read().household.id; to_owned![navigator]; cx.spawn(async move { match do_leave(token, household).await { Err(e) => { log::error!("Could not leave household: {e:?}"); } Ok(_) => { navigator.push(Route::HouseholdSelection); } } }); }; let logout = || { LocalStorage::delete("token"); LocalStorage::delete("household"); navigator.push(Route::Login); }; cx.render(rsx! { div { class: "dropdown", a { href: "#", "data-bs-toggle": "dropdown", "aria-expanded": "false", class: "d-flex align-items-center text-white text-decoration-none dropdown-toggle", i { class: "fs-4 bi-house-door-fill" } strong { class: "ms-2 d-none d-sm-inline", "{ctx.read().household.name} ({ctx.read().login.name})" } } ConfirmDangerModal { id: "leaveModal", title: "Leave household", centered: true, on_confirm: move |_| leave(), "Are you sure you want to leave the household '{ctx.read().household.name}' ?" } AddMemberModal {} RenameHousehold { name: "{ctx.read().household.name}" } ul { class: "dropdown-menu", li { a { class: "dropdown-item", href: "#", onclick: move |_| logout(), "Logout" } } hr {} li { a { class: "dropdown-item", href: "#", "data-bs-toggle": "modal", "data-bs-target": "#leaveModal", "Leave household" } } li { a { class: "dropdown-item", href: "#", "data-bs-toggle": "modal", "data-bs-target": "#addMember", "Add member" } } li { a { class: "dropdown-item", href: "#", "data-bs-toggle": "modal", "data-bs-target": "#renameHousehold", "Rename household" } } hr {} li { Link { to: "/household_selection", class: "dropdown-item", "Change household" } } } } }) } fn Sidebar(cx: Scope) -> Element { let entries = cx.props.entries.iter().map(|e| { let active = if e.page == cx.props.current { "active" } else { "" }; rsx! { li { class: "nav-item w-100", Link { to: e.page.to(), class: "nav-link text-white {active}", i { class: "fs-4 {e.icon}" } span { class: "ms-2 d-none d-sm-inline", "{e.label}" } } } } }); cx.render(rsx! { div { class: "container-fluid", div { class: "row flex-nowrap", div { class: "col-auto col-md-3 col-xl-2 px-sm-2 px-0 bg-dark-subtle", div { class: "d-flex flex-column align-items-center align-items-sm-start px-sm-3 px-1 pt-2 text-white min-vh-100", Link { to: "/", class: "d-flex align-items-center pb-3 mb-md-0 me-md-auto text-white text-decoration-none", span { class: "fs-5 d-none d-sm-inline", "Menu" } } hr { class: "w-100 d-none d-sm-inline" } ul { id: "menu", class: "nav nav-pills flex-column mb-sm-auto mb-0 align-items-center align-items-sm-start w-100", entries } hr { class: "w-100" } SidebarDropdown {} } } div { class: "col py-3 overflow-scroll vh-100", Outlet:: {} } } } }) } pub fn RegaladeSidebar(cx: Scope) -> Element { let current: Route = use_route(cx).unwrap(); let entries = vec![ MenuEntry { label: "Home", icon: "bi-house", page: Page::Home, }, MenuEntry { label: "Recipes", icon: "bi-book", page: Page::RecipeList, }, MenuEntry { label: "Ingredients", icon: "bi-egg-fill", page: Page::Ingredients, }, MenuEntry { label: "New Recipe", icon: "bi-clipboard2-plus-fill", page: Page::RecipeCreator, }, ]; cx.render(rsx! { FullContextRedirect { Sidebar { current: Option::from(current).unwrap(), entries: entries } } }) }