diff options
Diffstat (limited to '')
47 files changed, 2367 insertions, 280 deletions
diff --git a/.gitignore b/.gitignore index f608a06..8914bee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/setup/target /dist /public/tailwindcss-output.css diff --git a/Cargo.lock b/Cargo.lock index 645f23d..70883c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,6 +290,35 @@ dependencies = [ ] [[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -412,6 +441,15 @@ dependencies = [ ] [[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] name = "drain_filter_polyfill" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1216,6 +1254,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] name = "lock_api" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1509,6 +1553,22 @@ dependencies = [ ] [[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] name = "quote" version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1658,12 +1718,14 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "cookie", + "cookie_store", "futures-core", "futures-util", "http", @@ -1693,6 +1755,8 @@ dependencies = [ [[package]] name = "rocie-client" version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3bb4f4858511444da651f93844df66405447bc778545b096cc7214353e34929" dependencies = [ "reqwest", "serde", @@ -1919,9 +1983,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.15.1" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ "base64", "chrono", @@ -1938,9 +2002,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.15.1" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ "darling", "proc-macro2", @@ -2294,9 +2358,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", @@ -2423,13 +2487,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index eb9e795..9749bee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ web-sys = { version = "0.3", features = ["Document", "Window"] } [dependencies] rocie-macros = { path = "./rocie-macros/" } -rocie-client = { path = "../rocie-server/crates/rocie-client" } +rocie-client = { version = "0.1" } leptos = { version = "0.8", features = ["csr"] } reactive_stores = { version = "0.2" } leptos_meta = { version = "0.8" } diff --git a/Trunk.toml b/Trunk.toml index 68c3bf0..c16525c 100644 --- a/Trunk.toml +++ b/Trunk.toml @@ -1,10 +1,7 @@ [[hooks]] stage = "pre_build" -command = "sh" -command_arguments = [ - "-c", - "tailwindcss -i input.css -o public/tailwindcss-output.css", -] +command = "tailwindcss" +command_arguments = ["-i", "input.css", "-o", "public/tailwindcss-output.css"] [serve] addresses = ["127.0.0.1"] diff --git a/flake.lock b/flake.lock index 6f9f237..a0a10e6 100644 --- a/flake.lock +++ b/flake.lock @@ -1,12 +1,27 @@ { "nodes": { + "crane": { + "locked": { + "lastModified": 1773857772, + "narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=", + "owner": "ipetkov", + "repo": "crane", + "rev": "b556d7bbae5ff86e378451511873dfd07e4504cd", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, "nixpkgs": { "locked": { - "lastModified": 1761164809, - "narHash": "sha256-3uM91Lx9WZomE6MMEBorJyEyBNiHWRIxza/GganDxew=", + "lastModified": 1773826178, + "narHash": "sha256-RwA0KkNaCDBMDGYef/OjG3Z5B5oRTuV6Zy1iPk3F8Ro=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3d2db9755e7815937fb7b8f089fad9b44bc416d8", + "rev": "af90506ab0acf18cfd6449225c32c096138cba52", "type": "github" }, "original": { @@ -18,6 +33,7 @@ }, "root": { "inputs": { + "crane": "crane", "nixpkgs": "nixpkgs", "rust-overlay": "rust-overlay", "treefmt-nix": "treefmt-nix" @@ -30,11 +46,11 @@ ] }, "locked": { - "lastModified": 1761100675, - "narHash": "sha256-LX3TCDBeNpCWTDXtGyRASVcLmRPChSli34bgHnZ1DCw=", + "lastModified": 1773889863, + "narHash": "sha256-tSsmZOHBgq4qfu5MNCAEsKZL1cI4avNLw2oUTXWeb74=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "72161c6c53f6e3f8dadaf54b2204a5094c6a16ae", + "rev": "dbfd51be2692cb7022e301d14c139accb4ee63f0", "type": "github" }, "original": { @@ -50,11 +66,11 @@ ] }, "locked": { - "lastModified": 1760945191, - "narHash": "sha256-ZRVs8UqikBa4Ki3X4KCnMBtBW0ux1DaT35tgsnB1jM4=", + "lastModified": 1773297127, + "narHash": "sha256-6E/yhXP7Oy/NbXtf1ktzmU8SdVqJQ09HC/48ebEGBpk=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "f56b1934f5f8fcab8deb5d38d42fd692632b47c2", + "rev": "71b125cd05fbfd78cab3e070b73544abe24c5016", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 776e8bd..425a1a9 100644 --- a/flake.nix +++ b/flake.nix @@ -26,6 +26,7 @@ nixpkgs.follows = "nixpkgs"; }; }; + crane.url = "github:ipetkov/crane"; }; outputs = { @@ -33,6 +34,7 @@ nixpkgs, treefmt-nix, rust-overlay, + crane, }: let system = "x86_64-linux"; pkgs = import nixpkgs { @@ -48,11 +50,23 @@ pkgs.pkg-config ]; - rocie = pkgs.callPackage ./nix/package.nix {}; + rust = + pkgs.rust-bin.stable.latest.default.override { + targets = ["wasm32-unknown-unknown"]; + }; - rust = pkgs.rust-bin.stable.latest.default.override { - targets = ["wasm32-unknown-unknown"]; - }; + # NB: we don't need to overlay our custom toolchain for the *entire* + # pkgs (which would require rebuidling anything else which uses rust). + # Instead, we just want to update the scope that crane will use by appending + # our specific toolchain there. + craneLib = (crane.mkLib pkgs).overrideToolchain ( + p: + p.rust-bin.stable.latest.default.override { + targets = ["wasm32-unknown-unknown"]; + } + ); + + rocie = pkgs.callPackage ./nix/package.nix {inherit craneLib;}; treefmtEval = import ./treefmt.nix {inherit treefmt-nix pkgs;}; in { @@ -74,7 +88,7 @@ packages = [ # rust stuff rust - pkgs.mold-wrapped + pkgs.mold pkgs.cargo-edit pkgs.cargo-expand diff --git a/index.html b/index.html index 6fe87e8..7085f94 100644 --- a/index.html +++ b/index.html @@ -27,6 +27,16 @@ rel="css" href="./public/tailwindcss-output.css" /> + + <link + rel="manifest" + href="/manifest.json" + /> + <link + data-trunk + rel="copy-file" + href="./public/manifest.json" + /> </head> <body></body> diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..1bcc0ae --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,69 @@ +# rocie - An enterprise grocery management system +# +# Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> +# Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +# SPDX-License-Identifier: GPL-3.0-or-later +# +# This file is part of Rocie. +# +# You should have received a copy of the License along with this program. +# If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. +{ + lib, + craneLib, + # nativeBuildInputs + trunk, + tailwindcss, + wasm-bindgen-cli_0_2_104, + binaryen, +}: +craneLib.buildPackage { + pname = "rocie-mobile"; + inherit + ((builtins.fromTOML (builtins.readFile + ../Cargo.toml)).package) + version + ; + + src = lib.cleanSourceWith { + src = lib.cleanSource ./..; + filter = name: type: + (type == "directory") + || (builtins.elem (builtins.baseNameOf name) [ + "Cargo.toml" + "Cargo.lock" + "tailwind.config.js" + "index.html" + "input.css" + "Trunk.toml" + "manifest.json" + "favicon.ico" + ]) + || (lib.strings.hasSuffix ".rs" (builtins.baseNameOf name)); + }; + strictDeps = true; + + cargoExtraArgs = "--target wasm32-unknown-unknown"; + + # Tests currently need to be run via `cargo wasi` which + # isn't packaged in nixpkgs yet... + doCheck = false; + + nativeBuildInputs = [ + trunk + tailwindcss + wasm-bindgen-cli_0_2_104 + binaryen # for wasm-opt + ]; + + buildInputs = [ + ]; + + postInstall = '' + trunk --offline --verbose build --release --locked --frozen --dist "./dist" + + rm --recursive $out/bin + + cp --recursive ./dist/. $out/ + ''; +} diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..5ea8b9d --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "Rocie", + "icons": [ + { + "src": "favicon.ico", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": "/", + "display": "standalone", + "prefer_related_applications": false +} diff --git a/rocie-macros/src/form/generate.rs b/rocie-macros/src/form/generate.rs index 5642e6a..89acea8 100644 --- a/rocie-macros/src/form/generate.rs +++ b/rocie-macros/src/form/generate.rs @@ -1,7 +1,7 @@ use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote}; -use syn::{Ident, Type, parse_macro_input}; +use syn::{Ident, Path, Type, TypePath, parse_macro_input, punctuated::Punctuated}; use crate::form::{ParsedChild, ParsedInput}; @@ -33,7 +33,9 @@ pub fn form_impl(item: TokenStream) -> TokenStream { let output = quote!({ use crate::components::{ input_placeholder::InputPlaceholder, - select_placeholder::SelectPlaceholder + select_placeholder::SelectPlaceholder, + textarea_placeholder::TextareaPlaceholder, + checkbox_placeholder::CheckboxPlaceholder, }; use leptos::{ @@ -48,7 +50,7 @@ pub fn form_impl(item: TokenStream) -> TokenStream { Set, Show, }, - html::{Input, Select}, + html::{Input, Select, Textarea}, web_sys::SubmitEvent }; @@ -126,6 +128,8 @@ fn node_ref(child: &ParsedChild) -> TokenStream2 { let (name, ty) = match child { ParsedChild::Input { name, .. } => (name, quote! {Input}), ParsedChild::Select { name, .. } => (name, quote! {Select}), + ParsedChild::Textarea { name, .. } => (name, quote! {Textarea}), + ParsedChild::Checkbox { name, .. } => (name, quote! {Input}), ParsedChild::Show { .. } => unreachable!("Filtered out before"), }; @@ -149,7 +153,9 @@ fn signal(child: &ParsedChild) -> TokenStream2 { let (#signal_name_get, #signal_name_set) = signal(None); } } - ParsedChild::Select { .. } => quote! {}, + ParsedChild::Textarea { .. } + | ParsedChild::Checkbox { .. } + | ParsedChild::Select { .. } => quote! {}, ParsedChild::Show { children, .. } => children.iter().map(signal).collect(), } } @@ -185,7 +191,9 @@ fn auto_complete_signal(child: &ParsedChild) -> TokenStream2 { quote! {} } } - ParsedChild::Select { .. } => quote! {}, + ParsedChild::Select { .. } + | ParsedChild::Textarea { .. } + | ParsedChild::Checkbox { .. } => quote! {}, ParsedChild::Show { children, .. } => children.iter().map(auto_complete_signal).collect(), } } @@ -238,14 +246,16 @@ fn reactive_signal(child: &ParsedChild) -> TokenStream2 { quote! {} } } - ParsedChild::Select { .. } => quote! {}, + ParsedChild::Select { .. } + | ParsedChild::Textarea { .. } + | ParsedChild::Checkbox { .. } => quote! {}, ParsedChild::Show { children, .. } => children.iter().map(reactive_signal).collect(), } } fn type_verification(child: &ParsedChild) -> TokenStream2 { match child { - ParsedChild::Input { .. } => { + ParsedChild::Input { .. } | ParsedChild::Textarea { .. } | ParsedChild::Checkbox { .. } => { quote! {} } ParsedChild::Select { name, options, .. } => { @@ -278,24 +288,65 @@ fn type_verification(child: &ParsedChild) -> TokenStream2 { fn get_names(input: &ParsedChild) -> Vec<&Ident> { match input { - ParsedChild::Input { name, .. } => vec![name], - ParsedChild::Select { name, .. } => vec![name], + ParsedChild::Input { name, .. } + | ParsedChild::Textarea { name, .. } + | ParsedChild::Checkbox { name, .. } + | ParsedChild::Select { name, .. } => vec![name], ParsedChild::Show { children, .. } => children.iter().flat_map(get_names).collect(), } } fn output_struct_definition(children: &[ParsedChild]) -> TokenStream2 { - fn get_rust_types(input: &ParsedChild) -> Vec<&Type> { + macro_rules! mk_type { + ($name:ident) => {{ + let segments = { + let mut p = Punctuated::new(); + p.push(syn::PathSegment { + ident: format_ident!(stringify!($name)), + arguments: syn::PathArguments::None, + }); + p + }; + + Type::Path(TypePath { + qself: None, + path: Path { + leading_colon: None, + segments, + }, + }) + }}; + } + + let string: Type = mk_type!(String); + let boolean: Type = mk_type!(bool); + + fn get_rust_types<'a>( + input: &'a ParsedChild, + string: &'a Type, + bool: &'a Type, + ) -> Vec<&'a Type> { match input { - ParsedChild::Input { rust_type, .. } => vec![rust_type], - ParsedChild::Select { rust_type, .. } => vec![rust_type], - ParsedChild::Show { children, .. } => { - children.iter().flat_map(get_rust_types).collect() + ParsedChild::Textarea { .. } => { + vec![string] } + ParsedChild::Checkbox { .. } => { + vec![bool] + } + ParsedChild::Input { rust_type, .. } | ParsedChild::Select { rust_type, .. } => { + vec![rust_type] + } + ParsedChild::Show { children, .. } => children + .iter() + .flat_map(|i| get_rust_types(i, string, bool)) + .collect(), } } + let names = children.iter().flat_map(get_names); - let rust_types = children.iter().flat_map(get_rust_types); + let rust_types = children + .iter() + .flat_map(|i| get_rust_types(i, &string, &boolean)); quote! { struct Inputs { @@ -358,6 +409,42 @@ fn fetch_value(child: &ParsedChild) -> TokenStream2 { }; } } + ParsedChild::Textarea { name, .. } => { + let node_ref_name = node_ref_name(name); + + quote! { + let #name: String = { + let output = #node_ref_name + .get() + // event handlers can only fire after the view + // is mounted to the DOM, so the `NodeRef` will be `Some` + .expect("<textarea> to exist") + .value(); + + let fin: String = output.to_owned(); + + fin + }; + } + } + ParsedChild::Checkbox { name, .. } => { + let node_ref_name = node_ref_name(name); + + quote! { + let #name: bool = { + let output = #node_ref_name + .get() + // event handlers can only fire after the view + // is mounted to the DOM, so the `NodeRef` will be `Some` + .expect("<checkbox> to exist") + .value(); + + let fin: bool = if output == "on" { true } else { false }; + + fin + }; + } + } ParsedChild::Select { name, rust_type, .. } => { @@ -370,7 +457,7 @@ fn fetch_value(child: &ParsedChild) -> TokenStream2 { .get() // event handlers can only fire after the view // is mounted to the DOM, so the `NodeRef` will be `Some` - .expect("<input> to exist") + .expect("<select> to exist") .value(); let fin: Result<#rust_type, leptos::error::Error> = output @@ -384,6 +471,9 @@ fn fetch_value(child: &ParsedChild) -> TokenStream2 { ok } , Err(err) => { + // TODO: This can certainly happen (think of an empty string in a numeric input.) + // As such, we should have an error field per input, that we can populate with the + // error we received here. <2025-12-30> unreachable!("Should be ruled out at compile time: {err}") } } @@ -450,6 +540,20 @@ fn input_html(child: &ParsedChild) -> TokenStream2 { </Show> } } + ParsedChild::Textarea { name, label, .. } => { + let node_ref_name = node_ref_name(name); + + quote! { + <TextareaPlaceholder label=#label node_ref=#node_ref_name /> + } + } + ParsedChild::Checkbox { name, label } => { + let node_ref_name = node_ref_name(name); + + quote! { + <CheckboxPlaceholder label=#label node_ref=#node_ref_name /> + } + } ParsedChild::Select { name, label, diff --git a/rocie-macros/src/form/mod.rs b/rocie-macros/src/form/mod.rs index b165750..978b081 100644 --- a/rocie-macros/src/form/mod.rs +++ b/rocie-macros/src/form/mod.rs @@ -5,14 +5,12 @@ mod parse; pub use generate::form_impl; -#[derive(Debug)] pub struct ParsedOnSubmit { inputs: Vec<Ident>, block: Expr, pub(crate) should_use_move: bool, } -#[derive(Debug)] pub enum ParsedChild { Input { name: Ident, @@ -22,6 +20,10 @@ pub enum ParsedChild { reactive: Option<Ident>, auto_complete: Option<Ident>, }, + Checkbox { + name: Ident, + label: LitStr, + }, Select { name: Ident, label: LitStr, @@ -32,9 +34,12 @@ pub enum ParsedChild { when: Expr, children: Vec<ParsedChild>, }, + Textarea { + name: Ident, + label: LitStr, + }, } -#[derive(Debug)] pub struct ParsedInput { on_submit: ParsedOnSubmit, children: Vec<ParsedChild>, diff --git a/rocie-macros/src/form/parse.rs b/rocie-macros/src/form/parse.rs index 59a82c1..2cf8799 100644 --- a/rocie-macros/src/form/parse.rs +++ b/rocie-macros/src/form/parse.rs @@ -86,6 +86,15 @@ impl Parse for ParsedChild { auto_complete, } } + variant if variant == format_ident!("Textarea") => { + let name = parse_key_value!(input, name as Ident); + let label = parse_key_value!(input, label as LitStr); + + input.parse::<Token![/]>()?; + input.parse::<Token![>]>()?; + + ParsedChild::Textarea { name, label } + } variant if variant == format_ident!("Select") => { let name = parse_key_value!(input, name as Ident); let rust_type = parse_key_value!(input, rust_type as Type); @@ -130,6 +139,15 @@ impl Parse for ParsedChild { ParsedChild::Show { when, children } } + variant if variant == format_ident!("Checkbox") => { + let name = parse_key_value!(input, name as Ident); + let label = parse_key_value!(input, label as LitStr); + + input.parse::<Token![/]>()?; + input.parse::<Token![>]>()?; + + ParsedChild::Checkbox { name, label } + } _ => panic!("Unknown form child variant: {variant}"), }; diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..175ec13 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,6 @@ +#! /usr/bin/env sh + +cargo run --manifest-path ./webserver/Cargo.toml & +trunk serve + +# vim: ft=sh diff --git a/setup/Cargo.lock b/setup/Cargo.lock new file mode 100644 index 0000000..5c88354 --- /dev/null +++ b/setup/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "setup" +version = "0.1.0" diff --git a/setup/Cargo.toml b/setup/Cargo.toml new file mode 100644 index 0000000..bb8f741 --- /dev/null +++ b/setup/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "setup" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/setup/src/main.rs b/setup/src/main.rs new file mode 100644 index 0000000..90bd23d --- /dev/null +++ b/setup/src/main.rs @@ -0,0 +1,55 @@ +use std::{ + process::{Child, Command}, + thread::sleep, + time::Duration, +}; + +struct Processes { + inner: Vec<Child>, +} + +fn main() { + let mut processes = Processes { inner: vec![] }; + + processes.spawn_in_terminal(&[ + "bash", + "-c", + "(cd ../rocie-server && eval \"$(direnv export bash 2>/dev/null)\" && bash -c 'cargo run --package rocie-server -- serve --port 8080')" + ]); + processes.spawn_in_terminal(&[ + "bash", + "-c", + "trunk serve --proxy-backend=http://localhost:8080/ --proxy-rewrite=/api/", + ]); + processes.spawn_in_terminal(&[ + "bash", + "-c", + "nix run n#floorp-bin -- http://localhost:3000", + ]); + + loop { + sleep(Duration::from_secs(2 * 60 * 60)); + } +} + +impl Drop for Processes { + fn drop(&mut self) { + for process in &mut self.inner { + process.kill().unwrap(); + } + } +} + +impl Processes { + fn spawn_in_terminal(&mut self, args: &[&str]) { + let mut alacritty = Command::new("alacritty"); + alacritty.arg("--hold"); + alacritty.arg("--command"); + alacritty.args(args); + + eprintln!("Spawning `{:?}`", alacritty); + + let new_child = alacritty.spawn().unwrap(); + self.inner.push(new_child); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 3bc870c..eb9ca3a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,20 +1,38 @@ use leptos::error::Error; use rocie_client::{ apis::{ - api_get_inventory_api::amount_by_id, - api_get_product_api::{ - product_by_id, product_by_name, product_suggestion_by_name, products_in_storage, - products_registered, + api_get_auth_inventory_api::amount_by_id, + api_get_auth_product_api::{ + product_by_id, product_by_name, product_suggestion_by_name, + products_by_product_parent_id_direct, products_by_product_parent_id_indirect, + products_in_storage, products_registered, products_without_product_parent, }, - api_get_unit_api::unit_by_id, - api_get_unit_property_api::{unit_properties, unit_property_by_id}, - api_set_barcode_api::buy_barcode, - api_set_product_api::{associate_barcode, register_product}, + api_get_auth_product_parent_api::{ + product_parents, product_parents_toplevel, product_parents_under, + }, + api_get_auth_recipe_api::{ + recipe_by_id, recipe_by_name, recipes, recipes_by_recipe_parent_id_direct, + recipes_by_recipe_parent_id_indirect, recipes_without_recipe_parent, + }, + api_get_auth_recipe_parent_api::{ + recipe_parents, recipe_parents_toplevel, recipe_parents_under, + }, + api_get_auth_unit_api::{unit_by_id, units, units_by_property_id}, + api_get_auth_unit_property_api::{unit_properties, unit_property_by_id}, + api_get_no_auth_state_api::{can_be_provisioned, is_logged_in}, + api_set_auth_barcode_api::buy_barcode, + api_set_auth_product_api::{associate_barcode, register_product}, + api_set_auth_product_parent_api::register_product_parent, + api_set_auth_recipe_api::add_recipe, + api_set_auth_recipe_parent_api::register_recipe_parent, + api_set_no_auth_user_api::{login, provision}, configuration::Configuration, }, models::{ - Barcode, BarcodeId, Product, ProductAmount, ProductId, ProductStub, Unit, UnitId, - UnitProperty, UnitPropertyId, + Barcode, BarcodeId, LoginInfo, Product, ProductAmount, ProductId, ProductParent, + ProductParentId, ProductParentStub, ProductStub, ProvisionInfo, Recipe, RecipeId, + RecipeParent, RecipeParentId, RecipeParentStub, RecipeStub, Unit, UnitId, UnitProperty, + UnitPropertyId, UserId, UserStub, }, }; @@ -89,22 +107,179 @@ macro_rules! mk_wrapper { } } -mk_wrapper!(product_by_id(product_id: ProductId) -> Product as product_by_id_wrapped); +mk_wrapper!( + is_logged_in() -> bool + as is_logged_in_wrapped +); +mk_wrapper!( + can_be_provisioned() -> bool + as can_be_provisioned_wrapped +); + +mk_wrapper!( + @external_config + login(&config, login_info: LoginInfo) -> () + as login_external_wrapped +); +mk_wrapper!( + @external_config + provision(&config, provsion_info: ProvisionInfo) -> UserId + as provision_external_wrapped +); + +mk_wrapper!( + product_by_id(product_id: ProductId) -> Product + as product_by_id_wrapped +); + +mk_wrapper!( + product_by_name(name: &str) -> Product + as product_by_name_wrapped +); +mk_wrapper!( + @treat_404_as_None + product_by_name(name: &str) -> Option<Product> + as product_by_name_404_wrapped +); +mk_wrapper!( + @external_config + product_by_name(&config, name: &str) -> Product + as product_by_name_external_wrapped +); + +mk_wrapper!( + product_suggestion_by_name(part_name: &str) -> Vec<Product> + as product_suggestion_by_name_wrapped +); + +mk_wrapper!( + units() -> Vec<Unit> + as units_wrapped +); +mk_wrapper!( + units_by_property_id(id: UnitPropertyId) -> Vec<Unit> + as units_by_property_id_wrapped +); +mk_wrapper!( + unit_by_id(unit_id: UnitId) -> Unit + as unit_by_id_wrapped +); +mk_wrapper!( + unit_property_by_id(unit_id: UnitPropertyId) -> UnitProperty + as unit_property_by_id_wrapped +); +mk_wrapper!( + unit_properties() -> Vec<UnitProperty> + as unit_properties_wrapped +); -mk_wrapper!(@treat_404_as_None product_by_name(name: &str) -> Option<Product> as product_by_name_404_wrapped); -mk_wrapper!(@external_config product_by_name(&config, name: &str) -> Product as product_by_name_external_wrapped); +mk_wrapper!( + @treat_404_as_None + amount_by_id(product_id: ProductId) -> Option<ProductAmount> + as amount_by_id_404_wrapped +); -mk_wrapper!(product_suggestion_by_name(part_name: &str) -> Vec<Product> as product_suggestion_by_name_wrapped); +mk_wrapper!( + products_registered() -> Vec<Product> + as products_registered_wrapped +); +mk_wrapper!( + products_in_storage() -> Vec<Product> + as products_in_storage_wrapped +); +mk_wrapper!( + products_without_product_parent() -> Vec<Product> + as products_without_product_parent_wrapped +); +mk_wrapper!( + products_by_product_parent_id_indirect(product_parent_id: ProductParentId) -> Vec<Product> + as products_by_product_parent_id_indirect_wrapped +); +mk_wrapper!( + products_by_product_parent_id_direct(product_parent_id: ProductParentId) -> Vec<Product> + as products_by_product_parent_id_direct_wrapped +); -mk_wrapper!(unit_by_id(unit_id: UnitId) -> Unit as unit_by_id_wrapped); -mk_wrapper!(unit_property_by_id(unit_id: UnitPropertyId) -> UnitProperty as unit_property_by_id_wrapped); -mk_wrapper!(unit_properties() -> Vec<UnitProperty> as unit_properties_wrapped); +mk_wrapper!( + @external_config + register_product_parent(&config, product_parent_stub: ProductParentStub) -> ProductParentId + as register_product_parent_external_wrapped +); +mk_wrapper!( + product_parents_toplevel() -> Vec<ProductParent> + as product_parents_toplevel_wrapped +); +mk_wrapper!( + @treat_404_as_None + product_parents_under(id: ProductParentId) -> Option<Vec<ProductParent>> + as product_parents_under_404_wrapped +); +mk_wrapper!( + product_parents() -> Vec<ProductParent> + as product_parents_wrapped +); -mk_wrapper!(amount_by_id(product_id: ProductId) -> ProductAmount as amount_by_id_wrapped); +mk_wrapper!( + recipes() -> Vec<Recipe> + as recipes_wrapped +); +mk_wrapper!( + recipes_without_recipe_parent() -> Vec<Recipe> + as recipes_without_recipe_parent_wrapped +); +mk_wrapper!( + @external_config + add_recipe(&config, stub: RecipeStub) -> RecipeId + as add_recipe_external_wrapped +); +mk_wrapper!( + recipes_by_recipe_parent_id_indirect(recipe_parent_id: RecipeParentId) -> Vec<Recipe> + as recipes_by_recipe_parent_id_indirect_wrapped +); +mk_wrapper!( + recipes_by_recipe_parent_id_direct(recipe_parent_id: RecipeParentId) -> Vec<Recipe> + as recipes_by_recipe_parent_id_direct_wrapped +); +mk_wrapper!( + recipe_by_name(name: &str) -> Recipe + as recipe_by_name_wrapped +); +mk_wrapper!( + recipe_by_id(id: RecipeId) -> Recipe + as recipe_by_id_wrapped +); -mk_wrapper!(products_registered() -> Vec<Product> as products_registered_wrapped); -mk_wrapper!(products_in_storage() -> Vec<Product> as products_in_storage_wrapped); +mk_wrapper!( + @external_config + register_recipe_parent(&config, recipe_parent_stub: RecipeParentStub) -> RecipeParentId + as register_recipe_parent_external_wrapped +); +mk_wrapper!( + recipe_parents_toplevel() -> Vec<RecipeParent> + as recipe_parents_toplevel_wrapped +); +mk_wrapper!( + @treat_404_as_None + recipe_parents_under(id: RecipeParentId) -> Option<Vec<RecipeParent>> + as recipe_parents_under_404_wrapped +); +mk_wrapper!( + recipe_parents() -> Vec<RecipeParent> + as recipe_parents_wrapped +); -mk_wrapper!(@external_config buy_barcode(&config, barcode_number: BarcodeId, times: u32) -> () as buy_barcode_external_wrapped); -mk_wrapper!(@external_config register_product(&config, product_stub: ProductStub) -> ProductId as register_product_external_wrapped); -mk_wrapper!(@external_config associate_barcode(&config, id: ProductId, barcode: Barcode) -> () as associate_barcode_external_wrapped); +mk_wrapper!( + @external_config + buy_barcode(&config, barcode_number: BarcodeId, times: u32) -> () + as buy_barcode_external_wrapped +); +mk_wrapper!( + @external_config + register_product(&config, product_stub: ProductStub) -> ProductId + as register_product_external_wrapped +); +mk_wrapper!( + @external_config + associate_barcode(&config, id: ProductId, barcode: Barcode) -> () + as associate_barcode_external_wrapped +); diff --git a/src/components/async_fetch.rs b/src/components/async_fetch.rs index 7bf44a0..43469a7 100644 --- a/src/components/async_fetch.rs +++ b/src/components/async_fetch.rs @@ -37,11 +37,13 @@ macro_rules! AsyncFetch { <Transition fallback=|| { view! { <p>"Loading..."</p> } }> - {move || Suspend::new(async move { - $resource - .await - .map($producer) - })} + { + Suspend::new(async move { + $resource + .await + .map($producer) + }) + } </Transition> } }}; diff --git a/src/components/buy.rs b/src/components/buy.rs index e69de29..8b13789 100644 --- a/src/components/buy.rs +++ b/src/components/buy.rs @@ -0,0 +1 @@ + diff --git a/src/components/catch_errors.rs b/src/components/catch_errors.rs new file mode 100644 index 0000000..d5a452d --- /dev/null +++ b/src/components/catch_errors.rs @@ -0,0 +1,40 @@ +use leptos::{ + IntoView, component, + error::ErrorBoundary, + prelude::{Children, ClassAttribute, CollectView, ElementChild, Get}, + view, +}; + +use crate::components::site_header::SiteHeader; + +#[component] +pub(crate) fn CatchErrors(children: Children) -> impl IntoView { + view! { + <ErrorBoundary fallback=|errors| { + view! { + <SiteHeader + logo=icondata_io::IoRoseSharp + back_location="/" + name="Errors occurred" + /> + + <h1>"Uh oh! Something went wrong!"</h1> + + <p>"Errors: "</p> + <ul class="flex flex-col gap-1"> + {move || { + errors + .get() + .into_iter() + .map(|(_, e)| { + view! { + <li class="bg-gray-200 rounded-lg m-2 p-1">{e.to_string()}</li> + } + }) + .collect_view() + }} + </ul> + } + }>{children()}</ErrorBoundary> + } +} diff --git a/src/components/checkbox_placeholder.rs b/src/components/checkbox_placeholder.rs new file mode 100644 index 0000000..a1aaa0c --- /dev/null +++ b/src/components/checkbox_placeholder.rs @@ -0,0 +1,54 @@ +use leptos::{ + IntoView, component, + html::Input, + prelude::{ClassAttribute, ElementChild, GlobalAttributes, NodeRef, NodeRefAttribute}, + view, +}; + +use crate::components::get_id; + +#[component] +pub fn CheckboxPlaceholder( + label: &'static str, + node_ref: NodeRef<Input>, +) -> impl IntoView { + let id = get_id(); + + view! { + <div class="relative h-14"> + <input + id=id.to_string() + type="checkbox" + autocomplete="off" + class="\ + absolute \ + bottom-0 \ + right-5 \ + bg-gray-200 \ + border-8 \ + border-b-2 \ + border-b-trasparent \ + border-gray-200 \ + focus:outline-none \ + h-10 \ + rounded-t-lg \ + text-gray-900 \ + " + node_ref=node_ref + /> + + <label + for=id.to_string() + class="\ + bottom-10 \ + absolute \ + left-0 \ + text-gray-700 \ + text-sm \ + " + > + {label} + </label> + </div> + } +} diff --git a/src/components/container.rs b/src/components/container.rs index d6d2f03..3b56713 100644 --- a/src/components/container.rs +++ b/src/components/container.rs @@ -1,46 +1,38 @@ use leptos::{ IntoView, component, - prelude::{Children, ClassAttribute, ElementChild, OnAttribute}, + prelude::{Children, ClassAttribute, ElementChild}, view, }; -use leptos_router::{NavigateOptions, hooks::use_navigate}; +use leptos_router::components::A; #[component] pub fn Container( - header: impl IntoView, - buttons: Vec<(impl IntoView, &'static str)>, + header: impl IntoView + 'static, + buttons: Vec<(impl IntoView + 'static, &'static str)>, children: Children, ) -> impl IntoView { assert!(!buttons.is_empty()); - let first_button_path = buttons.first().expect("Should have at least on button").1; + // TODO: Add the direct link to the first button back. <2026-02-15> + // let first_button_path = buttons.first().expect("Should have at least on button").1; view! { - <button - type="button" - on:click=|_| { - use_navigate()(first_button_path, NavigateOptions::default()); - } - > - <div class="p-4 mt-4 mr-4 ml-4 md-2 text-justify rounded-lg border-gray-600 border"> - <p class="text-lg text-bold">{header}</p> - {children()} + <div class="p-4 mt-4 mr-4 ml-4 md-2 text-justify rounded-lg border-gray-600 border"> + <h2 class="text-lg text-bold">{header}</h2> + {children()} - <ul class="flex flex-row gap-1 pt-2 overflow-x-auto"> - {buttons - .into_iter() - .map(|(name, path)| { - view! { - <li class="bg-green-400/40 p-2 text-nowrap first:rounded-l-full last:rounded-r-full"> - <button on:click=move |_| { - use_navigate()(path, NavigateOptions::default()); - }>{name}</button> - </li> - } - }) - .collect::<Vec<_>>()} - </ul> - </div> - </button> + <ul class="flex flex-row gap-1 pt-2 overflow-x-auto"> + {buttons + .into_iter() + .map(|(name, path)| { + view! { + <li class="bg-green-400/40 p-2 text-nowrap first:rounded-l-full last:rounded-r-full"> + <A href=move || path.to_owned()>{name}</A> + </li> + } + }) + .collect::<Vec<_>>()} + </ul> + </div> } } diff --git a/src/components/inventory.rs b/src/components/inventory.rs index 275dd0b..31b1c12 100644 --- a/src/components/inventory.rs +++ b/src/components/inventory.rs @@ -23,8 +23,8 @@ pub fn Inventory() -> impl IntoView { producer = |products| { let products_num = products.len(); let plural_s = if products_num == 1 { "" } else { "s" }; - let products_value = 2; - let products_currency = "EUR"; + let products_value = -1; + let products_currency = "TODO"; view! { <p> diff --git a/src/components/login_wall.rs b/src/components/login_wall.rs new file mode 100644 index 0000000..fd5c64f --- /dev/null +++ b/src/components/login_wall.rs @@ -0,0 +1,42 @@ +use leptos::{ + IntoView, component, + error::Error, + prelude::{Children, IntoAny}, + view, +}; +use leptos_router::{NavigateOptions, hooks::use_navigate}; + +use crate::{ + api::{can_be_provisioned_wrapped, is_logged_in_wrapped}, + components::async_fetch::{AsyncFetch, AsyncResource}, +}; + +#[component] +pub fn LoginWall( + back: impl Fn() -> String + Send + Sync + 'static, + children: Children, +) -> impl IntoView { + view! { + { + AsyncFetch! { + @map_error_in_producer + from_resource = AsyncResource!( + () -> Result<(bool, bool), Error> { + Ok((can_be_provisioned_wrapped().await?, is_logged_in_wrapped().await?)) + } + ), + producer = |(can_be_provisioned, is_logged_in)| { + if is_logged_in { + children() + } else if can_be_provisioned { + use_navigate()(format!("/provision/?back={}", back()).as_str(), NavigateOptions::default()); + ().into_any() + } else { + use_navigate()(format!("/login/?back={}", back()).as_str(), NavigateOptions::default()); + ().into_any() + } + } + } + } + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 2c3d79a..2a3a0b1 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -3,15 +3,22 @@ use std::sync::atomic::{AtomicU32, Ordering}; // Generic pub mod async_fetch; pub mod banner; +pub mod catch_errors; pub mod container; pub mod form; pub mod icon_p; +pub mod login_wall; + +// placeholders +pub mod checkbox_placeholder; pub mod input_placeholder; pub mod select_placeholder; +pub mod textarea_placeholder; // Specific pub mod inventory; pub mod product_overview; +pub mod product_parent_overview; pub mod recipies; pub mod site_header; pub mod unit_overview; diff --git a/src/components/product_overview.rs b/src/components/product_overview.rs index bf81624..233b8a7 100644 --- a/src/components/product_overview.rs +++ b/src/components/product_overview.rs @@ -11,9 +11,20 @@ pub fn ProductOverview() -> impl IntoView { <Container header="Products" buttons=vec![ - (view! { <IconP icon=icondata_io::IoClipboard text="Show products" /> }, "products"), - (view! { <IconP icon=icondata_io::IoPricetags text="Create product" /> }, "create-product"), - (view! { <IconP icon=icondata_io::IoPricetags text="Associate barcode with product" /> }, "associate-barcode-product"), + (view! { <IconP icon=icondata_io::IoClipboard text="Show" /> }, "products"), + ( + view! { <IconP icon=icondata_io::IoPricetags text="Create product" /> }, + "create-product", + ), + ( + view! { + <IconP + icon=icondata_io::IoPricetags + text="Associate barcode with product" + /> + }, + "associate-barcode-product", + ), ] > { @@ -22,7 +33,7 @@ pub fn ProductOverview() -> impl IntoView { fetcher = products_registered_wrapped(), producer = |products| { view! { - <p>{format!("You have {} products", products.len())}</p> + <p>{format!("You have {} products.", products.len())}</p> } } } diff --git a/src/components/product_parent_overview.rs b/src/components/product_parent_overview.rs new file mode 100644 index 0000000..4aa2a0f --- /dev/null +++ b/src/components/product_parent_overview.rs @@ -0,0 +1,37 @@ +use leptos::{IntoView, component, view}; + +use crate::{ + api::product_parents_wrapped, + components::{async_fetch::AsyncFetch, container::Container, icon_p::IconP}, +}; + +#[component] +pub fn ProductParentOverview() -> impl IntoView { + view! { + <Container + header="Products Parents" + buttons=vec![ + ( + view! { <IconP icon=icondata_io::IoClipboard text="Show products" /> }, + "products", + ), + ( + view! { <IconP icon=icondata_io::IoPricetags text="Create product parent" /> }, + "create-product-parent", + ), + ] + > + { + AsyncFetch! { + @map_error_in_producer + fetcher = product_parents_wrapped(), + producer = |product_parents| { + view! { + <p>{format!("You have {} product parents.", product_parents.len())}</p> + } + } + } + } + </Container> + } +} diff --git a/src/components/recipies.rs b/src/components/recipies.rs index f7903e4..755954e 100644 --- a/src/components/recipies.rs +++ b/src/components/recipies.rs @@ -1,6 +1,9 @@ -use leptos::{IntoView, component, prelude::ElementChild, view}; +use leptos::{IntoView, component, view}; -use crate::components::{container::Container, icon_p::IconP}; +use crate::{ + api::recipes_wrapped, + components::{async_fetch::AsyncFetch, container::Container, icon_p::IconP}, +}; #[component] pub fn Recipies() -> impl IntoView { @@ -9,10 +12,21 @@ pub fn Recipies() -> impl IntoView { header="Recipies" buttons=vec![ (view! { <IconP icon=icondata_io::IoFastFood text="Recipies" /> }, "recipies"), + (view! { <IconP icon=icondata_io::IoPin text="Create recipe" /> }, "create-recipe"), (view! { <IconP icon=icondata_io::IoCalendarSharp text="Mealplan" /> }, "mealplan"), ] > - <p>"You have 0 recipies."</p> + { + AsyncFetch! { + @map_error_in_producer + fetcher = recipes_wrapped(), + producer = |recipes| { + view! { + <p>{format!("You have {} recipies.", recipes.len())}</p> + } + } + } + } </Container> } } diff --git a/src/components/textarea_placeholder.rs b/src/components/textarea_placeholder.rs new file mode 100644 index 0000000..a0bae6d --- /dev/null +++ b/src/components/textarea_placeholder.rs @@ -0,0 +1,60 @@ +use leptos::{ + IntoView, component, + html::Textarea, + prelude::{ClassAttribute, ElementChild, GlobalAttributes, NodeRef, NodeRefAttribute}, + view, +}; + +use crate::components::get_id; + +#[component] +pub fn TextareaPlaceholder( + label: &'static str, + node_ref: NodeRef<Textarea>, + #[prop(default = None)] initial_value: Option<String>, +) -> impl IntoView { + let id = get_id(); + + view! { + <div class="relative h-80"> + <textarea + id=id.to_string() + class="\ + absolute \ + bottom-0 \ + bg-gray-200 \ + border-2 \ + border-b-2 \ + border-b-trasparent \ + border-gray-200 \ + focus:border-indigo-600 \ + focus:border-b-transparent \ + focus:outline-none \ + h-[300px] \ + placeholder-transparent \ + rounded-t-lg \ + text-gray-900 \ + w-full \ + " + placeholder="sentinel value" + node_ref=node_ref + > + {initial_value} + </textarea> + + <label + for=id.to_string() + class="\ + absolute \ + transition-all \ + text-sm \ + text-gray-700 \ + top-0 \ + left-0 \ + " + > + {label} + </label> + </div> + } +} diff --git a/src/components/unit_overview.rs b/src/components/unit_overview.rs index 25e5675..0ea3825 100644 --- a/src/components/unit_overview.rs +++ b/src/components/unit_overview.rs @@ -1,6 +1,10 @@ use leptos::{IntoView, component, view}; +use rocie_client::models::{Unit, UnitProperty}; -use crate::components::{container::Container, icon_p::IconP}; +use crate::{ + api::{unit_properties_wrapped, units_wrapped}, + components::{async_fetch::AsyncFetch, container::Container, icon_p::IconP}, +}; #[component] pub fn UnitOverview() -> impl IntoView { @@ -8,14 +12,37 @@ pub fn UnitOverview() -> impl IntoView { <Container header="Units" buttons=vec![ - (view! { <IconP icon=icondata_io::IoClipboard text="Show unit" /> }, "units"), - (view! { <IconP icon=icondata_io::IoClipboard text="Create unit" /> }, "create-unit"), - (view! { <IconP icon=icondata_io::IoPricetags text="Create unit property" /> }, "create-unit-property"), + (view! { <IconP icon=icondata_io::IoClipboard text="Show" /> }, "units"), + ( + view! { <IconP icon=icondata_io::IoClipboard text="Create unit" /> }, + "create-unit", + ), + ( + view! { <IconP icon=icondata_io::IoPricetags text="Create unit property" /> }, + "create-unit-property", + ), ] > { - "You have units" + AsyncFetch! { + @map_error_in_producer + fetcher = get_units_and_unit_properties(), + producer = |(units, unit_properties)| { + view! { + <p>{move || format!( + "You have {} units and {} unit properties.", + units.len(), + unit_properties.len() + )}</p> + } + }, + } } </Container> } } + +async fn get_units_and_unit_properties() +-> Result<(Vec<Unit>, Vec<UnitProperty>), leptos::error::Error> { + Ok((units_wrapped().await?, unit_properties_wrapped().await?)) +} diff --git a/src/lib.rs b/src/lib.rs index 36210e7..a884201 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,7 +31,10 @@ use reactive_stores::Store; use rocie_client::apis::configuration::Configuration; use crate::pages::{ - associate_barcode::AssociateBarcode, buy::Buy, create_product::CreateProduct, home::Home, inventory::Inventory, not_found::NotFound, recipies::Recipies + associate_barcode::AssociateBarcode, buy::Buy, create_product::CreateProduct, + create_product_parent::CreateProductParent, create_recipe::CreateRecipe, home::Home, + inventory::Inventory, login::Login, not_found::NotFound, product::Product, products::Products, + provision::Provision, recipe::Recipe, recipies::Recipies, units::Units, }; #[derive(Debug, Clone, Store)] @@ -40,6 +43,7 @@ pub struct ConfigState { } #[component] +#[expect(clippy::too_many_lines)] pub fn App() -> impl IntoView { // Provides context that manages stylesheets, titles, meta tags, etc. provide_meta_context(); @@ -48,7 +52,7 @@ pub fn App() -> impl IntoView { let mut config = Configuration::new(); config.user_agent = Some("rocie-mobile".to_owned()); - "http://127.0.0.1:8080".clone_into(&mut config.base_path); + "/api/".clone_into(&mut config.base_path); config }; @@ -71,6 +75,18 @@ pub fn App() -> impl IntoView { view! { <Home /> } } /> + <Route + path=path!("/login") + view=move || { + view! { <Login /> } + } + /> + <Route + path=path!("/provision") + view=move || { + view! { <Provision /> } + } + /> // Inventory <Route @@ -93,9 +109,27 @@ pub fn App() -> impl IntoView { view! { <Recipies /> } } /> + <Route + path=path!("/recipe/:name") + view=move || { + view! { <Recipe /> } + } + /> + <Route + path=path!("/create-recipe") + view=move || { + view! { <CreateRecipe /> } + } + /> // Products <Route + path=path!("/products") + view=move || { + view! { <Products /> } + } + /> + <Route path=path!("/create-product") view=move || { view! { <CreateProduct /> } @@ -107,6 +141,28 @@ pub fn App() -> impl IntoView { view! { <AssociateBarcode /> } } /> + <Route + path=path!("/product/:name") + view=move || { + view! { <Product /> } + } + /> + + // Product Parents + <Route + path=path!("/create-product-parent") + view=move || { + view! { <CreateProductParent /> } + } + /> + + // Units + <Route + path=path!("/units") + view=move || { + view! { <Units /> } + } + /> </Routes> </Router> } diff --git a/src/pages/associate_barcode.rs b/src/pages/associate_barcode.rs index 20714ff..0e1308d 100644 --- a/src/pages/associate_barcode.rs +++ b/src/pages/associate_barcode.rs @@ -1,9 +1,10 @@ use leptos::{ IntoView, component, - prelude::{Get, Show, WriteSignal, signal}, + prelude::{ElementExt, Get, Show, WriteSignal, signal}, task::spawn_local, view, }; +use leptos_router::{NavigateOptions, hooks::use_navigate}; use rocie_client::models::{Barcode, BarcodeId, Product, Unit, UnitAmount, UnitId}; use rocie_macros::Form; use uuid::Uuid; @@ -14,55 +15,62 @@ use crate::{ product_by_name_404_wrapped, product_by_name_external_wrapped, product_suggestion_by_name_wrapped, unit_by_id_wrapped, unit_property_by_id_wrapped, }, - components::{async_fetch::AsyncResource, banner::Banner, site_header::SiteHeader}, + components::{ + async_fetch::AsyncResource, banner::Banner, catch_errors::CatchErrors, + login_wall::LoginWall, site_header::SiteHeader, + }, }; #[component] pub fn AssociateBarcode() -> impl IntoView { - let product_name_signal; - let (errors, errors_set) = signal(None); let (show_units, show_units_set) = signal(false); view! { - <SiteHeader logo=icondata_io::IoPricetag back_location="/" name="Buy" /> - - <Show when=move || errors.get().is_some()> - <Banner text=move || errors.get().expect("Was some") /> - </Show> - - { - Form! { - on_submit = |barcode_id, product_name, amount, unit_id| { - let config = get_config!(); - - spawn_local(async move { - let output = async { - let product = product_by_name_external_wrapped(&config, &product_name).await?; - - associate_barcode_external_wrapped(&config, product.id, Barcode { - amount:UnitAmount { - unit: UnitId { value: unit_id }, - value: u32::from(amount), - }, - id: BarcodeId { value: barcode_id }, - }).await?; - - Ok::<_, leptos::error::Error>(()) - }; - - match output.await { - Ok(()) => (), - Err(err) => { - errors_set.set( - Some( - format!("Could not associate barcode: {err}") - ) - ); - }, - } - }); + <CatchErrors> + <LoginWall back=move || "/associate-barcode-product".to_owned()> + <SiteHeader logo=icondata_io::IoPricetag back_location="/" name="Buy" /> + + <Show when=move || errors.get().is_some()> + <Banner text=move || errors.get().expect("Was some") /> + </Show> + + { + let product_name_signal; + Form! { + on_submit = |barcode_id, product_name, amount, unit_id| { + let config = get_config!(); + let navigate = use_navigate(); + + spawn_local(async move { + let output = async { + let product = product_by_name_external_wrapped(&config, product_name.trim()).await?; + + associate_barcode_external_wrapped(&config, product.id, Barcode { + amount:UnitAmount { + unit: UnitId { value: unit_id }, + value: u32::from(amount), + }, + id: BarcodeId { value: barcode_id }, + }).await?; + + Ok::<_, leptos::error::Error>(()) + }; + + match output.await { + Ok(()) => { + navigate("/associate-barcode-product", NavigateOptions::default()); + }, + Err(err) => { + errors_set.set( + Some( + format!("Could not associate barcode: {err}") + ) + ); + }, + } + }); }; <Input @@ -117,8 +125,10 @@ pub fn AssociateBarcode() -> impl IntoView { html_type="number", label="Amount" /> - } - } + } + } + </LoginWall> + </CatchErrors> } } diff --git a/src/pages/buy.rs b/src/pages/buy.rs index f3335f6..e4cd599 100644 --- a/src/pages/buy.rs +++ b/src/pages/buy.rs @@ -4,12 +4,16 @@ use leptos::{ task::spawn_local, view, }; +use leptos_router::{NavigateOptions, hooks::use_navigate}; use log::info; use rocie_client::models::BarcodeId; use crate::{ api::{buy_barcode_external_wrapped, get_config}, - components::{banner::Banner, form::Form, site_header::SiteHeader}, + components::{ + banner::Banner, catch_errors::CatchErrors, form::Form, login_wall::LoginWall, + site_header::SiteHeader, + }, }; #[component] @@ -17,29 +21,44 @@ pub fn Buy() -> impl IntoView { let (on_submit_errored, on_submit_errored_set) = signal(None); view! { - <SiteHeader logo=icondata_io::IoPricetag back_location="/" name="Buy" /> + <CatchErrors> + <LoginWall back=move || "/buy".to_owned()> + <SiteHeader logo=icondata_io::IoPricetag back_location="/" name="Buy" /> - <Show when=move || on_submit_errored.get().is_some()> - <Banner text=move || on_submit_errored.get().expect("Should be some") /> - </Show> + <Show when=move || on_submit_errored.get().is_some()> + <Banner text=move || on_submit_errored.get().expect("Should be some") /> + </Show> - { - Form! { - on_submit = |barcode_number, times| { - let config = get_config!(); + { + Form! { + on_submit = |barcode_number, times| { + let config = get_config!(); + let navigate = use_navigate(); - spawn_local(async move { - if let Err(err) = buy_barcode_external_wrapped(&config, BarcodeId { value: barcode_number }, u32::from(times)).await { - let error = format!("Error in form on-submit for barcode `{barcode_number}`: {err}"); + spawn_local(async move { + match buy_barcode_external_wrapped( + &config, + BarcodeId { value: barcode_number }, + u32::from(times) + ).await { + Ok(()) => { + navigate("/buy", NavigateOptions::default()); + on_submit_errored_set.set(None); + }, + Err(err) => { + let error = + format!( + "Error in form \ + on-submit for barcode \ + `{barcode_number}`: {err}" + ); + on_submit_errored_set.set(Some(error)); + }, + } - on_submit_errored_set.set(Some(error)); - } else { - on_submit_errored_set.set(None); - } - - info!("Bought barcode {barcode_number} {times} times"); - }); + info!("Bought barcode {barcode_number} {times} times"); + }); }; <Input @@ -55,7 +74,9 @@ pub fn Buy() -> impl IntoView { html_type="number", label="Times" /> - } - } + } + } + </LoginWall> + </CatchErrors> } } diff --git a/src/pages/create_product.rs b/src/pages/create_product.rs index fcd3b0b..fdf8f28 100644 --- a/src/pages/create_product.rs +++ b/src/pages/create_product.rs @@ -1,4 +1,4 @@ -use std::{convert::Infallible, str::FromStr}; +use std::{convert::Infallible, iter, str::FromStr}; use leptos::{ IntoView, component, @@ -6,16 +6,23 @@ use leptos::{ task::spawn_local, view, }; -use rocie_client::models::{ProductStub, UnitPropertyId}; +use leptos_router::{NavigateOptions, hooks::use_navigate}; +use rocie_client::models::{ProductParentId, ProductStub, UnitPropertyId}; use rocie_macros::Form; use uuid::Uuid; use crate::{ - api::{get_config, register_product_external_wrapped, unit_properties_wrapped}, - components::{async_fetch::AsyncResource, banner::Banner, site_header::SiteHeader}, + api::{ + get_config, product_parents_wrapped, register_product_external_wrapped, + unit_properties_wrapped, + }, + components::{ + async_fetch::AsyncResource, banner::Banner, catch_errors::CatchErrors, + login_wall::LoginWall, site_header::SiteHeader, + }, }; -struct OptionalString(Option<String>); +pub(crate) struct OptionalString(pub(crate) Option<String>); impl FromStr for OptionalString { type Err = Infallible; @@ -29,35 +36,54 @@ impl FromStr for OptionalString { } } +struct OptionalParentId(Option<ProductParentId>); + +impl FromStr for OptionalParentId { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + if s.is_empty() { + Ok(Self(None)) + } else { + Ok(Self(Some(ProductParentId { value: s.parse()? }))) + } + } +} + #[component] pub fn CreateProduct() -> impl IntoView { let (error_message, error_message_set) = signal(None); view! { - <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Create Product" /> - - <Show when=move || error_message.get().is_some()> - <Banner text=move || error_message.get().expect("Is some") /> - </Show> - - { - Form! { - on_submit = |product_name, product_description, unit_property_id| { - let config = get_config!(); - - spawn_local(async move { - match register_product_external_wrapped(&config, ProductStub { - description: Some(product_description.0), - name: product_name, - parent: None, // TODO: Add this <2025-10-25> - unit_property: UnitPropertyId { value: unit_property_id }, + <CatchErrors> + <LoginWall back=move || "/create-product".to_owned()> + <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Create Product" /> + + <Show when=move || error_message.get().is_some()> + <Banner text=move || error_message.get().expect("Is some") /> + </Show> + + { + Form! { + on_submit = |product_name, product_description, unit_property_id, parent| { + let config = get_config!(); + let navigate = use_navigate(); + + spawn_local(async move { + match register_product_external_wrapped(&config, ProductStub { + description: product_description.0.map(|d| d.trim().to_owned()), + name: product_name.trim().to_owned(), + parent: parent.0, + unit_property: UnitPropertyId { value: unit_property_id }, + } + ).await { + Ok(_id) => { + navigate("/create-product", NavigateOptions::default()); + } + Err(err) => error_message_set.set(Some(format!("Failed to create product: {err}"))), } - ).await { - Ok(_id) => {} - Err(err) => error_message_set.set(Some(format!("Failed to create product: {err}"))), - } - }); - }; + }); + }; <Input name=product_name, @@ -74,6 +100,27 @@ pub fn CreateProduct() -> impl IntoView { /> <Select + name=parent, + rust_type=OptionalParentId, + label="Parent", + options=AsyncResource! { + () -> Result<Vec<(String, String)>, leptos::error::Error> { + let parents = product_parents_wrapped().await?; + + Ok( + iter::once(("No parent".to_owned(), String::new())) + .chain( + parents + .into_iter() + .map(|prop| (prop.name, prop.id.to_string())) + ) + .collect() + ) + } + }, + /> + + <Select name=unit_property_id, rust_type=Uuid, label="Unit property", @@ -90,7 +137,9 @@ pub fn CreateProduct() -> impl IntoView { } }, /> - } - } + } + } + </LoginWall> + </CatchErrors> } } diff --git a/src/pages/create_product_parent.rs b/src/pages/create_product_parent.rs new file mode 100644 index 0000000..152347a --- /dev/null +++ b/src/pages/create_product_parent.rs @@ -0,0 +1,126 @@ +use std::{convert::Infallible, iter, str::FromStr}; + +use leptos::{ + IntoView, component, + prelude::{Get, Show, signal}, + task::spawn_local, + view, +}; +use leptos_router::{NavigateOptions, hooks::use_navigate}; +use rocie_client::models::{ProductParentId, ProductParentStub}; +use rocie_macros::Form; + +use crate::{ + api::{get_config, product_parents_wrapped, register_product_parent_external_wrapped}, + components::{ + async_fetch::AsyncResource, banner::Banner, catch_errors::CatchErrors, + login_wall::LoginWall, site_header::SiteHeader, + }, +}; + +struct OptionalString(Option<String>); + +impl FromStr for OptionalString { + type Err = Infallible; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + if s.is_empty() { + Ok(Self(None)) + } else { + Ok(Self(Some(s.to_owned()))) + } + } +} + +struct OptionalParentId(Option<ProductParentId>); + +impl FromStr for OptionalParentId { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + if s.is_empty() { + Ok(Self(None)) + } else { + Ok(Self(Some(ProductParentId { value: s.parse()? }))) + } + } +} + +#[component] +pub fn CreateProductParent() -> impl IntoView { + let (error_message, error_message_set) = signal(None); + + view! { + <CatchErrors> + <LoginWall back=move || "/create-product-parent".to_owned()> + <SiteHeader + logo=icondata_io::IoArrowBack + back_location="/" + name="Create Product Parent" + /> + + <Show when=move || error_message.get().is_some()> + <Banner text=move || error_message.get().expect("Is some") /> + </Show> + + { + Form! { + on_submit = |name, description, parent| { + let config = get_config!(); + let navigate = use_navigate(); + + spawn_local(async move { + match register_product_parent_external_wrapped(&config, ProductParentStub { + description: description.0.map(|d| d.trim().to_owned()), + name: name.trim().to_owned(), + parent: parent.0, + } + ).await { + Ok(_id) => { + navigate("/create-product-parent", NavigateOptions::default()); + } + Err(err) => error_message_set.set(Some(format!("Failed to create product: {err}"))), + } + }); + }; + + <Input + name=name, + rust_type=String, + html_type="text", + label="Product Name", + /> + + <Input + name=description, + rust_type=OptionalString, + html_type="text", + label="Product Description" + /> + + <Select + name=parent, + rust_type=OptionalParentId, + label="Parent", + options=AsyncResource! { + () -> Result<Vec<(String, String)>, leptos::error::Error> { + let parents = product_parents_wrapped().await?; + + Ok( + iter::once(("No parent".to_owned(), String::new())) + .chain( + parents + .into_iter() + .map(|prop| (prop.name, prop.id.to_string())) + ) + .collect() + ) + } + }, + /> + } + } + </LoginWall> + </CatchErrors> + } +} diff --git a/src/pages/create_recipe.rs b/src/pages/create_recipe.rs new file mode 100644 index 0000000..20ec4ed --- /dev/null +++ b/src/pages/create_recipe.rs @@ -0,0 +1,108 @@ +use std::{iter, str::FromStr}; + +use leptos::{ + IntoView, component, + prelude::{Get, Show, signal}, + task::spawn_local, + view, +}; +use leptos_router::{NavigateOptions, hooks::use_navigate}; +use log::info; +use rocie_client::models::{RecipeParentId, RecipeStub}; +use rocie_macros::Form; + +use crate::{ + api::{add_recipe_external_wrapped, get_config, recipe_parents_wrapped}, + components::{ + async_fetch::AsyncResource, banner::Banner, catch_errors::CatchErrors, + login_wall::LoginWall, site_header::SiteHeader, + }, + pages::create_product::OptionalString, +}; + +struct OptionalParentId(Option<RecipeParentId>); + +impl FromStr for OptionalParentId { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + if s.is_empty() { + Ok(Self(None)) + } else { + Ok(Self(Some(RecipeParentId { value: s.parse()? }))) + } + } +} + +#[component] +pub fn CreateRecipe() -> impl IntoView { + let (error_message, error_message_set) = signal(None); + + view! { + <CatchErrors> + <LoginWall back=move || "/create-recipe".to_owned()> + <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Create Recipe" /> + + <Show when=move || error_message.get().is_some()> + <Banner text=move || error_message.get().expect("Is some") /> + </Show> + + { + Form! { + on_submit = |content, name, parent| { + let config = get_config!(); + let navigate = use_navigate(); + + spawn_local(async move { + match add_recipe_external_wrapped(&config, RecipeStub { + content: content.trim().to_owned(), + name: name.trim().to_owned(), + parent: parent.0, + }).await { + Ok(_id) => { + info!("Navigating"); + navigate("/create-recipe", NavigateOptions::default()); + } + Err(err) => error_message_set.set(Some(format!("Failed to create recipe: {err}"))), + } + }); + }; + + <Input + name=name, + rust_type=String, + html_type="text", + label="Recipe Name", + /> + + <Select + name=parent, + rust_type=OptionalParentId, + label="Parent", + options=AsyncResource! { + () -> Result<Vec<(String, String)>, leptos::error::Error> { + let parents = recipe_parents_wrapped().await?; + + Ok( + iter::once(("No parent".to_owned(), String::new())) + .chain( + parents + .into_iter() + .map(|prop| (prop.name, prop.id.to_string())) + ) + .collect() + ) + } + }, + /> + + <Textarea + name=content, + label="Recipe", + /> + } + } + </LoginWall> + </CatchErrors> + } +} diff --git a/src/pages/home.rs b/src/pages/home.rs index b9dba64..e3767fd 100644 --- a/src/pages/home.rs +++ b/src/pages/home.rs @@ -1,7 +1,6 @@ use leptos::{ IntoView, component, - error::ErrorBoundary, - prelude::{ClassAttribute, CollectView, ElementChild, Get, GetUntracked}, + prelude::{ClassAttribute, ElementChild, GetUntracked}, view, }; use leptos_router::{ @@ -10,8 +9,9 @@ use leptos_router::{ }; use crate::components::{ - inventory::Inventory, product_overview::ProductOverview, recipies::Recipies, - site_header::SiteHeader, unit_overview::UnitOverview, + catch_errors::CatchErrors, inventory::Inventory, login_wall::LoginWall, + product_overview::ProductOverview, product_parent_overview::ProductParentOverview, + recipies::Recipies, site_header::SiteHeader, unit_overview::UnitOverview, }; #[component] @@ -24,31 +24,19 @@ pub fn Home() -> impl IntoView { } view! { - <ErrorBoundary fallback=|errors| { - view! { - <h1>"Uh oh! Something went wrong!"</h1> - - <p>"Errors: "</p> - // Render a list of errors as strings - good for development purposes - <ul> - {move || { - errors - .get() - .into_iter() - .map(|(_, e)| view! { <li>{e.to_string()}</li> }) - .collect_view() - }} - </ul> - } - }> - - <div class="flex flex-col content-start"> + <CatchErrors> + <LoginWall back=move || "/".to_owned()> <SiteHeader logo=icondata_io::IoRoseSharp back_location="/" name="Rocie" /> - <Inventory /> - <Recipies /> - <ProductOverview /> - <UnitOverview /> - </div> - </ErrorBoundary> + + <div class="flex flex-col content-start"> + <Inventory /> + <Recipies /> + <hr class="w-8 h-0.5 rounded-lg mt-4 self-center bg-gray-500" /> + <ProductOverview /> + <UnitOverview /> + <ProductParentOverview /> + </div> + </LoginWall> + </CatchErrors> } } diff --git a/src/pages/inventory.rs b/src/pages/inventory.rs index b2ce4a1..0ad5613 100644 --- a/src/pages/inventory.rs +++ b/src/pages/inventory.rs @@ -7,26 +7,33 @@ use rocie_client::models::{Product, ProductAmount, ProductId, Unit}; use crate::{ api::{ - amount_by_id_wrapped, product_by_id_wrapped, products_in_storage_wrapped, + amount_by_id_404_wrapped, product_by_id_wrapped, products_in_storage_wrapped, unit_by_id_wrapped, }, - components::{async_fetch::AsyncFetch, site_header::SiteHeader}, + components::{ + async_fetch::AsyncFetch, catch_errors::CatchErrors, login_wall::LoginWall, + site_header::SiteHeader, + }, }; #[component] pub fn Inventory() -> impl IntoView { view! { - <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Inventory" /> + <CatchErrors> + <LoginWall back=move || "/inventory".to_owned()> + <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Inventory" /> - <ul class="flex flex-col p-2 m-2"> - { - AsyncFetch! { - @map_error_in_producer - fetcher = products_in_storage_wrapped(), - producer = render_products, - } - } - </ul> + <ul class="flex flex-col p-2 m-2"> + { + AsyncFetch! { + @map_error_in_producer + fetcher = products_in_storage_wrapped(), + producer = render_products, + } + } + </ul> + </LoginWall> + </CatchErrors> } } @@ -45,23 +52,30 @@ fn render_products(products: Vec<Product>) -> impl IntoView { async fn get_full_product_by_id( id: ProductId, -) -> Result<(Product, ProductAmount, Unit), leptos::error::Error> { +) -> Result<Option<(Product, ProductAmount, Unit)>, leptos::error::Error> { let product = product_by_id_wrapped(id).await?; - let amount = amount_by_id_wrapped(id).await?; - let unit = unit_by_id_wrapped(amount.amount.unit).await?; + let amount = amount_by_id_404_wrapped(id).await?; - Ok((product, amount, unit)) -} + if let Some(amount) = amount { + let unit = unit_by_id_wrapped(amount.amount.unit).await?; -fn format_full_product((product, amount, unit): (Product, ProductAmount, Unit)) -> impl IntoView { - view! { - <ul class="my-3"> - <li class="m-2">{product.name}</li> - <li class="m-2"> - <span class="bg-gray-200 p-1 px-2 rounded-lg"> - {format!("{} {}", amount.amount.value, unit.short_name)} - </span> - </li> - </ul> + Ok(Some((product, amount, unit))) + } else { + Ok(None) } } + +fn format_full_product(maybe_product: Option<(Product, ProductAmount, Unit)>) -> impl IntoView { + maybe_product.map(|(product, amount, unit)| { + view! { + <ul class="my-3"> + <li class="m-2">{product.name}</li> + <li class="m-2"> + <span class="bg-gray-200 p-1 px-2 rounded-lg"> + {format!("{} {}", amount.amount.value, unit.short_name)} + </span> + </li> + </ul> + } + }) +} diff --git a/src/pages/login.rs b/src/pages/login.rs new file mode 100644 index 0000000..af3f660 --- /dev/null +++ b/src/pages/login.rs @@ -0,0 +1,81 @@ +use leptos::{ + IntoView, component, + prelude::{Get, Show, signal}, + task::spawn_local, + view, +}; +use leptos_router::{ + NavigateOptions, + hooks::{use_navigate, use_query_map}, +}; +use rocie_client::models::LoginInfo; +use rocie_macros::Form; + +use crate::{ + api::{get_config, login_external_wrapped}, + components::{banner::Banner, catch_errors::CatchErrors, site_header::SiteHeader}, +}; + +#[component] +pub fn Login() -> impl IntoView { + let back = || { + let back = use_query_map() + .get() + .get("back") + .expect("Should always have a back, because the router would otherwise not match"); + + if back.starts_with('/') { + back + } else { + // Prevent a redirect like `/login?back=https://gnu.org` to work + "/".to_owned() + } + }; + + let (error_message, error_message_set) = signal(None); + + view! { + <CatchErrors> + <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Login" /> + + <Show when=move || error_message.get().is_some()> + <Banner text=move || error_message.get().expect("Is some") /> + </Show> + + { + Form! { + on_submit = |user_name, password| { + let config = get_config!(); + let navigate = use_navigate(); + let back = back(); + + spawn_local(async move { + match login_external_wrapped( + &config, + LoginInfo { user_name, password } + ).await { + Ok(()) => { + navigate(back.as_str(), NavigateOptions::default()); + } + Err(err) => error_message_set.set(Some(format!("Failed to login: {err}"))), + } + }); + }; + + <Input + name=user_name, + rust_type=String, + html_type="text", + label="Username" + /> + <Input + name=password, + rust_type=String, + html_type="password", + label="Password" + /> + } + } + </CatchErrors> + } +} diff --git a/src/pages/mod.rs b/src/pages/mod.rs index b8a68c7..8a38db2 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,7 +1,86 @@ +pub mod associate_barcode; pub mod buy; +pub mod create_product; +pub mod create_product_parent; +pub mod create_recipe; pub mod home; pub mod inventory; +pub mod login; pub mod not_found; +pub mod product; +pub mod products; +pub mod provision; +pub mod recipe; pub mod recipies; -pub mod create_product; -pub mod associate_barcode; +pub mod units; + +macro_rules! mk_render_parents { + ( + self = $self:ident, + parent_type = $parent_type:ty, + item_type = $item_type:ty, + value_renderer = $value_renderer:ident, + under_parent_fetcher = $under_parent_fetcher:ident, + indirect_fetcher = $indirect_fetcher:ident, + direct_fetcher = $direct_fetcher:ident $(,)? + ) => { + fn $self( + parents: Option<Vec<$parent_type>>, + toplevel_items: Option<Vec<$item_type>>, + ) -> impl IntoView { + use leptos::prelude::IntoAny; + + view! { + { + parents.map(|parents| { + parents + .into_iter() + .map(|parent| { + view! { + <li> + <details> + <summary>{parent.name} {" ("} { + AsyncFetch! { + @map_error_in_producer + fetcher = $indirect_fetcher(parent.id), + producer = |products| {products.len()} + } + } {")"}</summary> + + <ul class="flex flex-col p-2"> + { + AsyncFetch! { + @map_error_in_producer + fetcher = $under_parent_fetcher(parent.id), + producer = |val| $self(val, None) + } + } + + { + AsyncFetch! { + @map_error_in_producer + fetcher = $direct_fetcher(parent.id), + producer = $value_renderer + } + } + </ul> + </details> + </li> + } + .into_any() + }) + .collect::<Vec<_>>() + }) + } + { + if let Some(toplevel_items) = toplevel_items { + $value_renderer(toplevel_items).into_any() + } else { + ().into_any() + } + } + } + } + }; +} +use mk_render_parents; diff --git a/src/pages/not_found.rs b/src/pages/not_found.rs index 7b5c127..2adb598 100644 --- a/src/pages/not_found.rs +++ b/src/pages/not_found.rs @@ -1,6 +1,11 @@ use leptos::{IntoView, component, prelude::ElementChild, view}; +use crate::components::site_header::SiteHeader; + #[component] pub fn NotFound() -> impl IntoView { - view! { <h1>"Uh oh!" <br /> "We couldn't find that page!"</h1> } + view! { + <SiteHeader logo=icondata_io::IoRoseSharp back_location="/" name="Not Found" /> + <h1>"Uh oh!" <br /> "We couldn't find that page!"</h1> + } } diff --git a/src/pages/product.rs b/src/pages/product.rs new file mode 100644 index 0000000..0e4ac04 --- /dev/null +++ b/src/pages/product.rs @@ -0,0 +1,52 @@ +use leptos::{ + IntoView, component, + prelude::{ElementChild, Get, IntoAny}, + view, +}; +use leptos_router::hooks::use_params_map; +use rocie_client::models::product; + +use crate::{ + api::product_by_name_wrapped, + components::{ + async_fetch::{AsyncFetch, AsyncResource}, + catch_errors::CatchErrors, + login_wall::LoginWall, + site_header::SiteHeader, + }, +}; + +#[component] +pub fn Product() -> impl IntoView { + let name = || { + use_params_map() + .get() + .get("name") + .expect("Should always have a name, because the router would otherwise not match") + }; + + view! { + <CatchErrors> + <LoginWall back=move || format!("/product/{}", name())> + <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Recipies" /> + { + AsyncFetch! { + @map_error_in_producer + from_resource=AsyncResource! { + (name: String = name().clone()) + -> Result<product::Product, leptos::error::Error> { + product_by_name_wrapped(&name).await + + } + }, + producer=render_product + } + } + </LoginWall> + </CatchErrors> + } +} + +fn render_product(product: product::Product) -> impl IntoView { + view! { <h1>{product.name}</h1> } +} diff --git a/src/pages/products.rs b/src/pages/products.rs new file mode 100644 index 0000000..5c5b885 --- /dev/null +++ b/src/pages/products.rs @@ -0,0 +1,68 @@ +use leptos::{ + IntoView, component, + prelude::{ClassAttribute, ElementChild}, + view, +}; +use rocie_client::models::{Product, ProductParent}; + +use crate::{ + api::{ + product_parents_toplevel_wrapped, product_parents_under_404_wrapped, + products_by_product_parent_id_direct_wrapped, + products_by_product_parent_id_indirect_wrapped, products_without_product_parent_wrapped, + }, + components::{ + async_fetch::{AsyncFetch, AsyncResource}, + catch_errors::CatchErrors, + login_wall::LoginWall, + site_header::SiteHeader, + }, + pages::mk_render_parents, +}; + +#[component] +pub fn Products() -> impl IntoView { + view! { + <CatchErrors> + <LoginWall back=move || "/products".to_owned()> + <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Products" /> + + <ul class="flex flex-col p-2 m-2"> + { + AsyncFetch! { + @map_error_in_producer + from_resource = AsyncResource!( + () -> Result<(Vec<ProductParent>, Vec<Product>), leptos::error::Error> { + Ok(( + product_parents_toplevel_wrapped().await?, + products_without_product_parent_wrapped().await? + )) + } + ), + producer = |(parents, toplevel_products)| render_product_parents(Some(parents), Some(toplevel_products)), + } + } + </ul> + </LoginWall> + </CatchErrors> + } +} + +mk_render_parents!( + self = render_product_parents, + parent_type = ProductParent, + item_type = Product, + value_renderer = render_products, + under_parent_fetcher = product_parents_under_404_wrapped, + indirect_fetcher = products_by_product_parent_id_indirect_wrapped, + direct_fetcher = products_by_product_parent_id_direct_wrapped, +); + +fn render_products(products: Vec<Product>) -> impl IntoView { + products + .into_iter() + .map(|product| { + view! { <li>{product.name}</li> } + }) + .collect::<Vec<_>>() +} diff --git a/src/pages/provision.rs b/src/pages/provision.rs new file mode 100644 index 0000000..340a076 --- /dev/null +++ b/src/pages/provision.rs @@ -0,0 +1,93 @@ +use leptos::{ + IntoView, component, + prelude::{Get, Show, signal}, + task::spawn_local, + view, +}; +use leptos_router::{ + NavigateOptions, + hooks::{use_navigate, use_query_map}, +}; +use rocie_client::models::{ProvisionInfo, UserStub}; +use rocie_macros::Form; + +use crate::{ + api::{get_config, provision_external_wrapped}, + components::{banner::Banner, catch_errors::CatchErrors, site_header::SiteHeader}, +}; + +#[component] +pub fn Provision() -> impl IntoView { + let back = || { + let back = use_query_map() + .get() + .get("back") + .expect("Should always have a back, because the router would otherwise not match"); + + if back.starts_with('/') { + back + } else { + // Prevent a redirect like `/provision?back=https://gnu.org` to work + "/".to_owned() + } + }; + + let (error_message, error_message_set) = signal(None); + + view! { + <CatchErrors> + <SiteHeader + logo=icondata_io::IoArrowBack + back_location="/" + name="Provision this instance" + /> + + <Show when=move || error_message.get().is_some()> + <Banner text=move || error_message.get().expect("Is some") /> + </Show> + + { + Form! { + on_submit = |user_name, password, should_use_defaults| { + let config = get_config!(); + let navigate = use_navigate(); + let back = back(); + + spawn_local(async move { + match provision_external_wrapped( + &config, + ProvisionInfo { + // TODO: Make it possible to give this user a description <2025-12-30> + user: UserStub { description: None, name: user_name, password }, + use_defaults: should_use_defaults + } + ).await { + Ok(_) => { + navigate(back.as_str(), NavigateOptions::default()); + } + Err(err) => error_message_set.set(Some(format!("Failed to provision: {err}"))), + } + }); + }; + + <Input + name=user_name, + rust_type=String, + html_type="text", + label="Username" + /> + <Input + name=password, + rust_type=String, + html_type="password", + label="Password" + /> + <Checkbox + name=should_use_defaults, + label="Use defaults" + /> + } + } + </CatchErrors> + } +} diff --git a/src/pages/recipe.rs b/src/pages/recipe.rs new file mode 100644 index 0000000..4e56e1d --- /dev/null +++ b/src/pages/recipe.rs @@ -0,0 +1,344 @@ +#![expect( + clippy::needless_pass_by_value, + reason = "It's soo much easier to just pass these values by value" +)] + +use std::sync::Arc; + +use leptos::{ + IntoView, component, + prelude::{ClassAttribute, CollectView, ElementChild, Get, GlobalAttributes, IntoAny}, + view, +}; +use leptos_router::hooks::use_params_map; +use rocie_client::models::{self, Content, Ingredient, Item, Section, UnitAmount}; + +use crate::{ + api::{ + product_by_id_wrapped, recipe_by_id_wrapped, recipe_by_name_wrapped, unit_by_id_wrapped, + }, + components::{ + async_fetch::{AsyncFetch, AsyncResource}, + catch_errors::CatchErrors, + login_wall::LoginWall, + site_header::SiteHeader, + }, +}; + +#[component] +pub fn Recipe() -> impl IntoView { + let name = || { + use_params_map() + .get() + .get("name") + .expect("Should always have a name, because the router would otherwise not match") + }; + + view! { + <CatchErrors> + <LoginWall back=move || format!("/recipe/{}", name())> + <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Recipies" /> + { + AsyncFetch! { + @map_error_in_producer + from_resource=AsyncResource! { + (name: String = name().clone()) + -> Result<models::recipe::Recipe, leptos::error::Error> { + recipe_by_name_wrapped(&name).await + + } + }, + producer=render_recipe + } + } + </LoginWall> + </CatchErrors> + } +} + +fn render_recipe(recipe: models::recipe::Recipe) -> impl IntoView { + let recipe_content = Arc::new(recipe.content.clone()); + + view! { + <div class="flex flex-col contents-start"> + <h1>{recipe.name}</h1> + + <div> + <h3 class="text-lg font-bold">"Ingredients"</h3> + <ul> + {recipe + .content + .ingredients + .into_iter() + .map(|ingredient| { + view! { <li>{render_ingredient_ingredient_list(ingredient)}</li> } + }) + .collect_view()} + </ul> + </div> + + <div> + <h3>"Productions steps"</h3> + { + let local_recipe_content = Arc::clone(&recipe_content); + recipe + .content + .sections + .into_iter() + .map(|section| { + render_section(Arc::clone(&local_recipe_content), section) + }) + .collect_view() + } + </div> + + </div> + } +} + +fn render_section(recipe: Arc<models::CooklangRecipe>, section: Section) -> impl IntoView { + view! { + <div> + <ol class="list-inside list-decimal"> + {section + .content + .into_iter() + .map(|content| { + view! { <li>{render_content(Arc::clone(&recipe), content)}</li> } + }) + .collect_view()} + </ol> + </div> + } +} + +fn render_content(recipe: Arc<models::CooklangRecipe>, content: Content) -> impl IntoView { + match content { + Content::ContentOneOf(content_one_of) => { + // Step + let step = content_one_of.step; + + step.items + .into_iter() + .map(|item| render_item(Arc::clone(&recipe), item)) + .collect_view() + .into_any() + } + Content::ContentOneOf1(content_one_of1) => { + // Text + view! { {content_one_of1.text} }.into_any() + } + } +} + +fn render_item(recipe: Arc<models::CooklangRecipe>, item: Item) -> impl IntoView { + match item { + Item::ItemOneOf(item_one_of) => { + // text + let text = item_one_of.text; + view! { {text.value} }.into_any() + } + Item::ItemOneOf1(item_one_of1) => { + // Ingredient + let ingr = recipe + .ingredients + .get(item_one_of1.ingredient.index as usize) + .expect("to be valid, as cooklang parser should have varified it") + .to_owned(); + + render_ingredient_text(ingr).into_any() + } + Item::ItemOneOf2(item_one_of2) => { + // Cookware index + let cookware = recipe + .cookware + .get(item_one_of2.cookware.index as usize) + .expect("to be valid, as cooklang parser should have varified it") + .to_owned(); + + view! { + <span class="bg-green-400/50 rounded"> + {if let Some(qty) = cookware.quantity { + format!("{} x {}", qty, cookware.name) + } else { + cookware.name + }} + </span> + } + .into_any() + } + Item::ItemOneOf3(item_one_of3) => { + // Timer index + let timer = recipe + .timers + .get(item_one_of3.timer.index as usize) + .expect("to be valid, as cooklang parser should have varified it") + .to_owned(); + + let amount = timer.quantity.map_or(().into_any(), |amount| { + render_unit_amount_text(amount).into_any() + }); + + let name = timer.name.unwrap_or(String::from("<Unnamed timer>")); + + view! { <span class="bg-blue-400/50 rounded">{name}{amount}</span> }.into_any() + } + Item::ItemOneOf4(item_one_of4) => { + // InlineQuantity + todo!("Inline quantity not yet supported") + } + } +} + +fn render_unit_amount_text(amount: UnitAmount) -> impl IntoView { + AsyncFetch! { + @map_error_in_producer + fetcher = unit_by_id_wrapped(amount.unit), + producer = |unit| { + view! { + { + format!(" ({} {})", amount.value, unit.short_name) + } + } + } + } +} + +fn render_ingredient_text(ingr: Ingredient) -> impl IntoView { + match ingr.clone() { + Ingredient::IngredientOneOf(ingredient_one_of) => { + // Registered product + AsyncFetch! { + @map_error_in_producer + fetcher = product_by_id_wrapped(ingredient_one_of.registered_product.id), + producer =|product| { + let amount = ingredient_one_of + .registered_product + .quantity + .map_or(().into_any(), |amount| render_unit_amount_text(amount.amount).into_any()); + + view! { + <span class="rounded bg-gray-300/70"> + <a href=format!("/product/{}", &product.name)>{product.name.clone()}</a>{amount} + </span> + } + } + } + .into_any() + } + Ingredient::IngredientOneOf1(ingredient_one_of1) => { + // Not registered product + let amount = ingredient_one_of1 + .not_registered_product + .quantity + .map_or(().into_any(), |amount| { + render_unit_amount_text(amount).into_any() + }); + + view! { + <span class="rounded-lg bg-red-400/70"> + {ingredient_one_of1.not_registered_product.name}{amount} + </span> + } + .into_any() + } + Ingredient::IngredientOneOf2(ingredient_one_of2) => { + // Recipe reference + let id = ingredient_one_of2.recipe_reference.id; + + AsyncFetch! { + @map_error_in_producer + fetcher=recipe_by_id_wrapped(id), + producer=|recipe| { + view! { + <span class="bg-blue-300/50 rounded-lg"> + <a href=format!("/recipe/{}", recipe.name)>{recipe.name.clone()}</a> + </span> + } + }, + } + .into_any() + } + } +} + +fn render_unit_amount_ingredient_list(amount: UnitAmount) -> impl IntoView { + AsyncFetch! { + @map_error_in_producer + fetcher = unit_by_id_wrapped(amount.unit), + producer = |unit| { + view! { + { + format!( + "{} {} of ", + amount.value, + if amount.value == 1 {unit.full_name_singular} else {unit.full_name_plural}, + ) + } + } + } + } +} + +fn render_ingredient_ingredient_list(ingr: Ingredient) -> impl IntoView { + match ingr.clone() { + Ingredient::IngredientOneOf(ingredient_one_of) => { + // Registered product + AsyncFetch! { + @map_error_in_producer + fetcher = product_by_id_wrapped(ingredient_one_of.registered_product.id), + producer = |product| { + let amount = ingredient_one_of + .registered_product + .quantity + .map_or( + ().into_any(), + |amount| render_unit_amount_ingredient_list(amount.amount).into_any()); + + + view! { + <span id={product.name}> + <a href={format!("/product/{}", &product.name)}> + {amount}{product.name.clone()} + </a> + </span> + } + } + } + .into_any() + } + Ingredient::IngredientOneOf1(ingredient_one_of1) => { + // Not registered product + let amount = ingredient_one_of1 + .not_registered_product + .quantity + .map_or(().into_any(), |amount| { + render_unit_amount_ingredient_list(amount).into_any() + }); + + view! { + <span class="rounded-lg bg-red-400/70"> + {amount}{ingredient_one_of1.not_registered_product.name} + </span> + } + .into_any() + } + Ingredient::IngredientOneOf2(ingredient_one_of2) => { + // Recipe reference + let id = ingredient_one_of2.recipe_reference.id; + + AsyncFetch! { + @map_error_in_producer + fetcher=recipe_by_id_wrapped(id), + producer=|recipe| { + view! { + <span class="bg-blue-300/50 rounded-lg"> + <a href=format!("/recipe/{}", recipe.name)>{recipe.name.clone()}</a> + </span> + } + }, + } + .into_any() + } + } +} diff --git a/src/pages/recipies.rs b/src/pages/recipies.rs index 1fc9dcc..c372d9b 100644 --- a/src/pages/recipies.rs +++ b/src/pages/recipies.rs @@ -1,8 +1,76 @@ -use leptos::{IntoView, component, view}; +use leptos::{ + IntoView, component, + prelude::{ClassAttribute, ElementChild, OnAttribute}, + view, +}; +use leptos_router::{NavigateOptions, components::A, hooks::use_navigate}; +use log::info; +use rocie_client::models::{Recipe, RecipeParent}; -use crate::components::site_header::SiteHeader; +use crate::{ + api::{ + recipe_parents_toplevel_wrapped, recipe_parents_under_404_wrapped, + recipes_by_recipe_parent_id_direct_wrapped, recipes_by_recipe_parent_id_indirect_wrapped, + recipes_without_recipe_parent_wrapped, + }, + components::{ + async_fetch::{AsyncFetch, AsyncResource}, + catch_errors::CatchErrors, + login_wall::LoginWall, + site_header::SiteHeader, + }, + pages::mk_render_parents, +}; #[component] pub fn Recipies() -> impl IntoView { - view! { <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Recipies" /> } + view! { + <CatchErrors> + <LoginWall back=move || "/recipies".to_owned()> + <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Recipies" /> + <ul class="flex flex-col p-2 m-2"> + { + AsyncFetch! { + @map_error_in_producer + from_resource = AsyncResource!( + () -> Result<(Vec<RecipeParent>, Vec<Recipe>), leptos::error::Error> { + Ok(( + recipe_parents_toplevel_wrapped().await?, + recipes_without_recipe_parent_wrapped().await? + )) + } + ), + producer = |(parents, toplevel_recipes)| { + render_recipe_parents(Some(parents), Some(toplevel_recipes)) + }, + } + } + </ul> + </LoginWall> + </CatchErrors> + } +} + +mk_render_parents!( + self = render_recipe_parents, + parent_type = RecipeParent, + item_type = Recipe, + value_renderer = render_recipes, + under_parent_fetcher = recipe_parents_under_404_wrapped, + indirect_fetcher = recipes_by_recipe_parent_id_indirect_wrapped, + direct_fetcher = recipes_by_recipe_parent_id_direct_wrapped, +); + +fn render_recipes(recipes: Vec<Recipe>) -> impl IntoView { + recipes + .into_iter() + .map(|recipe| { + let name = recipe.name.clone(); + view! { + <li> + <A href=move || format!("/recipe/{name}")>{recipe.name}</A> + </li> + } + }) + .collect::<Vec<_>>() } diff --git a/src/pages/units.rs b/src/pages/units.rs new file mode 100644 index 0000000..a5d8655 --- /dev/null +++ b/src/pages/units.rs @@ -0,0 +1,78 @@ +use leptos::{ + IntoView, component, + prelude::{ClassAttribute, CollectView, ElementChild}, + view, +}; +use rocie_client::models::{Unit, UnitPropertyId}; + +use crate::{ + api::{unit_properties_wrapped, units_by_property_id_wrapped}, + components::{ + async_fetch::{AsyncFetch, AsyncResource}, + catch_errors::CatchErrors, + login_wall::LoginWall, + site_header::SiteHeader, + }, +}; + +#[component] +pub(crate) fn Units() -> impl IntoView { + view! { + <CatchErrors> + <LoginWall back=move || "/units".to_owned()> + <SiteHeader logo=icondata_io::IoArrowBack back_location="/" name="Units" /> + + <ul class="flex flex-col gap-2 p-2 m-2"> + { + AsyncFetch! { + @map_error_in_producer + fetcher = unit_properties_wrapped(), + producer = |unit_properties| { + unit_properties.into_iter().map(|unit_property| { + let resource = AsyncResource!{ + ( + unit_property_name: String = unit_property.name.clone(), + unit_property_id: UnitPropertyId = unit_property.id + ) -> Result<(Vec<Unit>, String), leptos::error::Error> { + Ok( + ( + units_by_property_id_wrapped(unit_property_id).await?, + unit_property_name + ) + ) + } + }; + + AsyncFetch! { + @map_error_in_producer + from_resource = resource, + producer = |(units, unit_property_name)| { + let units = units.into_iter().map(|unit| view!{ + <li> + {format!("{} ({})", unit.full_name_singular, unit.short_name)} + </li> + }).collect::<Vec<_>>(); + + + view! { + <li> + <div class="bg-gray-200 p-1 rounded-lg"> + <p class="font-bold">{unit_property_name}</p> + + <ul class="ml-4"> + {units} + </ul> + </div> + </li> + } + } + } + }).collect_view() + }, + } + } + </ul> + </LoginWall> + </CatchErrors> + } +} |
