From eacd89119f10d97fbf99aedab27f65e5860fd702 Mon Sep 17 00:00:00 2001 From: Quentin Boyer Date: Wed, 1 Jan 2025 18:25:50 +0100 Subject: [PATCH] Add the recipe creator --- Cargo.lock | 1 + Cargo.toml | 1 + src/app/mod.rs | 18 ++- src/app/recipe.rs | 360 +++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 372 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96ce226..8e1b5fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2638,6 +2638,7 @@ dependencies = [ "sea-orm", "sea-query", "serde", + "serde_with", "sha2", "thiserror 2.0.9", "time", diff --git a/Cargo.toml b/Cargo.toml index bbf60d7..52ba36a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ maud = { version = "0.26.0", features = ["axum"] } pulldown-cmark = "0.12.2" ammonia = "4.0.0" tower-sessions-sqlx-store = { version = "0.14.2", features = ["postgres"] } +serde_with = "3.12.0" [dependencies.sea-orm] version = "1.1" diff --git a/src/app/mod.rs b/src/app/mod.rs index 7913e3a..87fac55 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -45,11 +45,18 @@ pub fn base_page_with_head(body: Markup, head: Option) -> Markup { href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" integrity="sha384-XGjxtQfXaH2tnPFa9x+ruJTuLE3Aa6LhHSWRr1XeTyhezb4abCG4ccI5AkVDxqC+" crossorigin="anonymous"; + link rel="stylesheet" + href="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.7/awesomplete.css" + integrity="sha512-GEMEzu9K8wXXaW527IHfGIOaTQ0hXxZPJXZOwGDIO+nrR9Z0ttJih1ZehiEoWY8xPtqzzD7pxAEnQInTZwn3MQ==" + crossorigin="anonymous"; @if let Some(head) = head { (head) } } body { + script src="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.7/awesomplete.min.js" + integrity="sha512-Pc3/aEr2FIVZhHxe0RAC9SFrd+pxBJHN3pNJfJNTKc2XAFnXUjgQGIh6X935ePSXNMN6rFa3yftxSnZfJE8ZAg==" + crossorigin="anonymous" {} (body) script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" @@ -120,6 +127,8 @@ enum RouteError { SessionExtract, #[error("Unexpected internal error")] Internal(String), + #[error("Invalid form")] + InvalidForm(String), } impl From for Box { @@ -149,7 +158,14 @@ impl IntoResponse for RouteError { RouteError::InvalidRequest(reason) => { error_page(StatusCode::BAD_REQUEST, reason).into_response() } - e => { + RouteError::InvalidForm(r) => { + error_page(StatusCode::UNPROCESSABLE_ENTITY, r).into_response() + } + e @ (RouteError::Db(_) + | RouteError::Session(_) + | RouteError::SessionExtract + | RouteError::Internal(_) + | RouteError::TxnError(_)) => { tracing::error!("Internal error: {e:?}"); error_page(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() } diff --git a/src/app/recipe.rs b/src/app/recipe.rs index 24d1065..48ec47e 100644 --- a/src/app/recipe.rs +++ b/src/app/recipe.rs @@ -1,15 +1,17 @@ -use std::borrow::Cow; +use std::{borrow::Cow, fmt::Display, str::FromStr}; use axum::{ extract::{Path, State}, - routing::get, - Router, + response::Redirect, + routing::{get, post}, + Form, Router, }; use maud::{html, Markup, PreEscaped}; use pulldown_cmark::Parser; -use sea_orm::{prelude::*, QueryOrder}; +use sea_orm::{prelude::*, ActiveValue, QueryOrder, TransactionTrait}; +use serde_with::{serde_as, NoneAsEmptyString}; -use crate::entity::{prelude::*, recipe}; +use crate::entity::{ingredient, prelude::*, recipe, recipe_ingredients}; use super::{ base_page, @@ -21,7 +23,8 @@ use super::{ pub(super) fn routes() -> Router { Router::new() .route("/", get(list_recipes)) - .route("/create", get(create_recipe)) + .route("/create", get(create_recipe).post(do_create_recipe)) + .route("/create/ingredient", post(create_recipe_ingredient)) .route("/:id", get(view_recipe)) .route("/public/:hs/:id", get(view_public_recipe)) .route("/public/:hs", get(list_public_recipe)) @@ -266,14 +269,357 @@ async fn view_public_recipe( )) } +fn recipe_ingredient_item(ingredient: &ingredient::Model, amount: u32) -> Markup { + html! { + li .list-group-item.d-flex.justify-content-between.align-items-center { + (format!("{}{} {}", amount, ingredient.unit.as_deref().unwrap_or(""), ingredient.name)) + input type="hidden" name="ingredient" value=(format!("{},{}", ingredient.id, amount)); + button type="button" .btn.btn-danger onclick="removeIngredient(this)" { "Remove" } + } + } +} + +#[serde_as] +#[derive(serde::Deserialize)] +struct CreateAddIngredient { + name: String, + #[serde_as(as = "NoneAsEmptyString")] + unit: Option, + amount: u32, +} + +async fn create_recipe_ingredient( + state: State, + _user: AuthenticatedUser, + household: CurrentHousehold, + Form(form): Form, +) -> Result { + let ingredient = ingredient::ActiveModel { + household: sea_orm::ActiveValue::Set(household.0.id), + name: sea_orm::ActiveValue::Set(form.name), + unit: sea_orm::ActiveValue::Set(form.unit), + id: sea_orm::ActiveValue::NotSet, + }; + + let ingredient = ingredient.insert(&state.db).await?; + + Ok(html! { + (recipe_ingredient_item(&ingredient, form.amount)) + datalist hx-swap-oob="beforeend:#igList" { + option id={"ig" (ingredient.id)} value=(ingredient.id) unit=[ingredient.unit] { + (ingredient.name) + } + } + }) +} + +fn create_ingredient_modal() -> Markup { + let id = "newRcpCreateIg"; + maud::html! { + .modal.fade #(id) tabindex="-1" aria-labelledby={(id) "Label"} aria-hidden="true" { + .modal-dialog.modal-dialog-centered { + .modal-content { + .modal-header { + h1 .modal-title."fs-5" #{(id) "Label"} { "Create & Add ingredient" } + input + type="reset" + form={(id) "Form"} + .btn-close + data-bs-dismiss="modal" + aria-label="Close" + value=""; + } + .modal-body { + form #{(id) "Form"} hx-post="/recipe/create/ingredient" + hx-swap="beforeend" hx-target="#recipeIngredients" + "hx-on::after-request"="this.reset()" + { + .form-floating { + input .form-control + #{(id) "Name"} placeholder="Ingrendient name" name="name" {} + label for={(id) "Name"} { "Ingredient name" } + } + .form-floating { + input .form-control + #{(id) "Unit"} placeholder="Ingrendient unit" name="unit" {} + label for={(id) "Unit"} { "Ingredient unit" } + } + .form-floating { + input .form-control + #{(id) "Amount"} type="number" min="0" placeholder="Ingredient amount" name="amount"; + label for={(id) "Amount"} { "Ingredient amount" } + } + } + } + .modal-footer { + input type="reset" form={(id) "Form"} .btn.btn-danger data-bs-dismiss="modal" value="Cancel"; + input type="submit" form={(id) "Form"} .btn.btn-primary data-bs-dismiss="modal" value="Create & Add"; + } + } + } + } + } +} + +async fn ingredient_list( + state: &State, + household: &CurrentHousehold, +) -> Result { + let list = household.0.find_related(Ingredient).all(&state.db).await?; + + Ok(html! { + @for ig in list { + option id={"ig" (ig.id)} value=(ig.id) unit=[ig.unit] { (ig.name) } + } + }) +} + +async fn select_ingredient( + state: &State, + household: &CurrentHousehold, + extra_controls: Markup, +) -> Result { + let ingredients = ingredient_list(state, household).await?; + + Ok(html! { + .d-flex.flex-column.align-items-start { + .container { + .row { + .col-sm-6.d-flex.align-items-center.mb-1 { + label .pe-1 for="igSelect" { "Name:" } + input .form-control list="igList" #igSelect; + datalist #igList { (ingredients) } + } + .col-sm-6.d-flex.align-items-center { + label .px-1 for="igAmount" { "Amount: " } + .input-group { + input .form-control type="number" id="igAmount" min="0" value="0"; + span #igUnit .input-group-text hidden { } + } + } + .col-sm { + button .btn.btn-primary.me-1 type="button" #igAdd { "Add" } + (extra_controls) + } + script { + (PreEscaped(r#" +currentIg = null; +igUnit = document.getElementById("igUnit"); +igSelect = document.getElementById("igSelect"); + +igSelect.addEventListener("awesomplete-selectcomplete", function(event) { + currentIg = document.getElementById(`ig${event.text.value}`); + igSelect.value = event.text.label; + + if ("unit" in currentIg.attributes) { + igUnit.hidden = false; + igUnit.innerHTML = currentIg.attributes.unit.value; + } else { + igUnit.hidden = true; + } +}); + +igList = document.getElementById("igList") +igSelectCompletion = new Awesomplete(igSelect, {list: igList}); +document.body.addEventListener("htmx:oobAfterSwap", function(event) { + igSelectCompletion.destroy(); + igSelectCompletion = new Awesomplete(igSelect, {list: igList}); +}) + +igAmount = document.getElementById("igAmount") +igAdd = document.getElementById("igAdd") +igAdd.addEventListener("click", function(event) { + if (currentIg == null) + return; + + list = document.getElementById("recipeIngredients") + + listItem = document.createElement("li"); + listItem.classList.add("list-group-item", "d-flex", "justify-content-between", "align-items-center"); + + text = document.createTextNode(`${igAmount.value}${currentIg.attributes?.unit?.value || ""} ${currentIg.innerText}`); + listItem.appendChild(text); + + listItemInput = document.createElement("input"); + listItemInput.type = "hidden"; + listItemInput.name = "ingredient"; + listItemInput.value = `${currentIg.value},${igAmount.value}`; + listItem.appendChild(listItemInput); + + listItemRemove = document.createElement("button"); + listItemRemove.classList.add("btn", "btn-danger"); + listItemRemove.onclick = function () { removeIngredient(listItemRemove) }; + text = document.createTextNode("Remove") + listItemRemove.appendChild(text); + listItem.appendChild(listItemRemove); + + list.appendChild(listItem) + + igSelect.value = "" + igAmount.value = ""; + currentIg = null; +}) + "#)) + } + } + } + } + }) +} + +async fn do_create_recipe( + state: State, + _user: AuthenticatedUser, + household: CurrentHousehold, + Form(form): Form>, +) -> Result { + let mut name = None; + let mut person_count = None; + let mut rating = None; + let mut ingredients = Vec::new(); + let mut steps = None; + + fn parse(name: &str, v: &str) -> Result + where + T: FromStr, + T::Err: Display, + { + v.parse() + .map_err(|e| RouteError::InvalidForm(format!("Invalid {name}: {e}"))) + } + + for (key, value) in form { + match key.as_str() { + "name" => name = Some(value), + "person_count" => person_count = Some(parse("person_count", &value)?), + "rating" => rating = Some(parse("rating", &value)?), + "ingredient" => { + let Some((id, amount)) = value.split_once(',') else { + return Err(RouteError::InvalidForm( + "Invalid ingredient, missing `,`".to_string(), + )); + }; + + let id: i64 = parse("ingredient id", id)?; + let amount: i64 = parse("ingredient amount", amount)?; + ingredients.push((id, amount)); + } + "steps" => steps = Some(value), + _ => (), + } + } + + fn extract(name: &str, v: Option) -> Result, RouteError> + where + T: Into, + { + v.ok_or_else(|| RouteError::InvalidForm(format!("Missing field `{name}`"))) + .map(ActiveValue::Set) + } + + let recipe = state + .db + .transaction(|tx| { + Box::pin(async move { + let recipe = recipe::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + name: extract("name", name)?, + person_count: extract("person_count", person_count)?, + ranking: extract("rating", rating)?, + steps: extract("steps", steps)?, + household: sea_orm::ActiveValue::Set(household.0.id), + } + .insert(tx) + .await?; + + for (id, amount) in ingredients { + recipe_ingredients::ActiveModel { + recipe_id: ActiveValue::Set(recipe.id), + ingredient_id: ActiveValue::Set(id), + amount: ActiveValue::Set(amount as f64), + } + .insert(tx) + .await?; + } + + Ok::<_, RouteError>(recipe) + }) + }) + .await?; + + Ok(Redirect::to(&format!("/recipe/{}", recipe.id))) +} + async fn create_recipe( + state: State, user: AuthenticatedUser, household: CurrentHousehold, ) -> Result { + let create_ig = html! { + button .btn.btn-primary + type="button" data-bs-toggle="modal" data-bs-target="#newRcpCreateIg" { + "Create & Add" + } + }; + Ok(sidebar( SidebarLocation::RecipeCreator, &household, &user, - html! {}, + html! { + script { (PreEscaped(r#" +function removeIngredient (self) { + self.parentElement.remove() +} + "#)) } + .d-flex.align-items-center.justify-content-center.w-100 { + .container.rounded.border.py-2.m-2.text-center { + h1 { "Create a new recipe" } + hr; + (create_ingredient_modal()) + form action="/recipe/create" method="POST" { + .form-floating { + input #newRcpName .form-control placeholder="Name" name="name" {} + label for="newRcpName" { "Name" } + } + + .form-floating { + input #newRcpPersonCount .form-control + placeholder="Person Count" type="number" min="1" value="1" name="person_count" {} + label for="newRcpPersonCount" { "Person Count" } + } + + .pt-2 { + "Rating: " + @for (i, label) in ["Like", "Like a lot", "Love"].iter().enumerate() { + @let id = format!("rating{i}"); + .form-check.form-check-inline { + input .form-check-input type="radio" + name="rating" #(id) checked[i == 0] value=(i) {} + label .form-check-label for=(id) { (label) } + } + } + } + + .d-flex.flex-column.justify-content-start { + h2 { "Ingredients" } + (select_ingredient(&state, &household, create_ig).await?) + ul .list-group.list-group-flush.text-start #recipeIngredients {} + } + + div { + h2 { "Steps" } + .text-start { + textarea .form-control name="steps" rows="10" {} + } + } + + hr {} + + button .btn.btn-lg.btn-primary { "Create Recipe" } + } + } + } + }, )) }