app: Swap to using dioxus instead of yew
This commit is contained in:
parent
02a4187c39
commit
c80cc99255
29 changed files with 3558 additions and 3639 deletions
1164
Cargo.lock
generated
1164
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
26
app/Dioxus.toml
Normal 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 = []
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
[[hooks]]
|
|
||||||
stage = "build"
|
|
||||||
command = "./dl_bootstrap.sh"
|
|
||||||
|
|
||||||
[[hooks]]
|
|
||||||
stage = "build"
|
|
||||||
command = "./dl_bootstrap_icons.sh"
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
36
app/dl_deps.sh
Executable 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
|
||||||
|
|
@ -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
2
app/public/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/bootstrap-icons
|
||||||
|
/bootstrap
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
231
app/src/bootstrap/mod.rs
Normal 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
189
app/src/full_context.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
let edit_name = use_state(cx, String::new);
|
||||||
|
let edit_unit = use_state(cx, String::new);
|
||||||
|
let edit_id = use_state(cx, || None);
|
||||||
|
let item_edit = |&id, current: IngredientInfo| {
|
||||||
|
to_owned![edit_name, edit_unit, edit_id];
|
||||||
|
move |_| {
|
||||||
|
edit_id.set(Some(id));
|
||||||
|
edit_name.set(current.name.clone());
|
||||||
|
edit_unit.set(current.unit.clone().unwrap_or_default());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component]
|
|
||||||
fn IngredientList(props: &IngredientListProps) -> HtmlResult {
|
|
||||||
let fetch_id = use_state(|| 0u64);
|
|
||||||
let ingredients = use_future_with_deps(
|
|
||||||
|_| fetch_ingredients(props.token.clone(), props.household),
|
|
||||||
(*fetch_id as u128) << 64 | props.render_id as u128,
|
|
||||||
)?;
|
|
||||||
let error = use_state(|| None::<String>);
|
|
||||||
|
|
||||||
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");
|
|
||||||
let unit = unit.value();
|
|
||||||
|
|
||||||
let token = token.clone();
|
|
||||||
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(_) => {
|
Ok(_) => {
|
||||||
|
fetch_id.set(fetch_id.wrapping_add(1));
|
||||||
let modal = bs::Modal::get_instance("#editIgModal");
|
let modal = bs::Modal::get_instance("#editIgModal");
|
||||||
modal.hide();
|
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 token = props.token.clone();
|
|
||||||
let err = global_error.clone();
|
|
||||||
let item_delete = move |id| {
|
|
||||||
let fetch = fetch_id.clone();
|
|
||||||
let err = err.clone();
|
|
||||||
let token = token.clone();
|
|
||||||
|
|
||||||
Callback::from(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 {
|
|
||||||
Ok(_) => {
|
|
||||||
fetch.set(*fetch + 1);
|
|
||||||
}
|
|
||||||
Err(e) => err.set(Some(format!("Could not edit ingredient: {e:?}"))),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(match &*ingredients {
|
let delete_ig = |&id| {
|
||||||
Ok(l) => html! {<>
|
to_owned![token];
|
||||||
if let Some(err) = &*global_error {
|
move |_| {
|
||||||
<div class={classes!("alert", "alert-danger")} role="alert">
|
to_owned![fetch_id, error, token];
|
||||||
{err}
|
|
||||||
</div>
|
cx.spawn(async move {
|
||||||
}
|
match do_delete_ingredient(token, household, id).await {
|
||||||
<ul class="list-group list-group-flush text-start">
|
Ok(_) => fetch_id.set(fetch_id.wrapping_add(1)),
|
||||||
{ for l.ingredients.iter().sorted_by_key(|(&k,_)| k).map(|(&k,i)| {
|
Err(e) => error.set(Some(format!("Could not delete ingredient: {e:?}"))),
|
||||||
html! {
|
|
||||||
<li class="list-group-item d-flex align-items-center" key={k}>
|
|
||||||
<p class="flex-fill m-auto">
|
|
||||||
{&i.name}
|
|
||||||
if let Some(unit) = &i.unit {
|
|
||||||
{format!(" (unit: {unit})")}
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary"
|
|
||||||
onclick={item_edit(k, i.clone())}
|
|
||||||
data-bs-toggle="modal"
|
|
||||||
data-bs-target="#editIgModal"
|
|
||||||
>
|
|
||||||
<i class={classes!("bi-pencil-fill")}></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-danger ms-1"
|
|
||||||
onclick={item_delete(k)}
|
|
||||||
>
|
|
||||||
<i class={classes!("bi-trash-fill")}></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</ul>
|
};
|
||||||
<FormModal
|
|
||||||
centered={true}
|
cx.render(match future.value() {
|
||||||
id="editIgModal"
|
Some(Err(e)) => rsx! { ErrorAlert { error: "Could not fetch ingredients: {e}" } },
|
||||||
submit_label="Edit"
|
Some(Ok(ingredients)) => rsx! {
|
||||||
title="Edit Ingredient"
|
ErrorView { error: error }
|
||||||
{on_submit}
|
ul { class: "list-group list-group-flush text-start",
|
||||||
>
|
for (id , info) in ingredients.ingredients.iter().sorted_by_key(|(&k, _)| k) {
|
||||||
if let Some(err) = &*error {
|
li { key: "{id}", class: "list-group-item d-flex align-items-center",
|
||||||
<div class={classes!("alert", "alert-danger")} role="alert">
|
p { class: "flex-fill m-auto",
|
||||||
{err}
|
"{info.name}"
|
||||||
</div>
|
if let Some(unit) = &info.unit {
|
||||||
|
format!(" (unit: {unit})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
"type": "button",
|
||||||
|
class: "btn btn-primary",
|
||||||
|
"data-bs-toggle": "modal",
|
||||||
|
"data-bs-target": "#editIgModal",
|
||||||
|
onclick: item_edit(id, info.clone()),
|
||||||
|
i { class: "bi-pencil-fill" }
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
"type": "button",
|
||||||
|
class: "btn btn-danger ms-1",
|
||||||
|
onclick: delete_ig(id),
|
||||||
|
i { class: "bi-trash-fill" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FormModal {
|
||||||
|
centered: true,
|
||||||
|
id: "editIgModal",
|
||||||
|
submit_label: "Edit",
|
||||||
|
title: "Edit ingredient",
|
||||||
|
on_submit: on_edit_ig,
|
||||||
|
ErrorView { error: error }
|
||||||
|
div { class: "form-floating",
|
||||||
|
input {
|
||||||
|
id: "editIgName",
|
||||||
|
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",
|
||||||
|
input {
|
||||||
|
id: "editIgUnit",
|
||||||
|
class: "form-control",
|
||||||
|
placeholder: "Ingredient unit",
|
||||||
|
value: "{edit_unit}",
|
||||||
|
oninput: move |e| edit_unit.set(e.value.clone())
|
||||||
|
}
|
||||||
|
label { "for": "editIgUnit", "Ingredient unit" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
<div class="form-floating">
|
|
||||||
<input
|
|
||||||
id="editIgName"
|
|
||||||
class={classes!("form-control")}
|
|
||||||
placeholder="Ingredient Name"
|
|
||||||
value={edit_state.as_ref().map(|s| s.1.name.clone())}
|
|
||||||
/>
|
|
||||||
<label for="editIgName">{"Ingredient name"}</label>
|
|
||||||
</div>
|
|
||||||
<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 {
|
||||||
|
Err(e) => {
|
||||||
|
error.set(Some(format!("Could not add ingredient: {e}")));
|
||||||
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
name_elem.set_value("");
|
render_id.set(render_id.wrapping_add(1));
|
||||||
unit_elem.set_value("");
|
|
||||||
render.set(*render + 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"
|
||||||
}
|
}
|
||||||
<div class="form-floating">
|
label { "for": "newIgName", "Ingredient name" }
|
||||||
<input
|
}
|
||||||
type="text"
|
div { class: "form-floating my-1",
|
||||||
class="form-control"
|
input {
|
||||||
placeholder="Ingredient Name"
|
name: "newIgUnit",
|
||||||
id="newIgName"
|
id: "newIgUnit",
|
||||||
/>
|
placeholder: "Ingredient unit",
|
||||||
<label for="newIgName">{"Ingredient Name"}</label>
|
class: "form-control"
|
||||||
</div>
|
}
|
||||||
<div class="form-floating my-1">
|
label { "for": "newIgUnit", "Ingredient unit" }
|
||||||
<input
|
}
|
||||||
type="text"
|
button { class: "btn btn-primary mt-2", "Add Ingredient" }
|
||||||
class="form-control"
|
}
|
||||||
placeholder="Ingredient Unit"
|
hr {}
|
||||||
id="newIgUnit"
|
IngredientList { render_id: *render_id.get() }
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
876
app/src/main.rs
876
app/src/main.rs
|
|
@ -1,29 +1,33 @@
|
||||||
use gloo_storage::{errors::StorageError, LocalStorage, Storage};
|
#![allow(non_snake_case)]
|
||||||
use itertools::Itertools;
|
use std::rc::Rc;
|
||||||
use log::Level;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
use web_sys::HtmlInputElement;
|
|
||||||
use yew::{prelude::*, suspense::use_future};
|
|
||||||
use yew_router::prelude::*;
|
|
||||||
|
|
||||||
use api::{
|
use api::{
|
||||||
AddToHouseholdRequest, CreateHouseholdRequest, CreateHouseholdResponse, Household,
|
AddToHouseholdRequest, CreateHouseholdRequest, CreateHouseholdResponse, LoginRequest,
|
||||||
LoginRequest, LoginResponse, UserInfo,
|
LoginResponse, UserInfo,
|
||||||
};
|
};
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_router::{use_router, Route, Router};
|
||||||
|
use gloo_storage::{errors::StorageError, LocalStorage, Storage};
|
||||||
|
use itertools::Itertools;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bootstrap::{bs, FormModal, ModalToggleButton},
|
bootstrap::{bs, FormModal, ModalToggleButton, Spinner},
|
||||||
sidebar::RegaladeSidebar,
|
sidebar::Page,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod bootstrap;
|
mod bootstrap;
|
||||||
mod ingredients;
|
mod ingredients;
|
||||||
mod recipe_creator;
|
|
||||||
mod sidebar;
|
mod sidebar;
|
||||||
|
|
||||||
mod recipe;
|
mod recipe;
|
||||||
|
|
||||||
|
mod full_context;
|
||||||
|
|
||||||
|
pub use full_context::{use_full_context, use_trimmed_context};
|
||||||
|
use sidebar::RegaladeSidebar;
|
||||||
|
|
||||||
const API_ROUTE: &str = match option_env!("REGALADE_API_SERVER_BASE") {
|
const API_ROUTE: &str = match option_env!("REGALADE_API_SERVER_BASE") {
|
||||||
None => "http://localhost:8085",
|
None => "http://localhost:8085",
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
|
|
@ -37,151 +41,229 @@ macro_rules! api {
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Routable, Debug, Clone, Copy, PartialEq, Eq)]
|
#[macro_export]
|
||||||
enum Route {
|
macro_rules! to_owned_props {
|
||||||
#[at("/")]
|
// Rule matching simple symbols without a path
|
||||||
Index,
|
($es:ident $(, $($rest:tt)*)?) => {
|
||||||
#[at("/login")]
|
#[allow(unused_mut)]
|
||||||
Login,
|
let mut $es = $es.to_owned();
|
||||||
#[at("/ingredients")]
|
$( to_owned_props![$($rest)*] )?
|
||||||
Ingredients,
|
};
|
||||||
#[at("/household_select")]
|
|
||||||
HouseholdSelect,
|
// We need to find the last element in a path, for this we need to unstack the path part by
|
||||||
#[at("/new_recipe")]
|
// part using, separating what we have with a '@'
|
||||||
NewRecipe,
|
($($deref:ident).+ $(, $($rest:tt)*)?) => {
|
||||||
#[at("/recipe/:id")]
|
to_owned_props![@ $($deref).+ $(, $($rest)*)?]
|
||||||
Recipe { id: i64 },
|
};
|
||||||
#[at("/recipe")]
|
|
||||||
SearchRecipe,
|
// Take the head of the path and add it to the list of $deref
|
||||||
#[at("/404")]
|
($($deref:ident)* @ $head:ident $( . $tail:ident)+ $(, $($rest:tt)*)?) => {
|
||||||
#[not_found]
|
to_owned_props![$($deref)* $head @ $($tail).+ $(, $($rest)*)?]
|
||||||
NotFound,
|
};
|
||||||
|
// We have exhausted the path, use the last as a name
|
||||||
|
($($deref:ident)* @ $last:ident $(, $($rest:tt)*)? ) => {
|
||||||
|
#[allow(unused_mut)]
|
||||||
|
let mut $last = $($deref .)* $last .to_owned();
|
||||||
|
$(to_owned_props![$($rest)*])?
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Props)]
|
||||||
enum RouteKind {
|
pub struct ErrorProps<'a> {
|
||||||
Index,
|
error: &'a Option<String>,
|
||||||
Ingredients,
|
|
||||||
NewRecipe,
|
|
||||||
Recipe,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Route {
|
pub fn ErrorView<'a>(cx: Scope<'a, ErrorProps<'a>>) -> Element {
|
||||||
fn kind(&self) -> Option<RouteKind> {
|
cx.props
|
||||||
match self {
|
.error
|
||||||
Route::Index => Some(RouteKind::Index),
|
.as_ref()
|
||||||
Route::Ingredients => Some(RouteKind::Ingredients),
|
.and_then(|err| cx.render(rsx! { ErrorAlert { error: "{err}" } }))
|
||||||
Route::NewRecipe => Some(RouteKind::NewRecipe),
|
|
||||||
Route::Recipe { .. } => Some(RouteKind::Recipe),
|
|
||||||
Route::SearchRecipe => Some(RouteKind::Recipe),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RouteKind {
|
#[inline_props]
|
||||||
fn redirect_to(&self) -> Route {
|
pub fn ErrorAlert<'a>(cx: Scope<'a>, error: &'a str) -> Element<'a> {
|
||||||
match self {
|
cx.render(rsx! {
|
||||||
RouteKind::Index => Route::Index,
|
div { class: "alert alert-danger", *error }
|
||||||
RouteKind::Ingredients => Route::Ingredients,
|
})
|
||||||
RouteKind::NewRecipe => Route::NewRecipe,
|
|
||||||
RouteKind::Recipe => Route::SearchRecipe,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component]
|
|
||||||
fn App() -> Html {
|
|
||||||
html! {
|
|
||||||
<BrowserRouter>
|
|
||||||
<Switch<Route> render={switch} />
|
|
||||||
</BrowserRouter>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
struct HouseholdInfo {
|
pub struct LoginInfo {
|
||||||
id: Uuid,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
struct LoginInfo {
|
|
||||||
token: String,
|
token: String,
|
||||||
name: String,
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
pub fn use_login(cx: &ScopeState) -> UseSharedState<LoginInfo> {
|
||||||
struct RegaladeGlobalState {
|
use_shared_state::<LoginInfo>(cx)
|
||||||
token: LoginInfo,
|
.expect("no login info in scope")
|
||||||
household: HouseholdInfo,
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RegaladeGlobalState {
|
pub fn use_error(cx: &ScopeState) -> &UseState<Option<String>> {
|
||||||
pub fn get_or_navigate(navigator: Navigator) -> Option<Self> {
|
use_state(cx, || None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Callback {
|
||||||
|
pub cb: Rc<dyn Fn()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Callback {
|
||||||
|
pub fn call(&self) {
|
||||||
|
(self.cb)()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::vtable_address_comparisons)]
|
||||||
|
impl PartialEq for Callback {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
Rc::ptr_eq(&self.cb, &other.cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Rc<dyn Fn()>> for Callback {
|
||||||
|
fn from(cb: Rc<dyn Fn()>) -> Self {
|
||||||
|
Self { cb }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F> From<F> for Callback
|
||||||
|
where
|
||||||
|
F: Fn() + 'static,
|
||||||
|
{
|
||||||
|
fn from(cb: F) -> Self {
|
||||||
|
Self { cb: Rc::new(cb) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_refresh(cx: &ScopeState) -> (u64, Callback) {
|
||||||
|
let refresh = use_state(cx, || 0u64);
|
||||||
|
|
||||||
|
let callback = {
|
||||||
|
to_owned![refresh];
|
||||||
|
Callback::from(move || refresh.set(refresh.wrapping_add(1)))
|
||||||
|
};
|
||||||
|
|
||||||
|
(**refresh, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct HouseholdInfo {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Props)]
|
||||||
|
pub struct RedirectorProps<'a> {
|
||||||
|
children: Element<'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn LoginRedirect<'a>(cx: Scope<'a, RedirectorProps<'a>>) -> Element {
|
||||||
|
let router = use_router(cx);
|
||||||
|
|
||||||
let token = match LocalStorage::get::<LoginInfo>("token") {
|
let token = match LocalStorage::get::<LoginInfo>("token") {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(StorageError::KeyNotFound(_)) => {
|
Err(StorageError::KeyNotFound(_)) => {
|
||||||
navigator.push(&Route::Login);
|
router.navigate_to("/login");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Err(e) => unreachable!("Could not get token: {e:?}"),
|
Err(e) => unreachable!("Could not get token: {e:?}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let household = match LocalStorage::get::<HouseholdInfo>("household") {
|
use_shared_state_provider(cx, || token);
|
||||||
Ok(v) => v,
|
|
||||||
Err(StorageError::KeyNotFound(_)) => {
|
cx.render(rsx! {&cx.props.children})
|
||||||
navigator.push(&Route::HouseholdSelect);
|
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
Err(e) => unreachable!("Could not get household: {e:?}"),
|
|
||||||
|
async fn do_login(username: String, password: String) -> anyhow::Result<()> {
|
||||||
|
let rsp = gloo_net::http::Request::post(api!("login"))
|
||||||
|
.json(&LoginRequest {
|
||||||
|
username: username.clone(),
|
||||||
|
password,
|
||||||
|
})?
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if rsp.status() == 404 {
|
||||||
|
anyhow::bail!("Account not foud")
|
||||||
|
} else if !rsp.ok() {
|
||||||
|
let body = rsp.text().await?;
|
||||||
|
anyhow::bail!("Request failed: {body:?}")
|
||||||
|
}
|
||||||
|
|
||||||
|
let rsp: LoginResponse = rsp.json().await?;
|
||||||
|
|
||||||
|
LocalStorage::set(
|
||||||
|
"token",
|
||||||
|
LoginInfo {
|
||||||
|
token: rsp.token,
|
||||||
|
name: username,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn Login(cx: Scope) -> Element {
|
||||||
|
let error = use_state(cx, || None::<String>);
|
||||||
|
let router = use_router(cx);
|
||||||
|
|
||||||
|
let on_submit = move |e: Event<FormData>| {
|
||||||
|
to_owned![error, router];
|
||||||
|
cx.spawn(async move {
|
||||||
|
match do_login(
|
||||||
|
e.values["username"].to_string(),
|
||||||
|
e.values["password"].to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
error.set(None);
|
||||||
|
router.navigate_to("/");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error.set(Some(format!("Could not log in: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(Self { token, household })
|
cx.render(rsx! {
|
||||||
|
link { href: "/login.css", rel: "stylesheet" }
|
||||||
|
form {
|
||||||
|
onsubmit: on_submit,
|
||||||
|
prevent_default: "onsubmit",
|
||||||
|
class: "form-signin w-100 m-auto text-center",
|
||||||
|
h1 { class: "h3 mb-3", "Please log in" }
|
||||||
|
ErrorView { error: error }
|
||||||
|
div { class: "form-floating",
|
||||||
|
input {
|
||||||
|
name: "username",
|
||||||
|
id: "floatingUser",
|
||||||
|
class: "form-control",
|
||||||
|
placeholder: "Username"
|
||||||
}
|
}
|
||||||
|
label { "for": "floatingUser", "Username" }
|
||||||
pub fn get() -> Self {
|
|
||||||
let token = match LocalStorage::get::<LoginInfo>("token") {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => unreachable!("Could not get token: {e:?}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let household = match LocalStorage::get::<HouseholdInfo>("household") {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => unreachable!("Could not get household: {e:?}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
Self { token, household }
|
|
||||||
}
|
}
|
||||||
|
div { class: "form-floating",
|
||||||
|
input {
|
||||||
|
name: "password",
|
||||||
|
id: "floatingPass",
|
||||||
|
class: "form-control",
|
||||||
|
placeholder: "Password",
|
||||||
|
"type": "password"
|
||||||
}
|
}
|
||||||
|
label { "for": "floatingPass", "Password" }
|
||||||
#[derive(Debug, PartialEq, Properties)]
|
|
||||||
struct GlobalStateRedirectorProps {
|
|
||||||
children: Children,
|
|
||||||
route: Route,
|
|
||||||
}
|
}
|
||||||
|
button { class: "w-100 btn btn-lg btn-primary", "type": "submit", "Login" }
|
||||||
#[function_component]
|
|
||||||
fn GlobalStateRedirector(props: &GlobalStateRedirectorProps) -> Html {
|
|
||||||
let navigator = use_navigator().unwrap();
|
|
||||||
let s = RegaladeGlobalState::get_or_navigate(navigator);
|
|
||||||
|
|
||||||
match s {
|
|
||||||
Some(_) => {
|
|
||||||
html! {
|
|
||||||
<RegaladeSidebar current={props.route}>
|
|
||||||
{ for props.children.iter() }
|
|
||||||
</RegaladeSidebar>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => html! {},
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn do_new_household(token: String, name: String) -> anyhow::Result<Uuid> {
|
async fn do_new_household(token: String, name: String) -> anyhow::Result<Uuid> {
|
||||||
let rsp = gloo_net::http::Request::post(api!("household"))
|
let rsp = gloo_net::http::Request::post(api!("household"))
|
||||||
.json(&CreateHouseholdRequest { name })?
|
|
||||||
.header("Authorization", &format!("Bearer {token}"))
|
.header("Authorization", &format!("Bearer {token}"))
|
||||||
|
.json(&CreateHouseholdRequest { name })?
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -194,79 +276,6 @@ async fn do_new_household(token: String, name: String) -> anyhow::Result<Uuid> {
|
||||||
Ok(rsp.id)
|
Ok(rsp.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_households(token: String) -> anyhow::Result<api::Households> {
|
|
||||||
let rsp = gloo_net::http::Request::get(api!("household"))
|
|
||||||
.header("Authorization", &format!("Bearer {token}"))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !rsp.ok() {
|
|
||||||
anyhow::bail!("Request failed: {rsp:?}")
|
|
||||||
}
|
|
||||||
|
|
||||||
let rsp: api::Households = rsp.json().await?;
|
|
||||||
|
|
||||||
Ok(rsp)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component]
|
|
||||||
fn HouseholdListSelect() -> HtmlResult {
|
|
||||||
let token = use_state(|| match LocalStorage::get::<LoginInfo>("token") {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => unreachable!("Need to be logged in to list households: {e:?}"),
|
|
||||||
});
|
|
||||||
let households = use_future(move || fetch_households(token.token.to_string()))?;
|
|
||||||
let navigator = use_navigator().unwrap();
|
|
||||||
|
|
||||||
let mk_household = |(id, info): (_, Household)| {
|
|
||||||
let name = info.name.clone();
|
|
||||||
let nav = navigator.clone();
|
|
||||||
let onclick = Callback::from(move |_| {
|
|
||||||
if let Err(e) = LocalStorage::set(
|
|
||||||
"household",
|
|
||||||
HouseholdInfo {
|
|
||||||
id,
|
|
||||||
name: name.clone(),
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
log::error!("Could not select household: {e:?}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav.push(&Route::Index);
|
|
||||||
});
|
|
||||||
html! {<>
|
|
||||||
<button class={classes!("btn", "btn-secondary", "m-1")} {onclick}>
|
|
||||||
{&info.name}
|
|
||||||
</button>
|
|
||||||
<br />
|
|
||||||
</>}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(match &*households {
|
|
||||||
Ok(households) => html! {
|
|
||||||
{ for households
|
|
||||||
.households
|
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.sorted_by_key(|(_,i)| i.name.clone())
|
|
||||||
.map(mk_household)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Could not fetch households: {e:?}");
|
|
||||||
html! {
|
|
||||||
{"<ERROR>"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
struct CreateHouseholdProps {
|
|
||||||
token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn do_resolve_user(token: String, username: String) -> anyhow::Result<Option<Uuid>> {
|
async fn do_resolve_user(token: String, username: String) -> anyhow::Result<Option<Uuid>> {
|
||||||
let rsp = gloo_net::http::Request::get(api!("search/user/{username}"))
|
let rsp = gloo_net::http::Request::get(api!("search/user/{username}"))
|
||||||
.header("Authorization", &format!("Bearer {token}"))
|
.header("Authorization", &format!("Bearer {token}"))
|
||||||
|
|
@ -292,8 +301,8 @@ pub async fn do_add_user_to_household(
|
||||||
user: Uuid,
|
user: Uuid,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let rsp = gloo_net::http::Request::put(api!("household/{household}"))
|
let rsp = gloo_net::http::Request::put(api!("household/{household}"))
|
||||||
.json(&AddToHouseholdRequest { user })?
|
|
||||||
.header("Authorization", &format!("Bearer {token}"))
|
.header("Authorization", &format!("Bearer {token}"))
|
||||||
|
.json(&AddToHouseholdRequest { user })?
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -304,338 +313,231 @@ pub async fn do_add_user_to_household(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component]
|
fn CreateHousehold(cx: Scope) -> Element {
|
||||||
fn CreateHousehold(props: &CreateHouseholdProps) -> Html {
|
let login = use_login(cx);
|
||||||
let error = use_state(|| None::<String>);
|
let error = use_state(cx, || None::<String>);
|
||||||
let navigator = use_navigator().unwrap();
|
let name = use_state(cx, String::new);
|
||||||
|
|
||||||
let members = use_state(Vec::<(Uuid, String)>::new);
|
let members = use_ref(cx, Vec::<(Uuid, String)>::new);
|
||||||
|
|
||||||
let err = error.clone();
|
let router = use_router(cx);
|
||||||
let tok = props.token.clone();
|
|
||||||
let mem = members.clone();
|
|
||||||
let on_submit = Callback::from(move |()| {
|
|
||||||
let document = gloo_utils::document();
|
|
||||||
let token = tok.clone();
|
|
||||||
|
|
||||||
let name: HtmlInputElement = document
|
let token = login.read().token.clone();
|
||||||
.get_element_by_id("newHsName")
|
let on_submit = move |_| {
|
||||||
.unwrap()
|
to_owned![members, name, error, token, router];
|
||||||
.dyn_into()
|
|
||||||
.expect("newHsName is not an input element");
|
|
||||||
let name = name.value();
|
|
||||||
|
|
||||||
let members = mem.clone();
|
cx.spawn(async move {
|
||||||
let err = err.clone();
|
match do_new_household(token.clone(), name.to_string()).await {
|
||||||
let navigator = navigator.clone();
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
match do_new_household(token.clone(), name.clone()).await {
|
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
let household_info = HouseholdInfo { name, id };
|
let info = HouseholdInfo {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
for (uid, user) in &*members {
|
for (uid, user) in members.read().iter() {
|
||||||
if let Err(e) = do_add_user_to_household(token.clone(), id, *uid).await {
|
if let Err(e) = do_add_user_to_household(token.clone(), id, *uid).await {
|
||||||
err.set(Some(format!(
|
error.set(Some(format!(
|
||||||
"Could not add user {user} (but household was created): {e:?}"
|
"Could not add user {user} (but household was created): {e:?}"
|
||||||
)));
|
)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = LocalStorage::set("household", household_info) {
|
if let Err(e) = LocalStorage::set("household", info) {
|
||||||
log::error!("Could not switch to new household: {e:?}")
|
log::error!("Could not switch to new household: {e:?}");
|
||||||
}
|
};
|
||||||
|
|
||||||
let modal = bs::Modal::get_instance("#newHsModal");
|
let modal = bs::Modal::get_instance("#newHsModal");
|
||||||
modal.hide();
|
modal.hide();
|
||||||
|
|
||||||
navigator.push(&Route::Index);
|
router.navigate_to("/");
|
||||||
err.set(None);
|
error.set(None);
|
||||||
members.set(Vec::new());
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
err.set(Some(format!("Could not create: {e:?}")));
|
error.set(Some(format!("Could not create household: {e:?}")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let err = error.clone();
|
|
||||||
let tok = props.token.clone();
|
|
||||||
let mem = members.clone();
|
|
||||||
let add_member = Callback::from(move |_| {
|
|
||||||
let document = gloo_utils::document();
|
|
||||||
|
|
||||||
let username: HtmlInputElement = document
|
|
||||||
.get_element_by_id("newHsAddMember")
|
|
||||||
.unwrap()
|
|
||||||
.dyn_into()
|
|
||||||
.expect("newHsAddMember is not an input element");
|
|
||||||
let username = username.value();
|
|
||||||
|
|
||||||
let tok = tok.clone();
|
|
||||||
let err = err.clone();
|
|
||||||
let members = mem.clone();
|
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
match do_resolve_user(tok.clone(), username.clone()).await {
|
|
||||||
Err(e) => {
|
|
||||||
err.set(Some(format!("Error adding a member: {e:?}")));
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
err.set(Some(format!("User '{username}' does not exist")));
|
|
||||||
}
|
|
||||||
Ok(Some(id)) => {
|
|
||||||
let mut m = (*members).clone();
|
|
||||||
m.push((id, username));
|
|
||||||
members.set(m);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let mem = members.clone();
|
|
||||||
let remove_user = |idx| {
|
|
||||||
Callback::from(move |_| {
|
|
||||||
let mut m = (*mem).clone();
|
|
||||||
m.remove(idx);
|
|
||||||
mem.set(m);
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
let new_member = use_state(cx, String::new);
|
||||||
<FormModal
|
let token = login.read().token.clone();
|
||||||
id="newHsModal"
|
let on_add_member = move |_| {
|
||||||
centered={true}
|
to_owned![new_member, members, error, token];
|
||||||
submit_label={"Create"}
|
|
||||||
title="Create a Household"
|
cx.spawn(async move {
|
||||||
{on_submit}
|
match do_resolve_user(token, new_member.to_string()).await {
|
||||||
>
|
Err(e) => {
|
||||||
if let Some(err) = &*error {
|
error.set(Some(format!("Could not add member: {e:?}")));
|
||||||
<div class={classes!("alert", "alert-danger")} role="alert">
|
}
|
||||||
{err}
|
Ok(None) => {
|
||||||
</div>
|
error.set(Some(format!("User {new_member} does not exist")));
|
||||||
|
}
|
||||||
|
Ok(Some(id)) => {
|
||||||
|
members.with_mut(|m| {
|
||||||
|
if !m.iter().any(|&(i, _)| i == id) {
|
||||||
|
m.push((id, new_member.to_string()))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
error.set(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
cx.render(rsx! {
|
||||||
|
FormModal {
|
||||||
|
id: "newHsModal",
|
||||||
|
fade: true,
|
||||||
|
centered: true,
|
||||||
|
submit_label: "Create",
|
||||||
|
title: "Create a Household",
|
||||||
|
on_submit: on_submit,
|
||||||
|
ErrorView { error: error }
|
||||||
|
div { class: "form-floating",
|
||||||
|
input {
|
||||||
|
id: "newHsName",
|
||||||
|
class: "form-control",
|
||||||
|
placeholder: "Household name",
|
||||||
|
oninput: move |ev| name.set(ev.value.clone())
|
||||||
|
}
|
||||||
|
label { "for": "newHsName", "Household name" }
|
||||||
|
}
|
||||||
|
h2 { class: "fs-5 m-2", "Additional Members" }
|
||||||
|
ul { class: "list-group list-group-flush",
|
||||||
|
for (idx , (id , name)) in members.read().iter().enumerate() {
|
||||||
|
li { key: "{id}", class: "list-group-item",
|
||||||
|
"{name}"
|
||||||
|
button {
|
||||||
|
"type": "button",
|
||||||
|
class: "btn btn-danger ms-2",
|
||||||
|
onclick: move |_| {
|
||||||
|
members
|
||||||
|
.with_mut(|m| {
|
||||||
|
m.remove(idx);
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"Remove"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "d-flex flex-row",
|
||||||
|
input {
|
||||||
|
id: "newHsAddMember",
|
||||||
|
class: "form-control me-2",
|
||||||
|
oninput: move |ev| new_member.set(ev.value.clone()),
|
||||||
|
placeholder: "Additional member"
|
||||||
|
}
|
||||||
|
button { "type": "button", class: "btn btn-primary", onclick: on_add_member, "Add" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
<div class="form-floating">
|
|
||||||
<input
|
|
||||||
id="newHsName"
|
|
||||||
class={classes!("form-control")}
|
|
||||||
placeholder="Household name"
|
|
||||||
/>
|
|
||||||
<label for="newHsName">{"Household name"}</label>
|
|
||||||
</div>
|
|
||||||
<h2 class={classes!("fs-5", "m-2")}>{"Additional Members"}</h2>
|
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
{
|
|
||||||
for members.iter().enumerate().map(move |(idx, (_, name))| html!{
|
|
||||||
<li class="list-group-item">
|
|
||||||
{name}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={classes!("btn", "btn-danger", "ms-2")}
|
|
||||||
onclick={remove_user.clone()(idx)}
|
|
||||||
>
|
|
||||||
{"Remove"}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</ul>
|
|
||||||
<div class="d-flex flex-row">
|
|
||||||
<input
|
|
||||||
id="newHsAddMember"
|
|
||||||
class={classes!("form-control", "me-2")}
|
|
||||||
placeholder="Additional member"
|
|
||||||
/>
|
|
||||||
<button type="button" class={classes!("btn", "btn-primary")} onclick={add_member}>
|
|
||||||
{"Add"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</FormModal>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component]
|
async fn fetch_households(token: String) -> anyhow::Result<api::Households> {
|
||||||
fn HouseholdSelection() -> Html {
|
let rsp = gloo_net::http::Request::get(api!("household"))
|
||||||
let token = use_state(|| match LocalStorage::get::<LoginInfo>("token") {
|
.header("Authorization", &format!("Bearer {token}"))
|
||||||
Ok(v) => Some(v),
|
|
||||||
Err(StorageError::KeyNotFound(_)) => None,
|
|
||||||
Err(e) => unreachable!("Could not get household: {e:?}"),
|
|
||||||
});
|
|
||||||
|
|
||||||
let fallback = html! { {"Loading..."} };
|
|
||||||
|
|
||||||
match &*token {
|
|
||||||
None => html! {
|
|
||||||
<Redirect<Route> to={Route::Login} />
|
|
||||||
},
|
|
||||||
Some(_) => html! {<>
|
|
||||||
<link href="/household_selection.css" rel="stylesheet" />
|
|
||||||
<div class={classes!("col-sm-3", "m-auto", "p-2", "text-center", "border", "rounded")}>
|
|
||||||
<h1 class="h3">{"Available"}</h1>
|
|
||||||
<hr />
|
|
||||||
<Suspense {fallback}>
|
|
||||||
<HouseholdListSelect />
|
|
||||||
</Suspense>
|
|
||||||
<hr />
|
|
||||||
<ModalToggleButton
|
|
||||||
classes={classes!("btn", "btn-lg", "btn-primary")}
|
|
||||||
modal_id="newHsModal"
|
|
||||||
>
|
|
||||||
{"New household"}
|
|
||||||
</ModalToggleButton>
|
|
||||||
<CreateHousehold token={token.as_ref().unwrap().token.to_owned()} />
|
|
||||||
</div>
|
|
||||||
</>},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn switch(route: Route) -> Html {
|
|
||||||
match route {
|
|
||||||
Route::Index => html! {
|
|
||||||
<GlobalStateRedirector {route}>
|
|
||||||
{"Index"}
|
|
||||||
</GlobalStateRedirector>
|
|
||||||
},
|
|
||||||
Route::Login => html! {
|
|
||||||
<Login />
|
|
||||||
},
|
|
||||||
Route::Ingredients => html! {
|
|
||||||
<GlobalStateRedirector {route}>
|
|
||||||
<ingredients::Ingredients />
|
|
||||||
</GlobalStateRedirector>
|
|
||||||
},
|
|
||||||
Route::HouseholdSelect => html! {
|
|
||||||
<HouseholdSelection />
|
|
||||||
},
|
|
||||||
Route::NewRecipe => html! {
|
|
||||||
<GlobalStateRedirector {route}>
|
|
||||||
<recipe_creator::RecipeCreator />
|
|
||||||
</GlobalStateRedirector>
|
|
||||||
},
|
|
||||||
Route::Recipe { id } => html! {
|
|
||||||
<GlobalStateRedirector {route}>
|
|
||||||
<recipe::RecipeViewer {id} />
|
|
||||||
</GlobalStateRedirector>
|
|
||||||
},
|
|
||||||
Route::SearchRecipe => html!{
|
|
||||||
<GlobalStateRedirector {route}>
|
|
||||||
<recipe::RecipeList />
|
|
||||||
</GlobalStateRedirector>
|
|
||||||
},
|
|
||||||
Route::NotFound => html! {
|
|
||||||
"Page not found"
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn do_login(username: String, password: String) -> anyhow::Result<()> {
|
|
||||||
let rsp = gloo_net::http::Request::post(api!("login"))
|
|
||||||
.json(&LoginRequest {
|
|
||||||
username: username.clone(),
|
|
||||||
password,
|
|
||||||
})?
|
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if rsp.status() == 404 {
|
if !rsp.ok() {
|
||||||
anyhow::bail!("Account not foud")
|
|
||||||
} else if !rsp.ok() {
|
|
||||||
anyhow::bail!("Request failed: {rsp:?}")
|
anyhow::bail!("Request failed: {rsp:?}")
|
||||||
}
|
}
|
||||||
|
|
||||||
let rsp: LoginResponse = rsp.json().await?;
|
let rsp: api::Households = rsp.json().await?;
|
||||||
|
|
||||||
LocalStorage::set(
|
Ok(rsp)
|
||||||
"token",
|
}
|
||||||
LoginInfo {
|
|
||||||
token: rsp.token,
|
fn HouseholdListSelect(cx: Scope) -> Element {
|
||||||
name: username,
|
let login = use_login(cx);
|
||||||
|
let households = use_future(cx, (), |_| fetch_households(login.read().token.clone()));
|
||||||
|
let router = use_router(cx);
|
||||||
|
|
||||||
|
cx.render(match households.value() {
|
||||||
|
Some(Ok(response)) => {
|
||||||
|
let households = response
|
||||||
|
.households
|
||||||
|
.iter()
|
||||||
|
.sorted_by_key(|(_, i)| i.name.clone())
|
||||||
|
.map(|(id, info)| {
|
||||||
|
let onclick = move |_| {
|
||||||
|
if let Err(e) = LocalStorage::set(
|
||||||
|
"household",
|
||||||
|
HouseholdInfo {
|
||||||
|
id: *id,
|
||||||
|
name: info.name.clone(),
|
||||||
},
|
},
|
||||||
)?;
|
) {
|
||||||
|
log::error!("Could not select household: {e:?}");
|
||||||
Ok(())
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component]
|
router.navigate_to("/");
|
||||||
fn Login() -> Html {
|
};
|
||||||
let error = use_state(|| None);
|
rsx! {button { key: "{id}", class: "btn btn-secondary m-1", onclick: onclick, "{info.name}" }}
|
||||||
|
|
||||||
let navigator = use_navigator().unwrap();
|
|
||||||
let err = error.clone();
|
|
||||||
let onsubmit = Callback::from(move |e: SubmitEvent| {
|
|
||||||
e.prevent_default();
|
|
||||||
|
|
||||||
let document = gloo_utils::document();
|
|
||||||
|
|
||||||
let username: HtmlInputElement = document
|
|
||||||
.get_element_by_id("floatingUser")
|
|
||||||
.unwrap()
|
|
||||||
.dyn_into()
|
|
||||||
.expect("floatingUser is not an input element");
|
|
||||||
let username = username.value();
|
|
||||||
|
|
||||||
let password: HtmlInputElement = document
|
|
||||||
.get_element_by_id("floatingPass")
|
|
||||||
.unwrap()
|
|
||||||
.dyn_into()
|
|
||||||
.expect("floatingUser is not an input element");
|
|
||||||
let password = password.value();
|
|
||||||
|
|
||||||
let err = err.clone();
|
|
||||||
let navigator = navigator.clone();
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
match do_login(username, password).await {
|
|
||||||
Ok(_) => {
|
|
||||||
navigator.push(&Route::Index);
|
|
||||||
err.set(None);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
err.set(Some(format!("Could not log in: {e:?}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
rsx! {households}
|
||||||
|
|
||||||
html! {<>
|
|
||||||
<link href="/login.css" rel="stylesheet" />
|
|
||||||
<form class={classes!("form-signin", "w-100", "m-auto", "text-center")} {onsubmit}>
|
|
||||||
<h1 class={classes!("h3", "mb-3")}>{"Please log in"}</h1>
|
|
||||||
if let Some(err) = &*error {
|
|
||||||
<div class={classes!("alert", "alert-danger")} role="alert">
|
|
||||||
{err}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
<div class={classes!("form-floating")}>
|
Some(Err(e)) => {
|
||||||
<input id="floatingUser" class={classes!("form-control")} placeholder="Username" />
|
rsx! { div { class: "alert alert-danger", "Could not fetch households: {e:?}" } }
|
||||||
<label for="floatingUser">{"Username"}</label>
|
}
|
||||||
</div>
|
None => rsx! { Spinner {} },
|
||||||
<div class={classes!("form-floating")}>
|
})
|
||||||
<input
|
}
|
||||||
id="floatingPass"
|
|
||||||
class={classes!("form-control")}
|
fn HouseholdSelection(cx: Scope) -> Element {
|
||||||
placeholder="Password"
|
cx.render(rsx! {
|
||||||
type="password"
|
link { href: "/household_selection.css", rel: "stylesheet" }
|
||||||
/>
|
div { class: "col-sm-3 m-auto p-2 text-center border rounded",
|
||||||
<label for="floatingPass">{"Password"}</label>
|
h1 { class: "h3", "Available" }
|
||||||
</div>
|
hr {}
|
||||||
<button class={classes!("w-100", "btn", "btn-lg", "btn-primary")} type="submit">
|
HouseholdListSelect {}
|
||||||
{"Login"}
|
hr {}
|
||||||
</button>
|
ModalToggleButton { class: "btn btn-lg btn-primary", modal_id: "newHsModal", "New household" }
|
||||||
</form>
|
CreateHousehold {}
|
||||||
</>}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn Index(cx: Scope) -> Element {
|
||||||
|
cx.render(rsx! {"INDEX"})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn App(cx: Scope) -> Element {
|
||||||
|
cx.render(rsx! {
|
||||||
|
Router {
|
||||||
|
Route { to: Page::Home.to(),
|
||||||
|
RegaladeSidebar { current: Page::Home, Index {} }
|
||||||
|
}
|
||||||
|
Route { to: Page::Ingredients.to(),
|
||||||
|
RegaladeSidebar { current: Page::Ingredients, ingredients::Ingredients {} }
|
||||||
|
}
|
||||||
|
Route { to: Page::RecipeCreator.to(),
|
||||||
|
RegaladeSidebar { current: Page::RecipeCreator, recipe::RecipeCreator {} }
|
||||||
|
}
|
||||||
|
Route { to: Page::RecipeList.to(),
|
||||||
|
RegaladeSidebar { current: Page::RecipeList, recipe::RecipeList {} }
|
||||||
|
}
|
||||||
|
Route { to: "/recipe/:recipe_id",
|
||||||
|
RegaladeSidebar { current: Page::RecipeList, recipe::RecipeView {} }
|
||||||
|
}
|
||||||
|
Route { to: "/login", Login {} }
|
||||||
|
Route { to: "/household_selection",
|
||||||
|
LoginRedirect { HouseholdSelection {} }
|
||||||
|
}
|
||||||
|
Route { to: "", "Not found" }
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
console_log::init_with_level(Level::Debug).unwrap();
|
console_log::init_with_level(log::Level::Info).unwrap();
|
||||||
|
|
||||||
yew::Renderer::<App>::with_root(
|
let html = gloo_utils::document_element();
|
||||||
gloo_utils::document()
|
html.set_attribute("data-bs-theme", "dark")
|
||||||
.body()
|
.expect("could not set dark theme");
|
||||||
.expect("no body")
|
|
||||||
.get_elements_by_tag_name("main")
|
dioxus_web::launch(App)
|
||||||
.item(0)
|
|
||||||
.expect("no main"),
|
|
||||||
)
|
|
||||||
.render();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1108
app/src/recipe.rs
1108
app/src/recipe.rs
File diff suppressed because it is too large
Load diff
373
app/src/recipe/creator.rs
Normal file
373
app/src/recipe/creator.rs
Normal 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
56
app/src/recipe/list.rs
Normal 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
150
app/src/recipe/mod.rs
Normal 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
760
app/src/recipe/view.rs
Normal 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 } })
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
let on_rename = Callback::from(move |_| {
|
|
||||||
gs.set(RegaladeGlobalState::get());
|
|
||||||
});
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class={classes!("row", "flex-nowrap")}>
|
|
||||||
<div class={classes!(
|
|
||||||
"col-auto",
|
|
||||||
"col-md-3",
|
|
||||||
"col-xl-2",
|
|
||||||
"px-sm-2",
|
|
||||||
"px-0",
|
|
||||||
"bg-dark-subtle"
|
|
||||||
)}>
|
|
||||||
<div class={classes!(
|
|
||||||
"d-flex",
|
|
||||||
"flex-column",
|
|
||||||
"align-items-center",
|
|
||||||
"align-items-sm-start",
|
|
||||||
"px-sm-3",
|
|
||||||
"px-1",
|
|
||||||
"pt-2",
|
|
||||||
"text-white",
|
|
||||||
"min-vh-100"
|
|
||||||
)}>
|
|
||||||
<a href="/" class={classes!(
|
|
||||||
"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"}</span>
|
|
||||||
</a>
|
|
||||||
<hr class={classes!("w-100", "d-none", "d-sm-inline")} />
|
|
||||||
<ul id="menu" class={classes!(
|
|
||||||
"nav",
|
|
||||||
"nav-pills",
|
|
||||||
"flex-column",
|
|
||||||
"mb-sm-auto",
|
|
||||||
"mb-0",
|
|
||||||
"align-items-center",
|
|
||||||
"align-items-sm-start",
|
|
||||||
"w-100",
|
|
||||||
)}>
|
|
||||||
{
|
|
||||||
for props.entries.iter().map(|e| {
|
|
||||||
let active = if Some(e.page) == props.current.kind() {
|
|
||||||
Some("active")
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
};
|
||||||
html! {
|
|
||||||
<li class="nav-item w-100">
|
cx.render(rsx! {
|
||||||
<Link<Route>
|
div { class: "dropdown",
|
||||||
classes={classes!(
|
a {
|
||||||
"nav-link",
|
href: "#",
|
||||||
"text-white",
|
"data-bs-toggle": "dropdown",
|
||||||
active,
|
"aria-expanded": "false",
|
||||||
)}
|
class: "d-flex align-items-center text-white text-decoration-none dropdown-toggle",
|
||||||
to={e.page.redirect_to()}
|
i { class: "fs-4 bi-house-door-fill" }
|
||||||
>
|
strong { class: "ms-2 d-none d-sm-inline",
|
||||||
<i class={classes!("fs-4", e.icon)}></i>
|
"{ctx.read().household.name} ({ctx.read().login.name})"
|
||||||
<span class={classes!("ms-2", "d-none", "d-sm-inline")}>
|
}
|
||||||
{e.label}
|
}
|
||||||
</span>
|
ConfirmDangerModal {
|
||||||
</Link<Route>>
|
id: "leaveModal",
|
||||||
</li>
|
title: "Leave household",
|
||||||
|
centered: true,
|
||||||
|
on_confirm: move |_| leave(),
|
||||||
|
"Are you sure you want to leave the household '{ctx.read().household.name}' ?"
|
||||||
|
}
|
||||||
|
AddMemberModal {}
|
||||||
|
RenameHousehold { name: "{ctx.read().household.name}" }
|
||||||
|
ul { class: "dropdown-menu",
|
||||||
|
li { a { class: "dropdown-item", href: "#", onclick: move |_| logout(), "Logout" } }
|
||||||
|
hr {}
|
||||||
|
li {
|
||||||
|
a {
|
||||||
|
class: "dropdown-item",
|
||||||
|
href: "#",
|
||||||
|
"data-bs-toggle": "modal",
|
||||||
|
"data-bs-target": "#leaveModal",
|
||||||
|
"Leave household"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
a {
|
||||||
|
class: "dropdown-item",
|
||||||
|
href: "#",
|
||||||
|
"data-bs-toggle": "modal",
|
||||||
|
"data-bs-target": "#addMember",
|
||||||
|
"Add member"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
a {
|
||||||
|
class: "dropdown-item",
|
||||||
|
href: "#",
|
||||||
|
"data-bs-toggle": "modal",
|
||||||
|
"data-bs-target": "#renameHousehold",
|
||||||
|
"Rename household"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hr {}
|
||||||
|
li {
|
||||||
|
Link { to: "/household_selection", class: "dropdown-item", "Change household" }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</ul>
|
|
||||||
<hr class="w-100" />
|
fn Sidebar<'a>(cx: Scope<'a, SidebarProps<'a>>) -> Element {
|
||||||
<div class={classes!("dropdown")}>
|
let entries = cx.props.entries.iter().map(|e| {
|
||||||
<a href="#"
|
let active = if e.page == cx.props.current {
|
||||||
data-bs-toggle="dropdown"
|
"active"
|
||||||
aria-expanded="false"
|
} else {
|
||||||
class={classes!(
|
""
|
||||||
"d-flex",
|
};
|
||||||
"align-items-center",
|
|
||||||
"text-white",
|
rsx! {
|
||||||
"text-decoration-none",
|
li { class: "nav-item w-100",
|
||||||
"dropdown-toggle",
|
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}" }
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Props)]
|
||||||
pub(crate) struct RegaladeSidebarProps {
|
pub struct RegaladeSidebarProps<'a> {
|
||||||
pub(crate) current: Route,
|
current: Page,
|
||||||
pub(crate) children: Children,
|
children: Element<'a>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component]
|
pub fn RegaladeSidebar<'a>(cx: Scope<'a, RegaladeSidebarProps<'a>>) -> Element {
|
||||||
pub(crate) fn RegaladeSidebar(props: &RegaladeSidebarProps) -> Html {
|
|
||||||
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
17
flake.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
10
flake.nix
10
flake.nix
|
|
@ -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
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue