495 lines
15 KiB
Rust
495 lines
15 KiB
Rust
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::{prelude::*, JsCast};
|
|
use web_sys::HtmlInputElement;
|
|
use yew::{prelude::*, suspense::use_future};
|
|
use itertools::Itertools;
|
|
use yew_router::prelude::*;
|
|
|
|
use crate::sidebar::RegaladeSidebar;
|
|
|
|
mod sidebar;
|
|
|
|
const API_ROUTE: &str = match option_env!("REGALADE_API_SERVER_BASE") {
|
|
None => "http://localhost:8085",
|
|
Some(v) => v,
|
|
};
|
|
|
|
macro_rules! api {
|
|
($($arg:tt)*) => {
|
|
&format!("{API_ROUTE}/api/{}", format_args!($($arg)*))
|
|
};
|
|
}
|
|
|
|
#[derive(Routable, Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Route {
|
|
#[at("/")]
|
|
Index,
|
|
#[at("/login")]
|
|
Login,
|
|
#[at("/ingredients")]
|
|
Ingredients,
|
|
#[at("/household_select")]
|
|
HouseholdSelect,
|
|
#[at("/404")]
|
|
#[not_found]
|
|
NotFound,
|
|
}
|
|
|
|
#[function_component]
|
|
fn App() -> Html {
|
|
html! {
|
|
<BrowserRouter>
|
|
<Switch<Route> render={switch} />
|
|
</BrowserRouter>
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
struct HouseholdInfo {
|
|
id: Uuid,
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
struct LoginInfo {
|
|
token: String,
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct RegaladeGlobalState {
|
|
token: LoginInfo,
|
|
household: HouseholdInfo,
|
|
}
|
|
|
|
impl RegaladeGlobalState {
|
|
pub fn get_or_navigate(navigator: Navigator) -> Option<Self> {
|
|
let token = match LocalStorage::get::<LoginInfo>("token") {
|
|
Ok(v) => v,
|
|
Err(StorageError::KeyNotFound(_)) => {
|
|
navigator.push(&Route::Login);
|
|
return None;
|
|
}
|
|
Err(e) => unreachable!("Could not get token: {e:?}"),
|
|
};
|
|
|
|
let household = match LocalStorage::get::<HouseholdInfo>("household") {
|
|
Ok(v) => v,
|
|
Err(StorageError::KeyNotFound(_)) => {
|
|
navigator.push(&Route::HouseholdSelect);
|
|
return None;
|
|
}
|
|
Err(e) => unreachable!("Could not get household: {e:?}"),
|
|
};
|
|
|
|
Some(Self { token, household })
|
|
}
|
|
|
|
pub fn get() -> Self {
|
|
let token = match LocalStorage::get::<LoginInfo>("token") {
|
|
Ok(v) => v,
|
|
Err(e) => unreachable!("Could not get token: {e:?}"),
|
|
};
|
|
|
|
let household = match LocalStorage::get::<HouseholdInfo>("household") {
|
|
Ok(v) => v,
|
|
Err(e) => unreachable!("Could not get household: {e:?}"),
|
|
};
|
|
|
|
Self { token, household }
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Properties)]
|
|
struct GlobalStateRedirectorProps {
|
|
children: Children,
|
|
route: Route,
|
|
}
|
|
|
|
#[function_component]
|
|
fn GlobalStateRedirector(props: &GlobalStateRedirectorProps) -> Html {
|
|
let navigator = use_navigator().unwrap();
|
|
let s = RegaladeGlobalState::get_or_navigate(navigator);
|
|
|
|
match s {
|
|
Some(_) => {
|
|
html! {
|
|
<RegaladeSidebar current={props.route}>
|
|
{ for props.children.iter() }
|
|
</RegaladeSidebar>
|
|
}
|
|
}
|
|
None => 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::<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! {<>
|
|
<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>
|
|
</>},
|
|
}
|
|
}
|
|
|
|
fn switch(route: Route) -> Html {
|
|
match route {
|
|
Route::Index => html! {
|
|
<GlobalStateRedirector {route}>
|
|
{"Index"}
|
|
</GlobalStateRedirector>
|
|
},
|
|
Route::Login => html! {
|
|
<Login />
|
|
},
|
|
Route::Ingredients => html! {
|
|
<GlobalStateRedirector {route}>
|
|
{"Ingredients"}
|
|
</GlobalStateRedirector>
|
|
},
|
|
Route::HouseholdSelect => html! {
|
|
<HouseholdSelection />
|
|
},
|
|
Route::NotFound => html! {
|
|
"Page not found"
|
|
},
|
|
}
|
|
}
|
|
|
|
async fn do_login(username: String, password: String) -> anyhow::Result<()> {
|
|
let rsp = gloo_net::http::Request::post(api!("login"))
|
|
.json(&LoginRequest {
|
|
username: username.clone(),
|
|
password,
|
|
})?
|
|
.send()
|
|
.await?;
|
|
|
|
if rsp.status() == 404 {
|
|
anyhow::bail!("Account not foud")
|
|
} else if !rsp.ok() {
|
|
anyhow::bail!("Request failed: {rsp:?}")
|
|
}
|
|
|
|
let rsp: LoginResponse = rsp.json().await?;
|
|
|
|
LocalStorage::set(
|
|
"token",
|
|
LoginInfo {
|
|
token: rsp.token,
|
|
name: username,
|
|
},
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[function_component]
|
|
fn Login() -> Html {
|
|
let error = use_state(|| None);
|
|
|
|
let navigator = use_navigator().unwrap();
|
|
let err = error.clone();
|
|
let onsubmit = Callback::from(move |e: SubmitEvent| {
|
|
e.prevent_default();
|
|
|
|
let document = gloo_utils::document();
|
|
|
|
let username: HtmlInputElement = document
|
|
.get_element_by_id("floatingUser")
|
|
.unwrap()
|
|
.dyn_into()
|
|
.expect("floatingUser is not an input element");
|
|
let username = username.value();
|
|
|
|
let password: HtmlInputElement = document
|
|
.get_element_by_id("floatingPass")
|
|
.unwrap()
|
|
.dyn_into()
|
|
.expect("floatingUser is not an input element");
|
|
let password = password.value();
|
|
|
|
let err = err.clone();
|
|
let navigator = navigator.clone();
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match do_login(username, password).await {
|
|
Ok(_) => {
|
|
navigator.push(&Route::Index);
|
|
err.set(None);
|
|
}
|
|
Err(e) => {
|
|
err.set(Some(format!("Could not log in: {e:?}")));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
html! {<>
|
|
<link href="/login.css" rel="stylesheet" />
|
|
<form class={classes!("form-signin", "w-100", "m-auto", "text-center")} {onsubmit}>
|
|
<h1 class={classes!("h3", "mb-3")}>{"Please log in"}</h1>
|
|
if let Some(err) = &*error {
|
|
<div class={classes!("alert", "alert-danger")} role="alert">
|
|
{err}
|
|
</div>
|
|
}
|
|
<div class={classes!("form-floating")}>
|
|
<input id="floatingUser" class={classes!("form-control")} placeholder="Username" />
|
|
<label for="floatingUser">{"Username"}</label>
|
|
</div>
|
|
<div class={classes!("form-floating")}>
|
|
<input
|
|
id="floatingPass"
|
|
class={classes!("form-control")}
|
|
placeholder="Password"
|
|
type="password"
|
|
/>
|
|
<label for="floatingPass">{"Password"}</label>
|
|
</div>
|
|
<button class={classes!("w-100", "btn", "btn-lg", "btn-primary")} type="submit">
|
|
{"Login"}
|
|
</button>
|
|
</form>
|
|
</>}
|
|
}
|
|
|
|
fn main() {
|
|
console_log::init_with_level(Level::Debug).unwrap();
|
|
|
|
yew::Renderer::<App>::with_root(
|
|
gloo_utils::document()
|
|
.body()
|
|
.expect("no body")
|
|
.get_elements_by_tag_name("main")
|
|
.item(0)
|
|
.expect("no main"),
|
|
)
|
|
.render();
|
|
}
|