(FEAT): Deletion is implemented!
This should be a nice test! Hopefully other users cannot delete my recipes. Though they're setup to soft delete.
This commit is contained in:
parent
396ca08381
commit
b8c9fc469e
@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe"
|
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe"
|
||||||
|
domainUser "github.com/haydenhargreaves/Potion/internal/domain/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetRecipeOfTheWeekHandler fetchs the current recipe of the week and returns it.
|
// GetRecipeOfTheWeekHandler fetchs the current recipe of the week and returns it.
|
||||||
@ -129,3 +130,70 @@ func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) {
|
|||||||
"recipe": recipe,
|
"recipe": recipe,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) DeleteRecipeHandlerV2(ctx *gin.Context) {
|
||||||
|
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domainUser.User) {
|
||||||
|
id := ctx.Param("id")
|
||||||
|
parsedId, err := strconv.Atoi(id)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"status": http.StatusBadRequest,
|
||||||
|
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.deps.EngagementService.UserDeleteRecipe(user.Id, parsedId)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"status": http.StatusBadRequest,
|
||||||
|
"message": fmt.Sprintf("[ERROR] Failed to create recipe engagement. %s", err.Error()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.deps.RecipeService.DeleteRecipe(user.Id, parsedId); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"status": http.StatusBadRequest,
|
||||||
|
"message": fmt.Sprintf("[ERROR] Failed to delete recipe. %s", err.Error()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": http.StatusOK,
|
||||||
|
"message": "[OK] Successfully deleted recipe.",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) IsRecipeOwnerV2(ctx *gin.Context) {
|
||||||
|
userId := getUserId(ctx)
|
||||||
|
|
||||||
|
id := ctx.Param("id")
|
||||||
|
parsedId, err := strconv.Atoi(id)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"owner": false,
|
||||||
|
"status": http.StatusBadRequest,
|
||||||
|
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isOwner, err := s.deps.RecipeService.IsRecipeOwner(userId, parsedId)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"owner": false,
|
||||||
|
"status": http.StatusBadRequest,
|
||||||
|
"message": fmt.Sprintf("[ERROR] Failed to determine is user is recipe owner.", err.Error()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
|
"owner": isOwner,
|
||||||
|
"status": http.StatusOK,
|
||||||
|
"message": "[OK] Successfully determined recipe ownership status.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -263,6 +263,8 @@ func (s *Server) Setup() *Server {
|
|||||||
router_api_v2.GET("/recipe/of-the-week", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeOfTheWeekHandlerV2)
|
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/search", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.SearchRecipeHandlerV2)
|
||||||
router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2)
|
router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2)
|
||||||
|
router_api_v2.DELETE("/recipe/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.DeleteRecipeHandlerV2)
|
||||||
|
router_api_v2.GET("/recipe/:id/is-owner", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.IsRecipeOwnerV2)
|
||||||
|
|
||||||
router_api_v2.GET("/auth/login", s.GetGoogleAuthUrlHandlerV2)
|
router_api_v2.GET("/auth/login", s.GetGoogleAuthUrlHandlerV2)
|
||||||
router_api_v2.GET("/auth/callback", s.GoogleCallbackHandlerV2)
|
router_api_v2.GET("/auth/callback", s.GoogleCallbackHandlerV2)
|
||||||
|
|||||||
@ -30,7 +30,7 @@ func NewRecipeService(recipeRepository domain.RecipeRepository, engagementReposi
|
|||||||
|
|
||||||
// CreateRecipe creates a recipe in the database using the recipe repository. This function requires
|
// CreateRecipe creates a recipe in the database using the recipe repository. This function requires
|
||||||
// all the data to be present, though validation does not occur in this function. However, the UI
|
// all the data to be present, though validation does not occur in this function. However, the UI
|
||||||
// will enforce validation, as will the database. Errors will be returned to the called when they
|
// will enforce validation, as will the database. Errors will be returned to the caller when they
|
||||||
// occur.
|
// occur.
|
||||||
//
|
//
|
||||||
// TODO: Implement validation in the API.
|
// TODO: Implement validation in the API.
|
||||||
@ -78,85 +78,22 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &recipe, nil
|
return &recipe, nil
|
||||||
|
}
|
||||||
|
|
||||||
// title := ctx.PostForm("title")
|
// DeleteRecipe deletes a recipe in the database using the recipe repository. This function requires
|
||||||
// description := ctx.PostForm("description")
|
// the userId of the requesting user - to ensure the user is the owner of the recipe. Any errors will
|
||||||
// preparation := ctx.PostForm("preparation-time")
|
// be returned to caller when/if they occur.
|
||||||
// cook := ctx.PostForm("cook-time")
|
func (s *RecipeService) DeleteRecipe(userId, recipeId int) error {
|
||||||
// serving := ctx.PostForm("serving-size")
|
recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
|
||||||
// category := ctx.PostForm("category")
|
if recipe == nil || err != nil {
|
||||||
// difficulty := ctx.PostForm("difficulty")
|
return fmt.Errorf("Recipe does not exist or has been relocated. Please try again. %s", err.Error())
|
||||||
// ingredients := ctx.PostFormArray("ingredients")
|
}
|
||||||
// quantity := ctx.PostFormArray("quantity")
|
|
||||||
// instructions := ctx.PostFormArray("instructions")
|
if recipe.UserId != userId {
|
||||||
// tags := strings.Split(ctx.PostForm("tags"), ",")
|
return fmt.Errorf("User id does not match. Do you own the target recipe?")
|
||||||
// userId := ctx.MustGet("userId").(int)
|
}
|
||||||
//
|
|
||||||
// // Have to get the image differently
|
return s.recipeRepository.DeleteRecipe(recipeId)
|
||||||
// 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,
|
// GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore,
|
||||||
@ -271,3 +208,15 @@ func (s *RecipeService) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error)
|
|||||||
|
|
||||||
return s.recipeRepository.GetRecipe(*id, userId)
|
return s.recipeRepository.GetRecipe(*id, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsRecipeOwner takes an optional userId and a recipeId. If the userId is nil (not given) this
|
||||||
|
// function will return false. Otherwise, it will query the database to find out of the user is
|
||||||
|
// the owner of the recipe. Any error will be bubbled to the caller.
|
||||||
|
func (s *RecipeService) IsRecipeOwner(userId *int, recipeId int) (bool, error) {
|
||||||
|
// No user, obviously not the user.
|
||||||
|
if userId == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.recipeRepository.IsRecipeOwner(*userId, recipeId)
|
||||||
|
}
|
||||||
|
|||||||
@ -124,6 +124,7 @@ type Recipe struct {
|
|||||||
Created time.Time
|
Created time.Time
|
||||||
Tags []Tag
|
Tags []Tag
|
||||||
Favorite bool // Per requesting user
|
Favorite bool // Per requesting user
|
||||||
|
Deleted bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchFilters is a model which represents the required filters to complete a recipe search.
|
// SearchFilters is a model which represents the required filters to complete a recipe search.
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package domain
|
|||||||
|
|
||||||
type RecipeRepository interface {
|
type RecipeRepository interface {
|
||||||
CreateRecipe(recipe *Recipe) error
|
CreateRecipe(recipe *Recipe) error
|
||||||
|
DeleteRecipe(recipeId int) error
|
||||||
GetRecipe(id int, userId *int) (*Recipe, error)
|
GetRecipe(id int, userId *int) (*Recipe, error)
|
||||||
GetRecipes(ids []int, userId *int) ([]Recipe, error)
|
GetRecipes(ids []int, userId *int) ([]Recipe, error)
|
||||||
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error)
|
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error)
|
||||||
@ -11,4 +12,5 @@ type RecipeRepository interface {
|
|||||||
GetRecipeTags(recipe *Recipe) error
|
GetRecipeTags(recipe *Recipe) error
|
||||||
GetRecipeFavorite(recipe *Recipe, userId int) error
|
GetRecipeFavorite(recipe *Recipe, userId int) error
|
||||||
GetRecipeOfTheWeekId(userId *int) (*int, error)
|
GetRecipeOfTheWeekId(userId *int) (*int, error)
|
||||||
|
IsRecipeOwner(userId, recipeId int) (bool, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
type RecipeService interface {
|
type RecipeService interface {
|
||||||
CreateRecipe(ctx *gin.Context) (*Recipe, error)
|
CreateRecipe(ctx *gin.Context) (*Recipe, error)
|
||||||
|
DeleteRecipe(userId, recipeId int) error
|
||||||
GetRecipe(id int, userId *int) (*Recipe, error)
|
GetRecipe(id int, userId *int) (*Recipe, error)
|
||||||
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error)
|
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error)
|
||||||
GetUserRecipes(userId int) ([]Recipe, error)
|
GetUserRecipes(userId int) ([]Recipe, error)
|
||||||
@ -13,4 +14,5 @@ type RecipeService interface {
|
|||||||
GetUserViewedRecipes(userId, limit int) ([]Recipe, error)
|
GetUserViewedRecipes(userId, limit int) ([]Recipe, error)
|
||||||
GetUserMadeRecipes(userId, limit int) ([]Recipe, error)
|
GetUserMadeRecipes(userId, limit int) ([]Recipe, error)
|
||||||
GetRecipeOfTheWeek(userId *int) (*Recipe, error)
|
GetRecipeOfTheWeek(userId *int) (*Recipe, error)
|
||||||
|
IsRecipeOwner(userId *int, recipeId int) (bool, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
||||||
-- Desc: Create the recipe of the week stored procedure.
|
-- Desc: Create the recipe of the week stored procedure.
|
||||||
-- Date: 07/26/2025
|
-- Date: 07/26/2025, 1/10/2026
|
||||||
|
|
||||||
CREATE OR REPLACE PROCEDURE calculate_recipe_of_the_week_procedure()
|
CREATE OR REPLACE PROCEDURE calculate_recipe_of_the_week_procedure()
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
@ -20,6 +20,9 @@ BEGIN
|
|||||||
NOW()
|
NOW()
|
||||||
FROM
|
FROM
|
||||||
Engagements e
|
Engagements e
|
||||||
|
JOIN Recipes r
|
||||||
|
ON r.Id = e.Entity
|
||||||
|
AND r.Deleted = FALSE
|
||||||
WHERE
|
WHERE
|
||||||
e.Created >= NOW() - INTERVAL '7 days'
|
e.Created >= NOW() - INTERVAL '7 days'
|
||||||
AND e.Entity IS NOT NULL
|
AND e.Entity IS NOT NULL
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
||||||
-- Desc: Updated the E_ENGAGEMENT enum to contain created and deleted.
|
-- Desc: Updated the E_ENGAGEMENT enum to contain created and deleted.
|
||||||
-- Date: 01/10/2026
|
-- Date: 01/10/2026
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
||||||
|
-- Desc: Updated the E_ENGAGEMENT enum to contain created and deleted.
|
||||||
|
-- Date: 01/10/2026
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE recipes
|
||||||
|
ADD COLUMN Deleted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@ -11,7 +11,7 @@ psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infra
|
|||||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/009_create_recipe_of_the_week_table.sql
|
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/009_create_recipe_of_the_week_table.sql
|
||||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/010_create_recipe_of_the_week_procedure.sql
|
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/010_create_recipe_of_the_week_procedure.sql
|
||||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/011_update_engagement_enum.sql
|
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/011_update_engagement_enum.sql
|
||||||
|
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/012_update_recipes_table_deleted_column.sql
|
||||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/013_update_recipes_allow_large_servings.sql
|
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/013_update_recipes_allow_large_servings.sql
|
||||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/014_create_logs_table.sql
|
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/014_create_logs_table.sql
|
||||||
|
|
||||||
|
|||||||
@ -102,15 +102,37 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteRecipe deletes a recipe in the database. This is done by setting the deleted field to true.
|
||||||
|
// This will create a "soft delete" effect. This function does not validate that the user is the owner,
|
||||||
|
// so the caller should validate the owner. If any errors occur, they will be returned to the caller.
|
||||||
|
func (r *RecipeRepository) DeleteRecipe(recipeId int) error {
|
||||||
|
query := "UPDATE recipes SET deleted = TRUE WHERE id = $1"
|
||||||
|
|
||||||
|
result, err := r.db.Exec(query, recipeId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows != 1 {
|
||||||
|
return fmt.Errorf("[ERROR] Incorrect number of rows modified. Expected 1, received %d.", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetRecipe gets a recipe from the database via its ID. The operation is wrapped in a transaction
|
// GetRecipe gets a recipe from the database via its ID. The operation is wrapped in a transaction
|
||||||
// for added safety. The repository will not check for a nil result, instead the service will. Callers
|
// 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.
|
// are responsible for protecting against double nil results. Any errors will be bubbled to the caller.
|
||||||
|
//
|
||||||
|
// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored
|
||||||
|
// and the standard "not-found" error will be returned.
|
||||||
func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
|
func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
|
||||||
query := ` SELECT
|
query := `SELECT
|
||||||
id, title, description, instructions, serves, difficulty, duration, category, ingredients,
|
id, title, description, instructions, serves, difficulty, duration, category, ingredients,
|
||||||
userid, modified, created
|
userid, modified, created, deleted
|
||||||
FROM recipes
|
FROM recipes
|
||||||
WHERE id = $1
|
WHERE id = $1 AND deleted = false;
|
||||||
`
|
`
|
||||||
|
|
||||||
var durationBytes []byte
|
var durationBytes []byte
|
||||||
@ -122,7 +144,6 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
|
|||||||
&recipe.Id,
|
&recipe.Id,
|
||||||
&recipe.Title,
|
&recipe.Title,
|
||||||
&recipe.Description,
|
&recipe.Description,
|
||||||
// pq.Array(&instructions),
|
|
||||||
&instructions,
|
&instructions,
|
||||||
&recipe.Serves,
|
&recipe.Serves,
|
||||||
&recipe.Difficulty,
|
&recipe.Difficulty,
|
||||||
@ -132,6 +153,7 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
|
|||||||
&recipe.UserId,
|
&recipe.UserId,
|
||||||
&recipe.Modified,
|
&recipe.Modified,
|
||||||
&recipe.Created,
|
&recipe.Created,
|
||||||
|
&recipe.Deleted,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error())
|
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error())
|
||||||
}
|
}
|
||||||
@ -188,6 +210,9 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
|
|||||||
// transaction for added safety. The repository will not check for a nil result, instead the service
|
// transaction 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
|
// will. Callers are responsible for protecting against double nil results. Any errors will be bubbled
|
||||||
// to the caller.
|
// to the caller.
|
||||||
|
//
|
||||||
|
// This function calls a function that only returns recipes that are not deleted. Any recipes marked
|
||||||
|
// deleted will be ignored and the standard "not-found" error will be returned.
|
||||||
func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) {
|
func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) {
|
||||||
var recipes []domain.Recipe
|
var recipes []domain.Recipe
|
||||||
|
|
||||||
@ -220,10 +245,11 @@ func isBitActive(bits, pos int) bool {
|
|||||||
//
|
//
|
||||||
// TODO: Pagination is required, to provide infinite scroll.
|
// TODO: Pagination is required, to provide infinite scroll.
|
||||||
//
|
//
|
||||||
// TODO: This does not work in the current build, the DB does not return valid values.
|
|
||||||
//
|
|
||||||
// 12/28/25: This function has changed, now longer returns the recipes, but their IDs for fetching
|
// 12/28/25: This function has changed, now longer returns the recipes, but their IDs for fetching
|
||||||
// elsewhere.
|
// elsewhere.
|
||||||
|
//
|
||||||
|
// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored
|
||||||
|
// and the standard "not-found" error will be returned.
|
||||||
func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]int, error) {
|
func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]int, error) {
|
||||||
// Compute meals type filters (there are 7 bits)
|
// Compute meals type filters (there are 7 bits)
|
||||||
var mealConditions []string
|
var mealConditions []string
|
||||||
@ -368,6 +394,7 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert and append conditions if provided
|
// Convert and append conditions if provided
|
||||||
|
conditions = append(conditions, "deleted = false")
|
||||||
if len(conditions) > 0 {
|
if len(conditions) > 0 {
|
||||||
conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND "))
|
conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND "))
|
||||||
query = fmt.Sprintf("%s %s", query, conditionsString)
|
query = fmt.Sprintf("%s %s", query, conditionsString)
|
||||||
@ -465,11 +492,14 @@ func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string)
|
|||||||
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
|
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
|
||||||
//
|
//
|
||||||
// 12/28/25: This now returns just the IDs, the service can handle fetching them.
|
// 12/28/25: This now returns just the IDs, the service can handle fetching them.
|
||||||
|
//
|
||||||
|
// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored
|
||||||
|
// and the standard "not-found" error will be returned.
|
||||||
func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) {
|
func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM recipes
|
FROM recipes
|
||||||
WHERE userid = $1
|
WHERE userid = $1 AND deleted = false
|
||||||
ORDER BY created DESC;
|
ORDER BY created DESC;
|
||||||
`
|
`
|
||||||
|
|
||||||
@ -497,12 +527,15 @@ func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) {
|
|||||||
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
|
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
|
||||||
//
|
//
|
||||||
// 12/28/25: This now just returns the IDs, so the service can handle the fetching.
|
// 12/28/25: This now just returns the IDs, so the service can handle the fetching.
|
||||||
|
//
|
||||||
|
// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored
|
||||||
|
// and the standard "not-found" error will be returned.
|
||||||
func (r *RecipeRepository) GetUserFavoriteRecipesIds(id int) ([]int, error) {
|
func (r *RecipeRepository) GetUserFavoriteRecipesIds(id int) ([]int, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT r.id
|
SELECT r.id
|
||||||
FROM favorites f
|
FROM favorites f
|
||||||
JOIN recipes r ON r.id = f.recipeid
|
JOIN recipes r ON r.id = f.recipeid
|
||||||
WHERE f.userid = $1
|
WHERE f.userid = $1 AND deleted = false
|
||||||
ORDER BY f.created DESC;
|
ORDER BY f.created DESC;
|
||||||
`
|
`
|
||||||
rows, err := r.db.Query(query, id)
|
rows, err := r.db.Query(query, id)
|
||||||
@ -595,6 +628,7 @@ func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
|
|||||||
r.id
|
r.id
|
||||||
FROM recipes r
|
FROM recipes r
|
||||||
JOIN recipeoftheweek rw ON rw.recipeid = r.id
|
JOIN recipeoftheweek rw ON rw.recipeid = r.id
|
||||||
|
WHERE r.deleted = false
|
||||||
ORDER BY rw.created DESC
|
ORDER BY rw.created DESC
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`
|
||||||
@ -604,8 +638,27 @@ func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
|
|||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error())
|
return nil, fmt.Errorf("Failed to locate recipe in database: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return &id, nil
|
return &id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsRecipeOwner takes two required arguments: a user id and a recipe id. This function queries the DB
|
||||||
|
// to check if the user is the owner of the provided recipe. Any error will be bubbled to the caller.
|
||||||
|
func (r *RecipeRepository) IsRecipeOwner(userId, recipeId int) (bool, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
userid
|
||||||
|
FROM recipes
|
||||||
|
WHERE deleted = false
|
||||||
|
AND id = $1;
|
||||||
|
`
|
||||||
|
|
||||||
|
var recipeOwnerId int
|
||||||
|
if err := r.db.QueryRow(query, recipeId).Scan(&recipeOwnerId); err != nil {
|
||||||
|
return false, fmt.Errorf("Failed to get recipe owner id: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipeOwnerId == userId, nil
|
||||||
|
}
|
||||||
|
|||||||
15
web/src/components/buttons/DeleteButton.tsx
Normal file
15
web/src/components/buttons/DeleteButton.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import DeleteIconSmall from "../icons/DeleteIconSmall";
|
||||||
|
|
||||||
|
interface DeleteButtonProps {
|
||||||
|
clickHandler: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteButton({ clickHandler }: DeleteButtonProps) {
|
||||||
|
return (
|
||||||
|
<button onClick={clickHandler} className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-red-300 duration-300 cursor-pointer">
|
||||||
|
<DeleteIconSmall />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
@ -14,7 +14,6 @@ export default function ShareButton({ id }: ShareButtonProps) {
|
|||||||
const clickHandler = async () => {
|
const clickHandler = async () => {
|
||||||
if (clicked) return;
|
if (clicked) return;
|
||||||
|
|
||||||
console.log(window.location);
|
|
||||||
// Copy first, so it feels fast
|
// Copy first, so it feels fast
|
||||||
const url = `${window.location.origin}${ROUTE_CONSTANTS.Recipe(id)}`;
|
const url = `${window.location.origin}${ROUTE_CONSTANTS.Recipe(id)}`;
|
||||||
|
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export default function RecipeCardLarge({ recipe }: RecipeCardLargeProps) {
|
|||||||
<p className="text-xs overflow-hidden whitespace-nowrap text-ellipsis">
|
<p className="text-xs overflow-hidden whitespace-nowrap text-ellipsis">
|
||||||
Serves {recipe.Serves}
|
Serves {recipe.Serves}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-wrap w-80">
|
<p className="text-sm text-wrap w-80 break-all">
|
||||||
{recipe.Description}
|
{recipe.Description}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-end justify-between">
|
<div className="flex items-end justify-between">
|
||||||
|
|||||||
8
web/src/components/icons/WarningIconLarge.tsx
Normal file
8
web/src/components/icons/WarningIconLarge.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
export default function WarningIconLarge() {
|
||||||
|
return (
|
||||||
|
<svg className="size-18 text-red-700" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||||
|
<path d="M320 112C434.9 112 528 205.1 528 320C528 434.9 434.9 528 320 528C205.1 528 112 434.9 112 320C112 205.1 205.1 112 320 112zM320 576C461.4 576 576 461.4 576 320C576 178.6 461.4 64 320 64C178.6 64 64 178.6 64 320C64 461.4 178.6 576 320 576zM231 231C221.6 240.4 221.6 255.6 231 264.9L286 319.9L231 374.9C221.6 384.3 221.6 399.5 231 408.8C240.4 418.1 255.6 418.2 264.9 408.8L319.9 353.8L374.9 408.8C384.3 418.2 399.5 418.2 408.8 408.8C418.1 399.4 418.2 384.2 408.8 374.9L353.8 319.9L408.8 264.9C418.2 255.5 418.2 240.3 408.8 231C399.4 221.7 384.2 221.6 374.9 231L319.9 286L264.9 231C255.5 221.6 240.3 221.6 231 231z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
web/src/components/icons/XIconSmall.tsx
Normal file
8
web/src/components/icons/XIconSmall.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
export default function XIconSmall() {
|
||||||
|
return (
|
||||||
|
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||||
|
<path d="M504.6 148.5C515.9 134.9 514.1 114.7 500.5 103.4C486.9 92.1 466.7 93.9 455.4 107.5L320 270L184.6 107.5C173.3 93.9 153.1 92.1 139.5 103.4C125.9 114.7 124.1 134.9 135.4 148.5L278.3 320L135.4 491.5C124.1 505.1 125.9 525.3 139.5 536.6C153.1 547.9 173.3 546.1 184.6 532.5L320 370L455.4 532.5C466.7 546.1 486.9 547.9 500.5 536.6C514.1 525.3 515.9 505.1 504.6 491.5L361.7 320L504.6 148.5z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
web/src/components/modals/ConfirmRecipeDeleteModal.tsx
Normal file
46
web/src/components/modals/ConfirmRecipeDeleteModal.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import WarningIconLarge from "../icons/WarningIconLarge"
|
||||||
|
import XIconSmall from "../icons/XIconSmall";
|
||||||
|
|
||||||
|
interface ConfirmRecipeDeleteModalProps {
|
||||||
|
cancelHandler: () => void;
|
||||||
|
deleteHandler: () => void;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmRecipeDeleteModal({ cancelHandler, deleteHandler }: ConfirmRecipeDeleteModalProps) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-black/25 fixed w-screen h-screen top-0 left-0 flex items-center justify-center select-none">
|
||||||
|
|
||||||
|
<div className="bg-white relative max-w-9/10 md:max-w-1/2 lg:max-w-1/4 rounded-sm broder-gray-300 flex flex-col items-center justify-evenly py-8 px-16 gap-y-4">
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<button onClick={cancelHandler} className="absolute cursor-pointer top-1 right-1 p-3 duration-100 text-gray-500 hover:text-gray-600">
|
||||||
|
<XIconSmall />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<WarningIconLarge />
|
||||||
|
<h2 className="text-lg md:text-xl"> Are you sure?</h2>
|
||||||
|
<p className="text-gray-600 text-md text-center">
|
||||||
|
Are you sure you want to delete this recipe? This action cannot be undone!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-x-4">
|
||||||
|
<button
|
||||||
|
onClick={cancelHandler}
|
||||||
|
className="py-2 px-8 bg-gray-200 rounded-sm cursor-pointer duration-300 hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={deleteHandler}
|
||||||
|
className="py-2 px-8 bg-red-700 text-white rounded-sm cursor-pointer duration-300 hover:bg-red-800"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,12 +9,12 @@ export default function WebLayout() {
|
|||||||
<div className="bg-gray-100 min-h-screen">
|
<div className="bg-gray-100 min-h-screen">
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<div className="w-full flex justify-center">
|
<div className="w-full flex justify-center">
|
||||||
<div className="mx-2 md:mx-0 w-full md:w-1/2 md:pt-14 min-h-screen h-fit border-l border-r border-gray-300 bg-white">
|
<div className="mx-2 md:mx-0 w-full md:w-1/2 md:pt-14 min-h-screen h-fit border-l border-r
|
||||||
|
border-gray-300 bg-white relative">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -127,8 +127,6 @@ export default function Create() {
|
|||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
const createRecipe = async (): Promise<void> => {
|
const createRecipe = async (): Promise<void> => {
|
||||||
console.log({ title, description, tags, prepTime, cookTime, servingSize, category, difficulty, sections, ingredients, instructions });
|
|
||||||
|
|
||||||
// Exit if not valid recipe meal
|
// Exit if not valid recipe meal
|
||||||
if (!isRecipeMeal(category)) {
|
if (!isRecipeMeal(category)) {
|
||||||
console.error("[ERROR] Recipe meal is invalid.");
|
console.error("[ERROR] Recipe meal is invalid.");
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { isApiError, type ApiError } from "../types/api/error";
|
import { isApiError, type ApiError } from "../types/api/error";
|
||||||
import { GetRecipe } from "../services/RecipeService";
|
import { DeleteRecipe, GetRecipe, IsRecipeOwner } from "../services/RecipeService";
|
||||||
import type { Recipe } from "../types/recipe";
|
import type { Recipe } from "../types/recipe";
|
||||||
import { useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import RecipePlaceholder from "../assets/images/recipe_placeholder_wide.jpg"
|
import RecipePlaceholder from "../assets/images/recipe_placeholder_wide.jpg"
|
||||||
import RecipeMetaData from "../components/display/RecipeMetaData";
|
import RecipeMetaData from "../components/display/RecipeMetaData";
|
||||||
@ -15,6 +15,9 @@ import InstructionList from "../components/items/InstructionList";
|
|||||||
import Spinner from "../components/Spinner";
|
import Spinner from "../components/Spinner";
|
||||||
import { GetUser } from "../services/UserService";
|
import { GetUser } from "../services/UserService";
|
||||||
import type { User } from "../types/user";
|
import type { User } from "../types/user";
|
||||||
|
import DeleteButton from "../components/buttons/DeleteButton";
|
||||||
|
import ROUTE_CONSTANTS from "../types/routes";
|
||||||
|
import ConfirmRecipeDeleteModal from "../components/modals/ConfirmRecipeDeleteModal";
|
||||||
|
|
||||||
export default function RecipePage() {
|
export default function RecipePage() {
|
||||||
// Url params
|
// Url params
|
||||||
@ -25,44 +28,86 @@ export default function RecipePage() {
|
|||||||
const [author, setAuthor] = useState<User | null>(null);
|
const [author, setAuthor] = useState<User | null>(null);
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
const [isAuthor, setIsAuthor] = useState<boolean>(false);
|
||||||
async function fetch() {
|
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||||
const result: Recipe | ApiError = await GetRecipe(Number(id));
|
|
||||||
if (isApiError(result)) {
|
const navigate = useNavigate();
|
||||||
setError(result.message);
|
|
||||||
} else {
|
// Functions
|
||||||
setRecipe(result);
|
const getRecipe = async (id: number) => {
|
||||||
}
|
const result: Recipe | ApiError = await GetRecipe(id);
|
||||||
|
if (isApiError(result)) {
|
||||||
|
setError(result.message);
|
||||||
|
} else {
|
||||||
|
setRecipe(result);
|
||||||
}
|
}
|
||||||
void fetch();
|
}
|
||||||
|
|
||||||
|
const getAuthor = async (id: number) => {
|
||||||
|
const result: User | ApiError = await GetUser(id);
|
||||||
|
if (isApiError(result)) {
|
||||||
|
setError(result.message);
|
||||||
|
} else {
|
||||||
|
setAuthor(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIsAuthor = async () => {
|
||||||
|
if (!recipe) return;
|
||||||
|
const response = await IsRecipeOwner(recipe.Id)
|
||||||
|
if (isApiError(response)) {
|
||||||
|
setError(response.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsAuthor(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRecipe = async (id: number) => {
|
||||||
|
const error = await DeleteRecipe(id);
|
||||||
|
if (isApiError(error)) {
|
||||||
|
setError(error.message)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Some toast, maybe?
|
||||||
|
|
||||||
|
await navigate(ROUTE_CONSTANTS.Home);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const deleteHandler = () => {
|
||||||
|
if (!recipe || !isAuthor) return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effects
|
||||||
|
useEffect(() => {
|
||||||
|
void getRecipe(Number(id));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetch() {
|
if (recipe)
|
||||||
if (!recipe) return;
|
void getAuthor(recipe.UserId);
|
||||||
|
|
||||||
const result: User | ApiError = await GetUser(recipe.UserId);
|
|
||||||
if (isApiError(result)) {
|
|
||||||
setError(result.message);
|
|
||||||
} else {
|
|
||||||
setAuthor(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void fetch();
|
|
||||||
}, [recipe]);
|
}, [recipe]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void getIsAuthor();
|
||||||
|
}, [recipe, author]);
|
||||||
|
|
||||||
// BUG: Prob remove
|
// BUG: Prob remove
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error)
|
if (error)
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(recipe?.Description);
|
|
||||||
}, [recipe]);
|
|
||||||
|
|
||||||
return recipe ? (
|
return recipe ? (
|
||||||
<>
|
<>
|
||||||
|
{isDeleting &&
|
||||||
|
<ConfirmRecipeDeleteModal
|
||||||
|
cancelHandler={() => setIsDeleting(false)}
|
||||||
|
deleteHandler={() => void deleteRecipe(recipe.Id)}
|
||||||
|
/>}
|
||||||
|
|
||||||
<img className="bg-gray-100 w-full h-64 md:h-96 mx-auto mb-8" src={RecipePlaceholder} />
|
<img className="bg-gray-100 w-full h-64 md:h-96 mx-auto mb-8" src={RecipePlaceholder} />
|
||||||
<div className="px-4 py-8 md:px-8">
|
<div className="px-4 py-8 md:px-8">
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-gray-800">{recipe.Title}</h1>
|
<h1 className="text-3xl md:text-4xl font-bold text-gray-800">{recipe.Title}</h1>
|
||||||
@ -70,10 +115,12 @@ export default function RecipePage() {
|
|||||||
<p className="text-sm mb-2 text-gray-700">Category: {recipe.Category}</p>
|
<p className="text-sm mb-2 text-gray-700">Category: {recipe.Category}</p>
|
||||||
</div>
|
</div>
|
||||||
<RecipeMetaData recipe={recipe} />
|
<RecipeMetaData recipe={recipe} />
|
||||||
<section className="w-full flex flex-col md:flex-row gap-x-4 gap-y-2 py-8 px-4 md:px-8">
|
<section className="w-full flex flex-col md:flex-row gap-x-4 gap-y-2 py-8 px-4 md:px-8 flex-wrap">
|
||||||
<FavoriteButton favorite={recipe.Favorite} id={recipe.Id} />
|
<FavoriteButton favorite={recipe.Favorite} id={recipe.Id} />
|
||||||
<MadeButton id={recipe.Id} />
|
<MadeButton id={recipe.Id} />
|
||||||
<ShareButton id={recipe.Id} />
|
<ShareButton id={recipe.Id} />
|
||||||
|
|
||||||
|
{isAuthor && <DeleteButton clickHandler={deleteHandler} />}
|
||||||
</section>
|
</section>
|
||||||
<div className="px-4 py-8 md:px-8">
|
<div className="px-4 py-8 md:px-8">
|
||||||
<h3 className="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3>
|
<h3 className="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import type { CreateRecipeRequest, CreateRecipeResponse, GetRecipeOfTheWeekResponse, GetRecipeResponse, SearchRecipesResponse } from "../types/api/recipe";
|
import type { CreateRecipeRequest, CreateRecipeResponse, DeleteRecipeResponse, GetRecipeOfTheWeekResponse, GetRecipeResponse, IsRecipeOwnerResponse, SearchRecipesResponse } from "../types/api/recipe";
|
||||||
import type { Recipe } from "../types/recipe";
|
import type { Recipe } from "../types/recipe";
|
||||||
import type { ApiError } from "../types/api/error";
|
import type { ApiError } from "../types/api/error";
|
||||||
import type { SearchFilters } from "../types/search";
|
import type { SearchFilters } from "../types/search";
|
||||||
@ -63,3 +63,31 @@ export async function CreateRecipe(data: CreateRecipeRequest): Promise<Recipe |
|
|||||||
|
|
||||||
return response.data.recipe;
|
return response.data.recipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DeleteRecipe(id: number): Promise<ApiError | null> {
|
||||||
|
const response = await axios.delete<DeleteRecipeResponse>(`${BACKEND_URL}/v2/api/recipe/${id}`);
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
const err: ApiError = {
|
||||||
|
status: response.data.status,
|
||||||
|
message: response.data.message
|
||||||
|
};
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function IsRecipeOwner(recipeId: number): Promise<boolean | ApiError> {
|
||||||
|
const response = await axios.get<IsRecipeOwnerResponse>(`${BACKEND_URL}/v2/api/recipe/${recipeId}/is-owner`);
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
const err: ApiError = {
|
||||||
|
status: response.data.status,
|
||||||
|
message: response.data.message
|
||||||
|
};
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.owner;
|
||||||
|
}
|
||||||
|
|||||||
@ -36,3 +36,14 @@ export interface CreateRecipeRequest {
|
|||||||
Sections: RecipeIngredientSection[];
|
Sections: RecipeIngredientSection[];
|
||||||
Tags: string[];
|
Tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeleteRecipeResponse {
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IsRecipeOwnerResponse {
|
||||||
|
owner: boolean;
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -93,4 +93,5 @@ export interface Recipe {
|
|||||||
Created: Date;
|
Created: Date;
|
||||||
Tags: Tag[];
|
Tags: Tag[];
|
||||||
Favorite: boolean;
|
Favorite: boolean;
|
||||||
|
Deleted: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user