use std::{marker::PhantomData, rc::Rc}; use api::{CreateRecipeRequest, CreateRecipeResponse, IngredientInfo}; use dioxus::prelude::*; use dioxus_router::use_router; use uuid::Uuid; use crate::{ api, bootstrap::{bs, FormModal, ModalBody, ModalFooter, ModalToggleButton, TitledModal}, ingredients::do_add_ingredient, recipe::IngredientSelect, use_error, use_trimmed_context, ErrorView, }; use super::RecipeIngredient; #[derive(Props)] struct IngredientAddProps<'a> { add: Rc, #[props(default = PhantomData)] _ph: PhantomData<&'a ()>, } fn IngredientCreate<'a>(cx: Scope<'a, IngredientAddProps>) -> Element<'a> { let error = use_error(cx); let (token, household) = use_trimmed_context(cx); let amount = use_state(cx, String::new); let unit = use_state(cx, String::new); let name = use_state(cx, String::new); let on_submit = move |_| { let am: f64 = match amount.parse() { Ok(v) if v >= 0. => v, _ => { error.set(Some("Amount must be a positive number".into())); return; } }; if name.is_empty() { error.set(Some("Name can't be empty".into())); return; } let on_add = cx.props.add.clone(); to_owned![token, name, unit, error, amount]; cx.spawn(async move { match do_add_ingredient(token, household, name.to_string(), unit.to_string()).await { Ok(rsp) => { (on_add)(RecipeIngredient { id: rsp.id, info: IngredientInfo { name: name.to_string(), unit: (!unit.is_empty()).then(|| unit.to_string()), }, amount: am, }); error.set(None); name.set(String::new()); unit.set(String::new()); amount.set(String::new()); let modal = bs::Modal::get_instance("#newRcpCreateIg"); modal.hide(); } Err(e) => { error.set(Some(format!("Could not add ingredient: {e}"))); } } }) }; cx.render(rsx! { FormModal { id: "newRcpCreateIg", fade: true, centered: true, submit_label: "Create & Add", title: "Create & Add ingredient", on_submit: on_submit, ErrorView { error: error } div { class: "form-floating", input { class: "form-control", id: "newRcpCreateIgNameInp", placeholder: "Name", value: "{name}", oninput: move |e| name.set(e.value.clone()) } label { "for": "newRcpCreateIgNameInp", "Ingredient Name" } } div { class: "form-floating my-1", input { class: "form-control", id: "newRcpCreateIgUnitInp", placeholder: "Unit", value: "{unit}", oninput: move |e| unit.set(e.value.clone()) } label { "for": "newRcpCreateIgUnitInp", "Ingredient Unit" } } div { class: "form-floating", input { class: "form-control", "type": "number", min: "0", id: "newRcpCreateIgAmountInp", placeholder: "Amount", value: "{amount}", oninput: move |e| amount.set(e.value.clone()) } label { "for": "newRcpCreateIgAmountInp", "Ingredient Amount" } } } }) } fn IngredientAdd<'a>(cx: Scope<'a, IngredientAddProps>) -> Element<'a> { let amount = use_state(cx, || Ok::<_, String>(None)); let selected_ingredient = use_state(cx, || Err::<(i64, IngredientInfo), _>("".to_string())); let refresh = use_state(cx, || 0u64); let error = use_error(cx); let add_ingredient = move |_| { let amount = match &**amount { &Ok(Some(v)) => v, Ok(None) => { error.set(Some("Amount must be a number".to_string())); return; } Err(e) => { error.set(Some(e.clone())); return; } }; let (id, info) = match &**selected_ingredient { Ok(v) => v.clone(), Err(e) => { error.set(Some(format!("Ingredient does not exist: '{e}'"))); return; } }; (cx.props.add)(RecipeIngredient { id, info, amount }); error.set(None); }; let create_ingredient = { let on_add = cx.props.add.clone(); to_owned![refresh]; Rc::new(move |ig| { (on_add)(ig); refresh.set(refresh.wrapping_add(1)); }) }; cx.render(rsx! { ErrorView { error: error } IngredientCreate { add: create_ingredient } IngredientSelect { on_amount_change: move |v| amount.set(v), on_ingredient_change: move |v| selected_ingredient.set(v), refresh: **refresh, button { class: "btn btn-primary me-1", onclick: add_ingredient, "Add" } ModalToggleButton { class: "btn btn-secondary", modal_id: "newRcpCreateIg", "Create" } } }) } 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) } pub fn RecipeCreator(cx: Scope) -> Element { let (token, household) = use_trimmed_context(cx); let error = use_error(cx); let name = use_state(cx, String::new); let current_rating = use_state(cx, || 0u8); let person_count_input = use_state(cx, || "1".to_string()); let person_count = use_memo(cx, &**person_count_input, |pc| pc.parse().unwrap_or(1)); let ingredients = use_ref(cx, Vec::::new); let steps = use_state(cx, String::new); let router = use_router(cx); let ingredient_list: Vec<_> = ingredients.with(|ig| { ig.iter().enumerate().map(move |(idx, ig)| { let ig = ig.clone(); rsx! { li { class: "list-group-item d-flex justify-content-between align-items-center", "{ig.amount}{ig.info.unit.as_deref().unwrap_or(\"\")} {ig.info.name}" button { class: "btn btn-danger", onclick: move |_| { ingredients .with_mut(|igs| { igs.remove(idx); }) }, "Remove" } } } }).collect() }); let add_ingredient = { to_owned![ingredients]; Rc::new(move |i| ingredients.with_mut(|ig| ig.push(i))) }; let new_rcp_submit = move |_| { if name.is_empty() { error.set(Some("Name can't be empty".into())); return; } to_owned![ token, current_rating, name, ingredients, person_count, steps, error, router ]; cx.spawn(async move { match do_create_recipe( token, household, CreateRecipeRequest { person_count, name: name.to_string(), rating: *current_rating, ingredients: ingredients.with(|ig| { ig.iter() .map(|i| (i.id, i.amount / (person_count as f64))) .collect() }), steps: steps.to_string(), }, ) .await { Ok(id) => { steps.set(Default::default()); ingredients.set(Default::default()); current_rating.set(Default::default()); name.set(Default::default()); error.set(Default::default()); router.navigate_to(&format!("/recipe/{id}")); } Err(e) => { error.set(Some(format!("Error creating recipe: {e:?}"))); } } }); }; cx.render(rsx! { div { class: "d-flex align-items-center justify-content-center w-100", div { class: "container rounded border py-2 m-2 text-center", h1 { "Create a new recipe" } ErrorView { error: error } hr {} div { class: "form-floating", input { id: "newRcpName", class: "form-control", placeholder: "Name", value: "{name}", oninput: move |e| name.set(e.value.clone()) } label { "for": "newRcpName", "Name" } } div { class: "form-floating", input { id: "newRcpPersonCount", class: "form-control", placeholder: "Person Count", "type": "number", min: "1", value: "{person_count_input}", oninput: move |e| person_count_input.set(e.value.clone()) } label { "for": "newRcpPersonCount", "Person Count" } } div { class: "pt-2", "Rating: " for (i , label) in ["Like", "Like a lot", "Love"].iter().enumerate() { div { class: "form-check form-check-inline", input { class: "form-check-input", "type": "radio", name: "ratingOptions", id: "rating{i}", checked: **current_rating == i as u8, oninput: move |_| current_rating.set(i as _) } label { class: "form-check-label", "for": "rating{i}", *label } } } } div { class: "d-flex flex-column justify-content-start", h2 { "Ingredients" } IngredientAdd { add: add_ingredient } ul { class: "list-group list-group-flush text-start", ingredient_list.into_iter() } } div { h2 { "Steps" } div {class: "text-start", textarea { class: "form-control", id: "steps-area", value: "{steps}", rows: "10", oninput: move |e| steps.set(e.value.clone()) } } } hr {} ModalToggleButton { class: "btn btn-lg btn-primary", modal_id: "newRcpModal", "Create Recipe" } TitledModal { id: "newRcpModal", fade: true, centered: true, title: "Create Recipe", ModalBody { "Do you confirm this recipe ?" } ModalFooter { button { "type": "button", class: "btn btn-secondary", "data-bs-dismiss": "modal", "Cancel" } button { "type": "button", class: "btn btn-primary", "data-bs-dismiss": "modal", onclick: new_rcp_submit, "Confirm" } } } } } }) }