377 lines
13 KiB
Rust
377 lines
13 KiB
Rust
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<dyn Fn(RecipeIngredient)>,
|
|
#[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<i64> {
|
|
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::<RecipeIngredient>::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"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|