summary refs log tree commit diff stats
path: root/src/pages
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-03-19 07:45:14 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-03-19 07:45:14 +0100
commitf6a3fb9c4d8dd86f78c9f75a23c1ac35bf35d4eb (patch)
tree5f28fbca03d83921b568f7cb1708374456d9ec42 /src/pages
parentfeat(treewide): Add further buttons (diff)
downloadweb-client-f6a3fb9c4d8dd86f78c9f75a23c1ac35bf35d4eb.zip
feat(treewide): Commit MVP
Diffstat (limited to 'src/pages')
-rw-r--r--src/pages/associate_barcode.rs96
-rw-r--r--src/pages/buy.rs63
-rw-r--r--src/pages/create_product.rs109
-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
16 files changed, 1340 insertions, 156 deletions
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>
+    }
+}