Make the GUI a library to support multiple platforms
This commit is contained in:
parent
d3f89a8757
commit
0d900024cb
24 changed files with 76 additions and 39 deletions
378
gui/src/recipe/creator.rs
Normal file
378
gui/src/recipe/creator.rs
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
use std::{marker::PhantomData, rc::Rc};
|
||||
|
||||
use api::{CreateRecipeRequest, CreateRecipeResponse, IngredientInfo};
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_router::prelude::*;
|
||||
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, Route,
|
||||
};
|
||||
|
||||
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 navigator = use_navigator(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,
|
||||
navigator
|
||||
];
|
||||
|
||||
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());
|
||||
|
||||
navigator.push(Route::RecipeView{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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue