1101 lines
33 KiB
Rust
1101 lines
33 KiB
Rust
use std::rc::Rc;
|
|
|
|
use api::{
|
|
AddRecipeIngredientRequest, IngredientInfo, RecipeEditPersonCount, RecipeEditRating,
|
|
RecipeEditStepsRequest, RecipeInfo, RecipeIngredientEditRequest, RecipeRenameRequest,
|
|
};
|
|
use itertools::Itertools;
|
|
use uuid::Uuid;
|
|
use wasm_bindgen::JsCast;
|
|
use web_sys::{HtmlInputElement, HtmlTextAreaElement};
|
|
use yew::{
|
|
prelude::*,
|
|
suspense::{use_future, use_future_with_deps},
|
|
};
|
|
use yew_router::prelude::*;
|
|
|
|
use crate::{
|
|
api,
|
|
bootstrap::{bs, ConfirmDangerModal, FormModal, ModalToggleButton},
|
|
recipe_creator::IngredientSelectBase,
|
|
RegaladeGlobalState, Route,
|
|
};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Properties)]
|
|
struct RecipeRatingProps {
|
|
rating: u8,
|
|
}
|
|
|
|
#[function_component]
|
|
fn RecipeRating(props: &RecipeRatingProps) -> Html {
|
|
let rating = (props.rating + 1).min(3);
|
|
html! {
|
|
<span aria-label={format!("Rating: {rating}")} class="ms-1">
|
|
{ for (0..rating).map(|_| html!{
|
|
<i aria-hidden="true" class="bi-star-fill ms-1"/>
|
|
}) }
|
|
</span>
|
|
}
|
|
}
|
|
|
|
async fn get_all_recipes(
|
|
token: String,
|
|
household: Uuid,
|
|
) -> anyhow::Result<api::ListRecipesResponse> {
|
|
let rsp = gloo_net::http::Request::get(api!("household/{household}/recipe"))
|
|
.header("Authorization", &format!("Bearer {token}"))
|
|
.send()
|
|
.await?;
|
|
|
|
if !rsp.ok() {
|
|
let body = rsp.text().await.unwrap_or_default();
|
|
anyhow::bail!("Could not get recipes (code={}): {body}", rsp.status());
|
|
}
|
|
|
|
Ok(rsp.json().await?)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Properties)]
|
|
struct RecipeListProps {
|
|
token: String,
|
|
household: Uuid,
|
|
}
|
|
|
|
#[function_component]
|
|
fn RecipeListInner(props: &RecipeListProps) -> HtmlResult {
|
|
let state = use_future(|| get_all_recipes(props.token.clone(), props.household))?;
|
|
|
|
Ok(match &*state {
|
|
Ok(l) => html! {
|
|
<div class="container text-center">
|
|
<div class="row row-cols-2 row-cols-sm-2 row-cols-md-4 g-2 mb-2">
|
|
{for l.recipes.iter().sorted_by_key(|(_, name, _)| name).map(|(id, name, rating)| html!{
|
|
<div class="col" key={*id}>
|
|
<div class="p-3 border rounded border-light-subtle h-100">
|
|
<Link<Route>
|
|
classes={classes!(
|
|
"link-light",
|
|
"link-offset-2",
|
|
"link-underline-opacity-25",
|
|
"link-underline-opacity-100-hover",
|
|
)}
|
|
to={Route::Recipe { id: *id }}
|
|
>
|
|
{name}
|
|
</Link<Route>>
|
|
<RecipeRating {rating} />
|
|
</div>
|
|
</div>
|
|
})}
|
|
</div>
|
|
</div>
|
|
},
|
|
Err(e) => html! {
|
|
<div class={classes!("alert", "alert-danger")} role="alert">
|
|
{format!("Error fetching recipes: {e}")}
|
|
</div>
|
|
},
|
|
})
|
|
}
|
|
|
|
#[function_component]
|
|
pub fn RecipeList() -> Html {
|
|
let fallback = html! {"Loading ..."};
|
|
let global_state = use_state(RegaladeGlobalState::get);
|
|
|
|
html! {
|
|
<div class="d-flex align-items-center justify-content-center w-100">
|
|
<div class={classes!("container", "text-center", "rounded", "border", "pt-2", "m-2")}>
|
|
<h2>{"Recipes"}</h2>
|
|
<Suspense {fallback}>
|
|
<RecipeListInner
|
|
token={global_state.token.token.clone()}
|
|
household={global_state.household.id}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
#[derive(PartialEq, Eq, Clone, Properties)]
|
|
pub struct RecipeViewerProps {
|
|
pub id: i64,
|
|
}
|
|
|
|
#[derive(PartialEq, Eq, Clone, Properties)]
|
|
struct RecipeViewerInnerProps {
|
|
id: i64,
|
|
token: String,
|
|
household: Uuid,
|
|
}
|
|
|
|
async fn fetch_recipe(token: String, household: Uuid, id: i64) -> anyhow::Result<Rc<RecipeInfo>> {
|
|
let rsp = gloo_net::http::Request::get(api!("household/{household}/recipe/{id}"))
|
|
.header("Authorization", &format!("Bearer {token}"))
|
|
.send()
|
|
.await?;
|
|
|
|
if !rsp.ok() {
|
|
let body = rsp.text().await.unwrap_or_default();
|
|
anyhow::bail!("Could not get recipe (code={}): {body}", rsp.status());
|
|
}
|
|
|
|
Ok(Rc::new(rsp.json().await?))
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Properties)]
|
|
struct EditNameProps {
|
|
token: String,
|
|
household: Uuid,
|
|
id: i64,
|
|
name: String,
|
|
update: Callback<()>,
|
|
}
|
|
|
|
async fn do_rename_recipe(
|
|
token: String,
|
|
household: Uuid,
|
|
recipe: i64,
|
|
name: String,
|
|
) -> anyhow::Result<()> {
|
|
let rsp = gloo_net::http::Request::patch(api!("household/{household}/recipe/{recipe}"))
|
|
.json(&RecipeRenameRequest { name })?
|
|
.header("Authorization", &format!("Bearer {token}"))
|
|
.send()
|
|
.await?;
|
|
|
|
if !rsp.ok() {
|
|
let body = rsp.text().await.unwrap_or_default();
|
|
anyhow::bail!("Could not get recipes (code={}): {body}", rsp.status());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[function_component]
|
|
fn EditName(props: &EditNameProps) -> Html {
|
|
let name = use_state(|| props.name.clone());
|
|
|
|
let nm = name.clone();
|
|
let onchange = Callback::from(move |e: Event| {
|
|
let Some(target) = e.target() else {
|
|
return;
|
|
};
|
|
|
|
let Ok(target) = target.dyn_into::<HtmlInputElement>() else {
|
|
return;
|
|
};
|
|
|
|
nm.set(target.value());
|
|
});
|
|
|
|
let error = use_state(|| None::<String>);
|
|
|
|
let nm = name.clone();
|
|
let err = error.clone();
|
|
let token = props.token.clone();
|
|
let household = props.household;
|
|
let recipe = props.id;
|
|
let update = props.update.clone();
|
|
let on_submit = Callback::from(move |_| {
|
|
if nm.is_empty() {
|
|
err.set(Some("Name can't be empty".into()));
|
|
return;
|
|
}
|
|
|
|
let future = do_rename_recipe(token.clone(), household, recipe, (*nm).clone());
|
|
|
|
let err = err.clone();
|
|
let update = update.clone();
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match future.await {
|
|
Ok(_) => {
|
|
let modal = bs::Modal::get_instance("#rcpEditName");
|
|
modal.hide();
|
|
|
|
err.set(None);
|
|
update.emit(());
|
|
}
|
|
Err(e) => {
|
|
err.set(Some(format!("Could not edit name: {e}")));
|
|
}
|
|
}
|
|
})
|
|
});
|
|
|
|
html! {<>
|
|
<ModalToggleButton classes={classes!("btn", "btn-secondary")} modal_id="rcpEditName">
|
|
{"Edit name"}
|
|
</ModalToggleButton>
|
|
<FormModal
|
|
id="rcpEditName"
|
|
fade=true
|
|
centered=true
|
|
submit_label="Edit"
|
|
title="Edit Name"
|
|
{on_submit}
|
|
>
|
|
if let Some(e) = &*error {
|
|
<div class={classes!("alert", "alert-danger")} role="alert">
|
|
{e}
|
|
</div>
|
|
}
|
|
<div class="form-floating">
|
|
<input
|
|
class="form-control"
|
|
id="rcpEditNameInp"
|
|
placeholder={(*name).clone()}
|
|
value={(*name).clone()}
|
|
{onchange}
|
|
/>
|
|
<label for="rcpEditNameInp">{"Recipe Name"}</label>
|
|
</div>
|
|
</FormModal>
|
|
</>}
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Properties)]
|
|
struct EditIngredientProps {
|
|
token: String,
|
|
household: Uuid,
|
|
recipe: i64,
|
|
ingredient: i64,
|
|
amount: f64,
|
|
update: Callback<()>,
|
|
}
|
|
|
|
async fn do_edit_ingredient_recipe(
|
|
token: String,
|
|
household: Uuid,
|
|
recipe: i64,
|
|
ingredient: i64,
|
|
amount: f64,
|
|
) -> anyhow::Result<()> {
|
|
let rsp = gloo_net::http::Request::patch(api!(
|
|
"household/{household}/recipe/{recipe}/ingredients/{ingredient}"
|
|
))
|
|
.json(&RecipeIngredientEditRequest { amount })?
|
|
.header("Authorization", &format!("Bearer {token}"))
|
|
.send()
|
|
.await?;
|
|
|
|
if !rsp.ok() {
|
|
let body = rsp.text().await.unwrap_or_default();
|
|
anyhow::bail!("Could not get recipes (code={}): {body}", rsp.status());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[function_component]
|
|
fn EditIngredient(props: &EditIngredientProps) -> Html {
|
|
let amount = use_state(|| props.amount);
|
|
|
|
let am = amount.clone();
|
|
let onchange = Callback::from(move |e: Event| {
|
|
let Some(target) = e.target() else {
|
|
return;
|
|
};
|
|
|
|
let Ok(target) = target.dyn_into::<HtmlInputElement>() else {
|
|
return;
|
|
};
|
|
|
|
am.set(target.value().parse().unwrap());
|
|
});
|
|
|
|
let error = use_state(|| None::<String>);
|
|
|
|
let modal_id = format!("rcpEditIg{}", props.ingredient);
|
|
|
|
let am = amount.clone();
|
|
let err = error.clone();
|
|
let token = props.token.clone();
|
|
let household = props.household;
|
|
let recipe = props.recipe;
|
|
let ingredient = props.ingredient;
|
|
let update = props.update.clone();
|
|
let mid = modal_id.clone();
|
|
let on_submit = Callback::from(move |_| {
|
|
let future = do_edit_ingredient_recipe(token.clone(), household, recipe, ingredient, *am);
|
|
|
|
let err = err.clone();
|
|
let update = update.clone();
|
|
let modal_selector = format!("#{mid}");
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match future.await {
|
|
Ok(_) => {
|
|
let modal = bs::Modal::get_instance(&modal_selector);
|
|
modal.hide();
|
|
|
|
err.set(None);
|
|
update.emit(());
|
|
}
|
|
Err(e) => {
|
|
err.set(Some(format!("Could not edit ingredient: {e}")));
|
|
}
|
|
}
|
|
})
|
|
});
|
|
|
|
let input_id = format!("{modal_id}Inp");
|
|
|
|
html! {<>
|
|
<FormModal
|
|
id={modal_id.clone()}
|
|
fade=true
|
|
centered=true
|
|
submit_label="Edit"
|
|
title="Edit Ingredient"
|
|
{on_submit}
|
|
>
|
|
if let Some(e) = &*error {
|
|
<div class={classes!("alert", "alert-danger")} role="alert">
|
|
{e}
|
|
</div>
|
|
}
|
|
<div class="form-floating">
|
|
<input
|
|
class="form-control"
|
|
id={input_id.clone()}
|
|
placeholder="Ingredient Amount"
|
|
value={amount.to_string()}
|
|
type="number"
|
|
step="any"
|
|
{onchange}
|
|
/>
|
|
<label for={input_id}>{"Ingredient Amount"}</label>
|
|
</div>
|
|
</FormModal>
|
|
<ModalToggleButton {modal_id}>
|
|
<i class="bi-pencil-fill" />
|
|
</ModalToggleButton>
|
|
</>}
|
|
}
|
|
|
|
async fn do_add_ingredient_recipe(
|
|
token: String,
|
|
household: Uuid,
|
|
recipe: i64,
|
|
ingredient: i64,
|
|
amount: f64,
|
|
) -> anyhow::Result<()> {
|
|
let rsp = gloo_net::http::Request::put(api!(
|
|
"household/{household}/recipe/{recipe}/ingredients/{ingredient}"
|
|
))
|
|
.json(&AddRecipeIngredientRequest { amount })?
|
|
.header("Authorization", &format!("Bearer {token}"))
|
|
.send()
|
|
.await?;
|
|
|
|
if !rsp.ok() {
|
|
let body = rsp.text().await.unwrap_or_default();
|
|
anyhow::bail!("Could not get recipes (code={}): {body}", rsp.status());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Properties)]
|
|
struct AddIngredientProps {
|
|
token: String,
|
|
household: Uuid,
|
|
recipe: i64,
|
|
update: Callback<()>,
|
|
}
|
|
|
|
#[function_component]
|
|
fn AddIngredientInner(props: &AddIngredientProps) -> HtmlResult {
|
|
let error = use_state(|| None::<String>);
|
|
|
|
let amount = use_state(|| None::<f64>);
|
|
let selected_ig = use_state(|| None::<(i64, IngredientInfo)>);
|
|
|
|
let s_ig = selected_ig.clone();
|
|
let am = amount.clone();
|
|
let err = error.clone();
|
|
let token = props.token.clone();
|
|
let household = props.household;
|
|
let recipe = props.recipe;
|
|
let update = props.update.clone();
|
|
let on_submit = Callback::from(move |_| match &*s_ig {
|
|
&Some((id, _)) => match &*am {
|
|
&Some(amount) => {
|
|
let fut = do_add_ingredient_recipe(token.clone(), household, recipe, id, amount);
|
|
|
|
let am = am.clone();
|
|
let s_ig = s_ig.clone();
|
|
let err = err.clone();
|
|
let update = update.clone();
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match fut.await {
|
|
Ok(_) => {
|
|
err.set(None);
|
|
am.set(None);
|
|
s_ig.set(None);
|
|
update.emit(());
|
|
|
|
let modal = bs::Modal::get_instance("#rcpEditNewIg");
|
|
modal.hide();
|
|
}
|
|
Err(e) => {
|
|
err.set(Some(format!("Could not add ingredient: {e}")));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
None => {
|
|
err.set(Some("Amount can't be empty".into()));
|
|
}
|
|
},
|
|
None => {
|
|
err.set(Some("Ingredient does not exist".into()));
|
|
}
|
|
});
|
|
|
|
let on_ig_change = {
|
|
let selected_ig = selected_ig.clone();
|
|
Callback::from(move |v| {
|
|
selected_ig.set(v);
|
|
})
|
|
};
|
|
let on_amount_change = {
|
|
let amount = amount.clone();
|
|
Callback::from(move |v| {
|
|
amount.set(Some(v));
|
|
})
|
|
};
|
|
|
|
Ok({
|
|
html! {<>
|
|
<FormModal
|
|
id="rcpEditNewIg"
|
|
fade=true
|
|
centered=true
|
|
submit_label="Add"
|
|
{on_submit}
|
|
title="Add ingredient"
|
|
>
|
|
if let Some(e) = &*error {
|
|
<div class={classes!("alert", "alert-danger")} role="alert">
|
|
{e}
|
|
</div>
|
|
}
|
|
<IngredientSelectBase
|
|
token={props.token.clone()}
|
|
household={props.household}
|
|
{on_ig_change}
|
|
{on_amount_change}
|
|
amount={*amount}
|
|
ig_select={(*selected_ig).as_ref().map(|(_, info)| AttrValue::from(info.name.clone()))}
|
|
/>
|
|
</FormModal>
|
|
<ModalToggleButton modal_id="rcpEditNewIg" classes={classes!("btn", "btn-secondary")}>
|
|
{"Add Ingredient"}
|
|
</ModalToggleButton>
|
|
</>}
|
|
})
|
|
}
|
|
|
|
#[function_component]
|
|
fn AddIngredient(props: &AddIngredientProps) -> Html {
|
|
let fallback = html! {
|
|
<div class="spinner-border" role="status">
|
|
<span class="visually-hidden">{"Loading ..."}</span>
|
|
</div>
|
|
};
|
|
|
|
html! {<>
|
|
<Suspense {fallback}>
|
|
<AddIngredientInner
|
|
token={props.token.clone()}
|
|
household={props.household}
|
|
recipe={props.recipe}
|
|
update={props.update.clone()}
|
|
/>
|
|
</Suspense>
|
|
</>}
|
|
}
|
|
|
|
async fn do_delete_ig(
|
|
token: String,
|
|
household: Uuid,
|
|
recipe: i64,
|
|
ingredient: i64,
|
|
) -> anyhow::Result<()> {
|
|
let rsp = gloo_net::http::Request::delete(api!(
|
|
"household/{household}/recipe/{recipe}/ingredients/{ingredient}"
|
|
))
|
|
.header("Authorization", &format!("Bearer {token}"))
|
|
.send()
|
|
.await?;
|
|
|
|
if !rsp.ok() {
|
|
let body = rsp.text().await.unwrap_or_default();
|
|
anyhow::bail!("Could not get recipes (code={}): {body}", rsp.status());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn do_edit_steps(
|
|
token: String,
|
|
household: Uuid,
|
|
recipe: i64,
|
|
steps: String,
|
|
) -> anyhow::Result<()> {
|
|
let rsp = gloo_net::http::Request::patch(api!("household/{household}/recipe/{recipe}/steps"))
|
|
.json(&RecipeEditStepsRequest { steps })?
|
|
.header("Authorization", &format!("Bearer {token}"))
|
|
.send()
|
|
.await?;
|
|
|
|
if !rsp.ok() {
|
|
let body = rsp.text().await.unwrap_or_default();
|
|
anyhow::bail!("Could not get recipes (code={}): {body}", rsp.status());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Properties, PartialEq, Clone)]
|
|
struct EditStepsProps {
|
|
token: String,
|
|
household: Uuid,
|
|
recipe: i64,
|
|
steps: String,
|
|
update: Callback<()>,
|
|
}
|
|
|
|
#[function_component]
|
|
fn EditSteps(props: &EditStepsProps) -> Html {
|
|
let steps = use_state(|| props.steps.clone());
|
|
|
|
let error = use_state(|| None::<String>);
|
|
|
|
let onchange = {
|
|
let steps = steps.clone();
|
|
Callback::from(move |e: Event| {
|
|
let Some(target) = e.target() else {
|
|
return;
|
|
};
|
|
|
|
let Ok(target) = target.dyn_into::<HtmlTextAreaElement>() else {
|
|
return;
|
|
};
|
|
|
|
steps.set(target.value());
|
|
})
|
|
};
|
|
let on_submit = {
|
|
let steps = steps.clone();
|
|
let token = props.token.clone();
|
|
let household = props.household;
|
|
let recipe = props.recipe;
|
|
let error = error.clone();
|
|
let update = props.update.clone();
|
|
Callback::from(move |_| {
|
|
let token = token.clone();
|
|
let steps = steps.clone();
|
|
let error = error.clone();
|
|
let update = update.clone();
|
|
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match do_edit_steps(token.clone(), household, recipe, (*steps).clone()).await {
|
|
Ok(_) => {
|
|
let modal = bs::Modal::get_instance("#rcpEditSteps");
|
|
modal.hide();
|
|
error.set(None);
|
|
update.emit(());
|
|
}
|
|
Err(e) => {
|
|
error.set(Some(format!("Could not edit steps: {e}")));
|
|
}
|
|
}
|
|
});
|
|
})
|
|
};
|
|
|
|
html! {<>
|
|
<ModalToggleButton modal_id="rcpEditSteps" classes={classes!("btn", "btn-secondary", "mb-2")}>
|
|
{"Edit Steps"}
|
|
</ModalToggleButton>
|
|
<FormModal
|
|
id="rcpEditSteps"
|
|
fade=true
|
|
centered=true
|
|
submit_label="Edit"
|
|
title="Edit steps"
|
|
{on_submit}
|
|
>
|
|
if let Some(e) = &*error {
|
|
<div class={classes!("alert", "alert-danger")} role="alert">
|
|
{e}
|
|
</div>
|
|
}
|
|
<textarea
|
|
class="form-control"
|
|
value={(*steps).clone()}
|
|
{onchange}
|
|
/>
|
|
</FormModal>
|
|
</>}
|
|
}
|
|
|
|
async fn do_edit_rating(
|
|
token: String,
|
|
household: Uuid,
|
|
recipe: i64,
|
|
rating: u8,
|
|
) -> anyhow::Result<()> {
|
|
let rsp = gloo_net::http::Request::patch(api!("household/{household}/recipe/{recipe}/rating"))
|
|
.json(&RecipeEditRating { rating })?
|
|
.header("Authorization", &format!("Bearer {token}"))
|
|
.send()
|
|
.await?;
|
|
|
|
if !rsp.ok() {
|
|
let body = rsp.text().await.unwrap_or_default();
|
|
anyhow::bail!("Could not edit rating (code={}): {body}", rsp.status());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Properties, PartialEq, Clone)]
|
|
struct EditRatingProps {
|
|
token: String,
|
|
household: Uuid,
|
|
recipe: i64,
|
|
rating: u8,
|
|
update: Callback<()>,
|
|
}
|
|
|
|
#[function_component]
|
|
fn EditRating(props: &EditRatingProps) -> Html {
|
|
let rating = use_state(|| props.rating);
|
|
|
|
let error = use_state(|| None::<String>);
|
|
|
|
let onchange = {
|
|
let rating = rating.clone();
|
|
Callback::from(move |e: Event| {
|
|
let Some(target) = e.target() else {
|
|
return;
|
|
};
|
|
|
|
let Ok(target) = target.dyn_into::<HtmlInputElement>() else {
|
|
return;
|
|
};
|
|
|
|
if !target.report_validity() {
|
|
return;
|
|
}
|
|
|
|
rating.set(target.value().parse().expect("invalid number"));
|
|
})
|
|
};
|
|
let on_submit = {
|
|
let rating = rating.clone();
|
|
let token = props.token.clone();
|
|
let household = props.household;
|
|
let recipe = props.recipe;
|
|
let error = error.clone();
|
|
let update = props.update.clone();
|
|
Callback::from(move |_| {
|
|
let token = token.clone();
|
|
let rating = rating.clone();
|
|
let error = error.clone();
|
|
let update = update.clone();
|
|
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match do_edit_rating(token.clone(), household, recipe, *rating - 1).await {
|
|
Ok(_) => {
|
|
let modal = bs::Modal::get_instance("#rcpEditRating");
|
|
modal.hide();
|
|
error.set(None);
|
|
update.emit(());
|
|
}
|
|
Err(e) => {
|
|
error.set(Some(format!("Could not edit rating: {e}")));
|
|
}
|
|
}
|
|
});
|
|
})
|
|
};
|
|
|
|
html! {<>
|
|
<ModalToggleButton modal_id="rcpEditRating" classes={classes!("btn", "btn-secondary", "ms-2")}>
|
|
{"Edit Rating"}
|
|
</ModalToggleButton>
|
|
<FormModal
|
|
id="rcpEditRating"
|
|
fade=true
|
|
centered=true
|
|
submit_label="Edit"
|
|
title="Edit rating"
|
|
{on_submit}
|
|
>
|
|
if let Some(e) = &*error {
|
|
<div class={classes!("alert", "alert-danger")} role="alert">
|
|
{e}
|
|
</div>
|
|
}
|
|
<div class="form-floating">
|
|
<input
|
|
type="number"
|
|
max="3"
|
|
min="1"
|
|
class="form-control"
|
|
id="rcpEditRatingInp"
|
|
value={(*rating).to_string()}
|
|
{onchange}
|
|
/>
|
|
<label for="rcpEditRatingInp">{"Rating"}</label>
|
|
</div>
|
|
</FormModal>
|
|
</>}
|
|
}
|
|
|
|
async fn do_edit_person_count(
|
|
token: String,
|
|
household: Uuid,
|
|
recipe: i64,
|
|
person_count: u32,
|
|
) -> anyhow::Result<()> {
|
|
let rsp =
|
|
gloo_net::http::Request::patch(api!("household/{household}/recipe/{recipe}/person_count"))
|
|
.json(&RecipeEditPersonCount { person_count })?
|
|
.header("Authorization", &format!("Bearer {token}"))
|
|
.send()
|
|
.await?;
|
|
|
|
if !rsp.ok() {
|
|
let body = rsp.text().await.unwrap_or_default();
|
|
anyhow::bail!(
|
|
"Could not edit person_count (code={}): {body}",
|
|
rsp.status()
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Properties, PartialEq, Clone)]
|
|
struct EditPersonCountProps {
|
|
token: String,
|
|
household: Uuid,
|
|
recipe: i64,
|
|
person_count: u32,
|
|
}
|
|
|
|
#[function_component]
|
|
fn EditPersonCount(props: &EditPersonCountProps) -> Html {
|
|
let person_count = use_state(|| props.person_count);
|
|
|
|
let error = use_state(|| None::<String>);
|
|
|
|
let onchange = {
|
|
let person_count = person_count.clone();
|
|
Callback::from(move |e: Event| {
|
|
let Some(target) = e.target() else {
|
|
return;
|
|
};
|
|
|
|
let Ok(target) = target.dyn_into::<HtmlInputElement>() else {
|
|
return;
|
|
};
|
|
|
|
if !target.report_validity() {
|
|
return;
|
|
}
|
|
|
|
person_count.set(target.value().parse().expect("invalid number"));
|
|
})
|
|
};
|
|
let on_submit = {
|
|
let person_count = person_count.clone();
|
|
let token = props.token.clone();
|
|
let household = props.household;
|
|
let recipe = props.recipe;
|
|
let error = error.clone();
|
|
Callback::from(move |_| {
|
|
let token = token.clone();
|
|
let person_count = person_count.clone();
|
|
let error = error.clone();
|
|
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match do_edit_person_count(token.clone(), household, recipe, *person_count)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
let modal = bs::Modal::get_instance("#rcpEditPersonCount");
|
|
modal.hide();
|
|
error.set(None);
|
|
}
|
|
Err(e) => {
|
|
error.set(Some(format!("Could not edit person count: {e}")));
|
|
}
|
|
}
|
|
});
|
|
})
|
|
};
|
|
|
|
html! {<>
|
|
<ModalToggleButton modal_id="rcpEditPersonCount" classes={classes!("btn", "btn-secondary")}>
|
|
{"Edit"}
|
|
</ModalToggleButton>
|
|
<FormModal
|
|
id="rcpEditPersonCount"
|
|
fade=true
|
|
centered=true
|
|
submit_label="Edit"
|
|
title="Edit default person count"
|
|
{on_submit}
|
|
>
|
|
if let Some(e) = &*error {
|
|
<div class={classes!("alert", "alert-danger")} role="alert">
|
|
{e}
|
|
</div>
|
|
}
|
|
<div class="form-floating">
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
class="form-control"
|
|
id="rcpEditPersonCountInp"
|
|
value={(*person_count).to_string()}
|
|
{onchange}
|
|
/>
|
|
<label for="rcpEditPersonCountInp">{"Default person count"}</label>
|
|
</div>
|
|
</FormModal>
|
|
</>}
|
|
}
|
|
|
|
#[derive(Properties, PartialEq, Clone)]
|
|
struct RecipeInfoProps {
|
|
token: String,
|
|
household: Uuid,
|
|
update: Callback<()>,
|
|
recipe_id: i64,
|
|
info: Rc<RecipeInfo>,
|
|
}
|
|
|
|
#[function_component]
|
|
fn RecipeInfoView(props: &RecipeInfoProps) -> Html {
|
|
let error = use_state(|| None::<String>);
|
|
let mk_del_ig = |&id| {
|
|
let update = props.update.clone();
|
|
let token = props.token.clone();
|
|
let household = props.household;
|
|
let recipe = props.recipe_id;
|
|
let err = error.clone();
|
|
Callback::from(move |_| {
|
|
let update = update.clone();
|
|
let future = do_delete_ig(token.clone(), household, recipe, id);
|
|
let err = err.clone();
|
|
wasm_bindgen_futures::spawn_local(async move {
|
|
match future.await {
|
|
Err(e) => {
|
|
err.set(Some(format!("Could not delete ingredient: {e}")));
|
|
}
|
|
Ok(_) => {
|
|
update.emit(());
|
|
err.set(None);
|
|
}
|
|
}
|
|
})
|
|
})
|
|
};
|
|
|
|
let person_count = use_state(|| props.info.person_count);
|
|
let onchange = {
|
|
let person_count = person_count.clone();
|
|
Callback::from(move |e: Event| {
|
|
let Some(target) = e.target() else {
|
|
return;
|
|
};
|
|
|
|
let Ok(target) = target.dyn_into::<HtmlInputElement>() else {
|
|
return;
|
|
};
|
|
|
|
let Ok(pc) = target.value().parse() else {
|
|
return;
|
|
};
|
|
|
|
person_count.set(pc);
|
|
})
|
|
};
|
|
|
|
html! {<>
|
|
<h1>{&props.info.name} <RecipeRating rating={props.info.rating} /> </h1>
|
|
<div class="mt-2">
|
|
<EditName
|
|
token={props.token.clone()}
|
|
id={props.recipe_id}
|
|
household={props.household}
|
|
name={props.info.name.clone()}
|
|
update={props.update.clone()}
|
|
/>
|
|
<EditRating
|
|
token={props.token.clone()}
|
|
recipe={props.recipe_id}
|
|
household={props.household}
|
|
rating={props.info.rating + 1}
|
|
update={props.update.clone()}
|
|
/>
|
|
</div>
|
|
<div class="mt-2 container text-start">
|
|
<div class="row">
|
|
<div class="col-8">
|
|
<div class="input-group">
|
|
<input
|
|
class="form-control"
|
|
type="number"
|
|
id="rcpPeopleCount"
|
|
min="1"
|
|
value={person_count.to_string()}
|
|
{onchange}
|
|
/>
|
|
<span class="input-group-text">{"people"}</span>
|
|
</div>
|
|
</div>
|
|
<div class="col">
|
|
<EditPersonCount
|
|
token={props.token.clone()}
|
|
recipe={props.recipe_id}
|
|
household={props.household}
|
|
person_count={props.info.person_count}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
if let Some(e) = &*error {
|
|
<div class={classes!("alert", "alert-danger")} role="alert">
|
|
{e}
|
|
</div>
|
|
}
|
|
<hr />
|
|
<div class="text-start">
|
|
<h2>{"Ingredients"}</h2>
|
|
<ul class="list-group mb-2">
|
|
{for props.info.ingredients.iter().map(|(id, info, amount)| {
|
|
let delete_modal_id = format!("rcpRmIg{id}");
|
|
let amount_rounded = (amount * (*person_count) as f64).round();
|
|
html!{
|
|
<li
|
|
key={*id}
|
|
class="list-group-item d-flex justify-content-between align-items-center"
|
|
>
|
|
{format!("{amount_rounded}{} {}", info.unit.as_deref().unwrap_or(""), info.name)}
|
|
<div>
|
|
<ConfirmDangerModal
|
|
id={delete_modal_id.clone()}
|
|
title="Remove ingredient"
|
|
centered=true
|
|
on_confirm={mk_del_ig(id)}
|
|
>
|
|
{format!("Are you sure you to delete '{}'", info.name)}
|
|
</ConfirmDangerModal>
|
|
<ModalToggleButton
|
|
modal_id={delete_modal_id}
|
|
classes={classes!("btn", "btn-danger", "me-1")}
|
|
>
|
|
<i class="bi-trash3" />
|
|
</ModalToggleButton>
|
|
<EditIngredient
|
|
token={props.token.clone()}
|
|
recipe={props.recipe_id}
|
|
ingredient={*id}
|
|
household={props.household}
|
|
amount={*amount}
|
|
update={props.update.clone()}
|
|
/>
|
|
</div>
|
|
</li>
|
|
}})}
|
|
</ul>
|
|
<AddIngredient
|
|
token={props.token.clone()}
|
|
household={props.household}
|
|
recipe={props.recipe_id}
|
|
update={props.update.clone()}
|
|
/>
|
|
</div>
|
|
<hr />
|
|
<div class="text-start">
|
|
<h2>{"Steps"}</h2>
|
|
<ul class="list-group list-group-flush">
|
|
{for props.info.steps.split('\n').map(|text| html!{
|
|
<li class="list-group-item">{text}</li>
|
|
})}
|
|
</ul>
|
|
<EditSteps
|
|
token={props.token.clone()}
|
|
household={props.household}
|
|
recipe={props.recipe_id}
|
|
steps={props.info.steps.clone()}
|
|
update={props.update.clone()}
|
|
/>
|
|
</div>
|
|
</>}
|
|
}
|
|
|
|
#[function_component]
|
|
fn RecipeViewerInner(props: &RecipeViewerInnerProps) -> HtmlResult {
|
|
let recipe_render = use_state(|| 0u64);
|
|
let recipe = use_future_with_deps(
|
|
|_| fetch_recipe(props.token.clone(), props.household, props.id),
|
|
*recipe_render,
|
|
)?;
|
|
|
|
let update = Callback::from(move |_| {
|
|
recipe_render.set((*recipe_render).wrapping_add(1));
|
|
});
|
|
|
|
Ok(match &*recipe {
|
|
Ok(r) => {
|
|
html! {
|
|
<RecipeInfoView
|
|
token={props.token.clone()}
|
|
recipe_id={props.id}
|
|
info={r.clone()}
|
|
household={props.household}
|
|
{update}
|
|
/>
|
|
}
|
|
}
|
|
Err(e) => html! {
|
|
<div class={classes!("alert", "alert-danger")} role="alert">
|
|
{format!("Error fetching recipe: {e}")}
|
|
</div>
|
|
},
|
|
})
|
|
}
|
|
|
|
#[function_component]
|
|
pub fn RecipeViewer(props: &RecipeViewerProps) -> Html {
|
|
let global_state = use_state(RegaladeGlobalState::get);
|
|
let fallback = html! {
|
|
<div class="spinner-border" role="status">
|
|
<span class="visually-hidden">{"Loading ..."}</span>
|
|
</div>
|
|
};
|
|
|
|
html! {
|
|
<div class="d-flex align-items-center justify-content-center w-100">
|
|
<div class={classes!("container", "text-center", "rounded", "border", "pt-2", "m-2")}>
|
|
<Suspense {fallback}>
|
|
<RecipeViewerInner
|
|
id={props.id}
|
|
token={global_state.token.token.clone()}
|
|
household={global_state.household.id}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|