app: Swap to using dioxus instead of yew

This commit is contained in:
traxys 2023-07-03 23:20:19 +02:00
parent 02a4187c39
commit c80cc99255
29 changed files with 3558 additions and 3639 deletions

1164
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
[package] [package]
name = "app" name = "dioxus_app"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
@ -9,17 +9,15 @@ edition = "2021"
anyhow = "1.0.71" anyhow = "1.0.71"
api = { version = "0.1.0", path = "../api" } api = { version = "0.1.0", path = "../api" }
console_log = { version = "1.0.0", features = ["color"] } console_log = { version = "1.0.0", features = ["color"] }
gloo-net = "0.2.6" dioxus = "0.3.2"
dioxus-class = "0.3.0"
dioxus-router = { version = "0.3.0", features = ["web"] }
dioxus-web = "0.3.2"
gloo-net = { version = "0.3.0", features = ["json"] }
gloo-storage = "0.2.2" gloo-storage = "0.2.2"
gloo-utils = "0.1.6" gloo-utils = "0.1.7"
im = "15.1.0" itertools = "0.11.0"
itertools = "0.10.5" log = "0.4.19"
log = "0.4.17" serde = { version = "1.0.164", features = ["derive"] }
serde = { version = "1.0.163", features = ["derive"] } uuid = "1.4.0"
serde_json = "1.0.96" wasm-bindgen = "0.2.87"
uuid = "1.3.3"
wasm-bindgen = "0.2.86"
wasm-bindgen-futures = "0.4.36"
web-sys = "0.3.63"
yew = { version = "0.20.0", features = ["csr"] }
yew-router = "0.17.0"

26
app/Dioxus.toml Normal file
View file

@ -0,0 +1,26 @@
[application]
name = "Regalade"
default_platform = "web"
out_dir = "dist"
asset_dir = "public"
[web.app]
title = "Regalade"
[web.watcher]
reload_html = true
watch_path = ["src", "public"]
index_on_404 = true
[web.resource]
style = [
"style.css",
"awesomplete.css",
"/bootstrap/css/bootstrap.min.css",
"/bootstrap-icons/font/bootstrap-icons.min.css",
]
script = ["/bootstrap/js/bootstrap.bundle.min.js"]
[web.resource.dev]
script = []

View file

@ -1,7 +0,0 @@
[[hooks]]
stage = "build"
command = "./dl_bootstrap.sh"
[[hooks]]
stage = "build"
command = "./dl_bootstrap_icons.sh"

View file

@ -1,17 +0,0 @@
#!/usr/bin/env bash
VERSION=5.3.0-alpha3
URL=https://github.com/twbs/bootstrap/releases/download/v${VERSION}/bootstrap-${VERSION}-dist.zip
if [[ ! -d "$TRUNK_DIST_DIR/bootstrap" ]]; then
cd "$TRUNK_STAGING_DIR" || {
echo "Can't cd to staging directory"
exit 1
}
wget "$URL"
unzip bootstrap-$VERSION-dist.zip
rm bootstrap-$VERSION-dist.zip
mv bootstrap-$VERSION-dist bootstrap
else
cp -r "$TRUNK_DIST_DIR/bootstrap" "$TRUNK_STAGING_DIR"
fi

View file

@ -1,17 +0,0 @@
#!/usr/bin/env bash
VERSION=1.10.5
URL=https://github.com/twbs/icons/releases/download/v${VERSION}/bootstrap-icons-${VERSION}.zip
if [[ ! -d "$TRUNK_DIST_DIR/bootstrap-icons" ]]; then
cd "$TRUNK_STAGING_DIR" || {
echo "Can't cd to staging directory"
exit 1
}
wget "$URL"
unzip bootstrap-icons-$VERSION.zip
rm bootstrap-icons-$VERSION.zip
mv bootstrap-icons-$VERSION bootstrap-icons
else
cp -r "$TRUNK_DIST_DIR/bootstrap-icons" "$TRUNK_STAGING_DIR"
fi

36
app/dl_deps.sh Executable file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env sh
CURDIR=$(dirname "$0")
PUBLIC=$(realpath "$CURDIR"/public)
BS_VERSION=5.3.0-alpha3
BS_URL=https://github.com/twbs/bootstrap/releases/download/v${BS_VERSION}/bootstrap-${BS_VERSION}-dist.zip
if [ ! -d "$PUBLIC/bootstrap" ]; then
cd "$PUBLIC" || {
echo "Can't cd to public ($PUBLIC)"
exit 1
}
wget "$BS_URL"
bs_name=bootstrap-$BS_VERSION-dist
unzip $bs_name.zip
rm $bs_name.zip
mv $bs_name bootstrap
fi
BS_I_VERSION=1.10.5
BS_I_URL=https://github.com/twbs/icons/releases/download/v${BS_I_VERSION}/bootstrap-icons-${BS_I_VERSION}.zip
if [ ! -d "$PUBLIC/bootstrap-icons" ]; then
cd "$PUBLIC" || {
echo "Can't cd to public ($PUBLIC)"
exit 1
}
wget "$BS_I_URL"
bs_i_name=bootstrap-icons-$BS_I_VERSION
unzip $bs_i_name.zip
rm $bs_i_name.zip
mv $bs_i_name bootstrap-icons
fi

View file

@ -1,22 +0,0 @@
<!DOCTYPE html>
<html data-bs-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
<link
href="/bootstrap-icons/font/bootstrap-icons.min.css"
rel="stylesheet"
/>
<link data-trunk rel="css" href="static/style.css" />
<link data-trunk rel="copy-file" href="static/login.css" />
<link data-trunk rel="copy-file" href="static/household_selection.css" />
<link data-trunk rel="copy-file" href="static/awesomplete.min.js.map" />
<link data-trunk rel="css" href="static/awesomplete.css" />
<link data-trunk rel="copy-file" href="static/awesomplete.min.js" />
</head>
<body>
<main></main>
<script src="/bootstrap/js/bootstrap.bundle.min.js"></script>
</body>
</html>

2
app/public/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/bootstrap-icons
/bootstrap

View file

@ -1,10 +1,10 @@
html, html,
body, body,
main { #main {
height: 100%; height: 100%;
} }
main { #main {
display: flex; display: flex;
align-items: center; align-items: center;
padding-top: 40px; padding-top: 40px;

View file

@ -1,10 +1,10 @@
html, html,
body, body,
main { #main {
height: 100%; height: 100%;
} }
main { #main {
display: flex; display: flex;
align-items: center; align-items: center;
padding-top: 40px; padding-top: 40px;

View file

@ -1,257 +0,0 @@
use yew::prelude::*;
pub mod bs {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(js_namespace = bootstrap)]
extern "C" {
pub type Modal;
#[wasm_bindgen(static_method_of = Modal, js_name = "getInstance")]
pub fn get_instance(selector: &str) -> Modal;
#[wasm_bindgen(static_method_of = Modal, js_name = "getOrCreateInstance")]
pub fn get_or_create_instance(selector: &str) -> Modal;
#[wasm_bindgen(method)]
pub fn hide(this: &Modal);
#[wasm_bindgen(method)]
pub fn show(this: &Modal);
}
}
#[derive(Properties, PartialEq)]
pub struct ModalProps {
pub id: AttrValue,
#[prop_or(true)]
pub fade: bool,
#[prop_or_default]
pub centered: bool,
#[prop_or_default]
pub labeled_by: Option<AttrValue>,
pub children: Children,
}
#[function_component]
pub fn Modal(props: &ModalProps) -> Html {
let mut class = classes!("modal");
if props.fade {
class.push("fade");
}
let mut dialog_class = classes!("modal-dialog");
if props.centered {
dialog_class.push("modal-dialog-centered");
}
html! {
<div
{class}
id={props.id.clone()}
tabindex="-1"
aria-labelledby={props.labeled_by.clone()}
aria-hidden="true"
>
<div class={dialog_class}>
<div class="modal-content">
{ for props.children.iter() }
</div>
</div>
</div>
}
}
#[derive(Properties, PartialEq)]
pub struct ConfirmDangerModalProps {
pub id: AttrValue,
#[prop_or(true)]
pub fade: bool,
#[prop_or_default]
pub centered: bool,
pub title: AttrValue,
pub on_confirm: Callback<()>,
pub children: Children,
}
#[function_component]
pub fn ConfirmDangerModal(
ConfirmDangerModalProps {
id,
fade,
centered,
title,
children,
on_confirm,
}: &ConfirmDangerModalProps,
) -> Html {
let on_confirm = on_confirm.clone();
html! {
<TitledModal {id} {fade} {centered} {title}>
<ModalBody>
{ for children.iter() }
</ModalBody>
<ModalFooter>
<button type="button" class={classes!("btn", "btn-secondary")} data-bs-dismiss="modal">
{"Cancel"}
</button>
<button
type="button"
class={classes!("btn", "btn-danger")}
data-bs-dismiss="modal"
onclick={Callback::from(move |_| on_confirm.emit(()))}
>
{"Confirm"}
</button>
</ModalFooter>
</TitledModal>
}
}
#[derive(Properties, PartialEq)]
pub struct TitledModalProps {
pub id: AttrValue,
#[prop_or(true)]
pub fade: bool,
#[prop_or_default]
pub centered: bool,
pub title: AttrValue,
pub children: Children,
}
#[function_component]
pub fn TitledModal(
TitledModalProps {
id,
fade,
centered,
children,
title,
}: &TitledModalProps,
) -> Html {
let label = format!("{id}Label");
html! {
<Modal {id} {fade} {centered} labeled_by={label.clone()}>
<ModalHeader>
<h1 class={classes!("modal-title", "fs-5")} id={label}>{title}</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
>
</button>
</ModalHeader>
{ for children.iter() }
</Modal>
}
}
#[derive(PartialEq, Properties)]
pub struct FormModalProps {
pub id: AttrValue,
#[prop_or(true)]
pub fade: bool,
#[prop_or_default]
pub centered: bool,
#[prop_or("Submit".into())]
pub submit_label: AttrValue,
pub on_submit: Callback<()>,
pub title: AttrValue,
pub children: Children,
}
#[function_component]
pub fn FormModal(
FormModalProps {
id,
fade,
centered,
submit_label,
title,
on_submit,
children,
}: &FormModalProps,
) -> Html {
let form_id = format!("{id}Form");
let on_submit = on_submit.clone();
let onsubmit = Callback::from(move |e: SubmitEvent| {
e.prevent_default();
on_submit.emit(());
});
html! {
<TitledModal {id} {fade} {centered} {title}>
<ModalBody>
<form id={form_id.clone()} {onsubmit}>
{ for children.iter() }
</form>
</ModalBody>
<ModalFooter>
<button type="button" class={classes!("btn", "btn-danger")} data-bs-dismiss="modal">
{"Cancel"}
</button>
<button type="submit" class={classes!("btn", "btn-primary")} form={form_id}>
{submit_label}
</button>
</ModalFooter>
</TitledModal>
}
}
#[derive(Properties, PartialEq)]
pub struct ModalToggleProps {
#[prop_or(classes!("btn", "btn-primary"))]
pub classes: Classes,
pub modal_id: AttrValue,
pub children: Children,
}
#[function_component]
pub fn ModalToggleButton(props: &ModalToggleProps) -> Html {
html! {
<button
class={props.classes.clone()}
data-bs-toggle="modal"
data-bs-target={format!("#{}", props.modal_id)}
>
{ for props.children.iter() }
</button>
}
}
#[derive(Properties, PartialEq)]
pub struct ModalContentProps {
pub children: Children,
}
#[function_component]
pub fn ModalHeader(props: &ModalContentProps) -> Html {
html! {
<div class="modal-header">
{ for props.children.iter() }
</div>
}
}
#[function_component]
pub fn ModalBody(props: &ModalContentProps) -> Html {
html! {
<div class="modal-body">
{ for props.children.iter() }
</div>
}
}
#[function_component]
pub fn ModalFooter(props: &ModalContentProps) -> Html {
html! {
<div class="modal-footer">
{ for props.children.iter() }
</div>
}
}

231
app/src/bootstrap/mod.rs Normal file
View file

@ -0,0 +1,231 @@
use dioxus::prelude::*;
use dioxus_class::prelude::*;
pub mod bs {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(js_namespace = bootstrap)]
extern "C" {
pub type Modal;
#[wasm_bindgen(static_method_of = Modal, js_name = "getInstance")]
pub fn get_instance(selector: &str) -> Modal;
#[wasm_bindgen(static_method_of = Modal, js_name = "getOrCreateInstance")]
pub fn get_or_create_instance(selector: &str) -> Modal;
#[wasm_bindgen(method)]
pub fn hide(this: &Modal);
#[wasm_bindgen(method)]
pub fn show(this: &Modal);
}
}
pub fn Spinner(cx: Scope) -> Element {
cx.render(rsx! {
div { class: "spinner-border", role: "status", span { class: "visually-hidden", "Loading" } }
})
}
#[derive(Props)]
pub struct ModalContentProps<'a> {
pub children: Element<'a>,
}
pub fn ModalHeader<'a>(cx: Scope<'a, ModalContentProps<'a>>) -> Element {
cx.render(rsx! {
div { class: "modal-header", &cx.props.children }
})
}
pub fn ModalBody<'a>(cx: Scope<'a, ModalContentProps<'a>>) -> Element {
cx.render(rsx! {
div { class: "modal-body", &cx.props.children }
})
}
pub fn ModalFooter<'a>(cx: Scope<'a, ModalContentProps<'a>>) -> Element {
cx.render(rsx! {
div { class: "modal-footer", &cx.props.children }
})
}
#[derive(Props)]
pub struct ModalProps<'a> {
#[props(into)]
pub id: String,
#[props(default = false)]
pub fade: bool,
#[props(default = false)]
pub centered: bool,
#[props(into)]
pub labeled_by: Option<String>,
pub children: Element<'a>,
}
pub fn Modal<'a>(cx: Scope<'a, ModalProps<'a>>) -> Element<'a> {
let mut classes = Class::from(vec!["modal"]);
if cx.props.fade {
classes.append("fade");
}
let mut dialog_class = Class::from(vec!["modal-dialog"]);
if cx.props.centered {
dialog_class.append("modal-dialog-centered");
}
cx.render(rsx! {
div { class: classes, id: cx.props.id.as_str(), tabindex: "-1", "aria-labelledby": cx.props.labeled_by.as_deref(), "aria-hidden": "true",
div { class: dialog_class,
div { class: "modal-content", &cx.props.children }
}
}
})
}
#[derive(Props)]
pub struct TitledModalProps<'a> {
#[props(into)]
pub id: String,
#[props(default = false)]
pub fade: bool,
#[props(default = false)]
pub centered: bool,
#[props(into)]
pub title: String,
pub children: Element<'a>,
}
pub fn TitledModal<'a>(cx: Scope<'a, TitledModalProps<'a>>) -> Element {
cx.render(rsx! {
Modal {
id: &cx.props.id,
fade: cx.props.fade,
centered: cx.props.centered,
labeled_by: "{cx.props.id}Label",
ModalHeader {
h1 { class: "modal-title fs-5", id: "{cx.props.id}Label", cx.props.title.as_str() }
button {
"type": "button",
class: "btn-close",
"data-bs-dismiss": "modal",
"aria-label": "Close"
}
}
&cx.props.children
}
})
}
#[derive(Props)]
pub struct FormModalProps<'a> {
#[props(into)]
pub id: String,
#[props(default = false)]
pub fade: bool,
#[props(default = false)]
pub centered: bool,
#[props(into)]
pub title: String,
#[props(into, default = "Submit".into())]
pub submit_label: String,
pub on_submit: EventHandler<'a, FormEvent>,
pub children: Element<'a>,
}
pub fn FormModal<'a>(cx: Scope<'a, FormModalProps<'a>>) -> Element {
cx.render(rsx! {
TitledModal {
id: &cx.props.id,
fade: cx.props.fade,
centered: cx.props.centered,
title: &cx.props.title,
ModalBody {
form {
id: "{cx.props.id}Form",
prevent_default: "onsubmit",
onsubmit: move |ev| cx.props.on_submit.call(ev),
&cx.props.children
}
}
ModalFooter {
button {
"type": "button",
class: "btn btn-danger",
"data-bs-dismiss": "modal",
"Cancel"
}
button {
"type": "submit",
class: "btn btn-primary",
form: "{cx.props.id}Form",
cx.props.submit_label.as_str()
}
}
}
})
}
#[derive(Props)]
pub struct ConfirmDangerModalProps<'a> {
#[props(into)]
pub id: String,
#[props(default = false)]
pub fade: bool,
#[props(default = false)]
pub centered: bool,
#[props(into)]
pub title: String,
pub on_confirm: EventHandler<'a, MouseEvent>,
pub children: Element<'a>,
}
pub fn ConfirmDangerModal<'a>(cx: Scope<'a, ConfirmDangerModalProps<'a>>) -> Element {
cx.render(rsx! {
TitledModal {
id: &cx.props.id,
fade: cx.props.fade,
centered: cx.props.centered,
title: &cx.props.title,
ModalBody { &cx.props.children }
ModalFooter {
button {
"type": "button",
class: "btn btn-secondary",
"data-bs-dismiss": "modal",
"Cancel"
}
button {
"type": "button",
class: "btn btn-danger",
"data-bs-dismiss": "modal",
onclick: move |ev| cx.props.on_confirm.call(ev),
"Confirm"
}
}
}
})
}
#[derive(Props)]
pub struct ModalToggleProps<'a> {
#[props(into)]
pub class: String,
#[props(into)]
pub modal_id: String,
pub children: Element<'a>,
}
pub fn ModalToggleButton<'a>(cx: Scope<'a, ModalToggleProps<'a>>) -> Element {
cx.render(rsx! {
button {
class: cx.props.class.as_str(),
"data-bs-toggle": "modal",
"data-bs-target": "#{cx.props.modal_id}",
&cx.props.children
}
})
}

189
app/src/full_context.rs Normal file
View file

@ -0,0 +1,189 @@
use std::{
cell::{Cell, Ref, RefCell},
collections::HashSet,
rc::Rc,
sync::Arc,
};
use dioxus::prelude::*;
use dioxus_router::use_router;
use gloo_storage::{errors::StorageError, LocalStorage, Storage};
use uuid::Uuid;
use crate::{HouseholdInfo, LoginInfo, RedirectorProps};
pub struct RefreshHandle {
run: Box<dyn FnOnce()>,
}
impl RefreshHandle {
pub fn refresh(self) {
(self.run)()
}
}
#[derive(Copy, Clone)]
pub struct FullContextState<'a> {
root: &'a ProvidedFullContext,
value: &'a Rc<RefCell<FullContext>>,
}
impl<'a> FullContextState<'a> {
pub fn read(&self) -> Ref<'_, FullContext> {
self.value.borrow()
}
pub fn refresh(&self) {
let r = self.root.borrow();
r.needs_regen.set(true);
(r.update_root)();
}
pub fn refresh_handle(&self) -> RefreshHandle {
let r = self.root.clone();
RefreshHandle {
run: Box::new(move || {
let root = r.borrow();
root.needs_regen.set(true);
(root.update_root)();
}),
}
}
}
struct FullContextStateInner {
root: ProvidedFullContext,
value: Rc<RefCell<FullContext>>,
scope_id: ScopeId,
}
impl Drop for FullContextStateInner {
fn drop(&mut self) {
let mut root = self.root.borrow_mut();
root.consumers.remove(&self.scope_id);
}
}
pub fn use_full_context(cx: &ScopeState) -> FullContextState {
let state = cx.use_hook(|| {
let scope_id = cx.scope_id();
let root = cx
.consume_context::<ProvidedFullContext>()
.expect("Called use_full_context not in a full context scope");
let mut r = root.borrow_mut();
r.consumers.insert(scope_id);
let value = r.value.clone();
drop(r);
FullContextStateInner {
root,
value,
scope_id,
}
});
FullContextState {
root: &state.root,
value: &state.value,
}
}
pub fn use_trimmed_context(cx: &ScopeState) -> (String, Uuid) {
let binding = use_full_context(cx);
let ctx = binding.read();
(ctx.login.token.clone(), ctx.household.id)
}
#[derive(Clone)]
pub struct FullContext {
pub login: LoginInfo,
pub household: HouseholdInfo,
}
type ProvidedFullContext = Rc<RefCell<ProvidedFullContextInner>>;
struct ProvidedFullContextInner {
value: Rc<RefCell<FullContext>>,
notify_any: Arc<dyn Fn(ScopeId)>,
consumers: HashSet<ScopeId>,
needs_regen: Cell<bool>,
update_root: Arc<dyn Fn()>,
}
impl ProvidedFullContextInner {
fn notify_consumers(&mut self) {
for &consumer in &self.consumers {
(self.notify_any)(consumer)
}
}
}
fn use_full_context_setter(cx: &ScopeState) {
let gen = || {
let login = LocalStorage::get::<LoginInfo>("token").expect("Not called in a full context");
let household =
LocalStorage::get::<HouseholdInfo>("household").expect("Not called in a full context");
FullContext { login, household }
};
let hook = cx.use_hook(move || {
let state = Rc::new(RefCell::new(ProvidedFullContextInner {
value: Rc::new(RefCell::new(gen())),
consumers: HashSet::new(),
notify_any: cx.schedule_update_any(),
update_root: cx.schedule_update(),
needs_regen: Cell::new(false),
}));
cx.provide_context(state.clone());
state
});
if hook.borrow().needs_regen.get() {
let mut hook = (**hook).borrow_mut();
*(*hook.value).borrow_mut() = gen();
hook.notify_consumers();
}
}
fn FullContextRedirectInner<'a>(cx: Scope<'a, RedirectorProps<'a>>) -> Element {
use_full_context_setter(cx);
cx.render(rsx! {&cx.props.children})
}
pub fn FullContextRedirect<'a>(cx: Scope<'a, RedirectorProps<'a>>) -> Element {
let router = use_router(cx);
let check_token = match LocalStorage::get::<LoginInfo>("token") {
Ok(_) => true,
Err(StorageError::KeyNotFound(_)) => {
router.navigate_to("/login");
false
}
Err(e) => unreachable!("Could not get token: {e:?}"),
};
let check_household = match LocalStorage::get::<HouseholdInfo>("household") {
Ok(_) => true,
Err(StorageError::KeyNotFound(_)) => {
router.navigate_to("/household_selection");
false
}
Err(e) => unreachable!("Could not get household: {e:?}"),
};
if check_token && check_household {
cx.render(rsx! {
FullContextRedirectInner { &cx.props.children }
})
} else {
None
}
}

View file

@ -1,28 +1,31 @@
use api::{CreateIngredientRequest, EditIngredientRequest, IngredientInfo, CreateIngredientResponse}; use api::{
CreateIngredientRequest, CreateIngredientResponse, EditIngredientRequest, IngredientInfo,
};
use dioxus::prelude::*;
use itertools::Itertools; use itertools::Itertools;
use uuid::Uuid; use uuid::Uuid;
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use yew::{prelude::*, suspense::use_future_with_deps};
use crate::{ use crate::{
api, api,
bootstrap::{bs, FormModal}, bootstrap::{bs, FormModal, Spinner},
RegaladeGlobalState, use_error, use_trimmed_context, ErrorAlert, ErrorView,
}; };
pub async fn fetch_ingredients(token: String, household: Uuid) -> anyhow::Result<api::IngredientList> { pub async fn fetch_ingredients(
token: String,
household: Uuid,
) -> anyhow::Result<api::IngredientList> {
let rsp = gloo_net::http::Request::get(api!("household/{household}/ingredients")) let rsp = gloo_net::http::Request::get(api!("household/{household}/ingredients"))
.header("Authorization", &format!("Bearer {token}")) .header("Authorization", &format!("Bearer {token}"))
.send() .send()
.await?; .await?;
if !rsp.ok() { if !rsp.ok() {
let body = rsp.body(); anyhow::bail!(
match body { "Could not fetch ingredients (status:{}): {}",
None => anyhow::bail!("Could not fetch ingredients: {rsp:?}"), rsp.status(),
Some(b) => anyhow::bail!("Could not fetch ingredients: {}", b.to_string()), rsp.text().await?
} );
} }
Ok(rsp.json().await?) Ok(rsp.json().await?)
@ -73,170 +76,135 @@ async fn do_delete_ingredient(token: String, household: Uuid, id: i64) -> anyhow
Ok(()) Ok(())
} }
#[derive(Properties, PartialEq, Eq)] #[inline_props]
struct IngredientListProps { pub fn IngredientList(cx: Scope, render_id: u64) -> Element {
token: String, let (token, household) = use_trimmed_context(cx);
household: Uuid, let fetch_id = use_state(cx, || 0u64);
render_id: u64, let future = use_future(cx, &((*render_id) as u128 | (**fetch_id) as u128), |_| {
} fetch_ingredients(token.clone(), household)
});
let error = use_error(cx);
let modal_error = use_error(cx);
#[function_component] let edit_name = use_state(cx, String::new);
fn IngredientList(props: &IngredientListProps) -> HtmlResult { let edit_unit = use_state(cx, String::new);
let fetch_id = use_state(|| 0u64); let edit_id = use_state(cx, || None);
let ingredients = use_future_with_deps( let item_edit = |&id, current: IngredientInfo| {
|_| fetch_ingredients(props.token.clone(), props.household), to_owned![edit_name, edit_unit, edit_id];
(*fetch_id as u128) << 64 | props.render_id as u128, move |_| {
)?; edit_id.set(Some(id));
let error = use_state(|| None::<String>); edit_name.set(current.name.clone());
edit_unit.set(current.unit.clone().unwrap_or_default());
let edit_state = use_state(|| None); }
let es = edit_state.clone();
let item_edit = |id, current: IngredientInfo| {
let es = es.clone();
Callback::from(move |_| {
es.set(Some((id, current.clone())));
})
}; };
let es = edit_state.clone(); let tk = token.clone();
let token = props.token.clone(); let on_edit_ig = move |_| {
let household = props.household; let &id = match edit_id.get() {
let err = error.clone(); Some(i) => i,
let fetch = fetch_id.clone(); None => {
let on_submit = Callback::from(move |()| { error.set(Some("Internal error: no ingredient id".into()));
if let Some((id, _)) = &*es { return;
let document = gloo_utils::document(); }
};
let name: HtmlInputElement = document to_owned![fetch_id, edit_name, edit_unit, tk, modal_error, household];
.get_element_by_id("editIgName") cx.spawn(async move {
.unwrap() match do_edit_ingredient(
.dyn_into() tk,
.expect("editIgName is not an input element"); household,
let name = name.value(); id,
edit_name.to_string(),
let unit: HtmlInputElement = document edit_unit.to_string(),
.get_element_by_id("editIgUnit") )
.unwrap() .await
.dyn_into() {
.expect("editIgUnit is not an input element"); Ok(_) => {
let unit = unit.value(); fetch_id.set(fetch_id.wrapping_add(1));
let modal = bs::Modal::get_instance("#editIgModal");
let token = token.clone(); modal.hide();
let id = *id;
let err = err.clone();
let fetch = fetch.clone();
wasm_bindgen_futures::spawn_local(async move {
match do_edit_ingredient(token, household, id, name, unit).await {
Ok(_) => {
let modal = bs::Modal::get_instance("#editIgModal");
modal.hide();
fetch.set(*fetch + 1);
}
Err(e) => err.set(Some(format!("Could not edit ingredient: {e:?}"))),
} }
}); Err(e) => {
} modal_error.set(Some(format!("Could not edit ingredient: {e:?}")));
}); }
}
});
};
let global_error = use_state(|| None::<String>); let delete_ig = |&id| {
let token = props.token.clone(); to_owned![token];
let err = global_error.clone(); move |_| {
let item_delete = move |id| { to_owned![fetch_id, error, token];
let fetch = fetch_id.clone();
let err = err.clone();
let token = token.clone();
Callback::from(move |_| { cx.spawn(async move {
let fetch = fetch.clone();
let err = err.clone();
let token = token.clone();
wasm_bindgen_futures::spawn_local(async move {
match do_delete_ingredient(token, household, id).await { match do_delete_ingredient(token, household, id).await {
Ok(_) => { Ok(_) => fetch_id.set(fetch_id.wrapping_add(1)),
fetch.set(*fetch + 1); Err(e) => error.set(Some(format!("Could not delete ingredient: {e:?}"))),
}
Err(e) => err.set(Some(format!("Could not edit ingredient: {e:?}"))),
} }
}) })
}) }
}; };
Ok(match &*ingredients { cx.render(match future.value() {
Ok(l) => html! {<> Some(Err(e)) => rsx! { ErrorAlert { error: "Could not fetch ingredients: {e}" } },
if let Some(err) = &*global_error { Some(Ok(ingredients)) => rsx! {
<div class={classes!("alert", "alert-danger")} role="alert"> ErrorView { error: error }
{err} ul { class: "list-group list-group-flush text-start",
</div> for (id , info) in ingredients.ingredients.iter().sorted_by_key(|(&k, _)| k) {
} li { key: "{id}", class: "list-group-item d-flex align-items-center",
<ul class="list-group list-group-flush text-start"> p { class: "flex-fill m-auto",
{ for l.ingredients.iter().sorted_by_key(|(&k,_)| k).map(|(&k,i)| { "{info.name}"
html! { if let Some(unit) = &info.unit {
<li class="list-group-item d-flex align-items-center" key={k}> format!(" (unit: {unit})")
<p class="flex-fill m-auto"> }
{&i.name} }
if let Some(unit) = &i.unit { button {
{format!(" (unit: {unit})")} "type": "button",
} class: "btn btn-primary",
</p> "data-bs-toggle": "modal",
<button "data-bs-target": "#editIgModal",
type="button" onclick: item_edit(id, info.clone()),
class="btn btn-primary" i { class: "bi-pencil-fill" }
onclick={item_edit(k, i.clone())} }
data-bs-toggle="modal" button {
data-bs-target="#editIgModal" "type": "button",
> class: "btn btn-danger ms-1",
<i class={classes!("bi-pencil-fill")}></i> onclick: delete_ig(id),
</button> i { class: "bi-trash-fill" }
<button }
type="button"
class="btn btn-danger ms-1"
onclick={item_delete(k)}
>
<i class={classes!("bi-trash-fill")}></i>
</button>
</li>
} }
})
} }
</ul> }
<FormModal FormModal {
centered={true} centered: true,
id="editIgModal" id: "editIgModal",
submit_label="Edit" submit_label: "Edit",
title="Edit Ingredient" title: "Edit ingredient",
{on_submit} on_submit: on_edit_ig,
> ErrorView { error: error }
if let Some(err) = &*error { div { class: "form-floating",
<div class={classes!("alert", "alert-danger")} role="alert"> input {
{err} id: "editIgName",
</div> class: "form-control",
placeholder: "Ingredient name",
value: "{edit_name}",
oninput: move |e| edit_name.set(e.value.clone())
}
label { "for": "editIgName", "Ingredient name" }
} }
<div class="form-floating"> div { class: "form-floating",
<input input {
id="editIgName" id: "editIgUnit",
class={classes!("form-control")} class: "form-control",
placeholder="Ingredient Name" placeholder: "Ingredient unit",
value={edit_state.as_ref().map(|s| s.1.name.clone())} value: "{edit_unit}",
/> oninput: move |e| edit_unit.set(e.value.clone())
<label for="editIgName">{"Ingredient name"}</label> }
</div> label { "for": "editIgUnit", "Ingredient unit" }
<div class="form-floating"> }
<input }
id="editIgUnit"
class={classes!("form-control")}
placeholder="Ingredient Unit"
value={edit_state.as_ref().map(|s| s.1.unit.clone().unwrap_or_default())}
/>
<label for="editIgUnit">{"Ingredient unit"}</label>
</div>
</FormModal>
</>},
Err(e) => html! {
{format!("Error fetching ingredients: {e:?}")}
}, },
None => rsx! { Spinner {} },
}) })
} }
@ -266,97 +234,61 @@ pub async fn do_add_ingredient(
Ok(rsp.json().await?) Ok(rsp.json().await?)
} }
#[function_component] pub fn Ingredients(cx: Scope) -> Element {
pub fn Ingredients() -> Html { let (token, household) = use_trimmed_context(cx);
let fallback = html! { {"Loading..."} }; let render_id = use_state(cx, || 0u64);
let global_state = use_state(RegaladeGlobalState::get); let error = use_error(cx);
let render_id = use_state(|| 0u64); let add_ingredient = move |ev: FormEvent| {
let error = use_state(|| None::<String>); let name = ev.values["newIgName"].to_string();
let unit = ev.values["newIgUnit"].to_string();
let token = global_state.token.token.clone();
let household = global_state.household.id;
let err = error.clone();
let render = render_id.clone();
let onsubmit = Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let document = gloo_utils::document();
let name_elem: HtmlInputElement = document
.get_element_by_id("newIgName")
.unwrap()
.dyn_into()
.expect("editIgName is not an input element");
let name = name_elem.value();
let unit_elem: HtmlInputElement = document
.get_element_by_id("newIgUnit")
.unwrap()
.dyn_into()
.expect("editIgUnit is not an input element");
let unit = unit_elem.value();
if name.is_empty() && unit.is_empty() { if name.is_empty() && unit.is_empty() {
return; return;
} }
let token = token.clone(); to_owned![token, error, render_id];
let err = err.clone();
let render = render.clone();
wasm_bindgen_futures::spawn_local(async move { cx.spawn(async move {
match do_add_ingredient(token, household, name, unit).await { match do_add_ingredient(token, household, name, unit).await {
Ok(_) => { Err(e) => {
name_elem.set_value(""); error.set(Some(format!("Could not add ingredient: {e}")));
unit_elem.set_value(""); }
render.set(*render + 1); Ok(_) => {
render_id.set(render_id.wrapping_add(1));
} }
Err(e) => err.set(Some(format!("Could not add ingredient: {e:?}"))),
} }
}); })
}); };
html! { cx.render(rsx! {
<div class="d-flex align-items-center justify-content-center w-100"> div { class: "d-flex align-items-center justify-content-center w-100",
<div class={classes!("container", "text-center", "rounded", "border", "pt-2", "m-2")}> div { class: "container text-center rounded border pt-2 m-2",
<form {onsubmit}> form { prevent_default: "onsubmit", onsubmit: add_ingredient,
if let Some(err) = &*error { ErrorView { error: error }
<div class={classes!("alert", "alert-danger")} role="alert"> div { class: "form-floating",
{err} input {
</div> name: "newIgName",
id: "newIgName",
placeholder: "Ingredient name",
class: "form-control"
}
label { "for": "newIgName", "Ingredient name" }
} }
<div class="form-floating"> div { class: "form-floating my-1",
<input input {
type="text" name: "newIgUnit",
class="form-control" id: "newIgUnit",
placeholder="Ingredient Name" placeholder: "Ingredient unit",
id="newIgName" class: "form-control"
/> }
<label for="newIgName">{"Ingredient Name"}</label> label { "for": "newIgUnit", "Ingredient unit" }
</div> }
<div class="form-floating my-1"> button { class: "btn btn-primary mt-2", "Add Ingredient" }
<input }
type="text" hr {}
class="form-control" IngredientList { render_id: *render_id.get() }
placeholder="Ingredient Unit" }
id="newIgUnit" }
/> })
<label for="newIgUnit">{"Ingredient Unit"}</label>
</div>
<button class="btn btn-primary mt-2">
{"Add Ingredient"}
</button>
</form>
<hr />
<Suspense {fallback}>
<IngredientList
token={global_state.token.token.clone()}
household={global_state.household.id}
render_id={*render_id}
/>
</Suspense>
</div>
</div>
}
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

373
app/src/recipe/creator.rs Normal file
View file

@ -0,0 +1,373 @@
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" }
textarea {
class: "form-control",
value: "{steps}",
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"
}
}
}
}
}
})
}

56
app/src/recipe/list.rs Normal file
View file

@ -0,0 +1,56 @@
use dioxus::prelude::*;
use dioxus_router::Link;
use itertools::Itertools;
use uuid::Uuid;
use crate::{api, bootstrap::Spinner, recipe::RecipeRating, use_trimmed_context, ErrorAlert};
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?)
}
pub fn RecipeList(cx: Scope) -> Element {
let (token, household) = use_trimmed_context(cx);
let recipes = use_future(cx, (), |_| get_all_recipes(token, household));
cx.render(match recipes.value() {
Some(Ok(recipes)) => rsx! {
div { class: "d-flex align-items-center justify-content-center w-100",
div { class: "container text-center rounded border pt-2 m-2",
h2 { "Recipes" }
div { class: "container text-center",
div { class: "row row-cols-2 row-cols-sm-2 row-cols-md-4 g-2 mb-2",
for (id , name , rating) in recipes.recipes.iter().sorted_by_key(|(_, name, _)| name) {
div { key: "{id}", class: "col",
div { class: "p-3 border rounded border-light-subtle h-100",
Link {
to: "/recipe/{id}",
class: "link-light link-offset-2 link-underline-opacity-25 link-underline-opacity-100-hover",
"{name}"
RecipeRating { rating: *rating }
}
}
}
}
}
}
}
}
},
Some(Err(e)) => rsx! { ErrorAlert { error: "Could not fetch recipes: {e}" } },
None => rsx! { Spinner {} },
})
}

150
app/src/recipe/mod.rs Normal file
View file

@ -0,0 +1,150 @@
use std::{collections::BTreeMap, rc::Rc};
use api::IngredientInfo;
use dioxus::prelude::*;
use uuid::Uuid;
use crate::{bootstrap::Spinner, use_trimmed_context, ErrorAlert};
mod creator;
mod list;
mod view;
pub use creator::RecipeCreator;
pub use list::RecipeList;
pub use view::RecipeView;
#[derive(Props, PartialEq)]
pub struct RecipeRatingProps {
rating: u8,
}
pub fn RecipeRating(cx: Scope<RecipeRatingProps>) -> Element {
let rating = (cx.props.rating + 1).min(3);
cx.render(rsx! {
span { "arial-label": "Rating: {rating}", class: "ms-1",
for _ in (0..rating) {
i { "aria-hidden": "true", class: "bi-star-fill ms-1" }
}
}
})
}
#[derive(Clone)]
pub struct RecipeIngredient {
pub id: i64,
pub info: IngredientInfo,
pub amount: f64,
}
async fn fetch_ingredients(
token: String,
household: Uuid,
) -> anyhow::Result<Rc<BTreeMap<String, (i64, IngredientInfo)>>> {
let list = crate::ingredients::fetch_ingredients(token, household).await?;
Ok(Rc::new(
list.ingredients
.into_iter()
.map(|(k, v)| (v.name.clone(), (k, v)))
.collect(),
))
}
#[derive(Props)]
pub struct IngredientSelectProps<'a> {
pub on_amount_change: EventHandler<'a, Result<Option<f64>, String>>,
pub on_ingredient_change: EventHandler<'a, Result<(i64, IngredientInfo), String>>,
pub children: Element<'a>,
#[props(default = 0)]
pub refresh: u64,
}
pub fn IngredientSelect<'a>(cx: Scope<'a, IngredientSelectProps<'a>>) -> Element {
let (token, household) = use_trimmed_context(cx);
let ingredients = use_future(cx, &cx.props.refresh, |_| {
fetch_ingredients(token, household)
});
let unit = use_state(cx, || None::<String>);
let amount = use_state(cx, String::new);
let on_amount_change = move |_| {
if amount.is_empty() {
cx.props.on_amount_change.call(Ok(None));
return;
}
let value = match amount.parse::<f64>() {
Ok(v) if v < 0. => Err("Amount must be positive".to_string()),
Ok(v) => Ok(Some(v)),
Err(e) => Err(format!("Amount must be a number: {e}")),
};
cx.props.on_amount_change.call(value);
};
cx.render(match ingredients.value() {
Some(Ok(ingredients)) => {
let on_ingredient_change = move |e: FormEvent| {
match ingredients.get(&e.value) {
Some(info) => {
unit.set(info.1.unit.clone());
cx.props.on_ingredient_change.call(Ok(info.clone()));
}
None => {
unit.set(None);
cx.props.on_ingredient_change.call(Err(e.value.clone()));
}
}
};
rsx! {
script { src: "/awesomplete.min.js", "async": "async" }
div { class: "d-flex flex-column align-items-start",
div { class: "container",
div { class: "row",
div { class: "col-sm-6 d-flex align-items-center mb-1",
label { "for": "igSelect", class: "pe-1", "Name:" }
input {
class: "awesomplete form-control",
list: "igList",
onchange: on_ingredient_change,
id: "igSelect"
}
datalist { id: "igList",
for k in ingredients.keys() {
option { key: "{k}", k.as_str() }
}
}
}
div { class: "col-sm-6 d-flex align-items-center",
label { "for": "igAmount", class: "px-1", "Amount: " }
div { class: "input-group",
input {
class: "form-control",
"type": "number",
"id": "igAmount",
min: "0",
value: "{amount}",
oninput: move |e| amount.set(e.value.clone()),
onchange: on_amount_change
}
if let Some(unit) = &**unit {
rsx! {
span {class: "input-group-text", unit.as_str()}
}
}
}
}
div { class: "col-sm", &cx.props.children }
}
}
}
}
}
Some(Err(e)) => rsx! { ErrorAlert { error: "Could not fetch ingredients: {e}" } },
None => rsx! { Spinner {} },
})
}

760
app/src/recipe/view.rs Normal file
View file

@ -0,0 +1,760 @@
use std::rc::Rc;
use api::{
AddRecipeIngredientRequest, RecipeEditPersonCount, RecipeEditRating, RecipeEditStepsRequest,
RecipeInfo, RecipeIngredientEditRequest, RecipeRenameRequest,
};
use dioxus::prelude::*;
use dioxus_router::{use_route, use_router};
use uuid::Uuid;
use crate::{
api,
bootstrap::{bs, ConfirmDangerModal, FormModal, ModalToggleButton, Spinner},
recipe::{IngredientSelect, RecipeRating},
to_owned_props, use_error, use_refresh, use_trimmed_context, Callback, ErrorAlert, ErrorView,
};
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}"))
.header("Authorization", &format!("Bearer {token}"))
.json(&RecipeRenameRequest { name })?
.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(Props, PartialEq)]
struct EditNameProps {
recipe: i64,
name: String,
refresh: Callback,
}
fn EditName(cx: Scope<EditNameProps>) -> Element {
let name = use_state(cx, || cx.props.name.clone());
let error = use_error(cx);
let (token, household) = use_trimmed_context(cx);
let on_submit = move |_| {
if name.is_empty() {
error.set(Some("Name can't be empty".into()));
}
to_owned_props![name, error, token, cx.props.refresh, cx.props.recipe];
cx.spawn(async move {
match do_rename_recipe(token, household, recipe, name.to_string()).await {
Ok(_) => {
let modal = bs::Modal::get_instance("#rcpEditName");
modal.hide();
error.set(None);
refresh.call();
}
Err(e) => {
error.set(Some(format!("Could not edit name: {e}")));
}
}
})
};
cx.render(rsx! {
ModalToggleButton { class: "btn btn-secondary", modal_id: "rcpEditName", "Edit name" }
FormModal {
id: "rcpEditName",
fade: true,
centered: true,
submit_label: "Edit",
title: "Edit Name",
on_submit: on_submit,
ErrorView { error: error }
div { class: "form-floating",
input {
class: "form-control",
id: "rcpEditNameInp",
placeholder: "Name",
value: "{name}",
oninput: move |e| name.set(e.value.clone())
}
label { "for": "rcpEditNameInp", "Recipe Name" }
}
}
})
}
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"))
.header("Authorization", &format!("Bearer {token}"))
.json(&RecipeEditRating { rating })?
.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(Props, PartialEq)]
struct EditRatingProps {
recipe: i64,
rating: u8,
refresh: Callback,
}
fn EditRating(cx: Scope<EditRatingProps>) -> Element {
let rating = use_state(cx, || cx.props.rating.to_string());
let error = use_error(cx);
let (token, household) = use_trimmed_context(cx);
let on_submit = move |_| {
let rating: u8 = match rating.parse() {
Ok(v @ (1 | 2 | 3)) => v,
_ => {
error.set(Some("Rating must be a number between 1 and 3".into()));
return;
}
};
to_owned_props![error, token, cx.props.refresh, cx.props.recipe];
cx.spawn(async move {
match do_edit_rating(token, household, recipe, rating - 1).await {
Ok(_) => {
let modal = bs::Modal::get_instance("#rcpRtgName");
modal.hide();
error.set(None);
refresh.call();
}
Err(e) => {
error.set(Some(format!("Could not edit rating: {e}")));
}
}
})
};
cx.render(rsx! {
ModalToggleButton { class: "btn btn-secondary ms-2", modal_id: "rcpRtgName", "Edit rating" }
FormModal {
id: "rcpRtgName",
fade: true,
centered: true,
submit_label: "Edit",
title: "Edit Name",
on_submit: on_submit,
ErrorView { error: error }
div { class: "form-floating",
input {
class: "form-control",
id: "rcpEditRtgInp",
placeholder: "Rating",
value: "{rating}",
"type": "number",
"min": "1",
"max": "3",
oninput: move |e| rating.set(e.value.clone())
}
label { "for": "rcpEditRtgInp", "Recipe rating" }
}
}
})
}
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"))
.header("Authorization", &format!("Bearer {token}"))
.json(&RecipeEditPersonCount { person_count })?
.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(Props, PartialEq)]
struct EditPersonCountProps {
recipe: i64,
person_count: u32,
refresh: Callback,
}
fn EditPersonCount(cx: Scope<EditPersonCountProps>) -> Element {
let person_count = use_state(cx, || cx.props.person_count.to_string());
let error = use_error(cx);
let (token, household) = use_trimmed_context(cx);
let on_submit = move |_| {
let person_count: u32 = match person_count.parse() {
Ok(v) if v >= 1 => v,
_ => {
error.set(Some("Rating must be a number larger than 1".into()));
return;
}
};
to_owned_props![error, token, cx.props.refresh, cx.props.recipe];
cx.spawn(async move {
match do_edit_person_count(token, household, recipe, person_count).await {
Ok(_) => {
let modal = bs::Modal::get_instance("#rcpEditPersonCount");
modal.hide();
error.set(None);
refresh.call();
}
Err(e) => {
error.set(Some(format!("Could not edit person count: {e}")));
}
}
})
};
cx.render(rsx! {
ModalToggleButton { class: "btn btn-secondary", modal_id: "rcpEditPersonCount", "Edit" }
FormModal {
id: "rcpEditPersonCount",
fade: true,
centered: true,
submit_label: "Edit",
title: "Edit default person count",
on_submit: on_submit,
ErrorView { error: error }
div { class: "form-floating",
input {
class: "form-control",
id: "rcpEditPersonCountInp",
placeholder: "Rating",
value: "{person_count}",
"type": "number",
"min": "1",
oninput: move |e| person_count.set(e.value.clone())
}
label { "for": "rcpEditPersonCountInp", "Default person count" }
}
}
})
}
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}"
))
.header("Authorization", &format!("Bearer {token}"))
.json(&RecipeIngredientEditRequest { amount })?
.send()
.await?;
if !rsp.ok() {
let body = rsp.text().await.unwrap_or_default();
anyhow::bail!("Could not edit ingredient (code={}): {body}", rsp.status());
}
Ok(())
}
#[derive(Props, PartialEq)]
struct EditIngredientProps {
recipe: i64,
refresh: Callback,
ingredient_id: i64,
amount: f64,
person_count: u32,
#[props(!optional)]
unit: Option<String>,
}
fn EditIngredient(cx: Scope<EditIngredientProps>) -> Element {
let error = use_error(cx);
let amount = use_state(cx, || {
(cx.props.amount * cx.props.person_count as f64).to_string()
});
let (token, household) = use_trimmed_context(cx);
let modal_id = format!("rcpEditIg{}", cx.props.ingredient_id);
let on_submit = {
to_owned![modal_id];
move |_| {
let amount = match amount.parse::<f64>() {
Ok(v) if v >= 0. => v / cx.props.person_count as f64,
_ => {
error.set(Some("Amount must be a positive number".into()));
return;
}
};
to_owned_props![
token,
cx.props.recipe,
cx.props.refresh,
cx.props.ingredient_id,
modal_id,
error
];
cx.spawn(async move {
match do_edit_ingredient_recipe(token, household, recipe, ingredient_id, amount)
.await
{
Ok(_) => {
let modal = bs::Modal::get_instance(&format!("#{modal_id}"));
modal.hide();
error.set(None);
refresh.call();
}
Err(e) => {
error.set(Some(format!("Could not edit ingredient: {e}")));
}
}
})
}
};
cx.render(rsx! {
ModalToggleButton { class: "btn btn-primary", modal_id: "{modal_id}", i { class: "bi-pencil-fill" } }
FormModal {
id: modal_id.to_owned(),
fade: true,
centered: true,
submit_label: "Edit",
title: "Edit Ingredient",
on_submit: on_submit,
ErrorView { error: error }
div { class: "input-group",
input {
"type": "numeric",
class: "form-control",
placeholder: "Amount",
"aria-label": "Amount",
"aria-describedby": "rcpEditIgUnit{cx.props.ingredient_id}",
value: "{amount}",
oninput: move |e| amount.set(e.value.clone())
}
if let Some(unit) = &cx.props.unit {
rsx! {
span { class: "input-group-text", id: "rcpEditIgUnit{cx.props.ingredient_id}", "{unit}" }
}
}
}
}
})
}
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}"
))
.header("Authorization", &format!("Bearer {token}"))
.json(&AddRecipeIngredientRequest { amount })?
.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(Props, PartialEq)]
struct AddIngredientToRecipeProps {
recipe: i64,
refresh: Callback,
person_count: u32,
}
fn AddIngredientToRecipe(cx: Scope<AddIngredientToRecipeProps>) -> Element {
let error = use_error(cx);
let amount = use_state(cx, || None);
let ingredient = use_state(cx, || None);
let (token, household) = use_trimmed_context(cx);
let on_amount_change = move |v| match v {
Ok(v) => amount.set(v),
Err(_) => amount.set(None),
};
let on_ingredient_change = move |v| match v {
Ok((id, _)) => ingredient.set(Some(id)),
Err(_) => ingredient.set(None),
};
let on_submit = move |_| {
let Some(id) = **ingredient else {
error.set(Some("Invalid ingredient (does not exist ?)".into()));
return
};
let Some(am) = **amount else {
error.set(Some("Invalid ingredient amount".into()));
return;
};
to_owned_props![
cx.props.refresh,
cx.props.recipe,
token,
cx.props.person_count,
amount,
ingredient,
error
];
cx.spawn(async move {
match do_add_ingredient_recipe(token, household, recipe, id, am / person_count as f64)
.await
{
Ok(_) => {
amount.set(None);
ingredient.set(None);
error.set(None);
refresh.call();
let modal = bs::Modal::get_instance("#rcpEditNewIg");
modal.hide();
}
Err(e) => {
error.set(Some(format!("Could not add ingredient: {e}")));
}
}
});
};
cx.render(rsx! {
ModalToggleButton { modal_id: "rcpEditNewIg", class: "btn btn-secondary", "Add Ingredient" }
FormModal {
id: "rcpEditNewIg",
fade: true,
centered: true,
submit_label: "Add",
title: "Add ingredient",
on_submit: on_submit,
ErrorView { error: error }
IngredientSelect { on_amount_change: on_amount_change, on_ingredient_change: on_ingredient_change }
}
})
}
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"))
.header("Authorization", &format!("Bearer {token}"))
.json(&RecipeEditStepsRequest { steps })?
.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(Props, PartialEq)]
struct EditStepsProps {
recipe: i64,
refresh: Callback,
steps: String,
}
fn EditSteps(cx: Scope<EditStepsProps>) -> Element {
let error = use_error(cx);
let steps = use_state(cx, || cx.props.steps.clone());
let (token, household) = use_trimmed_context(cx);
let on_submit = move |_| {
to_owned_props![
error,
token,
steps,
cx.props.recipe,
cx.props.refresh
];
cx.spawn(async move {
match do_edit_steps(token, household, recipe, steps.to_string()).await {
Ok(_) => {
let modal = bs::Modal::get_instance("#rcpEditSteps");
modal.hide();
error.set(None);
refresh.call();
}
Err(e) => {
error.set(Some(format!("Could not edit steps: {e}")));
}
}
})
};
cx.render(rsx! {
ModalToggleButton { modal_id: "rcpEditSteps", class: "btn btn-secondary mb-2", "Edit Steps" }
FormModal {
id: "rcpEditSteps",
fade: true,
centered: true,
submit_label: "Edit",
title: "Edit steps",
on_submit: on_submit,
ErrorView { error: error }
textarea {
class: "form-control",
rows: "10",
value: "{steps}",
oninput: move |e| steps.set(e.value.clone())
}
}
})
}
#[derive(Props, PartialEq)]
struct RecipeViewerProps {
id: i64,
info: Rc<RecipeInfo>,
refresh: Callback,
}
fn RecipeViewer(cx: Scope<RecipeViewerProps>) -> Element {
let person_count = use_state(cx, || cx.props.info.person_count);
let (token, household) = use_trimmed_context(cx);
let error = use_error(cx);
let on_person_count_change = move |e: FormEvent| {
if let Ok(v) = e.value.parse() {
person_count.set(v);
}
};
let mk_del_ig = |&ingredient_id| {
to_owned_props![token];
move |_| {
to_owned_props![cx.props.refresh, token, cx.props.id, error];
cx.spawn(async move {
match do_delete_ig(token, household, id, ingredient_id).await {
Ok(_) => {
error.set(None);
refresh.call();
}
Err(e) => {
error.set(Some(format!("Could not delete ingredient: {e}")));
}
}
})
}
};
cx.render(rsx! {
h1 {
"{cx.props.info.name}"
RecipeRating { rating: cx.props.info.rating }
}
div { class: "mt-2",
EditName {
recipe: cx.props.id,
refresh: cx.props.refresh.clone(),
name: cx.props.info.name.clone()
}
EditRating {
recipe: cx.props.id,
refresh: cx.props.refresh.clone(),
rating: cx.props.info.rating + 1
}
}
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: "rcpPersonCount",
min: "1",
value: "{person_count}",
onchange: on_person_count_change
}
span { class: "input-group-text", "people" }
}
}
div { class: "col",
EditPersonCount {
recipe: cx.props.id,
refresh: cx.props.refresh.clone(),
person_count: cx.props.info.person_count
}
}
}
}
ErrorView { error: error }
hr {}
div { class: "text-start",
h2 { "Ingredients" }
ul { class: "list-group mb-2",
for (id , info , amount) in &cx.props.info.ingredients {
li { key: "{id}", class: "list-group-item d-flex justify-content-between align-items-center",
"{(amount * (**person_count as f64)).round()}{info.unit.as_deref().unwrap_or(\"\")} {info.name}"
div {
ModalToggleButton { modal_id: "rcpRmIg{id}", class: "btn btn-danger me-1", i { class: "bi-trash3" } }
ConfirmDangerModal {
id: "rcpRmIg{id}",
title: "Remove ingredient '{info.name}'",
centered: true,
on_confirm: mk_del_ig(id)
}
EditIngredient {
recipe: cx.props.id,
refresh: cx.props.refresh.clone(),
amount: *amount,
ingredient_id: *id,
person_count: **person_count,
unit: info.unit.clone()
}
}
}
}
}
AddIngredientToRecipe { recipe: cx.props.id, refresh: cx.props.refresh.clone(), person_count: **person_count }
}
hr {}
div { class: "text-start",
h2 { "Steps" }
ul { class: "list-group list-group-flush",
for line in cx.props.info.steps.split('\n') {
li { class: "list-group-item", line }
}
}
EditSteps {
recipe: cx.props.id,
refresh: cx.props.refresh.clone(),
steps: cx.props.info.steps.clone()
}
}
})
}
#[derive(Props, PartialEq)]
struct RecipeFetchProps {
id: i64,
}
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?))
}
fn RecipeFetch(cx: Scope<RecipeFetchProps>) -> Element {
let (token, household) = use_trimmed_context(cx);
let id = cx.props.id;
let (refresh_dep, do_refresh) = use_refresh(cx);
let info = use_future(cx, &refresh_dep, move |_| {
fetch_recipe(token, household, id)
});
cx.render(match info.value() {
Some(Ok(info)) => rsx! {
div { class: "d-flex align-items-center justify-content-center w-100",
div { class: "container text-center rounded border pt-2 m-2",
RecipeViewer { id: cx.props.id, info: info.clone(), refresh: do_refresh }
}
}
},
Some(Err(e)) => rsx! { ErrorAlert { error: "Could not fetch recipe: {e}" } },
None => rsx! { Spinner {} },
})
}
pub fn RecipeView(cx: Scope) -> Element {
let id = use_route(cx).parse_segment_or_404("recipe_id");
let router = use_router(cx);
let id = match id {
Some(id) => id,
None => {
router.navigate_to("/404");
return None;
}
};
cx.render(rsx! { RecipeFetch { id: id } })
}

View file

@ -1,726 +0,0 @@
use std::{collections::BTreeMap, rc::Rc};
use api::{CreateRecipeRequest, CreateRecipeResponse, IngredientInfo};
use uuid::Uuid;
use wasm_bindgen::JsCast;
use web_sys::{HtmlInputElement, HtmlTextAreaElement};
use yew::{prelude::*, suspense::use_future_with_deps};
use yew_router::prelude::use_navigator;
use crate::{
api,
bootstrap::{bs, FormModal, ModalBody, ModalFooter, ModalToggleButton, TitledModal},
RegaladeGlobalState, Route,
};
#[derive(Clone)]
pub(super) struct RecipeIngredient {
id: i64,
info: IngredientInfo,
amount: f64,
}
async fn fetch_ingredients(
token: String,
household: Uuid,
) -> anyhow::Result<Rc<BTreeMap<String, (i64, IngredientInfo)>>> {
let list = crate::ingredients::fetch_ingredients(token, household).await?;
Ok(Rc::new(
list.ingredients
.into_iter()
.map(|(k, v)| (v.name.clone(), (k, v)))
.collect(),
))
}
#[derive(PartialEq, Properties, Clone)]
pub(super) struct IngredientSelectBaseProps {
pub token: String,
pub household: Uuid,
pub on_amount_change: Callback<f64>,
pub on_ig_change: Callback<Option<(i64, IngredientInfo)>>,
#[prop_or_default]
pub children: Children,
pub amount: Option<f64>,
pub ig_select: Option<AttrValue>,
#[prop_or_default]
pub refresh: u64,
}
#[function_component]
pub(super) fn IngredientSelectBase(props: &IngredientSelectBaseProps) -> HtmlResult {
let ingredients = use_future_with_deps(
|_| fetch_ingredients(props.token.clone(), props.household),
props.refresh,
)?;
let unit = use_state(|| None::<String>);
let input_value_h = use_state(|| {
props
.ig_select
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default()
});
let input_value = input_value_h.to_string();
let error = use_state(|| None::<String>);
let amount_change = props.on_amount_change.clone();
let am_change = 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;
}
if let Ok(value) = target.value().parse() {
amount_change.emit(value);
}
});
match &*ingredients {
Ok(ig) => {
let igc = ig.clone();
let u = unit.clone();
let on_ig_change = props.on_ig_change.clone();
let onchange = Callback::from(move |e: Event| {
let Some(target) = e.target() else {
return;
};
let ip = input_value_h.clone();
let Ok(target) = target.dyn_into::<HtmlInputElement>() else {
return;
};
ip.set(target.value());
match igc.get(&target.value()) {
Some(info) => {
on_ig_change.emit(Some(info.clone()));
u.set(info.1.unit.clone());
}
None => {
on_ig_change.emit(None);
u.set(None);
}
}
});
Ok(html! {
<div class="d-flex flex-column align-items-start">
if let Some(e) = &*error {
<div class={classes!("alert", "alert-danger")} role="alert">
{e}
</div>
}
<script src="/awesomplete.min.js" async=true></script>
<div class="container">
<div class="row">
<div class="col-sm-6 d-flex align-items-center mb-1">
<label for="igSelect" class="pe-1">{"Name:"}</label>
<input
class="awesomplete form-control"
list="igList"
value={input_value}
{onchange}
id="igSelect"
/>
<datalist id="igList">
{ for ig.keys().map(|k| html!{<option key={k.clone()}>{k}</option>}) }
</datalist>
</div>
<div class="col-sm-6 d-flex align-items-center">
<label for="igAmount" class="px-1">{"Amount: "}</label>
<div class="input-group">
<input
class="form-control"
type="number"
id="igAmount"
min="0"
value={props.amount.map(|v| v.to_string())}
onchange={am_change}
/>
if let Some(unit) = &*unit {
<span class="input-group-text">{unit}</span>
}
</div>
</div>
<div class="col-sm">
{props.children.clone()}
</div>
</div>
</div>
</div>
})
}
Err(e) => Ok(html! {
<div class={classes!("alert", "alert-danger")} role="alert">
{format!("Could not load ingredients: {e:?}")}
</div>
}),
}
}
#[derive(PartialEq, Clone, Properties)]
struct RecipeCreateIngredientProps {
token: String,
household: Uuid,
on_ig_add: Callback<RecipeIngredient>,
}
#[function_component]
fn RecipeCreateIngredient(props: &RecipeCreateIngredientProps) -> Html {
let error = use_state(|| None::<String>);
let amount = use_state(|| 1);
let name = use_state(String::new);
let unit = use_state(String::new);
let get_target = |e: Event| match e.target() {
None => None,
Some(e) => e.dyn_into::<HtmlInputElement>().ok(),
};
let on_name_change = {
let name = name.clone();
let error = error.clone();
Callback::from(move |e| {
let Some(tgt) = get_target(e) else {
return;
};
let value = tgt.value();
if value.is_empty() {
error.set(Some("Name can't be empty".into()));
return;
}
name.set(value);
})
};
let on_unit_change = {
let unit = unit.clone();
Callback::from(move |e| {
let Some(tgt) = get_target(e) else {
return;
};
unit.set(tgt.value());
})
};
let on_amount_change = {
let amount = amount.clone();
Callback::from(move |e| {
let Some(tgt) = get_target(e) else {
return;
};
if !tgt.report_validity() {
return;
}
amount.set(tgt.value().parse().expect("amount not a number"));
})
};
let on_submit = {
let name = name.clone();
let unit = unit.clone();
let amount = amount.clone();
let token = props.token.clone();
let household = props.household;
let error = error.clone();
let on_ig_add = props.on_ig_add.clone();
Callback::from(move |_| {
let fut = super::ingredients::do_add_ingredient(
token.clone(),
household,
name.to_string(),
unit.to_string(),
);
let error = error.clone();
let info = IngredientInfo {
name: name.to_string(),
unit: (!unit.is_empty()).then(|| unit.to_string()),
};
let amount = amount.clone();
let on_ig_add = on_ig_add.clone();
wasm_bindgen_futures::spawn_local(async move {
match fut.await {
Ok(rsp) => {
on_ig_add.emit(RecipeIngredient {
id: rsp.id,
info,
amount: *amount as f64,
});
error.set(None);
let modal = bs::Modal::get_instance("#newRcpCreateIg");
modal.hide();
}
Err(e) => {
error.set(Some(format!("Could not add ingredient: {e}")));
}
}
});
})
};
html! {
<FormModal
id="newRcpCreateIg"
fade=true
centered=true
submit_label="Create & Add"
title="Create & Add 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="newRcpCreateIgNameInp"
placeholder={"Name"}
value={name.to_string()}
onchange={on_name_change}
/>
<label for="newRcpCreateIgNameInp">{"Ingredient Name"}</label>
</div>
<div class="form-floating my-1">
<input
class="form-control"
id="newRcpCreateIgUnitInp"
placeholder={"Unit"}
value={unit.to_string()}
onchange={on_unit_change}
/>
<label for="newRcpCreateIgUnitInp">{"Ingredient Unit"}</label>
</div>
<div class="form-floating">
<input
class="form-control"
type="number"
min="1"
id="newRcpCreateIgAmountInp"
placeholder={"Amount"}
value={amount.to_string()}
onchange={on_amount_change}
/>
<label for="newRcpCreateIgAmountInp">{"Ingredient Amount"}</label>
</div>
</FormModal>
}
}
#[derive(PartialEq, Properties, Clone)]
struct IngredientSelectProps {
token: String,
household: Uuid,
onselect: Callback<RecipeIngredient>,
}
#[function_component]
fn IngredientSelect(props: &IngredientSelectProps) -> Html {
let on_select = props.onselect.clone();
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 onclick = Callback::from(move |_| match &*s_ig {
Some((id, info)) => match &*am {
&Some(amount) => {
on_select.emit(RecipeIngredient {
id: *id,
info: info.clone(),
amount,
});
err.set(None);
}
None => {
err.set(Some("Amount can't be empty".into()));
}
},
None => {
err.set(Some("Ingredient does not exist".into()));
}
});
let fallback = html! {
<div class="spinner-border" role="status">
<span class="visually-hidden">{"Loading ..."}</span>
</div>
};
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));
})
};
let ingredient_refresh = use_state(|| 0u64);
let on_ig_add = {
let on_select = props.onselect.clone();
let ingredient_refresh = ingredient_refresh.clone();
Callback::from(move |info| {
on_select.emit(info);
ingredient_refresh.set(ingredient_refresh.wrapping_add(1));
})
};
html! {<>
if let Some(e) = &*error {
<div class={classes!("alert", "alert-danger")} role="alert">
{e}
</div>
}
<RecipeCreateIngredient
token={props.token.clone()}
household={props.household}
{on_ig_add}
/>
<Suspense {fallback}>
<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()))}
refresh={*ingredient_refresh}
>
<button class="btn btn-primary me-1" {onclick}>
{"Add"}
</button>
<button class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#newRcpCreateIg">
{"Create"}
</button>
</IngredientSelectBase>
</Suspense>
</>}
}
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)
}
#[function_component]
pub fn RecipeCreator() -> Html {
let current_rating = use_state(|| 0u8);
let global_state = use_state(RegaladeGlobalState::get);
let person_count = use_state(|| 1u8);
let mk_rating_oninput = |id| {
let current_rating = current_rating.clone();
Callback::from(move |_| {
current_rating.set(id);
})
};
let ingredients = use_state(im::Vector::new);
let ig = ingredients.clone();
let onselect = Callback::from(move |rcp_ig: RecipeIngredient| {
let mut ingredients = (*ig).clone();
ingredients.push_back(rcp_ig);
ig.set(ingredients);
});
let mk_ig_delete = |idx| {
let ig = ingredients.clone();
Callback::from(move |_| {
let mut ingredients = (*ig).clone();
ingredients.remove(idx);
ig.set(ingredients);
})
};
let steps = use_state(String::new);
let on_step_change = {
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 name = use_state(String::new);
let nm = name.clone();
let on_name_change = 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(|| Option::None::<String>);
let s = steps.clone();
let ig = ingredients.clone();
let rtg = current_rating.clone();
let nm = name.clone();
let err = error.clone();
let token = global_state.token.token.clone();
let household = global_state.household.id;
let pc = person_count.clone();
let nav = use_navigator().unwrap();
let new_rcp_submit = Callback::from(move |_| {
if nm.is_empty() {
err.set(Some("Name can't be empty".into()));
return;
}
let s = s.clone();
let ig = ig.clone();
let rtg = rtg.clone();
let nm = nm.clone();
let err = err.clone();
let token = token.clone();
let pc = pc.clone();
let nav = nav.clone();
wasm_bindgen_futures::spawn_local(async move {
match do_create_recipe(
token,
household,
CreateRecipeRequest {
name: (*nm).clone(),
rating: (*rtg),
ingredients: ig
.iter()
.map(|rcp_ig: &RecipeIngredient| (rcp_ig.id, rcp_ig.amount / (*pc) as f64))
.collect(),
person_count: (*pc) as _,
steps: (*s).clone(),
},
)
.await
{
Ok(id) => {
s.set(Default::default());
ig.set(Default::default());
rtg.set(Default::default());
nm.set(Default::default());
err.set(None);
nav.push(&Route::Recipe { id });
}
Err(e) => {
err.set(Some(format!("Error creating recipe: {e:?}")));
}
}
});
});
let pc = person_count.clone();
let on_person_count_change = 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;
}
pc.set(target.value().parse().unwrap());
});
html! {
<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"}</h1>
if let Some(e) = &*error {
<div class={classes!("alert", "alert-danger")} role="alert">
{e}
</div>
}
<hr />
<div class="form-floating">
<input
id="newRcpName"
class="form-control"
placeholder="Name"
value={name.to_string()}
onchange={on_name_change}
/>
<label for="newRcpName">{"Name"}</label>
</div>
<div class="form-floating">
<input
id="newRcpPersonCount"
class="form-control"
placeholder="Person Count"
type="number"
min=0
onchange={on_person_count_change}
value={person_count.to_string()}
/>
<label for="newRcpPersonCount">{"Person Count"}</label>
</div>
<div class="pt-2">
{"Rating: "}
<div class="form-check form-check-inline">
<input
class="form-check-input"
type="radio"
name="ratingOptions"
id="likeRating"
checked={*current_rating == 0}
oninput={mk_rating_oninput(0)}
/>
<label class="form-check-label" for="likeRating">{"Like"}</label>
</div>
<div class="form-check form-check-inline">
<input
class="form-check-input"
type="radio"
name="ratingOptions"
id="likeLotRating"
checked={*current_rating == 1}
oninput={mk_rating_oninput(1)}
/>
<label class="form-check-label" for="likeLotRating">{"Like a lot"}</label>
</div>
<div class="form-check form-check-inline">
<input
class="form-check-input"
type="radio"
name="ratingOptions"
id="loveRating"
checked={*current_rating == 2}
oninput={mk_rating_oninput(2)}
/>
<label class="form-check-label" for="loveRating">{"Love"}</label>
</div>
</div>
<div class="d-flex flex-column justify-content-start">
<h2>{"Ingredients"}</h2>
<IngredientSelect
token={global_state.token.token.clone()}
household={global_state.household.id}
{onselect}
/>
<ul class="list-group list-group-flush text-start">
{for (*ingredients).iter().enumerate().map(|(idx, ig)| {
html!{
<li
class={classes!(
"list-group-item",
"d-flex",
"justify-content-between",
"align-items-center",
)}
>
{format!("{}{} {}",
ig.amount,
ig.info.unit.as_deref().unwrap_or(""),
ig.info.name,
)}
<button
class="btn btn-danger"
onclick={mk_ig_delete(idx)}
>
{"Remove"}
</button>
</li>
}
})
}
</ul>
</div>
<div>
<h2>{"Steps"}</h2>
<textarea
class="form-control"
value={(*steps).clone()}
onchange={on_step_change}
/>
</div>
<hr />
<ModalToggleButton classes={classes!("btn", "btn-lg", "btn-primary")} modal_id="newRcpModal">
{"Create Recipe"}
</ModalToggleButton>
<TitledModal id="newRcpModal" fade=true centered=true title="Create Recipe">
<ModalBody>
{"Do you confirm this recipe ?"}
</ModalBody>
<ModalFooter>
<button type="button" class={classes!("btn", "btn-secondary")} data-bs-dismiss="modal">
{"Cancel"}
</button>
<button
type="button"
class={classes!("btn", "btn-primary")}
data-bs-dismiss="modal"
onclick={new_rcp_submit}
>
{"Confirm"}
</button>
</ModalFooter>
</TitledModal>
</div>
</div>
}
}

View file

@ -1,30 +1,176 @@
use api::RenameHouseholdRequest; use api::RenameHouseholdRequest;
use dioxus::prelude::*;
use dioxus_router::{use_router, Link};
use gloo_storage::{LocalStorage, Storage}; use gloo_storage::{LocalStorage, Storage};
use uuid::Uuid; use uuid::Uuid;
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use yew::prelude::*;
use yew_router::prelude::*;
use crate::{ use crate::{
api, api,
bootstrap::{bs, ConfirmDangerModal, FormModal}, bootstrap::{bs, ConfirmDangerModal, FormModal},
do_add_user_to_household, do_resolve_user, HouseholdInfo, RegaladeGlobalState, Route, do_add_user_to_household, do_resolve_user,
RouteKind, full_context::FullContextRedirect,
use_error, use_full_context, use_trimmed_context, ErrorView, HouseholdInfo,
}; };
#[derive(PartialEq)] #[derive(Clone, Copy, PartialEq)]
pub enum Page {
Home,
Ingredients,
RecipeCreator,
RecipeList,
}
impl Page {
pub fn to(&self) -> &'static str {
match self {
Page::Home => "/",
Page::Ingredients => "/ingredients",
Page::RecipeCreator => "/recipe_creator",
Page::RecipeList => "/recipe",
}
}
}
struct MenuEntry { struct MenuEntry {
icon: &'static str, icon: &'static str,
label: &'static str, label: &'static str,
page: RouteKind, page: Page,
} }
#[derive(Properties, PartialEq)] fn AddMemberModal(cx: Scope) -> Element {
struct SidebarProps { let error = use_error(cx);
let (token, household) = use_trimmed_context(cx);
let member = use_state(cx, String::new);
let add_member = move || {
to_owned![member, error, token, member];
cx.spawn(async move {
match do_resolve_user(token.clone(), member.to_string()).await {
Ok(Some(uid)) => match do_add_user_to_household(token, household, uid).await {
Err(e) => {
error.set(Some(format!("Could not add user: {e:?}")));
}
Ok(_) => {
error.set(None);
let modal = bs::Modal::get_instance("#addMember");
modal.hide();
}
},
Ok(None) => {
error.set(Some(format!("User {member} does not exist")));
}
Err(e) => {
error.set(Some(format!("Could not resolve user: {e:?}")));
}
}
})
};
cx.render(rsx! {
FormModal {
id: "addMember",
centered: true,
submit_label: "Add",
title: "Add a member",
on_submit: move |_| add_member(),
ErrorView { error: error }
div { class: "form-floating",
input {
class: "form-control",
id: "addMemberName",
placeholder: "Member name",
value: "{member}",
oninput: move |e| member.set(e.value.to_string())
}
label { "for": "addMemberName", "Member name" }
}
}
})
}
async fn do_rename_household(token: String, household: Uuid, name: String) -> anyhow::Result<()> {
let rsp = gloo_net::http::Request::patch(api!("household/{household}"))
.header("Authorization", &format!("Bearer {token}"))
.json(&RenameHouseholdRequest { name: name.clone() })?
.send()
.await?;
if !rsp.ok() {
anyhow::bail!("Could not leave: {}", rsp.text().await?);
}
LocalStorage::set(
"household",
HouseholdInfo {
id: household,
name,
},
)
.expect("Could not set household info");
Ok(())
}
#[inline_props]
fn RenameHousehold<'a>(cx: Scope<'a>, name: &'a str) -> Element {
let error = use_error(cx);
let ctx = use_full_context(cx);
let name = use_state(cx, || name.to_string());
let rename_hs = move |_| {
to_owned![name, error];
let (token, household) = {
let h = ctx.read();
(h.login.token.clone(), h.household.id)
};
let refresh = ctx.refresh_handle();
cx.spawn(async move {
match do_rename_household(token, household, name.to_string()).await {
Ok(_) => {
error.set(None);
refresh.refresh();
let modal = bs::Modal::get_instance("#renameHousehold");
modal.hide();
}
Err(e) => {
error.set(Some(format!("Could not rename household: {e:?}")));
}
}
});
};
cx.render(rsx! {
FormModal {
id: "renameHousehold",
centered: true,
submit_label: "Rename",
title: "Rename household",
on_submit: rename_hs,
ErrorView { error: error }
div { class: "form-floating",
input {
id: "householdRename",
class: "form-control",
placeholder: "New household name",
value: "{name}",
oninput: move |e| name.set(e.value.clone())
}
label { "for": "householdRename", "New household name" }
}
}
})
}
#[derive(Props)]
struct SidebarProps<'a> {
entries: Vec<MenuEntry>, entries: Vec<MenuEntry>,
current: Route, current: Page,
children: Children, children: Element<'a>,
} }
async fn do_leave(token: String, household: Uuid) -> anyhow::Result<()> { async fn do_leave(token: String, household: Uuid) -> anyhow::Result<()> {
@ -46,416 +192,171 @@ async fn do_leave(token: String, household: Uuid) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
#[derive(PartialEq, Properties)] fn SidebarDropdown(cx: Scope) -> Element {
struct AddMemberProps { let ctx = use_full_context(cx);
token: String, let router = use_router(cx);
household: Uuid,
}
#[function_component] let leave = move || {
fn AddMemberModal(props: &AddMemberProps) -> Html { let token = ctx.read().login.token.clone();
let error = use_state(|| None::<String>); let household = ctx.read().household.id;
let err = error.clone(); to_owned![router];
let token = props.token.clone();
let household = props.household;
let add_member = Callback::from(move |_| {
let document = gloo_utils::document();
let name: HtmlInputElement = document cx.spawn(async move {
.get_element_by_id("addMemberName")
.unwrap()
.dyn_into()
.expect("addMemberName is not an input element");
let name = name.value();
let err = err.clone();
let token = token.clone();
wasm_bindgen_futures::spawn_local(async move {
match do_resolve_user(token.clone(), name.clone()).await {
Ok(Some(uid)) => match do_add_user_to_household(token, household, uid).await {
Err(e) => {
err.set(Some(format!("Could not add user: {e:?}")));
}
Ok(_) => {
err.set(None);
let modal = bs::Modal::get_instance("#addMember");
modal.hide();
}
},
Ok(None) => {
err.set(Some(format!("User '{name}' does not exist")));
}
Err(e) => {
err.set(Some(format!("Could not resolve user '{name}': {e:?}")));
}
}
})
});
html! {
<FormModal
id="addMember"
centered={true}
submit_label="Add"
title="Add a member"
on_submit={add_member}
>
if let Some(err) = &*error {
<div class={classes!("alert", "alert-danger")} role="alert">
{err}
</div>
}
<div class="form-floating">
<input
id="addMemberName"
class={classes!("form-control")}
placeholder="Member Name"
/>
<label for="addMemberName">{"Member name"}</label>
</div>
</FormModal>
}
}
async fn do_rename_household(token: String, household: Uuid, name: String) -> anyhow::Result<()> {
let rsp = gloo_net::http::Request::patch(api!("household/{household}"))
.header("Authorization", &format!("Bearer {token}"))
.json(&RenameHouseholdRequest { name: name.clone() })?
.send()
.await?;
if !rsp.ok() {
let body = rsp.body();
match body {
None => anyhow::bail!("Could not leave: {rsp:?}"),
Some(s) => anyhow::bail!("Could not leave: {}", s.to_string()),
}
}
LocalStorage::set(
"household",
HouseholdInfo {
id: household,
name,
},
)
.expect("Could not set household info");
Ok(())
}
#[derive(Properties, PartialEq)]
struct RenameHouseholdProps {
token: String,
household: Uuid,
name: String,
on_rename: Callback<()>,
}
#[function_component]
fn RenameHouseholdModal(props: &RenameHouseholdProps) -> Html {
let error = use_state(|| None::<String>);
let err = error.clone();
let token = props.token.clone();
let household = props.household;
let on_rename = props.on_rename.clone();
let add_member = Callback::from(move |_| {
let document = gloo_utils::document();
let name: HtmlInputElement = document
.get_element_by_id("householdRename")
.unwrap()
.dyn_into()
.expect("householdRename is not an input element");
let name = name.value();
let err = err.clone();
let token = token.clone();
let on_rename = on_rename.clone();
wasm_bindgen_futures::spawn_local(async move {
match do_rename_household(token, household, name).await {
Ok(_) => {
err.set(None);
on_rename.emit(());
let modal = bs::Modal::get_instance("#renameHousehold");
modal.hide();
}
Err(e) => {
err.set(Some(format!("Could not rename household: {e:?}")));
}
}
})
});
html! {
<FormModal
id="renameHousehold"
centered={true}
submit_label="Rename"
title="Rename houshold"
on_submit={add_member}
>
if let Some(err) = &*error {
<div class={classes!("alert", "alert-danger")} role="alert">
{err}
</div>
}
<div class="form-floating">
<input
id="householdRename"
class={classes!("form-control")}
placeholder="New household name"
value={props.name.clone()}
/>
<label for="householdRename">{"New household name"}</label>
</div>
</FormModal>
}
}
#[function_component]
fn Sidebar(props: &SidebarProps) -> Html {
let global_state = use_state(RegaladeGlobalState::get);
let navigator = use_navigator().unwrap();
let token = global_state.token.token.clone();
let household = global_state.household.id;
let nav = navigator.clone();
let leave_household = Callback::from(move |()| {
let token = token.clone();
let nav = nav.clone();
wasm_bindgen_futures::spawn_local(async move {
match do_leave(token, household).await { match do_leave(token, household).await {
Err(e) => { Err(e) => {
log::error!("Could not leave household: {e:?}"); log::error!("Could not leave household: {e:?}");
} }
Ok(_) => { Ok(_) => {
nav.push(&Route::HouseholdSelect); router.navigate_to("/household_selection");
} }
} }
}) });
}); };
let logout = Callback::from(move |_| { let logout = || {
LocalStorage::delete("token"); LocalStorage::delete("token");
LocalStorage::delete("household"); LocalStorage::delete("household");
navigator.push(&Route::HouseholdSelect); router.navigate_to("/login");
}); };
let gs = global_state.clone(); cx.render(rsx! {
let on_rename = Callback::from(move |_| { div { class: "dropdown",
gs.set(RegaladeGlobalState::get()); a {
}); href: "#",
"data-bs-toggle": "dropdown",
html! { "aria-expanded": "false",
<div class="container-fluid"> class: "d-flex align-items-center text-white text-decoration-none dropdown-toggle",
<div class={classes!("row", "flex-nowrap")}> i { class: "fs-4 bi-house-door-fill" }
<div class={classes!( strong { class: "ms-2 d-none d-sm-inline",
"col-auto", "{ctx.read().household.name} ({ctx.read().login.name})"
"col-md-3", }
"col-xl-2", }
"px-sm-2", ConfirmDangerModal {
"px-0", id: "leaveModal",
"bg-dark-subtle" title: "Leave household",
)}> centered: true,
<div class={classes!( on_confirm: move |_| leave(),
"d-flex", "Are you sure you want to leave the household '{ctx.read().household.name}' ?"
"flex-column", }
"align-items-center", AddMemberModal {}
"align-items-sm-start", RenameHousehold { name: "{ctx.read().household.name}" }
"px-sm-3", ul { class: "dropdown-menu",
"px-1", li { a { class: "dropdown-item", href: "#", onclick: move |_| logout(), "Logout" } }
"pt-2", hr {}
"text-white", li {
"min-vh-100" a {
)}> class: "dropdown-item",
<a href="/" class={classes!( href: "#",
"d-flex", "data-bs-toggle": "modal",
"align-items-center", "data-bs-target": "#leaveModal",
"pb-3", "Leave household"
"mb-md-0", }
"me-md-auto", }
"text-white", li {
"text-decoration-none" a {
)}> class: "dropdown-item",
<span class="fs-5 d-none d-sm-inline">{"Menu"}</span> href: "#",
</a> "data-bs-toggle": "modal",
<hr class={classes!("w-100", "d-none", "d-sm-inline")} /> "data-bs-target": "#addMember",
<ul id="menu" class={classes!( "Add member"
"nav", }
"nav-pills", }
"flex-column", li {
"mb-sm-auto", a {
"mb-0", class: "dropdown-item",
"align-items-center", href: "#",
"align-items-sm-start", "data-bs-toggle": "modal",
"w-100", "data-bs-target": "#renameHousehold",
)}> "Rename household"
{ }
for props.entries.iter().map(|e| { }
let active = if Some(e.page) == props.current.kind() { hr {}
Some("active") li {
} else { Link { to: "/household_selection", class: "dropdown-item", "Change household" }
None }
}; }
html! { }
<li class="nav-item w-100"> })
<Link<Route>
classes={classes!(
"nav-link",
"text-white",
active,
)}
to={e.page.redirect_to()}
>
<i class={classes!("fs-4", e.icon)}></i>
<span class={classes!("ms-2", "d-none", "d-sm-inline")}>
{e.label}
</span>
</Link<Route>>
</li>
}
})
}
</ul>
<hr class="w-100" />
<div class={classes!("dropdown")}>
<a href="#"
data-bs-toggle="dropdown"
aria-expanded="false"
class={classes!(
"d-flex",
"align-items-center",
"text-white",
"text-decoration-none",
"dropdown-toggle",
)}
>
<i class={classes!("fs-4", "bi-house-door-fill")}></i>
<strong class={classes!("ms-2", "d-none", "d-sm-inline")}>
{format!("{} ({})", global_state.household.name, global_state.token.name)}
</strong>
</a>
<ConfirmDangerModal
id="leaveModal"
title="Leaving Household"
centered={true}
on_confirm={leave_household}
>
{format!("Are you sure you want to leave the household '{}' ?", global_state.household.name)}
</ConfirmDangerModal>
<AddMemberModal
token={global_state.token.token.clone()}
household={global_state.household.id}
/>
<RenameHouseholdModal
token={global_state.token.token.clone()}
household={global_state.household.id}
name={global_state.household.name.clone()}
{on_rename}
/>
<ul class="dropdown-menu">
<li>
<a
class="dropdown-item"
href="#"
onclick={logout}
>
{"Logout"}
</a>
</li>
<hr />
<li>
<a
class="dropdown-item"
href="#"
data-bs-toggle="modal"
data-bs-target="#leaveModal"
>
{"Leave Household"}
</a>
</li>
<li>
<a
class="dropdown-item"
href="#"
data-bs-toggle="modal"
data-bs-target="#addMember"
>
{"Add Member"}
</a>
</li>
<li>
<a
class="dropdown-item"
href="#"
data-bs-toggle="modal"
data-bs-target="#renameHousehold"
>
{"Rename household"}
</a>
</li>
<hr />
<li>
<Link<Route>
classes={classes!("dropdown-item")}
to={Route::HouseholdSelect}
>
{"Change household"}
</Link<Route>>
</li>
</ul>
</div>
</div>
</div>
<div class={classes!("col", "py-3", "overflow-scroll", "vh-100")}>
{ for props.children.iter() }
</div>
</div>
</div>
}
} }
#[derive(Properties, PartialEq)] fn Sidebar<'a>(cx: Scope<'a, SidebarProps<'a>>) -> Element {
pub(crate) struct RegaladeSidebarProps { let entries = cx.props.entries.iter().map(|e| {
pub(crate) current: Route, let active = if e.page == cx.props.current {
pub(crate) children: Children, "active"
} else {
""
};
rsx! {
li { class: "nav-item w-100",
Link { to: e.page.to(), class: "nav-link text-white {active}",
i { class: "fs-4 {e.icon}" }
span { class: "ms-2 d-none d-sm-inline", "{e.label}" }
}
}
}
});
cx.render(rsx! {
div { class: "container-fluid",
div { class: "row flex-nowrap",
div { class: "col-auto col-md-3 col-xl-2 px-sm-2 px-0 bg-dark-subtle",
div { class: "d-flex flex-column align-items-center align-items-sm-start px-sm-3 px-1 pt-2 text-white min-vh-100",
Link {
to: "/",
class: "d-flex align-items-center pb-3 mb-md-0 me-md-auto text-white text-decoration-none",
span { class: "fs-5 d-none d-sm-inline", "Menu" }
}
hr { class: "w-100 d-none d-sm-inline" }
ul {
id: "menu",
class: "nav nav-pills flex-column mb-sm-auto mb-0 align-items-center align-items-sm-start w-100",
entries
}
hr { class: "w-100" }
SidebarDropdown {}
}
}
div { class: "col py-3 overflow-scroll vh-100", &cx.props.children }
}
}
})
} }
#[function_component] #[derive(Props)]
pub(crate) fn RegaladeSidebar(props: &RegaladeSidebarProps) -> Html { pub struct RegaladeSidebarProps<'a> {
current: Page,
children: Element<'a>,
}
pub fn RegaladeSidebar<'a>(cx: Scope<'a, RegaladeSidebarProps<'a>>) -> Element {
let entries = vec![ let entries = vec![
MenuEntry { MenuEntry {
label: "Home", label: "Home",
icon: "bi-house", icon: "bi-house",
page: RouteKind::Index, page: Page::Home,
}, },
MenuEntry { MenuEntry {
label: "Recipes", label: "Recipes",
icon: "bi-book", icon: "bi-book",
page: RouteKind::Recipe, page: Page::RecipeList,
}, },
MenuEntry { MenuEntry {
label: "Ingredients", label: "Ingredients",
icon: "bi-egg-fill", icon: "bi-egg-fill",
page: RouteKind::Ingredients, page: Page::Ingredients,
}, },
MenuEntry { MenuEntry {
label: "New Recipe", label: "New Recipe",
icon: "bi-clipboard2-plus-fill", icon: "bi-clipboard2-plus-fill",
page: RouteKind::NewRecipe, page: Page::RecipeCreator,
}, },
]; ];
html! { cx.render(rsx! {
<Sidebar {entries} current={props.current}> FullContextRedirect {
{ for props.children.iter() } Sidebar { current: cx.props.current, entries: entries, &cx.props.children }
</Sidebar> }
} })
} }

17
flake.lock generated
View file

@ -1,5 +1,21 @@
{ {
"nodes": { "nodes": {
"dioxus": {
"flake": false,
"locked": {
"lastModified": 1688154505,
"narHash": "sha256-ZDh7HVVY7ZoHmvv+vRwXZNT/ebHUnQVf6dmt8AM64o8=",
"owner": "DioxusLabs",
"repo": "dioxus",
"rev": "6512c153dd0ded101eb818f35cda87af99f26a31",
"type": "github"
},
"original": {
"owner": "DioxusLabs",
"repo": "dioxus",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@ -102,6 +118,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"dioxus": "dioxus",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"naersk": "naersk", "naersk": "naersk",
"nixpkgs": "nixpkgs_2", "nixpkgs": "nixpkgs_2",

View file

@ -8,6 +8,10 @@
url = "github:thedodd/trunk"; url = "github:thedodd/trunk";
flake = false; flake = false;
}; };
inputs.dioxus = {
url = "github:DioxusLabs/dioxus";
flake = false;
};
outputs = { outputs = {
self, self,
@ -16,6 +20,7 @@
naersk, naersk,
rust-overlay, rust-overlay,
trunk, trunk,
dioxus,
}: }:
flake-utils.lib.eachDefaultSystem (system: let flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs { pkgs = import nixpkgs {
@ -34,6 +39,11 @@
nativeBuildInputs = [ nativeBuildInputs = [
rust rust
(naersk'.buildPackage trunk) (naersk'.buildPackage trunk)
(naersk'.buildPackage {
src = "${dioxus}/packages/cli";
buildInputs = [pkgs.openssl];
nativeBuildInputs = [pkgs.pkg-config];
})
pkgs.httpie pkgs.httpie
pkgs.sea-orm-cli pkgs.sea-orm-cli
]; ];