summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/api/mod.rs221
-rw-r--r--src/components/async_fetch.rs12
-rw-r--r--src/components/buy.rs1
-rw-r--r--src/components/catch_errors.rs40
-rw-r--r--src/components/checkbox_placeholder.rs54
-rw-r--r--src/components/container.rs52
-rw-r--r--src/components/inventory.rs4
-rw-r--r--src/components/login_wall.rs42
-rw-r--r--src/components/mod.rs7
-rw-r--r--src/components/product_overview.rs19
-rw-r--r--src/components/product_parent_overview.rs37
-rw-r--r--src/components/recipies.rs20
-rw-r--r--src/components/textarea_placeholder.rs60
-rw-r--r--src/components/unit_overview.rs37
-rw-r--r--src/lib.rs60
-rw-r--r--src/pages/associate_barcode.rs84
-rw-r--r--src/pages/buy.rs63
-rw-r--r--src/pages/create_product.rs103
-rw-r--r--src/pages/create_product_parent.rs126
-rw-r--r--src/pages/create_recipe.rs108
-rw-r--r--src/pages/home.rs46
-rw-r--r--src/pages/inventory.rs68
-rw-r--r--src/pages/login.rs81
-rw-r--r--src/pages/mod.rs83
-rw-r--r--src/pages/not_found.rs7
-rw-r--r--src/pages/product.rs52
-rw-r--r--src/pages/products.rs68
-rw-r--r--src/pages/provision.rs93
-rw-r--r--src/pages/recipe.rs344
-rw-r--r--src/pages/recipies.rs74
-rw-r--r--src/pages/units.rs78
31 files changed, 1923 insertions, 221 deletions
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" />
+ <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>
+ <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!();
+ {
+ 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).await?;
+ 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?;
+ 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>(())
- };
+ Ok::<_, leptos::error::Error>(())
+ };
- match output.await {
- Ok(()) => (),
- Err(err) => {
- errors_set.set(
- Some(
- format!("Could not associate barcode: {err}")
- )
- );
- },
- }
- });
+ 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" />
+ <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>
+ <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!();
+ {
+ 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: Some(product_description.0),
- name: product_name,
- parent: None, // TODO: Add this <2025-10-25>
- unit_property: UnitPropertyId { value: unit_property_id },
+ 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>
+ }
+}