Code cleanup
This commit is contained in:
parent
b52443f833
commit
df17deabf3
30 changed files with 5 additions and 4381 deletions
|
|
@ -5,7 +5,7 @@ authors = ["traxys <quentin@familleboyer.net>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [".", "api", "migration"]
|
members = [".", "migration"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.95"
|
anyhow = "1.0.95"
|
||||||
|
|
@ -15,17 +15,14 @@ serde = { version = "1.0.217", features = ["derive"] }
|
||||||
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = "0.3.19"
|
tracing-subscriber = "0.3.19"
|
||||||
api = { path = "./api" }
|
|
||||||
migration = { path = "./migration" }
|
migration = { path = "./migration" }
|
||||||
thiserror = "2.0.9"
|
thiserror = "2.0.9"
|
||||||
tower-http = { version = "0.6.2", features = ["cors", "fs"] }
|
tower-http = { version = "0.6.2", features = ["cors", "fs"] }
|
||||||
sha2 = "0.10"
|
|
||||||
uuid = { version = "1.11", features = ["v4"] }
|
uuid = { version = "1.11", features = ["v4"] }
|
||||||
sea-query = "0.32"
|
sea-query = "0.32"
|
||||||
openidconnect = "3.5.0"
|
openidconnect = "3.5.0"
|
||||||
envious = "0.2.2"
|
envious = "0.2.2"
|
||||||
parking_lot = "0.12.3"
|
parking_lot = "0.12.3"
|
||||||
urlencoding = "2.1.3"
|
|
||||||
tower-sessions = "0.13.0"
|
tower-sessions = "0.13.0"
|
||||||
tower = "0.5.2"
|
tower = "0.5.2"
|
||||||
time = "0.3.37"
|
time = "0.3.37"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "api"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
serde = { version = "1.0.217", features = ["derive"] }
|
|
||||||
uuid = { version = "1.11.0", features = ["serde"] }
|
|
||||||
143
api/src/lib.rs
143
api/src/lib.rs
|
|
@ -1,143 +0,0 @@
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct LoginRequest {
|
|
||||||
pub username: String,
|
|
||||||
pub password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct LoginResponse {
|
|
||||||
pub token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct EmptyResponse {}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct Household {
|
|
||||||
pub name: String,
|
|
||||||
pub members: Vec<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct Households {
|
|
||||||
pub households: HashMap<Uuid, Household>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CreateHouseholdRequest {
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CreateHouseholdResponse {
|
|
||||||
pub id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct RenameHouseholdRequest {
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct AddToHouseholdRequest {
|
|
||||||
pub user: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct UserInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CreateIngredientRequest {
|
|
||||||
pub name: String,
|
|
||||||
pub unit: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CreateIngredientResponse {
|
|
||||||
pub id: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct IngredientInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub unit: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct IngredientList {
|
|
||||||
pub ingredients: HashMap<i64, IngredientInfo>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct EditIngredientRequest {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub unit: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub has_unit: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
||||||
pub struct CreateRecipeRequest {
|
|
||||||
pub person_count: u32,
|
|
||||||
pub name: String,
|
|
||||||
pub rating: u8,
|
|
||||||
pub ingredients: Vec<(i64, f64)>,
|
|
||||||
pub steps: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CreateRecipeResponse {
|
|
||||||
pub id: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ListRecipesResponse {
|
|
||||||
pub recipes: Vec<(i64, String, u8)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
||||||
pub struct RecipeInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub rating: u8,
|
|
||||||
pub person_count: u32,
|
|
||||||
pub steps: String,
|
|
||||||
pub ingredients: Vec<(i64, IngredientInfo, f64)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct RecipeRenameRequest {
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
||||||
pub struct RecipeIngredientEditRequest {
|
|
||||||
pub amount: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
||||||
pub struct AddRecipeIngredientRequest {
|
|
||||||
pub amount: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct RecipeEditStepsRequest {
|
|
||||||
pub steps: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct RecipeEditRating {
|
|
||||||
pub rating: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct RecipeEditPersonCount {
|
|
||||||
pub person_count: u32,
|
|
||||||
}
|
|
||||||
|
|
@ -20,9 +20,7 @@
|
||||||
inherit system;
|
inherit system;
|
||||||
overlays = [ (import rust-overlay) ];
|
overlays = [ (import rust-overlay) ];
|
||||||
};
|
};
|
||||||
rust = pkgs.rust-bin.stable.latest.default.override {
|
rust = pkgs.rust-bin.stable.latest.default;
|
||||||
targets = [ "wasm32-unknown-unknown" ];
|
|
||||||
};
|
|
||||||
naersk' = pkgs.callPackage naersk {
|
naersk' = pkgs.callPackage naersk {
|
||||||
cargo = rust;
|
cargo = rust;
|
||||||
rustc = rust;
|
rustc = rust;
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "regalade_gui"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
ammonia = "4.0.0"
|
|
||||||
anyhow = "1.0.95"
|
|
||||||
api = { version = "0.1.0", path = "../api" }
|
|
||||||
dioxus = "0.6.1"
|
|
||||||
dioxus-router = { version = "0.6.1", features = ["web"] }
|
|
||||||
dioxus-web = "0.6.1"
|
|
||||||
gloo-net = { version = "0.6.0", features = ["json"] }
|
|
||||||
gloo-storage = "0.3.0"
|
|
||||||
gloo-utils = "0.2.0"
|
|
||||||
itertools = "0.13.0"
|
|
||||||
log = "0.4.22"
|
|
||||||
pulldown-cmark = "0.12.2"
|
|
||||||
serde = { version = "1.0.217", features = ["derive"] }
|
|
||||||
urlencoding = "2.1.3"
|
|
||||||
uuid = "1.11.0"
|
|
||||||
wasm-bindgen = "0.2.99"
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
#!/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,238 +0,0 @@
|
||||||
use dioxus::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 = vec!["modal"];
|
|
||||||
|
|
||||||
if cx.props.fade {
|
|
||||||
classes.push("fade");
|
|
||||||
}
|
|
||||||
|
|
||||||
let classes = classes.join(" ");
|
|
||||||
|
|
||||||
let mut dialog_class = vec!["modal-dialog"];
|
|
||||||
|
|
||||||
if cx.props.centered {
|
|
||||||
dialog_class.push("modal-dialog-centered");
|
|
||||||
}
|
|
||||||
|
|
||||||
let dialog_class = dialog_class.join(" ");
|
|
||||||
|
|
||||||
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",
|
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
use std::{
|
|
||||||
cell::{Cell, Ref, RefCell},
|
|
||||||
collections::HashSet,
|
|
||||||
rc::Rc,
|
|
||||||
sync::Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_router::prelude::*;
|
|
||||||
use gloo_storage::{errors::StorageError, LocalStorage, Storage};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{HouseholdInfo, LoginInfo, Route};
|
|
||||||
|
|
||||||
#[derive(Props)]
|
|
||||||
pub struct RedirectorProps<'a> {
|
|
||||||
children: Element<'a>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 navigator = use_navigator(cx);
|
|
||||||
|
|
||||||
let check_token = match LocalStorage::get::<LoginInfo>("token") {
|
|
||||||
Ok(_) => true,
|
|
||||||
Err(StorageError::KeyNotFound(_)) => {
|
|
||||||
navigator.push(Route::Login);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Err(e) => unreachable!("Could not get token: {e:?}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let check_household = match LocalStorage::get::<HouseholdInfo>("household") {
|
|
||||||
Ok(_) => true,
|
|
||||||
Err(StorageError::KeyNotFound(_)) => {
|
|
||||||
navigator.push(Route::HouseholdSelection);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Err(e) => unreachable!("Could not get household: {e:?}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
if check_token && check_household {
|
|
||||||
cx.render(rsx! {
|
|
||||||
FullContextRedirectInner { &cx.props.children }
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,294 +0,0 @@
|
||||||
use api::{
|
|
||||||
CreateIngredientRequest, CreateIngredientResponse, EditIngredientRequest, IngredientInfo,
|
|
||||||
};
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
use itertools::Itertools;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
api,
|
|
||||||
bootstrap::{bs, FormModal, Spinner},
|
|
||||||
use_error, use_trimmed_context, ErrorAlert, ErrorView,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub async fn fetch_ingredients(
|
|
||||||
token: String,
|
|
||||||
household: Uuid,
|
|
||||||
) -> anyhow::Result<api::IngredientList> {
|
|
||||||
let rsp = gloo_net::http::Request::get(api!("household/{household}/ingredients"))
|
|
||||||
.header("Authorization", &format!("Bearer {token}"))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !rsp.ok() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Could not fetch ingredients (status:{}): {}",
|
|
||||||
rsp.status(),
|
|
||||||
rsp.text().await?
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(rsp.json().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn do_edit_ingredient(
|
|
||||||
token: String,
|
|
||||||
household: Uuid,
|
|
||||||
id: i64,
|
|
||||||
new_name: String,
|
|
||||||
new_unit: String,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let rsp = gloo_net::http::Request::patch(api!("household/{household}/ingredients/{id}"))
|
|
||||||
.header("Authorization", &format!("Bearer {token}"))
|
|
||||||
.json(&EditIngredientRequest {
|
|
||||||
name: Some(new_name),
|
|
||||||
has_unit: true,
|
|
||||||
unit: (!new_unit.is_empty()).then_some(new_unit),
|
|
||||||
})?
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !rsp.ok() {
|
|
||||||
let body = rsp.body();
|
|
||||||
match body {
|
|
||||||
None => anyhow::bail!("Could not edit ingredients: {rsp:?}"),
|
|
||||||
Some(b) => anyhow::bail!("Could not edit ingredients: {}", b.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn do_delete_ingredient(token: String, household: Uuid, id: i64) -> anyhow::Result<()> {
|
|
||||||
let rsp = gloo_net::http::Request::delete(api!("household/{household}/ingredients/{id}"))
|
|
||||||
.header("Authorization", &format!("Bearer {token}"))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !rsp.ok() {
|
|
||||||
let body = rsp.body();
|
|
||||||
match body {
|
|
||||||
None => anyhow::bail!("Could not delete ingredient: {rsp:?}"),
|
|
||||||
Some(b) => anyhow::bail!("Could not delete ingredient: {}", b.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline_props]
|
|
||||||
pub fn IngredientList(cx: Scope, render_id: u64) -> Element {
|
|
||||||
let (token, household) = use_trimmed_context(cx);
|
|
||||||
let fetch_id = use_state(cx, || 0u64);
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let tk = token.clone();
|
|
||||||
let on_edit_ig = move |_| {
|
|
||||||
let &id = match edit_id.get() {
|
|
||||||
Some(i) => i,
|
|
||||||
None => {
|
|
||||||
error.set(Some("Internal error: no ingredient id".into()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
to_owned![fetch_id, edit_name, edit_unit, tk, modal_error, household];
|
|
||||||
cx.spawn(async move {
|
|
||||||
match do_edit_ingredient(
|
|
||||||
tk,
|
|
||||||
household,
|
|
||||||
id,
|
|
||||||
edit_name.to_string(),
|
|
||||||
edit_unit.to_string(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
|
||||||
fetch_id.set(fetch_id.wrapping_add(1));
|
|
||||||
let modal = bs::Modal::get_instance("#editIgModal");
|
|
||||||
modal.hide();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
modal_error.set(Some(format!("Could not edit ingredient: {e:?}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let delete_ig = |&id| {
|
|
||||||
to_owned![token];
|
|
||||||
move |_| {
|
|
||||||
to_owned![fetch_id, error, token];
|
|
||||||
|
|
||||||
cx.spawn(async move {
|
|
||||||
match do_delete_ingredient(token, household, id).await {
|
|
||||||
Ok(_) => fetch_id.set(fetch_id.wrapping_add(1)),
|
|
||||||
Err(e) => error.set(Some(format!("Could not delete ingredient: {e:?}"))),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
cx.render(match future.value() {
|
|
||||||
Some(Err(e)) => rsx! { ErrorAlert { error: "Could not fetch ingredients: {e}" } },
|
|
||||||
Some(Ok(ingredients)) => rsx! {
|
|
||||||
ErrorView { error: error }
|
|
||||||
ul { class: "list-group list-group-flush text-start",
|
|
||||||
for (id , info) in ingredients.ingredients.iter().sorted_by_key(|(&k, _)| k) {
|
|
||||||
li { key: "{id}", class: "list-group-item d-flex align-items-center",
|
|
||||||
p { class: "flex-fill m-auto",
|
|
||||||
"{info.name}"
|
|
||||||
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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => rsx! { Spinner {} },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn do_add_ingredient(
|
|
||||||
token: String,
|
|
||||||
household: Uuid,
|
|
||||||
name: String,
|
|
||||||
unit: String,
|
|
||||||
) -> anyhow::Result<CreateIngredientResponse> {
|
|
||||||
let rsp = gloo_net::http::Request::post(api!("household/{household}/ingredients"))
|
|
||||||
.header("Authorization", &format!("Bearer {token}"))
|
|
||||||
.json(&CreateIngredientRequest {
|
|
||||||
name,
|
|
||||||
unit: (!unit.is_empty()).then_some(unit),
|
|
||||||
})?
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !rsp.ok() {
|
|
||||||
let body = rsp.body();
|
|
||||||
match body {
|
|
||||||
None => anyhow::bail!("Could not add ingredient: {rsp:?}"),
|
|
||||||
Some(b) => anyhow::bail!("Could not add ingredient: {}", b.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(rsp.json().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn Ingredients(cx: Scope) -> Element {
|
|
||||||
let (token, household) = use_trimmed_context(cx);
|
|
||||||
let render_id = use_state(cx, || 0u64);
|
|
||||||
let error = use_error(cx);
|
|
||||||
|
|
||||||
let add_ingredient = move |ev: FormEvent| {
|
|
||||||
let name = ev.values["newIgName"][0].to_string();
|
|
||||||
let unit = ev.values["newIgUnit"][0].to_string();
|
|
||||||
|
|
||||||
if name.is_empty() && unit.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
to_owned![token, error, render_id];
|
|
||||||
|
|
||||||
cx.spawn(async move {
|
|
||||||
match do_add_ingredient(token, household, name, unit).await {
|
|
||||||
Err(e) => {
|
|
||||||
error.set(Some(format!("Could not add ingredient: {e}")));
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
render_id.set(render_id.wrapping_add(1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
cx.render(rsx! {
|
|
||||||
div { class: "d-flex align-items-center justify-content-center w-100",
|
|
||||||
div { class: "container text-center rounded border pt-2 m-2",
|
|
||||||
form { onsubmit: add_ingredient,
|
|
||||||
ErrorView { error: error }
|
|
||||||
div { class: "form-floating",
|
|
||||||
input {
|
|
||||||
name: "newIgName",
|
|
||||||
id: "newIgName",
|
|
||||||
placeholder: "Ingredient name",
|
|
||||||
class: "form-control"
|
|
||||||
}
|
|
||||||
label { "for": "newIgName", "Ingredient name" }
|
|
||||||
}
|
|
||||||
div { class: "form-floating my-1",
|
|
||||||
input {
|
|
||||||
name: "newIgUnit",
|
|
||||||
id: "newIgUnit",
|
|
||||||
placeholder: "Ingredient unit",
|
|
||||||
class: "form-control"
|
|
||||||
}
|
|
||||||
label { "for": "newIgUnit", "Ingredient unit" }
|
|
||||||
}
|
|
||||||
button { class: "btn btn-primary mt-2", "Add Ingredient" }
|
|
||||||
}
|
|
||||||
hr {}
|
|
||||||
IngredientList { render_id: *render_id.get() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
607
gui/src/lib.rs
607
gui/src/lib.rs
|
|
@ -1,607 +0,0 @@
|
||||||
#![allow(non_snake_case)]
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use api::{
|
|
||||||
AddToHouseholdRequest, CreateHouseholdRequest, CreateHouseholdResponse, LoginRequest,
|
|
||||||
LoginResponse, UserInfo,
|
|
||||||
};
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_router::prelude::*;
|
|
||||||
use gloo_storage::{errors::StorageError, LocalStorage, Storage};
|
|
||||||
use itertools::Itertools;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::bootstrap::{bs, FormModal, ModalToggleButton, Spinner};
|
|
||||||
|
|
||||||
mod bootstrap;
|
|
||||||
mod ingredients;
|
|
||||||
mod sidebar;
|
|
||||||
|
|
||||||
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") {
|
|
||||||
None => "http://localhost:8085",
|
|
||||||
Some(v) => v,
|
|
||||||
};
|
|
||||||
|
|
||||||
const FRONTEND_ROOT: &str = match option_env!("REGALADE_FRONTEND_DOMAIN") {
|
|
||||||
None => "http://localhost:8080",
|
|
||||||
Some(v) => v,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! api {
|
|
||||||
($($arg:tt)*) => {{
|
|
||||||
use $crate::API_ROUTE;
|
|
||||||
&format!("{API_ROUTE}/api/{}", format_args!($($arg)*))
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Props)]
|
|
||||||
pub struct ErrorProps<'a> {
|
|
||||||
error: &'a Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ErrorView<'a>(cx: Scope<'a, ErrorProps<'a>>) -> Element {
|
|
||||||
cx.props
|
|
||||||
.error
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|err| cx.render(rsx! { ErrorAlert { error: "{err}" } }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline_props]
|
|
||||||
pub fn ErrorAlert<'a>(cx: Scope<'a>, error: &'a str) -> Element<'a> {
|
|
||||||
cx.render(rsx! {
|
|
||||||
div { class: "alert alert-danger", *error }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct LoginInfo {
|
|
||||||
token: String,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn use_login(cx: &ScopeState) -> UseSharedState<LoginInfo> {
|
|
||||||
use_shared_state::<LoginInfo>(cx)
|
|
||||||
.expect("no login info in scope")
|
|
||||||
.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn use_error(cx: &ScopeState) -> &UseState<Option<String>> {
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn LoginRedirect(cx: Scope) -> Element {
|
|
||||||
let navigator = use_navigator(cx);
|
|
||||||
|
|
||||||
let token = match LocalStorage::get::<LoginInfo>("token") {
|
|
||||||
Ok(v) => Some(v),
|
|
||||||
Err(StorageError::KeyNotFound(_)) => {
|
|
||||||
navigator.push(Route::Login);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Err(e) => unreachable!("Could not get token: {e:?}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
use_shared_state_provider(cx, || token.clone());
|
|
||||||
|
|
||||||
cx.render(match token {
|
|
||||||
Some(info) => rsx! {
|
|
||||||
LoginRedirectInner {info: info},
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
rsx! {{}}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Props, PartialEq)]
|
|
||||||
struct LoginRedirectInnerProps {
|
|
||||||
info: LoginInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn LoginRedirectInner(cx: Scope<LoginRedirectInnerProps>) -> Element {
|
|
||||||
use_shared_state_provider(cx, || cx.props.info.clone());
|
|
||||||
|
|
||||||
cx.render(rsx! {
|
|
||||||
Outlet::<Route> {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_oidc() -> anyhow::Result<bool> {
|
|
||||||
let rsp = gloo_net::http::Request::get(api!("login/has_oidc"))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(rsp.status() == 200)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn Openid(cx: Scope) -> Element {
|
|
||||||
let has = use_future(cx, (), |()| check_oidc());
|
|
||||||
|
|
||||||
cx.render(match has.value().unwrap_or(&Ok(false)) {
|
|
||||||
Ok(true) => {
|
|
||||||
let route = api!("login/oidc").to_owned();
|
|
||||||
let ret = urlencoding::encode(&format!("{FRONTEND_ROOT}/login/oidc")).to_string();
|
|
||||||
rsx! {
|
|
||||||
a {
|
|
||||||
href: "{route}?return={ret}",
|
|
||||||
class: "mt-1 w-100 btn btn-lg btn-primary",
|
|
||||||
"Login with OpenID"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false) => rsx! {{}},
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Could not check oidc status: {e:?}");
|
|
||||||
rsx! {{}}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn Login(cx: Scope) -> Element {
|
|
||||||
let error = use_state(cx, || None::<String>);
|
|
||||||
let navigator = use_navigator(cx);
|
|
||||||
|
|
||||||
let on_submit = move |e: Event<FormData>| {
|
|
||||||
to_owned![error, navigator];
|
|
||||||
cx.spawn(async move {
|
|
||||||
match do_login(
|
|
||||||
e.values["username"][0].to_string(),
|
|
||||||
e.values["password"][0].to_string(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
|
||||||
error.set(None);
|
|
||||||
navigator.push(Route::Index);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error.set(Some(format!("Could not log in: {e}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
cx.render(rsx! {
|
|
||||||
link { href: "/login.css", rel: "stylesheet" }
|
|
||||||
form {
|
|
||||||
onsubmit: on_submit,
|
|
||||||
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" }
|
|
||||||
}
|
|
||||||
div { class: "form-floating",
|
|
||||||
input {
|
|
||||||
name: "password",
|
|
||||||
id: "floatingPass",
|
|
||||||
class: "form-control",
|
|
||||||
placeholder: "Password",
|
|
||||||
"type": "password"
|
|
||||||
}
|
|
||||||
label { "for": "floatingPass", "Password" }
|
|
||||||
}
|
|
||||||
button { class: "w-100 btn btn-lg btn-primary", "type": "submit", "Login" }
|
|
||||||
Openid {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn do_new_household(token: String, name: String) -> anyhow::Result<Uuid> {
|
|
||||||
let rsp = gloo_net::http::Request::post(api!("household"))
|
|
||||||
.header("Authorization", &format!("Bearer {token}"))
|
|
||||||
.json(&CreateHouseholdRequest { name })?
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !rsp.ok() {
|
|
||||||
anyhow::bail!("Request failed: {rsp:?}")
|
|
||||||
}
|
|
||||||
|
|
||||||
let rsp: CreateHouseholdResponse = rsp.json().await?;
|
|
||||||
|
|
||||||
Ok(rsp.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn do_resolve_user(token: String, username: String) -> anyhow::Result<Option<Uuid>> {
|
|
||||||
let rsp = gloo_net::http::Request::get(api!("search/user/{username}"))
|
|
||||||
.header("Authorization", &format!("Bearer {token}"))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if rsp.status() == 404 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !rsp.ok() {
|
|
||||||
anyhow::bail!("Request failed: {rsp:?}")
|
|
||||||
}
|
|
||||||
|
|
||||||
let rsp: UserInfo = rsp.json().await?;
|
|
||||||
|
|
||||||
Ok(Some(rsp.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn do_add_user_to_household(
|
|
||||||
token: String,
|
|
||||||
household: Uuid,
|
|
||||||
user: Uuid,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let rsp = gloo_net::http::Request::put(api!("household/{household}"))
|
|
||||||
.header("Authorization", &format!("Bearer {token}"))
|
|
||||||
.json(&AddToHouseholdRequest { user })?
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !rsp.ok() {
|
|
||||||
anyhow::bail!("Request failed: {rsp:?}")
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn CreateHousehold(cx: Scope) -> Element {
|
|
||||||
let login = use_login(cx);
|
|
||||||
let error = use_state(cx, || None::<String>);
|
|
||||||
let name = use_state(cx, String::new);
|
|
||||||
|
|
||||||
let members = use_ref(cx, Vec::<(Uuid, String)>::new);
|
|
||||||
|
|
||||||
let navigator = use_navigator(cx);
|
|
||||||
|
|
||||||
let token = login.read().token.clone();
|
|
||||||
let on_submit = move |_| {
|
|
||||||
to_owned![members, name, error, token, navigator];
|
|
||||||
|
|
||||||
cx.spawn(async move {
|
|
||||||
match do_new_household(token.clone(), name.to_string()).await {
|
|
||||||
Ok(id) => {
|
|
||||||
let info = HouseholdInfo {
|
|
||||||
id,
|
|
||||||
name: name.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
for (uid, user) in members.read().iter() {
|
|
||||||
if let Err(e) = do_add_user_to_household(token.clone(), id, *uid).await {
|
|
||||||
error.set(Some(format!(
|
|
||||||
"Could not add user {user} (but household was created): {e:?}"
|
|
||||||
)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = LocalStorage::set("household", info) {
|
|
||||||
log::error!("Could not switch to new household: {e:?}");
|
|
||||||
};
|
|
||||||
|
|
||||||
let modal = bs::Modal::get_instance("#newHsModal");
|
|
||||||
modal.hide();
|
|
||||||
|
|
||||||
navigator.push(Route::Index);
|
|
||||||
error.set(None);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error.set(Some(format!("Could not create household: {e:?}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_member = use_state(cx, String::new);
|
|
||||||
let token = login.read().token.clone();
|
|
||||||
let on_add_member = move |_| {
|
|
||||||
to_owned![new_member, members, error, token];
|
|
||||||
|
|
||||||
cx.spawn(async move {
|
|
||||||
match do_resolve_user(token, new_member.to_string()).await {
|
|
||||||
Err(e) => {
|
|
||||||
error.set(Some(format!("Could not add member: {e:?}")));
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn HouseholdListSelect(cx: Scope) -> Element {
|
|
||||||
let login = use_login(cx);
|
|
||||||
let households = use_future(cx, (), |_| fetch_households(login.read().token.clone()));
|
|
||||||
let navigator = use_navigator(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:?}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigator.push(Route::Index);
|
|
||||||
};
|
|
||||||
rsx! {button { key: "{id}", class: "btn btn-secondary m-1", onclick: onclick, "{info.name}" }}
|
|
||||||
});
|
|
||||||
rsx! {households}
|
|
||||||
}
|
|
||||||
Some(Err(e)) => {
|
|
||||||
rsx! { div { class: "alert alert-danger", "Could not fetch households: {e:?}" } }
|
|
||||||
}
|
|
||||||
None => rsx! { Spinner {} },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn HouseholdSelection(cx: Scope) -> Element {
|
|
||||||
cx.render(rsx! {
|
|
||||||
link { href: "/household_selection.css", rel: "stylesheet" }
|
|
||||||
div { class: "col-sm-3 m-auto p-2 text-center border rounded",
|
|
||||||
h1 { class: "h3", "Available" }
|
|
||||||
hr {}
|
|
||||||
HouseholdListSelect {}
|
|
||||||
hr {}
|
|
||||||
ModalToggleButton { class: "btn btn-lg btn-primary", modal_id: "newHsModal", "New household" }
|
|
||||||
CreateHousehold {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn Index(cx: Scope) -> Element {
|
|
||||||
cx.render(rsx! {"INDEX"})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, PartialEq, Clone)]
|
|
||||||
struct OidcQuery {
|
|
||||||
token: String,
|
|
||||||
username: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq, Props)]
|
|
||||||
struct OidcProps {
|
|
||||||
info: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn OidcRedirect(cx: Scope<OidcProps>) -> Element {
|
|
||||||
let (username, token) = cx
|
|
||||||
.props
|
|
||||||
.info
|
|
||||||
.split("---")
|
|
||||||
.collect_tuple()
|
|
||||||
.expect("invalid token kind");
|
|
||||||
cx.render({
|
|
||||||
match LocalStorage::set(
|
|
||||||
"token",
|
|
||||||
LoginInfo {
|
|
||||||
token: urlencoding::decode(token)
|
|
||||||
.expect("token urldecode")
|
|
||||||
.to_string(),
|
|
||||||
name: urlencoding::decode(username)
|
|
||||||
.expect("username urldecode")
|
|
||||||
.to_string(),
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
|
||||||
gloo_utils::window().location().replace("/").unwrap();
|
|
||||||
rsx! {{}}
|
|
||||||
}
|
|
||||||
Err(_) => rsx! {"Could not store authentication, try again."},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
use ingredients::Ingredients;
|
|
||||||
use recipe::{RecipeCreator, RecipeList, RecipeView};
|
|
||||||
|
|
||||||
#[rustfmt::skip]
|
|
||||||
#[derive(Clone, Routable)]
|
|
||||||
enum Route {
|
|
||||||
#[route("/login")]
|
|
||||||
Login,
|
|
||||||
#[route("/login/oidc/:info")]
|
|
||||||
OidcRedirect { info: String, },
|
|
||||||
|
|
||||||
#[layout(LoginRedirect)]
|
|
||||||
#[route("/household_selection")]
|
|
||||||
HouseholdSelection,
|
|
||||||
#[end_layout]
|
|
||||||
|
|
||||||
#[layout(RegaladeSidebar)]
|
|
||||||
#[route("/")]
|
|
||||||
Index,
|
|
||||||
#[route("/ingredients")]
|
|
||||||
Ingredients,
|
|
||||||
#[route("/recipe_creator")]
|
|
||||||
RecipeCreator,
|
|
||||||
#[nest("/recipe")]
|
|
||||||
#[route("/")]
|
|
||||||
RecipeList,
|
|
||||||
#[route("/:id")]
|
|
||||||
RecipeView {id: i64}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait AppContext {}
|
|
||||||
|
|
||||||
pub struct AppProps<'a, C> {
|
|
||||||
pub context: &'a C,
|
|
||||||
}
|
|
||||||
pub fn App<'a, C: AppContext>(cx: Scope<'a, AppProps<'a, C>>) -> Element {
|
|
||||||
cx.render(rsx! {
|
|
||||||
Router::<Route> {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,378 +0,0 @@
|
||||||
use std::{marker::PhantomData, rc::Rc};
|
|
||||||
|
|
||||||
use api::{CreateRecipeRequest, CreateRecipeResponse, IngredientInfo};
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_router::prelude::*;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
api,
|
|
||||||
bootstrap::{bs, FormModal, ModalBody, ModalFooter, ModalToggleButton, TitledModal},
|
|
||||||
ingredients::do_add_ingredient,
|
|
||||||
recipe::IngredientSelect,
|
|
||||||
use_error, use_trimmed_context, ErrorView, Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::RecipeIngredient;
|
|
||||||
|
|
||||||
#[derive(Props)]
|
|
||||||
struct IngredientAddProps<'a> {
|
|
||||||
add: Rc<dyn Fn(RecipeIngredient)>,
|
|
||||||
#[props(default = PhantomData)]
|
|
||||||
_ph: PhantomData<&'a ()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn IngredientCreate<'a>(cx: Scope<'a, IngredientAddProps>) -> Element<'a> {
|
|
||||||
let error = use_error(cx);
|
|
||||||
|
|
||||||
let (token, household) = use_trimmed_context(cx);
|
|
||||||
|
|
||||||
let amount = use_state(cx, String::new);
|
|
||||||
let unit = use_state(cx, String::new);
|
|
||||||
let name = use_state(cx, String::new);
|
|
||||||
|
|
||||||
let on_submit = move |_| {
|
|
||||||
let am: f64 = match amount.parse() {
|
|
||||||
Ok(v) if v >= 0. => v,
|
|
||||||
_ => {
|
|
||||||
error.set(Some("Amount must be a positive number".into()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if name.is_empty() {
|
|
||||||
error.set(Some("Name can't be empty".into()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let on_add = cx.props.add.clone();
|
|
||||||
to_owned![token, name, unit, error, amount];
|
|
||||||
|
|
||||||
cx.spawn(async move {
|
|
||||||
match do_add_ingredient(token, household, name.to_string(), unit.to_string()).await {
|
|
||||||
Ok(rsp) => {
|
|
||||||
(on_add)(RecipeIngredient {
|
|
||||||
id: rsp.id,
|
|
||||||
info: IngredientInfo {
|
|
||||||
name: name.to_string(),
|
|
||||||
unit: (!unit.is_empty()).then(|| unit.to_string()),
|
|
||||||
},
|
|
||||||
amount: am,
|
|
||||||
});
|
|
||||||
|
|
||||||
error.set(None);
|
|
||||||
name.set(String::new());
|
|
||||||
unit.set(String::new());
|
|
||||||
amount.set(String::new());
|
|
||||||
|
|
||||||
let modal = bs::Modal::get_instance("#newRcpCreateIg");
|
|
||||||
modal.hide();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error.set(Some(format!("Could not add ingredient: {e}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
cx.render(rsx! {
|
|
||||||
FormModal {
|
|
||||||
id: "newRcpCreateIg",
|
|
||||||
fade: true,
|
|
||||||
centered: true,
|
|
||||||
submit_label: "Create & Add",
|
|
||||||
title: "Create & Add ingredient",
|
|
||||||
on_submit: on_submit,
|
|
||||||
ErrorView { error: error }
|
|
||||||
div { class: "form-floating",
|
|
||||||
input {
|
|
||||||
class: "form-control",
|
|
||||||
id: "newRcpCreateIgNameInp",
|
|
||||||
placeholder: "Name",
|
|
||||||
value: "{name}",
|
|
||||||
oninput: move |e| name.set(e.value.clone())
|
|
||||||
}
|
|
||||||
label { "for": "newRcpCreateIgNameInp", "Ingredient Name" }
|
|
||||||
}
|
|
||||||
div { class: "form-floating my-1",
|
|
||||||
input {
|
|
||||||
class: "form-control",
|
|
||||||
id: "newRcpCreateIgUnitInp",
|
|
||||||
placeholder: "Unit",
|
|
||||||
value: "{unit}",
|
|
||||||
oninput: move |e| unit.set(e.value.clone())
|
|
||||||
}
|
|
||||||
label { "for": "newRcpCreateIgUnitInp", "Ingredient Unit" }
|
|
||||||
}
|
|
||||||
div { class: "form-floating",
|
|
||||||
input {
|
|
||||||
class: "form-control",
|
|
||||||
"type": "number",
|
|
||||||
min: "0",
|
|
||||||
id: "newRcpCreateIgAmountInp",
|
|
||||||
placeholder: "Amount",
|
|
||||||
value: "{amount}",
|
|
||||||
oninput: move |e| amount.set(e.value.clone())
|
|
||||||
}
|
|
||||||
label { "for": "newRcpCreateIgAmountInp", "Ingredient Amount" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn IngredientAdd<'a>(cx: Scope<'a, IngredientAddProps>) -> Element<'a> {
|
|
||||||
let amount = use_state(cx, || Ok::<_, String>(None));
|
|
||||||
let selected_ingredient = use_state(cx, || Err::<(i64, IngredientInfo), _>("".to_string()));
|
|
||||||
|
|
||||||
let refresh = use_state(cx, || 0u64);
|
|
||||||
|
|
||||||
let error = use_error(cx);
|
|
||||||
|
|
||||||
let add_ingredient = move |_| {
|
|
||||||
let amount = match &**amount {
|
|
||||||
&Ok(Some(v)) => v,
|
|
||||||
Ok(None) => {
|
|
||||||
error.set(Some("Amount must be a number".to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error.set(Some(e.clone()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let (id, info) = match &**selected_ingredient {
|
|
||||||
Ok(v) => v.clone(),
|
|
||||||
Err(e) => {
|
|
||||||
error.set(Some(format!("Ingredient does not exist: '{e}'")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
(cx.props.add)(RecipeIngredient { id, info, amount });
|
|
||||||
error.set(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let create_ingredient = {
|
|
||||||
let on_add = cx.props.add.clone();
|
|
||||||
to_owned![refresh];
|
|
||||||
Rc::new(move |ig| {
|
|
||||||
(on_add)(ig);
|
|
||||||
refresh.set(refresh.wrapping_add(1));
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
cx.render(rsx! {
|
|
||||||
ErrorView { error: error }
|
|
||||||
IngredientCreate { add: create_ingredient }
|
|
||||||
IngredientSelect {
|
|
||||||
on_amount_change: move |v| amount.set(v),
|
|
||||||
on_ingredient_change: move |v| selected_ingredient.set(v),
|
|
||||||
refresh: **refresh,
|
|
||||||
button { class: "btn btn-primary me-1", onclick: add_ingredient, "Add" }
|
|
||||||
ModalToggleButton { class: "btn btn-secondary", modal_id: "newRcpCreateIg", "Create" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn do_create_recipe(
|
|
||||||
token: String,
|
|
||||||
household: Uuid,
|
|
||||||
request: CreateRecipeRequest,
|
|
||||||
) -> anyhow::Result<i64> {
|
|
||||||
let rsp = gloo_net::http::Request::post(api!("household/{household}/recipe"))
|
|
||||||
.header("Authorization", &format!("Bearer {token}"))
|
|
||||||
.json(&request)?
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !rsp.ok() {
|
|
||||||
let body = rsp.text().await.unwrap_or_default();
|
|
||||||
anyhow::bail!("Could not post recipe (code={}): {body}", rsp.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
let rsp: CreateRecipeResponse = rsp.json().await?;
|
|
||||||
|
|
||||||
Ok(rsp.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn RecipeCreator(cx: Scope) -> Element {
|
|
||||||
let (token, household) = use_trimmed_context(cx);
|
|
||||||
|
|
||||||
let error = use_error(cx);
|
|
||||||
|
|
||||||
let name = use_state(cx, String::new);
|
|
||||||
let current_rating = use_state(cx, || 0u8);
|
|
||||||
|
|
||||||
let person_count_input = use_state(cx, || "1".to_string());
|
|
||||||
let person_count = use_memo(cx, &**person_count_input, |pc| pc.parse().unwrap_or(1));
|
|
||||||
|
|
||||||
let ingredients = use_ref(cx, Vec::<RecipeIngredient>::new);
|
|
||||||
|
|
||||||
let steps = use_state(cx, String::new);
|
|
||||||
|
|
||||||
let navigator = use_navigator(cx);
|
|
||||||
|
|
||||||
let ingredient_list: Vec<_> =
|
|
||||||
ingredients.with(|ig| {
|
|
||||||
ig.iter().enumerate().map(move |(idx, ig)| {
|
|
||||||
let ig = ig.clone();
|
|
||||||
rsx! {
|
|
||||||
li { class: "list-group-item d-flex justify-content-between align-items-center",
|
|
||||||
"{ig.amount}{ig.info.unit.as_deref().unwrap_or(\"\")} {ig.info.name}"
|
|
||||||
button {
|
|
||||||
class: "btn btn-danger",
|
|
||||||
onclick: move |_| {
|
|
||||||
ingredients
|
|
||||||
.with_mut(|igs| {
|
|
||||||
igs.remove(idx);
|
|
||||||
})
|
|
||||||
},
|
|
||||||
"Remove"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).collect()
|
|
||||||
});
|
|
||||||
|
|
||||||
let add_ingredient = {
|
|
||||||
to_owned![ingredients];
|
|
||||||
Rc::new(move |i| ingredients.with_mut(|ig| ig.push(i)))
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_rcp_submit = move |_| {
|
|
||||||
if name.is_empty() {
|
|
||||||
error.set(Some("Name can't be empty".into()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
to_owned![
|
|
||||||
token,
|
|
||||||
current_rating,
|
|
||||||
name,
|
|
||||||
ingredients,
|
|
||||||
person_count,
|
|
||||||
steps,
|
|
||||||
error,
|
|
||||||
navigator
|
|
||||||
];
|
|
||||||
|
|
||||||
cx.spawn(async move {
|
|
||||||
match do_create_recipe(
|
|
||||||
token,
|
|
||||||
household,
|
|
||||||
CreateRecipeRequest {
|
|
||||||
person_count,
|
|
||||||
name: name.to_string(),
|
|
||||||
rating: *current_rating,
|
|
||||||
ingredients: ingredients.with(|ig| {
|
|
||||||
ig.iter()
|
|
||||||
.map(|i| (i.id, i.amount / (person_count as f64)))
|
|
||||||
.collect()
|
|
||||||
}),
|
|
||||||
steps: steps.to_string(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(id) => {
|
|
||||||
steps.set(Default::default());
|
|
||||||
ingredients.set(Default::default());
|
|
||||||
current_rating.set(Default::default());
|
|
||||||
name.set(Default::default());
|
|
||||||
error.set(Default::default());
|
|
||||||
|
|
||||||
navigator.push(Route::RecipeView{id});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error.set(Some(format!("Error creating recipe: {e:?}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
cx.render(rsx! {
|
|
||||||
div { class: "d-flex align-items-center justify-content-center w-100",
|
|
||||||
div { class: "container rounded border py-2 m-2 text-center",
|
|
||||||
h1 { "Create a new recipe" }
|
|
||||||
ErrorView { error: error }
|
|
||||||
hr {}
|
|
||||||
div { class: "form-floating",
|
|
||||||
input {
|
|
||||||
id: "newRcpName",
|
|
||||||
class: "form-control",
|
|
||||||
placeholder: "Name",
|
|
||||||
value: "{name}",
|
|
||||||
oninput: move |e| name.set(e.value.clone())
|
|
||||||
}
|
|
||||||
label { "for": "newRcpName", "Name" }
|
|
||||||
}
|
|
||||||
div { class: "form-floating",
|
|
||||||
input {
|
|
||||||
id: "newRcpPersonCount",
|
|
||||||
class: "form-control",
|
|
||||||
placeholder: "Person Count",
|
|
||||||
"type": "number",
|
|
||||||
min: "1",
|
|
||||||
value: "{person_count_input}",
|
|
||||||
oninput: move |e| person_count_input.set(e.value.clone())
|
|
||||||
}
|
|
||||||
label { "for": "newRcpPersonCount", "Person Count" }
|
|
||||||
}
|
|
||||||
div { class: "pt-2",
|
|
||||||
"Rating: "
|
|
||||||
for (i , label) in ["Like", "Like a lot", "Love"].iter().enumerate() {
|
|
||||||
div { class: "form-check form-check-inline",
|
|
||||||
input {
|
|
||||||
class: "form-check-input",
|
|
||||||
"type": "radio",
|
|
||||||
name: "ratingOptions",
|
|
||||||
id: "rating{i}",
|
|
||||||
checked: **current_rating == i as u8,
|
|
||||||
oninput: move |_| current_rating.set(i as _)
|
|
||||||
}
|
|
||||||
label { class: "form-check-label", "for": "rating{i}", *label }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div { class: "d-flex flex-column justify-content-start",
|
|
||||||
h2 { "Ingredients" }
|
|
||||||
IngredientAdd { add: add_ingredient }
|
|
||||||
ul { class: "list-group list-group-flush text-start", ingredient_list.into_iter() }
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
h2 { "Steps" }
|
|
||||||
div { class: "text-start",
|
|
||||||
textarea {
|
|
||||||
class: "form-control",
|
|
||||||
id: "steps-area",
|
|
||||||
value: "{steps}",
|
|
||||||
rows: "10",
|
|
||||||
oninput: move |e| steps.set(e.value.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hr {}
|
|
||||||
ModalToggleButton { class: "btn btn-lg btn-primary", modal_id: "newRcpModal", "Create Recipe" }
|
|
||||||
TitledModal { id: "newRcpModal", fade: true, centered: true, title: "Create Recipe",
|
|
||||||
ModalBody { "Do you confirm this recipe ?" }
|
|
||||||
ModalFooter {
|
|
||||||
button {
|
|
||||||
"type": "button",
|
|
||||||
class: "btn btn-secondary",
|
|
||||||
"data-bs-dismiss": "modal",
|
|
||||||
"Cancel"
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
"type": "button",
|
|
||||||
class: "btn btn-primary",
|
|
||||||
"data-bs-dismiss": "modal",
|
|
||||||
onclick: new_rcp_submit,
|
|
||||||
"Confirm"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_router::prelude::*;
|
|
||||||
use itertools::Itertools;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
api, bootstrap::Spinner, recipe::RecipeRating, use_trimmed_context, ErrorAlert, Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
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: Route::RecipeView {id: *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 {} },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
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 {} },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,744 +0,0 @@
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use api::{
|
|
||||||
AddRecipeIngredientRequest, RecipeEditPersonCount, RecipeEditRating, RecipeEditStepsRequest,
|
|
||||||
RecipeInfo, RecipeIngredientEditRequest, RecipeRenameRequest,
|
|
||||||
};
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
use pulldown_cmark::{html, Parser};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
api,
|
|
||||||
bootstrap::{bs, ConfirmDangerModal, FormModal, ModalToggleButton, Spinner},
|
|
||||||
recipe::{IngredientSelect, RecipeRating},
|
|
||||||
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![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![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![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![
|
|
||||||
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![
|
|
||||||
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![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![token];
|
|
||||||
move |_| {
|
|
||||||
to_owned![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}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let steps_rendered = use_memo(cx, &cx.props.info.steps, |steps| {
|
|
||||||
let parser = Parser::new(&steps);
|
|
||||||
|
|
||||||
let mut html_output = String::new();
|
|
||||||
html::push_html(&mut html_output, parser);
|
|
||||||
|
|
||||||
ammonia::clean(&html_output)
|
|
||||||
});
|
|
||||||
|
|
||||||
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" }
|
|
||||||
div { dangerous_inner_html: "{steps_rendered}" }
|
|
||||||
EditSteps {
|
|
||||||
recipe: cx.props.id,
|
|
||||||
refresh: cx.props.refresh.clone(),
|
|
||||||
steps: cx.props.info.steps.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Props, PartialEq)]
|
|
||||||
pub struct RecipeViewProps {
|
|
||||||
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?))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn RecipeView(cx: Scope<RecipeViewProps>) -> 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 {} },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,372 +0,0 @@
|
||||||
use api::RenameHouseholdRequest;
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_router::prelude::*;
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
api,
|
|
||||||
bootstrap::{bs, ConfirmDangerModal, FormModal},
|
|
||||||
do_add_user_to_household, do_resolve_user,
|
|
||||||
full_context::FullContextRedirect,
|
|
||||||
use_error, use_full_context, use_trimmed_context, ErrorView, HouseholdInfo, Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[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",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Route> for Option<Page> {
|
|
||||||
fn from(value: Route) -> Self {
|
|
||||||
match value {
|
|
||||||
Route::Index => Some(Page::Home),
|
|
||||||
Route::Login => None,
|
|
||||||
Route::OidcRedirect { .. } => None,
|
|
||||||
Route::HouseholdSelection => None,
|
|
||||||
Route::Ingredients => Some(Page::Ingredients),
|
|
||||||
Route::RecipeCreator => Some(Page::RecipeCreator),
|
|
||||||
Route::RecipeList => Some(Page::RecipeList),
|
|
||||||
Route::RecipeView { .. } => Some(Page::RecipeList),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
|
||||||
struct MenuEntry {
|
|
||||||
icon: &'static str,
|
|
||||||
label: &'static str,
|
|
||||||
page: Page,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn AddMemberModal(cx: Scope) -> Element {
|
|
||||||
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, PartialEq)]
|
|
||||||
struct SidebarProps {
|
|
||||||
entries: Vec<MenuEntry>,
|
|
||||||
current: Page,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn do_leave(token: String, household: Uuid) -> anyhow::Result<()> {
|
|
||||||
let rsp = gloo_net::http::Request::delete(api!("household/{household}"))
|
|
||||||
.header("Authorization", &format!("Bearer {token}"))
|
|
||||||
.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::delete("household");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn SidebarDropdown(cx: Scope) -> Element {
|
|
||||||
let ctx = use_full_context(cx);
|
|
||||||
let navigator = use_navigator(cx);
|
|
||||||
|
|
||||||
let leave = move || {
|
|
||||||
let token = ctx.read().login.token.clone();
|
|
||||||
let household = ctx.read().household.id;
|
|
||||||
to_owned![navigator];
|
|
||||||
|
|
||||||
cx.spawn(async move {
|
|
||||||
match do_leave(token, household).await {
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Could not leave household: {e:?}");
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
navigator.push(Route::HouseholdSelection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let logout = || {
|
|
||||||
LocalStorage::delete("token");
|
|
||||||
LocalStorage::delete("household");
|
|
||||||
|
|
||||||
navigator.push(Route::Login);
|
|
||||||
};
|
|
||||||
|
|
||||||
cx.render(rsx! {
|
|
||||||
div { class: "dropdown",
|
|
||||||
a {
|
|
||||||
href: "#",
|
|
||||||
"data-bs-toggle": "dropdown",
|
|
||||||
"aria-expanded": "false",
|
|
||||||
class: "d-flex align-items-center text-white text-decoration-none dropdown-toggle",
|
|
||||||
i { class: "fs-4 bi-house-door-fill" }
|
|
||||||
strong { class: "ms-2 d-none d-sm-inline",
|
|
||||||
"{ctx.read().household.name} ({ctx.read().login.name})"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ConfirmDangerModal {
|
|
||||||
id: "leaveModal",
|
|
||||||
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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn Sidebar(cx: Scope<SidebarProps>) -> Element {
|
|
||||||
let entries = cx.props.entries.iter().map(|e| {
|
|
||||||
let active = if e.page == cx.props.current {
|
|
||||||
"active"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
li { class: "nav-item w-100",
|
|
||||||
Link { to: e.page.to(), class: "nav-link text-white {active}",
|
|
||||||
i { class: "fs-4 {e.icon}" }
|
|
||||||
span { class: "ms-2 d-none d-sm-inline", "{e.label}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.render(rsx! {
|
|
||||||
div { class: "container-fluid",
|
|
||||||
div { class: "row flex-nowrap",
|
|
||||||
div { class: "col-auto col-md-3 col-xl-2 px-sm-2 px-0 bg-dark-subtle",
|
|
||||||
div { class: "d-flex flex-column align-items-center align-items-sm-start px-sm-3 px-1 pt-2 text-white min-vh-100",
|
|
||||||
Link {
|
|
||||||
to: "/",
|
|
||||||
class: "d-flex align-items-center pb-3 mb-md-0 me-md-auto text-white text-decoration-none",
|
|
||||||
span { class: "fs-5 d-none d-sm-inline", "Menu" }
|
|
||||||
}
|
|
||||||
hr { class: "w-100 d-none d-sm-inline" }
|
|
||||||
ul {
|
|
||||||
id: "menu",
|
|
||||||
class: "nav nav-pills flex-column mb-sm-auto mb-0 align-items-center align-items-sm-start w-100",
|
|
||||||
entries
|
|
||||||
}
|
|
||||||
hr { class: "w-100" }
|
|
||||||
SidebarDropdown {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div { class: "col py-3 overflow-scroll vh-100", Outlet::<Route> {} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn RegaladeSidebar(cx: Scope) -> Element {
|
|
||||||
let current: Route = use_route(cx).unwrap();
|
|
||||||
let entries = vec![
|
|
||||||
MenuEntry {
|
|
||||||
label: "Home",
|
|
||||||
icon: "bi-house",
|
|
||||||
page: Page::Home,
|
|
||||||
},
|
|
||||||
MenuEntry {
|
|
||||||
label: "Recipes",
|
|
||||||
icon: "bi-book",
|
|
||||||
page: Page::RecipeList,
|
|
||||||
},
|
|
||||||
MenuEntry {
|
|
||||||
label: "Ingredients",
|
|
||||||
icon: "bi-egg-fill",
|
|
||||||
page: Page::Ingredients,
|
|
||||||
},
|
|
||||||
MenuEntry {
|
|
||||||
label: "New Recipe",
|
|
||||||
icon: "bi-clipboard2-plus-fill",
|
|
||||||
page: Page::RecipeCreator,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
cx.render(rsx! {
|
|
||||||
FullContextRedirect {
|
|
||||||
Sidebar { current: Option::from(current).unwrap(), entries: entries }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
33
src/main.rs
33
src/main.rs
|
|
@ -1,12 +1,10 @@
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, VecDeque},
|
collections::{HashMap, VecDeque},
|
||||||
net::SocketAddr,
|
net::SocketAddr,
|
||||||
path::PathBuf,
|
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use axum::Router;
|
|
||||||
use migration::{Migrator, MigratorTrait};
|
use migration::{Migrator, MigratorTrait};
|
||||||
use openidconnect::{
|
use openidconnect::{
|
||||||
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
|
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
|
||||||
|
|
@ -17,7 +15,6 @@ use openidconnect::{
|
||||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
|
||||||
use serde::{Deserialize, Deserializer};
|
use serde::{Deserialize, Deserializer};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
|
||||||
use tower_sessions::{session_store::ExpiredDeletion, SessionManagerLayer};
|
use tower_sessions::{session_store::ExpiredDeletion, SessionManagerLayer};
|
||||||
use tower_sessions_sqlx_store::{sqlx::PgPool, PostgresStore};
|
use tower_sessions_sqlx_store::{sqlx::PgPool, PostgresStore};
|
||||||
use tracing::level_filters::LevelFilter;
|
use tracing::level_filters::LevelFilter;
|
||||||
|
|
@ -26,7 +23,6 @@ use uuid::Uuid;
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
pub(crate) mod entity;
|
pub(crate) mod entity;
|
||||||
mod routes;
|
|
||||||
|
|
||||||
const SESSION_DURATION: time::Duration = time::Duration::weeks(26);
|
const SESSION_DURATION: time::Duration = time::Duration::weeks(26);
|
||||||
|
|
||||||
|
|
@ -37,7 +33,7 @@ where
|
||||||
use serde::de::Visitor;
|
use serde::de::Visitor;
|
||||||
|
|
||||||
struct CommaVisitor;
|
struct CommaVisitor;
|
||||||
impl<'de> Visitor<'de> for CommaVisitor {
|
impl Visitor<'_> for CommaVisitor {
|
||||||
type Value = Vec<openidconnect::Scope>;
|
type Value = Vec<openidconnect::Scope>;
|
||||||
|
|
||||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
|
@ -83,10 +79,6 @@ struct Settings {
|
||||||
host: String,
|
host: String,
|
||||||
#[serde(default = "default_port")]
|
#[serde(default = "default_port")]
|
||||||
port: u16,
|
port: u16,
|
||||||
#[serde(default)]
|
|
||||||
api_allowed: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
serve_app: Option<PathBuf>,
|
|
||||||
database_url: String,
|
database_url: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
oidc: Option<OpenidConnectSettings>,
|
oidc: Option<OpenidConnectSettings>,
|
||||||
|
|
@ -106,7 +98,6 @@ impl Settings {
|
||||||
struct AppState {
|
struct AppState {
|
||||||
db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
oidc: Option<OpenidConnector>,
|
oidc: Option<OpenidConnector>,
|
||||||
sessions: Arc<PostgresStore>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OpenidConnector {
|
struct OpenidConnector {
|
||||||
|
|
@ -127,7 +118,7 @@ struct FifoMapInsert<'a> {
|
||||||
map: &'a mut FifoMap,
|
map: &'a mut FifoMap,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> FifoMapInsert<'a> {
|
impl FifoMapInsert<'_> {
|
||||||
pub fn insert(self, state: OpenidAuthState) {
|
pub fn insert(self, state: OpenidAuthState) {
|
||||||
let FifoMapInsert { id, map } = self;
|
let FifoMapInsert { id, map } = self;
|
||||||
|
|
||||||
|
|
@ -340,30 +331,12 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
db: Database::connect(opt).await?,
|
db: Database::connect(opt).await?,
|
||||||
sessions: sessions.into(),
|
|
||||||
oidc,
|
oidc,
|
||||||
});
|
});
|
||||||
|
|
||||||
Migrator::up(&state.db, None).await?;
|
Migrator::up(&state.db, None).await?;
|
||||||
|
|
||||||
let router = Router::new()
|
let router = app::router().with_state(state).layer(session_layer);
|
||||||
.nest(
|
|
||||||
"/api",
|
|
||||||
routes::router(
|
|
||||||
config.api_allowed.map(|s| s.parse()).transpose()?,
|
|
||||||
state.oidc.is_some(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.merge(app::router())
|
|
||||||
.with_state(state)
|
|
||||||
.layer(session_layer);
|
|
||||||
|
|
||||||
let router = match config.serve_app {
|
|
||||||
None => router,
|
|
||||||
Some(path) => router.fallback_service(
|
|
||||||
ServeDir::new(&path).fallback(ServeFile::new(path.join("index.html"))),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::info!("Listening on http://{addr}");
|
tracing::info!("Listening on http://{addr}");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
async_trait,
|
|
||||||
extract::{FromRef, FromRequestParts, Path, State},
|
|
||||||
http::request::Parts,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use sea_orm::{prelude::*, ActiveValue, DatabaseTransaction, TransactionTrait};
|
|
||||||
use sea_query::OnConflict;
|
|
||||||
|
|
||||||
use api::{
|
|
||||||
AddToHouseholdRequest, CreateHouseholdRequest, CreateHouseholdResponse, EmptyResponse,
|
|
||||||
Households, RenameHouseholdRequest,
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use super::{AppState, AuthenticatedUser, RouteError};
|
|
||||||
use crate::entity::{household, household_members, prelude::*};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub(super) struct AuthorizedHousehold(pub household::Model);
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<S> FromRequestParts<S> for AuthorizedHousehold
|
|
||||||
where
|
|
||||||
S: Send + Sync,
|
|
||||||
AppState: FromRef<S>,
|
|
||||||
{
|
|
||||||
type Rejection = RouteError;
|
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
|
||||||
let State(app_state): State<AppState> = State::from_request_parts(parts, state)
|
|
||||||
.await
|
|
||||||
.expect("Could not get state");
|
|
||||||
|
|
||||||
let user = AuthenticatedUser::from_request_parts(parts, state).await?;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct HouseholdPathParam {
|
|
||||||
house_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
let Path(household): Path<HouseholdPathParam> =
|
|
||||||
Path::from_request_parts(parts, state).await?;
|
|
||||||
|
|
||||||
let household = user
|
|
||||||
.model
|
|
||||||
.find_related(Household)
|
|
||||||
.filter(household::Column::Id.eq(household.house_id))
|
|
||||||
.one(&app_state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match household {
|
|
||||||
None => Err(RouteError::Unauthorized),
|
|
||||||
Some(household) => Ok(AuthorizedHousehold(household)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn list(
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
state: State<AppState>,
|
|
||||||
) -> super::JsonResult<Households> {
|
|
||||||
let related_households = user.model.find_related(Household).all(&state.db).await?;
|
|
||||||
|
|
||||||
let mut households = HashMap::new();
|
|
||||||
|
|
||||||
for household in related_households {
|
|
||||||
let members = household.find_related(User).all(&state.db).await?;
|
|
||||||
households.insert(
|
|
||||||
household.id,
|
|
||||||
api::Household {
|
|
||||||
name: household.name,
|
|
||||||
members: members.into_iter().map(|m| m.id).collect(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(Households { households }))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn create(
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
state: State<AppState>,
|
|
||||||
Json(request): Json<CreateHouseholdRequest>,
|
|
||||||
) -> super::JsonResult<CreateHouseholdResponse> {
|
|
||||||
let household = household::ActiveModel {
|
|
||||||
name: ActiveValue::Set(request.name),
|
|
||||||
id: ActiveValue::Set(Uuid::new_v4()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let household = household.insert(&state.db).await?;
|
|
||||||
|
|
||||||
let member = household_members::ActiveModel {
|
|
||||||
household: ActiveValue::Set(household.id),
|
|
||||||
user: ActiveValue::Set(user.model.id),
|
|
||||||
};
|
|
||||||
|
|
||||||
member.insert(&state.db).await?;
|
|
||||||
|
|
||||||
Ok(Json(CreateHouseholdResponse { id: household.id }))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn add_member(
|
|
||||||
AuthorizedHousehold(household): AuthorizedHousehold,
|
|
||||||
state: State<AppState>,
|
|
||||||
Json(request): Json<AddToHouseholdRequest>,
|
|
||||||
) -> super::JsonResult<EmptyResponse> {
|
|
||||||
let member = household_members::ActiveModel {
|
|
||||||
household: ActiveValue::Set(household.id),
|
|
||||||
user: ActiveValue::Set(request.user),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = HouseholdMembers::insert(member)
|
|
||||||
.on_conflict(
|
|
||||||
OnConflict::columns([
|
|
||||||
household_members::Column::Household,
|
|
||||||
household_members::Column::User,
|
|
||||||
])
|
|
||||||
.do_nothing()
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.exec(&state.db)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
if !matches!(e, DbErr::RecordNotInserted) {
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(EmptyResponse {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_household(
|
|
||||||
household: household::Model,
|
|
||||||
txn: &DatabaseTransaction,
|
|
||||||
) -> Result<(), RouteError> {
|
|
||||||
for ingredient in household.find_related(Ingredient).all(txn).await? {
|
|
||||||
ingredient.delete(txn).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
household.delete(txn).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn leave(
|
|
||||||
AuthorizedHousehold(household): AuthorizedHousehold,
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
state: State<AppState>,
|
|
||||||
) -> super::JsonResult<EmptyResponse> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.transaction(|txn| {
|
|
||||||
Box::pin(async move {
|
|
||||||
HouseholdMembers::delete_by_id((household.id, user.model.id))
|
|
||||||
.exec(txn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let Some(household) = Household::find_by_id(household.id)
|
|
||||||
.one(txn)
|
|
||||||
.await? else {
|
|
||||||
return Ok(Json(EmptyResponse {}));
|
|
||||||
};
|
|
||||||
|
|
||||||
let member_count = household.find_related(User).count(txn).await?;
|
|
||||||
if member_count == 0 {
|
|
||||||
delete_household(household, txn).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(EmptyResponse {}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(Into::into)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn rename(
|
|
||||||
AuthorizedHousehold(household): AuthorizedHousehold,
|
|
||||||
state: State<AppState>,
|
|
||||||
Json(request): Json<RenameHouseholdRequest>,
|
|
||||||
) -> super::JsonResult<EmptyResponse> {
|
|
||||||
let mut household: household::ActiveModel = household.into();
|
|
||||||
|
|
||||||
household.name = ActiveValue::Set(request.name);
|
|
||||||
|
|
||||||
household.update(&state.db).await?;
|
|
||||||
|
|
||||||
Ok(Json(EmptyResponse {}))
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
use axum::{
|
|
||||||
async_trait,
|
|
||||||
extract::{FromRef, FromRequestParts, Path, State},
|
|
||||||
http::request::Parts,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
|
|
||||||
use api::{
|
|
||||||
CreateIngredientRequest, CreateIngredientResponse, EditIngredientRequest, EmptyResponse,
|
|
||||||
IngredientInfo, IngredientList,
|
|
||||||
};
|
|
||||||
use sea_orm::{prelude::*, ActiveValue};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use super::{household::AuthorizedHousehold, AppState, JsonResult, RouteError};
|
|
||||||
use crate::entity::{ingredient, prelude::*};
|
|
||||||
|
|
||||||
pub(super) async fn create_ingredient(
|
|
||||||
AuthorizedHousehold(household): AuthorizedHousehold,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(request): Json<CreateIngredientRequest>,
|
|
||||||
) -> JsonResult<CreateIngredientResponse> {
|
|
||||||
let model = ingredient::ActiveModel {
|
|
||||||
household: ActiveValue::Set(household.id),
|
|
||||||
name: ActiveValue::Set(request.name),
|
|
||||||
unit: ActiveValue::Set(request.unit),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let ingredient = model.insert(&state.db).await?;
|
|
||||||
|
|
||||||
Ok(Json(CreateIngredientResponse { id: ingredient.id }))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn list_ingredients(
|
|
||||||
AuthorizedHousehold(household): AuthorizedHousehold,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> JsonResult<IngredientList> {
|
|
||||||
let ingredients = household.find_related(Ingredient).all(&state.db).await?;
|
|
||||||
|
|
||||||
Ok(Json(IngredientList {
|
|
||||||
ingredients: ingredients
|
|
||||||
.into_iter()
|
|
||||||
.map(|m| {
|
|
||||||
(
|
|
||||||
m.id,
|
|
||||||
IngredientInfo {
|
|
||||||
name: m.name,
|
|
||||||
unit: m.unit,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct IngredientExtractor(pub(super) ingredient::Model);
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<S> FromRequestParts<S> for IngredientExtractor
|
|
||||||
where
|
|
||||||
S: Send + Sync,
|
|
||||||
AppState: FromRef<S>,
|
|
||||||
{
|
|
||||||
type Rejection = RouteError;
|
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
|
||||||
let State(app_state): State<AppState> = State::from_request_parts(parts, state)
|
|
||||||
.await
|
|
||||||
.expect("No state");
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct IngredientId {
|
|
||||||
iid: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
let household = AuthorizedHousehold::from_request_parts(parts, state).await?;
|
|
||||||
let Path(ingredient_id): Path<IngredientId> =
|
|
||||||
Path::from_request_parts(parts, state).await?;
|
|
||||||
|
|
||||||
match household
|
|
||||||
.0
|
|
||||||
.find_related(Ingredient)
|
|
||||||
.filter(ingredient::Column::Id.eq(ingredient_id.iid))
|
|
||||||
.one(&app_state.db)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
None => Err(RouteError::RessourceNotFound),
|
|
||||||
Some(r) => Ok(Self(r)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn remove_ingredient(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
IngredientExtractor(ingredient): IngredientExtractor,
|
|
||||||
) -> JsonResult<EmptyResponse> {
|
|
||||||
ingredient.delete(&state.db).await?;
|
|
||||||
|
|
||||||
Ok(Json(EmptyResponse {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn edit_ingredient(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
IngredientExtractor(ingredient): IngredientExtractor,
|
|
||||||
Json(request): Json<EditIngredientRequest>,
|
|
||||||
) -> JsonResult<EmptyResponse> {
|
|
||||||
let mut ingredient: ingredient::ActiveModel = ingredient.into();
|
|
||||||
|
|
||||||
if let Some(name) = request.name {
|
|
||||||
ingredient.name = ActiveValue::Set(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.has_unit || request.unit.is_some() {
|
|
||||||
ingredient.unit = ActiveValue::Set(request.unit);
|
|
||||||
}
|
|
||||||
|
|
||||||
ingredient.update(&state.db).await?;
|
|
||||||
|
|
||||||
Ok(Json(EmptyResponse {}))
|
|
||||||
}
|
|
||||||
|
|
@ -1,303 +0,0 @@
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use api::{LoginRequest, LoginResponse, UserInfo};
|
|
||||||
use axum::{
|
|
||||||
async_trait,
|
|
||||||
extract::{FromRef, FromRequestParts, Path, Query, State},
|
|
||||||
http::{
|
|
||||||
header::{AUTHORIZATION, CONTENT_TYPE},
|
|
||||||
request::Parts,
|
|
||||||
HeaderValue, Method, StatusCode,
|
|
||||||
},
|
|
||||||
response::{IntoResponse, Redirect},
|
|
||||||
routing::{delete, get, patch, post, put},
|
|
||||||
Json, Router,
|
|
||||||
};
|
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
|
||||||
use sea_orm::{prelude::*, ActiveValue, TransactionError};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tower_http::cors::{self, AllowOrigin, CorsLayer};
|
|
||||||
|
|
||||||
use crate::entity::{prelude::*, user};
|
|
||||||
|
|
||||||
mod household;
|
|
||||||
mod ingredients;
|
|
||||||
mod recipe;
|
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
|
||||||
enum RouteError {
|
|
||||||
#[error("This account does not exist")]
|
|
||||||
UnknownAccount,
|
|
||||||
#[error("Database encountered an error")]
|
|
||||||
Db(#[from] DbErr),
|
|
||||||
#[error("Request is missing the bearer token")]
|
|
||||||
MissingAuthorization,
|
|
||||||
#[error("User tried to edit an unauthorized ressource")]
|
|
||||||
Unauthorized,
|
|
||||||
#[error("Could not fetch required value from path")]
|
|
||||||
PathRejection(#[from] axum::extract::rejection::PathRejection),
|
|
||||||
#[error("The supplied ressource does not exist")]
|
|
||||||
RessourceNotFound,
|
|
||||||
#[error("The request was malformed")]
|
|
||||||
InvalidRequest(String),
|
|
||||||
#[error("A normal account with this name already exists")]
|
|
||||||
NormalAccount,
|
|
||||||
#[error("Error in DB transaction")]
|
|
||||||
TxnError(#[from] TransactionError<Box<RouteError>>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DbErr> for Box<RouteError> {
|
|
||||||
fn from(value: DbErr) -> Self {
|
|
||||||
Box::new(value.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for RouteError {
|
|
||||||
fn into_response(self) -> axum::response::Response {
|
|
||||||
match self {
|
|
||||||
RouteError::TxnError(TransactionError::Transaction(e)) => e.into_response(),
|
|
||||||
RouteError::UnknownAccount => {
|
|
||||||
(StatusCode::NOT_FOUND, "Account not found").into_response()
|
|
||||||
}
|
|
||||||
RouteError::MissingAuthorization => {
|
|
||||||
(StatusCode::BAD_REQUEST, "Missing authorization header").into_response()
|
|
||||||
}
|
|
||||||
RouteError::PathRejection(p) => p.into_response(),
|
|
||||||
RouteError::Unauthorized => (
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
"Unauthorized to access this ressource",
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
RouteError::RessourceNotFound => StatusCode::NOT_FOUND.into_response(),
|
|
||||||
RouteError::InvalidRequest(reason) => (StatusCode::BAD_REQUEST, reason).into_response(),
|
|
||||||
s @ RouteError::NormalAccount => {
|
|
||||||
(StatusCode::BAD_REQUEST, s.to_string()).into_response()
|
|
||||||
}
|
|
||||||
e => {
|
|
||||||
tracing::error!("Internal error: {e:?}");
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type JsonResult<T, E = RouteError> = Result<Json<T>, E>;
|
|
||||||
|
|
||||||
type AppState = Arc<crate::AppState>;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct AuthenticatedUser {
|
|
||||||
pub model: user::Model,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
|
||||||
where
|
|
||||||
S: Send + Sync,
|
|
||||||
AppState: FromRef<S>,
|
|
||||||
{
|
|
||||||
type Rejection = RouteError;
|
|
||||||
|
|
||||||
async fn from_request_parts(_parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
||||||
Err(RouteError::Unauthorized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn login(
|
|
||||||
State(_state): State<AppState>,
|
|
||||||
Json(_req): Json<LoginRequest>,
|
|
||||||
) -> JsonResult<LoginResponse> {
|
|
||||||
return Err(RouteError::Unauthorized);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct OidcStartParam {
|
|
||||||
r#return: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn oidc_login(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
jar: CookieJar,
|
|
||||||
) -> Result<(CookieJar, Redirect), RouteError> {
|
|
||||||
tracing::info!("Starting OIDC login");
|
|
||||||
let oidc = state.oidc.as_ref().unwrap();
|
|
||||||
|
|
||||||
let (flow_id, redirect_url) = oidc.start_auth();
|
|
||||||
let jar = jar.add(
|
|
||||||
Cookie::build(("login_flow_id", flow_id.to_string()))
|
|
||||||
.secure(true)
|
|
||||||
.same_site(SameSite::Lax)
|
|
||||||
.build(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok((jar, Redirect::to(redirect_url.as_str())))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct OidcRedirectParams {
|
|
||||||
state: String,
|
|
||||||
code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn oidc_login_finish(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(redirect): Query<OidcRedirectParams>,
|
|
||||||
jar: CookieJar,
|
|
||||||
) -> Result<Redirect, RouteError> {
|
|
||||||
let Some(Ok(id)) = jar.get("login_flow_id").map(|c| c.value().parse()) else {
|
|
||||||
return Err(RouteError::Unauthorized);
|
|
||||||
};
|
|
||||||
|
|
||||||
match state
|
|
||||||
.oidc
|
|
||||||
.as_ref()
|
|
||||||
.unwrap()
|
|
||||||
.redirected(id, redirect.state, redirect.code)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Error when finishing OAuth2 flow {e:?}");
|
|
||||||
Err(RouteError::Unauthorized)
|
|
||||||
}
|
|
||||||
Ok(account) => {
|
|
||||||
let user = User::find()
|
|
||||||
.filter(
|
|
||||||
user::Column::Name
|
|
||||||
.eq(&account.name)
|
|
||||||
.or(user::Column::OpenIdSubject.eq(&account.sub)),
|
|
||||||
)
|
|
||||||
.one(&state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match user {
|
|
||||||
None => {
|
|
||||||
let model = user::ActiveModel {
|
|
||||||
id: ActiveValue::Set(Uuid::new_v4()),
|
|
||||||
name: ActiveValue::Set(account.name),
|
|
||||||
password: ActiveValue::NotSet,
|
|
||||||
open_id_subject: ActiveValue::Set(Some(account.sub)),
|
|
||||||
};
|
|
||||||
|
|
||||||
model.insert(&state.db).await?
|
|
||||||
}
|
|
||||||
Some(user) => {
|
|
||||||
if user.open_id_subject.as_ref() != Some(&account.sub) {
|
|
||||||
return Err(RouteError::NormalAccount);
|
|
||||||
}
|
|
||||||
|
|
||||||
user
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return Err(RouteError::Unauthorized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_user_id(
|
|
||||||
_: AuthenticatedUser,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(name): Path<String>,
|
|
||||||
) -> Result<Result<Json<UserInfo>, StatusCode>, RouteError> {
|
|
||||||
let Some(user) = User::find()
|
|
||||||
.filter(user::Column::Name.eq(name))
|
|
||||||
.one(&state.db)
|
|
||||||
.await?
|
|
||||||
else {
|
|
||||||
return Ok(Err(StatusCode::NOT_FOUND));
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Ok(Json(UserInfo {
|
|
||||||
name: user.name,
|
|
||||||
id: user.id,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn router(api_allowed: Option<HeaderValue>, has_oidc: bool) -> Router<AppState> {
|
|
||||||
let origin: AllowOrigin = match api_allowed {
|
|
||||||
Some(n) => n.into(),
|
|
||||||
None => cors::Any.into(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let cors_base = CorsLayer::new()
|
|
||||||
.allow_headers([CONTENT_TYPE, AUTHORIZATION])
|
|
||||||
.allow_origin(origin);
|
|
||||||
|
|
||||||
let mk_service = |m: Vec<Method>| cors_base.clone().allow_methods(m);
|
|
||||||
|
|
||||||
let router = Router::new()
|
|
||||||
.route(
|
|
||||||
"/search/user/:name",
|
|
||||||
get(get_user_id).layer(mk_service(vec![Method::GET])),
|
|
||||||
)
|
|
||||||
.route("/login", post(login).layer(mk_service(vec![Method::POST])))
|
|
||||||
.route(
|
|
||||||
"/household",
|
|
||||||
get(household::list)
|
|
||||||
.post(household::create)
|
|
||||||
.layer(mk_service(vec![Method::GET, Method::POST])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/household/:house_id",
|
|
||||||
put(household::add_member)
|
|
||||||
.delete(household::leave)
|
|
||||||
.patch(household::rename)
|
|
||||||
.layer(mk_service(vec![Method::PUT, Method::DELETE, Method::PATCH])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/household/:house_id/ingredients/:iid",
|
|
||||||
delete(ingredients::remove_ingredient)
|
|
||||||
.patch(ingredients::edit_ingredient)
|
|
||||||
.layer(mk_service(vec![Method::DELETE, Method::PATCH])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/household/:house_id/ingredients",
|
|
||||||
post(ingredients::create_ingredient)
|
|
||||||
.get(ingredients::list_ingredients)
|
|
||||||
.layer(mk_service(vec![Method::GET, Method::POST])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/household/:house_id/recipe",
|
|
||||||
post(recipe::create_recipe)
|
|
||||||
.get(recipe::list_recipes)
|
|
||||||
.layer(mk_service(vec![Method::POST, Method::GET])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/household/:house_id/recipe/:recipe_id",
|
|
||||||
get(recipe::fetch_recipe)
|
|
||||||
.patch(recipe::edit_name)
|
|
||||||
.layer(mk_service(vec![Method::GET, Method::PATCH])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/household/:house_id/recipe/:recipe_id/steps",
|
|
||||||
patch(recipe::edit_step).layer(mk_service(vec![Method::PATCH])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/household/:house_id/recipe/:recipe_id/rating",
|
|
||||||
patch(recipe::edit_rating).layer(mk_service(vec![Method::PATCH])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/household/:house_id/recipe/:recipe_id/person_count",
|
|
||||||
patch(recipe::edit_person_count).layer(mk_service(vec![Method::PATCH])),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/household/:house_id/recipe/:recipe_id/ingredients/:iid",
|
|
||||||
patch(recipe::edit_ig_amount)
|
|
||||||
.delete(recipe::delete_ig)
|
|
||||||
.put(recipe::add_ig_request)
|
|
||||||
.layer(mk_service(vec![Method::PATCH, Method::DELETE, Method::PUT])),
|
|
||||||
);
|
|
||||||
|
|
||||||
if has_oidc {
|
|
||||||
async fn unit() {}
|
|
||||||
router
|
|
||||||
.route("/login/oidc", get(oidc_login))
|
|
||||||
.route(
|
|
||||||
"/login/has_oidc",
|
|
||||||
get(unit).layer(mk_service(vec![Method::GET])),
|
|
||||||
)
|
|
||||||
.route("/login/redirect", get(oidc_login_finish))
|
|
||||||
} else {
|
|
||||||
router
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
use api::{
|
|
||||||
AddRecipeIngredientRequest, CreateRecipeRequest, CreateRecipeResponse, EmptyResponse,
|
|
||||||
IngredientInfo, ListRecipesResponse, RecipeEditRating, RecipeEditStepsRequest, RecipeInfo,
|
|
||||||
RecipeIngredientEditRequest, RecipeRenameRequest, RecipeEditPersonCount,
|
|
||||||
};
|
|
||||||
use axum::{
|
|
||||||
async_trait,
|
|
||||||
extract::{FromRef, FromRequestParts, Path, State},
|
|
||||||
http::request::Parts,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use sea_orm::{prelude::*, ActiveValue, TransactionTrait};
|
|
||||||
|
|
||||||
use crate::entity::{ingredient, prelude::*, recipe, recipe_ingredients};
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
household::AuthorizedHousehold, ingredients::IngredientExtractor, AppState, JsonResult,
|
|
||||||
RouteError,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(super) async fn create_recipe(
|
|
||||||
AuthorizedHousehold(household): AuthorizedHousehold,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(request): Json<CreateRecipeRequest>,
|
|
||||||
) -> JsonResult<CreateRecipeResponse> {
|
|
||||||
let id = state
|
|
||||||
.db
|
|
||||||
.transaction(|txn| {
|
|
||||||
Box::pin(async move {
|
|
||||||
let model = recipe::ActiveModel {
|
|
||||||
name: ActiveValue::Set(request.name),
|
|
||||||
ranking: ActiveValue::Set(request.rating as i32),
|
|
||||||
household: ActiveValue::Set(household.id),
|
|
||||||
steps: ActiveValue::Set(request.steps),
|
|
||||||
person_count: ActiveValue::Set(request.person_count.max(1) as i32),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let recipe = model.insert(txn).await?;
|
|
||||||
|
|
||||||
for (ig, amount) in request.ingredients {
|
|
||||||
if 0 == household
|
|
||||||
.find_related(Ingredient)
|
|
||||||
.filter(ingredient::Column::Id.eq(ig))
|
|
||||||
.count(txn)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
Err(RouteError::InvalidRequest(format!(
|
|
||||||
"No such ingredient {ig}"
|
|
||||||
)))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let model = recipe_ingredients::ActiveModel {
|
|
||||||
recipe_id: ActiveValue::Set(recipe.id),
|
|
||||||
ingredient_id: ActiveValue::Set(ig),
|
|
||||||
amount: ActiveValue::Set(amount),
|
|
||||||
};
|
|
||||||
|
|
||||||
model.insert(txn).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(recipe.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(CreateRecipeResponse { id }.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn list_recipes(
|
|
||||||
AuthorizedHousehold(household): AuthorizedHousehold,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> JsonResult<ListRecipesResponse> {
|
|
||||||
Ok(ListRecipesResponse {
|
|
||||||
recipes: household
|
|
||||||
.find_related(Recipe)
|
|
||||||
.all(&state.db)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| (r.id, r.name, r.ranking as _))
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct RecipeExtractor(recipe::Model);
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<S> FromRequestParts<S> for RecipeExtractor
|
|
||||||
where
|
|
||||||
S: Send + Sync,
|
|
||||||
AppState: FromRef<S>,
|
|
||||||
{
|
|
||||||
type Rejection = RouteError;
|
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
|
||||||
let State(app_state): State<AppState> = State::from_request_parts(parts, state)
|
|
||||||
.await
|
|
||||||
.expect("No state");
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub(super) struct RecipeId {
|
|
||||||
recipe_id: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
let household = AuthorizedHousehold::from_request_parts(parts, state).await?;
|
|
||||||
let Path(recipe): Path<RecipeId> = Path::from_request_parts(parts, state).await?;
|
|
||||||
|
|
||||||
match household
|
|
||||||
.0
|
|
||||||
.find_related(Recipe)
|
|
||||||
.filter(recipe::Column::Id.eq(recipe.recipe_id))
|
|
||||||
.one(&app_state.db)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
None => Err(RouteError::RessourceNotFound),
|
|
||||||
Some(r) => Ok(Self(r)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn fetch_recipe(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
RecipeExtractor(recipe): RecipeExtractor,
|
|
||||||
) -> JsonResult<RecipeInfo> {
|
|
||||||
let recipe_ingredients = recipe.find_related(Ingredient).all(&state.db).await?;
|
|
||||||
|
|
||||||
let mut ingredients = Vec::new();
|
|
||||||
|
|
||||||
for ingredient in recipe_ingredients {
|
|
||||||
ingredients.push((
|
|
||||||
ingredient.id,
|
|
||||||
IngredientInfo {
|
|
||||||
name: ingredient.name,
|
|
||||||
unit: ingredient.unit,
|
|
||||||
},
|
|
||||||
RecipeIngredients::find_by_id((recipe.id, ingredient.id))
|
|
||||||
.one(&state.db)
|
|
||||||
.await?
|
|
||||||
.expect("Ingredient should exist as it was fetched")
|
|
||||||
.amount,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(RecipeInfo {
|
|
||||||
person_count: recipe.person_count as _,
|
|
||||||
name: recipe.name,
|
|
||||||
steps: recipe.steps,
|
|
||||||
rating: recipe.ranking as _,
|
|
||||||
ingredients,
|
|
||||||
}
|
|
||||||
.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn edit_name(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
RecipeExtractor(recipe): RecipeExtractor,
|
|
||||||
Json(req): Json<RecipeRenameRequest>,
|
|
||||||
) -> JsonResult<EmptyResponse> {
|
|
||||||
let active_model = recipe::ActiveModel {
|
|
||||||
name: ActiveValue::Set(req.name),
|
|
||||||
id: ActiveValue::Set(recipe.id),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
active_model.update(&state.db).await?;
|
|
||||||
|
|
||||||
Ok(EmptyResponse {}.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn edit_ig_amount(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
RecipeExtractor(recipe): RecipeExtractor,
|
|
||||||
IngredientExtractor(ingredient): IngredientExtractor,
|
|
||||||
Json(req): Json<RecipeIngredientEditRequest>,
|
|
||||||
) -> JsonResult<EmptyResponse> {
|
|
||||||
let active_model = recipe_ingredients::ActiveModel {
|
|
||||||
recipe_id: ActiveValue::Set(recipe.id),
|
|
||||||
ingredient_id: ActiveValue::Set(ingredient.id),
|
|
||||||
amount: ActiveValue::Set(req.amount),
|
|
||||||
};
|
|
||||||
|
|
||||||
active_model.update(&state.db).await?;
|
|
||||||
|
|
||||||
Ok(EmptyResponse {}.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn delete_ig(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
RecipeExtractor(recipe): RecipeExtractor,
|
|
||||||
IngredientExtractor(ingredient): IngredientExtractor,
|
|
||||||
) -> JsonResult<EmptyResponse> {
|
|
||||||
RecipeIngredients::delete_by_id((recipe.id, ingredient.id))
|
|
||||||
.exec(&state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(EmptyResponse {}.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn add_ig_request(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
RecipeExtractor(recipe): RecipeExtractor,
|
|
||||||
IngredientExtractor(ingredient): IngredientExtractor,
|
|
||||||
Json(req): Json<AddRecipeIngredientRequest>,
|
|
||||||
) -> JsonResult<EmptyResponse> {
|
|
||||||
let model = recipe_ingredients::ActiveModel {
|
|
||||||
recipe_id: ActiveValue::Set(recipe.id),
|
|
||||||
ingredient_id: ActiveValue::Set(ingredient.id),
|
|
||||||
amount: ActiveValue::Set(req.amount),
|
|
||||||
};
|
|
||||||
|
|
||||||
model.insert(&state.db).await?;
|
|
||||||
|
|
||||||
Ok(EmptyResponse {}.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn edit_step(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
RecipeExtractor(recipe): RecipeExtractor,
|
|
||||||
Json(req): Json<RecipeEditStepsRequest>,
|
|
||||||
) -> JsonResult<EmptyResponse> {
|
|
||||||
let model = recipe::ActiveModel {
|
|
||||||
id: ActiveValue::Set(recipe.id),
|
|
||||||
steps: ActiveValue::Set(req.steps),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
model.update(&state.db).await?;
|
|
||||||
|
|
||||||
Ok(EmptyResponse {}.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn edit_rating(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
RecipeExtractor(recipe): RecipeExtractor,
|
|
||||||
Json(req): Json<RecipeEditRating>,
|
|
||||||
) -> JsonResult<EmptyResponse> {
|
|
||||||
let model = recipe::ActiveModel {
|
|
||||||
id: ActiveValue::Set(recipe.id),
|
|
||||||
ranking: ActiveValue::Set(req.rating as _),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
model.update(&state.db).await?;
|
|
||||||
|
|
||||||
Ok(EmptyResponse {}.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn edit_person_count(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
RecipeExtractor(recipe): RecipeExtractor,
|
|
||||||
Json(req): Json<RecipeEditPersonCount>,
|
|
||||||
) -> JsonResult<EmptyResponse> {
|
|
||||||
let model = recipe::ActiveModel {
|
|
||||||
id: ActiveValue::Set(recipe.id),
|
|
||||||
person_count: ActiveValue::Set(req.person_count.max(1) as _),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
model.update(&state.db).await?;
|
|
||||||
|
|
||||||
Ok(EmptyResponse {}.into())
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "regalade_web"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
console_log = "1.0.0"
|
|
||||||
dioxus-web = "0.6.1"
|
|
||||||
gloo-utils = "0.2.0"
|
|
||||||
log = "0.4.22"
|
|
||||||
regalade_gui = { path = "../gui" }
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
[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 = []
|
|
||||||
2
web/public/.gitignore
vendored
2
web/public/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
/bootstrap-icons
|
|
||||||
/bootstrap
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
.awesomplete [hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.awesomplete .visually-hidden {
|
|
||||||
position: absolute;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.awesomplete {
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.awesomplete > input {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.awesomplete > ul {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
z-index: 1;
|
|
||||||
min-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.awesomplete > ul:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.awesomplete > ul {
|
|
||||||
border-radius: .3em;
|
|
||||||
margin: .2em 0 0;
|
|
||||||
background: hsla(0,0%,100%,.9);
|
|
||||||
background: #1a1d20;
|
|
||||||
border: 1px solid rgba(0,0,0,.3);
|
|
||||||
box-shadow: .05em .2em .6em rgba(0,0,0,.2);
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@supports (transform: scale(0)) {
|
|
||||||
.awesomplete > ul {
|
|
||||||
transition: .3s cubic-bezier(.4,.2,.5,1.4);
|
|
||||||
transform-origin: 1.43em -.43em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.awesomplete > ul[hidden],
|
|
||||||
.awesomplete > ul:empty {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0);
|
|
||||||
display: block;
|
|
||||||
transition-timing-function: ease;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pointer */
|
|
||||||
.awesomplete > ul:before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: -.43em;
|
|
||||||
left: 1em;
|
|
||||||
width: 0; height: 0;
|
|
||||||
padding: .4em;
|
|
||||||
background: #1a1d20;
|
|
||||||
border: inherit;
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: 0;
|
|
||||||
-webkit-transform: rotate(45deg);
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.awesomplete > ul > li {
|
|
||||||
position: relative;
|
|
||||||
padding: .2em .5em;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.awesomplete > ul > li:hover {
|
|
||||||
background: hsl(200, 40%, 80%);
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.awesomplete > ul > li[aria-selected="true"] {
|
|
||||||
background: hsl(205, 40%, 40%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.awesomplete mark {
|
|
||||||
background: hsl(65, 100%, 50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.awesomplete li:hover mark {
|
|
||||||
background: hsl(68, 100%, 41%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.awesomplete li[aria-selected="true"] mark {
|
|
||||||
background: hsl(86, 100%, 21%);
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
3
web/public/awesomplete.min.js
vendored
3
web/public/awesomplete.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +0,0 @@
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#main {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding-top: 40px;
|
|
||||||
padding-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#main {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding-top: 40px;
|
|
||||||
padding-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-signin {
|
|
||||||
max-width: 330px;
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-signin .form-floating:focus-within {
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#floatingUser {
|
|
||||||
margin-bottom: -1px;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#floatingPass {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
hr {
|
|
||||||
border: 1px solid #595c5f;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
use regalade_gui::{App, AppContext, AppProps};
|
|
||||||
|
|
||||||
struct WebApp {}
|
|
||||||
|
|
||||||
impl AppContext for WebApp {}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
console_log::init_with_level(log::Level::Info).unwrap();
|
|
||||||
|
|
||||||
let html = gloo_utils::document_element();
|
|
||||||
html.set_attribute("data-bs-theme", "dark")
|
|
||||||
.expect("could not set dark theme");
|
|
||||||
|
|
||||||
dioxus_web::launch_with_props(
|
|
||||||
App,
|
|
||||||
AppProps {
|
|
||||||
context: &WebApp {},
|
|
||||||
},
|
|
||||||
dioxus_web::Config::new(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue