Compare commits

...

16 Commits

Author SHA1 Message Date
Hayden Hargreaves
479e709b4e (FIX): Needed to update flake for debugging. 2025-10-29 17:25:52 -07:00
Hayden Hargreaves
ee6017f8ca (FIX): Fixed the database scripts.
They were not working properly.
2025-10-09 17:52:04 -07:00
e5a0482236 Merge pull request '(FIX): Database backup scripts.' (#50) from feature/sql-backup into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 1m59s
Reviewed-on: #50
2025-10-07 12:08:10 -07:00
Hayden Hargreaves
ad63f6e3c5 (FIX): Database backup scripts. 2025-10-07 12:07:05 -07:00
af8708f028 Merge pull request '(FIX): CI/CD pipeline fixed.' (#48) from fix/cicd into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 49s
Reviewed-on: #48
2025-09-30 13:16:14 -07:00
Hayden Hargreaves
5d2048488c (FIX): CI/CD pipeline fixed.
Go versions were mismatched
2025-09-30 13:15:28 -07:00
6e96c847ec Merge pull request '(FIX): Fixed issues with search.' (#47) from feature/search-fixes into master
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 1m39s
Reviewed-on: #47
2025-09-19 13:39:26 -07:00
Hayden Hargreaves
445557f37e (FIX): Fixed issues with search.
Spaces are now ignored and special characters that are used for the
query are not a problem. This can be pushed up for UAT.
2025-09-19 13:38:16 -07:00
535b8f1a6f Merge pull request '(REFACTOR): Working on dependency injection.' (#45) from feature/dep-inj into master
Some checks failed
Deploy application with Docker / build_and_deploy (push) Has been cancelled
Reviewed-on: #45
2025-09-12 19:14:08 -07:00
Hayden Hargreaves
033b4fe410 (FIX): Fixed comp issues 2025-09-12 18:58:40 -07:00
Hayden Hargreaves
aad4640527 (REFACTOR): Working on dependency injection.
However, everything seems really slow now... 950ms for a page request?
Something is wrong, just not sure what yet.
2025-09-04 21:51:16 -07:00
1e4cf8f922 Merge pull request '(FEAT): Errors can now be displayed using the RenderErrorBanner fx.' (#44) from feature/errors into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 1m56s
Reviewed-on: #44
2025-09-04 20:26:05 -07:00
Hayden Hargreaves
1e6a06e8ed (FEAT): Added errors to each of the handlers.
I think this is really the only place we need them. For now at least.
2025-09-04 20:25:12 -07:00
Hayden Hargreaves
6b030adf02 (DOC): Updated documentation 2025-09-03 22:25:13 -07:00
Hayden Hargreaves
47b8386844 (FEAT): Errors can now be displayed using the RenderErrorBanner fx.
This is found in the components domain. Make sure only HTMX routes call
it. Although, I think I put this into the page handlers WHICH IS WRONG.

However, it does work, ish. But it does not load into the DOM properly.
But it seems to display just fine.
2025-09-03 22:22:23 -07:00
05ecea56db Merge pull request '(FIX): Added directions for create page.' (#42) from dev into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 41s
Reviewed-on: #42
2025-08-20 21:39:46 -07:00
45 changed files with 1971 additions and 884 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/flake.lock /flake.lock
/go.sum /go.sum
/.env /.env
/*.dump

View File

@ -29,7 +29,7 @@ RUN npm install tailwindcss @tailwindcss/cli && npx @tailwindcss/cli -i ./web/st
# Build stage # Build stage
FROM golang:1.24 AS build-stage FROM golang:latest AS build-stage
COPY --from=tailwind-build-stage /app /app COPY --from=tailwind-build-stage /app /app

View File

@ -27,6 +27,8 @@
watchman watchman
docker-language-server docker-language-server
dockerfile-language-server-nodejs dockerfile-language-server-nodejs
gcc_multi
glibc_multi
]; ];
# Define the shell that will be executed. # Define the shell that will be executed.
@ -41,6 +43,11 @@
export GOPATH="$HOME/.local/go" export GOPATH="$HOME/.local/go"
echo "Settings GOPATH to: $HOME/.local/go " echo "Settings GOPATH to: $HOME/.local/go "
export GOOS=linux
export GOARCH=amd64
export CGO_CFLAGS=-Wno-error=cpp;
# Exec zsh to replace the current shell process with zsh. # Exec zsh to replace the current shell process with zsh.
# This ensures your prompt and zsh configurations load correctly. # This ensures your prompt and zsh configurations load correctly.
exec zsh exec zsh

2
go.mod
View File

@ -1,6 +1,6 @@
module github.com/haydenhargreaves/Potion module github.com/haydenhargreaves/Potion
go 1.24.3 go 1.25.0
require ( require (
github.com/a-h/templ v0.3.920 github.com/a-h/templ v0.3.920

View File

@ -1,55 +1,47 @@
package handlers package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
)
// GoogleLogin directs the user to Googles select user login page. Once the user has selected an // GoogleLogin directs the user to Googles select user login page. Once the user has selected an
// account, they will be directed to the GoogleCallback handler where the main logic resides. // account, they will be directed to the GoogleCallback handler where the main logic resides.
func GoogleLogin(ctx *gin.Context) { //
deps := ctx.MustGet("deps").(*domain.InjectedDependencies) // DEPRECATED: As of September 4th, 2025.
url := deps.AuthService.GetGoogleAuthUrl() // func GoogleLogin(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
ctx.Redirect(http.StatusSeeOther, url) // url := deps.AuthService.GetGoogleAuthUrl()
} //
// ctx.Redirect(http.StatusSeeOther, url)
// }
// GoogleCallback is the callback handler when the user successfully logs in with their Google // GoogleCallback is the callback handler when the user successfully logs in with their Google
// account. They will be directed here and a JWT is generated. This JWT is stored in the users // account. They will be directed here and a JWT is generated. This JWT is stored in the users
// cookies and will be used by protected routes to validate their login status. // cookies and will be used by protected routes to validate their login status.
// //
// TODO: This route does not do the proper handling, need to work on the redirection or handling.
//
// We do not need to return all of this data, it is just for testing. // We do not need to return all of this data, it is just for testing.
func GoogleCallback(ctx *gin.Context) { //
deps := ctx.MustGet("deps").(*domain.InjectedDependencies) // DEPRECATED: As of September 4th, 2025.
// func GoogleCallback(ctx *gin.Context) {
var ( // deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
state string = ctx.Query("state") //
code string = ctx.Query("code") // var (
) // state string = ctx.Query("state")
// code string = ctx.Query("code")
// TODO: Do something real, not just return data // )
if jwt, dbUser, googleUserInfo, err := deps.AuthService.GoogleAuthSuccess(state, code); err != nil { //
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) // if jwt, err := deps.AuthService.GoogleAuthSuccess(state, code); err != nil {
} else { // components.RenderErrorBanner(ctx, err.Error())
domain.SetCookie(ctx, "jwt_token", jwt, time.Hour*24*7) // ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
// ctx.JSON(http.StatusOK, gin.H{"jwt": jwt, "googleUserInfo": googleUserInfo, "dbUser": dbUser}) // } else {
_ = dbUser // domain.SetCookie(ctx, "jwt_token", jwt, time.Hour*24*7)
_ = googleUserInfo // ctx.Redirect(http.StatusSeeOther, "/")
// }
ctx.Redirect(http.StatusSeeOther, "/") // }
}
}
// Logout removes the token from the user's browser. Effectively "logging them out." Routes that // Logout removes the token from the user's browser. Effectively "logging them out." Routes that
// require authentication will require the user to sign back in before accessing them again. // require authentication will require the user to sign back in before accessing them again.
// This route will direct the user back to the home page. // This route will direct the user back to the home page.
func Logout(ctx *gin.Context) { //
domain.SetCookie(ctx, "jwt_token", "", -1) // DEPRECATED: As of September 4th, 2025.
domain.SetCookie(ctx, "search-filters", "", -1) // func Logout(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME) // domain.SetCookie(ctx, "jwt_token", "", -1)
} // domain.SetCookie(ctx, "search-filters", "", -1)
// ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME)
// }

View File

@ -1,141 +1,142 @@
package handlers package handlers
import ( // DEPRECATED: As of September 4th, 2025.
"fmt" // func EngagementViewRecipe(ctx *gin.Context) {
"net/http" // deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
"strconv" // recipeId, _ := strconv.Atoi(ctx.Param("id"))
//
// // Ensure user is logged in with a valid account
// user := deps.UserService.GetAuthenicatedUser(ctx)
// if user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// }
//
// if !domain.IsLoggedIn(ctx) || user == nil {
// if _, err := deps.EngagementService.ViewRecipe(recipeId); err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": err.Error(),
// })
// } else {
// ctx.Header("HX-Redirect", fmt.Sprintf(domain.WEB_RECIPE, recipeId))
// ctx.Status(http.StatusOK)
// }
// return
// }
//
// // We caught nil already, we can assume the user exists
// if _, err := deps.EngagementService.UserViewRecipe(user.Id, recipeId); err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": err.Error(),
// })
// } else {
// ctx.Header("HX-Redirect", fmt.Sprintf(domain.WEB_RECIPE, recipeId))
// ctx.Status(http.StatusOK)
// }
// }
"github.com/gin-gonic/gin" // DEPRECATED: As of September 4th, 2025.
domain "github.com/haydenhargreaves/Potion/internal/domain/server" // func EngagementShareRecipe(ctx *gin.Context) {
) // deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
// recipeId, _ := strconv.Atoi(ctx.Param("id"))
//
// // Ensure user is logged in with a valid account
// user := deps.UserService.GetAuthenicatedUser(ctx)
// if user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// }
//
// if !domain.IsLoggedIn(ctx) || user == nil {
// if _, err := deps.EngagementService.ShareRecipe(recipeId); err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": err.Error(),
// })
// } else {
// ctx.Status(http.StatusNoContent)
// }
// return
// }
//
// if _, err := deps.EngagementService.UserShareRecipe(user.Id, recipeId); err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": err.Error(),
// })
// } else {
// ctx.Status(http.StatusNoContent)
// }
// }
func EngagementViewRecipe(ctx *gin.Context) { // DEPRECATED: As of September 4th, 2025.
deps := ctx.MustGet("deps").(*domain.InjectedDependencies) // func EngagementFavoriteRecipe(ctx *gin.Context) {
recipeId, _ := strconv.Atoi(ctx.Param("id")) // deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
// // Ensure user is logged in with a valid account
// user := deps.UserService.GetAuthenicatedUser(ctx)
// if user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// }
//
// if !domain.IsLoggedIn(ctx) || user == nil {
// ctx.Header("HX-Redirect", domain.WEB_LOGIN)
// ctx.Status(http.StatusOK)
// return
// }
//
// id := ctx.Param("id")
// recipeId, _ := strconv.Atoi(id)
//
// if _, err := deps.EngagementService.UserFavoriteRecipe(user.Id, recipeId); err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Something went wrong. %s.", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": err.Error(),
// })
// } else {
// ctx.Status(http.StatusNoContent)
// }
// }
// Ensure user is logged in with a valid account // DEPRECATED: As of September 4th, 2025.
user := deps.UserService.GetAuthenicatedUser(ctx) // func EngagementMakeRecipe(ctx *gin.Context) {
if user == nil { // deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
// Log (stale) user out //
domain.SetCookie(ctx, "jwt_token", "", -1) // // Ensure user is logged in with a valid account
domain.SetCookie(ctx, "search-filters", "", -1) // user := deps.UserService.GetAuthenicatedUser(ctx)
} // if user == nil {
// // Log (stale) user out
if !domain.IsLoggedIn(ctx) || user == nil { // domain.SetCookie(ctx, "jwt_token", "", -1)
if _, err := deps.EngagementService.ViewRecipe(recipeId); err != nil { // domain.SetCookie(ctx, "search-filters", "", -1)
ctx.JSON(http.StatusInternalServerError, gin.H{ // }
"status": http.StatusInternalServerError, //
"message": err.Error(), // if !domain.IsLoggedIn(ctx) || user == nil {
}) // ctx.Header("HX-Redirect", domain.WEB_LOGIN)
} else { // ctx.Status(http.StatusOK)
ctx.Header("HX-Redirect", fmt.Sprintf(domain.WEB_RECIPE, recipeId)) // return
ctx.Status(http.StatusOK) // }
} //
return // id := ctx.Param("id")
} // recipeId, _ := strconv.Atoi(id)
//
// We caught nil already, we can assume the user exists // if _, err := deps.EngagementService.UserMakeRecipe(user.Id, recipeId); err != nil {
if _, err := deps.EngagementService.UserViewRecipe(user.Id, recipeId); err != nil { // components.RenderErrorBanner(ctx, err.Error())
ctx.JSON(http.StatusInternalServerError, gin.H{ // ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError, // "status": http.StatusInternalServerError,
"message": err.Error(), // "message": err.Error(),
}) // })
} else { // } else {
ctx.Header("HX-Redirect", fmt.Sprintf(domain.WEB_RECIPE, recipeId)) // ctx.Status(http.StatusNoContent)
ctx.Status(http.StatusOK) // }
} // }
}
func EngagementShareRecipe(ctx *gin.Context) {
deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
recipeId, _ := strconv.Atoi(ctx.Param("id"))
// Ensure user is logged in with a valid account
user := deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// Log (stale) user out
domain.SetCookie(ctx, "jwt_token", "", -1)
domain.SetCookie(ctx, "search-filters", "", -1)
}
if !domain.IsLoggedIn(ctx) || user == nil {
if _, err := deps.EngagementService.ShareRecipe(recipeId); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
} else {
ctx.Status(http.StatusNoContent)
}
return
}
if _, err := deps.EngagementService.UserShareRecipe(user.Id, recipeId); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
} else {
ctx.Status(http.StatusNoContent)
}
}
func EngagementFavoriteRecipe(ctx *gin.Context) {
deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
// Ensure user is logged in with a valid account
user := deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// Log (stale) user out
domain.SetCookie(ctx, "jwt_token", "", -1)
domain.SetCookie(ctx, "search-filters", "", -1)
}
if !domain.IsLoggedIn(ctx) || user == nil {
ctx.Header("HX-Redirect", domain.WEB_LOGIN)
ctx.Status(http.StatusOK)
return
}
id := ctx.Param("id")
recipeId, _ := strconv.Atoi(id)
if _, err := deps.EngagementService.UserFavoriteRecipe(user.Id, recipeId); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
} else {
ctx.Status(http.StatusNoContent)
}
}
func EngagementMakeRecipe(ctx *gin.Context) {
deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
// Ensure user is logged in with a valid account
user := deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// Log (stale) user out
domain.SetCookie(ctx, "jwt_token", "", -1)
domain.SetCookie(ctx, "search-filters", "", -1)
}
if !domain.IsLoggedIn(ctx) || user == nil {
ctx.Header("HX-Redirect", domain.WEB_LOGIN)
ctx.Status(http.StatusOK)
return
}
id := ctx.Param("id")
recipeId, _ := strconv.Atoi(id)
if _, err := deps.EngagementService.UserMakeRecipe(user.Id, recipeId); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
} else {
ctx.Status(http.StatusNoContent)
}
}

View File

@ -1,299 +1,296 @@
package handlers package handlers
import ( // DEPRECATED: As of September 4th, 2025.
"encoding/json" // func LoginPage(ctx *gin.Context) {
"fmt" // title := "Potion - Login"
"net/http" // page := pages.LoginPage()
"strconv" //
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
"github.com/a-h/templ" // DEPRECATED: As of September 4th, 2025.
"github.com/gin-gonic/gin" // func HomePage(ctx *gin.Context) {
domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe" // deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
domain "github.com/haydenhargreaves/Potion/internal/domain/server" //
domainServer "github.com/haydenhargreaves/Potion/internal/domain/server" // loggedIn := domain.IsLoggedIn(ctx)
layouts "github.com/haydenhargreaves/Potion/internal/templates/layouts" //
pages "github.com/haydenhargreaves/Potion/internal/templates/pages" // // Ensure user is logged in with a valid account
templates "github.com/haydenhargreaves/Potion/internal/templates/pages" // if user := deps.UserService.GetAuthenicatedUser(ctx); user == nil {
) // // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// loggedIn = false
// }
//
// var page templ.Component
// if loggedIn {
// userId := ctx.MustGet("userId").(int)
// madeRecipes, err := deps.RecipeService.GetUserMadeRecipes(userId, 6)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting made recipes. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting made recipes. %s\n", err.Error()),
// })
// return
// }
// viewedRecipes, err := deps.RecipeService.GetUserViewedRecipes(userId, 6)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting viewed recipes. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting viewed recipes. %s\n", err.Error()),
// })
// return
// }
//
// // Get the recipe of the week
// recipeOfTheWeek, err := deps.RecipeService.GetRecipeOfTheWeek(&userId)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()),
// })
// return
// }
//
// if bytes, err := ctx.Cookie("search-filters"); err != nil {
// fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
// page = templates.HomePage(true, viewedRecipes, madeRecipes, recipeOfTheWeek, nil)
// } else {
// var filters domainRecipe.SearchFilters
// if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
// fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
// page = templates.HomePage(true, viewedRecipes, madeRecipes, recipeOfTheWeek, nil)
// } else {
// page = templates.HomePage(true, viewedRecipes, madeRecipes, recipeOfTheWeek, &filters)
// }
// }
// } else {
// // Get the recipe of the week
// recipeOfTheWeek, err := deps.RecipeService.GetRecipeOfTheWeek(nil)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()),
// })
// return
// }
//
// if bytes, err := ctx.Cookie("search-filters"); err != nil {
// fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
// page = templates.HomePage(false, nil, nil, recipeOfTheWeek, nil)
// } else {
// var filters domainRecipe.SearchFilters
// if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
// fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
// page = templates.HomePage(false, nil, nil, recipeOfTheWeek, nil)
// } else {
// page = templates.HomePage(false, nil, nil, recipeOfTheWeek, &filters)
// }
// }
// }
//
// title := "Potion - Home"
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
func LoginPage(ctx *gin.Context) { // DEPRECATED: As of September 4th, 2025.
title := "Potion - Login" // func FavoritesPage(ctx *gin.Context) {
page := pages.LoginPage() // // If not logged in, direct to the login page
// if !domainServer.IsLoggedIn(ctx) {
// ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
// return
// }
//
// title := "Potion - Favorites"
// var page templ.Component
//
// // Get filters from cookies
// if bytes, err := ctx.Cookie("search-filters"); err != nil {
// fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
// page = pages.FavoritesPage(nil)
// } else {
// var filters domainRecipe.SearchFilters
// if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
// fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
// page = pages.FavoritesPage(nil)
// } else {
// page = pages.FavoritesPage(&filters)
// }
// }
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
//
// func CreatePage(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
//
// // If not logged in, direct to the login page
// if !domainServer.IsLoggedIn(ctx) {
// ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
// return
// }
//
// // Ensure user is logged in with a valid account
// if user := deps.UserService.GetAuthenicatedUser(ctx); user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
//
// ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
// return
// }
//
// title := "Potion - Create"
// page := pages.CreatePage()
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page)) // DEPRECATED: As of September 4th, 2025.
} // func ProfilePage(ctx *gin.Context) {
// // If not logged in, direct to the login page
// if !domainServer.IsLoggedIn(ctx) {
// ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
// return
// }
//
// // Else, get the user data
// deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
// user := deps.UserService.GetAuthenicatedUser(ctx)
// if user == nil {
// // User is failing to be found, direct to the login page
// ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
// return
// }
//
// recipes, err := deps.RecipeService.GetUserRecipes(user.Id)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting recipes. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting recipes. %s\n", err.Error()),
// })
// return
// }
//
// favorites, err := deps.RecipeService.GetUserFavoriteRecipes(user.Id)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting favorite recipes. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting favorite recipes. %s\n", err.Error()),
// })
// return
// }
//
// // Get the engagement data, not sure what will happen when errors occur
// engagements, err := deps.EngagementService.GetUserEngagement(user.Id, 6)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting user engagements. %s\n", err.Error()))
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": fmt.Sprintf("Error getting user engagements. %s\n", err.Error()),
// })
// return
// }
//
// title := "Potion - Profile"
// page := pages.ProfilePage(*user, recipes, favorites, engagements)
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
func HomePage(ctx *gin.Context) { // DEPRECATED: As of September 4th, 2025.
deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies) // func ListPage(ctx *gin.Context) {
// title := "Potion - Shopping List"
// page := pages.ListPage()
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
loggedIn := domain.IsLoggedIn(ctx) // DEPRECATED: As of September 4th, 2025.
// func RecipePage(ctx *gin.Context) {
// // Call recipe service to get via ID
// deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
// id := ctx.Param("id")
//
// // Parse ID
// parsed, err := strconv.Atoi(id)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("ERROR: %s", err.Error()))
// ctx.JSON(400, err.Error())
// return
// }
//
// // Get signed in user, if they exist
// var userId *int = nil
// var loggedIn = domainServer.IsLoggedIn(ctx)
//
// // Ensure user is logged in with a valid account
// if user := deps.UserService.GetAuthenicatedUser(ctx); user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// loggedIn = false
// }
//
// if loggedIn {
// storeId := ctx.MustGet("userId").(int)
// userId = &storeId
// }
//
// // Get recipe
// recipe, err := deps.RecipeService.GetRecipe(parsed, userId)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("ERROR: %s", err.Error()))
// ctx.JSON(400, err.Error())
// return
// }
//
// // Get user (owner)
// user, err := deps.UserService.GetUser(recipe.UserId)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("ERROR: %s", err.Error()))
// ctx.JSON(400, err.Error())
// return
// }
//
// title := "Potion - View Recipe"
// page := pages.RecipePage(*recipe, *user, loggedIn, deps.EnvironmentConfig.Domain)
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
//
// func SearchPage(ctx *gin.Context) {
// var page templ.Component
// // Get filters from cookies
// if bytes, err := ctx.Cookie("search-filters"); err != nil {
// fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
// page = pages.SearchPage(nil, false)
// } else {
// var filters domainRecipe.SearchFilters
// if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
// fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
// page = pages.SearchPage(nil, false)
// } else {
// page = pages.SearchPage(&filters, true)
// }
// }
//
// title := "Potion - Recipe Search"
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
// Ensure user is logged in with a valid account // DEPRECATED: As of September 4th, 2025.
if user := deps.UserService.GetAuthenicatedUser(ctx); user == nil { // func NotFoundPage(ctx *gin.Context) {
// Log (stale) user out // title := "Potion - Not Found"
domain.SetCookie(ctx, "jwt_token", "", -1) // page := pages.NotFoundPage()
domain.SetCookie(ctx, "search-filters", "", -1) //
loggedIn = false // ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
} // }
var page templ.Component
if loggedIn {
userId := ctx.MustGet("userId").(int)
madeRecipes, err := deps.RecipeService.GetUserMadeRecipes(userId, 6)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting made recipes. %s\n", err.Error()),
})
return
}
viewedRecipes, err := deps.RecipeService.GetUserViewedRecipes(userId, 6)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting made recipes. %s\n", err.Error()),
})
return
}
// Get the recipe of the week
recipeOfTheWeek, err := deps.RecipeService.GetRecipeOfTheWeek(&userId)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting made recipes. %s\n", err.Error()),
})
return
}
if bytes, err := ctx.Cookie("search-filters"); err != nil {
fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
page = templates.HomePage(true, viewedRecipes, madeRecipes, recipeOfTheWeek, nil)
} else {
var filters domainRecipe.SearchFilters
if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
page = templates.HomePage(true, viewedRecipes, madeRecipes, recipeOfTheWeek, nil)
} else {
page = templates.HomePage(true, viewedRecipes, madeRecipes, recipeOfTheWeek, &filters)
}
}
} else {
// Get the recipe of the week
recipeOfTheWeek, err := deps.RecipeService.GetRecipeOfTheWeek(nil)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting made recipes. %s\n", err.Error()),
})
return
}
if bytes, err := ctx.Cookie("search-filters"); err != nil {
fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
page = templates.HomePage(false, nil, nil, recipeOfTheWeek, nil)
} else {
var filters domainRecipe.SearchFilters
if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
page = templates.HomePage(false, nil, nil, recipeOfTheWeek, nil)
} else {
page = templates.HomePage(false, nil, nil, recipeOfTheWeek, &filters)
}
}
}
title := "Potion - Home"
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func FavoritesPage(ctx *gin.Context) {
// If not logged in, direct to the login page
if !domainServer.IsLoggedIn(ctx) {
ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
return
}
title := "Potion - Favorites"
var page templ.Component
// Get filters from cookies
if bytes, err := ctx.Cookie("search-filters"); err != nil {
fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
page = pages.FavoritesPage(nil)
} else {
var filters domainRecipe.SearchFilters
if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
page = pages.FavoritesPage(nil)
} else {
page = pages.FavoritesPage(&filters)
}
}
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func CreatePage(ctx *gin.Context) {
deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
// If not logged in, direct to the login page
if !domainServer.IsLoggedIn(ctx) {
ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
return
}
// Ensure user is logged in with a valid account
if user := deps.UserService.GetAuthenicatedUser(ctx); user == nil {
// Log (stale) user out
domain.SetCookie(ctx, "jwt_token", "", -1)
domain.SetCookie(ctx, "search-filters", "", -1)
ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
return
}
title := "Potion - Create"
page := pages.CreatePage()
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func ProfilePage(ctx *gin.Context) {
// If not logged in, direct to the login page
if !domainServer.IsLoggedIn(ctx) {
ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
return
}
// Else, get the user data
deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
user := deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// User is failing to be found, direct to the login page
ctx.Redirect(http.StatusSeeOther, domainServer.WEB_LOGIN)
return
}
recipes, err := deps.RecipeService.GetUserRecipes(user.Id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting recipes. %s\n", err.Error()),
})
return
}
favorites, err := deps.RecipeService.GetUserFavoriteRecipes(user.Id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting recipes. %s\n", err.Error()),
})
return
}
// Get the engagement data, not sure what will happen when errors occur
engagements, err := deps.EngagementService.GetUserEngagement(user.Id, 6)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting user engagements. %s\n", err.Error()),
})
return
}
title := "Potion - Profile"
page := pages.ProfilePage(*user, recipes, favorites, engagements)
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func ListPage(ctx *gin.Context) {
title := "Potion - Shopping List"
page := pages.ListPage()
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
// TODO: Figure out how to handle errors, think we just need a simple display.
func RecipePage(ctx *gin.Context) {
// Call recipe service to get via ID
deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
id := ctx.Param("id")
// Parse ID
parsed, err := strconv.Atoi(id)
if err != nil {
fmt.Printf("ERROR: %s\n", err.Error())
ctx.JSON(400, err.Error())
return
}
// Get signed in user, if they exist
var userId *int = nil
var loggedIn = domainServer.IsLoggedIn(ctx)
// Ensure user is logged in with a valid account
if user := deps.UserService.GetAuthenicatedUser(ctx); user == nil {
// Log (stale) user out
domain.SetCookie(ctx, "jwt_token", "", -1)
domain.SetCookie(ctx, "search-filters", "", -1)
loggedIn = false
}
if loggedIn {
storeId := ctx.MustGet("userId").(int)
userId = &storeId
}
// Get recipe
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 (owner)
user, err := deps.UserService.GetUser(recipe.UserId)
if err != nil {
fmt.Printf("ERROR: %s\n", err.Error())
ctx.JSON(400, err.Error())
return
}
title := "Potion - View Recipe"
page := pages.RecipePage(*recipe, *user, loggedIn, deps.EnvironmentConfig.Domain)
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func SearchPage(ctx *gin.Context) {
var page templ.Component
// Get filters from cookies
if bytes, err := ctx.Cookie("search-filters"); err != nil {
fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
page = pages.SearchPage(nil, false)
} else {
var filters domainRecipe.SearchFilters
if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
page = pages.SearchPage(nil, false)
} else {
page = pages.SearchPage(&filters, true)
}
}
title := "Potion - Recipe Search"
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func NotFoundPage(ctx *gin.Context) {
title := "Potion - Not Found"
page := pages.NotFoundPage()
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}

View File

@ -1,142 +1,136 @@
package handlers package handlers
import ( // DEPRECATED: As of September 4th, 2025.
"encoding/json" // const CREATE_ERROR_HTML = `
"fmt" // <p id="response" class="text-sm text-red-500 px-4 py-1 bg-red-100 rounded-full w-fit">
"net/http" // Uh oh! Something went wrong when creating your recipe. Please try again. %s
"strconv" // </p>
"time" // `
"github.com/gin-gonic/gin" // DEPRECATED: As of September 4th, 2025.
domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe" // func CreateRecipe(ctx *gin.Context) {
domain "github.com/haydenhargreaves/Potion/internal/domain/server" // deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
templates "github.com/haydenhargreaves/Potion/internal/templates/pages" //
) // recipe, err := deps.RecipeService.CreateRecipe(ctx)
// if err != nil {
const CREATE_ERROR_HTML = ` // components.RenderErrorBanner(ctx, err.Error())
<p id="response" class="text-sm text-red-500 px-4 py-1 bg-red-100 rounded-full w-fit"> // ctx.String(http.StatusOK, CREATE_ERROR_HTML, err.Error())
Uh oh! Something went wrong when creating your recipe. Please try again. %s // return
</p> // }
` //
// // Send HTMX redirection
func CreateRecipe(ctx *gin.Context) { // url := fmt.Sprintf(domain.WEB_RECIPE, recipe.Id)
deps := ctx.MustGet("deps").(*domain.InjectedDependencies) // ctx.Header("HX-Redirect", url)
// ctx.Status(http.StatusCreated)
recipe, err := deps.RecipeService.CreateRecipe(ctx) // }
if err != nil {
ctx.String(http.StatusOK, CREATE_ERROR_HTML, err.Error())
return
}
// Send HTMX redirection
url := fmt.Sprintf(domain.WEB_RECIPE, recipe.Id)
ctx.Header("HX-Redirect", url)
ctx.Status(http.StatusCreated)
}
// toBits converts an array of stringified numbers into a single summed value // toBits converts an array of stringified numbers into a single summed value
func toBits(arr []string) (bits int) { //
for _, x := range arr { // DEPRECATED: As of September 4th, 2025.
num, _ := strconv.Atoi(x) // func toBits(arr []string) (bits int) {
bits += num // for _, x := range arr {
} // num, _ := strconv.Atoi(x)
return // bits += num
} // }
// return
// }
// TODO: I don't love doing all of this here, but it seems to be the only way to get it to work... // DEPRECATED: As of September 4th, 2025.
func SearchRecipes(ctx *gin.Context) { // func SearchRecipes(ctx *gin.Context) {
deps := ctx.MustGet("deps").(*domain.InjectedDependencies) // deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
// // create filters
// filters := domainRecipe.SearchFilters{
// Search: ctx.PostForm("search"), // string, search query for titles
// MealType: toBits(ctx.PostFormArray("meal")),
// Time: toBits(ctx.PostFormArray("time")),
// Difficulty: toBits(ctx.PostFormArray("difficulty")),
// ServingSize: toBits(ctx.PostFormArray("serving")),
// }
//
// // Set the filters into the cookies, so they can be reloaded
// if bytes, err := json.Marshal(filters); err == nil {
// domain.SetCookie(ctx, "search-filters", string(bytes), time.Hour*24)
// // ctx.SetCookie(
// // "search-filters",
// // string(bytes),
// // int(time.Now().Add(24*time.Hour).Sub(time.Now()).Seconds()),
// // "/",
// // true,
// // )
// }
//
// redirect := ctx.PostForm("redirect")
// if redirect == "true" {
// ctx.Header("HX-Redirect", domain.WEB_SEARCH)
// ctx.Status(http.StatusOK)
// return
// }
//
// // Get user if logged in, so we can get favorite status
// var userId *int = nil
// if domain.IsLoggedIn(ctx) {
// id := ctx.MustGet("userId").(int)
// userId = &id
// }
//
// // TODO: Not sure if we need to ensure the user is valid here
//
// // We don't care about favorite status, so use false
// recipes, err := deps.RecipeService.SearchRecipes(filters, userId, false)
// if err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusOK, gin.H{"error": err.Error()})
// }
//
// // Render content as the response
// ctx.Status(200)
// templates.ResultList(recipes).Render(ctx.Request.Context(), ctx.Writer)
// }
// create filters // DEPRECATED: As of September 4th, 2025.
filters := domainRecipe.SearchFilters{ // func SearchRecipesFavorites(ctx *gin.Context) {
Search: ctx.PostForm("search"), // string, search query for titles // deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
MealType: toBits(ctx.PostFormArray("meal")), //
Time: toBits(ctx.PostFormArray("time")), // // create filters
Difficulty: toBits(ctx.PostFormArray("difficulty")), // filters := domainRecipe.SearchFilters{
ServingSize: toBits(ctx.PostFormArray("serving")), // Search: ctx.PostForm("search"), // string, search query for titles
} // MealType: toBits(ctx.PostFormArray("meal")),
// Time: toBits(ctx.PostFormArray("time")),
// Set the filters into the cookies, so they can be reloaded // Difficulty: toBits(ctx.PostFormArray("difficulty")),
if bytes, err := json.Marshal(filters); err == nil { // ServingSize: toBits(ctx.PostFormArray("serving")),
domain.SetCookie(ctx, "search-filters", string(bytes), time.Hour*24) // }
// ctx.SetCookie( //
// "search-filters", // // Set the filters into the cookies, so they can be reloaded
// string(bytes), // if bytes, err := json.Marshal(filters); err == nil {
// int(time.Now().Add(24*time.Hour).Sub(time.Now()).Seconds()), // domain.SetCookie(ctx, "search-filters", string(bytes), time.Hour*24)
// "/", // // ctx.SetCookie(
// "", // TODO: Need an actual domain // // "search-filters",
// false, // TODO: True in prod // // string(bytes),
// true, // // int(time.Now().Add(24*time.Hour).Sub(time.Now()).Seconds()),
// ) // // "/",
} // // "", // TODO: Need an actual domain
// // false, // TODO: True in prod
redirect := ctx.PostForm("redirect") // // true,
if redirect == "true" { // // )
ctx.Header("HX-Redirect", domain.WEB_SEARCH) // }
ctx.Status(http.StatusOK) //
return // // TODO: Error here if they're not logged in?
} // // Get user data (they should be logged in)
// if !domain.IsLoggedIn(ctx) {
// Get user if logged in, so we can get favorite status // components.RenderErrorBanner(ctx, "User is not logged in. User will be nil.")
var userId *int = nil // ctx.JSON(http.StatusOK, gin.H{"error": "User is not logged in. User will be nil."})
if domain.IsLoggedIn(ctx) { // }
id := ctx.MustGet("userId").(int) //
userId = &id // userId := ctx.MustGet("userId").(int)
} //
// recipes, err := deps.RecipeService.SearchRecipes(filters, &userId, true)
// TODO: Not sure if we need to ensure the user is valid here // if err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// We don't care about favorite status, so use false // ctx.JSON(http.StatusOK, gin.H{"error": err.Error()})
recipes, err := deps.RecipeService.SearchRecipes(filters, userId, false) // }
if err != nil { //
ctx.JSON(http.StatusOK, gin.H{"error": err.Error()}) // // Render content as the response
} // ctx.Status(200)
// templates.FavoriteList(recipes).Render(ctx.Request.Context(), ctx.Writer)
// Render content as the response // }
ctx.Status(200)
templates.ResultList(recipes).Render(ctx.Request.Context(), ctx.Writer)
}
func SearchRecipesFavorites(ctx *gin.Context) {
deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
// create filters
filters := domainRecipe.SearchFilters{
Search: ctx.PostForm("search"), // string, search query for titles
MealType: toBits(ctx.PostFormArray("meal")),
Time: toBits(ctx.PostFormArray("time")),
Difficulty: toBits(ctx.PostFormArray("difficulty")),
ServingSize: toBits(ctx.PostFormArray("serving")),
}
// Set the filters into the cookies, so they can be reloaded
if bytes, err := json.Marshal(filters); err == nil {
domain.SetCookie(ctx, "search-filters", string(bytes), time.Hour*24)
// ctx.SetCookie(
// "search-filters",
// string(bytes),
// int(time.Now().Add(24*time.Hour).Sub(time.Now()).Seconds()),
// "/",
// "", // TODO: Need an actual domain
// false, // TODO: True in prod
// true,
// )
}
// TODO: Error here if they're not logged in?
// Get user data (they should be logged in)
if !domain.IsLoggedIn(ctx) {
ctx.JSON(http.StatusOK, gin.H{"error": "User is not logged in. User will be nil."})
}
userId := ctx.MustGet("userId").(int)
recipes, err := deps.RecipeService.SearchRecipes(filters, &userId, true)
if err != nil {
ctx.JSON(http.StatusOK, gin.H{"error": err.Error()})
}
// Render content as the response
ctx.Status(200)
templates.FavoriteList(recipes).Render(ctx.Request.Context(), ctx.Writer)
}

View File

@ -1,76 +1,71 @@
package handlers package handlers
import ( // DEPRECATED: As of September 4th, 2025.
"fmt" // const TAG_HTML = `
"net/http" // <li
"strings" // hx-post="%s"
// hx-trigger="click"
// hx-target="#tag-list"
// hx-swap="innerHTML"
// hx-include="#tag-list"
// hx-vals='{"target": "%s"}'
// class="flex text-xs items-center bg-blue-100 w-fit px-2 py-1 rounded-full gap-x-1 select-none cursor-pointer hover:bg-blue-200 duration-300">
// &times; %s
// </li>
// `
"github.com/gin-gonic/gin" // DEPRECATED: As of September 4th, 2025.
domain "github.com/haydenhargreaves/Potion/internal/domain/server" // const TAG_LIST_HTML = `
) // <input
// hx-swap-oob="outerHTML"
// type="hidden"
// name="tags"
// id="tags"
// value="%s"
// />
// `
const TAG_HTML = ` // DEPRECATED: As of September 4th, 2025.
<li // func NewTag(ctx *gin.Context) {
hx-post="%s" // tag := strings.ToLower(ctx.PostForm("tag"))
hx-trigger="click" // tags := strings.Split(ctx.PostForm("tags"), ",")
hx-target="#tag-list" //
hx-swap="innerHTML" // tags = append([]string{tag}, tags...)
hx-include="#tag-list" //
hx-vals='{"target": "%s"}' // var html string
class="flex text-xs items-center bg-blue-100 w-fit px-2 py-1 rounded-full gap-x-1 select-none cursor-pointer hover:bg-blue-200 duration-300"> // var cleaned_tags []string
&times; %s // for _, tag := range tags {
</li> // if tag != "" {
` // html += fmt.Sprintf(TAG_HTML, domain.STATE_TAGS_DELETE, tag, tag)
//
// // Ensure that the list provided does not contain blank spaces.
// // This is another measure to ensure this state is bulletproof.
// cleaned_tags = append(cleaned_tags, tag)
// }
// }
//
// // Execute OOB swap for the tags
// html += fmt.Sprintf(TAG_LIST_HTML, strings.Join(cleaned_tags, ","))
//
// ctx.String(http.StatusOK, html)
// }
const TAG_LIST_HTML = ` // DEPRECATED: As of September 4th, 2025.
<input // func DeleteTag(ctx *gin.Context) {
hx-swap-oob="outerHTML" // tags := strings.Split(ctx.PostForm("tags"), ",")
type="hidden" // target := ctx.PostForm("target")
name="tags" //
id="tags" // var html string
value="%s" // var new_tags []string
/> // for _, tag := range tags {
` // if tag != target && tag != "" {
// html += fmt.Sprintf(TAG_HTML, domain.STATE_TAGS_DELETE ,tag, tag)
func NewTag(ctx *gin.Context) { // new_tags = append(new_tags, tag)
tag := strings.ToLower(ctx.PostForm("tag")) // }
tags := strings.Split(ctx.PostForm("tags"), ",") // }
//
tags = append([]string{tag}, tags...) // // Execute OOB swap for the tags
// html += fmt.Sprintf(TAG_LIST_HTML, strings.Join(new_tags, ","))
var html string //
var cleaned_tags []string // ctx.String(http.StatusOK, html)
for _, tag := range tags { // }
if tag != "" {
html += fmt.Sprintf(TAG_HTML, domain.STATE_TAGS_DELETE, tag, tag)
// Ensure that the list provided does not contain blank spaces.
// This is another measure to ensure this state is bulletproof.
cleaned_tags = append(cleaned_tags, tag)
}
}
// Execute OOB swap for the tags
html += fmt.Sprintf(TAG_LIST_HTML, strings.Join(cleaned_tags, ","))
ctx.String(http.StatusOK, html)
}
func DeleteTag(ctx *gin.Context) {
tags := strings.Split(ctx.PostForm("tags"), ",")
target := ctx.PostForm("target")
var html string
var new_tags []string
for _, tag := range tags {
if tag != target && tag != "" {
html += fmt.Sprintf(TAG_HTML, domain.STATE_TAGS_DELETE ,tag, tag)
new_tags = append(new_tags, tag)
}
}
// Execute OOB swap for the tags
html += fmt.Sprintf(TAG_LIST_HTML, strings.Join(new_tags, ","))
ctx.String(http.StatusOK, html)
}

View File

@ -1,85 +1,83 @@
package handlers package handlers
import ( // DEPRECATED: As of September 4th, 2025.
"fmt" // func GetUserRecipes(ctx *gin.Context) {
"net/http" // deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
// // Ensure user is logged in with a valid account
// user := deps.UserService.GetAuthenicatedUser(ctx)
// if user == nil {
// // Log (stale) user out
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// }
//
// // Ensure logged in
// if !domain.IsLoggedIn(ctx) || user == nil {
// components.RenderErrorBanner(ctx, "User is not authorized to access this endpoint. Please login to continue.")
// ctx.JSON(http.StatusUnauthorized, gin.H{
// "status": http.StatusUnauthorized,
// "message": "User is not authorized to access this endpoint. Please login to continue.",
// "recipes": nil,
// })
// return
// }
//
// recipes, err := deps.RecipeService.GetUserRecipes(user.Id)
// if err != nil {
// components.RenderErrorBanner(ctx, fmt.Sprintf("Could not get user recipes. %s", err.Error()))
// ctx.JSON(http.StatusBadRequest, gin.H{
// "status": http.StatusBadRequest,
// "message": fmt.Sprintf("Could not get user recipes. %s", err.Error()),
// "recipes": nil,
// })
// return
// }
//
// ctx.JSON(http.StatusOK, gin.H{
// "status": http.StatusOK,
// "message": "User recipes successfully retrieved.",
// "recipes": recipes,
// })
// }
"github.com/gin-gonic/gin" // DEPRECATED: As of September 4th, 2025.
domain "github.com/haydenhargreaves/Potion/internal/domain/server" // func GetUserFavoriteRecipes(ctx *gin.Context) {
) // deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
func GetUserRecipes(ctx *gin.Context) { // // Ensure user is logged in with a valid account
deps := ctx.MustGet("deps").(*domain.InjectedDependencies) // user := deps.UserService.GetAuthenicatedUser(ctx)
// if user == nil {
// Ensure user is logged in with a valid account // // Log (stale) user out
user := deps.UserService.GetAuthenicatedUser(ctx) // domain.SetCookie(ctx, "jwt_token", "", -1)
if user == nil { // domain.SetCookie(ctx, "search-filters", "", -1)
// Log (stale) user out // }
domain.SetCookie(ctx, "jwt_token", "", -1) //
domain.SetCookie(ctx, "search-filters", "", -1) // // Ensure logged in
} // if !domain.IsLoggedIn(ctx) || user == nil {
// components.RenderErrorBanner(ctx, "User is not authorized to access this endpoint. Please login to continue.")
// Ensure logged in // ctx.JSON(http.StatusUnauthorized, gin.H{
if !domain.IsLoggedIn(ctx) || user == nil { // "status": http.StatusUnauthorized,
ctx.JSON(http.StatusUnauthorized, gin.H{ // "message": "User is not authorized to access this endpoint. Please login to continue.",
"status": http.StatusUnauthorized, // "recipes": nil,
"message": "User is not authorized to access this endpoint. Please login to continue.", // })
"recipes": nil, // return
}) // }
return //
} // recipes, err := deps.RecipeService.GetUserFavoriteRecipes(user.Id)
// if err != nil {
recipes, err := deps.RecipeService.GetUserRecipes(user.Id) // components.RenderErrorBanner(ctx, fmt.Sprintf("Could not get favorite recipes. %s", err.Error()))
if err != nil { // ctx.JSON(http.StatusBadRequest, gin.H{
ctx.JSON(http.StatusBadRequest, gin.H{ // "status": http.StatusBadRequest,
"status": http.StatusBadRequest, // "message": fmt.Sprintf("Could not get favorite recipes. %s", err.Error()),
"message": fmt.Sprintf("Could not get user recipes. %s", err.Error()), // "recipes": nil,
"recipes": nil, // })
}) // return
return // }
} //
// ctx.JSON(http.StatusOK, gin.H{
ctx.JSON(http.StatusOK, gin.H{ // "status": http.StatusOK,
"status": http.StatusOK, // "message": "User recipes successfully retrieved.",
"message": "User recipes successfully retrieved.", // "recipes": recipes,
"recipes": recipes, // })
}) // }
}
func GetUserFavoriteRecipes(ctx *gin.Context) {
deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
// Ensure user is logged in with a valid account
user := deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// Log (stale) user out
domain.SetCookie(ctx, "jwt_token", "", -1)
domain.SetCookie(ctx, "search-filters", "", -1)
}
// Ensure logged in
if !domain.IsLoggedIn(ctx) || user == nil {
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": "User is not authorized to access this endpoint. Please login to continue.",
"recipes": nil,
})
return
}
recipes, err := deps.RecipeService.GetUserFavoriteRecipes(user.Id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("Could not get user recipes. %s", err.Error()),
"recipes": nil,
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "User recipes successfully retrieved.",
"recipes": recipes,
})
}

View File

@ -0,0 +1,45 @@
package server
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
"github.com/haydenhargreaves/Potion/internal/templates/components"
)
// GoogleLogin directs the user to Googles select user login page. Once the user has selected an
// account, they will be directed to the GoogleCallback handler where the main logic resides.
func (s *Server) GoogleLoginHandler(ctx *gin.Context) {
url := s.deps.AuthService.GetGoogleAuthUrl()
ctx.Redirect(http.StatusSeeOther, url)
}
// GoogleCallback is the callback handler when the user successfully logs in with their Google
// account. They will be directed here and a JWT is generated. This JWT is stored in the users
// cookies and will be used by protected routes to validate their login status.
func (s *Server) GoogleCallbackHandler(ctx *gin.Context) {
var (
state string = ctx.Query("state")
code string = ctx.Query("code")
)
if jwt, err := s.deps.AuthService.GoogleAuthSuccess(state, code); err != nil {
components.RenderErrorBanner(ctx, err.Error())
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} else {
s.SetCookie(ctx, "jwt_token", jwt, time.Hour*24*7)
ctx.Redirect(http.StatusSeeOther, "/")
}
}
// Logout removes the token from the user's browser. Effectively "logging them out." Routes that
// require authentication will require the user to sign back in before accessing them again.
// This route will direct the user back to the home page.
func (s *Server) LogoutHandler(ctx *gin.Context) {
s.SetCookie(ctx, "jwt_token", "", -1)
s.SetCookie(ctx, "search-filters", "", -1)
ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME)
}

View File

@ -0,0 +1,53 @@
package server
import (
"time"
"github.com/gin-gonic/gin"
)
// SetCookie sets a cookie value with a duration provided. This function handles setting the security
// configuration as well as the domain. These values are based on the EnvironmentConfig, therefore
// the value should be set. Nothing is returned by this function, but the cookie will be set.
//
// This function can also be used to clear cookies, if a blank value ("") and invalid duration (-1)
// is provided.
//
// If 0 is provided as the duration, then a session cookie is created, which will be cleared when
// the browser is closed.
func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.Duration) {
var (
path string = "/"
httpOnly bool = true
maxAge int
secure bool
domain string
)
if duration < 0 {
// Delete the cookie
maxAge = -1
} else if duration == 0 {
// Session cookie, clears when browser is closed
maxAge = 0
} else {
// Normal calculation
maxAge = int(time.Now().Add(duration).Sub(time.Now()).Seconds())
}
if s.deps.EnvironmentConfig.Environment == "prod" {
secure = true
domain = s.deps.EnvironmentConfig.Domain
} else if s.deps.EnvironmentConfig.Environment == "dev" {
secure = false
domain = s.deps.EnvironmentConfig.Domain
} else {
// Defaults
secure = false
domain = ""
}
ctx.SetCookie(name, value, maxAge, path, domain, secure, httpOnly)
}

View File

@ -0,0 +1,142 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
"github.com/haydenhargreaves/Potion/internal/templates/components"
)
func (s *Server) EngagementViewRecipeHandler(ctx *gin.Context) {
recipeId, _ := strconv.Atoi(ctx.Param("id"))
// Ensure user is logged in with a valid account
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// Log (stale) user out
s.SetCookie(ctx, "jwt_token", "", -1)
s.SetCookie(ctx, "search-filters", "", -1)
}
if !domain.IsLoggedIn(ctx) || user == nil {
if _, err := s.deps.EngagementService.ViewRecipe(recipeId); err != nil {
components.RenderErrorBanner(ctx, err.Error())
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
} else {
ctx.Header("HX-Redirect", fmt.Sprintf(domain.WEB_RECIPE, recipeId))
ctx.Status(http.StatusOK)
}
return
}
// We caught nil already, we can assume the user exists
if _, err := s.deps.EngagementService.UserViewRecipe(user.Id, recipeId); err != nil {
components.RenderErrorBanner(ctx, err.Error())
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
} else {
ctx.Header("HX-Redirect", fmt.Sprintf(domain.WEB_RECIPE, recipeId))
ctx.Status(http.StatusOK)
}
}
func (s *Server) EngagementShareRecipeHandler(ctx *gin.Context) {
recipeId, _ := strconv.Atoi(ctx.Param("id"))
// Ensure user is logged in with a valid account
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// Log (stale) user out
s.SetCookie(ctx, "jwt_token", "", -1)
s.SetCookie(ctx, "search-filters", "", -1)
}
if !domain.IsLoggedIn(ctx) || user == nil {
if _, err := s.deps.EngagementService.ShareRecipe(recipeId); err != nil {
components.RenderErrorBanner(ctx, err.Error())
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
} else {
ctx.Status(http.StatusNoContent)
}
return
}
if _, err := s.deps.EngagementService.UserShareRecipe(user.Id, recipeId); err != nil {
components.RenderErrorBanner(ctx, err.Error())
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
} else {
ctx.Status(http.StatusNoContent)
}
}
func (s *Server) EngagementFavoriteRecipeHandler(ctx *gin.Context) {
// Ensure user is logged in with a valid account
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// Log (stale) user out
s.SetCookie(ctx, "jwt_token", "", -1)
s.SetCookie(ctx, "search-filters", "", -1)
}
if !domain.IsLoggedIn(ctx) || user == nil {
ctx.Header("HX-Redirect", domain.WEB_LOGIN)
ctx.Status(http.StatusOK)
return
}
id := ctx.Param("id")
recipeId, _ := strconv.Atoi(id)
if _, err := s.deps.EngagementService.UserFavoriteRecipe(user.Id, recipeId); err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Something went wrong. %s.", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
} else {
ctx.Status(http.StatusNoContent)
}
}
func (s *Server) EngagementMakeRecipeHandler(ctx *gin.Context) {
// Ensure user is logged in with a valid account
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// Log (stale) user out
s.SetCookie(ctx, "jwt_token", "", -1)
s.SetCookie(ctx, "search-filters", "", -1)
}
if !domain.IsLoggedIn(ctx) || user == nil {
ctx.Header("HX-Redirect", domain.WEB_LOGIN)
ctx.Status(http.StatusOK)
return
}
id := ctx.Param("id")
recipeId, _ := strconv.Atoi(id)
if _, err := s.deps.EngagementService.UserMakeRecipe(user.Id, recipeId); err != nil {
components.RenderErrorBanner(ctx, err.Error())
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": err.Error(),
})
} else {
ctx.Status(http.StatusNoContent)
}
}

View File

@ -13,12 +13,14 @@ import (
// DepedencyInjectionMiddleware injects the dependencies into the context set. This is a middleware // DepedencyInjectionMiddleware injects the dependencies into the context set. This is a middleware
// that is used to apply the required services. // that is used to apply the required services.
func DepedencyInjectionMiddleware(deps *domain.InjectedDependencies) gin.HandlerFunc { //
return func(ctx *gin.Context) { // DEPRECATED: As of September 4th, 2025.
ctx.Set("deps", deps) // func DepedencyInjectionMiddleware(deps *domain.InjectedDependencies) gin.HandlerFunc {
ctx.Next() // return func(ctx *gin.Context) {
} // ctx.Set("deps", deps)
} // ctx.Next()
// }
// }
// JwtAuthMiddleWare handles collection the JWT from the browser's cookies and setting the // JwtAuthMiddleWare handles collection the JWT from the browser's cookies and setting the
// appropriate data. If the data is not found, this middleware will do effectively nothing, by not // appropriate data. If the data is not found, this middleware will do effectively nothing, by not

View File

@ -0,0 +1,298 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/a-h/templ"
"github.com/gin-gonic/gin"
domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
"github.com/haydenhargreaves/Potion/internal/templates/components"
layouts "github.com/haydenhargreaves/Potion/internal/templates/layouts"
pages "github.com/haydenhargreaves/Potion/internal/templates/pages"
)
func (s *Server) LoginPageHandler(ctx *gin.Context) {
title := "Potion - Login"
page := pages.LoginPage()
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func (s *Server) HomePageHandler(ctx *gin.Context) {
loggedIn := domain.IsLoggedIn(ctx)
// Ensure user is logged in with a valid account
if user := s.deps.UserService.GetAuthenicatedUser(ctx); user == nil {
// Log (stale) user out
s.SetCookie(ctx, "jwt_token", "", -1)
s.SetCookie(ctx, "search-filters", "", -1)
loggedIn = false
}
var page templ.Component
if loggedIn {
userId := ctx.MustGet("userId").(int)
madeRecipes, err := s.deps.RecipeService.GetUserMadeRecipes(userId, 6)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting made recipes. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting made recipes. %s\n", err.Error()),
})
return
}
viewedRecipes, err := s.deps.RecipeService.GetUserViewedRecipes(userId, 6)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting viewed recipes. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting viewed recipes. %s\n", err.Error()),
})
return
}
// Get the recipe of the week
recipeOfTheWeek, err := s.deps.RecipeService.GetRecipeOfTheWeek(&userId)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()),
})
return
}
if bytes, err := ctx.Cookie("search-filters"); err != nil {
fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
page = pages.HomePage(true, viewedRecipes, madeRecipes, recipeOfTheWeek, nil)
} else {
var filters domainRecipe.SearchFilters
if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
page = pages.HomePage(true, viewedRecipes, madeRecipes, recipeOfTheWeek, nil)
} else {
page = pages.HomePage(true, viewedRecipes, madeRecipes, recipeOfTheWeek, &filters)
}
}
} else {
// Get the recipe of the week
recipeOfTheWeek, err := s.deps.RecipeService.GetRecipeOfTheWeek(nil)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting recipe of the week. %s\n", err.Error()),
})
return
}
if bytes, err := ctx.Cookie("search-filters"); err != nil {
fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
page = pages.HomePage(false, nil, nil, recipeOfTheWeek, nil)
} else {
var filters domainRecipe.SearchFilters
if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
page = pages.HomePage(false, nil, nil, recipeOfTheWeek, nil)
} else {
page = pages.HomePage(false, nil, nil, recipeOfTheWeek, &filters)
}
}
}
title := "Potion - Home"
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func (s *Server) FavoritesPageHandler(ctx *gin.Context) {
// If not logged in, direct to the login page
if !domain.IsLoggedIn(ctx) {
ctx.Redirect(http.StatusSeeOther, domain.WEB_LOGIN)
return
}
title := "Potion - Favorites"
var page templ.Component
// Get filters from cookies
if bytes, err := ctx.Cookie("search-filters"); err != nil {
fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
page = pages.FavoritesPage(nil)
} else {
var filters domainRecipe.SearchFilters
if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
page = pages.FavoritesPage(nil)
} else {
page = pages.FavoritesPage(&filters)
}
}
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func (s *Server) CreatePageHandler(ctx *gin.Context) {
// If not logged in, direct to the login page
if !domain.IsLoggedIn(ctx) {
ctx.Redirect(http.StatusSeeOther, domain.WEB_LOGIN)
return
}
// Ensure user is logged in with a valid account
if user := s.deps.UserService.GetAuthenicatedUser(ctx); user == nil {
// Log (stale) user out
s.SetCookie(ctx, "jwt_token", "", -1)
s.SetCookie(ctx, "search-filters", "", -1)
ctx.Redirect(http.StatusSeeOther, domain.WEB_LOGIN)
return
}
title := "Potion - Create"
page := pages.CreatePage()
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func (s *Server) ProfilePageHandler(ctx *gin.Context) {
// If not logged in, direct to the login page
if !domain.IsLoggedIn(ctx) {
ctx.Redirect(http.StatusSeeOther, domain.WEB_LOGIN)
return
}
// Else, get the user data
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// User is failing to be found, direct to the login page
ctx.Redirect(http.StatusSeeOther, domain.WEB_LOGIN)
return
}
recipes, err := s.deps.RecipeService.GetUserRecipes(user.Id)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting recipes. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting recipes. %s\n", err.Error()),
})
return
}
favorites, err := s.deps.RecipeService.GetUserFavoriteRecipes(user.Id)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting favorite recipes. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting favorite recipes. %s\n", err.Error()),
})
return
}
// Get the engagement data, not sure what will happen when errors occur
engagements, err := s.deps.EngagementService.GetUserEngagement(user.Id, 6)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Error getting user engagements. %s\n", err.Error()))
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting user engagements. %s\n", err.Error()),
})
return
}
title := "Potion - Profile"
page := pages.ProfilePage(*user, recipes, favorites, engagements)
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func (s *Server) ListPageHandler(ctx *gin.Context) {
title := "Potion - Shopping List"
page := pages.ListPage()
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func (s *Server) RecipePageHandler(ctx *gin.Context) {
// Call recipe service to get via ID
id := ctx.Param("id")
// Parse ID
parsed, err := strconv.Atoi(id)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("ERROR: %s", err.Error()))
ctx.JSON(400, err.Error())
return
}
// Get signed in user, if they exist
var userId *int = nil
var loggedIn = domain.IsLoggedIn(ctx)
// Ensure user is logged in with a valid account
if user := s.deps.UserService.GetAuthenicatedUser(ctx); user == nil {
// Log (stale) user out
s.SetCookie(ctx, "jwt_token", "", -1)
s.SetCookie(ctx, "search-filters", "", -1)
loggedIn = false
}
if loggedIn {
storeId := ctx.MustGet("userId").(int)
userId = &storeId
}
// Get recipe
recipe, err := s.deps.RecipeService.GetRecipe(parsed, userId)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("ERROR: %s", err.Error()))
ctx.JSON(400, err.Error())
return
}
// Get user (owner)
user, err := s.deps.UserService.GetUser(recipe.UserId)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("ERROR: %s", err.Error()))
ctx.JSON(400, err.Error())
return
}
title := "Potion - View Recipe"
page := pages.RecipePage(*recipe, *user, loggedIn, s.deps.EnvironmentConfig.Domain)
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func (s *Server) SearchPageHandler(ctx *gin.Context) {
var page templ.Component
// Get filters from cookies
if bytes, err := ctx.Cookie("search-filters"); err != nil {
fmt.Printf("ERROR: Failed to get search-filter cookie. %s\n", err.Error())
page = pages.SearchPage(nil, false)
} else {
var filters domainRecipe.SearchFilters
if err := json.Unmarshal([]byte(bytes), &filters); err != nil {
fmt.Printf("ERROR: Failed to unmarshal search-filter cookie. %s\n", err.Error())
page = pages.SearchPage(nil, false)
} else {
page = pages.SearchPage(&filters, true)
}
}
title := "Potion - Recipe Search"
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
func (s *Server) NotFoundPageHandler(ctx *gin.Context) {
title := "Potion - Not Found"
page := pages.NotFoundPage()
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}

View File

@ -0,0 +1,121 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
"github.com/haydenhargreaves/Potion/internal/templates/components"
templates "github.com/haydenhargreaves/Potion/internal/templates/pages"
)
const CREATE_ERROR_HTML = `
<p id="response" class="text-sm text-red-500 px-4 py-1 bg-red-100 rounded-full w-fit">
Uh oh! Something went wrong when creating your recipe. Please try again. %s
</p>
`
func (s *Server) CreateRecipeHandler(ctx *gin.Context) {
recipe, err := s.deps.RecipeService.CreateRecipe(ctx)
if err != nil {
components.RenderErrorBanner(ctx, err.Error())
ctx.String(http.StatusOK, CREATE_ERROR_HTML, err.Error())
return
}
// Send HTMX redirection
url := fmt.Sprintf(domain.WEB_RECIPE, recipe.Id)
ctx.Header("HX-Redirect", url)
ctx.Status(http.StatusCreated)
}
// toBits converts an array of stringified numbers into a single summed value
func toBits(arr []string) (bits int) {
for _, x := range arr {
num, _ := strconv.Atoi(x)
bits += num
}
return
}
// TODO: (7/06/2025) I don't love doing all of this here, but it seems to be the only way to get it to work...
func (s *Server) SearchRecipesHandler(ctx *gin.Context) {
// create filters
filters := domainRecipe.SearchFilters{
Search: ctx.PostForm("search"), // string, search query for titles
MealType: toBits(ctx.PostFormArray("meal")),
Time: toBits(ctx.PostFormArray("time")),
Difficulty: toBits(ctx.PostFormArray("difficulty")),
ServingSize: toBits(ctx.PostFormArray("serving")),
}
// Set the filters into the cookies, so they can be reloaded
if bytes, err := json.Marshal(filters); err == nil {
s.SetCookie(ctx, "search-filters", string(bytes), time.Hour*24)
}
redirect := ctx.PostForm("redirect")
if redirect == "true" {
ctx.Header("HX-Redirect", domain.WEB_SEARCH)
ctx.Status(http.StatusOK)
return
}
// Get user if logged in, so we can get favorite status
var userId *int = nil
if domain.IsLoggedIn(ctx) {
id := ctx.MustGet("userId").(int)
userId = &id
}
// TODO: Not sure if we need to ensure the user is valid here
// We don't care about favorite status, so use false
recipes, err := s.deps.RecipeService.SearchRecipes(filters, userId, false)
if err != nil {
components.RenderErrorBanner(ctx, err.Error())
ctx.JSON(http.StatusOK, gin.H{"error": err.Error()})
}
// Render content as the response
ctx.Status(200)
templates.ResultList(recipes).Render(ctx.Request.Context(), ctx.Writer)
}
func (s *Server) SearchRecipesFavoritesHandler(ctx *gin.Context) {
// create filters
filters := domainRecipe.SearchFilters{
Search: ctx.PostForm("search"), // string, search query for titles
MealType: toBits(ctx.PostFormArray("meal")),
Time: toBits(ctx.PostFormArray("time")),
Difficulty: toBits(ctx.PostFormArray("difficulty")),
ServingSize: toBits(ctx.PostFormArray("serving")),
}
// Set the filters into the cookies, so they can be reloaded
if bytes, err := json.Marshal(filters); err == nil {
s.SetCookie(ctx, "search-filters", string(bytes), time.Hour*24)
}
if !domain.IsLoggedIn(ctx) {
components.RenderErrorBanner(ctx, "User is not logged in. User will be nil.")
ctx.JSON(http.StatusOK, gin.H{"error": "User is not logged in. User will be nil."})
}
userId := ctx.MustGet("userId").(int)
recipes, err := s.deps.RecipeService.SearchRecipes(filters, &userId, true)
if err != nil {
components.RenderErrorBanner(ctx, err.Error())
ctx.JSON(http.StatusOK, gin.H{"error": err.Error()})
}
// Render content as the response
ctx.Status(200)
templates.FavoriteList(recipes).Render(ctx.Request.Context(), ctx.Writer)
}

View File

@ -9,7 +9,6 @@ import (
"github.com/a-h/templ/examples/integration-gin/gintemplrenderer" "github.com/a-h/templ/examples/integration-gin/gintemplrenderer"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/haydenhargreaves/Potion/internal/app/handlers"
"github.com/haydenhargreaves/Potion/internal/app/service" "github.com/haydenhargreaves/Potion/internal/app/service"
domain "github.com/haydenhargreaves/Potion/internal/domain/server" domain "github.com/haydenhargreaves/Potion/internal/domain/server"
"github.com/haydenhargreaves/Potion/internal/infrastructure/auth" "github.com/haydenhargreaves/Potion/internal/infrastructure/auth"
@ -23,6 +22,7 @@ type Server struct {
Router *gin.Engine Router *gin.Engine
config cors.Config config cors.Config
DB *sql.DB DB *sql.DB
deps domain.InjectedDependencies
} }
// Init initializes the server with the provided port. CORS settings are defined here. // Init initializes the server with the provided port. CORS settings are defined here.
@ -53,6 +53,7 @@ func (s *Server) Start() {
s.Router.Run(fmt.Sprintf(":%d", s.port)) s.Router.Run(fmt.Sprintf(":%d", s.port))
} }
// TODO: (9/4/2025) Abstract these functions and cleanup. This is fucking messy...
func (s *Server) Setup() *Server { func (s *Server) Setup() *Server {
// SETUP THE ENVIRONMENT CONFIGURATION // SETUP THE ENVIRONMENT CONFIGURATION
cfg, err := domain.LoadEnvironment() cfg, err := domain.LoadEnvironment()
@ -109,7 +110,7 @@ func (s *Server) Setup() *Server {
recipeService := service.NewRecipeService(recipeRepo, engagementRepo) recipeService := service.NewRecipeService(recipeRepo, engagementRepo)
engagementService := service.NewEngagementService(engagementRepo, recipeRepo) engagementService := service.NewEngagementService(engagementRepo, recipeRepo)
deps := &domain.InjectedDependencies{ s.deps = domain.InjectedDependencies{
UserService: userService, UserService: userService,
AuthService: authService, AuthService: authService,
RecipeService: recipeService, RecipeService: recipeService,
@ -119,7 +120,6 @@ func (s *Server) Setup() *Server {
// Apply middleware // Apply middleware
s.Router.Use(RecoveryMiddleware()) s.Router.Use(RecoveryMiddleware())
s.Router.Use(DepedencyInjectionMiddleware(deps))
s.Router.Use(JwtAuthMiddleWare(jwtSecret)) s.Router.Use(JwtAuthMiddleWare(jwtSecret))
// Redirect index to home page: Update this as needed // Redirect index to home page: Update this as needed
@ -138,44 +138,41 @@ func (s *Server) Setup() *Server {
// API router endpoints // API router endpoints
router_api.GET("/", func(ctx *gin.Context) { ctx.JSON(200, gin.H{"message": "Server is active."}) }) router_api.GET("/", func(ctx *gin.Context) { ctx.JSON(200, gin.H{"message": "Server is active."}) })
router_api.GET("/tmp", func(ctx *gin.Context) {
deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
ctx.JSON(200, gin.H{"config": deps.EnvironmentConfig})
})
// WEB router endpoints // WEB router endpoints
router_web.GET("/", func(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME) }) router_web.GET("/", func(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME) })
router_web.GET("/login", handlers.LoginPage) router_web.GET("/login", s.LoginPageHandler)
router_web.GET("/home", handlers.HomePage) router_web.GET("/home", s.HomePageHandler)
router_web.GET("/favorites", handlers.FavoritesPage) router_web.GET("/favorites", s.FavoritesPageHandler)
router_web.GET("/create", handlers.CreatePage) router_web.GET("/create", s.CreatePageHandler)
router_web.GET("/profile", handlers.ProfilePage) router_web.GET("/profile", s.ProfilePageHandler)
router_web.GET("/list", handlers.ListPage) router_web.GET("/list", s.ListPageHandler)
router_web.GET("/recipe/:id", handlers.RecipePage) router_web.GET("/recipe/:id", s.RecipePageHandler)
router_web.GET("/search", handlers.SearchPage) router_web.GET("/search", s.SearchPageHandler)
router_web.GET("/404", handlers.NotFoundPage) router_web.GET("/404", s.NotFoundPageHandler)
// WEB state endpoints // WEB state endpoints
router_state.POST("/tags", handlers.NewTag) router_state.POST("/tags", s.NewTagHandler)
router_state.POST("/tags/delete", handlers.DeleteTag) router_state.POST("/tags/delete", s.DeleteTagHandler)
// Authentication // Authentication
router_api.GET("/auth/login", handlers.GoogleLogin) router_api.GET("/auth/login", s.GoogleLoginHandler)
router_api.GET("/auth/callback", handlers.GoogleCallback) router_api.GET("/auth/callback", s.GoogleCallbackHandler)
router_api.GET("/auth/logout", handlers.Logout) router_api.GET("/auth/logout", s.LogoutHandler)
// Recipe endpoints // Recipe endpoints
router_api.POST("/recipe", handlers.CreateRecipe) router_api.POST("/recipe", s.CreateRecipeHandler)
router_api.POST("/recipe/search", handlers.SearchRecipes) router_api.POST("/recipe/search", s.SearchRecipesHandler)
router_api.POST("/recipe/search/favorites", handlers.SearchRecipesFavorites) router_api.POST("/recipe/search/favorites", s.SearchRecipesFavoritesHandler)
router_api.GET("/user/recipes", handlers.GetUserRecipes)
router_api.GET("/user/favorites", handlers.GetUserFavoriteRecipes) router_api.GET("/user/recipes", s.GetUserFavoriteRecipesHandler)
router_api.GET("/user/favorites", s.GetUserFavoriteRecipesHandler)
// Engagement endpoints // Engagement endpoints
router_api.POST("/engagement/view/:id", handlers.EngagementViewRecipe) router_api.POST("/engagement/view/:id", s.EngagementViewRecipeHandler)
router_api.POST("/engagement/share/:id", handlers.EngagementShareRecipe) router_api.POST("/engagement/share/:id", s.EngagementShareRecipeHandler)
router_api.POST("/engagement/favorite/:id", handlers.EngagementFavoriteRecipe) router_api.POST("/engagement/favorite/:id", s.EngagementFavoriteRecipeHandler)
router_api.POST("/engagement/make/:id", handlers.EngagementMakeRecipe) router_api.POST("/engagement/make/:id", s.EngagementMakeRecipeHandler)
// Catch un-routed URLS // Catch un-routed URLS
s.Router.NoRoute(func(ctx *gin.Context) { s.Router.NoRoute(func(ctx *gin.Context) {

View File

@ -0,0 +1,76 @@
package server
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
)
const TAG_HTML = `
<li
hx-post="%s"
hx-trigger="click"
hx-target="#tag-list"
hx-swap="innerHTML"
hx-include="#tag-list"
hx-vals='{"target": "%s"}'
class="flex text-xs items-center bg-blue-100 w-fit px-2 py-1 rounded-full gap-x-1 select-none cursor-pointer hover:bg-blue-200 duration-300">
&times; %s
</li>
`
const TAG_LIST_HTML = `
<input
hx-swap-oob="outerHTML"
type="hidden"
name="tags"
id="tags"
value="%s"
/>
`
func (s *Server) NewTagHandler(ctx *gin.Context) {
tag := strings.ToLower(ctx.PostForm("tag"))
tags := strings.Split(ctx.PostForm("tags"), ",")
tags = append([]string{tag}, tags...)
var html string
var cleaned_tags []string
for _, tag := range tags {
if tag != "" {
html += fmt.Sprintf(TAG_HTML, domain.STATE_TAGS_DELETE, tag, tag)
// Ensure that the list provided does not contain blank spaces.
// This is another measure to ensure this state is bulletproof.
cleaned_tags = append(cleaned_tags, tag)
}
}
// Execute OOB swap for the tags
html += fmt.Sprintf(TAG_LIST_HTML, strings.Join(cleaned_tags, ","))
ctx.String(http.StatusOK, html)
}
func (s *Server) DeleteTagHandler(ctx *gin.Context) {
tags := strings.Split(ctx.PostForm("tags"), ",")
target := ctx.PostForm("target")
var html string
var new_tags []string
for _, tag := range tags {
if tag != target && tag != "" {
html += fmt.Sprintf(TAG_HTML, domain.STATE_TAGS_DELETE, tag, tag)
new_tags = append(new_tags, tag)
}
}
// Execute OOB swap for the tags
html += fmt.Sprintf(TAG_LIST_HTML, strings.Join(new_tags, ","))
ctx.String(http.StatusOK, html)
}

View File

@ -0,0 +1,87 @@
package server
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
"github.com/haydenhargreaves/Potion/internal/templates/components"
)
func (s *Server) GetUserRecipesHandler(ctx *gin.Context) {
// Ensure user is logged in with a valid account
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// Log (stale) user out
s.SetCookie(ctx, "jwt_token", "", -1)
s.SetCookie(ctx, "search-filters", "", -1)
}
// Ensure logged in
if !domain.IsLoggedIn(ctx) || user == nil {
components.RenderErrorBanner(ctx, "User is not authorized to access this endpoint. Please login to continue.")
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": "User is not authorized to access this endpoint. Please login to continue.",
"recipes": nil,
})
return
}
recipes, err := s.deps.RecipeService.GetUserRecipes(user.Id)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Could not get user recipes. %s", err.Error()))
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("Could not get user recipes. %s", err.Error()),
"recipes": nil,
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "User recipes successfully retrieved.",
"recipes": recipes,
})
}
func (s *Server) GetUserFavoriteRecipesHandler(ctx *gin.Context) {
// Ensure user is logged in with a valid account
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// Log (stale) user out
s.SetCookie(ctx, "jwt_token", "", -1)
s.SetCookie(ctx, "search-filters", "", -1)
}
// Ensure logged in
if !domain.IsLoggedIn(ctx) || user == nil {
components.RenderErrorBanner(ctx, "User is not authorized to access this endpoint. Please login to continue.")
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": "User is not authorized to access this endpoint. Please login to continue.",
"recipes": nil,
})
return
}
recipes, err := s.deps.RecipeService.GetUserFavoriteRecipes(user.Id)
if err != nil {
components.RenderErrorBanner(ctx, fmt.Sprintf("Could not get favorite recipes. %s", err.Error()))
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("Could not get favorite recipes. %s", err.Error()),
"recipes": nil,
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "User recipes successfully retrieved.",
"recipes": recipes,
})
}

View File

@ -60,48 +60,48 @@ func (s *AuthService) GetGoogleAuthUrl() string {
// GoogleAuthSuccess accepts the data from the Google login endpoint and uses it to fetch the users // GoogleAuthSuccess accepts the data from the Google login endpoint and uses it to fetch the users
// data. The data is then used to log the user in or create an account. // data. The data is then used to log the user in or create an account.
func (s *AuthService) GoogleAuthSuccess(state, code string) (string, domain.User, domain.GoogleUserInfo, error) { func (s *AuthService) GoogleAuthSuccess(state, code string) (string, error) {
// Ensure the state matches, prevents M.I.T.M. attacks // Ensure the state matches, prevents M.I.T.M. attacks
if state != "randomstate" { if state != "randomstate" {
return "", domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("States don't match, received %s", state) return "", fmt.Errorf("States don't match, received %s", state)
} }
// Get access token from Google // Get access token from Google
token, err := auth.GoogleAuthConfig.Exchange(context.Background(), code) token, err := auth.GoogleAuthConfig.Exchange(context.Background(), code)
if err != nil { if err != nil {
return "", domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("Code exchange failed: %s", err.Error()) return "", fmt.Errorf("Code exchange failed: %s", err.Error())
} }
// Use the access token to get user data // Use the access token to get user data
googleUserInfo, err := auth.GetUserData(token.AccessToken) googleUserInfo, err := auth.GetUserData(token.AccessToken)
if err != nil { if err != nil {
return "", domain.User{}, domain.GoogleUserInfo{}, err return "", err
} }
// Attempt to get the user, user is nil when they don't exit // Attempt to get the user, user is nil when they don't exit
user, err := s.userRepository.GetGoogleUser(googleUserInfo.Id) user, err := s.userRepository.GetGoogleUser(googleUserInfo.Id)
if err != nil { if err != nil {
return "", domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("Failed to get db user: %s", err) return "", fmt.Errorf("Failed to get db user: %s", err)
} }
// A user was found // A user was found
if user != nil { if user != nil {
jwt, err := generateJwt(user.Id, user.Email, s.jwtSecret) jwt, err := generateJwt(user.Id, user.Email, s.jwtSecret)
return jwt, *user, googleUserInfo, err return jwt, err
} }
// user did not exist, need to create one // user did not exist, need to create one
newUser, err := s.userRepository.CreateGoogleUser(&googleUserInfo, token.RefreshToken) newUser, err := s.userRepository.CreateGoogleUser(&googleUserInfo, token.RefreshToken)
if err != nil { if err != nil {
return "", domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("Repository failed to create user: %s", err.Error()) return "", fmt.Errorf("Repository failed to create user: %s", err.Error())
} }
jwt, err := generateJwt(newUser.Id, newUser.Email, s.jwtSecret) jwt, err := generateJwt(newUser.Id, newUser.Email, s.jwtSecret)
return jwt, newUser, googleUserInfo, err return jwt, err
} }
// generateJwt requires user data and returns a JSON web token which can be stored in the browsers // generateJwt requires user data and returns a JSON web token which can be stored in the browsers
// cookies. This token is used to log a user into the application and allow access to protected // cookies. This token is used to log a user into the application and allow access to protected
// routes. // routes.
func generateJwt(userId int, email string, jwtSecret []byte) (string, error) { func generateJwt(userId int, email string, jwtSecret []byte) (string, error) {
expiration := time.Now().Add(7 * 24 * time.Hour) expiration := time.Now().Add(7 * 24 * time.Hour)

View File

@ -1,10 +1,6 @@
package domain package domain
import (
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
)
type AuthService interface { type AuthService interface {
GetGoogleAuthUrl() string GetGoogleAuthUrl() string
GoogleAuthSuccess(state, code string) (string, domain.User, domain.GoogleUserInfo, error) GoogleAuthSuccess(state, code string) (string, error)
} }

View File

@ -3,7 +3,6 @@ package domain
import ( import (
"fmt" "fmt"
"os" "os"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@ -132,41 +131,43 @@ func LoadEnvironment() (*EnvironmentConfig, error) {
// //
// If 0 is provided as the duration, then a session cookie is created, which will be cleared when // If 0 is provided as the duration, then a session cookie is created, which will be cleared when
// the browser is closed. // the browser is closed.
func SetCookie(ctx *gin.Context, name, value string, duration time.Duration) { //
deps := ctx.MustGet("deps").(*InjectedDependencies) // DEPRECATED: As of September 4th, 2025.
// func SetCookie(ctx *gin.Context, name, value string, duration time.Duration) {
var ( // deps := ctx.MustGet("deps").(*InjectedDependencies)
path string = "/" //
httpOnly bool = true // var (
maxAge int // path string = "/"
secure bool // httpOnly bool = true
domain string // maxAge int
) // secure bool
// domain string
if duration < 0 { // )
// Delete the cookie //
maxAge = -1 // if duration < 0 {
} else if duration == 0 { // // Delete the cookie
// Session cookie, clears when browser is closed // maxAge = -1
maxAge = 0 // } else if duration == 0 {
} else { // // Session cookie, clears when browser is closed
// Normal calculation // maxAge = 0
maxAge = int(time.Now().Add(duration).Sub(time.Now()).Seconds()) // } else {
} // // Normal calculation
// maxAge = int(time.Now().Add(duration).Sub(time.Now()).Seconds())
if deps.EnvironmentConfig.Environment == "prod" { // }
secure = true //
domain = deps.EnvironmentConfig.Domain // if deps.EnvironmentConfig.Environment == "prod" {
// secure = true
} else if deps.EnvironmentConfig.Environment == "dev" { // domain = deps.EnvironmentConfig.Domain
secure = false //
domain = deps.EnvironmentConfig.Domain // } else if deps.EnvironmentConfig.Environment == "dev" {
// secure = false
} else { // domain = deps.EnvironmentConfig.Domain
// Defaults //
secure = false // } else {
domain = "" // // Defaults
} // secure = false
// domain = ""
ctx.SetCookie(name, value, maxAge, path, domain, secure, httpOnly) // }
} //
// ctx.SetCookie(name, value, maxAge, path, domain, secure, httpOnly)
// }

View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Ensure the environment is sourced
source .env
pg_dump \
-h "$PSQL_HOST" \
-U "$PSQL_USERNAME" \
-Fc -v -d "$PSQL_DATABASE" \
-f backup.dump

View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Ensure the environment is sourced
source .env
pg_restore \
-v -d "$PSQL_DATABASE_BACKUP" \
-U "$PSQL_USERNAME" \
-h "$PSQL_HOST" \
-c -F c backup.dump

View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/001_create_users_table.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/002_create_meal_enum.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/003_create_recipes_table.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/004_create_fts_index.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/005_create_tags_tables.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/006_create_engagment_enum.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/007_create_engagement_table.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/008_create_favorites_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

View File

@ -369,7 +369,27 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
// Create search vector query // Create search vector query
var orderBy string = "" var orderBy string = ""
if filters.Search != "" { if filters.Search != "" {
vector_query := strings.ReplaceAll(filters.Search, " ", " | ") spl := strings.Split(filters.Search, " ")
var cleaned []string
// Use a string replacer, each word in the query will be passed through this
replacer := strings.NewReplacer(
"'", "",
"-", "",
"&", "",
"|", "",
"!", "",
)
for i := range len(spl) {
q := strings.TrimSpace(replacer.Replace(spl[i]))
if q != "" {
cleaned = append(cleaned, q)
}
}
vector_query := strings.Join(cleaned, " | ")
conditions = append( conditions = append(
conditions, conditions,
@ -377,8 +397,8 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
) )
template := ` template := `
ORDER BY ORDER BY
ts_rank(r.search_vector, to_tsquery('english', '%s')) DESC, ts_rank(r.search_vector, to_tsquery('english', '%s')) DESC,
ts_rank_cd(r.search_vector, to_tsquery('english', '%s')) DESC ts_rank_cd(r.search_vector, to_tsquery('english', '%s')) DESC
` `
orderBy = fmt.Sprintf(template, vector_query, vector_query) orderBy = fmt.Sprintf(template, vector_query, vector_query)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,50 @@
package components
import "github.com/gin-gonic/gin"
templ errorIcon() {
<svg class="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 16.99V17M12 7V14M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
}
templ closeButton() {
<button onclick="hideError();"
class="text-red-500 ml-auto hover:bg-red-200 p-1 rounded-sm transition-all duration-300 cursor-pointer">
<svg class="h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M19.207 6.207a1 1 0 0 0-1.414-1.414L12 10.586 6.207 4.793a1 1 0 0 0-1.414 1.414L10.586 12l-5.793 5.793a1 1 0 1 0 1.414 1.414L12 13.414l5.793 5.793a1 1 0 0 0 1.414-1.414L13.414 12l5.793-5.793z"
fill="currentColor"></path>
</svg>
</button>
}
templ errorBanner(message string) {
<div id="error-toast" hx-swap-oob="outerHTML"
class="fixed z-20 border border-red-500 rounded-sm right-0 top-0 m-4 p-4 bg-red-100 shadow shadow-red-200 transition-all duration-300 text-sm md:w-1/3">
<div class="flex items-center gap-x-2 pb-1">
@errorIcon()
<h1 class="text-red-500 font-semibold">Error</h1>
@closeButton()
</div>
<div class="ml-8">
<p class="text-red-500 text-sm">
{ message }
</p>
</div>
</div>
}
// RenderErrorBanner renders the error banner. However, this function must ONLY be called by an
// HTMX route. Otherwise, there is no promise it will work (may result in undefined behavior).
// Just writes a piece of content to the response.
//
// If this is called from an NON-HTMX request, it will display (and functionality seems to work)
// but it will be loaded at an unknown position in the DOM. It will not swap with the proper
// element.
func RenderErrorBanner(ctx *gin.Context, message string) {
ctx.Writer.Header().Set("Content-Type", "text/html")
errorBanner(message).Render(ctx.Request.Context(), ctx.Writer)
}

View File

@ -0,0 +1,141 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "github.com/gin-gonic/gin"
func errorIcon() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<svg class=\"h-6 text-red-500\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M12 16.99V17M12 7V14M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func closeButton() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<button onclick=\"hideError();\" class=\"text-red-500 ml-auto hover:bg-red-200 p-1 rounded-sm transition-all duration-300 cursor-pointer\"><svg class=\"h-4\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M19.207 6.207a1 1 0 0 0-1.414-1.414L12 10.586 6.207 4.793a1 1 0 0 0-1.414 1.414L10.586 12l-5.793 5.793a1 1 0 1 0 1.414 1.414L12 13.414l5.793 5.793a1 1 0 0 0 1.414-1.414L13.414 12l5.793-5.793z\" fill=\"currentColor\"></path></svg></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func errorBanner(message string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div id=\"error-toast\" hx-swap-oob=\"outerHTML\" class=\"fixed z-20 border border-red-500 rounded-sm right-0 top-0 m-4 p-4 bg-red-100 shadow shadow-red-200 transition-all duration-300 text-sm md:w-1/3\"><div class=\"flex items-center gap-x-2 pb-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = errorIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<h1 class=\"text-red-500 font-semibold\">Error</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = closeButton().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div class=\"ml-8\"><p class=\"text-red-500 text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/components/error.templ`, Line: 34, Col: 15}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// RenderErrorBanner renders the error banner. However, this function must ONLY be called by an
// HTMX route. Otherwise, there is no promise it will work (may result in undefined behavior).
// Just writes a piece of content to the response.
//
// If this is called from an NON-HTMX request, it will display (and functionality seems to work)
// but it will be loaded at an unknown position in the DOM. It will not swap with the proper
// element.
func RenderErrorBanner(ctx *gin.Context, message string) {
ctx.Writer.Header().Set("Content-Type", "text/html")
errorBanner(message).Render(ctx.Request.Context(), ctx.Writer)
}
var _ = templruntime.GeneratedTemplate

View File

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

View File

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

View File

@ -3,20 +3,24 @@ package templates
// AppLayout is the main application layout, this does not contain any content other than // AppLayout is the main application layout, this does not contain any content other than
// meta data, links, scripts and whatever is passed into it as a component. // meta data, links, scripts and whatever is passed into it as a component.
templ AppLayout(title string, child templ.Component) { templ AppLayout(title string, child templ.Component) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<head> <meta charset="UTF-8"/>
<meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>{ title }</title>
<title>{ title }</title> <link rel="stylesheet" href="/v1/web/static/css/tailwind.css"/>
<link rel="stylesheet" href="/v1/web/static/css/tailwind.css" /> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script> </head>
</head> <body class="bg-gray-100">
<div id="error-toast"></div>
<body class="bg-gray-100"> @child
@child <script>
</body> function hideError() {
const err = document.getElementById("error-toast");
</html> err.classList.add("hidden");
}
</script>
</body>
</html>
} }

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.937 // templ: version: v0.3.943
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@ -38,13 +38,13 @@ func AppLayout(title string, child templ.Component) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/layouts/app_layout.templ`, Line: 12, Col: 16} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/layouts/app_layout.templ`, Line: 11, Col: 17}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"/v1/web/static/css/tailwind.css\"><script src=\"https://unpkg.com/htmx.org@2.0.4\"></script></head><body class=\"bg-gray-100\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"/v1/web/static/css/tailwind.css\"><script src=\"https://unpkg.com/htmx.org@2.0.4\"></script></head><body class=\"bg-gray-100\"><div id=\"error-toast\"></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -52,7 +52,7 @@ func AppLayout(title string, child templ.Component) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<script>\n function hideError() {\n const err = document.getElementById(\"error-toast\");\n err.classList.add(\"hidden\");\n }\n </script></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace; monospace;
--color-red-100: oklch(93.6% 0.032 17.717); --color-red-100: oklch(93.6% 0.032 17.717);
--color-red-200: oklch(88.5% 0.062 18.334);
--color-red-500: oklch(63.7% 0.237 25.331); --color-red-500: oklch(63.7% 0.237 25.331);
--color-green-500: oklch(72.3% 0.219 149.579); --color-green-500: oklch(72.3% 0.219 149.579);
--color-blue-50: oklch(97% 0.014 254.604); --color-blue-50: oklch(97% 0.014 254.604);
@ -235,18 +236,27 @@
.absolute { .absolute {
position: absolute; position: absolute;
} }
.fixed {
position: fixed;
}
.relative { .relative {
position: relative; position: relative;
} }
.static { .static {
position: static; position: static;
} }
.top-0 {
top: calc(var(--spacing) * 0);
}
.top-1\/2 { .top-1\/2 {
top: calc(1/2 * 100%); top: calc(1/2 * 100%);
} }
.top-\[100\%\] { .top-\[100\%\] {
top: 100%; top: 100%;
} }
.right-0 {
right: calc(var(--spacing) * 0);
}
.left-0 { .left-0 {
left: calc(var(--spacing) * 0); left: calc(var(--spacing) * 0);
} }
@ -262,6 +272,9 @@
.z-20 { .z-20 {
z-index: 20; z-index: 20;
} }
.m-4 {
margin: calc(var(--spacing) * 4);
}
.mx-2 { .mx-2 {
margin-inline: calc(var(--spacing) * 2); margin-inline: calc(var(--spacing) * 2);
} }
@ -337,6 +350,12 @@
.mb-16 { .mb-16 {
margin-bottom: calc(var(--spacing) * 16); margin-bottom: calc(var(--spacing) * 16);
} }
.ml-8 {
margin-left: calc(var(--spacing) * 8);
}
.ml-auto {
margin-left: auto;
}
.block { .block {
display: block; display: block;
} }
@ -694,6 +713,9 @@
--tw-gradient-to: var(--color-purple-200); --tw-gradient-to: var(--color-purple-200);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
} }
.p-1 {
padding: calc(var(--spacing) * 1);
}
.p-2 { .p-2 {
padding: calc(var(--spacing) * 2); padding: calc(var(--spacing) * 2);
} }
@ -939,6 +961,12 @@
--tw-shadow-color: color-mix(in oklab, var(--color-gray-300) var(--tw-shadow-alpha), transparent); --tw-shadow-color: color-mix(in oklab, var(--color-gray-300) var(--tw-shadow-alpha), transparent);
} }
} }
.shadow-red-200 {
--tw-shadow-color: oklch(88.5% 0.062 18.334);
@supports (color: color-mix(in lab, red, red)) {
--tw-shadow-color: color-mix(in oklab, var(--color-red-200) var(--tw-shadow-alpha), transparent);
}
}
.outline { .outline {
outline-style: var(--tw-outline-style); outline-style: var(--tw-outline-style);
outline-width: 1px; outline-width: 1px;
@ -1141,6 +1169,13 @@
} }
} }
} }
.hover\:bg-red-200 {
&:hover {
@media (hover: hover) {
background-color: var(--color-red-200);
}
}
}
.hover\:text-blue-400 { .hover\:text-blue-400 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {