Render recipes

This commit is contained in:
traxys 2024-01-21 15:48:28 +01:00
parent 55dd953f43
commit 1aecaf9286
4 changed files with 217 additions and 31 deletions

2
Cargo.lock generated
View file

@ -2953,6 +2953,7 @@ dependencies = [
name = "regalade" name = "regalade"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ammonia",
"anyhow", "anyhow",
"api", "api",
"axum", "axum",
@ -2963,6 +2964,7 @@ dependencies = [
"migration", "migration",
"openidconnect", "openidconnect",
"parking_lot", "parking_lot",
"pulldown-cmark",
"sea-orm", "sea-orm",
"sea-query", "sea-query",
"serde", "serde",

View file

@ -36,6 +36,8 @@ time = "0.3.31"
maud = { git = "https://github.com/lambda-fairy/maud", version = "0.25.0", features = [ maud = { git = "https://github.com/lambda-fairy/maud", version = "0.25.0", features = [
"axum", "axum",
] } ] }
pulldown-cmark = "0.9.3"
ammonia = "3.3.0"
[dependencies.sea-orm] [dependencies.sea-orm]
version = "0.12" version = "0.12"

View file

@ -116,6 +116,8 @@ enum RouteError {
Session(#[from] session::Error), Session(#[from] session::Error),
#[error("Could not extract session")] #[error("Could not extract session")]
SessionExtract, SessionExtract,
#[error("Unexpected internal error")]
Internal(String),
} }
impl From<DbErr> for Box<RouteError> { impl From<DbErr> for Box<RouteError> {

View file

@ -1,17 +1,29 @@
use axum::{extract::State, routing::get, Router}; use std::borrow::Cow;
use maud::{html, Markup};
use sea_orm::prelude::*;
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::{ use super::{
base_page,
household::CurrentHousehold, household::CurrentHousehold,
sidebar::{sidebar, SidebarLocation}, sidebar::{sidebar, SidebarLocation},
AppState, AuthenticatedUser, RouteError, AppState, AuthenticatedUser, RouteError,
}; };
pub(super) fn routes() -> Router<AppState> { pub(super) fn routes() -> Router<AppState> {
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 { 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<Uuid>) -> 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( async fn list_recipes(
state: State<AppState>, state: State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
household: CurrentHousehold, household: CurrentHousehold,
) -> Result<Markup, RouteError> { ) -> Result<Markup, RouteError> {
let mut recipes = household.0.find_related(Recipe).all(&state.db).await?; let recipes = household
recipes.sort_unstable_by(|m1, m2| m1.name.cmp(&m2.name)); .0
.find_related(Recipe)
let content = html! { .order_by_asc(recipe::Column::Name)
.d-flex.align-items-center.justify-content-center."w-100" { .all(&state.db)
.container.text-center.rounded.border."pt-2"."m-2" { .await?;
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))
}
}
}
}
}
}
}
}
};
Ok(sidebar( Ok(sidebar(
SidebarLocation::RecipeList, SidebarLocation::RecipeList,
&household, &household,
&user, &user,
content, recipe_list(&recipes, None),
)) ))
} }
async fn list_public_recipe(
state: State<AppState>,
household: Path<Uuid>,
) -> Result<Markup, RouteError> {
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<Markup, RouteError> {
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<AppState>,
user: AuthenticatedUser,
household: CurrentHousehold,
id: Path<i32>,
) -> Result<Markup, RouteError> {
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<AppState>,
info: Path<PublicQuery>,
) -> Result<Markup, RouteError> {
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?))
}