Create recipe
This commit is contained in:
parent
c55ae4c523
commit
8e9849debb
@ -80,6 +80,8 @@ struct RecipeForm {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
known_tags: Vec<entities::tags::Model>,
|
known_tags: Vec<entities::tags::Model>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
known_ingredients: Vec<entities::ingredients::Model>,
|
||||||
|
#[serde(default)]
|
||||||
session: Option<User>,
|
session: Option<User>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
page: Page,
|
page: Page,
|
||||||
@ -326,7 +328,9 @@ async fn show(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
HttpResponse::Ok().body(
|
HttpResponse::Ok()
|
||||||
|
.append_header((CONTENT_TYPE, "text/html"))
|
||||||
|
.body(
|
||||||
RecipeDetailTemplate {
|
RecipeDetailTemplate {
|
||||||
steps,
|
steps,
|
||||||
tags,
|
tags,
|
||||||
@ -343,6 +347,7 @@ async fn show(
|
|||||||
#[get("/create")]
|
#[get("/create")]
|
||||||
async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeForm {
|
async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeForm {
|
||||||
let tags = Tags::find().all(&**db).await.unwrap_or_default();
|
let tags = Tags::find().all(&**db).await.unwrap_or_default();
|
||||||
|
let ingredients = Ingredients::find().all(&**db).await.unwrap_or_default();
|
||||||
RecipeForm {
|
RecipeForm {
|
||||||
title: "".into(),
|
title: "".into(),
|
||||||
summary: "".into(),
|
summary: "".into(),
|
||||||
@ -355,6 +360,7 @@ async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeFor
|
|||||||
time: None,
|
time: None,
|
||||||
error: None,
|
error: None,
|
||||||
known_tags: tags,
|
known_tags: tags,
|
||||||
|
known_ingredients: ingredients,
|
||||||
session: admin.id().ok(),
|
session: admin.id().ok(),
|
||||||
page: Page::Index,
|
page: Page::Index,
|
||||||
}
|
}
|
||||||
@ -363,7 +369,7 @@ async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeFor
|
|||||||
#[post("/create")]
|
#[post("/create")]
|
||||||
async fn create_recipe(
|
async fn create_recipe(
|
||||||
admin: Identity,
|
admin: Identity,
|
||||||
form: Form<RecipeForm>,
|
form: actix_web::web::Json<RecipeForm>,
|
||||||
db: Data<DatabaseConnection>,
|
db: Data<DatabaseConnection>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
let mut form = form.into_inner();
|
let mut form = form.into_inner();
|
||||||
@ -382,9 +388,10 @@ async fn create_recipe(
|
|||||||
failure.error = Some(e.to_string());
|
failure.error = Some(e.to_string());
|
||||||
let _ = t.rollback().await;
|
let _ = t.rollback().await;
|
||||||
failure.known_tags = Tags::find().all(&**db).await.unwrap_or_default();
|
failure.known_tags = Tags::find().all(&**db).await.unwrap_or_default();
|
||||||
|
failure.known_ingredients = Ingredients::find().all(&**db).await.unwrap_or_default();
|
||||||
Ok(failure.to_response())
|
Ok(failure.to_response())
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(recipe_id) => {
|
||||||
let _ = t.commit().await;
|
let _ = t.commit().await;
|
||||||
let Ok(_) = tokio::fs::copy(
|
let Ok(_) = tokio::fs::copy(
|
||||||
format!("/tmp{}", failure.image_url),
|
format!("/tmp{}", failure.image_url),
|
||||||
@ -395,14 +402,14 @@ async fn create_recipe(
|
|||||||
tracing::error!("Failed to copy file: {}", failure.image_url);
|
tracing::error!("Failed to copy file: {}", failure.image_url);
|
||||||
return Ok(HttpResponse::InternalServerError().finish());
|
return Ok(HttpResponse::InternalServerError().finish());
|
||||||
};
|
};
|
||||||
Ok(HttpResponse::TemporaryRedirect()
|
Ok(HttpResponse::SeeOther()
|
||||||
.append_header(("location", "/"))
|
.append_header(("location", format!("/recipe/{recipe_id}").as_str()))
|
||||||
.finish())
|
.finish())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<(), Error> {
|
async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<i32, Error> {
|
||||||
use crate::entities::recipies::ActiveModel as RAM;
|
use crate::entities::recipies::ActiveModel as RAM;
|
||||||
use sea_orm::ActiveValue::*;
|
use sea_orm::ActiveValue::*;
|
||||||
|
|
||||||
@ -434,6 +441,84 @@ async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<()
|
|||||||
.map_err(|_| Error::SaveRecipe)?
|
.map_err(|_| Error::SaveRecipe)?
|
||||||
.last_insert_id;
|
.last_insert_id;
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
{
|
||||||
|
tracing::debug!("Selected tags: {:?}", form.selected_tags);
|
||||||
|
let selected_tags = form
|
||||||
|
.selected_tags;
|
||||||
|
|
||||||
|
let text_tags = form
|
||||||
|
.tags
|
||||||
|
.split(",")
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
tracing::debug!("Received tags: {text_tags:?}");
|
||||||
|
let known = Tags::find()
|
||||||
|
.filter(entities::tags::Column::Name.is_in(&text_tags))
|
||||||
|
.all(&mut *t)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
tracing::debug!("Known tags: {known:?}");
|
||||||
|
let missing = {
|
||||||
|
let known = known
|
||||||
|
.iter()
|
||||||
|
.map(|model| &model.name)
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
tracing::debug!("Known tag names: {known:?}");
|
||||||
|
let mut missing = Vec::with_capacity(text_tags.len());
|
||||||
|
for tag_name in text_tags
|
||||||
|
.into_iter()
|
||||||
|
.filter(|tag_name| !known.contains(&tag_name))
|
||||||
|
{
|
||||||
|
tracing::debug!("Creating missing tag: {tag_name:?}");
|
||||||
|
missing.push(
|
||||||
|
Tags::insert(entities::tags::ActiveModel {
|
||||||
|
name: Set(tag_name),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.exec_with_returning(&mut *t)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::warn!("Failed to find tag: {e}"))
|
||||||
|
.map_err(|_| Error::SaveTag)?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::debug!("Missing tags: {missing:?}");
|
||||||
|
missing
|
||||||
|
};
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::debug!(
|
||||||
|
"Tags to attach: {:?}",
|
||||||
|
known
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|tag| tag.id)
|
||||||
|
.chain(missing.clone().into_iter().map(|tag| tag.id))
|
||||||
|
.chain(selected_tags.clone().into_iter())
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
);
|
||||||
|
for tag_id in known
|
||||||
|
.into_iter()
|
||||||
|
.map(|tag| tag.id)
|
||||||
|
.chain(missing.into_iter().map(|tag| tag.id))
|
||||||
|
.chain(selected_tags.into_iter())
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
{
|
||||||
|
RecipeTags::insert(entities::recipe_tags::ActiveModel {
|
||||||
|
recipe_id: Set(recipe_id),
|
||||||
|
tag_id: Set(tag_id),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.exec(&mut *t)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("Failed to save RecipeTag: {e}"))
|
||||||
|
.map_err(|_| Error::SaveRecipeTag)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps
|
||||||
|
{
|
||||||
let steps = crate::utils::parse_steps(&form.steps)?
|
let steps = crate::utils::parse_steps(&form.steps)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(body, hint)| entities::recipe_steps::ActiveModel {
|
.map(|(body, hint)| entities::recipe_steps::ActiveModel {
|
||||||
@ -448,6 +533,10 @@ async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<()
|
|||||||
.await
|
.await
|
||||||
.inspect_err(|e| tracing::error!("Save steps: {e}"))
|
.inspect_err(|e| tracing::error!("Save steps: {e}"))
|
||||||
.map_err(|_| Error::SaveRecipeStep)?;
|
.map_err(|_| Error::SaveRecipeStep)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingredients
|
||||||
|
{
|
||||||
let ingredients = crate::utils::parse_ingenedients(&form.ingeredients)?;
|
let ingredients = crate::utils::parse_ingenedients(&form.ingeredients)?;
|
||||||
let known = Ingredients::find()
|
let known = Ingredients::find()
|
||||||
.filter(
|
.filter(
|
||||||
@ -511,8 +600,9 @@ async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<()
|
|||||||
.await
|
.await
|
||||||
.inspect_err(|e| tracing::error!("Save ingeredients: {e}"))
|
.inspect_err(|e| tracing::error!("Save ingeredients: {e}"))
|
||||||
.map_err(|_| Error::SaveRecipeIngeredient)?;
|
.map_err(|_| Error::SaveRecipeIngeredient)?;
|
||||||
|
};
|
||||||
|
|
||||||
Ok(())
|
Ok(recipe_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/styles.css")]
|
#[get("/styles.css")]
|
||||||
@ -526,7 +616,7 @@ async fn tmp_cleanup() {
|
|||||||
let d = tokio::time::Duration::from_secs(10);
|
let d = tokio::time::Duration::from_secs(10);
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(d).await;
|
tokio::time::sleep(d).await;
|
||||||
tracing::info!("Starting files cleanup");
|
// tracing::info!("Starting files cleanup");
|
||||||
|
|
||||||
let Ok(mut dir) = tokio::fs::read_dir("/tmp/assets/recipies/images").await else {
|
let Ok(mut dir) = tokio::fs::read_dir("/tmp/assets/recipies/images").await else {
|
||||||
tracing::info!("Files cleanup failed. No tmp images dir");
|
tracing::info!("Files cleanup failed. No tmp images dir");
|
||||||
@ -545,7 +635,7 @@ async fn tmp_cleanup() {
|
|||||||
let _ = tokio::fs::remove_file(entry.path()).await;
|
let _ = tokio::fs::remove_file(entry.path()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::info!("Files cleanup done");
|
// tracing::info!("Files cleanup done");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,6 +27,8 @@ pub enum Error {
|
|||||||
#[error("Internal server error")]
|
#[error("Internal server error")]
|
||||||
SaveRecipeIngeredient,
|
SaveRecipeIngeredient,
|
||||||
#[error("Internal server error")]
|
#[error("Internal server error")]
|
||||||
|
SaveTag,
|
||||||
|
#[error("Internal server error")]
|
||||||
SaveRecipeTag,
|
SaveRecipeTag,
|
||||||
#[error("Internal server error")]
|
#[error("Internal server error")]
|
||||||
DatabaseError,
|
DatabaseError,
|
||||||
@ -42,6 +44,7 @@ impl actix_web::error::ResponseError for Error {
|
|||||||
Self::SaveRecipeStep => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::SaveRecipeStep => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Self::SaveRecipeIngeredient => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::SaveRecipeIngeredient => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Self::SaveRecipeTag => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::SaveRecipeTag => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Self::SaveTag => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -42,6 +42,13 @@
|
|||||||
<textarea id="ingeredients" name="ingeredients" class="textarea-without-view" required>{{ingeredients}}</textarea>
|
<textarea id="ingeredients" name="ingeredients" class="textarea-without-view" required>{{ingeredients}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="w-full flex gap-4 flex-wrap">
|
||||||
|
{% for ingredient in known_ingredients %}
|
||||||
|
<label class="border rounded-lg p-2 styled-checkbox cursor-pointer">
|
||||||
|
<span>{{ ingredient.name }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<details class="w-full collapse collapse-arrow">
|
<details class="w-full collapse collapse-arrow">
|
||||||
<summary class="text-base font-semibold collapse-title">Ingeredients should be listed as list of AMOUNT UNIT INGEREDIENT. Example:</summary>
|
<summary class="text-base font-semibold collapse-title">Ingeredients should be listed as list of AMOUNT UNIT INGEREDIENT. Example:</summary>
|
||||||
@ -79,9 +86,9 @@
|
|||||||
<span>{{ tag.name }}</span>
|
<span>{{ tag.name }}</span>
|
||||||
{% match (tag.id, selected_tags.as_slice())|is_checked %}
|
{% match (tag.id, selected_tags.as_slice())|is_checked %}
|
||||||
{% when true %}
|
{% when true %}
|
||||||
<input type="checkbox" name="selected_tags[{{loop.index0}}]" value="{{tag.id}}" class="hidden" checked />
|
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" checked />
|
||||||
{% when false %}
|
{% when false %}
|
||||||
<input type="checkbox" name="selected_tags[{{loop.index0}}]" value="{{tag.id}}" class="hidden" />
|
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" />
|
||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -95,7 +102,8 @@
|
|||||||
</main>
|
</main>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
document.addEventListener("DOMContentLoaded", (event) => {
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
Array.from(document.body.querySelectorAll('textarea')).forEach(el => {
|
const body = document.body;
|
||||||
|
Array.from(body.querySelectorAll('textarea')).forEach(el => {
|
||||||
if (!el.parentElement.querySelector('.content')) return;
|
if (!el.parentElement.querySelector('.content')) return;
|
||||||
el.parentElement.querySelector('div').innerHTML = marked.parse(el.value);
|
el.parentElement.querySelector('div').innerHTML = marked.parse(el.value);
|
||||||
el.addEventListener('keyup', () => {
|
el.addEventListener('keyup', () => {
|
||||||
@ -103,8 +111,8 @@ document.addEventListener("DOMContentLoaded", (event) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const image = document.body.querySelector('input#image');
|
const image = body.querySelector('input#image');
|
||||||
const image_url = document.body.querySelector('#image_url');
|
const image_url = body.querySelector('#image_url');
|
||||||
image.addEventListener('change', () => {
|
image.addEventListener('change', () => {
|
||||||
const f = new FormData();
|
const f = new FormData();
|
||||||
f.append('image', image.files[0]);
|
f.append('image', image.files[0]);
|
||||||
@ -112,6 +120,25 @@ document.addEventListener("DOMContentLoaded", (event) => {
|
|||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(({ url }) => image_url.value = url);
|
.then(({ url }) => image_url.value = url);
|
||||||
});
|
});
|
||||||
|
const form = body.querySelector('form');
|
||||||
|
form.addEventListener('submit', ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
const p = Array.from(form.elements).reduce((memo, el) => {
|
||||||
|
const { name, value } = el;
|
||||||
|
if (name.endsWith('[]')) {
|
||||||
|
const v = memo[name.replace('[]', '')] || [];
|
||||||
|
v.push(value);
|
||||||
|
memo[name] = v;
|
||||||
|
} else {
|
||||||
|
memo[name] = value;
|
||||||
|
}
|
||||||
|
return memo;
|
||||||
|
}, {});
|
||||||
|
fetch(form.action, { method: form.method, body: JSON.stringify(p), headers: { 'Content-Type': 'application/json' } })
|
||||||
|
.then(res => this.location = res.headers.get('location'))
|
||||||
|
.catch(res => res.text().then(html => body.innerHTML = html ));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -87,7 +87,9 @@ impl MigrationTrait for Migration {
|
|||||||
.to_tbl(Recipe::Recipies)
|
.to_tbl(Recipe::Recipies)
|
||||||
.to_col(Recipe::Id)
|
.to_col(Recipe::Id)
|
||||||
.from_tbl(RecipeStep::RecipeSteps)
|
.from_tbl(RecipeStep::RecipeSteps)
|
||||||
.from_col(RecipeStep::RecipeId),
|
.from_col(RecipeStep::RecipeId)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
@ -122,14 +124,18 @@ impl MigrationTrait for Migration {
|
|||||||
.to_tbl(Recipe::Recipies)
|
.to_tbl(Recipe::Recipies)
|
||||||
.to_col(Recipe::Id)
|
.to_col(Recipe::Id)
|
||||||
.from_tbl(RecipeIngredient::RecipeIngredients)
|
.from_tbl(RecipeIngredient::RecipeIngredients)
|
||||||
.from_col(RecipeIngredient::RecipeId),
|
.from_col(RecipeIngredient::RecipeId)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.foreign_key(
|
.foreign_key(
|
||||||
&mut ForeignKeyCreateStatement::new()
|
&mut ForeignKeyCreateStatement::new()
|
||||||
.to_tbl(Ingredient::Ingredients)
|
.to_tbl(Ingredient::Ingredients)
|
||||||
.to_col(Recipe::Id)
|
.to_col(Recipe::Id)
|
||||||
.from_tbl(RecipeIngredient::RecipeIngredients)
|
.from_tbl(RecipeIngredient::RecipeIngredients)
|
||||||
.from_col(RecipeIngredient::IngredientId),
|
.from_col(RecipeIngredient::IngredientId)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
@ -154,14 +160,18 @@ impl MigrationTrait for Migration {
|
|||||||
.to_tbl(Recipe::Recipies)
|
.to_tbl(Recipe::Recipies)
|
||||||
.to_col(Recipe::Id)
|
.to_col(Recipe::Id)
|
||||||
.from_tbl(RecipeTag::RecipeTags)
|
.from_tbl(RecipeTag::RecipeTags)
|
||||||
.from_col(RecipeTag::RecipeId),
|
.from_col(RecipeTag::RecipeId)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.foreign_key(
|
.foreign_key(
|
||||||
&mut ForeignKeyCreateStatement::new()
|
&mut ForeignKeyCreateStatement::new()
|
||||||
.to_tbl(Tag::Tags)
|
.to_tbl(Tag::Tags)
|
||||||
.to_col(Tag::Id)
|
.to_col(Tag::Id)
|
||||||
.from_tbl(RecipeTag::RecipeTags)
|
.from_tbl(RecipeTag::RecipeTags)
|
||||||
.from_col(RecipeTag::TagId),
|
.from_col(RecipeTag::TagId)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user