diff --git a/Cargo.lock b/Cargo.lock index cdaf613..adccc72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,7 @@ dependencies = [ "gloo-net", "gloo-storage", "gloo-utils", + "im", "itertools", "log", "serde", @@ -379,6 +380,15 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -1446,6 +1456,20 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core", + "rand_xoshiro", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "implicit-clone" version = "0.3.5" @@ -2226,6 +2250,15 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -2793,6 +2826,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + [[package]] name = "slab" version = "0.4.8" diff --git a/app/Cargo.toml b/app/Cargo.toml index f61ce28..0c11880 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" +im = "15.1.0" itertools = "0.10.5" log = "0.4.17" serde = { version = "1.0.163", features = ["derive"] } diff --git a/app/src/ingredients.rs b/app/src/ingredients.rs index c2b1332..1c69760 100644 --- a/app/src/ingredients.rs +++ b/app/src/ingredients.rs @@ -11,7 +11,7 @@ use crate::{ RegaladeGlobalState, }; -async fn fetch_ingredients(token: String, household: Uuid) -> anyhow::Result { +pub async fn fetch_ingredients(token: String, household: Uuid) -> anyhow::Result { let rsp = gloo_net::http::Request::get(api!("household/{household}/ingredients")) .header("Authorization", &format!("Bearer {token}")) .send() diff --git a/app/src/main.rs b/app/src/main.rs index bfc73a5..ecfa528 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -19,8 +19,9 @@ use crate::{ }; mod bootstrap; -mod sidebar; mod ingredients; +mod recipe_creator; +mod sidebar; const API_ROUTE: &str = match option_env!("REGALADE_API_SERVER_BASE") { None => "http://localhost:8085", @@ -45,6 +46,10 @@ enum Route { Ingredients, #[at("/household_select")] HouseholdSelect, + #[at("/new_recipe")] + NewRecipe, + #[at("/recipe/:id")] + Recipe { id: i64 }, #[at("/404")] #[not_found] NotFound, @@ -468,6 +473,16 @@ fn switch(route: Route) -> Html { Route::HouseholdSelect => html! { }, + Route::NewRecipe => html! { + + + + }, + Route::Recipe { id } => html! { + + {format!("RECIPE {id}")} + + }, Route::NotFound => html! { "Page not found" }, diff --git a/app/src/recipe_creator.rs b/app/src/recipe_creator.rs new file mode 100644 index 0000000..af17b59 --- /dev/null +++ b/app/src/recipe_creator.rs @@ -0,0 +1,498 @@ +use std::{collections::BTreeMap, rc::Rc}; + +use api::{CreateRecipeRequest, CreateRecipeResponse, IngredientInfo}; +use uuid::Uuid; +use wasm_bindgen::JsCast; +use web_sys::{HtmlInputElement, HtmlTextAreaElement}; +use yew::{prelude::*, suspense::use_future}; +use yew_router::prelude::use_navigator; + +use crate::{ + api, + bootstrap::{ModalBody, ModalFooter, ModalToggleButton, TitledModal}, + RegaladeGlobalState, Route, +}; + +#[derive(Clone)] +struct RecipeIngredient { + id: i64, + info: IngredientInfo, + amount: u64, +} + +async fn fetch_ingredients( + token: String, + household: Uuid, +) -> anyhow::Result>> { + let list = crate::ingredients::fetch_ingredients(token, household).await?; + + Ok(Rc::new( + list.ingredients + .into_iter() + .map(|(k, v)| (v.name.clone(), (k, v))) + .collect(), + )) +} + +#[derive(PartialEq, Properties, Clone)] +struct IngredientSelectProps { + token: String, + household: Uuid, + onselect: Callback, +} + +#[function_component] +fn IngredientSelect(props: &IngredientSelectProps) -> HtmlResult { + let ingredients = use_future(|| fetch_ingredients(props.token.clone(), props.household))?; + let ingredient = use_state(|| None::<(i64, IngredientInfo)>); + + let input_value_h = use_state(String::new); + let input_value = input_value_h.to_string(); + + let error = use_state(|| None::); + + let selected_ig = ingredient.clone(); + let on_select = props.onselect.clone(); + let err = error.clone(); + let onclick = Callback::from(move |_| match &*selected_ig { + Some((id, info)) => { + let document = gloo_utils::document(); + + let amount: HtmlInputElement = document + .get_element_by_id("igAmount") + .unwrap() + .dyn_into() + .unwrap(); + + if !amount.report_validity() { + err.set(Some("Invalid ingredient amount".into())); + return; + } + + let amount = amount.value(); + + match amount.is_empty() { + false => { + on_select.emit(RecipeIngredient { + id: *id, + info: info.clone(), + amount: amount.parse().unwrap(), + }); + err.set(None); + } + true => { + err.set(Some("Amount can't be empty".into())); + } + } + } + None => { + err.set(Some("Ingredient does not exist".into())); + } + }); + + match &*ingredients { + Ok(ig) => { + let igc = ig.clone(); + let selected_ig = ingredient.clone(); + + let onchange = Callback::from(move |e: Event| { + let Some(target) = e.target() else { + return; + }; + + let ip = input_value_h.clone(); + let Ok(target) = target.dyn_into::() else { + return; + }; + + ip.set(target.value()); + + match igc.get(&target.value()) { + Some(info) => { + selected_ig.set(Some(info.clone())); + } + None => { + selected_ig.set(None); + } + } + }); + + Ok(html! { +
+ if let Some(e) = &*error { + + } + +
+ + + + { for ig.keys().map(|k| html!{}) } + +
+
+ +
+ + if let Some(ig) = &*ingredient { + if let Some(unit) = &ig.1.unit { + {unit} + } + } +
+ +
+
+ }) + } + Err(e) => Ok(html! { + + }), + } +} + +async fn do_create_recipe( + token: String, + household: Uuid, + request: CreateRecipeRequest, +) -> anyhow::Result { + let rsp = gloo_net::http::Request::post(api!("household/{household}/recipe")) + .header("Authorization", &format!("Bearer {token}")) + .json(&request)? + .send() + .await?; + + if !rsp.ok() { + let body = rsp.text().await.unwrap_or_default(); + anyhow::bail!("Could not post recipe (code={}): {body}", rsp.status()); + } + + let rsp: CreateRecipeResponse = rsp.json().await?; + + Ok(rsp.id) +} + +#[function_component] +pub fn RecipeCreator() -> Html { + let current_rating = use_state(|| 0u8); + let global_state = use_state(RegaladeGlobalState::get); + let person_count = use_state(|| 1u8); + + let mk_rating_oninput = |id| { + let current_rating = current_rating.clone(); + Callback::from(move |_| { + current_rating.set(id); + }) + }; + + let fallback = html! {"Loading Ingredients ..."}; + + let ingredients = use_state(im::Vector::new); + let ig = ingredients.clone(); + let onselect = Callback::from(move |rcp_ig: RecipeIngredient| { + let mut ingredients = (*ig).clone(); + ingredients.push_back(rcp_ig); + ig.set(ingredients); + }); + + let mk_ig_delete = |idx| { + let ig = ingredients.clone(); + Callback::from(move |_| { + let mut ingredients = (*ig).clone(); + ingredients.remove(idx); + ig.set(ingredients); + }) + }; + + let steps = use_state(im::Vector::new); + + let s = steps.clone(); + let on_add_step = Callback::from(move |_| { + let mut steps = (*s).clone(); + steps.push_back(String::new()); + s.set(steps); + }); + + let mk_step_del = |idx| { + let s = steps.clone(); + Callback::from(move |_| { + let mut steps = (*s).clone(); + steps.remove(idx); + s.set(steps); + }) + }; + + let mk_step_change = |idx| { + let s = steps.clone(); + Callback::from(move |e: Event| { + let Some(target) = e.target() else { + return; + }; + + let Ok(target) = target.dyn_into::() else { + return; + }; + + let mut steps = (*s).clone(); + steps[idx] = target.value(); + s.set(steps); + }) + }; + + let name = use_state(String::new); + + let nm = name.clone(); + let on_name_change = Callback::from(move |e: Event| { + let Some(target) = e.target() else { + return; + }; + + let Ok(target) = target.dyn_into::() else { + return; + }; + + nm.set(target.value()); + }); + + let error = use_state(|| Option::None::); + + let s = steps.clone(); + let ig = ingredients.clone(); + let rtg = current_rating.clone(); + let nm = name.clone(); + let err = error.clone(); + let token = global_state.token.token.clone(); + let household = global_state.household.id; + let pc = person_count.clone(); + let nav = use_navigator().unwrap(); + let new_rcp_submit = Callback::from(move |_| { + if nm.is_empty() { + err.set(Some("Name can't be empty".into())); + return; + } + + let s = s.clone(); + let ig = ig.clone(); + let rtg = rtg.clone(); + let nm = nm.clone(); + let err = err.clone(); + let token = token.clone(); + let pc = pc.clone(); + let nav = nav.clone(); + wasm_bindgen_futures::spawn_local(async move { + match do_create_recipe( + token, + household, + CreateRecipeRequest { + name: (*nm).clone(), + rating: (*rtg), + ingredients: ig + .iter() + .map(|rcp_ig: &RecipeIngredient| { + (rcp_ig.id, rcp_ig.amount as f64 / (*pc) as f64) + }) + .collect(), + steps: (*s).iter().cloned().collect(), + }, + ) + .await + { + Ok(id) => { + s.set(Default::default()); + ig.set(Default::default()); + rtg.set(Default::default()); + nm.set(Default::default()); + err.set(None); + + nav.push(&Route::Recipe { id }); + } + Err(e) => { + err.set(Some(format!("Error creating recipe: {e:?}"))); + } + } + }); + }); + + let pc = person_count.clone(); + let on_person_count_change = Callback::from(move |e: Event| { + let Some(target) = e.target() else { + return; + }; + + let Ok(target) = target.dyn_into::() else { + return; + }; + + if !target.report_validity() { + return; + } + + pc.set(target.value().parse().unwrap()); + }); + + html! { +
+
+

{"Create a new recipe"}

+ if let Some(e) = &*error { + + } +
+
+ + +
+
+ + +
+
+ {"Rating: "} +
+ + +
+
+ + +
+
+ + +
+
+
+

{"Ingredients"}

+ + + +
    + {for (*ingredients).iter().enumerate().map(|(idx, ig)| { + html!{ +
  • + {format!("{}{} {}", + ig.amount, + ig.info.unit.as_deref().unwrap_or(""), + ig.info.name, + )} + +
  • + } + }) + } +
+
+
+

{"Steps"}

+
    + {for (*steps).iter().enumerate().map(|(idx, step)| { + html!{ +
  • +