(FEAT): Deletion is implemented! #84
@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
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.
|
||||
@ -129,3 +130,70 @@ func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) {
|
||||
"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.POST("/recipe/search", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.SearchRecipeHandlerV2)
|
||||
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/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
|
||||
// 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.
|
||||
//
|
||||
// TODO: Implement validation in the API.
|
||||
@ -78,85 +78,22 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, 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
|
||||
// DeleteRecipe deletes a recipe in the database using the recipe repository. This function requires
|
||||
// the userId of the requesting user - to ensure the user is the owner of the recipe. Any errors will
|
||||
// be returned to caller when/if they occur.
|
||||
func (s *RecipeService) DeleteRecipe(userId, recipeId int) error {
|
||||
recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
|
||||
if recipe == nil || err != nil {
|
||||
return fmt.Errorf("Recipe does not exist or has been relocated. Please try again. %s", err.Error())
|
||||
}
|
||||
|
||||
if recipe.UserId != userId {
|
||||
return fmt.Errorf("User id does not match. Do you own the target recipe?")
|
||||
}
|
||||
|
||||
return s.recipeRepository.DeleteRecipe(recipeId)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
Tags []Tag
|
||||
Favorite bool // Per requesting user
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
// SearchFilters is a model which represents the required filters to complete a recipe search.
|
||||
|
||||
@ -2,6 +2,7 @@ package domain
|
||||
|
||||
type RecipeRepository interface {
|
||||
CreateRecipe(recipe *Recipe) error
|
||||
DeleteRecipe(recipeId int) error
|
||||
GetRecipe(id int, userId *int) (*Recipe, error)
|
||||
GetRecipes(ids []int, userId *int) ([]Recipe, error)
|
||||
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error)
|
||||
@ -11,4 +12,5 @@ type RecipeRepository interface {
|
||||
GetRecipeTags(recipe *Recipe) error
|
||||
GetRecipeFavorite(recipe *Recipe, userId int) error
|
||||
GetRecipeOfTheWeekId(userId *int) (*int, error)
|
||||
IsRecipeOwner(userId, recipeId int) (bool, error)
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
|
||||
type RecipeService interface {
|
||||
CreateRecipe(ctx *gin.Context) (*Recipe, error)
|
||||
DeleteRecipe(userId, recipeId int) error
|
||||
GetRecipe(id int, userId *int) (*Recipe, error)
|
||||
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error)
|
||||
GetUserRecipes(userId int) ([]Recipe, error)
|
||||
@ -13,4 +14,5 @@ type RecipeService interface {
|
||||
GetUserViewedRecipes(userId, limit int) ([]Recipe, error)
|
||||
GetUserMadeRecipes(userId, limit 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)
|
||||
-- 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()
|
||||
LANGUAGE plpgsql
|
||||
@ -20,6 +20,9 @@ BEGIN
|
||||
NOW()
|
||||
FROM
|
||||
Engagements e
|
||||
JOIN Recipes r
|
||||
ON r.Id = e.Entity
|
||||
AND r.Deleted = FALSE
|
||||
WHERE
|
||||
e.Created >= NOW() - INTERVAL '7 days'
|
||||
AND e.Entity IS NOT NULL
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
||||
-- Desc: Updated the E_ENGAGEMENT enum to contain created and deleted.
|
||||
-- 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/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/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/014_create_logs_table.sql
|
||||
|
||||
|
||||
@ -102,15 +102,37 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
|
||||
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
|
||||
// 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.
|
||||
//
|
||||
// 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) {
|
||||
query := ` SELECT
|
||||
query := `SELECT
|
||||
id, title, description, instructions, serves, difficulty, duration, category, ingredients,
|
||||
userid, modified, created
|
||||
userid, modified, created, deleted
|
||||
FROM recipes
|
||||
WHERE id = $1
|
||||
WHERE id = $1 AND deleted = false;
|
||||
`
|
||||
|
||||
var durationBytes []byte
|
||||
@ -122,7 +144,6 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
|
||||
&recipe.Id,
|
||||
&recipe.Title,
|
||||
&recipe.Description,
|
||||
// pq.Array(&instructions),
|
||||
&instructions,
|
||||
&recipe.Serves,
|
||||
&recipe.Difficulty,
|
||||
@ -132,6 +153,7 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
|
||||
&recipe.UserId,
|
||||
&recipe.Modified,
|
||||
&recipe.Created,
|
||||
&recipe.Deleted,
|
||||
); err != nil {
|
||||
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
|
||||
// will. Callers are responsible for protecting against double nil results. Any errors will be bubbled
|
||||
// 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) {
|
||||
var recipes []domain.Recipe
|
||||
|
||||
@ -220,10 +245,11 @@ func isBitActive(bits, pos int) bool {
|
||||
//
|
||||
// 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
|
||||
// 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) {
|
||||
// Compute meals type filters (there are 7 bits)
|
||||
var mealConditions []string
|
||||
@ -368,6 +394,7 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
|
||||
}
|
||||
|
||||
// Convert and append conditions if provided
|
||||
conditions = append(conditions, "deleted = false")
|
||||
if len(conditions) > 0 {
|
||||
conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND "))
|
||||
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.
|
||||
//
|
||||
// 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) {
|
||||
query := `
|
||||
SELECT id
|
||||
FROM recipes
|
||||
WHERE userid = $1
|
||||
WHERE userid = $1 AND deleted = false
|
||||
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.
|
||||
//
|
||||
// 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) {
|
||||
query := `
|
||||
SELECT r.id
|
||||
FROM favorites f
|
||||
JOIN recipes r ON r.id = f.recipeid
|
||||
WHERE f.userid = $1
|
||||
WHERE f.userid = $1 AND deleted = false
|
||||
ORDER BY f.created DESC;
|
||||
`
|
||||
rows, err := r.db.Query(query, id)
|
||||
@ -595,6 +628,7 @@ func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
|
||||
r.id
|
||||
FROM recipes r
|
||||
JOIN recipeoftheweek rw ON rw.recipeid = r.id
|
||||
WHERE r.deleted = false
|
||||
ORDER BY rw.created DESC
|
||||
LIMIT 1;
|
||||
`
|
||||
@ -604,8 +638,27 @@ func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 () => {
|
||||
if (clicked) return;
|
||||
|
||||
console.log(window.location);
|
||||
// Copy first, so it feels fast
|
||||
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">
|
||||
Serves {recipe.Serves}
|
||||
</p>
|
||||
<p className="text-sm text-wrap w-80">
|
||||
<p className="text-sm text-wrap w-80 break-all">
|
||||
{recipe.Description}
|
||||
</p>
|
||||
<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">
|
||||
<Navigation />
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@ -127,8 +127,6 @@ export default function Create() {
|
||||
|
||||
// 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.");
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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 { useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import RecipePlaceholder from "../assets/images/recipe_placeholder_wide.jpg"
|
||||
import RecipeMetaData from "../components/display/RecipeMetaData";
|
||||
@ -15,6 +15,9 @@ import InstructionList from "../components/items/InstructionList";
|
||||
import Spinner from "../components/Spinner";
|
||||
import { GetUser } from "../services/UserService";
|
||||
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() {
|
||||
// Url params
|
||||
@ -25,44 +28,86 @@ export default function RecipePage() {
|
||||
const [author, setAuthor] = useState<User | null>(null);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
const result: Recipe | ApiError = await GetRecipe(Number(id));
|
||||
const [isAuthor, setIsAuthor] = useState<boolean>(false);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Functions
|
||||
const getRecipe = async (id: number) => {
|
||||
const result: Recipe | ApiError = await GetRecipe(id);
|
||||
if (isApiError(result)) {
|
||||
setError(result.message);
|
||||
} else {
|
||||
setRecipe(result);
|
||||
}
|
||||
}
|
||||
void fetch();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
if (!recipe) return;
|
||||
|
||||
const result: User | ApiError = await GetUser(recipe.UserId);
|
||||
const getAuthor = async (id: number) => {
|
||||
const result: User | ApiError = await GetUser(id);
|
||||
if (isApiError(result)) {
|
||||
setError(result.message);
|
||||
} else {
|
||||
setAuthor(result);
|
||||
}
|
||||
}
|
||||
void fetch();
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (recipe)
|
||||
void getAuthor(recipe.UserId);
|
||||
}, [recipe]);
|
||||
|
||||
useEffect(() => {
|
||||
void getIsAuthor();
|
||||
}, [recipe, author]);
|
||||
|
||||
// BUG: Prob remove
|
||||
useEffect(() => {
|
||||
if (error)
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(recipe?.Description);
|
||||
}, [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} />
|
||||
<div className="px-4 py-8 md:px-8">
|
||||
<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>
|
||||
</div>
|
||||
<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} />
|
||||
<MadeButton id={recipe.Id} />
|
||||
<ShareButton id={recipe.Id} />
|
||||
|
||||
{isAuthor && <DeleteButton clickHandler={deleteHandler} />}
|
||||
</section>
|
||||
<div className="px-4 py-8 md:px-8">
|
||||
<h3 className="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { ApiError } from "../types/api/error";
|
||||
import type { SearchFilters } from "../types/search";
|
||||
@ -63,3 +63,31 @@ export async function CreateRecipe(data: CreateRecipeRequest): Promise<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[];
|
||||
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;
|
||||
Tags: Tag[];
|
||||
Favorite: boolean;
|
||||
Deleted: boolean;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user