Add the recipe creator
This commit is contained in:
parent
0ecc852f37
commit
eacd89119f
4 changed files with 372 additions and 8 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2638,6 +2638,7 @@ dependencies = [
|
|||
"sea-orm",
|
||||
"sea-query",
|
||||
"serde",
|
||||
"serde_with",
|
||||
"sha2",
|
||||
"thiserror 2.0.9",
|
||||
"time",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
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<DbErr> for Box<RouteError> {
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AppState> {
|
||||
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<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(
|
||||
state: State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
household: CurrentHousehold,
|
||||
) -> 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(
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue