diff --git a/Cargo.toml b/Cargo.toml index 52ba36a..f7c2db9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ authors = ["traxys "] edition = "2021" [workspace] -members = [".", "api", "migration"] +members = [".", "migration"] [dependencies] anyhow = "1.0.95" @@ -15,17 +15,14 @@ serde = { version = "1.0.217", features = ["derive"] } tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } tracing = "0.1.41" tracing-subscriber = "0.3.19" -api = { path = "./api" } migration = { path = "./migration" } thiserror = "2.0.9" tower-http = { version = "0.6.2", features = ["cors", "fs"] } -sha2 = "0.10" uuid = { version = "1.11", features = ["v4"] } sea-query = "0.32" openidconnect = "3.5.0" envious = "0.2.2" parking_lot = "0.12.3" -urlencoding = "2.1.3" tower-sessions = "0.13.0" tower = "0.5.2" time = "0.3.37" diff --git a/api/Cargo.toml b/api/Cargo.toml deleted file mode 100644 index 5a3056b..0000000 --- a/api/Cargo.toml +++ /dev/null @@ -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"] } diff --git a/api/src/lib.rs b/api/src/lib.rs deleted file mode 100644 index 5260d1a..0000000 --- a/api/src/lib.rs +++ /dev/null @@ -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, -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct Households { - pub households: HashMap, -} - -#[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, -} - -#[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, -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct IngredientList { - pub ingredients: HashMap, -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct EditIngredientRequest { - pub name: Option, - pub unit: Option, - #[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, -} diff --git a/flake.nix b/flake.nix index dc2fca0..edcfe53 100644 --- a/flake.nix +++ b/flake.nix @@ -20,9 +20,7 @@ inherit system; overlays = [ (import rust-overlay) ]; }; - rust = pkgs.rust-bin.stable.latest.default.override { - targets = [ "wasm32-unknown-unknown" ]; - }; + rust = pkgs.rust-bin.stable.latest.default; naersk' = pkgs.callPackage naersk { cargo = rust; rustc = rust; diff --git a/gui/Cargo.toml b/gui/Cargo.toml deleted file mode 100644 index fd81608..0000000 --- a/gui/Cargo.toml +++ /dev/null @@ -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" diff --git a/gui/dl_deps.sh b/gui/dl_deps.sh deleted file mode 100755 index c53c026..0000000 --- a/gui/dl_deps.sh +++ /dev/null @@ -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 diff --git a/gui/src/bootstrap/mod.rs b/gui/src/bootstrap/mod.rs deleted file mode 100644 index f4763db..0000000 --- a/gui/src/bootstrap/mod.rs +++ /dev/null @@ -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, - 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 - } - }) -} diff --git a/gui/src/full_context.rs b/gui/src/full_context.rs deleted file mode 100644 index fe0215e..0000000 --- a/gui/src/full_context.rs +++ /dev/null @@ -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, -} - -impl RefreshHandle { - pub fn refresh(self) { - (self.run)() - } -} - -#[derive(Copy, Clone)] -pub struct FullContextState<'a> { - root: &'a ProvidedFullContext, - value: &'a Rc>, -} - -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>, - 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::() - .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>; - -struct ProvidedFullContextInner { - value: Rc>, - notify_any: Arc, - consumers: HashSet, - needs_regen: Cell, - update_root: Arc, -} - -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::("token").expect("Not called in a full context"); - let household = - LocalStorage::get::("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::("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::("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 - } -} diff --git a/gui/src/ingredients.rs b/gui/src/ingredients.rs deleted file mode 100644 index 61bf869..0000000 --- a/gui/src/ingredients.rs +++ /dev/null @@ -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 { - 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 { - 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() } - } - } - }) -} diff --git a/gui/src/lib.rs b/gui/src/lib.rs deleted file mode 100644 index cf7cfeb..0000000 --- a/gui/src/lib.rs +++ /dev/null @@ -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, -} - -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 { - use_shared_state::(cx) - .expect("no login info in scope") - .clone() -} - -pub fn use_error(cx: &ScopeState) -> &UseState> { - use_state(cx, || None) -} - -#[derive(Clone)] -pub struct Callback { - pub cb: Rc, -} - -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> for Callback { - fn from(cb: Rc) -> Self { - Self { cb } - } -} - -impl From 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::("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) -> Element { - use_shared_state_provider(cx, || cx.props.info.clone()); - - cx.render(rsx! { - Outlet:: {} - }) -} - -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 { - 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::); - let navigator = use_navigator(cx); - - let on_submit = move |e: Event| { - 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 { - 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> { - 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::); - 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 { - 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) -> 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:: {} - }) -} diff --git a/gui/src/recipe/creator.rs b/gui/src/recipe/creator.rs deleted file mode 100644 index fc192ed..0000000 --- a/gui/src/recipe/creator.rs +++ /dev/null @@ -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, - #[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 { - 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::::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" - } - } - } - } - } - }) -} diff --git a/gui/src/recipe/list.rs b/gui/src/recipe/list.rs deleted file mode 100644 index 57ef2b1..0000000 --- a/gui/src/recipe/list.rs +++ /dev/null @@ -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 { - 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 {} }, - }) -} diff --git a/gui/src/recipe/mod.rs b/gui/src/recipe/mod.rs deleted file mode 100644 index 3b2b905..0000000 --- a/gui/src/recipe/mod.rs +++ /dev/null @@ -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) -> 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>> { - 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, 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::); - 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::() { - 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 {} }, - }) -} diff --git a/gui/src/recipe/view.rs b/gui/src/recipe/view.rs deleted file mode 100644 index b408ac5..0000000 --- a/gui/src/recipe/view.rs +++ /dev/null @@ -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) -> 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) -> 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) -> 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, -} - -fn EditIngredient(cx: Scope) -> 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::() { - 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) -> 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) -> 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, - refresh: Callback, -} - -fn RecipeViewer(cx: Scope) -> 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> { - 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) -> 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 {} }, - }) -} diff --git a/gui/src/sidebar.rs b/gui/src/sidebar.rs deleted file mode 100644 index a4397da..0000000 --- a/gui/src/sidebar.rs +++ /dev/null @@ -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 for Option { - 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, - 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) -> 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:: {} } - } - } - }) -} - -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 } - } - }) -} diff --git a/src/main.rs b/src/main.rs index d259938..70fd5f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,10 @@ use std::{ collections::{HashMap, VecDeque}, net::SocketAddr, - path::PathBuf, sync::Arc, }; use anyhow::anyhow; -use axum::Router; use migration::{Migrator, MigratorTrait}; use openidconnect::{ core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata}, @@ -17,7 +15,6 @@ use openidconnect::{ use sea_orm::{ConnectOptions, Database, DatabaseConnection}; use serde::{Deserialize, Deserializer}; use time::OffsetDateTime; -use tower_http::services::{ServeDir, ServeFile}; use tower_sessions::{session_store::ExpiredDeletion, SessionManagerLayer}; use tower_sessions_sqlx_store::{sqlx::PgPool, PostgresStore}; use tracing::level_filters::LevelFilter; @@ -26,7 +23,6 @@ use uuid::Uuid; mod app; pub(crate) mod entity; -mod routes; const SESSION_DURATION: time::Duration = time::Duration::weeks(26); @@ -37,7 +33,7 @@ where use serde::de::Visitor; struct CommaVisitor; - impl<'de> Visitor<'de> for CommaVisitor { + impl Visitor<'_> for CommaVisitor { type Value = Vec; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { @@ -83,10 +79,6 @@ struct Settings { host: String, #[serde(default = "default_port")] port: u16, - #[serde(default)] - api_allowed: Option, - #[serde(default)] - serve_app: Option, database_url: String, #[serde(default)] oidc: Option, @@ -106,7 +98,6 @@ impl Settings { struct AppState { db: DatabaseConnection, oidc: Option, - sessions: Arc, } struct OpenidConnector { @@ -127,7 +118,7 @@ struct FifoMapInsert<'a> { map: &'a mut FifoMap, } -impl<'a> FifoMapInsert<'a> { +impl FifoMapInsert<'_> { pub fn insert(self, state: OpenidAuthState) { let FifoMapInsert { id, map } = self; @@ -340,30 +331,12 @@ async fn main() -> anyhow::Result<()> { let state = Arc::new(AppState { db: Database::connect(opt).await?, - sessions: sessions.into(), oidc, }); Migrator::up(&state.db, None).await?; - let router = Router::new() - .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"))), - ), - }; + let router = app::router().with_state(state).layer(session_layer); tracing::info!("Listening on http://{addr}"); diff --git a/src/routes/household.rs b/src/routes/household.rs deleted file mode 100644 index 03b7a13..0000000 --- a/src/routes/household.rs +++ /dev/null @@ -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 FromRequestParts for AuthorizedHousehold -where - S: Send + Sync, - AppState: FromRef, -{ - type Rejection = RouteError; - - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let State(app_state): State = 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 = - 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, -) -> super::JsonResult { - 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, - Json(request): Json, -) -> super::JsonResult { - 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, - Json(request): Json, -) -> super::JsonResult { - 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, -) -> super::JsonResult { - 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, - Json(request): Json, -) -> super::JsonResult { - let mut household: household::ActiveModel = household.into(); - - household.name = ActiveValue::Set(request.name); - - household.update(&state.db).await?; - - Ok(Json(EmptyResponse {})) -} diff --git a/src/routes/ingredients.rs b/src/routes/ingredients.rs deleted file mode 100644 index ee9a8be..0000000 --- a/src/routes/ingredients.rs +++ /dev/null @@ -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, - Json(request): Json, -) -> JsonResult { - 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, -) -> JsonResult { - 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 FromRequestParts for IngredientExtractor -where - S: Send + Sync, - AppState: FromRef, -{ - type Rejection = RouteError; - - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let State(app_state): State = 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 = - 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, - IngredientExtractor(ingredient): IngredientExtractor, -) -> JsonResult { - ingredient.delete(&state.db).await?; - - Ok(Json(EmptyResponse {})) -} - -pub(super) async fn edit_ingredient( - State(state): State, - IngredientExtractor(ingredient): IngredientExtractor, - Json(request): Json, -) -> JsonResult { - 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 {})) -} diff --git a/src/routes/mod.rs b/src/routes/mod.rs deleted file mode 100644 index 2d543fd..0000000 --- a/src/routes/mod.rs +++ /dev/null @@ -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>), -} - -impl From for Box { - 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 = Result, E>; - -type AppState = Arc; - -#[derive(Debug)] -struct AuthenticatedUser { - pub model: user::Model, -} - -#[async_trait] -impl FromRequestParts for AuthenticatedUser -where - S: Send + Sync, - AppState: FromRef, -{ - type Rejection = RouteError; - - async fn from_request_parts(_parts: &mut Parts, _state: &S) -> Result { - Err(RouteError::Unauthorized) - } -} - -async fn login( - State(_state): State, - Json(_req): Json, -) -> JsonResult { - return Err(RouteError::Unauthorized); -} - -#[derive(Deserialize)] -struct OidcStartParam { - r#return: String, -} - -async fn oidc_login( - State(state): State, - 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, - Query(redirect): Query, - jar: CookieJar, -) -> Result { - 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, - Path(name): Path, -) -> Result, 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, has_oidc: bool) -> Router { - 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| 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 - } -} diff --git a/src/routes/recipe.rs b/src/routes/recipe.rs deleted file mode 100644 index 7fcfb12..0000000 --- a/src/routes/recipe.rs +++ /dev/null @@ -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, - Json(request): Json, -) -> JsonResult { - 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, -) -> JsonResult { - 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 FromRequestParts for RecipeExtractor -where - S: Send + Sync, - AppState: FromRef, -{ - type Rejection = RouteError; - - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let State(app_state): State = 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 = 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, - RecipeExtractor(recipe): RecipeExtractor, -) -> JsonResult { - 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, - RecipeExtractor(recipe): RecipeExtractor, - Json(req): Json, -) -> JsonResult { - 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, - RecipeExtractor(recipe): RecipeExtractor, - IngredientExtractor(ingredient): IngredientExtractor, - Json(req): Json, -) -> JsonResult { - 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, - RecipeExtractor(recipe): RecipeExtractor, - IngredientExtractor(ingredient): IngredientExtractor, -) -> JsonResult { - 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, - RecipeExtractor(recipe): RecipeExtractor, - IngredientExtractor(ingredient): IngredientExtractor, - Json(req): Json, -) -> JsonResult { - 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, - RecipeExtractor(recipe): RecipeExtractor, - Json(req): Json, -) -> JsonResult { - 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, - RecipeExtractor(recipe): RecipeExtractor, - Json(req): Json, -) -> JsonResult { - 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, - RecipeExtractor(recipe): RecipeExtractor, - Json(req): Json, -) -> JsonResult { - 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()) -} diff --git a/web/Cargo.toml b/web/Cargo.toml deleted file mode 100644 index 0f24c13..0000000 --- a/web/Cargo.toml +++ /dev/null @@ -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" } diff --git a/web/Dioxus.toml b/web/Dioxus.toml deleted file mode 100644 index 66eb40f..0000000 --- a/web/Dioxus.toml +++ /dev/null @@ -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 = [] diff --git a/web/public/.gitignore b/web/public/.gitignore deleted file mode 100644 index 1e4616d..0000000 --- a/web/public/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/bootstrap-icons -/bootstrap diff --git a/web/public/awesomplete.css b/web/public/awesomplete.css deleted file mode 100644 index 27286dc..0000000 --- a/web/public/awesomplete.css +++ /dev/null @@ -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; - } diff --git a/web/public/awesomplete.min.js b/web/public/awesomplete.min.js deleted file mode 100644 index aa019a2..0000000 --- a/web/public/awesomplete.min.js +++ /dev/null @@ -1,3 +0,0 @@ -// Awesomplete - Lea Verou - MIT license -!function(){function t(t){var e=Array.isArray(t)?{label:t[0],value:t[1]}:"object"==typeof t&&"label"in t&&"value"in t?t:{label:t,value:t};this.label=e.label||e.value,this.value=e.value}function e(t,e,i){for(var n in e){var s=e[n],r=t.input.getAttribute("data-"+n.toLowerCase());"number"==typeof s?t[n]=parseInt(r):!1===s?t[n]=null!==r:s instanceof Function?t[n]=null:t[n]=r,t[n]||0===t[n]||(t[n]=n in i?i[n]:s)}}function i(t,e){return"string"==typeof t?(e||document).querySelector(t):t||null}function n(t,e){return o.call((e||document).querySelectorAll(t))}function s(){n("input.awesomplete").forEach(function(t){new r(t)})}var r=function(t,n){var s=this;r.count=(r.count||0)+1,this.count=r.count,this.isOpened=!1,this.input=i(t),this.input.setAttribute("autocomplete","off"),this.input.setAttribute("aria-expanded","false"),this.input.setAttribute("aria-owns","awesomplete_list_"+this.count),this.input.setAttribute("role","combobox"),this.options=n=n||{},e(this,{minChars:2,maxItems:10,autoFirst:!1,data:r.DATA,filter:r.FILTER_CONTAINS,sort:!1!==n.sort&&r.SORT_BYLENGTH,container:r.CONTAINER,item:r.ITEM,replace:r.REPLACE,tabSelect:!1,listLabel:"Results List"},n),this.index=-1,this.container=this.container(t),this.ul=i.create("ul",{hidden:"hidden",role:"listbox",id:"awesomplete_list_"+this.count,inside:this.container,"aria-label":this.listLabel}),this.status=i.create("span",{className:"visually-hidden",role:"status","aria-live":"assertive","aria-atomic":!0,inside:this.container,textContent:0!=this.minChars?"Type "+this.minChars+" or more characters for results.":"Begin typing for results."}),this._events={input:{input:this.evaluate.bind(this),blur:this.close.bind(this,{reason:"blur"}),keydown:function(t){var e=t.keyCode;s.opened&&(13===e&&s.selected?(t.preventDefault(),s.select(void 0,void 0,t)):9===e&&s.selected&&s.tabSelect?s.select(void 0,void 0,t):27===e?s.close({reason:"esc"}):38!==e&&40!==e||(t.preventDefault(),s[38===e?"previous":"next"]()))}},form:{submit:this.close.bind(this,{reason:"submit"})},ul:{mousedown:function(t){t.preventDefault()},click:function(t){var e=t.target;if(e!==this){for(;e&&!/li/i.test(e.nodeName);)e=e.parentNode;e&&0===t.button&&(t.preventDefault(),s.select(e,t.target,t))}}}},i.bind(this.input,this._events.input),i.bind(this.input.form,this._events.form),i.bind(this.ul,this._events.ul),this.input.hasAttribute("list")?(this.list="#"+this.input.getAttribute("list"),this.input.removeAttribute("list")):this.list=this.input.getAttribute("data-list")||n.list||[],r.all.push(this)};r.prototype={set list(t){if(Array.isArray(t))this._list=t;else if("string"==typeof t&&t.indexOf(",")>-1)this._list=t.split(/\s*,\s*/);else if((t=i(t))&&t.children){var e=[];o.apply(t.children).forEach(function(t){if(!t.disabled){var i=t.textContent.trim(),n=t.value||i,s=t.label||i;""!==n&&e.push({label:s,value:n})}}),this._list=e}document.activeElement===this.input&&this.evaluate()},get selected(){return this.index>-1},get opened(){return this.isOpened},close:function(t){this.opened&&(this.input.setAttribute("aria-expanded","false"),this.ul.setAttribute("hidden",""),this.isOpened=!1,this.index=-1,this.status.setAttribute("hidden",""),i.fire(this.input,"awesomplete-close",t||{}))},open:function(){this.input.setAttribute("aria-expanded","true"),this.ul.removeAttribute("hidden"),this.isOpened=!0,this.status.removeAttribute("hidden"),this.autoFirst&&-1===this.index&&this.goto(0),i.fire(this.input,"awesomplete-open")},destroy:function(){if(i.unbind(this.input,this._events.input),i.unbind(this.input.form,this._events.form),!this.options.container){var t=this.container.parentNode;t.insertBefore(this.input,this.container),t.removeChild(this.container)}this.input.removeAttribute("autocomplete"),this.input.removeAttribute("aria-autocomplete");var e=r.all.indexOf(this);-1!==e&&r.all.splice(e,1)},next:function(){var t=this.ul.children.length;this.goto(this.index-1&&e.length>0&&(e[t].setAttribute("aria-selected","true"),this.status.textContent=e[t].textContent+", list item "+(t+1)+" of "+e.length,this.input.setAttribute("aria-activedescendant",this.ul.id+"_item_"+this.index),this.ul.scrollTop=e[t].offsetTop-this.ul.clientHeight+e[t].clientHeight,i.fire(this.input,"awesomplete-highlight",{text:this.suggestions[this.index]}))},select:function(t,e,n){if(t?this.index=i.siblingIndex(t):t=this.ul.children[this.index],t){var s=this.suggestions[this.index];i.fire(this.input,"awesomplete-select",{text:s,origin:e||t,originalEvent:n})&&(this.replace(s),this.close({reason:"select"}),i.fire(this.input,"awesomplete-selectcomplete",{text:s,originalEvent:n}))}},evaluate:function(){var e=this,i=this.input.value;i.length>=this.minChars&&this._list&&this._list.length>0?(this.index=-1,this.ul.innerHTML="",this.suggestions=this._list.map(function(n){return new t(e.data(n,i))}).filter(function(t){return e.filter(t,i)}),!1!==this.sort&&(this.suggestions=this.suggestions.sort(this.sort)),this.suggestions=this.suggestions.slice(0,this.maxItems),this.suggestions.forEach(function(t,n){e.ul.appendChild(e.item(t,i,n))}),0===this.ul.children.length?(this.status.textContent="No results found",this.close({reason:"nomatches"})):(this.open(),this.status.textContent=this.ul.children.length+" results found")):(this.close({reason:"nomatches"}),this.status.textContent="No results found")}},r.all=[],r.FILTER_CONTAINS=function(t,e){return RegExp(i.regExpEscape(e.trim()),"i").test(t)},r.FILTER_STARTSWITH=function(t,e){return RegExp("^"+i.regExpEscape(e.trim()),"i").test(t)},r.SORT_BYLENGTH=function(t,e){return t.length!==e.length?t.length-e.length:t$&"),role:"option","aria-selected":"false",id:"awesomplete_list_"+this.count+"_item_"+n})},r.REPLACE=function(t){this.input.value=t.value},r.DATA=function(t){return t},Object.defineProperty(t.prototype=Object.create(String.prototype),"length",{get:function(){return this.label.length}}),t.prototype.toString=t.prototype.valueOf=function(){return""+this.label};var o=Array.prototype.slice;i.create=function(t,e){var n=document.createElement(t);for(var s in e){var r=e[s];if("inside"===s)i(r).appendChild(n);else if("around"===s){var o=i(r);o.parentNode.insertBefore(n,o),n.appendChild(o),null!=o.getAttribute("autofocus")&&o.focus()}else s in n?n[s]=r:n.setAttribute(s,r)}return n},i.bind=function(t,e){if(t)for(var i in e){var n=e[i];i.split(/\s+/).forEach(function(e){t.addEventListener(e,n)})}},i.unbind=function(t,e){if(t)for(var i in e){var n=e[i];i.split(/\s+/).forEach(function(e){t.removeEventListener(e,n)})}},i.fire=function(t,e,i){var n=document.createEvent("HTMLEvents");n.initEvent(e,!0,!0);for(var s in i)n[s]=i[s];return t.dispatchEvent(n)},i.regExpEscape=function(t){return t.replace(/[-\\^$*+?.()|[\]{}]/g,"\\$&")},i.siblingIndex=function(t){for(var e=0;t=t.previousElementSibling;e++);return e},"undefined"!=typeof self&&(self.Awesomplete=r),"undefined"!=typeof Document&&("loading"!==document.readyState?s():document.addEventListener("DOMContentLoaded",s)),r.$=i,r.$$=n,"object"==typeof module&&module.exports&&(module.exports=r)}(); -//# sourceMappingURL=awesomplete.min.js.map diff --git a/web/public/awesomplete.min.js.map b/web/public/awesomplete.min.js.map deleted file mode 100644 index 14802db..0000000 --- a/web/public/awesomplete.min.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["awesomplete.js"],"names":["Suggestion","data","o","Array","isArray","label","value","this","configure","instance","properties","i","initial","attrValue","input","getAttribute","toLowerCase","parseInt","Function","$","expr","con","document","querySelector","$$","slice","call","querySelectorAll","init","forEach","_","me","count","isOpened","setAttribute","options","minChars","maxItems","autoFirst","DATA","filter","FILTER_CONTAINS","sort","SORT_BYLENGTH","container","CONTAINER","item","ITEM","replace","REPLACE","tabSelect","listLabel","index","ul","create","hidden","role","id","inside","aria-label","status","className","aria-live","aria-atomic","textContent","_events","evaluate","bind","blur","close","reason","keydown","evt","c","keyCode","opened","selected","preventDefault","select","undefined","form","submit","mousedown","click","li","target","test","nodeName","parentNode","button","hasAttribute","list","removeAttribute","all","push","prototype","_list","indexOf","split","children","items","apply","el","disabled","text","trim","activeElement","fire","open","goto","destroy","unbind","insertBefore","removeChild","indexOfAwesomplete","splice","next","length","previous","pos","lis","scrollTop","offsetTop","clientHeight","suggestions","origin","originalEvent","siblingIndex","suggestion","innerHTML","map","appendChild","RegExp","regExpEscape","FILTER_STARTSWITH","a","b","around","item_id","aria-selected","Object","defineProperty","String","get","toString","valueOf","tag","element","createElement","val","ref","focus","event","callback","addEventListener","removeEventListener","type","createEvent","initEvent","j","dispatchEvent","s","previousElementSibling","self","Awesomplete","Document","readyState","module","exports"],"mappings":"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","file":"awesomplete.min.js","sourcesContent":["/**\n * Simple, lightweight, usable local autocomplete library for modern browsers\n * Because there weren’t enough autocomplete scripts in the world? Because I’m completely insane and have NIH syndrome? Probably both. :P\n * @author Lea Verou http://leaverou.github.io/awesomplete\n * MIT license\n */\n\n(function () {\n\nvar _ = function (input, o) {\n\tvar me = this;\n\n // Keep track of number of instances for unique IDs\n _.count = (_.count || 0) + 1;\n this.count = _.count;\n\n\t// Setup\n\n\tthis.isOpened = false;\n\n\tthis.input = $(input);\n\tthis.input.setAttribute(\"autocomplete\", \"off\");\n\tthis.input.setAttribute(\"aria-expanded\", \"false\");\n\tthis.input.setAttribute(\"aria-owns\", \"awesomplete_list_\" + this.count);\n\tthis.input.setAttribute(\"role\", \"combobox\");\n\n\t// store constructor options in case we need to distinguish\n\t// between default and customized behavior later on\n\tthis.options = o = o || {};\n\n\tconfigure(this, {\n\t\tminChars: 2,\n\t\tmaxItems: 10,\n\t\tautoFirst: false,\n\t\tdata: _.DATA,\n\t\tfilter: _.FILTER_CONTAINS,\n\t\tsort: o.sort === false ? false : _.SORT_BYLENGTH,\n\t\tcontainer: _.CONTAINER,\n\t\titem: _.ITEM,\n\t\treplace: _.REPLACE,\n\t\ttabSelect: false,\n\t\tlistLabel: \"Results List\"\n\t}, o);\n\n\tthis.index = -1;\n\n\t// Create necessary elements\n\n\tthis.container = this.container(input);\n\n\tthis.ul = $.create(\"ul\", {\n\t\thidden: \"hidden\",\n role: \"listbox\",\n id: \"awesomplete_list_\" + this.count,\n\t\tinside: this.container,\n\t\t\"aria-label\": this.listLabel\n\t});\n\n\tthis.status = $.create(\"span\", {\n\t\tclassName: \"visually-hidden\",\n\t\trole: \"status\",\n\t\t\"aria-live\": \"assertive\",\n \"aria-atomic\": true,\n inside: this.container,\n textContent: this.minChars != 0 ? (\"Type \" + this.minChars + \" or more characters for results.\") : \"Begin typing for results.\"\n\t});\n\n\t// Bind events\n\n\tthis._events = {\n\t\tinput: {\n\t\t\t\"input\": this.evaluate.bind(this),\n\t\t\t\"blur\": this.close.bind(this, { reason: \"blur\" }),\n\t\t\t\"keydown\": function(evt) {\n\t\t\t\tvar c = evt.keyCode;\n\n\t\t\t\t// If the dropdown `ul` is in view, then act on keydown for the following keys:\n\t\t\t\t// Enter / Esc / Up / Down\n\t\t\t\tif(me.opened) {\n\t\t\t\t\tif (c === 13 && me.selected) { // Enter\n\t\t\t\t\t\tevt.preventDefault();\n\t\t\t\t\t\tme.select(undefined, undefined, evt);\n\t\t\t\t\t}\n\t\t\t\t\telse if (c === 9 && me.selected && me.tabSelect) {\n\t\t\t\t\t\tme.select(undefined, undefined, evt);\n\t\t\t\t\t}\n\t\t\t\t\telse if (c === 27) { // Esc\n\t\t\t\t\t\tme.close({ reason: \"esc\" });\n\t\t\t\t\t}\n\t\t\t\t\telse if (c === 38 || c === 40) { // Down/Up arrow\n\t\t\t\t\t\tevt.preventDefault();\n\t\t\t\t\t\tme[c === 38? \"previous\" : \"next\"]();\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\tform: {\n\t\t\t\"submit\": this.close.bind(this, { reason: \"submit\" })\n\t\t},\n\t\tul: {\n\t\t\t// Prevent the default mousedowm, which ensures the input is not blurred.\n\t\t\t// The actual selection will happen on click. This also ensures dragging the\n\t\t\t// cursor away from the list item will cancel the selection\n\t\t\t\"mousedown\": function(evt) {\n\t\t\t\tevt.preventDefault();\n\t\t\t},\n\t\t\t// The click event is fired even if the corresponding mousedown event has called preventDefault\n\t\t\t\"click\": function(evt) {\n\t\t\t\tvar li = evt.target;\n\n\t\t\t\tif (li !== this) {\n\n\t\t\t\t\twhile (li && !/li/i.test(li.nodeName)) {\n\t\t\t\t\t\tli = li.parentNode;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (li && evt.button === 0) { // Only select on left click\n\t\t\t\t\t\tevt.preventDefault();\n\t\t\t\t\t\tme.select(li, evt.target, evt);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t};\n\n\t$.bind(this.input, this._events.input);\n\t$.bind(this.input.form, this._events.form);\n\t$.bind(this.ul, this._events.ul);\n\n\tif (this.input.hasAttribute(\"list\")) {\n\t\tthis.list = \"#\" + this.input.getAttribute(\"list\");\n\t\tthis.input.removeAttribute(\"list\");\n\t}\n\telse {\n\t\tthis.list = this.input.getAttribute(\"data-list\") || o.list || [];\n\t}\n\n\t_.all.push(this);\n};\n\n_.prototype = {\n\tset list(list) {\n\t\tif (Array.isArray(list)) {\n\t\t\tthis._list = list;\n\t\t}\n\t\telse if (typeof list === \"string\" && list.indexOf(\",\") > -1) {\n\t\t\t\tthis._list = list.split(/\\s*,\\s*/);\n\t\t}\n\t\telse { // Element or CSS selector\n\t\t\tlist = $(list);\n\n\t\t\tif (list && list.children) {\n\t\t\t\tvar items = [];\n\t\t\t\tslice.apply(list.children).forEach(function (el) {\n\t\t\t\t\tif (!el.disabled) {\n\t\t\t\t\t\tvar text = el.textContent.trim();\n\t\t\t\t\t\tvar value = el.value || text;\n\t\t\t\t\t\tvar label = el.label || text;\n\t\t\t\t\t\tif (value !== \"\") {\n\t\t\t\t\t\t\titems.push({ label: label, value: value });\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t\tthis._list = items;\n\t\t\t}\n\t\t}\n\n\t\tif (document.activeElement === this.input) {\n\t\t\tthis.evaluate();\n\t\t}\n\t},\n\n\tget selected() {\n\t\treturn this.index > -1;\n\t},\n\n\tget opened() {\n\t\treturn this.isOpened;\n\t},\n\n\tclose: function (o) {\n\t\tif (!this.opened) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis.input.setAttribute(\"aria-expanded\", \"false\");\n\t\tthis.ul.setAttribute(\"hidden\", \"\");\n\t\tthis.isOpened = false;\n\t\tthis.index = -1;\n\n\t\tthis.status.setAttribute(\"hidden\", \"\");\n\n\t\t$.fire(this.input, \"awesomplete-close\", o || {});\n\t},\n\n\topen: function () {\n\t\tthis.input.setAttribute(\"aria-expanded\", \"true\");\n\t\tthis.ul.removeAttribute(\"hidden\");\n\t\tthis.isOpened = true;\n\n\t\tthis.status.removeAttribute(\"hidden\");\n\n\t\tif (this.autoFirst && this.index === -1) {\n\t\t\tthis.goto(0);\n\t\t}\n\n\t\t$.fire(this.input, \"awesomplete-open\");\n\t},\n\n\tdestroy: function() {\n\t\t//remove events from the input and its form\n\t\t$.unbind(this.input, this._events.input);\n\t\t$.unbind(this.input.form, this._events.form);\n\n\t\t// cleanup container if it was created by Awesomplete but leave it alone otherwise\n\t\tif (!this.options.container) {\n\t\t\t//move the input out of the awesomplete container and remove the container and its children\n\t\t\tvar parentNode = this.container.parentNode;\n\n\t\t\tparentNode.insertBefore(this.input, this.container);\n\t\t\tparentNode.removeChild(this.container);\n\t\t}\n\n\t\t//remove autocomplete and aria-autocomplete attributes\n\t\tthis.input.removeAttribute(\"autocomplete\");\n\t\tthis.input.removeAttribute(\"aria-autocomplete\");\n\n\t\t//remove this awesomeplete instance from the global array of instances\n\t\tvar indexOfAwesomplete = _.all.indexOf(this);\n\n\t\tif (indexOfAwesomplete !== -1) {\n\t\t\t_.all.splice(indexOfAwesomplete, 1);\n\t\t}\n\t},\n\n\tnext: function () {\n\t\tvar count = this.ul.children.length;\n\t\tthis.goto(this.index < count - 1 ? this.index + 1 : (count ? 0 : -1) );\n\t},\n\n\tprevious: function () {\n\t\tvar count = this.ul.children.length;\n\t\tvar pos = this.index - 1;\n\n\t\tthis.goto(this.selected && pos !== -1 ? pos : count - 1);\n\t},\n\n\t// Should not be used, highlights specific item without any checks!\n\tgoto: function (i) {\n\t\tvar lis = this.ul.children;\n\n\t\tif (this.selected) {\n\t\t\tlis[this.index].setAttribute(\"aria-selected\", \"false\");\n\t\t}\n\n\t\tthis.index = i;\n\n\t\tif (i > -1 && lis.length > 0) {\n\t\t\tlis[i].setAttribute(\"aria-selected\", \"true\");\n\n\t\t\tthis.status.textContent = lis[i].textContent + \", list item \" + (i + 1) + \" of \" + lis.length;\n\n this.input.setAttribute(\"aria-activedescendant\", this.ul.id + \"_item_\" + this.index);\n\n\t\t\t// scroll to highlighted element in case parent's height is fixed\n\t\t\tthis.ul.scrollTop = lis[i].offsetTop - this.ul.clientHeight + lis[i].clientHeight;\n\n\t\t\t$.fire(this.input, \"awesomplete-highlight\", {\n\t\t\t\ttext: this.suggestions[this.index]\n\t\t\t});\n\t\t}\n\t},\n\n\tselect: function (selected, origin, originalEvent) {\n\t\tif (selected) {\n\t\t\tthis.index = $.siblingIndex(selected);\n\t\t} else {\n\t\t\tselected = this.ul.children[this.index];\n\t\t}\n\n\t\tif (selected) {\n\t\t\tvar suggestion = this.suggestions[this.index];\n\n\t\t\tvar allowed = $.fire(this.input, \"awesomplete-select\", {\n\t\t\t\ttext: suggestion,\n\t\t\t\torigin: origin || selected,\n\t\t\t\toriginalEvent: originalEvent\n\t\t\t});\n\n\t\t\tif (allowed) {\n\t\t\t\tthis.replace(suggestion);\n\t\t\t\tthis.close({ reason: \"select\" });\n\t\t\t\t$.fire(this.input, \"awesomplete-selectcomplete\", {\n\t\t\t\t\ttext: suggestion,\n\t\t\t\t\toriginalEvent: originalEvent\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t},\n\n\tevaluate: function() {\n\t\tvar me = this;\n\t\tvar value = this.input.value;\n\n\t\tif (value.length >= this.minChars && this._list && this._list.length > 0) {\n\t\t\tthis.index = -1;\n\t\t\t// Populate list with options that match\n\t\t\tthis.ul.innerHTML = \"\";\n\n\t\t\tthis.suggestions = this._list\n\t\t\t\t.map(function(item) {\n\t\t\t\t\treturn new Suggestion(me.data(item, value));\n\t\t\t\t})\n\t\t\t\t.filter(function(item) {\n\t\t\t\t\treturn me.filter(item, value);\n\t\t\t\t});\n\n\t\t\tif (this.sort !== false) {\n\t\t\t\tthis.suggestions = this.suggestions.sort(this.sort);\n\t\t\t}\n\n\t\t\tthis.suggestions = this.suggestions.slice(0, this.maxItems);\n\n\t\t\tthis.suggestions.forEach(function(text, index) {\n\t\t\t\t\tme.ul.appendChild(me.item(text, value, index));\n\t\t\t\t});\n\n\t\t\tif (this.ul.children.length === 0) {\n\n this.status.textContent = \"No results found\";\n\n\t\t\t\tthis.close({ reason: \"nomatches\" });\n\n\t\t\t} else {\n\t\t\t\tthis.open();\n\n this.status.textContent = this.ul.children.length + \" results found\";\n\t\t\t}\n\t\t}\n\t\telse {\n\t\t\tthis.close({ reason: \"nomatches\" });\n\n this.status.textContent = \"No results found\";\n\t\t}\n\t}\n};\n\n// Static methods/properties\n\n_.all = [];\n\n_.FILTER_CONTAINS = function (text, input) {\n\treturn RegExp($.regExpEscape(input.trim()), \"i\").test(text);\n};\n\n_.FILTER_STARTSWITH = function (text, input) {\n\treturn RegExp(\"^\" + $.regExpEscape(input.trim()), \"i\").test(text);\n};\n\n_.SORT_BYLENGTH = function (a, b) {\n\tif (a.length !== b.length) {\n\t\treturn a.length - b.length;\n\t}\n\n\treturn a < b? -1 : 1;\n};\n\n_.CONTAINER = function (input) {\n\treturn $.create(\"div\", {\n\t\tclassName: \"awesomplete\",\n\t\taround: input\n\t});\n}\n\n_.ITEM = function (text, input, item_id) {\n\tvar html = input.trim() === \"\" ? text : text.replace(RegExp($.regExpEscape(input.trim()), \"gi\"), \"$&\");\n\treturn $.create(\"li\", {\n\t\tinnerHTML: html,\n\t\t\"role\": \"option\",\n\t\t\"aria-selected\": \"false\",\n\t\t\"id\": \"awesomplete_list_\" + this.count + \"_item_\" + item_id\n\t});\n};\n\n_.REPLACE = function (text) {\n\tthis.input.value = text.value;\n};\n\n_.DATA = function (item/*, input*/) { return item; };\n\n// Private functions\n\nfunction Suggestion(data) {\n\tvar o = Array.isArray(data)\n\t ? { label: data[0], value: data[1] }\n\t : typeof data === \"object\" && \"label\" in data && \"value\" in data ? data : { label: data, value: data };\n\n\tthis.label = o.label || o.value;\n\tthis.value = o.value;\n}\nObject.defineProperty(Suggestion.prototype = Object.create(String.prototype), \"length\", {\n\tget: function() { return this.label.length; }\n});\nSuggestion.prototype.toString = Suggestion.prototype.valueOf = function () {\n\treturn \"\" + this.label;\n};\n\nfunction configure(instance, properties, o) {\n\tfor (var i in properties) {\n\t\tvar initial = properties[i],\n\t\t attrValue = instance.input.getAttribute(\"data-\" + i.toLowerCase());\n\n\t\tif (typeof initial === \"number\") {\n\t\t\tinstance[i] = parseInt(attrValue);\n\t\t}\n\t\telse if (initial === false) { // Boolean options must be false by default anyway\n\t\t\tinstance[i] = attrValue !== null;\n\t\t}\n\t\telse if (initial instanceof Function) {\n\t\t\tinstance[i] = null;\n\t\t}\n\t\telse {\n\t\t\tinstance[i] = attrValue;\n\t\t}\n\n\t\tif (!instance[i] && instance[i] !== 0) {\n\t\t\tinstance[i] = (i in o)? o[i] : initial;\n\t\t}\n\t}\n}\n\n// Helpers\n\nvar slice = Array.prototype.slice;\n\nfunction $(expr, con) {\n\treturn typeof expr === \"string\"? (con || document).querySelector(expr) : expr || null;\n}\n\nfunction $$(expr, con) {\n\treturn slice.call((con || document).querySelectorAll(expr));\n}\n\n$.create = function(tag, o) {\n\tvar element = document.createElement(tag);\n\n\tfor (var i in o) {\n\t\tvar val = o[i];\n\n\t\tif (i === \"inside\") {\n\t\t\t$(val).appendChild(element);\n\t\t}\n\t\telse if (i === \"around\") {\n\t\t\tvar ref = $(val);\n\t\t\tref.parentNode.insertBefore(element, ref);\n\t\t\telement.appendChild(ref);\n\n\t\t\tif (ref.getAttribute(\"autofocus\") != null) {\n\t\t\t\tref.focus();\n\t\t\t}\n\t\t}\n\t\telse if (i in element) {\n\t\t\telement[i] = val;\n\t\t}\n\t\telse {\n\t\t\telement.setAttribute(i, val);\n\t\t}\n\t}\n\n\treturn element;\n};\n\n$.bind = function(element, o) {\n\tif (element) {\n\t\tfor (var event in o) {\n\t\t\tvar callback = o[event];\n\n\t\t\tevent.split(/\\s+/).forEach(function (event) {\n\t\t\t\telement.addEventListener(event, callback);\n\t\t\t});\n\t\t}\n\t}\n};\n\n$.unbind = function(element, o) {\n\tif (element) {\n\t\tfor (var event in o) {\n\t\t\tvar callback = o[event];\n\n\t\t\tevent.split(/\\s+/).forEach(function(event) {\n\t\t\t\telement.removeEventListener(event, callback);\n\t\t\t});\n\t\t}\n\t}\n};\n\n$.fire = function(target, type, properties) {\n\tvar evt = document.createEvent(\"HTMLEvents\");\n\n\tevt.initEvent(type, true, true );\n\n\tfor (var j in properties) {\n\t\tevt[j] = properties[j];\n\t}\n\n\treturn target.dispatchEvent(evt);\n};\n\n$.regExpEscape = function (s) {\n\treturn s.replace(/[-\\\\^$*+?.()|[\\]{}]/g, \"\\\\$&\");\n};\n\n$.siblingIndex = function (el) {\n\t/* eslint-disable no-cond-assign */\n\tfor (var i = 0; el = el.previousElementSibling; i++);\n\treturn i;\n};\n\n// Initialization\n\nfunction init() {\n\t$$(\"input.awesomplete\").forEach(function (input) {\n\t\tnew _(input);\n\t});\n}\n\n// Make sure to export Awesomplete on self when in a browser\nif (typeof self !== \"undefined\") {\n\tself.Awesomplete = _;\n}\n\n// Are we in a browser? Check for Document constructor\nif (typeof Document !== \"undefined\") {\n\t// DOM already loaded?\n\tif (document.readyState !== \"loading\") {\n\t\tinit();\n\t}\n\telse {\n\t\t// Wait for it\n\t\tdocument.addEventListener(\"DOMContentLoaded\", init);\n\t}\n}\n\n_.$ = $;\n_.$$ = $$;\n\n// Expose Awesomplete as a CJS module\nif (typeof module === \"object\" && module.exports) {\n\tmodule.exports = _;\n}\n\nreturn _;\n\n}());\n"]} \ No newline at end of file diff --git a/web/public/household_selection.css b/web/public/household_selection.css deleted file mode 100644 index fe2e647..0000000 --- a/web/public/household_selection.css +++ /dev/null @@ -1,12 +0,0 @@ -html, -body, -#main { - height: 100%; -} - -#main { - display: flex; - align-items: center; - padding-top: 40px; - padding-bottom: 40px; -} diff --git a/web/public/login.css b/web/public/login.css deleted file mode 100644 index fdab365..0000000 --- a/web/public/login.css +++ /dev/null @@ -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; -} diff --git a/web/public/style.css b/web/public/style.css deleted file mode 100644 index e52048b..0000000 --- a/web/public/style.css +++ /dev/null @@ -1,4 +0,0 @@ -hr { - border: 1px solid #595c5f; - opacity: 1; -} diff --git a/web/src/main.rs b/web/src/main.rs deleted file mode 100644 index f1d47ab..0000000 --- a/web/src/main.rs +++ /dev/null @@ -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(), - ) -}