regalade/gui/src/lib.rs

599 lines
16 KiB
Rust
Raw Normal View History

#![allow(non_snake_case)]
use std::rc::Rc;
use api::{
AddToHouseholdRequest, CreateHouseholdRequest, CreateHouseholdResponse, LoginRequest,
LoginResponse, UserInfo,
};
use dioxus::prelude::*;
2023-08-05 12:54:49 +02:00
use dioxus_router::prelude::*;
use gloo_storage::{errors::StorageError, LocalStorage, Storage};
2023-05-29 16:47:18 +02:00
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
2023-05-29 16:47:18 +02:00
2023-08-05 12:54:49 +02:00
use crate::bootstrap::{bs, FormModal, ModalToggleButton, Spinner};
2023-05-28 19:48:58 +02:00
2023-05-29 16:47:18 +02:00
mod bootstrap;
2023-06-17 21:52:13 +02:00
mod ingredients;
2023-06-22 22:33:38 +02:00
mod sidebar;
2023-06-25 14:12:09 +02:00
mod recipe;
2023-05-28 19:48:58 +02:00
mod full_context;
pub use full_context::{use_full_context, use_trimmed_context};
use sidebar::RegaladeSidebar;
const API_ROUTE: &str = match option_env!("REGALADE_API_SERVER_BASE") {
None => "http://localhost:8085",
Some(v) => v,
};
2023-07-27 00:06:36 +02:00
const FRONTEND_ROOT: &str = match option_env!("REGALADE_FRONTEND_DOMAIN") {
None => "http://localhost:8080",
Some(v) => v,
};
#[macro_export]
macro_rules! api {
($($arg:tt)*) => {{
use $crate::API_ROUTE;
&format!("{API_ROUTE}/api/{}", format_args!($($arg)*))
}};
}
#[derive(Props)]
pub struct ErrorProps<'a> {
error: &'a Option<String>,
2023-06-25 14:12:09 +02:00
}
pub fn ErrorView<'a>(cx: Scope<'a, ErrorProps<'a>>) -> Element {
cx.props
.error
.as_ref()
.and_then(|err| cx.render(rsx! { ErrorAlert { error: "{err}" } }))
2023-05-19 11:23:44 +02:00
}
#[inline_props]
pub fn ErrorAlert<'a>(cx: Scope<'a>, error: &'a str) -> Element<'a> {
cx.render(rsx! {
div { class: "alert alert-danger", *error }
})
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LoginInfo {
token: String,
name: String,
}
pub fn use_login(cx: &ScopeState) -> UseSharedState<LoginInfo> {
use_shared_state::<LoginInfo>(cx)
.expect("no login info in scope")
.clone()
}
pub fn use_error(cx: &ScopeState) -> &UseState<Option<String>> {
use_state(cx, || None)
}
#[derive(Clone)]
pub struct Callback {
pub cb: Rc<dyn Fn()>,
}
impl Callback {
pub fn call(&self) {
(self.cb)()
}
}
#[allow(clippy::vtable_address_comparisons)]
impl PartialEq for Callback {
fn eq(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.cb, &other.cb)
}
}
impl From<Rc<dyn Fn()>> for Callback {
fn from(cb: Rc<dyn Fn()>) -> Self {
Self { cb }
}
}
impl<F> From<F> for Callback
where
F: Fn() + 'static,
{
fn from(cb: F) -> Self {
Self { cb: Rc::new(cb) }
}
}
pub fn use_refresh(cx: &ScopeState) -> (u64, Callback) {
let refresh = use_state(cx, || 0u64);
let callback = {
to_owned![refresh];
Callback::from(move || refresh.set(refresh.wrapping_add(1)))
};
(**refresh, callback)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HouseholdInfo {
id: Uuid,
name: String,
2023-05-19 11:23:44 +02:00
}
2023-08-05 12:54:49 +02:00
pub fn LoginRedirect(cx: Scope) -> Element {
let navigator = use_navigator(cx);
let token = match LocalStorage::get::<LoginInfo>("token") {
2023-08-05 12:54:49 +02:00
Ok(v) => Some(v),
Err(StorageError::KeyNotFound(_)) => {
2023-08-07 13:43:07 +02:00
navigator.push(Route::Login);
None
}
Err(e) => unreachable!("Could not get token: {e:?}"),
};
2023-08-05 12:54:49 +02:00
use_shared_state_provider(cx, || token.clone());
2023-08-05 12:54:49 +02:00
cx.render(match token {
2023-08-07 13:43:07 +02:00
Some(info) => rsx! {
LoginRedirectInner {info: info},
2023-08-05 12:54:49 +02:00
},
None => {
rsx! {{}}
}
})
}
2023-08-07 13:43:07 +02:00
#[derive(Props, PartialEq)]
struct LoginRedirectInnerProps {
info: LoginInfo,
}
fn LoginRedirectInner(cx: Scope<LoginRedirectInnerProps>) -> Element {
use_shared_state_provider(cx, || cx.props.info.clone());
cx.render(rsx! {
Outlet::<Route> {}
})
}
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() {
let body = rsp.text().await?;
anyhow::bail!("Request failed: {body:?}")
}
let rsp: LoginResponse = rsp.json().await?;
LocalStorage::set(
"token",
LoginInfo {
token: rsp.token,
name: username,
},
)?;
Ok(())
}
2023-07-27 00:06:36 +02:00
async fn check_oidc() -> anyhow::Result<bool> {
let rsp = gloo_net::http::Request::get(api!("login/has_oidc"))
.send()
.await?;
Ok(rsp.status() == 200)
}
fn Openid(cx: Scope) -> Element {
let has = use_future(cx, (), |()| check_oidc());
cx.render(match has.value().unwrap_or(&Ok(false)) {
Ok(true) => {
let route = api!("login/oidc").to_owned();
let ret = urlencoding::encode(&format!("{FRONTEND_ROOT}/login/oidc")).to_string();
rsx! {
a {
href: "{route}?return={ret}",
class: "mt-1 w-100 btn btn-lg btn-primary",
"Login with OpenID"
}
}
}
Ok(false) => rsx! {{}},
Err(e) => {
log::error!("Could not check oidc status: {e:?}");
rsx! {{}}
}
})
}
fn Login(cx: Scope) -> Element {
let error = use_state(cx, || None::<String>);
2023-08-05 12:54:49 +02:00
let navigator = use_navigator(cx);
let on_submit = move |e: Event<FormData>| {
2023-08-05 12:54:49 +02:00
to_owned![error, navigator];
cx.spawn(async move {
match do_login(
2023-08-05 12:54:49 +02:00
e.values["username"][0].to_string(),
e.values["password"][0].to_string(),
)
.await
{
Ok(_) => {
error.set(None);
2023-08-05 12:54:49 +02:00
navigator.push(Route::Index);
}
Err(e) => {
error.set(Some(format!("Could not log in: {e}")));
}
}
})
};
cx.render(rsx! {
link { href: "/login.css", rel: "stylesheet" }
form {
onsubmit: on_submit,
class: "form-signin w-100 m-auto text-center",
h1 { class: "h3 mb-3", "Please log in" }
ErrorView { error: error }
div { class: "form-floating",
input {
name: "username",
id: "floatingUser",
class: "form-control",
placeholder: "Username"
}
label { "for": "floatingUser", "Username" }
}
div { class: "form-floating",
input {
name: "password",
id: "floatingPass",
class: "form-control",
placeholder: "Password",
"type": "password"
}
label { "for": "floatingPass", "Password" }
}
button { class: "w-100 btn btn-lg btn-primary", "type": "submit", "Login" }
2023-07-27 00:06:36 +02:00
Openid {}
}
})
}
async fn do_new_household(token: String, name: String) -> anyhow::Result<Uuid> {
let rsp = gloo_net::http::Request::post(api!("household"))
.header("Authorization", &format!("Bearer {token}"))
.json(&CreateHouseholdRequest { name })?
.send()
.await?;
if !rsp.ok() {
anyhow::bail!("Request failed: {rsp:?}")
}
let rsp: CreateHouseholdResponse = rsp.json().await?;
Ok(rsp.id)
}
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}"))
.header("Authorization", &format!("Bearer {token}"))
.json(&AddToHouseholdRequest { user })?
.send()
.await?;
if !rsp.ok() {
anyhow::bail!("Request failed: {rsp:?}")
}
Ok(())
}
fn CreateHousehold(cx: Scope) -> Element {
let login = use_login(cx);
let error = use_state(cx, || None::<String>);
let name = use_state(cx, String::new);
let members = use_ref(cx, Vec::<(Uuid, String)>::new);
2023-08-05 12:54:49 +02:00
let navigator = use_navigator(cx);
let token = login.read().token.clone();
let on_submit = move |_| {
2023-08-05 12:54:49 +02:00
to_owned![members, name, error, token, navigator];
cx.spawn(async move {
match do_new_household(token.clone(), name.to_string()).await {
Ok(id) => {
let info = HouseholdInfo {
id,
name: name.to_string(),
};
for (uid, user) in members.read().iter() {
if let Err(e) = do_add_user_to_household(token.clone(), id, *uid).await {
error.set(Some(format!(
"Could not add user {user} (but household was created): {e:?}"
)));
return;
}
}
if let Err(e) = LocalStorage::set("household", info) {
log::error!("Could not switch to new household: {e:?}");
};
2023-05-29 16:47:18 +02:00
let modal = bs::Modal::get_instance("#newHsModal");
modal.hide();
2023-08-05 12:54:49 +02:00
navigator.push(Route::Index);
error.set(None);
}
Err(e) => {
error.set(Some(format!("Could not create household: {e:?}")));
}
}
})
};
let new_member = use_state(cx, String::new);
let token = login.read().token.clone();
let on_add_member = move |_| {
to_owned![new_member, members, error, token];
cx.spawn(async move {
match do_resolve_user(token, new_member.to_string()).await {
Err(e) => {
error.set(Some(format!("Could not add member: {e:?}")));
}
Ok(None) => {
error.set(Some(format!("User {new_member} does not exist")));
}
Ok(Some(id)) => {
members.with_mut(|m| {
if !m.iter().any(|&(i, _)| i == id) {
m.push((id, new_member.to_string()))
}
});
error.set(None);
}
}
});
};
cx.render(rsx! {
FormModal {
id: "newHsModal",
fade: true,
centered: true,
submit_label: "Create",
title: "Create a Household",
on_submit: on_submit,
ErrorView { error: error }
div { class: "form-floating",
input {
id: "newHsName",
class: "form-control",
placeholder: "Household name",
oninput: move |ev| name.set(ev.value.clone())
}
label { "for": "newHsName", "Household name" }
}
h2 { class: "fs-5 m-2", "Additional Members" }
ul { class: "list-group list-group-flush",
for (idx , (id , name)) in members.read().iter().enumerate() {
li { key: "{id}", class: "list-group-item",
"{name}"
button {
"type": "button",
class: "btn btn-danger ms-2",
onclick: move |_| {
members
.with_mut(|m| {
m.remove(idx);
})
},
"Remove"
}
}
}
}
div { class: "d-flex flex-row",
input {
id: "newHsAddMember",
class: "form-control me-2",
oninput: move |ev| new_member.set(ev.value.clone()),
placeholder: "Additional member"
}
button { "type": "button", class: "btn btn-primary", onclick: on_add_member, "Add" }
}
}
})
}
async fn fetch_households(token: String) -> anyhow::Result<api::Households> {
let rsp = gloo_net::http::Request::get(api!("household"))
.header("Authorization", &format!("Bearer {token}"))
2023-05-20 16:53:07 +02:00
.send()
.await?;
if !rsp.ok() {
2023-05-20 16:53:07 +02:00
anyhow::bail!("Request failed: {rsp:?}")
}
let rsp: api::Households = rsp.json().await?;
2023-05-20 16:53:07 +02:00
Ok(rsp)
}
2023-05-20 16:53:07 +02:00
fn HouseholdListSelect(cx: Scope) -> Element {
let login = use_login(cx);
let households = use_future(cx, (), |_| fetch_households(login.read().token.clone()));
2023-08-05 12:54:49 +02:00
let navigator = use_navigator(cx);
cx.render(match households.value() {
Some(Ok(response)) => {
let households = response
.households
.iter()
.sorted_by_key(|(_, i)| i.name.clone())
.map(|(id, info)| {
let onclick = move |_| {
if let Err(e) = LocalStorage::set(
"household",
HouseholdInfo {
id: *id,
name: info.name.clone(),
},
) {
log::error!("Could not select household: {e:?}");
return;
}
2023-08-05 12:54:49 +02:00
navigator.push(Route::Index);
};
rsx! {button { key: "{id}", class: "btn btn-secondary m-1", onclick: onclick, "{info.name}" }}
});
rsx! {households}
}
Some(Err(e)) => {
rsx! { div { class: "alert alert-danger", "Could not fetch households: {e:?}" } }
}
None => rsx! { Spinner {} },
})
2023-05-20 16:53:07 +02:00
}
fn HouseholdSelection(cx: Scope) -> Element {
cx.render(rsx! {
link { href: "/household_selection.css", rel: "stylesheet" }
div { class: "col-sm-3 m-auto p-2 text-center border rounded",
h1 { class: "h3", "Available" }
hr {}
HouseholdListSelect {}
hr {}
ModalToggleButton { class: "btn btn-lg btn-primary", modal_id: "newHsModal", "New household" }
CreateHousehold {}
}
})
}
fn Index(cx: Scope) -> Element {
cx.render(rsx! {"INDEX"})
}
2023-08-05 12:54:49 +02:00
#[derive(Deserialize, PartialEq, Clone)]
2023-07-27 00:06:36 +02:00
struct OidcQuery {
token: String,
username: String,
}
2023-08-05 12:54:49 +02:00
#[derive(PartialEq, Props)]
struct OidcProps {
token: String,
username: String,
}
2023-07-27 00:06:36 +02:00
2023-08-05 12:54:49 +02:00
fn OidcRedirect(cx: Scope<OidcProps>) -> Element {
cx.render({
match LocalStorage::set(
"token",
LoginInfo {
token: cx.props.token.clone(),
name: cx.props.username.clone(),
},
) {
Ok(_) => {
gloo_utils::window().location().replace("/").unwrap();
rsx! {{}}
2023-07-27 00:06:36 +02:00
}
2023-08-05 12:54:49 +02:00
Err(_) => rsx! {"Could not store authentication, try again."},
2023-07-27 00:06:36 +02:00
}
})
}
2023-08-05 12:54:49 +02:00
use ingredients::Ingredients;
use recipe::{RecipeCreator, RecipeList, RecipeView};
#[rustfmt::skip]
#[derive(Clone, Routable)]
enum Route {
#[route("/login")]
Login,
#[route("/login/oidc?:token?:username")]
OidcRedirect { token: String, username: String },
#[layout(LoginRedirect)]
#[route("/household_selection")]
HouseholdSelection,
#[end_layout]
#[layout(RegaladeSidebar)]
#[route("/")]
Index,
#[route("/ingredients")]
Ingredients,
#[route("/recipe_creator")]
RecipeCreator,
#[nest("/recipe")]
#[route("/")]
RecipeList,
#[route("/:id")]
RecipeView {id: i64}
}
pub trait AppContext {}
pub struct AppProps<'a, C> {
pub context: &'a C,
}
pub fn App<'a, C: AppContext>(cx: Scope<'a, AppProps<'a, C>>) -> Element {
cx.render(rsx! {
2023-08-05 12:54:49 +02:00
Router::<Route> {}
})
2023-05-20 16:53:07 +02:00
}