regalade/app/src/main.rs
2023-06-17 21:52:13 +02:00

586 lines
17 KiB
Rust

use gloo_storage::{errors::StorageError, LocalStorage, Storage};
use itertools::Itertools;
use log::Level;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use yew::{prelude::*, suspense::use_future};
use yew_router::prelude::*;
use api::{
AddToHouseholdRequest, CreateHouseholdRequest, CreateHouseholdResponse, Household,
LoginRequest, LoginResponse, UserInfo,
};
use crate::{
bootstrap::{bs, FormModal, ModalToggleButton},
sidebar::RegaladeSidebar,
};
mod bootstrap;
mod sidebar;
mod ingredients;
const API_ROUTE: &str = match option_env!("REGALADE_API_SERVER_BASE") {
None => "http://localhost:8085",
Some(v) => v,
};
#[macro_export]
macro_rules! api {
($($arg:tt)*) => {{
use $crate::API_ROUTE;
&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)
}
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 |_| {
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>"}
}
}
})
}
#[derive(Properties, PartialEq)]
struct CreateHouseholdProps {
token: String,
}
async fn do_resolve_user(token: String, username: String) -> anyhow::Result<Option<Uuid>> {
let rsp = gloo_net::http::Request::get(api!("search/user/{username}"))
.header("Authorization", &format!("Bearer {token}"))
.send()
.await?;
if rsp.status() == 404 {
return Ok(None);
}
if !rsp.ok() {
anyhow::bail!("Request failed: {rsp:?}")
}
let rsp: UserInfo = rsp.json().await?;
Ok(Some(rsp.id))
}
pub async fn do_add_user_to_household(
token: String,
household: Uuid,
user: Uuid,
) -> anyhow::Result<()> {
let rsp = gloo_net::http::Request::put(api!("household/{household}"))
.json(&AddToHouseholdRequest { user })?
.header("Authorization", &format!("Bearer {token}"))
.send()
.await?;
if !rsp.ok() {
anyhow::bail!("Request failed: {rsp:?}")
}
Ok(())
}
#[function_component]
fn CreateHousehold(props: &CreateHouseholdProps) -> Html {
let error = use_state(|| None::<String>);
let navigator = use_navigator().unwrap();
let members = use_state(Vec::<(Uuid, String)>::new);
let err = error.clone();
let tok = props.token.clone();
let mem = members.clone();
let on_submit = Callback::from(move |()| {
let document = gloo_utils::document();
let token = tok.clone();
let name: HtmlInputElement = document
.get_element_by_id("newHsName")
.unwrap()
.dyn_into()
.expect("newHsName is not an input element");
let name = name.value();
let members = mem.clone();
let err = err.clone();
let navigator = navigator.clone();
wasm_bindgen_futures::spawn_local(async move {
match do_new_household(token.clone(), name.clone()).await {
Ok(id) => {
let household_info = HouseholdInfo { name, id };
for (uid, user) in &*members {
if let Err(e) = do_add_user_to_household(token.clone(), id, *uid).await {
err.set(Some(format!(
"Could not add user {user} (but household was created): {e:?}"
)));
return;
}
}
if let Err(e) = LocalStorage::set("household", household_info) {
log::error!("Could not switch to new household: {e:?}")
}
let modal = bs::Modal::get_instance("#newHsModal");
modal.hide();
navigator.push(&Route::Index);
err.set(None);
members.set(Vec::new());
}
Err(e) => {
err.set(Some(format!("Could not create: {e:?}")));
}
}
});
});
let err = error.clone();
let tok = props.token.clone();
let mem = members.clone();
let add_member = Callback::from(move |_| {
let document = gloo_utils::document();
let username: HtmlInputElement = document
.get_element_by_id("newHsAddMember")
.unwrap()
.dyn_into()
.expect("newHsAddMember is not an input element");
let username = username.value();
let tok = tok.clone();
let err = err.clone();
let members = mem.clone();
wasm_bindgen_futures::spawn_local(async move {
match do_resolve_user(tok.clone(), username.clone()).await {
Err(e) => {
err.set(Some(format!("Error adding a member: {e:?}")));
}
Ok(None) => {
err.set(Some(format!("User '{username}' does not exist")));
}
Ok(Some(id)) => {
let mut m = (*members).clone();
m.push((id, username));
members.set(m);
}
}
});
});
let mem = members.clone();
let remove_user = |idx| {
Callback::from(move |_| {
let mut m = (*mem).clone();
m.remove(idx);
mem.set(m);
})
};
html! {
<FormModal
id="newHsModal"
centered={true}
submit_label={"Create"}
title="Create a Household"
{on_submit}
>
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>
<h2 class={classes!("fs-5", "m-2")}>{"Additional Members"}</h2>
<ul class="list-group list-group-flush">
{
for members.iter().enumerate().map(move |(idx, (_, name))| html!{
<li class="list-group-item">
{name}
<button
type="button"
class={classes!("btn", "btn-danger", "ms-2")}
onclick={remove_user.clone()(idx)}
>
{"Remove"}
</button>
</li>
})
}
</ul>
<div class="d-flex flex-row">
<input
id="newHsAddMember"
class={classes!("form-control", "me-2")}
placeholder="Additional member"
/>
<button type="button" class={classes!("btn", "btn-primary")} onclick={add_member}>
{"Add"}
</button>
</div>
</FormModal>
}
}
#[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 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 />
<ModalToggleButton
classes={classes!("btn", "btn-lg", "btn-primary")}
modal_id="newHsModal"
>
{"New household"}
</ModalToggleButton>
<CreateHousehold token={token.as_ref().unwrap().token.to_owned()} />
</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::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();
}