diff --git a/Cargo.lock b/Cargo.lock index ab813f8..cdaf613 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,7 @@ dependencies = [ "gloo-net", "gloo-storage", "gloo-utils", + "itertools", "log", "serde", "serde_json", diff --git a/api/src/lib.rs b/api/src/lib.rs index 21b1e51..419c01e 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -17,7 +17,7 @@ pub struct LoginResponse { #[derive(Serialize, Deserialize)] pub struct EmptyResponse {} -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone)] pub struct Household { pub name: String, pub members: Vec, diff --git a/app/Cargo.toml b/app/Cargo.toml index 849c3ab..f61ce28 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -12,6 +12,7 @@ console_log = { version = "1.0.0", features = ["color"] } gloo-net = "0.2.6" gloo-storage = "0.2.2" gloo-utils = "0.1.6" +itertools = "0.10.5" log = "0.4.17" serde = { version = "1.0.163", features = ["derive"] } serde_json = "1.0.96" diff --git a/app/index.html b/app/index.html index 6562ef0..234b171 100644 --- a/app/index.html +++ b/app/index.html @@ -10,6 +10,7 @@ /> +
diff --git a/app/src/main.rs b/app/src/main.rs index ccf1c48..5ac100f 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -1,11 +1,14 @@ -use api::{LoginRequest, LoginResponse}; +use api::{ + CreateHouseholdRequest, CreateHouseholdResponse, Household, LoginRequest, LoginResponse, +}; use gloo_storage::{errors::StorageError, LocalStorage, Storage}; use log::Level; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use wasm_bindgen::JsCast; +use wasm_bindgen::{prelude::*, JsCast}; use web_sys::HtmlInputElement; -use yew::prelude::*; +use yew::{prelude::*, suspense::use_future}; +use itertools::Itertools; use yew_router::prelude::*; use crate::sidebar::RegaladeSidebar; @@ -53,15 +56,21 @@ struct HouseholdInfo { name: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct LoginInfo { + token: String, + name: String, +} + #[derive(Debug, Clone)] struct RegaladeGlobalState { - token: AttrValue, + token: LoginInfo, household: HouseholdInfo, } impl RegaladeGlobalState { pub fn get_or_navigate(navigator: Navigator) -> Option { - let token = match LocalStorage::get::("token") { + let token = match LocalStorage::get::("token") { Ok(v) => v, Err(StorageError::KeyNotFound(_)) => { navigator.push(&Route::Login); @@ -79,14 +88,11 @@ impl RegaladeGlobalState { Err(e) => unreachable!("Could not get household: {e:?}"), }; - Some(Self { - token: token.into(), - household, - }) + Some(Self { token, household }) } pub fn get() -> Self { - let token = match LocalStorage::get::("token") { + let token = match LocalStorage::get::("token") { Ok(v) => v, Err(e) => unreachable!("Could not get token: {e:?}"), }; @@ -96,10 +102,7 @@ impl RegaladeGlobalState { Err(e) => unreachable!("Could not get household: {e:?}"), }; - Self { - token: token.into(), - household, - } + Self { token, household } } } @@ -112,12 +115,12 @@ struct GlobalStateRedirectorProps { #[function_component] fn GlobalStateRedirector(props: &GlobalStateRedirectorProps) -> Html { let navigator = use_navigator().unwrap(); - let state = use_state(|| RegaladeGlobalState::get_or_navigate(navigator)); + let s = RegaladeGlobalState::get_or_navigate(navigator); - match &*state { - Some(state) => { + match s { + Some(_) => { html! { - + { for props.children.iter() } } @@ -126,21 +129,233 @@ fn GlobalStateRedirector(props: &GlobalStateRedirectorProps) -> Html { } } +async fn do_new_household(token: String, name: String) -> anyhow::Result { + let rsp = gloo_net::http::Request::post(api!("household")) + .json(&CreateHouseholdRequest { name })? + .header("Authorization", &format!("Bearer {token}")) + .send() + .await?; + + if !rsp.ok() { + anyhow::bail!("Request failed: {rsp:?}") + } + + let rsp: CreateHouseholdResponse = rsp.json().await?; + + Ok(rsp.id) +} + +#[wasm_bindgen(js_namespace = bootstrap)] +extern "C" { + type Modal; + + #[wasm_bindgen(static_method_of = Modal, js_name = "getInstance")] + fn get_instance(selector: &str) -> Modal; + + #[wasm_bindgen(method)] + fn hide(this: &Modal); +} + +async fn fetch_households(token: String) -> anyhow::Result { + let rsp = gloo_net::http::Request::get(api!("household")) + .header("Authorization", &format!("Bearer {token}")) + .send() + .await?; + + if !rsp.ok() { + anyhow::bail!("Request failed: {rsp:?}") + } + + let rsp: api::Households = rsp.json().await?; + + Ok(rsp) +} + +#[function_component] +fn HouseholdListSelect() -> HtmlResult { + let token = use_state(|| match LocalStorage::get::("token") { + Ok(v) => v, + Err(e) => unreachable!("Need to be logged in to list households: {e:?}"), + }); + let households = use_future(move || fetch_households(token.token.to_string()))?; + let navigator = use_navigator().unwrap(); + + let mk_household = |(id, info): (_, Household)| { + let name = info.name.clone(); + let nav = navigator.clone(); + let onclick = Callback::from(move |_| { + log::info!("Clicked {id}"); + + if let Err(e) = LocalStorage::set( + "household", + HouseholdInfo { + id, + name: name.clone(), + }, + ) { + log::error!("Could not select household: {e:?}"); + return; + } + + nav.push(&Route::Index); + }); + html! {<> + +
+ } + }; + + Ok(match &*households { + Ok(households) => html! { + { for households + .households + .clone() + .into_iter() + .sorted_by_key(|(_,i)| i.name.clone()) + .map(mk_household) + } + }, + Err(e) => { + log::error!("Could not fetch households: {e:?}"); + html! { + {""} + } + } + }) +} + #[function_component] fn HouseholdSelection() -> Html { - let token = use_state(|| match LocalStorage::get::("token") { + let token = use_state(|| match LocalStorage::get::("token") { Ok(v) => Some(v), Err(StorageError::KeyNotFound(_)) => None, Err(e) => unreachable!("Could not get household: {e:?}"), }); + let error = use_state(|| None::); + let navigator = use_navigator().unwrap(); + + let err = error.clone(); + let tok = token.clone(); + let onsubmit = Callback::from(move |e: SubmitEvent| { + e.prevent_default(); + + let document = gloo_utils::document(); + let token = tok.as_ref().unwrap().to_owned(); + + let name: HtmlInputElement = document + .get_element_by_id("newHsName") + .unwrap() + .dyn_into() + .expect("newHsName is not an input element"); + let name = name.value(); + + let err = err.clone(); + let navigator = navigator.clone(); + wasm_bindgen_futures::spawn_local(async move { + match do_new_household(token.token, name.clone()).await { + Ok(id) => { + let household_info = HouseholdInfo { name, id }; + + if let Err(e) = LocalStorage::set("household", household_info) { + log::error!("Could not switch to new household: {e:?}") + } + + let modal = Modal::get_instance("#newHsModal"); + modal.hide(); + + navigator.push(&Route::Index); + err.set(None); + } + Err(e) => { + err.set(Some(format!("Could not create: {e:?}"))); + } + } + }); + }); + + let fallback = html! { {"Loading..."} }; match &*token { None => html! { to={Route::Login} /> }, - Some(_) => html! { - {"Household Selection"} - }, + Some(_) => html! {<> + +
+

{"Available"}

+
+ + + +
+ + +
+ }, } } @@ -170,7 +385,10 @@ fn switch(route: Route) -> Html { async fn do_login(username: String, password: String) -> anyhow::Result<()> { let rsp = gloo_net::http::Request::post(api!("login")) - .json(&LoginRequest { username, password })? + .json(&LoginRequest { + username: username.clone(), + password, + })? .send() .await?; @@ -182,7 +400,13 @@ async fn do_login(username: String, password: String) -> anyhow::Result<()> { let rsp: LoginResponse = rsp.json().await?; - LocalStorage::set("token", rsp.token)?; + LocalStorage::set( + "token", + LoginInfo { + token: rsp.token, + name: username, + }, + )?; Ok(()) } diff --git a/app/src/sidebar.rs b/app/src/sidebar.rs index 3e6581d..6ba9b87 100644 --- a/app/src/sidebar.rs +++ b/app/src/sidebar.rs @@ -1,5 +1,6 @@ +use crate::{RegaladeGlobalState, Route}; use yew::prelude::*; -use crate::{Route, HouseholdInfo}; +use yew_router::prelude::*; #[derive(PartialEq)] struct MenuEntry { @@ -12,12 +13,13 @@ struct MenuEntry { struct SidebarProps { entries: Vec, current: Route, - household: HouseholdInfo, children: Children, } #[function_component] fn Sidebar(props: &SidebarProps) -> Html { + let global_state = use_state(RegaladeGlobalState::get); + html! {
@@ -100,12 +102,19 @@ fn Sidebar(props: &SidebarProps) -> Html { > - {&props.household.name} + {format!("{} ({})", global_state.household.name, global_state.token.name)}
@@ -121,7 +130,6 @@ fn Sidebar(props: &SidebarProps) -> Html { #[derive(Properties, PartialEq)] pub(crate) struct RegaladeSidebarProps { pub(crate) current: Route, - pub(crate) household: HouseholdInfo, pub(crate) children: Children, } @@ -141,9 +149,8 @@ pub(crate) fn RegaladeSidebar(props: &RegaladeSidebarProps) -> Html { ]; html! { - + { for props.children.iter() } } } - diff --git a/app/static/household_selection.css b/app/static/household_selection.css new file mode 100644 index 0000000..41ba659 --- /dev/null +++ b/app/static/household_selection.css @@ -0,0 +1,12 @@ +html, +body, +main { + height: 100%; +} + +main { + display: flex; + align-items: center; + padding-top: 40px; + padding-bottom: 40px; +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index e4e9022..2db83c8 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -5,7 +5,11 @@ use axum::{ async_trait, extract::{FromRef, FromRequestParts, State}, headers::{authorization::Bearer, Authorization}, - http::{header::CONTENT_TYPE, request::Parts, HeaderValue, Method, StatusCode}, + http::{ + header::{AUTHORIZATION, CONTENT_TYPE}, + request::Parts, + HeaderValue, Method, StatusCode, + }, response::IntoResponse, routing::{get, post, put}, Json, Router, TypedHeader, @@ -141,7 +145,7 @@ pub(crate) fn router(api_allowed: Option) -> Router { }; let cors_base = CorsLayer::new() - .allow_headers([CONTENT_TYPE]) + .allow_headers([CONTENT_TYPE, AUTHORIZATION]) .allow_origin(origin); let mk_service = |m: Vec| cors_base.clone().allow_methods(m);