From 79bee1cde715ebd3bcd6967d660798f52f64de58 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Tue, 15 Jul 2025 19:17:41 -0700 Subject: [PATCH] (FEAT): Updated recipe repo to include recipe favorite status. This means we need to pass the user id into the various methods that call it. But, since it is a pointer, we can use nil if we don't have a user to check with (this is noted in the service). --- internal/app/handlers/page_handler.go | 11 ++- internal/app/service/engagement_service.go | 10 +-- internal/app/service/recipe_service.go | 4 +- internal/domain/recipe/recipe.go | 4 +- internal/domain/recipe/repository.go | 3 +- internal/domain/recipe/service.go | 2 +- .../database/repository/recipe_repository.go | 55 ++++++++++++- internal/templates/pages/recipe.templ | 8 +- internal/templates/pages/recipe_templ.go | 10 +-- web/static/css/tailwind.css | 81 ++++--------------- 10 files changed, 96 insertions(+), 92 deletions(-) diff --git a/internal/app/handlers/page_handler.go b/internal/app/handlers/page_handler.go index a11959f..b0e9f16 100644 --- a/internal/app/handlers/page_handler.go +++ b/internal/app/handlers/page_handler.go @@ -104,15 +104,22 @@ func RecipePage(ctx *gin.Context) { return } + // Get signed in user, if they exist + var userId *int = nil + if domainServer.IsLoggedIn(ctx) { + storeId := ctx.MustGet("userId").(int) + userId = &storeId + } + // Get recipe - recipe, err := deps.RecipeService.GetRecipe(parsed) + recipe, err := deps.RecipeService.GetRecipe(parsed, userId) if err != nil { fmt.Printf("ERROR: %s\n", err.Error()) ctx.JSON(400, err.Error()) return } - // Get user + // Get user (owner) user, err := deps.UserService.GetUser(recipe.UserId) if err != nil { fmt.Printf("ERROR: %s\n", err.Error()) diff --git a/internal/app/service/engagement_service.go b/internal/app/service/engagement_service.go index 6aa2c8e..362b9ad 100644 --- a/internal/app/service/engagement_service.go +++ b/internal/app/service/engagement_service.go @@ -29,7 +29,7 @@ func NewEngagementService(engagementRepository domain.EngagementRepository, reci // A message will be generated using the recipe data and then used to add a view engagement to the // database. func (s *EngagementService) UserViewRecipe(userId, recipeId int) (domain.Engagement, error) { - recipe, err := s.recipeRepository.GetRecipe(recipeId) + recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId) if err != nil { return domain.Engagement{}, err } @@ -43,17 +43,17 @@ func (s *EngagementService) UserViewRecipe(userId, recipeId int) (domain.Engagem // A message will be generated using the recipe data and then used to add a like engagement to the // database. func (s *EngagementService) UserFavoriteRecipe(userId, recipeId int) (domain.Engagement, error) { - recipe, err := s.recipeRepository.GetRecipe(recipeId) + recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId) if err != nil { return domain.Engagement{}, err } - // Update the favorites DB + // Update the favorites DB liked, err := s.engagementRepository.UserFavoriteRecipeToggle(userId, recipeId) if err != nil { return domain.Engagement{}, err } - + // Determine if this like is a saving or unsaving var message string if liked { @@ -70,7 +70,7 @@ func (s *EngagementService) UserFavoriteRecipe(userId, recipeId int) (domain.Eng // A message will be generated using the recipe data and then used to add a make engagement to the // database. func (s *EngagementService) UserMakeRecipe(userId, recipeId int) (domain.Engagement, error) { - recipe, err := s.recipeRepository.GetRecipe(recipeId) + recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId) if err != nil { return domain.Engagement{}, err } diff --git a/internal/app/service/recipe_service.go b/internal/app/service/recipe_service.go index 870f053..87cdae8 100644 --- a/internal/app/service/recipe_service.go +++ b/internal/app/service/recipe_service.go @@ -123,8 +123,8 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) { // GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore, // if the recipe is nil, an error will be returned, so the caller does not need to check for a nil // recipe (e.g., if the error is nil the recipe exists) -func (s *RecipeService) GetRecipe(id int) (*domain.Recipe, error) { - recipe, err := s.recipeRepository.GetRecipe(id) +func (s *RecipeService) GetRecipe(id int, userId *int) (*domain.Recipe, error) { + recipe, err := s.recipeRepository.GetRecipe(id, userId) if recipe == nil { return nil, fmt.Errorf("Failed to get recipe from database. Nil result.") diff --git a/internal/domain/recipe/recipe.go b/internal/domain/recipe/recipe.go index 013a091..bad14a2 100644 --- a/internal/domain/recipe/recipe.go +++ b/internal/domain/recipe/recipe.go @@ -55,7 +55,8 @@ type RecipeIngredient struct { // Recipe is the database model of a recipe. There is no need to map to a different model so // this will remain in the domain. The Tags field should be loaded from the external Tags table, -// but is still attached to this domain object. +// but is still attached to this domain object. The Favorite field should also be loaded from +// the external favorites table, these are user specific. type Recipe struct { Id int Title string @@ -70,6 +71,7 @@ type Recipe struct { Modified *time.Time // Pointer to allow null Created time.Time Tags []Tag + Favorite bool // Per requesting user } // SearchFilters is a model which represents the required filters to complete a recipe search. diff --git a/internal/domain/recipe/repository.go b/internal/domain/recipe/repository.go index a5c57d9..d1a3a6e 100644 --- a/internal/domain/recipe/repository.go +++ b/internal/domain/recipe/repository.go @@ -2,9 +2,10 @@ package domain type RecipeRepository interface { CreateRecipe(recipe *Recipe) error - GetRecipe(id int) (*Recipe, error) + GetRecipe(id int, userId *int) (*Recipe, error) SearchRecipes(filters SearchFilters) ([]Recipe, error) CreateRecipeTags(recipe Recipe, tags []string) error GetUserRecipes(id int) ([]Recipe, error) GetRecipeTags(recipe *Recipe) error + GetRecipeFavorite(recipe *Recipe, userId int) error } diff --git a/internal/domain/recipe/service.go b/internal/domain/recipe/service.go index b3466f4..33d919a 100644 --- a/internal/domain/recipe/service.go +++ b/internal/domain/recipe/service.go @@ -4,7 +4,7 @@ import "github.com/gin-gonic/gin" type RecipeService interface { CreateRecipe(ctx *gin.Context) (*Recipe, error) - GetRecipe(id int) (*Recipe, error) + GetRecipe(id int, userId *int) (*Recipe, error) SearchRecipes(filters SearchFilters) ([]Recipe, error) GetUserRecipes(id int) ([]Recipe, error) } diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go index e92739a..da5c6f7 100644 --- a/internal/infrastructure/database/repository/recipe_repository.go +++ b/internal/infrastructure/database/repository/recipe_repository.go @@ -92,7 +92,7 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error { // 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. -func (r *RecipeRepository) GetRecipe(id int) (*domain.Recipe, error) { +func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) { tx, err := r.db.Begin() if err != nil { tx.Rollback() @@ -153,7 +153,18 @@ func (r *RecipeRepository) GetRecipe(id int) (*domain.Recipe, error) { } // Add tags - r.GetRecipeTags(&recipe) + 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 + } if err := tx.Commit(); err != nil { tx.Rollback() @@ -368,7 +379,9 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters) ([]domain } // Add tags - r.GetRecipeTags(&recipe) + if err := r.GetRecipeTags(&recipe); err != nil { + fmt.Printf("ERROR getting recipe tags. %s\n", err.Error()) + } recipes = append(recipes, recipe) } @@ -517,7 +530,14 @@ func (r *RecipeRepository) GetUserRecipes(id int) ([]domain.Recipe, error) { } // Add tags - r.GetRecipeTags(&recipe) + 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) } @@ -571,3 +591,30 @@ func (r *RecipeRepository) GetRecipeTags(recipe *domain.Recipe) error { return nil } + +// GetRecipeFavorite requires a recipe to be filled with at least an ID. This function will use the +// ID defined in the provided recipe to fill the favorite status of the recipe, based on the provided +// userId. The recipe is modified in place and is not returned. Any errors will be bubbled to the caller. +func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int) error { + tx, err := r.db.Begin() + if err != nil { + tx.Rollback() + return err + } + + query := ` + SELECT COUNT(*) + FROM favorites + WHERE recipeid = $1 AND userid = $2; + ` + + var count int + if err := tx.QueryRow(query, recipe.Id, userId).Scan(&count); err != nil { + tx.Rollback() + return fmt.Errorf("Failed to get recipe favorite. %s", err.Error()) + } + + recipe.Favorite = count > 0 + + return nil +} diff --git a/internal/templates/pages/recipe.templ b/internal/templates/pages/recipe.templ index 1d8e17f..202ac5d 100644 --- a/internal/templates/pages/recipe.templ +++ b/internal/templates/pages/recipe.templ @@ -186,10 +186,10 @@ templ tagListItem(content string) { templ favoriteButton(favorited bool, id int) { if favorited { ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-trigger=\"click\" hx-swap=\"none\" class=\"flex items-center justify-center gap-x-1 rounded-lg border border-blue-300 bg-blue-50 text-gray-800 px-6 py-3 flex-grow hover:bg-blue-100 hover:border-blue-500 duration-300\"> Unfavorite") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -604,7 +604,7 @@ func favoriteButton(favorited bool, id int) templ.Component { var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(domainServer.API_ENGAGEMENT_FAVORITE, id)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 204, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 204, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { @@ -807,7 +807,7 @@ func RecipePage(recipe domain.Recipe, user domainUser.User) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = buttonSection(false, recipe.Id).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = buttonSection(recipe.Favorite, recipe.Id).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/static/css/tailwind.css b/web/static/css/tailwind.css index fc8d34e..14b37e1 100644 --- a/web/static/css/tailwind.css +++ b/web/static/css/tailwind.css @@ -9,7 +9,6 @@ monospace; --color-red-100: oklch(93.6% 0.032 17.717); --color-red-500: oklch(63.7% 0.237 25.331); - --color-red-600: oklch(57.7% 0.245 27.325); --color-green-300: oklch(87.1% 0.15 154.449); --color-green-500: oklch(72.3% 0.219 149.579); --color-blue-50: oklch(97% 0.014 254.604); @@ -240,9 +239,6 @@ .static { position: static; } - .top-1 { - top: calc(var(--spacing) * 1); - } .top-1\/2 { top: calc(1/2 * 100%); } @@ -252,9 +248,6 @@ .left-0 { left: calc(var(--spacing) * 0); } - .left-1 { - left: calc(var(--spacing) * 1); - } .left-1\/2 { left: calc(1/2 * 100%); } @@ -426,18 +419,12 @@ .min-h-screen { min-height: 100vh; } - .w-1 { - width: calc(var(--spacing) * 1); - } .w-1\/3 { width: calc(1/3 * 100%); } .w-1\/4 { width: calc(1/4 * 100%); } - .w-3 { - width: calc(var(--spacing) * 3); - } .w-3\/4 { width: calc(3/4 * 100%); } @@ -450,9 +437,6 @@ .w-5 { width: calc(var(--spacing) * 5); } - .w-9 { - width: calc(var(--spacing) * 9); - } .w-9\/10 { width: calc(9/10 * 100%); } @@ -471,9 +455,6 @@ .max-w-2xl { max-width: var(--container-2xl); } - .flex-shrink { - flex-shrink: 1; - } .flex-shrink-0 { flex-shrink: 0; } @@ -483,21 +464,10 @@ .flex-grow { flex-grow: 1; } - .border-collapse { - border-collapse: collapse; - } - .-translate-x-1 { - --tw-translate-x: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .-translate-x-1\/2 { --tw-translate-x: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); } - .-translate-y-1 { - --tw-translate-y: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -510,15 +480,9 @@ --tw-scale-y: 50%; scale: var(--tw-scale-x) var(--tw-scale-y); } - .transform { - transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); - } .cursor-pointer { cursor: pointer; } - .resize { - resize: both; - } .resize-none { resize: none; } @@ -657,9 +621,6 @@ .border-gray-300 { border-color: var(--color-gray-300); } - .border-green-300 { - border-color: var(--color-green-300); - } .border-green-500 { border-color: var(--color-green-500); } @@ -669,9 +630,6 @@ .border-white { border-color: var(--color-white); } - .bg-\[\#f8f8f8\] { - background-color: #f8f8f8; - } .bg-black { background-color: var(--color-black); } @@ -1132,6 +1090,20 @@ } } } + .hover\:border-blue-500 { + &:hover { + @media (hover: hover) { + border-color: var(--color-blue-500); + } + } + } + .hover\:bg-blue-100 { + &:hover { + @media (hover: hover) { + background-color: var(--color-blue-100); + } + } + } .hover\:bg-blue-200 { &:hover { @media (hover: hover) { @@ -1537,26 +1509,6 @@ inherits: false; initial-value: 1; } -@property --tw-rotate-x { - syntax: "*"; - inherits: false; -} -@property --tw-rotate-y { - syntax: "*"; - inherits: false; -} -@property --tw-rotate-z { - syntax: "*"; - inherits: false; -} -@property --tw-skew-x { - syntax: "*"; - inherits: false; -} -@property --tw-skew-y { - syntax: "*"; - inherits: false; -} @property --tw-border-style { syntax: "*"; inherits: false; @@ -1766,11 +1718,6 @@ --tw-scale-x: 1; --tw-scale-y: 1; --tw-scale-z: 1; - --tw-rotate-x: initial; - --tw-rotate-y: initial; - --tw-rotate-z: initial; - --tw-skew-x: initial; - --tw-skew-y: initial; --tw-border-style: solid; --tw-gradient-position: initial; --tw-gradient-from: #0000;