diff --git a/Cargo.lock b/Cargo.lock index 2949670..581ab56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2953,6 +2953,7 @@ dependencies = [ name = "regalade" version = "0.1.0" dependencies = [ + "ammonia", "anyhow", "api", "axum", @@ -2963,6 +2964,7 @@ dependencies = [ "migration", "openidconnect", "parking_lot", + "pulldown-cmark", "sea-orm", "sea-query", "serde", diff --git a/Cargo.toml b/Cargo.toml index a69a46e..6969092 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,8 @@ time = "0.3.31" maud = { git = "https://github.com/lambda-fairy/maud", version = "0.25.0", features = [ "axum", ] } +pulldown-cmark = "0.9.3" +ammonia = "3.3.0" [dependencies.sea-orm] version = "0.12" diff --git a/src/app/mod.rs b/src/app/mod.rs index 20888b9..9299e4d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -116,6 +116,8 @@ enum RouteError { Session(#[from] session::Error), #[error("Could not extract session")] SessionExtract, + #[error("Unexpected internal error")] + Internal(String), } impl From for Box { diff --git a/src/app/recipe.rs b/src/app/recipe.rs index a8a81a8..e3a2b46 100644 --- a/src/app/recipe.rs +++ b/src/app/recipe.rs @@ -1,17 +1,29 @@ -use axum::{extract::State, routing::get, Router}; -use maud::{html, Markup}; -use sea_orm::prelude::*; +use std::borrow::Cow; -use crate::entity::prelude::*; +use axum::{ + extract::{Path, State}, + routing::get, + Router, +}; +use maud::{html, Markup, PreEscaped}; +use pulldown_cmark::Parser; +use sea_orm::{prelude::*, QueryOrder}; + +use crate::entity::{prelude::*, recipe}; use super::{ + base_page, household::CurrentHousehold, sidebar::{sidebar, SidebarLocation}, AppState, AuthenticatedUser, RouteError, }; pub(super) fn routes() -> Router { - Router::new().route("/", get(list_recipes)) + Router::new() + .route("/", get(list_recipes)) + .route("/:id", get(view_recipe)) + .route("/public/:hs/:id", get(view_public_recipe)) + .route("/public/:hs", get(list_public_recipe)) } fn recipe_rating(rating: i32) -> Markup { @@ -24,41 +36,209 @@ fn recipe_rating(rating: i32) -> Markup { } } +/// If household is None then the function acts on the private household +fn recipe_list(recipes: &[recipe::Model], household: Option) -> Markup { + let destination = match household { + Some(id) => Cow::from(format!("/recipe/public/{id}/")), + None => Cow::from("/recipe/"), + }; + + html! { + .d-flex.align-items-center.justify-content-center."w-100" { + .container.text-center.rounded.border."pt-2"."m-2" { + h2 { "Recipes" } + .container.text-center { + .row."row-cols-2"."row-cols-sm-2"."row-cols-md-4"."g-2"."mb-2" { + @for r in recipes { + .col { + ."p-3".border.rounded.border-light-subtle."h-100" { + a .link-light."link-offset-2"."link-underline-opacity-25"."link-underline-opacity-100-hover" + href={(destination) (r.id)} { + (r.name) @if household.is_none() { (recipe_rating(r.ranking)) } + } + } + } + } + } + } + } + } + } +} + async fn list_recipes( state: State, user: AuthenticatedUser, household: CurrentHousehold, ) -> Result { - let mut recipes = household.0.find_related(Recipe).all(&state.db).await?; - recipes.sort_unstable_by(|m1, m2| m1.name.cmp(&m2.name)); - - let content = html! { - .d-flex.align-items-center.justify-content-center."w-100" { - .container.text-center.rounded.border."pt-2"."m-2" { - h2 { "Recipes" } - .container.text-center { - .row."row-cols-2"."row-cols-sm-2"."row-cols-md-4"."g-2"."mb-2" { - @for r in recipes { - .col { - ."p-3".border.rounded.border-light-subtle."h-100" { - a .link-light."link-offset-2"."link-underline-opacity-25"."link-underline-opacity-100-hover" - href={"/recipe/" (r.id)} - { - (r.name) (recipe_rating(r.ranking)) - } - } - } - } - } - } - } - } - }; + let recipes = household + .0 + .find_related(Recipe) + .order_by_asc(recipe::Column::Name) + .all(&state.db) + .await?; Ok(sidebar( SidebarLocation::RecipeList, &household, &user, - content, + recipe_list(&recipes, None), )) } + +async fn list_public_recipe( + state: State, + household: Path, +) -> Result { + let household = Household::find_by_id(household.0) + .one(&state.db) + .await? + .ok_or(RouteError::RessourceNotFound)?; + + let recipes = household + .find_related(Recipe) + .order_by_asc(recipe::Column::Name) + .all(&state.db) + .await?; + + Ok(base_page(recipe_list(&recipes, Some(household.id)))) +} + +async fn recipe_view( + r: &recipe::Model, + private: bool, + db: &DatabaseConnection, +) -> Result { + let base_ingredients = r.find_related(Ingredient).all(db).await?; + + let mut ingredients = Vec::with_capacity(base_ingredients.len()); + for ig in base_ingredients { + ingredients.push(( + RecipeIngredients::find_by_id((r.id, ig.id)) + .one(db) + .await? + .ok_or_else(|| { + RouteError::Internal(format!( + "No recipe ingredient found for rcp={}/ig={}", + r.id, ig.id + )) + })? + .amount, + ig, + )) + } + + let mut script = "const base_amount = [".to_string(); + for (amount, ig) in &ingredients { + script += &format!( + r#"{{amount: {}, elem: document.querySelector('#rcpIg{} > span > .ig-amount')}},"#, + amount, ig.id + ) + } + script += r#"]; + const chg_person = document.querySelector('#rcpPersonCount'); + chg_person.addEventListener("change", (event) => { + base_amount.forEach(({amount, elem}) => { + elem.textContent = amount * event.target.value; + }) + }); + "#; + + let steps = { + let parser = Parser::new(&r.steps); + + let mut html_output = String::new(); + pulldown_cmark::html::push_html(&mut html_output, parser); + + ammonia::clean(&html_output) + }; + + Ok(html! { + .d-flex.align-items-center.justify-content-center."w-100" { + .container.text-center.rounded.border."pt-2"."m-2" { + h1 { (r.name) @if private { (recipe_rating(r.ranking)) } } + @if private { ."mt-2" { "TODO: edit" } } + ."mt-2".container.text-start { + .row { + ."col-8"[private] { + .input-group { + input .form-control type="number" #rcpPersonCount min="1" value=(r.person_count) {} + span .input-group-text { "people" } + } + } + @if private { .col {"TODO: edit"} } + } + } + hr {} + .text-start { + h2 { "Ingredients" } + ul .list-group."mb-2" { + @for (amount, ig) in &ingredients { + li .list-group-item.d-flex.justify-content-between.align-items-center #{"rcpIg" (ig.id)} { + span { + span .ig-amount { ((*amount * r.person_count as f64).round()) } + @if let Some(u) = &ig.unit { (u) } " " (ig.name) + } + } + } + } + @if private { "TODO: add ingredient" } + } + hr {} + .text-start { + h2 {"Steps"} + div { (PreEscaped(steps)) } + @ if private { "TODO: edit steps" } + } + } + } + script { (PreEscaped(script)) } + }) +} + +async fn view_recipe( + state: State, + user: AuthenticatedUser, + household: CurrentHousehold, + id: Path, +) -> Result { + let recipe = household + .0 + .find_related(Recipe) + .filter(recipe::Column::Id.eq(id.0)) + .one(&state.db) + .await? + .ok_or(RouteError::RessourceNotFound)?; + + Ok(sidebar( + SidebarLocation::RecipeList, + &household, + &user, + recipe_view(&recipe, true, &state.db).await?, + )) +} + +#[derive(serde::Deserialize)] +struct PublicQuery { + hs: Uuid, + id: i32, +} + +async fn view_public_recipe( + state: State, + info: Path, +) -> Result { + let household = Household::find_by_id(info.0.hs) + .one(&state.db) + .await? + .ok_or(RouteError::RessourceNotFound)?; + + let recipe = household + .find_related(Recipe) + .filter(recipe::Column::Id.eq(info.0.id)) + .one(&state.db) + .await? + .ok_or(RouteError::RessourceNotFound)?; + + Ok(base_page(recipe_view(&recipe, false, &state.db).await?)) +}