Add all relations & update recipe
This commit is contained in:
parent
af558d47a6
commit
1031e461e8
@ -10,6 +10,7 @@ use actix_web::HttpMessage;
|
|||||||
use actix_web::{get, post, HttpRequest, HttpResponse, Responder};
|
use actix_web::{get, post, HttpRequest, HttpResponse, Responder};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use askama_actix::TemplateToResponse;
|
use askama_actix::TemplateToResponse;
|
||||||
|
use itertools::Itertools;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
prelude::*, DatabaseTransaction, JoinType, QueryOrder, QuerySelect, TransactionTrait,
|
prelude::*, DatabaseTransaction, JoinType, QueryOrder, QuerySelect, TransactionTrait,
|
||||||
};
|
};
|
||||||
@ -22,14 +23,249 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||||||
#[template(path = "recipe_card.jinja")]
|
#[template(path = "recipe_card.jinja")]
|
||||||
struct RecipeCard(entities::recipies::Model);
|
struct RecipeCard(entities::recipies::Model);
|
||||||
|
|
||||||
|
#[derive(Debug, Template, derive_more::Deref)]
|
||||||
|
#[template(path = "ingredient_card.jinja")]
|
||||||
|
struct IngredientCard(crate::types::ingredient_with_recipe_count::Model);
|
||||||
|
|
||||||
|
#[derive(Debug, Template, derive_more::Deref)]
|
||||||
|
#[template(path = "tag_card.jinja")]
|
||||||
|
struct TagCard(crate::types::tag_with_recipe_count::Model);
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
#[template(path = "index.jinja", ext = "html")]
|
#[template(path = "ingredient.jinja", ext = "html")]
|
||||||
struct IndexTemplate {
|
pub struct IngredientTemplate {
|
||||||
recipies: Vec<RecipeCard>,
|
recipies: Vec<RecipeCard>,
|
||||||
count: u64,
|
count: u64,
|
||||||
session: Option<User>,
|
session: Option<User>,
|
||||||
page: Page,
|
page: Page,
|
||||||
}
|
}
|
||||||
|
impl IngredientTemplate {
|
||||||
|
pub async fn load(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
ingredient_id: u32,
|
||||||
|
page: Option<u16>,
|
||||||
|
session: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
let count = (entities::prelude::Recipies::find()
|
||||||
|
.join(
|
||||||
|
JoinType::InnerJoin,
|
||||||
|
entities::recipies::Relation::RecipeIngredients.def(),
|
||||||
|
)
|
||||||
|
.filter(entities::recipe_ingredients::Column::IngredientId.eq(ingredient_id))
|
||||||
|
.count(&*db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default() as f64
|
||||||
|
/ 20.0)
|
||||||
|
.ceil() as u64;
|
||||||
|
let recipies = entities::prelude::Recipies::find()
|
||||||
|
.join(
|
||||||
|
JoinType::InnerJoin,
|
||||||
|
entities::recipies::Relation::RecipeIngredients.def(),
|
||||||
|
)
|
||||||
|
.filter(entities::recipe_ingredients::Column::IngredientId.eq(ingredient_id))
|
||||||
|
.order_by_desc(entities::recipies::Column::Title)
|
||||||
|
.limit(20)
|
||||||
|
.offset(page.unwrap_or_default() as u64)
|
||||||
|
.all(&*db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
recipies: recipies.into_iter().map(RecipeCard).collect(),
|
||||||
|
count,
|
||||||
|
session,
|
||||||
|
page: Page::Index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Template)]
|
||||||
|
#[template(path = "ingredients.jinja", ext = "html")]
|
||||||
|
pub struct IngredientsTemplate {
|
||||||
|
session: Option<User>,
|
||||||
|
page: Page,
|
||||||
|
ingredients: Vec<IngredientCard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IngredientsTemplate {
|
||||||
|
pub async fn load(db: &DatabaseConnection, session: Option<String>) -> Self {
|
||||||
|
let rel = || {
|
||||||
|
Ingredients::find()
|
||||||
|
.join(
|
||||||
|
JoinType::Join,
|
||||||
|
entities::ingredients::Relation::RecipeIngredients.def(),
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
JoinType::Join,
|
||||||
|
entities::recipe_ingredients::Relation::Recipies.def(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let ingredients = rel()
|
||||||
|
.select_only()
|
||||||
|
.column(entities::ingredients::Column::Id)
|
||||||
|
.column(entities::ingredients::Column::Name)
|
||||||
|
.column_as(
|
||||||
|
entities::recipe_ingredients::Column::RecipeId.count(),
|
||||||
|
"recipe_count",
|
||||||
|
)
|
||||||
|
.order_by_asc(entities::ingredients::Column::Name)
|
||||||
|
.group_by(entities::ingredients::Column::Id)
|
||||||
|
.into_model::<crate::types::ingredient_with_recipe_count::Model>()
|
||||||
|
.all(&*db)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("Failed to load ingredients: {e}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(IngredientCard)
|
||||||
|
.collect();
|
||||||
|
Self {
|
||||||
|
ingredients,
|
||||||
|
session,
|
||||||
|
page: Page::Index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Template)]
|
||||||
|
#[template(path = "tags.jinja", ext = "html")]
|
||||||
|
pub struct TagsTemplate {
|
||||||
|
session: Option<User>,
|
||||||
|
page: Page,
|
||||||
|
tags: Vec<TagCard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TagsTemplate {
|
||||||
|
pub async fn load(db: &DatabaseConnection, session: Option<String>) -> Self {
|
||||||
|
let rel = || {
|
||||||
|
Tags::find()
|
||||||
|
.join(JoinType::Join, entities::tags::Relation::RecipeTags.def())
|
||||||
|
.join(
|
||||||
|
JoinType::Join,
|
||||||
|
entities::recipe_tags::Relation::Recipies.def(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let tags = rel()
|
||||||
|
.select_only()
|
||||||
|
.column(entities::tags::Column::Id)
|
||||||
|
.column(entities::tags::Column::Name)
|
||||||
|
.column_as(
|
||||||
|
entities::recipe_tags::Column::RecipeId.count(),
|
||||||
|
"recipe_count",
|
||||||
|
)
|
||||||
|
.order_by_asc(entities::tags::Column::Name)
|
||||||
|
.group_by(entities::tags::Column::Id)
|
||||||
|
.into_model::<crate::types::tag_with_recipe_count::Model>()
|
||||||
|
.all(&*db)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("Failed to load ingredients: {e}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(TagCard)
|
||||||
|
.collect();
|
||||||
|
Self {
|
||||||
|
tags,
|
||||||
|
session,
|
||||||
|
page: Page::Index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
#[derive(Debug, Template)]
|
||||||
|
#[template(path = "tag.jinja", ext = "html")]
|
||||||
|
pub struct TagTemplate {
|
||||||
|
recipies: Vec<RecipeCard>,
|
||||||
|
count: u64,
|
||||||
|
session: Option<User>,
|
||||||
|
page: Page,
|
||||||
|
}
|
||||||
|
impl TagTemplate {
|
||||||
|
pub async fn load(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
ingredient_id: u32,
|
||||||
|
page: Option<u16>,
|
||||||
|
session: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
let count = (entities::prelude::Recipies::find()
|
||||||
|
.join(
|
||||||
|
JoinType::InnerJoin,
|
||||||
|
entities::recipies::Relation::RecipeTags.def(),
|
||||||
|
)
|
||||||
|
.filter(entities::recipe_tags::Column::TagId.eq(ingredient_id))
|
||||||
|
.count(&*db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default() as f64
|
||||||
|
/ 20.0)
|
||||||
|
.ceil() as u64;
|
||||||
|
let recipies = entities::prelude::Recipies::find()
|
||||||
|
.join(
|
||||||
|
JoinType::InnerJoin,
|
||||||
|
entities::recipies::Relation::RecipeTags.def(),
|
||||||
|
)
|
||||||
|
.filter(entities::recipe_tags::Column::TagId.eq(ingredient_id))
|
||||||
|
.order_by_desc(entities::recipies::Column::Title)
|
||||||
|
.limit(20)
|
||||||
|
.offset(page.unwrap_or_default() as u64)
|
||||||
|
.all(&*db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
recipies: recipies.into_iter().map(RecipeCard).collect(),
|
||||||
|
count,
|
||||||
|
session,
|
||||||
|
page: Page::Index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
#[derive(Debug, Template)]
|
||||||
|
#[template(path = "index.jinja", ext = "html")]
|
||||||
|
pub struct IndexTemplate {
|
||||||
|
recipies: Vec<RecipeCard>,
|
||||||
|
count: u64,
|
||||||
|
session: Option<User>,
|
||||||
|
page: Page,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IndexTemplate {
|
||||||
|
pub async fn load(db: &DatabaseConnection, page: Option<u16>, session: Option<String>) -> Self {
|
||||||
|
let count = (entities::prelude::Recipies::find()
|
||||||
|
.count(&*db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default() as f64
|
||||||
|
/ 20.0)
|
||||||
|
.ceil() as u64;
|
||||||
|
let recipies = entities::prelude::Recipies::find()
|
||||||
|
.order_by_desc(entities::recipies::Column::Title)
|
||||||
|
.limit(20)
|
||||||
|
.offset(page.unwrap_or_default() as u64)
|
||||||
|
.all(&*db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
recipies: recipies.into_iter().map(RecipeCard).collect(),
|
||||||
|
count,
|
||||||
|
session,
|
||||||
|
page: Page::Index,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_error(mut self, error: String) -> Self {
|
||||||
|
self.error = Some(error);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
#[template(path = "search.jinja", ext = "html")]
|
#[template(path = "search.jinja", ext = "html")]
|
||||||
@ -72,10 +308,10 @@ struct ImageUpload {
|
|||||||
struct RecipeForm {
|
struct RecipeForm {
|
||||||
title: String,
|
title: String,
|
||||||
summary: String,
|
summary: String,
|
||||||
ingeredients: String,
|
ingredients: String,
|
||||||
steps: String,
|
steps: String,
|
||||||
tags: String,
|
tags: String,
|
||||||
selected_tags: Vec<String>,
|
selected_tags: Option<Vec<String>>,
|
||||||
image_url: String,
|
image_url: String,
|
||||||
time: Option<String>,
|
time: Option<String>,
|
||||||
author: Option<String>,
|
author: Option<String>,
|
||||||
@ -99,8 +335,7 @@ struct EditRecipeForm {
|
|||||||
ingredients: String,
|
ingredients: String,
|
||||||
steps: String,
|
steps: String,
|
||||||
tags: String,
|
tags: String,
|
||||||
selected_tags: Vec<String>,
|
selected_tags: Option<Vec<String>>,
|
||||||
image_url: String,
|
|
||||||
time: Option<String>,
|
time: Option<String>,
|
||||||
author: Option<String>,
|
author: Option<String>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
@ -161,10 +396,6 @@ async fn sign_in(
|
|||||||
.any(|admin| admin.email == payload.email && admin.pass == payload.password);
|
.any(|admin| admin.email == payload.email && admin.pass == payload.password);
|
||||||
if is_admin {
|
if is_admin {
|
||||||
tracing::info!("Valid credentials");
|
tracing::info!("Valid credentials");
|
||||||
// let res = session
|
|
||||||
// .insert(SESSION_KEY, payload.email.clone())
|
|
||||||
// .inspect_err(|e| tracing::error!("Failed to save session: {e}"));
|
|
||||||
// tracing::debug!("Saving session res: {res:?}");
|
|
||||||
let _s =
|
let _s =
|
||||||
Identity::login(&req.extensions(), payload.email).expect("Failed to store session");
|
Identity::login(&req.extensions(), payload.email).expect("Failed to store session");
|
||||||
|
|
||||||
@ -256,33 +487,55 @@ async fn index_html(
|
|||||||
q: actix_web::web::Query<Padding>,
|
q: actix_web::web::Query<Padding>,
|
||||||
admin: Option<Identity>,
|
admin: Option<Identity>,
|
||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
let count = (entities::prelude::Recipies::find()
|
IndexTemplate::load(&**db, q.page, admin.and_then(|session| session.id().ok()))
|
||||||
.count(&**db)
|
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default() as f64
|
.to_response()
|
||||||
/ 20.0)
|
|
||||||
.ceil() as u64;
|
|
||||||
let recipies = entities::prelude::Recipies::find()
|
|
||||||
.order_by_desc(entities::recipies::Column::Title)
|
|
||||||
.limit(20)
|
|
||||||
.offset(q.page.unwrap_or_default() as u64)
|
|
||||||
.all(&**db)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let html = IndexTemplate {
|
|
||||||
recipies: recipies.into_iter().map(RecipeCard).collect(),
|
|
||||||
count,
|
|
||||||
session: admin.and_then(|s| s.id().ok()),
|
|
||||||
page: Page::Index,
|
|
||||||
}
|
}
|
||||||
.render()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
HttpResponse::Ok()
|
#[get("/ingredients")]
|
||||||
.append_header((CONTENT_TYPE, "text/html; charset=utf-8"))
|
async fn ingredients_list(db: Data<DatabaseConnection>, admin: Option<Identity>) -> HttpResponse {
|
||||||
.append_header((CONTENT_LENGTH, html.len()))
|
IngredientsTemplate::load(&**db, admin.and_then(|session| session.id().ok()))
|
||||||
.body(html)
|
.await
|
||||||
|
.to_response()
|
||||||
|
}
|
||||||
|
#[get("/tags")]
|
||||||
|
async fn tags_list(db: Data<DatabaseConnection>, admin: Option<Identity>) -> HttpResponse {
|
||||||
|
TagsTemplate::load(&**db, admin.and_then(|session| session.id().ok()))
|
||||||
|
.await
|
||||||
|
.to_response()
|
||||||
|
}
|
||||||
|
#[get("/tags/{ingredient}")]
|
||||||
|
async fn by_tag(
|
||||||
|
db: Data<DatabaseConnection>,
|
||||||
|
q: actix_web::web::Query<Padding>,
|
||||||
|
admin: Option<Identity>,
|
||||||
|
path: Path<u32>,
|
||||||
|
) -> HttpResponse {
|
||||||
|
TagTemplate::load(
|
||||||
|
&**db,
|
||||||
|
path.into_inner(),
|
||||||
|
q.page,
|
||||||
|
admin.and_then(|session| session.id().ok()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.to_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/ingredients/{ingredient}")]
|
||||||
|
async fn by_ingredient(
|
||||||
|
db: Data<DatabaseConnection>,
|
||||||
|
q: actix_web::web::Query<Padding>,
|
||||||
|
admin: Option<Identity>,
|
||||||
|
path: Path<u32>,
|
||||||
|
) -> HttpResponse {
|
||||||
|
IngredientTemplate::load(
|
||||||
|
&**db,
|
||||||
|
path.into_inner(),
|
||||||
|
q.page,
|
||||||
|
admin.and_then(|session| session.id().ok()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.to_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@ -330,14 +583,11 @@ async fn show(
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let ingredients = entities::prelude::Ingredients::find()
|
let ingredients = entities::prelude::Ingredients::find()
|
||||||
.filter(
|
.join(
|
||||||
entities::ingredients::Column::Id.is_in(
|
JoinType::InnerJoin,
|
||||||
recipe_ingredients
|
entities::ingredients::Relation::RecipeIngredients.def(),
|
||||||
.iter()
|
|
||||||
.map(|i| i.ingredient_id)
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
.filter(entities::recipe_ingredients::Column::RecipeId.eq(recipe.id))
|
||||||
.all(db)
|
.all(db)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@ -348,6 +598,7 @@ async fn show(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|ri| {
|
.filter_map(|ri| {
|
||||||
Some(Ingredient {
|
Some(Ingredient {
|
||||||
|
id: ri.ingredient_id,
|
||||||
name: ingredients.get(&ri.ingredient_id)?.to_owned(),
|
name: ingredients.get(&ri.ingredient_id)?.to_owned(),
|
||||||
qty: ri.qty,
|
qty: ri.qty,
|
||||||
unit: ri.unit,
|
unit: ri.unit,
|
||||||
@ -378,7 +629,7 @@ async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeFor
|
|||||||
RecipeForm {
|
RecipeForm {
|
||||||
title: "".into(),
|
title: "".into(),
|
||||||
summary: "".into(),
|
summary: "".into(),
|
||||||
ingeredients: "".into(),
|
ingredients: "".into(),
|
||||||
steps: "".into(),
|
steps: "".into(),
|
||||||
tags: "".into(),
|
tags: "".into(),
|
||||||
selected_tags: Default::default(),
|
selected_tags: Default::default(),
|
||||||
@ -413,6 +664,7 @@ async fn create_recipe(
|
|||||||
.map_err(|_| Error::DatabaseError)?;
|
.map_err(|_| Error::DatabaseError)?;
|
||||||
match save_recipe(form, &mut t).await {
|
match save_recipe(form, &mut t).await {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to save changes: {e}");
|
||||||
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();
|
||||||
@ -470,16 +722,161 @@ async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<i3
|
|||||||
.last_insert_id;
|
.last_insert_id;
|
||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
{
|
create_tags(recipe_id, form.selected_tags, form.tags, &mut *t).await?;
|
||||||
tracing::debug!("Selected tags: {:?}", form.selected_tags);
|
|
||||||
let selected_tags = form.selected_tags;
|
// Steps
|
||||||
|
create_steps(recipe_id, form.steps, &mut *t).await?;
|
||||||
|
|
||||||
|
// Ingredients
|
||||||
|
create_ingredients(recipe_id, form.ingredients, &mut *t).await?;
|
||||||
|
|
||||||
|
Ok(recipe_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_ingredients(
|
||||||
|
recipe_id: i32,
|
||||||
|
ingredients: String,
|
||||||
|
t: &mut DatabaseTransaction,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
use sea_orm::ActiveValue::*;
|
||||||
|
|
||||||
|
let ingredients = crate::utils::parse_ingredients(&ingredients)?;
|
||||||
|
let known = Ingredients::find()
|
||||||
|
.filter(
|
||||||
|
entities::ingredients::Column::Name.is_in(
|
||||||
|
ingredients
|
||||||
|
.iter()
|
||||||
|
.map(|(name, ..)| name.to_string())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all(&mut *t)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::warn!("Failed to find ingredients: {e}"))
|
||||||
|
.map_err(|_| Error::SaveRecipeIngeredient)?;
|
||||||
|
let missing = {
|
||||||
|
let known = known
|
||||||
|
.iter()
|
||||||
|
.map(|model| &model.name)
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let missing = ingredients
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, ..)| !known.contains(name))
|
||||||
|
.map(|(name, ..)| entities::ingredients::ActiveModel {
|
||||||
|
name: Set(name.to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut v = Vec::with_capacity(missing.len());
|
||||||
|
for missing in missing {
|
||||||
|
v.push(
|
||||||
|
Ingredients::insert(missing)
|
||||||
|
.exec_with_returning(&mut *t)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("Failed to create ingredients: {e}"))
|
||||||
|
.map_err(|_| Error::DatabaseError)?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let map = known
|
||||||
|
.into_iter()
|
||||||
|
.chain(missing.into_iter())
|
||||||
|
.map(|row| (row.name, row.id))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
|
||||||
|
RecipeIngredients::insert_many(
|
||||||
|
ingredients
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(name, unit, qty)| {
|
||||||
|
Some(entities::recipe_ingredients::ActiveModel {
|
||||||
|
ingredient_id: Set(*map.get(&name)?),
|
||||||
|
qty: Set(qty),
|
||||||
|
unit: Set(unit.unwrap_or_default()),
|
||||||
|
recipe_id: Set(recipe_id),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.exec(&mut *t)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("Save ingeredients: {e}"))
|
||||||
|
.map_err(|_| Error::SaveRecipeIngeredient)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_steps(
|
||||||
|
recipe_id: i32,
|
||||||
|
steps: String,
|
||||||
|
t: &mut DatabaseTransaction,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
use sea_orm::ActiveValue::*;
|
||||||
|
|
||||||
|
let steps = crate::utils::parse_steps(&steps)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(body, hint)| entities::recipe_steps::ActiveModel {
|
||||||
|
body: Set(body),
|
||||||
|
hint: Set(hint),
|
||||||
|
recipe_id: Set(recipe_id),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let _steps = RecipeSteps::insert_many(steps)
|
||||||
|
.exec(&mut *t)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("Save steps: {e}"))
|
||||||
|
.map_err(|_| Error::SaveRecipeStep)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_steps(recipe_id: i32, t: &mut DatabaseTransaction) -> Result<(), Error> {
|
||||||
|
RecipeSteps::delete_many()
|
||||||
|
.filter(entities::recipe_steps::Column::RecipeId.eq(recipe_id))
|
||||||
|
.exec(&mut *t)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("Delete steps: {e}"))
|
||||||
|
.map_err(|_| Error::SaveRecipeStep)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_tags(recipe_id: i32, t: &mut DatabaseTransaction) -> Result<(), Error> {
|
||||||
|
RecipeTags::delete_many()
|
||||||
|
.filter(entities::recipe_tags::Column::RecipeId.eq(recipe_id))
|
||||||
|
.exec(&mut *t)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("Delete tags: {e}"))
|
||||||
|
.map_err(|_| Error::SaveRecipeTag)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_ingredients(recipe_id: i32, t: &mut DatabaseTransaction) -> Result<(), Error> {
|
||||||
|
RecipeIngredients::delete_many()
|
||||||
|
.filter(entities::recipe_ingredients::Column::RecipeId.eq(recipe_id))
|
||||||
|
.exec(&mut *t)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("Delete ingeredients: {e}"))
|
||||||
|
.map_err(|_| Error::SaveRecipeIngeredient)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_tags(
|
||||||
|
recipe_id: i32,
|
||||||
|
selected_tags: Option<Vec<String>>,
|
||||||
|
tags: String,
|
||||||
|
t: &mut DatabaseTransaction,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
use sea_orm::ActiveValue::*;
|
||||||
|
|
||||||
|
tracing::debug!("Selected tags: {:?}", selected_tags);
|
||||||
|
let selected_tags = selected_tags.unwrap_or_default();
|
||||||
let selected_tags = selected_tags
|
let selected_tags = selected_tags
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|s| s.parse().ok())
|
.filter_map(|s| s.parse().ok())
|
||||||
.collect::<Vec<i32>>();
|
.collect::<Vec<i32>>();
|
||||||
|
|
||||||
let text_tags = form
|
let text_tags = tags
|
||||||
.tags
|
|
||||||
.split(",")
|
.split(",")
|
||||||
.map(|s| s.trim())
|
.map(|s| s.trim())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@ -547,94 +944,7 @@ async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<i3
|
|||||||
.inspect_err(|e| tracing::error!("Failed to save RecipeTag: {e}"))
|
.inspect_err(|e| tracing::error!("Failed to save RecipeTag: {e}"))
|
||||||
.map_err(|_| Error::SaveRecipeTag)?;
|
.map_err(|_| Error::SaveRecipeTag)?;
|
||||||
}
|
}
|
||||||
}
|
Ok(())
|
||||||
|
|
||||||
// Steps
|
|
||||||
{
|
|
||||||
let steps = crate::utils::parse_steps(&form.steps)?
|
|
||||||
.into_iter()
|
|
||||||
.map(|(body, hint)| entities::recipe_steps::ActiveModel {
|
|
||||||
body: Set(body),
|
|
||||||
hint: Set(hint),
|
|
||||||
recipe_id: Set(recipe_id),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let _steps = RecipeSteps::insert_many(steps)
|
|
||||||
.exec(&mut *t)
|
|
||||||
.await
|
|
||||||
.inspect_err(|e| tracing::error!("Save steps: {e}"))
|
|
||||||
.map_err(|_| Error::SaveRecipeStep)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ingredients
|
|
||||||
{
|
|
||||||
let ingredients = crate::utils::parse_ingredients(&form.ingeredients)?;
|
|
||||||
let known = Ingredients::find()
|
|
||||||
.filter(
|
|
||||||
entities::ingredients::Column::Name.is_in(
|
|
||||||
ingredients
|
|
||||||
.iter()
|
|
||||||
.map(|(name, ..)| name.to_string())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.all(&mut *t)
|
|
||||||
.await
|
|
||||||
.inspect_err(|e| tracing::warn!("Failed to find ingredients: {e}"))
|
|
||||||
.map_err(|_| Error::SaveRecipeIngeredient)?;
|
|
||||||
let missing = {
|
|
||||||
let known = known
|
|
||||||
.iter()
|
|
||||||
.map(|model| &model.name)
|
|
||||||
.collect::<BTreeSet<_>>();
|
|
||||||
let missing = ingredients
|
|
||||||
.iter()
|
|
||||||
.filter(|(name, ..)| !known.contains(name))
|
|
||||||
.map(|(name, ..)| entities::ingredients::ActiveModel {
|
|
||||||
name: Set(name.to_owned()),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let mut v = Vec::with_capacity(missing.len());
|
|
||||||
for missing in missing {
|
|
||||||
v.push(
|
|
||||||
Ingredients::insert(missing)
|
|
||||||
.exec_with_returning(&mut *t)
|
|
||||||
.await
|
|
||||||
.inspect_err(|e| tracing::error!("Failed to create ingredients: {e}"))
|
|
||||||
.map_err(|_| Error::DatabaseError)?,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
v
|
|
||||||
};
|
|
||||||
let map = known
|
|
||||||
.into_iter()
|
|
||||||
.chain(missing.into_iter())
|
|
||||||
.map(|row| (row.name, row.id))
|
|
||||||
.collect::<BTreeMap<_, _>>();
|
|
||||||
|
|
||||||
RecipeIngredients::insert_many(
|
|
||||||
ingredients
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|(name, unit, qty)| {
|
|
||||||
Some(entities::recipe_ingredients::ActiveModel {
|
|
||||||
ingredient_id: Set(*map.get(&name)?),
|
|
||||||
qty: Set(qty),
|
|
||||||
unit: Set(unit.unwrap_or_default()),
|
|
||||||
recipe_id: Set(recipe_id),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
)
|
|
||||||
.exec(&mut *t)
|
|
||||||
.await
|
|
||||||
.inspect_err(|e| tracing::error!("Save ingeredients: {e}"))
|
|
||||||
.map_err(|_| Error::SaveRecipeIngeredient)?;
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(recipe_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/recipe/{id}/edit")]
|
#[get("/recipe/{id}/edit")]
|
||||||
@ -642,7 +952,7 @@ async fn edit_recipe(
|
|||||||
id: actix_web::web::Path<i32>,
|
id: actix_web::web::Path<i32>,
|
||||||
admin: Identity,
|
admin: Identity,
|
||||||
db: Data<DatabaseConnection>,
|
db: Data<DatabaseConnection>,
|
||||||
) -> Result<EditRecipeForm, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
let id = id.into_inner();
|
let id = id.into_inner();
|
||||||
let recipe = match Recipies::find()
|
let recipe = match Recipies::find()
|
||||||
.filter(entities::recipies::Column::Id.eq(id))
|
.filter(entities::recipies::Column::Id.eq(id))
|
||||||
@ -652,13 +962,17 @@ async fn edit_recipe(
|
|||||||
Ok(Some(recipe)) => recipe,
|
Ok(Some(recipe)) => recipe,
|
||||||
Ok(_) => todo!("index & not found"),
|
Ok(_) => todo!("index & not found"),
|
||||||
Err(_err) => {
|
Err(_err) => {
|
||||||
todo!()
|
return Ok(IndexTemplate::load(&**db, None, admin.id().ok())
|
||||||
|
.await
|
||||||
|
.with_error(_err.to_string())
|
||||||
|
.to_response())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// steps
|
// steps
|
||||||
let recipe_steps = RecipeSteps::find()
|
let recipe_steps = RecipeSteps::find()
|
||||||
.filter(entities::recipe_steps::Column::RecipeId.eq(id))
|
.filter(entities::recipe_steps::Column::RecipeId.eq(id))
|
||||||
|
.order_by_asc(entities::recipe_steps::Column::Id)
|
||||||
.all(&**db)
|
.all(&**db)
|
||||||
.await
|
.await
|
||||||
.inspect_err(|e| tracing::error!("Failed to load recipe steps for edit recipe: {e}"))
|
.inspect_err(|e| tracing::error!("Failed to load recipe steps for edit recipe: {e}"))
|
||||||
@ -667,6 +981,7 @@ async fn edit_recipe(
|
|||||||
// tags
|
// tags
|
||||||
let recipe_tags = RecipeTags::find()
|
let recipe_tags = RecipeTags::find()
|
||||||
.filter(entities::recipe_tags::Column::RecipeId.eq(id))
|
.filter(entities::recipe_tags::Column::RecipeId.eq(id))
|
||||||
|
.order_by_asc(entities::recipe_tags::Column::Id)
|
||||||
.all(&**db)
|
.all(&**db)
|
||||||
.await
|
.await
|
||||||
.inspect_err(|e| tracing::error!("Failed to load recipe tags for edit recipe: {e}"))
|
.inspect_err(|e| tracing::error!("Failed to load recipe tags for edit recipe: {e}"))
|
||||||
@ -680,6 +995,7 @@ async fn edit_recipe(
|
|||||||
// ingredients
|
// ingredients
|
||||||
let recipe_ingredients = RecipeIngredients::find()
|
let recipe_ingredients = RecipeIngredients::find()
|
||||||
.filter(entities::recipe_ingredients::Column::RecipeId.eq(id))
|
.filter(entities::recipe_ingredients::Column::RecipeId.eq(id))
|
||||||
|
.order_by_asc(entities::recipe_ingredients::Column::Id)
|
||||||
.all(&**db)
|
.all(&**db)
|
||||||
.await
|
.await
|
||||||
.inspect_err(|e| tracing::error!("Failed to load recipe ingredients for edit recipe: {e}"))
|
.inspect_err(|e| tracing::error!("Failed to load recipe ingredients for edit recipe: {e}"))
|
||||||
@ -690,6 +1006,7 @@ async fn edit_recipe(
|
|||||||
entities::ingredients::Relation::RecipeIngredients.def(),
|
entities::ingredients::Relation::RecipeIngredients.def(),
|
||||||
)
|
)
|
||||||
.filter(entities::recipe_ingredients::Column::RecipeId.eq(id))
|
.filter(entities::recipe_ingredients::Column::RecipeId.eq(id))
|
||||||
|
.order_by_asc(entities::ingredients::Column::Name)
|
||||||
.all(&**db)
|
.all(&**db)
|
||||||
.await
|
.await
|
||||||
.inspect_err(|e| tracing::error!("Failed to load ingredients for edit recipe: {e}"))
|
.inspect_err(|e| tracing::error!("Failed to load ingredients for edit recipe: {e}"))
|
||||||
@ -705,9 +1022,12 @@ async fn edit_recipe(
|
|||||||
});
|
});
|
||||||
let steps = recipe_steps
|
let steps = recipe_steps
|
||||||
.iter()
|
.iter()
|
||||||
.map(|recipe| match recipe.hint {
|
.map(|recipe| {
|
||||||
Some(ref hint) => format!("* {body}\n> {hint}", body = recipe.body, hint = hint),
|
let body = recipe.body.replace("\n", " ");
|
||||||
None => format!("* {body}", body = recipe.body),
|
match recipe.hint {
|
||||||
|
Some(ref hint) => format!("* {body}\n> {hint}", hint = hint),
|
||||||
|
None => format!("* {body}"),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
@ -734,27 +1054,33 @@ async fn edit_recipe(
|
|||||||
.filter_map(|recipe_tag| Some(tags_by_id.get(&recipe_tag.tag_id)?.name.clone()))
|
.filter_map(|recipe_tag| Some(tags_by_id.get(&recipe_tag.tag_id)?.name.clone()))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(","),
|
.join(","),
|
||||||
selected_tags: recipe_tags
|
selected_tags: Some(
|
||||||
|
recipe_tags
|
||||||
.iter()
|
.iter()
|
||||||
.map(|rt| rt.recipe_id.to_string())
|
.map(|rt| rt.recipe_id.to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
image_url: recipe.image_url,
|
),
|
||||||
time: recipe.time.map(|i| i.to_string()),
|
time: recipe.time.map(|i| i.to_string()),
|
||||||
author: recipe.author,
|
author: recipe.author,
|
||||||
error: None,
|
error: None,
|
||||||
known_tags: tags.clone(),
|
known_tags: tags.clone(),
|
||||||
known_ingredients: ingredients.values().cloned().collect(),
|
known_ingredients: ingredients
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.sorted_by(|a, b| a.name.cmp(&b.name))
|
||||||
|
.collect(),
|
||||||
session: admin.id().ok(),
|
session: admin.id().ok(),
|
||||||
page: Page::Index,
|
page: Page::Index,
|
||||||
})
|
}
|
||||||
|
.to_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/recipe/{id}/edit")]
|
#[post("/recipe/{id}/update")]
|
||||||
async fn update_recipe(
|
async fn update_recipe(
|
||||||
_id: actix_web::web::Path<i32>,
|
_id: actix_web::web::Path<i32>,
|
||||||
admin: Identity,
|
admin: Identity,
|
||||||
db: Data<DatabaseConnection>,
|
db: Data<DatabaseConnection>,
|
||||||
form: QsForm<RecipeForm>,
|
form: QsForm<EditRecipeForm>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
let mut form = form.into_inner();
|
let mut form = form.into_inner();
|
||||||
form.session = admin.id().ok();
|
form.session = admin.id().ok();
|
||||||
@ -767,7 +1093,8 @@ async fn update_recipe(
|
|||||||
.await
|
.await
|
||||||
.inspect_err(|e| tracing::error!("Create recipe transaction: {e}"))
|
.inspect_err(|e| tracing::error!("Create recipe transaction: {e}"))
|
||||||
.map_err(|_| Error::DatabaseError)?;
|
.map_err(|_| Error::DatabaseError)?;
|
||||||
match save_recipe(form, &mut t).await {
|
|
||||||
|
match save_recipe_changes(form, &mut t).await {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
failure.error = Some(e.to_string());
|
failure.error = Some(e.to_string());
|
||||||
let _ = t.rollback().await;
|
let _ = t.rollback().await;
|
||||||
@ -777,15 +1104,6 @@ async fn update_recipe(
|
|||||||
}
|
}
|
||||||
Ok(recipe_id) => {
|
Ok(recipe_id) => {
|
||||||
let _ = t.commit().await;
|
let _ = t.commit().await;
|
||||||
let Ok(_) = tokio::fs::copy(
|
|
||||||
format!("/tmp{}", failure.image_url),
|
|
||||||
format!(".{}", failure.image_url),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.inspect_err(|e| tracing::error!("Failed to copy {}: {e}", failure.image_url)) else {
|
|
||||||
tracing::error!("Failed to copy file: {}", failure.image_url);
|
|
||||||
return Ok(HttpResponse::InternalServerError().finish());
|
|
||||||
};
|
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.append_header(("location", format!("/recipe/{recipe_id}").as_str()))
|
.append_header(("location", format!("/recipe/{recipe_id}").as_str()))
|
||||||
.finish())
|
.finish())
|
||||||
@ -793,6 +1111,52 @@ async fn update_recipe(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn save_recipe_changes(
|
||||||
|
form: EditRecipeForm,
|
||||||
|
t: &mut DatabaseTransaction,
|
||||||
|
) -> Result<i32, Error> {
|
||||||
|
use crate::entities::recipies::ActiveModel as RAM;
|
||||||
|
use sea_orm::ActiveValue::*;
|
||||||
|
|
||||||
|
let mut summary = String::new();
|
||||||
|
let parser = pulldown_cmark::Parser::new(&form.summary);
|
||||||
|
pulldown_cmark::html::push_html(&mut summary, parser);
|
||||||
|
|
||||||
|
let time = match form.time {
|
||||||
|
Some(s) => Some(
|
||||||
|
humantime::parse_duration(&s)
|
||||||
|
.inspect_err(|e| tracing::warn!("Invalid duration format: {e}"))
|
||||||
|
.map_err(|_| Error::InvalidTime)?
|
||||||
|
.as_secs() as i32,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
delete_steps(form.id, &mut *t).await?;
|
||||||
|
create_steps(form.id, form.steps, &mut *t).await?;
|
||||||
|
|
||||||
|
delete_tags(form.id, &mut *t).await?;
|
||||||
|
create_tags(form.id, form.selected_tags, form.tags, &mut *t).await?;
|
||||||
|
|
||||||
|
delete_ingredients(form.id, &mut *t).await?;
|
||||||
|
create_ingredients(form.id, form.ingredients, &mut *t).await?;
|
||||||
|
|
||||||
|
Recipies::update(RAM {
|
||||||
|
id: Set(form.id),
|
||||||
|
title: Set(form.title),
|
||||||
|
author: Set(form.author),
|
||||||
|
time: Set(time),
|
||||||
|
summary: Set(Some(summary)),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.exec(&mut *t)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("Failed to save Recipe: {e}"))
|
||||||
|
.map_err(|_| Error::SaveRecipe)?;
|
||||||
|
|
||||||
|
Ok(form.id)
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/styles.css")]
|
#[get("/styles.css")]
|
||||||
async fn styles_css() -> HttpResponse {
|
async fn styles_css() -> HttpResponse {
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
@ -843,5 +1207,9 @@ pub fn configure(config: &mut actix_web::web::ServiceConfig) {
|
|||||||
.service(recipe_form)
|
.service(recipe_form)
|
||||||
.service(create_recipe)
|
.service(create_recipe)
|
||||||
.service(recipe_image_upload)
|
.service(recipe_image_upload)
|
||||||
|
.service(by_ingredient)
|
||||||
|
.service(ingredients_list)
|
||||||
|
.service(by_tag)
|
||||||
|
.service(tags_list)
|
||||||
.service(Files::new("/assets", "./assets"));
|
.service(Files::new("/assets", "./assets"));
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ pub type User = String;
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Ingredient {
|
pub struct Ingredient {
|
||||||
|
pub id: i32,
|
||||||
pub qty: i32,
|
pub qty: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub unit: String,
|
pub unit: String,
|
||||||
@ -89,3 +90,38 @@ impl FromStr for Admins {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod ingredient_with_recipe_count {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "ingredients")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub recipe_count: Option<i64>,
|
||||||
|
}
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
pub mod tag_with_recipe_count {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "tags")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub recipe_count: Option<i64>,
|
||||||
|
}
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
@ -12,10 +12,16 @@ pub fn parse_steps(s: &str) -> Result<Vec<(String, Option<String>)>, Error> {
|
|||||||
v.push((line.replacen("*", "", 1).trim().to_string(), None));
|
v.push((line.replacen("*", "", 1).trim().to_string(), None));
|
||||||
}
|
}
|
||||||
Some('>') => {
|
Some('>') => {
|
||||||
v.last_mut().ok_or(InvalidStepList)?.1 =
|
v.last_mut()
|
||||||
Some(line.replacen(">", "", 1).trim().to_string());
|
.ok_or(InvalidStepList)
|
||||||
|
.inspect_err(|_| tracing::warn!("Hint must be part of step."))?
|
||||||
|
.1 = Some(line.replacen(">", "", 1).trim().to_string());
|
||||||
|
}
|
||||||
|
None => return Ok(v),
|
||||||
|
char => {
|
||||||
|
tracing::warn!("Invalid step starting character: {char:?}");
|
||||||
|
return Err(InvalidStepList);
|
||||||
}
|
}
|
||||||
_ => return Err(InvalidStepList),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(v)
|
Ok(v)
|
||||||
|
28
cooked/templates/ingredient.jinja
Normal file
28
cooked/templates/ingredient.jinja
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="md:col-span-3 flex flex-col gap-4 m-4">
|
||||||
|
{{ TopBar::new(session)|safe }}
|
||||||
|
|
||||||
|
<header class="flex flex-col gap-2">
|
||||||
|
<h2 class="text-gray-600 text-6xl font-semibold text-center">Recipes</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<div class="flex flex-wrap gap-8 justify-center">
|
||||||
|
{% for recipe in recipies %}
|
||||||
|
{{ recipe|safe }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 flex-wrap">
|
||||||
|
{% match count %}
|
||||||
|
{% when 0 %}
|
||||||
|
{% when _ %}
|
||||||
|
{% for page in 0..count %}
|
||||||
|
<a class="btn" href="/?page={{page}}">{{page}}</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% endmatch %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
12
cooked/templates/ingredient_card.jinja
Normal file
12
cooked/templates/ingredient_card.jinja
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<a class="card hover:shadow-lg transition ease-linear transform hover:scale-105 shrink-0 grow-0 w-full md:w-[225px] lg:w-[400px]" href="/ingredients/{{id}}">
|
||||||
|
<div class="m-4">
|
||||||
|
<span class="font-bold">{{name}}</span>
|
||||||
|
<div>
|
||||||
|
{% match recipe_count %}
|
||||||
|
{% when Some with (n) %}
|
||||||
|
{{n}} recipies
|
||||||
|
{% else %}
|
||||||
|
{% endmatch %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
19
cooked/templates/ingredients.jinja
Normal file
19
cooked/templates/ingredients.jinja
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="md:col-span-3 flex flex-col gap-4 m-4">
|
||||||
|
{{ TopBar::new(session)|safe }}
|
||||||
|
|
||||||
|
<header class="flex flex-col gap-2">
|
||||||
|
<h2 class="text-gray-600 text-6xl font-semibold text-center">Ingredients</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<div class="flex flex-wrap gap-8 justify-center">
|
||||||
|
{% for ingredient in ingredients %}
|
||||||
|
{{ ingredient|safe }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
@ -48,7 +48,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/ingeredients" class="px-4 flex justify-end hover:shadow-md {{(page, Page::Search)|page_class}} gap-2">
|
<a href="/ingredients" class="px-4 flex justify-end hover:shadow-md {{(page, Page::Search)|page_class}} gap-2">
|
||||||
<span>Ingeredients</span>
|
<span>Ingeredients</span>
|
||||||
<svg
|
<svg
|
||||||
version="1.1"
|
version="1.1"
|
||||||
|
@ -36,10 +36,10 @@
|
|||||||
<p class="text-base font-semibold">Summary supports Markdown</p>
|
<p class="text-base font-semibold">Summary supports Markdown</p>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
|
|
||||||
<label for="ingeredients" class="flex gap-4">
|
<label for="ingredients" class="flex gap-4">
|
||||||
<span class="w-24 shrink-0">Ingeredients:</span>
|
<span class="w-24 shrink-0">Ingredients:</span>
|
||||||
<div class="flex gap-2 w-full">
|
<div class="flex gap-2 w-full">
|
||||||
<textarea id="ingeredients" name="ingeredients" class="textarea-without-view" required>{{ingeredients}}</textarea>
|
<textarea id="ingredients" name="ingredients" class="textarea-without-view" required>{{ingredients}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<div class="w-full flex gap-4 flex-wrap">
|
<div class="w-full flex gap-4 flex-wrap">
|
||||||
@ -84,12 +84,18 @@
|
|||||||
{% for tag in known_tags %}
|
{% for tag in known_tags %}
|
||||||
<label class="border rounded-lg p-2 styled-checkbox cursor-pointer">
|
<label class="border rounded-lg p-2 styled-checkbox cursor-pointer">
|
||||||
<span>{{ tag.name }}</span>
|
<span>{{ tag.name }}</span>
|
||||||
|
|
||||||
|
{% match selected_tags %}
|
||||||
|
{% when Some with (selected_tags) %}
|
||||||
{% 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[]" value="{{tag.id}}" class="hidden" checked data-type="number[]" />
|
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" checked data-type="number[]" />
|
||||||
{% when false %}
|
{% when false %}
|
||||||
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
|
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
|
||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
|
{% else %}
|
||||||
|
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
|
||||||
|
{% endmatch %}
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -69,7 +69,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="flex gap-7">
|
<div class="flex gap-7">
|
||||||
{% for tag in tags %}
|
{% for tag in tags %}
|
||||||
<a class="bg-gray-300 dark:bg-gray-700 text-base text-gray-700 dark:text-white py-2 px-4 rounded-full font-bold hover:bg-gray-400 dark:hover:bg-gray-600 flex justify-center items-center">
|
<a class="bg-gray-300 dark:bg-gray-700 text-base text-gray-700 dark:text-white py-2 px-4 rounded-full font-bold hover:bg-gray-400 dark:hover:bg-gray-600 flex justify-center items-center" href="/tags/{{tag.id}}">
|
||||||
{{ tag.name }}
|
{{ tag.name }}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -82,7 +82,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="flex gap-7">
|
<div class="flex gap-7">
|
||||||
{% for ingredient in ingredients %}
|
{% for ingredient in ingredients %}
|
||||||
<a class="bg-gray-300 dark:bg-gray-700 text-base text-gray-700 dark:text-white py-2 px-4 rounded-full font-bold hover:bg-gray-400 dark:hover:bg-gray-600 flex justify-center items-center">
|
<a class="bg-gray-300 dark:bg-gray-700 text-base text-gray-700 dark:text-white py-2 px-4 rounded-full font-bold hover:bg-gray-400 dark:hover:bg-gray-600 flex justify-center items-center" href="/ingredients/{{ingredient.id}}">
|
||||||
{{ ingredient.name }}
|
{{ ingredient.name }}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -11,12 +11,19 @@
|
|||||||
<header class="flex flex-col gap-2">
|
<header class="flex flex-col gap-2">
|
||||||
<h2 class="text-gray-600 text-6xl font-semibold text-center">Create recipe</h2>
|
<h2 class="text-gray-600 text-6xl font-semibold text-center">Create recipe</h2>
|
||||||
</header>
|
</header>
|
||||||
|
<div class="error">
|
||||||
|
{% match error %}
|
||||||
|
{% when Some with (text) %}
|
||||||
|
<div>{{text}}</div>
|
||||||
|
{% else %}
|
||||||
|
{% endmatch %}
|
||||||
|
</div>
|
||||||
<div class="flex flex-col gap-8">
|
<div class="flex flex-col gap-8">
|
||||||
<div class="flex flex-wrap gap-8 justify-center">
|
<div class="flex flex-wrap gap-8 justify-center">
|
||||||
<form action="/recipe/{{id}}/update" method="post" class="flex flex-col gap-4 md:w-1/2 md:mb-10">
|
<form action="/recipe/{{id}}/update" method="post" class="flex flex-col gap-4 md:w-1/2 md:mb-10">
|
||||||
|
<input type="hidden" name="id" value="{{id}}" id="recipe-{{id}}"/>
|
||||||
<label for="image" class="flex gap-4">
|
<label for="image" class="flex gap-4">
|
||||||
<input type="file" class="file-input w-full max-w-xs" name="image" id="image" />
|
<input type="file" class="file-input w-full max-w-xs" name="image" id="image" />
|
||||||
<input name="image_url" id='image_url' class="hidden" required />
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label for="title" class="flex gap-4">
|
<label for="title" class="flex gap-4">
|
||||||
@ -84,14 +91,21 @@
|
|||||||
{% for tag in known_tags %}
|
{% for tag in known_tags %}
|
||||||
<label class="border rounded-lg p-2 styled-checkbox cursor-pointer">
|
<label class="border rounded-lg p-2 styled-checkbox cursor-pointer">
|
||||||
<span>{{ tag.name }}</span>
|
<span>{{ tag.name }}</span>
|
||||||
|
|
||||||
|
{% match selected_tags %}
|
||||||
|
{% when Some with (selected_tags) %}
|
||||||
{% 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[]" value="{{tag.id}}" class="hidden" checked data-type="number[]" />
|
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" checked data-type="number[]" />
|
||||||
{% when false %}
|
{% when false %}
|
||||||
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
|
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
|
||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
|
{% else %}
|
||||||
|
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
|
||||||
|
{% endmatch %}
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<input type="submit" class="btn w-full" value="Save" />
|
<input type="submit" class="btn w-full" value="Save" />
|
||||||
|
28
cooked/templates/tag.jinja
Normal file
28
cooked/templates/tag.jinja
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="md:col-span-3 flex flex-col gap-4 m-4">
|
||||||
|
{{ TopBar::new(session)|safe }}
|
||||||
|
|
||||||
|
<header class="flex flex-col gap-2">
|
||||||
|
<h2 class="text-gray-600 text-6xl font-semibold text-center">Recipes</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<div class="flex flex-wrap gap-8 justify-center">
|
||||||
|
{% for recipe in recipies %}
|
||||||
|
{{ recipe|safe }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 flex-wrap">
|
||||||
|
{% match count %}
|
||||||
|
{% when 0 %}
|
||||||
|
{% when _ %}
|
||||||
|
{% for page in 0..count %}
|
||||||
|
<a class="btn" href="/?page={{page}}">{{page}}</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% endmatch %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
12
cooked/templates/tag_card.jinja
Normal file
12
cooked/templates/tag_card.jinja
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<a class="card hover:shadow-lg transition ease-linear transform hover:scale-105 shrink-0 grow-0 w-full md:w-[225px] lg:w-[400px]" href="/tags/{{id}}">
|
||||||
|
<div class="m-4">
|
||||||
|
<span class="font-bold">{{name}}</span>
|
||||||
|
<div>
|
||||||
|
{% match recipe_count %}
|
||||||
|
{% when Some with (n) %}
|
||||||
|
{{n}} recipies
|
||||||
|
{% else %}
|
||||||
|
{% endmatch %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
19
cooked/templates/tags.jinja
Normal file
19
cooked/templates/tags.jinja
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="md:col-span-3 flex flex-col gap-4 m-4">
|
||||||
|
{{ TopBar::new(session)|safe }}
|
||||||
|
|
||||||
|
<header class="flex flex-col gap-2">
|
||||||
|
<h2 class="text-gray-600 text-6xl font-semibold text-center">Tags</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<div class="flex flex-wrap gap-8 justify-center">
|
||||||
|
{% for tag in tags %}
|
||||||
|
{{ tag|safe }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
Loading…
Reference in New Issue
Block a user