app,server: Implement selection of household
This commit is contained in:
parent
dd266dccfd
commit
1eb815ada9
8 changed files with 284 additions and 34 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -75,6 +75,7 @@ dependencies = [
|
|||
"gloo-net",
|
||||
"gloo-storage",
|
||||
"gloo-utils",
|
||||
"itertools",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -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<Uuid>,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
/>
|
||||
<link data-trunk rel="css" href="static/style.css" />
|
||||
<link data-trunk rel="copy-file" href="static/login.css" />
|
||||
<link data-trunk rel="copy-file" href="static/household_selection.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main></main>
|
||||
|
|
|
|||
272
app/src/main.rs
272
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<Self> {
|
||||
let token = match LocalStorage::get::<String>("token") {
|
||||
let token = match LocalStorage::get::<LoginInfo>("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::<String>("token") {
|
||||
let token = match LocalStorage::get::<LoginInfo>("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! {
|
||||
<RegaladeSidebar current={props.route} household={state.household.clone()}>
|
||||
<RegaladeSidebar current={props.route}>
|
||||
{ for props.children.iter() }
|
||||
</RegaladeSidebar>
|
||||
}
|
||||
|
|
@ -126,21 +129,233 @@ fn GlobalStateRedirector(props: &GlobalStateRedirectorProps) -> Html {
|
|||
}
|
||||
}
|
||||
|
||||
async fn do_new_household(token: String, name: String) -> anyhow::Result<Uuid> {
|
||||
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<api::Households> {
|
||||
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::<LoginInfo>("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! {<>
|
||||
<button class={classes!("btn", "btn-secondary", "m-1")} {onclick}>
|
||||
{&info.name}
|
||||
</button>
|
||||
<br />
|
||||
</>}
|
||||
};
|
||||
|
||||
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! {
|
||||
{"<ERROR>"}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
fn HouseholdSelection() -> Html {
|
||||
let token = use_state(|| match LocalStorage::get::<String>("token") {
|
||||
let token = use_state(|| match LocalStorage::get::<LoginInfo>("token") {
|
||||
Ok(v) => Some(v),
|
||||
Err(StorageError::KeyNotFound(_)) => None,
|
||||
Err(e) => unreachable!("Could not get household: {e:?}"),
|
||||
});
|
||||
let error = use_state(|| None::<String>);
|
||||
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! {
|
||||
<Redirect<Route> to={Route::Login} />
|
||||
},
|
||||
Some(_) => html! {
|
||||
{"Household Selection"}
|
||||
},
|
||||
Some(_) => html! {<>
|
||||
<link href="/household_selection.css" rel="stylesheet" />
|
||||
<div class={classes!("col-sm-3", "m-auto", "p-2", "text-center", "border", "rounded")}>
|
||||
<h1 class="h3">{"Available"}</h1>
|
||||
<hr />
|
||||
<Suspense {fallback}>
|
||||
<HouseholdListSelect />
|
||||
</Suspense>
|
||||
<hr />
|
||||
<button
|
||||
class={classes!("btn", "btn-lg", "btn-primary")}
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#newHsModal"
|
||||
>
|
||||
{"New household"}
|
||||
</button>
|
||||
<div
|
||||
class={classes!("modal", "fade")}
|
||||
id="newHsModal"
|
||||
tabindex="-1"
|
||||
aria-labelledby="newHsModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class={classes!("modal-title", "fs-5")} id="newHsModalLabel">
|
||||
{"Create a household"}
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Close"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="newHsForm" {onsubmit}>
|
||||
if let Some(err) = &*error {
|
||||
<div class={classes!("alert", "alert-danger")} role="alert">
|
||||
{err}
|
||||
</div>
|
||||
}
|
||||
<div class="form-floating">
|
||||
<input
|
||||
id="newHsName"
|
||||
class={classes!("form-control")}
|
||||
placeholder="Household name"
|
||||
/>
|
||||
<label for="newHsName">{"Household name"}</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class={classes!("btn", "btn-secondary")}
|
||||
data-bs-dismiss="modal"
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class={classes!("btn", "btn-primary")}
|
||||
form="newHsForm"
|
||||
>
|
||||
{"Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MenuEntry>,
|
||||
current: Route,
|
||||
household: HouseholdInfo,
|
||||
children: Children,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
fn Sidebar(props: &SidebarProps) -> Html {
|
||||
let global_state = use_state(RegaladeGlobalState::get);
|
||||
|
||||
html! {
|
||||
<div class="container-fluid">
|
||||
<div class={classes!("row", "flex-nowrap")}>
|
||||
|
|
@ -100,12 +102,19 @@ fn Sidebar(props: &SidebarProps) -> Html {
|
|||
>
|
||||
<i class={classes!("fs-4", "bi-house-door-fill")}></i>
|
||||
<strong class={classes!("ms-2", "d-none", "d-sm-inline")}>
|
||||
{&props.household.name}
|
||||
{format!("{} ({})", global_state.household.name, global_state.token.name)}
|
||||
</strong>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<hr />
|
||||
<li><a class="dropdown-item" href="#">{"New household"}</a></li>
|
||||
<li>
|
||||
<Link<Route>
|
||||
classes={classes!("dropdown-item")}
|
||||
to={Route::HouseholdSelect}
|
||||
>
|
||||
{"Change household"}
|
||||
</Link<Route>>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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! {
|
||||
<Sidebar {entries} current={props.current} household={props.household.clone()}>
|
||||
<Sidebar {entries} current={props.current}>
|
||||
{ for props.children.iter() }
|
||||
</Sidebar>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
12
app/static/household_selection.css
Normal file
12
app/static/household_selection.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
html,
|
||||
body,
|
||||
main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
|
@ -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<HeaderValue>) -> Router<AppState> {
|
|||
};
|
||||
|
||||
let cors_base = CorsLayer::new()
|
||||
.allow_headers([CONTENT_TYPE])
|
||||
.allow_headers([CONTENT_TYPE, AUTHORIZATION])
|
||||
.allow_origin(origin);
|
||||
|
||||
let mk_service = |m: Vec<Method>| cors_base.clone().allow_methods(m);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue