regalade/app/src/recipe.rs

307 lines
9.2 KiB
Rust
Raw Normal View History

2023-06-25 16:11:26 +02:00
use api::{RecipeInfo, RecipeRenameRequest};
2023-06-25 14:12:09 +02:00
use uuid::Uuid;
2023-06-25 16:11:26 +02:00
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use yew::{
prelude::*,
suspense::{use_future, use_future_with_deps},
};
2023-06-25 14:12:09 +02:00
use yew_router::prelude::*;
2023-06-25 16:11:26 +02:00
use crate::{
api,
bootstrap::{FormModal, ModalToggleButton, bs},
RegaladeGlobalState, Route,
};
2023-06-25 14:12:09 +02:00
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">
2023-06-25 15:13:15 +02:00
<div class="row row-cols-2 row-cols-sm-2 row-cols-md-4 g-2 mb-2">
2023-06-25 14:12:09 +02:00
{for l.recipes.iter().map(|(id, name)| 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>>
</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>
}
}
2023-06-25 15:13:15 +02:00
#[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<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(rsp.json().await?)
}
2023-06-25 16:11:26 +02:00
#[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>
</>}
}
2023-06-25 15:13:15 +02:00
#[function_component]
fn RecipeViewerInner(props: &RecipeViewerInnerProps) -> HtmlResult {
2023-06-25 16:11:26 +02:00
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));
});
2023-06-25 15:13:15 +02:00
Ok(match &*recipe {
Ok(r) => html! {<>
<h1>{&r.name}</h1>
2023-06-25 16:11:26 +02:00
<EditName
token={props.token.clone()}
id={props.id}
household={props.household}
name={r.name.clone()}
{update}
/>
2023-06-25 15:13:15 +02:00
<hr />
<div class="text-start">
<h2>{"Ingredients"}</h2>
<ul class="list-group">
{for r.ingredients.iter().map(|(id, info, amount)| html!{
<li key={*id} class="list-group-item">
{format!("{amount}{} {}", info.unit.as_deref().unwrap_or(""), info.name)}
</li>
})}
</ul>
</div>
<hr />
<div class="text-start">
<h2>{"Steps"}</h2>
<ul class="list-group list-group-flush">
{for r.steps.iter().map(|text| html!{
<li class="list-group-item">{text}</li>
})}
</ul>
</div>
</>},
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>
}
}