Add the recipe creator

This commit is contained in:
Quentin Boyer 2025-01-01 18:25:50 +01:00
parent 0ecc852f37
commit eacd89119f
4 changed files with 372 additions and 8 deletions

1
Cargo.lock generated
View file

@ -2638,6 +2638,7 @@ dependencies = [
"sea-orm", "sea-orm",
"sea-query", "sea-query",
"serde", "serde",
"serde_with",
"sha2", "sha2",
"thiserror 2.0.9", "thiserror 2.0.9",
"time", "time",

View file

@ -33,6 +33,7 @@ maud = { version = "0.26.0", features = ["axum"] }
pulldown-cmark = "0.12.2" pulldown-cmark = "0.12.2"
ammonia = "4.0.0" ammonia = "4.0.0"
tower-sessions-sqlx-store = { version = "0.14.2", features = ["postgres"] } tower-sessions-sqlx-store = { version = "0.14.2", features = ["postgres"] }
serde_with = "3.12.0"
[dependencies.sea-orm] [dependencies.sea-orm]
version = "1.1" version = "1.1"

View file

@ -45,11 +45,18 @@ pub fn base_page_with_head(body: Markup, head: Option<Markup>) -> Markup {
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
integrity="sha384-XGjxtQfXaH2tnPFa9x+ruJTuLE3Aa6LhHSWRr1XeTyhezb4abCG4ccI5AkVDxqC+" integrity="sha384-XGjxtQfXaH2tnPFa9x+ruJTuLE3Aa6LhHSWRr1XeTyhezb4abCG4ccI5AkVDxqC+"
crossorigin="anonymous"; 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 { @if let Some(head) = head {
(head) (head)
} }
} }
body { body {
script src="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.7/awesomplete.min.js"
integrity="sha512-Pc3/aEr2FIVZhHxe0RAC9SFrd+pxBJHN3pNJfJNTKc2XAFnXUjgQGIh6X935ePSXNMN6rFa3yftxSnZfJE8ZAg=="
crossorigin="anonymous" {}
(body) (body)
script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
@ -120,6 +127,8 @@ enum RouteError {
SessionExtract, SessionExtract,
#[error("Unexpected internal error")] #[error("Unexpected internal error")]
Internal(String), Internal(String),
#[error("Invalid form")]
InvalidForm(String),
} }
impl From<DbErr> for Box<RouteError> { impl From<DbErr> for Box<RouteError> {
@ -149,7 +158,14 @@ impl IntoResponse for RouteError {
RouteError::InvalidRequest(reason) => { RouteError::InvalidRequest(reason) => {
error_page(StatusCode::BAD_REQUEST, reason).into_response() 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:?}"); tracing::error!("Internal error: {e:?}");
error_page(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() error_page(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
} }

View file

@ -1,15 +1,17 @@
use std::borrow::Cow; use std::{borrow::Cow, fmt::Display, str::FromStr};
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
routing::get, response::Redirect,
Router, routing::{get, post},
Form, Router,
}; };
use maud::{html, Markup, PreEscaped}; use maud::{html, Markup, PreEscaped};
use pulldown_cmark::Parser; 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::{ use super::{
base_page, base_page,
@ -21,7 +23,8 @@ use super::{
pub(super) fn routes() -> Router<AppState> { pub(super) fn routes() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(list_recipes)) .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("/:id", get(view_recipe))
.route("/public/:hs/:id", get(view_public_recipe)) .route("/public/:hs/:id", get(view_public_recipe))
.route("/public/:hs", get(list_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<String>,
amount: u32,
}
async fn create_recipe_ingredient(
state: State<AppState>,
_user: AuthenticatedUser,
household: CurrentHousehold,
Form(form): Form<CreateAddIngredient>,
) -> Result<Markup, RouteError> {
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<AppState>,
household: &CurrentHousehold,
) -> Result<Markup, RouteError> {
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<AppState>,
household: &CurrentHousehold,
extra_controls: Markup,
) -> Result<Markup, RouteError> {
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<AppState>,
_user: AuthenticatedUser,
household: CurrentHousehold,
Form(form): Form<Vec<(String, String)>>,
) -> Result<Redirect, RouteError> {
let mut name = None;
let mut person_count = None;
let mut rating = None;
let mut ingredients = Vec::new();
let mut steps = None;
fn parse<T>(name: &str, v: &str) -> Result<T, RouteError>
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<T>(name: &str, v: Option<T>) -> Result<ActiveValue<T>, RouteError>
where
T: Into<Value>,
{
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( async fn create_recipe(
state: State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
household: CurrentHousehold, household: CurrentHousehold,
) -> Result<Markup, RouteError> { ) -> Result<Markup, RouteError> {
let create_ig = html! {
button .btn.btn-primary
type="button" data-bs-toggle="modal" data-bs-target="#newRcpCreateIg" {
"Create & Add"
}
};
Ok(sidebar( Ok(sidebar(
SidebarLocation::RecipeCreator, SidebarLocation::RecipeCreator,
&household, &household,
&user, &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" }
}
}
}
},
)) ))
} }