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 askama::Template;
|
||||
use askama_actix::TemplateToResponse;
|
||||
use itertools::Itertools;
|
||||
use sea_orm::{
|
||||
prelude::*, DatabaseTransaction, JoinType, QueryOrder, QuerySelect, TransactionTrait,
|
||||
};
|
||||
@ -22,14 +23,249 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
#[template(path = "recipe_card.jinja")]
|
||||
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)]
|
||||
#[template(path = "index.jinja", ext = "html")]
|
||||
struct IndexTemplate {
|
||||
#[template(path = "ingredient.jinja", ext = "html")]
|
||||
pub struct IngredientTemplate {
|
||||
recipies: Vec<RecipeCard>,
|
||||
count: u64,
|
||||
session: Option<User>,
|
||||
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)]
|
||||
#[template(path = "search.jinja", ext = "html")]
|
||||
@ -72,10 +308,10 @@ struct ImageUpload {
|
||||
struct RecipeForm {
|
||||
title: String,
|
||||
summary: String,
|
||||
ingeredients: String,
|
||||
ingredients: String,
|
||||
steps: String,
|
||||
tags: String,
|
||||
selected_tags: Vec<String>,
|
||||
selected_tags: Option<Vec<String>>,
|
||||
image_url: String,
|
||||
time: Option<String>,
|
||||
author: Option<String>,
|
||||
@ -99,8 +335,7 @@ struct EditRecipeForm {
|
||||
ingredients: String,
|
||||
steps: String,
|
||||
tags: String,
|
||||
selected_tags: Vec<String>,
|
||||
image_url: String,
|
||||
selected_tags: Option<Vec<String>>,
|
||||
time: Option<String>,
|
||||
author: Option<String>,
|
||||
error: Option<String>,
|
||||
@ -161,10 +396,6 @@ async fn sign_in(
|
||||
.any(|admin| admin.email == payload.email && admin.pass == payload.password);
|
||||
if is_admin {
|
||||
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 =
|
||||
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>,
|
||||
admin: Option<Identity>,
|
||||
) -> HttpResponse {
|
||||
let count = (entities::prelude::Recipies::find()
|
||||
.count(&**db)
|
||||
IndexTemplate::load(&**db, q.page, admin.and_then(|session| session.id().ok()))
|
||||
.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(q.page.unwrap_or_default() as u64)
|
||||
.all(&**db)
|
||||
.to_response()
|
||||
}
|
||||
|
||||
#[get("/ingredients")]
|
||||
async fn ingredients_list(db: Data<DatabaseConnection>, admin: Option<Identity>) -> HttpResponse {
|
||||
IngredientsTemplate::load(&**db, admin.and_then(|session| session.id().ok()))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
.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()
|
||||
}
|
||||
|
||||
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()
|
||||
.append_header((CONTENT_TYPE, "text/html; charset=utf-8"))
|
||||
.append_header((CONTENT_LENGTH, html.len()))
|
||||
.body(html)
|
||||
#[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)]
|
||||
@ -330,14 +583,11 @@ async fn show(
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let ingredients = entities::prelude::Ingredients::find()
|
||||
.filter(
|
||||
entities::ingredients::Column::Id.is_in(
|
||||
recipe_ingredients
|
||||
.iter()
|
||||
.map(|i| i.ingredient_id)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
.join(
|
||||
JoinType::InnerJoin,
|
||||
entities::ingredients::Relation::RecipeIngredients.def(),
|
||||
)
|
||||
.filter(entities::recipe_ingredients::Column::RecipeId.eq(recipe.id))
|
||||
.all(db)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
@ -348,6 +598,7 @@ async fn show(
|
||||
.into_iter()
|
||||
.filter_map(|ri| {
|
||||
Some(Ingredient {
|
||||
id: ri.ingredient_id,
|
||||
name: ingredients.get(&ri.ingredient_id)?.to_owned(),
|
||||
qty: ri.qty,
|
||||
unit: ri.unit,
|
||||
@ -378,7 +629,7 @@ async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeFor
|
||||
RecipeForm {
|
||||
title: "".into(),
|
||||
summary: "".into(),
|
||||
ingeredients: "".into(),
|
||||
ingredients: "".into(),
|
||||
steps: "".into(),
|
||||
tags: "".into(),
|
||||
selected_tags: Default::default(),
|
||||
@ -413,6 +664,7 @@ async fn create_recipe(
|
||||
.map_err(|_| Error::DatabaseError)?;
|
||||
match save_recipe(form, &mut t).await {
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to save changes: {e}");
|
||||
failure.error = Some(e.to_string());
|
||||
let _ = t.rollback().await;
|
||||
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;
|
||||
|
||||
// Tags
|
||||
{
|
||||
tracing::debug!("Selected tags: {:?}", form.selected_tags);
|
||||
let selected_tags = form.selected_tags;
|
||||
create_tags(recipe_id, form.selected_tags, form.tags, &mut *t).await?;
|
||||
|
||||
// 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
|
||||
.into_iter()
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect::<Vec<i32>>();
|
||||
|
||||
let text_tags = form
|
||||
.tags
|
||||
let text_tags = tags
|
||||
.split(",")
|
||||
.map(|s| s.trim())
|
||||
.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}"))
|
||||
.map_err(|_| Error::SaveRecipeTag)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/recipe/{id}/edit")]
|
||||
@ -642,7 +952,7 @@ async fn edit_recipe(
|
||||
id: actix_web::web::Path<i32>,
|
||||
admin: Identity,
|
||||
db: Data<DatabaseConnection>,
|
||||
) -> Result<EditRecipeForm, Error> {
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let id = id.into_inner();
|
||||
let recipe = match Recipies::find()
|
||||
.filter(entities::recipies::Column::Id.eq(id))
|
||||
@ -652,13 +962,17 @@ async fn edit_recipe(
|
||||
Ok(Some(recipe)) => recipe,
|
||||
Ok(_) => todo!("index & not found"),
|
||||
Err(_err) => {
|
||||
todo!()
|
||||
return Ok(IndexTemplate::load(&**db, None, admin.id().ok())
|
||||
.await
|
||||
.with_error(_err.to_string())
|
||||
.to_response())
|
||||
}
|
||||
};
|
||||
|
||||
// steps
|
||||
let recipe_steps = RecipeSteps::find()
|
||||
.filter(entities::recipe_steps::Column::RecipeId.eq(id))
|
||||
.order_by_asc(entities::recipe_steps::Column::Id)
|
||||
.all(&**db)
|
||||
.await
|
||||
.inspect_err(|e| tracing::error!("Failed to load recipe steps for edit recipe: {e}"))
|
||||
@ -667,6 +981,7 @@ async fn edit_recipe(
|
||||
// tags
|
||||
let recipe_tags = RecipeTags::find()
|
||||
.filter(entities::recipe_tags::Column::RecipeId.eq(id))
|
||||
.order_by_asc(entities::recipe_tags::Column::Id)
|
||||
.all(&**db)
|
||||
.await
|
||||
.inspect_err(|e| tracing::error!("Failed to load recipe tags for edit recipe: {e}"))
|
||||
@ -680,6 +995,7 @@ async fn edit_recipe(
|
||||
// ingredients
|
||||
let recipe_ingredients = RecipeIngredients::find()
|
||||
.filter(entities::recipe_ingredients::Column::RecipeId.eq(id))
|
||||
.order_by_asc(entities::recipe_ingredients::Column::Id)
|
||||
.all(&**db)
|
||||
.await
|
||||
.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(),
|
||||
)
|
||||
.filter(entities::recipe_ingredients::Column::RecipeId.eq(id))
|
||||
.order_by_asc(entities::ingredients::Column::Name)
|
||||
.all(&**db)
|
||||
.await
|
||||
.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
|
||||
.iter()
|
||||
.map(|recipe| match recipe.hint {
|
||||
Some(ref hint) => format!("* {body}\n> {hint}", body = recipe.body, hint = hint),
|
||||
None => format!("* {body}", body = recipe.body),
|
||||
.map(|recipe| {
|
||||
let body = recipe.body.replace("\n", " ");
|
||||
match recipe.hint {
|
||||
Some(ref hint) => format!("* {body}\n> {hint}", hint = hint),
|
||||
None => format!("* {body}"),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
selected_tags: recipe_tags
|
||||
selected_tags: Some(
|
||||
recipe_tags
|
||||
.iter()
|
||||
.map(|rt| rt.recipe_id.to_string())
|
||||
.collect(),
|
||||
image_url: recipe.image_url,
|
||||
),
|
||||
time: recipe.time.map(|i| i.to_string()),
|
||||
author: recipe.author,
|
||||
error: None,
|
||||
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(),
|
||||
page: Page::Index,
|
||||
})
|
||||
}
|
||||
.to_response())
|
||||
}
|
||||
|
||||
#[post("/recipe/{id}/edit")]
|
||||
#[post("/recipe/{id}/update")]
|
||||
async fn update_recipe(
|
||||
_id: actix_web::web::Path<i32>,
|
||||
admin: Identity,
|
||||
db: Data<DatabaseConnection>,
|
||||
form: QsForm<RecipeForm>,
|
||||
form: QsForm<EditRecipeForm>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let mut form = form.into_inner();
|
||||
form.session = admin.id().ok();
|
||||
@ -767,7 +1093,8 @@ async fn update_recipe(
|
||||
.await
|
||||
.inspect_err(|e| tracing::error!("Create recipe transaction: {e}"))
|
||||
.map_err(|_| Error::DatabaseError)?;
|
||||
match save_recipe(form, &mut t).await {
|
||||
|
||||
match save_recipe_changes(form, &mut t).await {
|
||||
Err(e) => {
|
||||
failure.error = Some(e.to_string());
|
||||
let _ = t.rollback().await;
|
||||
@ -777,15 +1104,6 @@ async fn update_recipe(
|
||||
}
|
||||
Ok(recipe_id) => {
|
||||
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()
|
||||
.append_header(("location", format!("/recipe/{recipe_id}").as_str()))
|
||||
.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")]
|
||||
async fn styles_css() -> HttpResponse {
|
||||
HttpResponse::Ok()
|
||||
@ -843,5 +1207,9 @@ pub fn configure(config: &mut actix_web::web::ServiceConfig) {
|
||||
.service(recipe_form)
|
||||
.service(create_recipe)
|
||||
.service(recipe_image_upload)
|
||||
.service(by_ingredient)
|
||||
.service(ingredients_list)
|
||||
.service(by_tag)
|
||||
.service(tags_list)
|
||||
.service(Files::new("/assets", "./assets"));
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ pub type User = String;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Ingredient {
|
||||
pub id: i32,
|
||||
pub qty: i32,
|
||||
pub name: 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));
|
||||
}
|
||||
Some('>') => {
|
||||
v.last_mut().ok_or(InvalidStepList)?.1 =
|
||||
Some(line.replacen(">", "", 1).trim().to_string());
|
||||
v.last_mut()
|
||||
.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)
|
||||
|
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>
|
||||
</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>
|
||||
<svg
|
||||
version="1.1"
|
||||
|
@ -36,10 +36,10 @@
|
||||
<p class="text-base font-semibold">Summary supports Markdown</p>
|
||||
</blockquote>
|
||||
|
||||
<label for="ingeredients" class="flex gap-4">
|
||||
<span class="w-24 shrink-0">Ingeredients:</span>
|
||||
<label for="ingredients" class="flex gap-4">
|
||||
<span class="w-24 shrink-0">Ingredients:</span>
|
||||
<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>
|
||||
</label>
|
||||
<div class="w-full flex gap-4 flex-wrap">
|
||||
@ -84,12 +84,18 @@
|
||||
{% for tag in known_tags %}
|
||||
<label class="border rounded-lg p-2 styled-checkbox cursor-pointer">
|
||||
<span>{{ tag.name }}</span>
|
||||
|
||||
{% match selected_tags %}
|
||||
{% when Some with (selected_tags) %}
|
||||
{% match (tag.id, selected_tags.as_slice())|is_checked %}
|
||||
{% when true %}
|
||||
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" checked data-type="number[]" />
|
||||
{% when false %}
|
||||
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
|
||||
{% endmatch %}
|
||||
{% else %}
|
||||
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
|
||||
{% endmatch %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
@ -69,7 +69,7 @@
|
||||
</p>
|
||||
<div class="flex gap-7">
|
||||
{% 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 }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
@ -82,7 +82,7 @@
|
||||
</p>
|
||||
<div class="flex gap-7">
|
||||
{% 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 }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
@ -11,12 +11,19 @@
|
||||
<header class="flex flex-col gap-2">
|
||||
<h2 class="text-gray-600 text-6xl font-semibold text-center">Create recipe</h2>
|
||||
</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-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">
|
||||
<input type="hidden" name="id" value="{{id}}" id="recipe-{{id}}"/>
|
||||
<label for="image" class="flex gap-4">
|
||||
<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 for="title" class="flex gap-4">
|
||||
@ -84,14 +91,21 @@
|
||||
{% for tag in known_tags %}
|
||||
<label class="border rounded-lg p-2 styled-checkbox cursor-pointer">
|
||||
<span>{{ tag.name }}</span>
|
||||
|
||||
{% match selected_tags %}
|
||||
{% when Some with (selected_tags) %}
|
||||
{% match (tag.id, selected_tags.as_slice())|is_checked %}
|
||||
{% when true %}
|
||||
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" checked data-type="number[]" />
|
||||
{% when false %}
|
||||
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
|
||||
{% endmatch %}
|
||||
{% else %}
|
||||
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
|
||||
{% endmatch %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<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