(FIX): So so so much has been migrated.

But this includes templ builds also... Needed for compilation. Search is
the last broken piece, I believe.
This commit is contained in:
Hayden Hargreaves 2025-12-27 23:45:09 -07:00
parent ce6d748731
commit 90b3b7b1b0
32 changed files with 420 additions and 384 deletions

View File

@ -12,8 +12,9 @@ import (
// GetRecipeOfTheWeekHandler fetchs the current recipe of the week and returns it.
// If an error occurs, it will be returned and a recipe will not be returned.
//
// Until auth is reimplemented, there is no way to determine what user is making the
// BUG: Until auth is reimplemented, there is no way to determine what user is making the
// call.
// NOTE: I believe this issue has been resolved
func (s *Server) GetRecipeOfTheWeekHandlerV2(ctx *gin.Context) {
userId := getUserId(ctx)
recipe, err := s.deps.RecipeService.GetRecipeOfTheWeek(userId)
@ -93,3 +94,20 @@ func (s *Server) SearchRecipeHandlerV2(ctx *gin.Context) {
"recipes": recipes,
})
}
func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) {
recipe, err := s.deps.RecipeService.CreateRecipe(ctx)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create recipe. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created new recipe.",
"recipe": recipe,
})
}

View File

@ -204,6 +204,7 @@ func (s *Server) Setup() *Server {
router_api_v2.GET("/recipe/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeHandlerV2)
router_api_v2.GET("/recipe/of-the-week", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeOfTheWeekHandlerV2)
router_api_v2.POST("/recipe/search", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.SearchRecipeHandlerV2)
router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2)
router_api_v2.GET("/auth/login", s.GetGoogleAuthUrlHandlerV2)
router_api_v2.GET("/auth/callback", s.GoogleCallbackHandlerV2)

View File

@ -1,11 +1,7 @@
package service
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@ -45,66 +41,25 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
return nil, fmt.Errorf("User is not logged in.")
}
title := ctx.PostForm("title")
description := ctx.PostForm("description")
preparation := ctx.PostForm("preparation-time")
cook := ctx.PostForm("cook-time")
serving := ctx.PostForm("serving-size")
category := ctx.PostForm("category")
difficulty := ctx.PostForm("difficulty")
ingredients := ctx.PostFormArray("ingredients")
quantity := ctx.PostFormArray("quantity")
instructions := ctx.PostFormArray("instructions")
tags := strings.Split(ctx.PostForm("tags"), ",")
userId := ctx.MustGet("userId").(int)
var req domain.CreateRecipeRequest
// Have to get the image differently
image, err := ctx.FormFile("image")
if err != nil && !errors.Is(err, http.ErrMissingFile) {
// Error getting image
if err := ctx.ShouldBindJSON(&req); err != nil {
return nil, err
}
// Convert to proper values
servingInt, _ := strconv.Atoi(serving)
difficultyInt, _ := strconv.Atoi(difficulty)
prepInt, _ := strconv.Atoi(preparation)
cookInt, _ := strconv.Atoi(cook)
var ingredientSlice []domain.RecipeIngredient
for i := range len(ingredients) {
if strings.TrimSpace(ingredients[i]) != "" {
ins := domain.RecipeIngredient{
Name: ingredients[i],
Quantity: quantity[i],
}
ingredientSlice = append(ingredientSlice, ins)
}
}
var instructionSlice []string
for _, ins := range instructions {
if ins != "" {
instructionSlice = append(instructionSlice, ins)
}
}
// Create the recipe
recipe := domain.Recipe{
Title: title,
Description: description,
Instructions: instructionSlice,
Serves: servingInt,
Difficulty: difficultyInt,
Duration: domain.RecipeDuration{
Total: prepInt + cookInt,
Prep: prepInt,
Cook: cookInt,
},
Category: domain.RecipeMeal(category),
Ingredients: ingredientSlice,
UserId: userId,
Created: time.Now(),
Title: req.Title,
Description: req.Description,
Instructions: req.Instructions,
Serves: req.Serves,
Difficulty: req.Difficulty,
Duration: req.Duration,
Category: req.Category,
Ingredients: req.Ingredients,
Sections: req.Sections,
UserId: userId,
Created: time.Now(),
}
if err := s.recipeRepository.CreateRecipe(&recipe); err != nil {
@ -112,17 +67,96 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
}
// TODO: Upload the image
if image != nil {
}
// if req.image != nil {
// }
// Create the tags
if len(tags) > 0 {
if err := s.recipeRepository.CreateRecipeTags(recipe, tags); err != nil {
if len(req.Tags) > 0 {
if err := s.recipeRepository.CreateRecipeTags(recipe, req.Tags); err != nil {
return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error())
}
}
return &recipe, nil
// title := ctx.PostForm("title")
// description := ctx.PostForm("description")
// preparation := ctx.PostForm("preparation-time")
// cook := ctx.PostForm("cook-time")
// serving := ctx.PostForm("serving-size")
// category := ctx.PostForm("category")
// difficulty := ctx.PostForm("difficulty")
// ingredients := ctx.PostFormArray("ingredients")
// quantity := ctx.PostFormArray("quantity")
// instructions := ctx.PostFormArray("instructions")
// tags := strings.Split(ctx.PostForm("tags"), ",")
// userId := ctx.MustGet("userId").(int)
//
// // Have to get the image differently
// image, err := ctx.FormFile("image")
// if err != nil && !errors.Is(err, http.ErrMissingFile) {
// // Error getting image
// }
//
// // Convert to proper values
// servingInt, _ := strconv.Atoi(serving)
// difficultyInt, _ := strconv.Atoi(difficulty)
// prepInt, _ := strconv.Atoi(preparation)
// cookInt, _ := strconv.Atoi(cook)
//
// var ingredientSlice []domain.RecipeIngredient
// for i := range len(ingredients) {
// if strings.TrimSpace(ingredients[i]) != "" {
// ins := domain.RecipeIngredient{
// Name: ingredients[i],
// Quantity: quantity[i],
// }
//
// ingredientSlice = append(ingredientSlice, ins)
// }
// }
//
// var instructionSlice []string
// for _, ins := range instructions {
// if ins != "" {
// instructionSlice = append(instructionSlice, ins)
// }
// }
//
// // Create the recipe
// recipe := domain.Recipe{
// Title: title,
// Description: description,
// Instructions: instructionSlice,
// Serves: servingInt,
// Difficulty: difficultyInt,
// Duration: domain.RecipeDuration{
// Total: prepInt + cookInt,
// Prep: prepInt,
// Cook: cookInt,
// },
// Category: domain.RecipeMeal(category),
// Ingredients: ingredientSlice,
// UserId: userId,
// Created: time.Now(),
// }
//
// if err := s.recipeRepository.CreateRecipe(&recipe); err != nil {
// return &recipe, err
// }
//
// // TODO: Upload the image
// if image != nil {
// }
//
// // Create the tags
// if len(tags) > 0 {
// if err := s.recipeRepository.CreateRecipeTags(recipe, tags); err != nil {
// return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error())
// }
// }
//
// return &recipe, nil
}
// GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore,
@ -135,7 +169,7 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
func (s *RecipeService) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
recipe, err := s.recipeRepository.GetRecipe(id, userId)
if recipe == nil {
if recipe == nil && err == nil {
return nil, fmt.Errorf("Recipe does not exist or has been relocated. Please try again.")
}
@ -211,5 +245,14 @@ func (s *RecipeService) GetUserMadeRecipes(userId, limit int) ([]domain.Recipe,
// GetRecipeOfTheWeek searches for the most recent recipe of the week. If there is not a value,
// the recipe will be nil. Any errors will be bubbled to the caller.
func (s *RecipeService) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error) {
return s.recipeRepository.GetRecipeOfTheWeek(userId)
id, err := s.recipeRepository.GetRecipeOfTheWeekId(userId)
if err != nil {
return nil, err
}
if id == nil {
return nil, fmt.Errorf("[ERROR] Recipe of the week ID could not be found. It may not exist.")
}
return s.recipeRepository.GetRecipe(*id, userId)
}

View File

@ -46,11 +46,62 @@ func ParseMeal(meal int) RecipeMeal {
}
}
// TODO: Comment
type RecipeIngredientUnit string
const (
Blank RecipeIngredientUnit = ""
Tsp RecipeIngredientUnit = "tsp"
Tbsp RecipeIngredientUnit = "tbsp"
FlOz RecipeIngredientUnit = "fl oz"
Cup RecipeIngredientUnit = "cup"
Ml RecipeIngredientUnit = "ml"
Litre RecipeIngredientUnit = "l"
Pint RecipeIngredientUnit = "pt"
Quart RecipeIngredientUnit = "qt"
Gallon RecipeIngredientUnit = "gal"
Gram RecipeIngredientUnit = "g"
Kilogram RecipeIngredientUnit = "kg"
Ounce RecipeIngredientUnit = "oz"
Pound RecipeIngredientUnit = "lb"
Piece RecipeIngredientUnit = "piece"
Clove RecipeIngredientUnit = "clove"
Slice RecipeIngredientUnit = "slice"
Stick RecipeIngredientUnit = "stick"
Bunch RecipeIngredientUnit = "bunch"
Pinch RecipeIngredientUnit = "pinch"
Dash RecipeIngredientUnit = "dash"
Splash RecipeIngredientUnit = "splash"
ToTaste RecipeIngredientUnit = "to taste"
)
// RecipeIngredient is a single ingredients in a recipe. These have JSON tags which allow them
// to be marshaled into a JSON array and stored in the database (JSONB).
type RecipeIngredient struct {
Name string `json:"Name"`
Quantity string `json:"Quantity"`
Id string `json:"Id"`
SectionId string `json:"SectionId"`
Name string `json:"Name"`
Amount float64 `json:"Amount"`
Unit RecipeIngredientUnit `json:"Unit"`
}
// TODO: Comment
type RecipeInstruction struct {
Id string `json:"Id"`
Content string `json:"Content"`
}
// TODO: Comment
type RecipeIngredientSection struct {
Id string `json:"Id"`
Name string `json:"Name"`
}
// RecipeIngredientStore is the struct stored in the database Ingredients column. It is simply a
// combindation of the sections and the ingredients so they can be stored together.
type RecipeIngredientStore struct {
Sections []RecipeIngredientSection `json:"Sections"`
Ingredients []RecipeIngredient `json:"Ingredients"`
}
// Recipe is the database model of a recipe. There is no need to map to a different model so
@ -61,12 +112,13 @@ type Recipe struct {
Id int
Title string
Description string
Instructions []string
Instructions []RecipeInstruction
Serves int
Difficulty int
Duration RecipeDuration
Category RecipeMeal
Ingredients []RecipeIngredient // Just a list of ingredients
Ingredients []RecipeIngredient
Sections []RecipeIngredientSection
UserId int
Modified *time.Time // Pointer to allow null
Created time.Time
@ -79,11 +131,11 @@ type Recipe struct {
// details can be found in the SearchRecipes service function.
type SearchFilters struct {
Search string `json:"Search"`
MealType int `json:"MealType"`
Time int `json:"Time"`
Difficulty int `json:"Difficulty"`
ServingSize int `json:"ServingSize"`
Favorites bool `json:"Favorites"`
MealType int `json:"MealType"`
Time int `json:"Time"`
Difficulty int `json:"Difficulty"`
ServingSize int `json:"ServingSize"`
Favorites bool `json:"Favorites"`
}
// Tag is a model which represents a single tag in the Tags table. A tag is mapped to a recipe
@ -102,3 +154,17 @@ type RecipeTag struct {
TagId int
Created time.Time
}
// TODO: Comment
type CreateRecipeRequest struct {
Title string
Description string
Instructions []RecipeInstruction
Serves int
Difficulty int
Duration RecipeDuration
Category RecipeMeal
Ingredients []RecipeIngredient
Sections []RecipeIngredientSection
Tags []string
}

View File

@ -10,5 +10,5 @@ type RecipeRepository interface {
GetUserFavoriteRecipes(id int) ([]Recipe, error)
GetRecipeTags(recipe *Recipe) error
GetRecipeFavorite(recipe *Recipe, userId int) error
GetRecipeOfTheWeek(userId *int) (*Recipe, error)
GetRecipeOfTheWeekId(userId *int) (*int, error)
}

View File

@ -46,7 +46,9 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
// NOTE: Data steps
// cast duration to JSON
// cast ingredients to JSON
// convert ingredients to store type
// cast store type to JSON
// extract string instructions from type
// cast category to string
// use nil for the modified time
@ -55,17 +57,27 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
return err
}
ingredientsJSON, err := json.Marshal(recipe.Ingredients)
ingredientsStore := domain.RecipeIngredientStore{
Sections: recipe.Sections,
Ingredients: recipe.Ingredients,
}
ingredientsJSON, err := json.Marshal(ingredientsStore)
if err != nil {
return err
}
instructions := make([]string, len(recipe.Instructions))
for i, instruction := range recipe.Instructions {
instructions[i] = instruction.Content
}
var id int
if err = tx.QueryRow(
query,
recipe.Title,
recipe.Description,
pq.Array(recipe.Instructions),
pq.Array(instructions),
recipe.Serves,
recipe.Difficulty,
durationJSON,
@ -94,8 +106,7 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
// for added safety. The repository will not check for a nil result, instead the service will. Callers
// are responsible for protecting against double nil results. Any errors will be bubbled to the caller.
func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
query := `
SELECT
query := ` SELECT
id, title, description, instructions, serves, difficulty, duration, category, ingredients,
userid, modified, created
FROM recipes
@ -103,6 +114,7 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
`
var durationBytes []byte
var instructions pq.StringArray
var ingredientBytes []byte
var recipe domain.Recipe
@ -110,7 +122,8 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
// pq.Array(&instructions),
&instructions,
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
@ -137,16 +150,23 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
var store domain.RecipeIngredientStore
if err := json.Unmarshal(ingredientBytes, &store); err != nil {
// Check for unmarshal to support backwards compatability
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
recipe.Ingredients = store.Ingredients
recipe.Sections = store.Sections
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add instructions
for _, instruction := range instructions {
recipe.Instructions = append(recipe.Instructions, domain.RecipeInstruction{Content: instruction})
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
@ -169,83 +189,18 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
// will. Callers are responsible for protecting against double nil results. Any errors will be bubbled
// to the caller.
func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) {
query := `
SELECT
id, title, description, instructions, serves, difficulty, duration, category, ingredients, userid, modified, created
FROM recipes
WHERE id = ANY($1)
ORDER BY array_position($1, id);
`
var recipes []domain.Recipe
rows, err := r.db.Query(query, pq.Array(ids))
if err != nil {
return nil, fmt.Errorf("Failed to get recipes. %s", err.Error())
}
defer rows.Close()
for rows.Next() {
var recipe domain.Recipe
var durationBytes []byte
var ingredientBytes []byte
if err := rows.Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("Failed to scan recipe from database: %s", err.Error())
for _, id := range ids {
recipe, err := r.GetRecipe(id, userId)
if err != nil {
return nil, err
}
// Parse duration
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error())
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
// Skip any un-found recipes...?
if recipe != nil {
recipes = append(recipes, *recipe)
}
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Get favorite status, if user id is provided
if userId != nil {
if err := r.GetRecipeFavorite(&recipe, *userId); err != nil {
fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
}
} else {
recipe.Favorite = false
}
recipes = append(recipes, recipe)
}
return recipes, nil
@ -264,6 +219,8 @@ func isBitActive(bits, pos int) bool {
// The favorites parameter is used to only return filters favorited by the userId provided.
//
// TODO: Pagination is required, to provide infinite scroll.
//
// TODO: This does not work in the current build, the DB does not return valid values.
func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]domain.Recipe, error) {
// Compute meals type filters (there are 7 bits)
var mealConditions []string
@ -569,10 +526,11 @@ func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string)
// GetUserRecipes gets a list of a users owned recipes. This function does not ensure the user is
// authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
//
// TODO: This should just return the IDs
func (r *RecipeRepository) GetUserRecipes(id int) ([]domain.Recipe, error) {
query := `
SELECT id, title, description, instructions, serves, difficulty, duration, category, ingredients,
userid, modified, created
SELECT id
FROM recipes
WHERE userid = $1
ORDER BY created DESC;
@ -584,69 +542,21 @@ func (r *RecipeRepository) GetUserRecipes(id int) ([]domain.Recipe, error) {
}
defer rows.Close()
// Prepare statement for tag query
// tagQuery := `
// `
var recipes []domain.Recipe
for rows.Next() {
var recipe domain.Recipe
var durationBytes []byte
var ingredientBytes []byte
// Scan results from recipe query onto recipe object
if err := rows.Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("Failed to scan row onto recipe object. %s\n", err.Error())
var r_id int
if err := rows.Scan(&r_id); err != nil {
return []domain.Recipe{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
}
// Parse duration
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error())
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
recipe, err := r.GetRecipe(r_id, &id)
if err != nil {
return []domain.Recipe{}, err
}
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
if recipe != nil {
recipes = append(recipes, *recipe)
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Get favorite status
if err := r.GetRecipeFavorite(&recipe, id); err != nil {
fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
}
recipes = append(recipes, recipe)
}
return recipes, nil
@ -655,10 +565,11 @@ func (r *RecipeRepository) GetUserRecipes(id int) ([]domain.Recipe, error) {
// GetUserRecipes gets a list of a users favorited recipes. This function does not ensure the user is
// authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
//
// TODO: This should just return the IDs
func (r *RecipeRepository) GetUserFavoriteRecipes(id int) ([]domain.Recipe, error) {
query := `
SELECT r.id, r.title, r.description, r.instructions, r.serves, r.difficulty, r.duration, r.category, r.ingredients, r.
userid, r.modified, r.created
SELECT r.id
FROM favorites f
JOIN recipes r ON r.id = f.recipeid
WHERE f.userid = $1
@ -672,61 +583,19 @@ func (r *RecipeRepository) GetUserFavoriteRecipes(id int) ([]domain.Recipe, erro
var recipes []domain.Recipe
for rows.Next() {
var recipe domain.Recipe
var durationBytes []byte
var ingredientBytes []byte
// Scan results from recipe query onto recipe object
if err := rows.Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("Failed to scan row onto recipe object. %s\n", err.Error())
var r_id int
if err := rows.Scan(&r_id); err != nil {
return []domain.Recipe{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
}
// Parse duration
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error())
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
recipe, err := r.GetRecipe(r_id, &id)
if err != nil {
return []domain.Recipe{}, err
}
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
if recipe != nil {
recipes = append(recipes, *recipe)
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Set favorite status (they're always true!)
recipe.Favorite = true
recipes = append(recipes, recipe)
}
return recipes, nil
@ -793,82 +662,27 @@ func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int)
return nil
}
// GetRecipeOfTheWeek searches for the most recent recipe of the week. If there is not a value,
// GetRecipeOfTheWeekId searches for the most recent recipe of the week. If there is not a value,
// the recipe will be nil. This function simply collects the most recent entry in the recipeoftheweek
// table and return it. If there is no entry, nil will be returned Any errors will be bubbled to
// the caller.
func (r *RecipeRepository) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error) {
// table and return it. If there is no entry, nil will be returned. Any errors will be bubbled to
// the caller. All that is returned is the recipe ID, that way the caller can handle the fetching.
func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
query := `
SELECT
r.id, r.title, r.description, r.instructions, r.serves, r.difficulty, r.duration, r.category,
r.ingredients, r.userid, r.modified, r.created
r.id
FROM recipes r
JOIN recipeoftheweek rw ON rw.recipeid = r.id
ORDER BY created DESC
ORDER BY rw.created DESC
LIMIT 1;
`
var durationBytes []byte
var ingredientBytes []byte
var recipe domain.Recipe
if err := r.db.QueryRow(query).Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
var id int
if err := r.db.QueryRow(query).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error())
}
// Parse duration
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error())
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
}
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Get favorite status, if user id is provided
if userId != nil {
if err := r.GetRecipeFavorite(&recipe, *userId); err != nil {
fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
}
} else {
recipe.Favorite = false
}
return &recipe, nil
return &id, nil
}

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -112,7 +112,7 @@ templ ingredientList(ingredients []domain.RecipeIngredient) {
<hr class="text-gray-300"/>
<ul class="text-lg my-4 text-gray-700">
for _, ingredient := range ingredients {
@ingredientListItem(ingredient.Name, ingredient.Quantity)
@ingredientListItem(ingredient.Name, "")
}
</ul>
</div>
@ -308,7 +308,7 @@ templ RecipePage(recipe domain.Recipe, user domainUser.User, loggedIn bool, doma
<p class="text-gray-700">{ recipe.Description }</p>
</div>
@ingredientList(recipe.Ingredients)
@instructionList(recipe.Instructions)
@instructionList([]string{})
@tagList(recipe.Tags, recipe.Created, recipe.Modified)
</div>
</div>

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
@ -268,7 +268,7 @@ func ingredientList(ingredients []domain.RecipeIngredient) templ.Component {
return templ_7745c5c3_Err
}
for _, ingredient := range ingredients {
templ_7745c5c3_Err = ingredientListItem(ingredient.Name, ingredient.Quantity).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = ingredientListItem(ingredient.Name, "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -875,7 +875,7 @@ func RecipePage(recipe domain.Recipe, user domainUser.User, loggedIn bool, domai
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = instructionList(recipe.Instructions).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = instructionList([]string{}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View File

@ -31,7 +31,7 @@ export default function IngredientItem({ classes, ingredient, onChange, removeIn
className="select-none p-2 flex gap-2 flex-col"
>
<div className="flex gap-2">
<div className="flex-col md:flex-row flex-grow flex gap-2">
<div className="flex-col md:flex-row flex-grow flex gap-2 flex-wrap">
<div className="flex gap-2">
<input
type="number"

View File

@ -29,8 +29,8 @@ export default function IngredientSection({ section, onChange, removeIngredientS
type="text"
value={section.Name}
onChange={(e) => onChange(section.Id, e.target.value)}
placeholder="Section title"
className="mx-2 px-2 py-1 border border-gray-300 flex-grow rounded-sm"
placeholder="Group label"
className="mx-2 px-2 py-1 border border-gray-300 flex-grow rounded-sm min-w-1"
/>
<div className="flex gap-x-2 items-center">

View File

@ -1,33 +1,46 @@
import type { RecipeIngredient } from "../../types/recipe";
import { Fragment } from "react/jsx-runtime";
import type { RecipeIngredient, RecipeIngredientSection } from "../../types/recipe";
interface IngredientListProps {
sections: RecipeIngredientSection[];
ingredients: RecipeIngredient[];
}
export default function IngredientList({ ingredients }: IngredientListProps) {
export default function IngredientList({ sections, ingredients }: IngredientListProps) {
return (
<>
<div className="px-4 py-8 md:px-8">
<h2 className="text-2xl text-gray-800 font-semibold mb-2">Ingredients</h2>
<hr className="text-gray-300" />
<ul className="text-lg my-4 text-gray-700">
{ingredients?.map(ingredient => (
<li key={ingredient.Name} className="p-2 hover:bg-gray-100 transition-all duration-300 rounded-sm flex items-center justify-start odd:bg-[#f8f8f8]">
<span className="mr-4">
<svg className="h-4 text-gray-400" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
</span>
<span className="font-semibold mr-2">{ingredient.Amount}: </span> {ingredient.Name}
</li>
))}
</ul>
{sections?.map(section => (
<Fragment key={section.Id}>
{/* NOTE: If there is a only one section, do not display a name. */}
{sections.length > 1 && (
<h3 className="text-xl text-gray-800 font-semibold my-4">{section.Name}</h3>
)}
<ul className="text-lg my-2 text-gray-700">
{ingredients?.filter(x => x.SectionId === section.Id).map(ingredient => (
<li key={ingredient.Id} className="p-2 hover:bg-gray-100 transition-all duration-300 rounded-sm flex items-center justify-start odd:bg-[#f8f8f8]">
<span className="mr-4">
<svg className="h-4 text-gray-400" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
</span>
<span className="font-semibold mr-2">
{ingredient.Amount > 0 ? ingredient.Amount : null} {ingredient.Unit}
</span>
{ingredient.Name}
</li>
))}
</ul>
</Fragment>
))}
</div>
</>
);

View File

@ -1,5 +1,7 @@
import type { RecipeInstruction } from "../../types/recipe";
interface InstructionListProps {
instructions: string[];
instructions: RecipeInstruction[];
}
export default function InstructionList({ instructions }: InstructionListProps) {
return (
@ -9,11 +11,11 @@ export default function InstructionList({ instructions }: InstructionListProps)
<hr className="text-gray-300" />
<ul className="text-lg my-4 text-gray-700">
{instructions?.map((instruction, i) => (
<li key={instruction} className="p-4 flex items-start gap-x-4 odd:bg-[#f8f8f8]">
<li key={instruction.Id || crypto.randomUUID()} className="p-4 flex items-start gap-x-4 odd:bg-[#f8f8f8]">
<div className="size-8 md:size-10 bg-blue-50 rounded-full flex items-center justify-center flex-shrink-0">
<h3 className="text-base md:text-xl text-blue-600 font-semibold">{i + 1}</h3>
</div>
<p className="text-base">{instruction}</p>
<p className="text-base">{instruction.Content}</p>
</li>
))}
</ul>

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import Banner from "../components/Banner";
import { type RecipeInstruction } from "../types/recipe";
import { isRecipeMeal, type RecipeInstruction } from "../types/recipe";
import InstructionList from "../components/forms/InstructionList";
import ValidationErrorList from "../components/forms/ValidationErrorList";
import IngredientSection from "../components/forms/IngredientSection";
@ -13,6 +13,10 @@ import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrappe
import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput";
import { useIngredients } from "../hooks/useIngredients";
import { validateCreateRecipeForm } from "../hooks/validation";
import { CreateRecipe } from "../services/RecipeService";
import type { CreateRecipeRequest } from "../types/api/recipe";
import { isApiError } from "../types/api/error";
import { useNavigate } from "react-router-dom";
// TODO: Move these
export interface RecipeValidationEntry {
@ -118,6 +122,45 @@ export default function Create() {
instructions: {},
});
const navigate = useNavigate();
// Functions
const createRecipe = async (): Promise<void> => {
console.log({ title, description, tags, prepTime, cookTime, servingSize, category, difficulty, sections, ingredients, instructions });
// Exit if not valid recipe meal
if (!isRecipeMeal(category)) {
console.error("[ERROR] Recipe meal is invalid.");
return;
}
const recipe: CreateRecipeRequest = {
Title: title,
Description: description,
Instructions: instructions,
Serves: Number(servingSize),
Difficulty: Number(difficulty),
Duration: {
Prep: Number(prepTime),
Cook: Number(cookTime),
Total: Number(prepTime) + Number(cookTime)
},
Category: category,
Ingredients: ingredients,
Sections: sections,
Tags: tags,
};
const response = await CreateRecipe(recipe);
if (isApiError(response)) {
console.error(response);
return;
}
// TODO: Success toast!
await navigate(`/web/recipe/${response.Id}`);
};
// Import ingredients
const {
sections,
@ -201,6 +244,8 @@ export default function Create() {
});
return;
}
void createRecipe();
}
@ -381,7 +426,7 @@ export default function Create() {
<RecipeCreateFormWrapper
label="Ingredients"
name="ingredients"
desc="Please provide a list of ingredients and their quantities."
desc="Please provide a list of ingredients and their quantities. Ingredients can be grouped together by item using the group headers. If only a single group is defined, the name will be ignored and they will be displayed without a heading."
required
parentClasses="my-4"
>

View File

@ -75,7 +75,7 @@ export default function RecipePage() {
<h3 className="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3>
<p className="text-gray-700">{recipe.Description}</p>
</div>
<IngredientList ingredients={recipe.Ingredients} />
<IngredientList sections={recipe.Sections} ingredients={recipe.Ingredients} />
<InstructionList instructions={recipe.Instructions} />
<TagList tags={recipe.Tags} created={recipe.Created} modified={recipe.Modified} />
</>

View File

@ -1,5 +1,5 @@
import axios from "axios";
import type { GetRecipeOfTheWeekResponse, GetRecipeResponse, SearchRecipesResponse } from "../types/api/recipe";
import type { CreateRecipeRequest, CreateRecipeResponse, GetRecipeOfTheWeekResponse, GetRecipeResponse, SearchRecipesResponse } from "../types/api/recipe";
import type { Recipe } from "../types/recipe";
import type { ApiError } from "../types/api/error";
import type { SearchFilters } from "../types/search";
@ -46,3 +46,17 @@ export async function SearchRecipes(filters: SearchFilters): Promise<Recipe[] |
return response.data.recipes;
}
export async function CreateRecipe(data: CreateRecipeRequest): Promise<Recipe | ApiError> {
const response = await axios.post<CreateRecipeResponse>("http://localhost:3000/v2/api/recipe", data);
if (response.status !== 200 || response.data.recipe === undefined) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return response.data.recipe;
}

View File

@ -1,4 +1,4 @@
import type { Recipe } from "../recipe";
import type { Recipe, RecipeDuration, RecipeIngredient, RecipeIngredientSection, RecipeInstruction, RecipeMeal } from "../recipe";
export interface GetRecipeOfTheWeekResponse {
status: number;
@ -17,3 +17,22 @@ export interface SearchRecipesResponse {
message: string;
recipes?: Recipe[];
}
export interface CreateRecipeResponse {
status: number;
message: string;
recipe?: Recipe;
}
export interface CreateRecipeRequest {
Title: string;
Description: string;
Instructions: RecipeInstruction[];
Serves: number;
Difficulty: number;
Duration: RecipeDuration;
Category: RecipeMeal;
Ingredients: RecipeIngredient[];
Sections: RecipeIngredientSection[];
Tags: string[];
}

View File

@ -81,12 +81,13 @@ export interface Recipe {
Id: number;
Title: string;
Description: string;
Instructions: string[];
Instructions: RecipeInstruction[];
Serves: number;
Difficulty: number;
Duration: RecipeDuration;
Category: RecipeMeal;
Ingredients: RecipeIngredient[];
Sections: RecipeIngredientSection[];
UserId: number;
Modified: Date;
Created: Date;