Compare commits

..

2 Commits

Author SHA1 Message Date
Hayden Hargreaves
7e355d5eda (UI/FIX): Fixed the favorite button rendering and updating!
I don't like the way that templ requires JS, but it is what it is. I
just don't want to return HTML from the server.
2025-07-15 19:44:19 -07:00
Hayden Hargreaves
79bee1cde7 (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).
2025-07-15 19:17:41 -07:00
10 changed files with 228 additions and 134 deletions

View File

@ -104,15 +104,23 @@ func RecipePage(ctx *gin.Context) {
return
}
// Get signed in user, if they exist
var userId *int = nil
var loggedIn = domainServer.IsLoggedIn(ctx)
if loggedIn {
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())
@ -132,7 +140,7 @@ func RecipePage(ctx *gin.Context) {
// I also do not really like that this runs on refresh, might need some better handling
title := "Potion - View Recipe"
page := pages.RecipePage(*recipe, *user)
page := pages.RecipePage(*recipe, *user, loggedIn)
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}

View File

@ -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,7 +43,7 @@ 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
}
@ -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
}

View File

@ -123,8 +123,12 @@ 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)
//
// A userId should be provided to allow the favorite status to be updated. Without a userId (nil),
// the favorite status will return false, not because its not a favorite, but because it cannot find
// out!
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.")

View File

@ -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.

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -151,7 +151,7 @@ templ tagList(tags []domain.Tag, created time.Time, modified *time.Time) {
templ ingredientListItem(name, quantity string) {
<li
class="text-sm md:text-base p-2 hover:bg-gray-100 transition-all duration-300 rounded-sm flex items-center justify-start odd:bg-[#f8f8f8]"
class="p-2 hover:bg-gray-100 transition-all duration-300 rounded-sm flex items-center justify-start odd:bg-[#f8f8f8]"
>
<span class="mr-4">
<svg class="h-4 text-gray-400" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@ -173,7 +173,7 @@ templ instructionListItem(num int, content string) {
<div class="size-8 md:size-10 bg-blue-50 rounded-full flex items-center justify-center flex-shrink-0">
<h3 class="text-base md:text-xl text-blue-600 font-semibold">{ num }</h3>
</div>
<p class="text-sm md:text-base">{ content }</p>
<p class="text-base">{ content }</p>
</li>
}
@ -183,13 +183,17 @@ templ tagListItem(content string) {
</li>
}
templ favoriteButton(favorited bool, id int) {
templ favoriteButton(favorited bool, id int, loggedIn bool) {
if favorited {
<button
hx-post={ fmt.Sprintf(domainServer.API_ENGAGEMENT_LIKE, id) }
hx-post={ fmt.Sprintf(domainServer.API_ENGAGEMENT_FAVORITE, id) }
hx-trigger="click"
hx-swap="none"
class="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-blue-300 duration-300"
if loggedIn {
hx-on:click="favoriteButtonHandler();"
}
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"
id="favorite-button"
>
<svg class="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
@ -201,10 +205,14 @@ templ favoriteButton(favorited bool, id int) {
</button>
} else {
<button
hx-post={ fmt.Sprintf(domainServer.API_ENGAGEMENT_LIKE, id) }
hx-post={ fmt.Sprintf(domainServer.API_ENGAGEMENT_FAVORITE, id) }
hx-trigger="click"
hx-swap="none"
if loggedIn {
hx-on:click="favoriteButtonHandler();"
}
class="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-blue-300 duration-300"
id="favorite-button"
>
<svg class="h-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
@ -222,13 +230,15 @@ templ favoriteButton(favorited bool, id int) {
}
}
templ madeButton(id int) {
templ madeButton(id int, loggedIn bool) {
<button
hx-post={ fmt.Sprintf(domainServer.API_ENGAGEMENT_MAKE, id) }
hx-trigger="click"
hx-swap="none"
id="make-button"
if loggedIn {
hx-on:click="makeButtonHandler();"
}
class="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-blue-300 duration-300"
>
<svg
@ -270,15 +280,15 @@ templ shareButton() {
</button>
}
templ buttonSection(favorited bool, id int) {
templ buttonSection(favorited bool, id int, loggedIn bool) {
<section class="w-full flex flex-col md:flex-row gap-x-4 gap-y-2 py-8 px-4 md:px-8">
@favoriteButton(favorited, id)
@madeButton(id)
@favoriteButton(favorited, id, loggedIn)
@madeButton(id, loggedIn)
@shareButton()
</section>
}
templ RecipePage(recipe domain.Recipe, user domainUser.User) {
templ RecipePage(recipe domain.Recipe, user domainUser.User, loggedIn bool) {
@components.Navbar("")
<div class="w-full flex justify-center">
<div class="mx-2 md:mx-0 w-full md:w-1/2 md:pt-14 h-full border-l border-r border-gray-300 bg-white">
@ -289,7 +299,7 @@ templ RecipePage(recipe domain.Recipe, user domainUser.User) {
<p class="text-sm mb-2 text-gray-700">Category: { recipe.Category }</p>
</div>
@metadataSection(recipe)
@buttonSection(false, recipe.Id)
@buttonSection(recipe.Favorite, recipe.Id, loggedIn)
<div class="px-4 py-8 md:px-8">
<h3 class="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3>
<p class="text-gray-700">{ recipe.Description }</p>
@ -377,9 +387,54 @@ templ scripts(id int) {
</svg>
Made This!
</button>
`;
}
function favoriteButtonHandler() {
const button = document.getElementById("favorite-button");
console.log(button.classList);
console.log(button.classList.contains("border-blue-300"));
const toggleClasses = [
"border-gray-300", "hover:bg-gray-50", "hover:border-blue-300",
"border-blue-300", "bg-blue-50", "hover:bg-blue-100", "hover:border-blue-500"
];
for (const cls of toggleClasses) {
console.log("toggling class " + cls);
button.classList.toggle(cls);
}
if (!button.classList.contains("border-blue-300")) {
button.innerHTML = `
<svg class="h-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 6.00019C10.2006 3.90317 7.19377 3.2551 4.93923 5.17534C2.68468 7.09558 2.36727 10.3061 4.13778 12.5772C5.60984 14.4654 10.0648 18.4479 11.5249 19.7369C11.6882 19.8811 11.7699 19.9532 11.8652 19.9815C11.9483 20.0062 12.0393 20.0062 12.1225 19.9815C12.2178 19.9532 12.2994 19.8811 12.4628 19.7369C13.9229 18.4479 18.3778 14.4654 19.8499 12.5772C21.6204 10.3061 21.3417 7.07538 19.0484 5.17534C16.7551 3.2753 13.7994 3.90317 12 6.00019Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Favorite
`;
} else {
button.innerHTML = `
<svg class="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 9.1371C2 14 6.01943 16.5914 8.96173 18.9109C10 19.7294 11 20.5 12 20.5C13 20.5 14 19.7294 15.0383 18.9109C17.9806 16.5914 22 14 22 9.1371C22 4.27416 16.4998 0.825464 12 5.50063C7.50016 0.825464 2 4.27416 2 9.1371Z"
fill="currentColor"
></path>
</svg>
Unfavorite
`;
}
}
</script>
}

File diff suppressed because one or more lines are too long

View File

@ -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;