Compare commits

...

6 Commits

Author SHA1 Message Date
Hayden Hargreaves
3177a4d089 (FEAT): Working on auth still 2025-11-14 22:33:54 -07:00
Hayden Hargreaves
1749a91bf9 (FEAT): Context is somewhat working
It works okay, feels a bit slugish, but that might just be the
environments fault.
2025-11-14 13:08:05 -07:00
Hayden Hargreaves
25ea3fcfd7 (FEAT): JWT auth is coming along so well!
We have it in the UI, just need a way to send it back and handle it in
the backend.
2025-11-13 22:57:05 -07:00
Hayden Hargreaves
7df879b04a (CHORE): Removed the handlers directory, they were old 2025-11-13 21:39:09 -07:00
Hayden Hargreaves
34b0cc4199 (FEAT): Translated the first API.
Auth is next...
2025-11-13 21:38:47 -07:00
Hayden Hargreaves
281fd673d3 (FEAT): Shopping list 2025-11-13 20:18:55 -07:00
33 changed files with 910 additions and 914 deletions

View File

@ -1,47 +0,0 @@
package handlers
// 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.
//
// DEPRECATED: As of September 4th, 2025.
// func GoogleLogin(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
// url := 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.
//
// We do not need to return all of this data, it is just for testing.
//
// DEPRECATED: As of September 4th, 2025.
// func GoogleCallback(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
// var (
// state string = ctx.Query("state")
// code string = ctx.Query("code")
// )
//
// if jwt, err := deps.AuthService.GoogleAuthSuccess(state, code); err != nil {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
// } else {
// domain.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.
//
// DEPRECATED: As of September 4th, 2025.
// func Logout(ctx *gin.Context) {
// domain.SetCookie(ctx, "jwt_token", "", -1)
// domain.SetCookie(ctx, "search-filters", "", -1)
// ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME)
// }

View File

@ -1,142 +0,0 @@
package handlers
// DEPRECATED: As of September 4th, 2025.
// func EngagementViewRecipe(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.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)
// }
// }
// DEPRECATED: As of September 4th, 2025.
// 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)
// }
// }
// DEPRECATED: As of September 4th, 2025.
// 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 {
// 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)
// }
// }
// DEPRECATED: As of September 4th, 2025.
// 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 {
// components.RenderErrorBanner(ctx, err.Error())
// ctx.JSON(http.StatusInternalServerError, gin.H{
// "status": http.StatusInternalServerError,
// "message": err.Error(),
// })
// } else {
// ctx.Status(http.StatusNoContent)
// }
// }

View File

@ -1,296 +0,0 @@
package handlers
// DEPRECATED: As of September 4th, 2025.
// func LoginPage(ctx *gin.Context) {
// title := "Potion - Login"
// page := pages.LoginPage()
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
// DEPRECATED: As of September 4th, 2025.
// func HomePage(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies)
//
// loggedIn := domain.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
// }
//
// 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))
// }
// DEPRECATED: As of September 4th, 2025.
// 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))
// }
// 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))
// }
// DEPRECATED: As of September 4th, 2025.
// func ListPage(ctx *gin.Context) {
// title := "Potion - Shopping List"
// page := pages.ListPage()
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }
// 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))
// }
// DEPRECATED: As of September 4th, 2025.
// func NotFoundPage(ctx *gin.Context) {
// title := "Potion - Not Found"
// page := pages.NotFoundPage()
//
// ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
// }

View File

@ -1,136 +0,0 @@
package handlers
// DEPRECATED: As of September 4th, 2025.
// 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>
// `
// DEPRECATED: As of September 4th, 2025.
// func CreateRecipe(ctx *gin.Context) {
// deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
//
// recipe, err := 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
//
// DEPRECATED: As of September 4th, 2025.
// func toBits(arr []string) (bits int) {
// for _, x := range arr {
// num, _ := strconv.Atoi(x)
// bits += num
// }
// return
// }
// DEPRECATED: As of September 4th, 2025.
// func SearchRecipes(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()),
// // "/",
// // 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)
// }
// DEPRECATED: As of September 4th, 2025.
// 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) {
// 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 := 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

@ -1,71 +0,0 @@
package handlers
// DEPRECATED: As of September 4th, 2025.
// 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>
// `
// DEPRECATED: As of September 4th, 2025.
// const TAG_LIST_HTML = `
// <input
// hx-swap-oob="outerHTML"
// type="hidden"
// name="tags"
// id="tags"
// value="%s"
// />
// `
// DEPRECATED: As of September 4th, 2025.
// func NewTag(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)
// }
// DEPRECATED: As of September 4th, 2025.
// 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,83 +0,0 @@
package handlers
// DEPRECATED: As of September 4th, 2025.
// func GetUserRecipes(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 {
// 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,
// })
// }
// DEPRECATED: As of September 4th, 2025.
// 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 {
// 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.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

@ -0,0 +1,47 @@
package server
import (
"fmt"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
)
// GetGoogleAuthUrlHandlerV2 fetches a Google authentication URl and returns it.
// This function is atomic and cannot fail.
func (s *Server) GetGoogleAuthUrlHandlerV2(ctx *gin.Context) {
url := s.deps.AuthService.GetGoogleAuthUrl()
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved Google auth URL.",
"url": url,
})
}
// GoogleCallbackHandlerV2 reads the data from the Google redirection and uses it
// to generate a JWT which is sent back to the UI via a URL query parameter. If an
// error occurs the user will be directed to the login page with an error query param.
func (s *Server) GoogleCallbackHandlerV2(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 {
url := fmt.Sprintf("http://localhost:5173/v2/web/login?error=%s", url.QueryEscape(err.Error()))
ctx.Redirect(http.StatusSeeOther, url)
} else {
url := "http://localhost:5173/v2/web/home"
s.SetCookie(ctx, "jwt_token", jwt, time.Hour*24*7)
ctx.Redirect(http.StatusSeeOther, url)
}
}
// BUG: This is not working, not yet sure why
func (s *Server) LogoutHandlerV2(ctx *gin.Context) {
s.SetCookie(ctx, "jwt_token", "", -1)
// s.SetCookie(ctx, "search-filters", "", -1) // TODO: This was copied, might function differently now
ctx.Status(http.StatusNoContent)
}

View File

@ -18,10 +18,10 @@ import (
func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.Duration) {
var (
path string = "/"
httpOnly bool = true
httpOnly bool = false // NOTE: Should use false so React can see it!
maxAge int
secure bool
domain string
secure bool = false
domain string = ""
)
if duration < 0 {
@ -32,7 +32,7 @@ func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.D
maxAge = 0
} else {
// Normal calculation
maxAge = int(time.Now().Add(duration).Sub(time.Now()).Seconds())
maxAge = int(time.Until(time.Now().Add(duration)).Seconds())
}
if s.deps.EnvironmentConfig.Environment == "prod" {
@ -41,12 +41,8 @@ func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.D
} else if s.deps.EnvironmentConfig.Environment == "dev" {
secure = false
domain = s.deps.EnvironmentConfig.Domain
} else {
// Defaults
secure = false
domain = ""
// domain = s.deps.EnvironmentConfig.Domain
domain = "localhost"
}
ctx.SetCookie(name, value, maxAge, path, domain, secure, httpOnly)

View File

@ -0,0 +1,66 @@
package server
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
)
// JwtAuthMiddlewareV2 is responsible to protecting routes. Anything that may go wrong
// will be returned via JSON with a 'message' field and a 401 error code. When this
// middleware is successful, it will set the 'userId' and 'userEmail' fields and pass
// to the next function in the chain.
//
// Functions that are called after this can assume that those values defined are always
// set.
func JwtAuthMiddlewareV2(jwtSecretKey []byte) gin.HandlerFunc {
return func(ctx *gin.Context) {
tokenString, err := ctx.Cookie("jwt_token")
fmt.Println(tokenString)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": fmt.Sprintf("[UNAUTHORIZED] Failed to get token from cookie. %s", err.Error()),
})
ctx.Abort()
return
}
claims := &domain.JwtClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtSecretKey, nil
})
// Error occurred when parsing
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": fmt.Sprintf("[UNAUTHORIZED] Error parsing cooking. %s", err.Error()),
})
ctx.Abort()
return
}
// Token is invalid
if !token.Valid {
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": "[UNAUTHORIZED] Token is invalid.",
})
ctx.Abort()
return
}
// Found: Set the values
ctx.Set("userId", claims.UserId)
ctx.Set("userEmail", claims.Email)
ctx.Next()
}
}

View File

@ -0,0 +1,35 @@
package server
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
// GetRecipeOfTheWeekHandler fetchs the current recipe of the week and returns it.
// If an error occurs, it will be returned and a recipe will not be returned.
//
// Until auth is reimplemented, there is no way to determine what user is making the
// call.
func (s *Server) GetRecipeOfTheWeekHandlerV2(ctx *gin.Context) {
// BUG: This needs to be different, not hard coded
userId := 1
recipe, err := s.deps.RecipeService.GetRecipeOfTheWeek(&userId)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get recipe of the week. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved recipe of the week.",
"recipe": recipe,
})
}

View File

@ -42,7 +42,11 @@ func Init(port int) *Server {
server.Router.SetTrustedProxies(nil)
// Setup the CORS settings and active them
server.config.AllowAllOrigins = true
// server.config.AllowAllOrigins = true
server.config.AllowOrigins = []string{"http://localhost:5173"}
server.config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE"}
server.config.AllowHeaders = []string{"Origin", "Content-Type", "Authorization"}
server.config.AllowCredentials = true
server.Router.Use(cors.New(server.config))
return server
@ -74,7 +78,8 @@ func (s *Server) Setup() *Server {
// SETUP GOOGLE AUTH
var (
redirectUrl string = fmt.Sprintf("%s%s", cfg.Domain, domain.API_AUTH_CALLBACK)
// NOTE: USING V2 NOW
redirectUrl string = fmt.Sprintf("%s%s", cfg.Domain, domain.API_AUTH_CALLBACK_V2)
clientId string = cfg.GoogleClientId
clientSecret string = cfg.GoogleClientSecret
scope []string = []string{
@ -120,13 +125,15 @@ func (s *Server) Setup() *Server {
// Apply middleware
s.Router.Use(RecoveryMiddleware())
s.Router.Use(JwtAuthMiddleWare(jwtSecret))
// NOTE: No longer running on every connection!
// s.Router.Use(JwtAuthMiddleWare(jwtSecret))
// Redirect index to home page: Update this as needed
s.Router.GET("/", func(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME) })
// Wrap all routes with a version
router_v1 := s.Router.Group(domain.VERSION)
router_v1 := s.Router.Group(domain.VERSION_1)
router_v2 := s.Router.Group(domain.VERSION_2)
// Domain specific routers
router_web := router_v1.Group(domain.WEB)
@ -179,7 +186,7 @@ func (s *Server) Setup() *Server {
path := ctx.Request.URL.Path
// TODO: Use constants for errors?
if strings.HasPrefix(path, domain.VERSION+domain.API) {
if strings.HasPrefix(path, domain.VERSION_1+domain.API) {
ctx.JSON(http.StatusNotFound, gin.H{
"status": http.StatusNotFound,
"error": "API_NOT_FOUND",
@ -192,5 +199,18 @@ func (s *Server) Setup() *Server {
ctx.Redirect(http.StatusSeeOther, domain.WEB_NOT_FOUND)
})
// ---- VERSION 2 ROUTES ---- //
router_api_v2 := router_v2.Group(domain.API)
router_api_v2.GET("/recipe/of-the-week", s.GetRecipeOfTheWeekHandlerV2)
router_api_v2.GET("/auth/login", s.GetGoogleAuthUrlHandlerV2)
router_api_v2.GET("/auth/callback", s.GoogleCallbackHandlerV2)
router_api_v2.GET("/auth/logout", s.LogoutHandlerV2)
router_api_v2.GET("/user", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenticatedUserHandlerV2)
router_api_v2.GET("/protected", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"msg": "YAY"})
})
return s
}

View File

@ -0,0 +1,25 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (s *Server) GetAuthenticatedUserHandlerV2(ctx *gin.Context) {
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": "[UNAUTHORIZED] Could not fetch authenticated user.",
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user.",
"user": user,
})
}

View File

@ -1,36 +1,38 @@
package domain
// Sub-routes
const VERSION = "/v1"
const VERSION_1 = "/v1"
const VERSION_2 = "/v2"
const WEB = "/web"
const API = "/api"
const STATE = "/state"
// Web prefixed routes
const WEB_LOGIN = VERSION + WEB + "/login"
const WEB_INDEX = VERSION + WEB
const WEB_HOME = VERSION + WEB + "/home"
const WEB_FAVORITES = VERSION + WEB + "/favorites"
const WEB_CREATE = VERSION + WEB + "/create"
const WEB_PROFIlE = VERSION + WEB + "/profile"
const WEB_LIST = VERSION + WEB + "/list"
const WEB_RECIPE = VERSION + WEB + "/recipe/%d"
const WEB_SEARCH = VERSION + WEB + "/search"
const WEB_NOT_FOUND = VERSION + WEB + "/404"
const WEB_LOGIN = VERSION_1 + WEB + "/login"
const WEB_INDEX = VERSION_1 + WEB
const WEB_HOME = VERSION_1 + WEB + "/home"
const WEB_FAVORITES = VERSION_1 + WEB + "/favorites"
const WEB_CREATE = VERSION_1 + WEB + "/create"
const WEB_PROFIlE = VERSION_1 + WEB + "/profile"
const WEB_LIST = VERSION_1 + WEB + "/list"
const WEB_RECIPE = VERSION_1 + WEB + "/recipe/%d"
const WEB_SEARCH = VERSION_1 + WEB + "/search"
const WEB_NOT_FOUND = VERSION_1 + WEB + "/404"
// API prefixed routes
const API_AUTH_LOGIN = VERSION + API + "/auth/login"
const API_AUTH_CALLBACK = VERSION + API + "/auth/callback"
const API_AUTH_LOGOUT = VERSION + API + "/auth/logout"
const API_CREATE_RECIPE = VERSION + API + "/recipe"
const API_SEARCH_RECIPES = VERSION + API + "/recipe/search"
const API_SEARCH_FAVORITES = VERSION + API + "/recipe/search/favorites"
const API_AUTH_LOGIN = VERSION_1 + API + "/auth/login"
const API_AUTH_CALLBACK = VERSION_1 + API + "/auth/callback"
const API_AUTH_CALLBACK_V2 = VERSION_2 + API + "/auth/callback"
const API_AUTH_LOGOUT = VERSION_1 + API + "/auth/logout"
const API_CREATE_RECIPE = VERSION_1 + API + "/recipe"
const API_SEARCH_RECIPES = VERSION_1 + API + "/recipe/search"
const API_SEARCH_FAVORITES = VERSION_1 + API + "/recipe/search/favorites"
const API_ENGAGEMENT_VIEW = VERSION + API + "/engagement/view/%d"
const API_ENGAGEMENT_SHARE = VERSION + API + "/engagement/share/%d"
const API_ENGAGEMENT_FAVORITE = VERSION + API + "/engagement/favorite/%d"
const API_ENGAGEMENT_MAKE = VERSION + API + "/engagement/make/%d"
const API_ENGAGEMENT_VIEW = VERSION_1 + API + "/engagement/view/%d"
const API_ENGAGEMENT_SHARE = VERSION_1 + API + "/engagement/share/%d"
const API_ENGAGEMENT_FAVORITE = VERSION_1 + API + "/engagement/favorite/%d"
const API_ENGAGEMENT_MAKE = VERSION_1 + API + "/engagement/make/%d"
// State prefixed routes
const STATE_TAGS_CREATE = VERSION + WEB + STATE + "/tags"
const STATE_TAGS_DELETE = VERSION + WEB + STATE + "/tags/delete"
const STATE_TAGS_CREATE = VERSION_1 + WEB + STATE + "/tags"
const STATE_TAGS_DELETE = VERSION_1 + WEB + STATE + "/tags/delete"

View File

@ -1,73 +1,4 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
# IF BACKEND CANNOT GET COOKIE
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress.
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
Do not forget to send the axios request with the `{ withCredentials: true }` flags.

333
web/package-lock.json generated
View File

@ -9,9 +9,11 @@
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.1.16",
"axios": "^1.13.2",
"eslint-plugin-react-dom": "^2.2.4",
"eslint-plugin-react-x": "^2.2.4",
"react": "^19.1.1",
"react-cookie": "^8.0.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.5",
"tailwindcss": "^4.1.16"
@ -1604,6 +1606,18 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz",
"integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
"license": "MIT",
"dependencies": {
"hoist-non-react-statics": "^3.3.0"
},
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -1624,7 +1638,6 @@
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@ -1963,6 +1976,23 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -1997,6 +2027,19 @@
"node": ">=8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -2040,6 +2083,18 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/compare-versions": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
@ -2079,7 +2134,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
@ -2105,6 +2159,15 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -2114,6 +2177,20 @@
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@ -2127,6 +2204,51 @@
"node": ">=10.13.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz",
@ -2518,6 +2640,42 @@
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -2532,6 +2690,52 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -2557,6 +2761,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -2579,6 +2795,54 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -3004,6 +3268,15 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -3026,6 +3299,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -3200,6 +3494,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -3238,6 +3538,20 @@
"node": ">=0.10.0"
}
},
"node_modules/react-cookie": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-8.0.1.tgz",
"integrity": "sha512-QNdAd0MLuAiDiLcDU/2s/eyKmmfMHtjPUKJ2dZ/5CcQ9QKUium4B3o61/haq6PQl/YWFqC5PO8GvxeHKhy3GFA==",
"license": "MIT",
"dependencies": {
"@types/hoist-non-react-statics": "^3.3.6",
"hoist-non-react-statics": "^3.3.2",
"universal-cookie": "^8.0.0"
},
"peerDependencies": {
"react": ">= 16.3.0"
}
},
"node_modules/react-dom": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
@ -3250,6 +3564,12 @@
"react": "^19.2.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-router": {
"version": "7.9.5",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz",
@ -3639,6 +3959,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/universal-cookie": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-8.0.1.tgz",
"integrity": "sha512-B6ks9FLLnP1UbPPcveOidfvB9pHjP+wekP2uRYB9YDfKVpvcjKgy1W5Zj+cEXJ9KTPnqOKGfVDQBmn8/YCQfRg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",

View File

@ -11,9 +11,11 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.1.16",
"axios": "^1.13.2",
"eslint-plugin-react-dom": "^2.2.4",
"eslint-plugin-react-x": "^2.2.4",
"react": "^19.1.1",
"react-cookie": "^8.0.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.5",
"tailwindcss": "^4.1.16"

View File

@ -8,19 +8,40 @@ import Create from './pages/Create';
import Favorites from './pages/Favorites';
import Profile from './pages/Profile';
import ShoppingList from './pages/ShoppingList';
import LoginPage from './pages/Login';
import { use, type ReactNode } from 'react';
import { AuthContext } from './context/AuthContext';
function ProtectedRoute({ children }: { children: ReactNode }) {
const { isLoggedIn } = use(AuthContext)
// Wait until the value is set
if (isLoggedIn === undefined) {
// Still checking auth state: don't render anything yet, or show a spinner if desired
return null; // or <Loading />
}
if (isLoggedIn) return children;
// Redirect to login page if not authenicated
return <Navigate to="/v2/web/login" replace />
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to={ROUTE_CONSTANTS.Home} replace />} />
{/* Login page does not inherit WebLayout */}
<Route path="/v2/web/login" element={<LoginPage />} />
<Route path="/v2/web" element={<WebLayout />}>
<Route index element={<Navigate to={ROUTE_CONSTANTS.Home} replace />} />
<Route path="home" element={<Home />} />
<Route path="favorites" element={<Favorites />} />
<Route path="create" element={<Create />} />
<Route path="profile" element={<Profile />} />
<Route path="list" element={<ShoppingList />} />
<Route path="favorites" element={<ProtectedRoute><Favorites /></ProtectedRoute>} />
<Route path="create" element={<ProtectedRoute><Create /></ProtectedRoute>} />
<Route path="profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<Route path="list" element={<ProtectedRoute><ShoppingList /></ProtectedRoute>} />
{/* <Route path="recipe/:id" element={<Home />} /> */}

View File

@ -11,7 +11,7 @@ export default function Navigation() {
return (
<>
<nav className="block md:fixed w-full z-10">
<nav className="block md:fixed w-full z-20">
<div
className="relative w-full px-8 md:px-44 p-4 border-b border-gray-300 shadow-sm shadow-gray-300 bg-white flex justify-between items-center"
>

View File

@ -0,0 +1,13 @@
import { createContext } from "react";
interface AuthContextType {
isLoggedIn: boolean | undefined;
setIsLoggedIn: (state: boolean) => void;
getJwt: () => string;
}
export const AuthContext = createContext<AuthContextType>({
isLoggedIn: undefined,
setIsLoggedIn: () => { return },
getJwt: () => ""
});

View File

@ -0,0 +1,34 @@
import { useEffect, useState, type ReactNode } from "react";
import { AuthContext } from "./AuthContext";
import { useCookies } from 'react-cookie';
// BUG: The rerender issue is ridiclious, and needs to be updated. Maybe using a global
// state management tool instead of a context
//
// BUG: We do not want to have access to these cookies in the UI, for security reasons.
// Instead, we should implement an api `/auth/status` can be requested to validate
// a token. Not sure how often this should get called, but it should be implemented.
export function AuthProvider({ children }: { children: ReactNode }) {
const [cookies] = useCookies(["jwt_token"]);
const [isLoggedIn, setIsLoggedIn] = useState<boolean | undefined>(cookies.jwt_token !== undefined);
const getJwt = (): string => {
if (!cookies.jwt_token) return "";
return cookies.jwt_token as string;
}
useEffect(() => {
setIsLoggedIn(cookies.jwt_token !== undefined);
}, [cookies]);
// NOTE: Display some loading page, maybe...
// if (isLoggedIn === undefined) {
// return <div>Loading authentication...</div>; // or null for no flicker
// }
return (
<AuthContext value={{ isLoggedIn, setIsLoggedIn, getJwt }}>
{children}
</AuthContext>
);
}

View File

@ -0,0 +1,2 @@
export function ProtectedRoute

View File

@ -2,9 +2,15 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { AuthProvider } from './context/AuthProvider.tsx'
import { CookiesProvider } from 'react-cookie'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<CookiesProvider>
<AuthProvider>
<App />
</AuthProvider>
</CookiesProvider>
</StrictMode>,
)

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { use, useEffect, useState } from "react";
import SalmonVideo from "../assets/videos/salmon_video.mp4";
import Banner from "../components/Banner";
import ROUTE_CONSTANTS from "../types/routes";
@ -8,14 +8,22 @@ import type { Recipe } from "../types/recipe";
import RecipeCardSmall from "../components/cards/RecipeCardSmall";
import ContentCardSmall from "../components/cards/ContentCardSmall";
import RecipeSearchBar from "../components/inputs/RecipeSearchBar";
import { GetRecipeOfTheWeek } from "../services/RecipeService";
import { isApiError, type ApiError } from "../types/api/error";
import { AuthContext } from "../context/AuthContext";
export default function Home() {
const [loggedIn, isLoggedIn] = useState<boolean>(false);
// Context
const { isLoggedIn } = use(AuthContext);
// Page state
const [recipeOfTheWeek, setRecipeOfTheWeek] = useState<Recipe | null>(null);
const [madeRecipes, setMadeRecipes] = useState<Recipe[]>([]);
const [viewedRecipes, setViewedRecipes] = useState<Recipe[]>([]);
const [error, setError] = useState<string>("");
// BUG: Remove these
useEffect(() => {
@ -97,7 +105,6 @@ export default function Home() {
Favorite: true
};
isLoggedIn(true);
setRecipeOfTheWeek(recipe);
const recipes: Recipe[] = [recipe, recipe2];
@ -105,6 +112,26 @@ export default function Home() {
setViewedRecipes(recipes);
}, []);
// TODO: Fetch other items when needed
// Fetch the recipe of the week
useEffect(() => {
async function fetch() {
const result: Recipe | ApiError = await GetRecipeOfTheWeek();
if (isApiError(result)) {
setError(result.message);
return;
}
setRecipeOfTheWeek(result);
}
void fetch();
}, []);
// BUG: Prob remove
useEffect(() => {
if (error)
console.error(error);
}, [error]);
return (
<>
{/* Intro Section */}
@ -155,7 +182,7 @@ export default function Home() {
<Banner content="Take Another Look." />
<div className="w-full">
<h3 className="text-lg mt-8 mx-4">Recently viewed</h3>
{loggedIn ?
{isLoggedIn ?
<div className="flex overflow-x-auto gap-x-4 mx-4 my-4">
{viewedRecipes && viewedRecipes.length > 0 ? (
<>
@ -176,7 +203,7 @@ export default function Home() {
</div>
}
<h3 className="text-lg mt-8 mx-4">Make again</h3>
{loggedIn ?
{isLoggedIn ?
<div className="flex overflow-x-auto gap-x-4 mx-4 my-4">
{madeRecipes && madeRecipes.length > 0 ? (
<>

75
web/src/pages/Login.tsx Normal file
View File

@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import { GetGoogleAuthUrl } from "../services/AuthService"
import { isApiError, type ApiError } from "../types/api/error"
import { useSearchParams } from "react-router-dom";
export default function LoginPage() {
const [error, setError] = useState<string>("");
const [searchParams, setSearchParams] = useSearchParams();
const clickHandler = async (): Promise<void> => {
const result: string | ApiError = await GetGoogleAuthUrl();
if (isApiError(result)) {
setError(result.message);
return;
}
window.location.href = result;
}
useEffect(() => {
if (error)
console.error(error);
}, [error]);
useEffect(() => {
if (searchParams.has("error")) {
const error: string = searchParams.get("error")!;
setError(error);
}
}, [searchParams]);
// TODO: Implement an error display!
return <>
<div className="h-screen w-full grid place-items-center bg-gray-100">
<div className="w-3/4 sm:w-3/4 md:w-1/2 lg:w-2/7 bg-white border border-gray-200 rounded-xl shadow-2xs">
<div className="p-4 sm:p-7">
<div className="">
<h1 className="block text-2xl font-bold text-gray-800">
Sign in to Continue
</h1>
<p className="mt-2 text-sm text-gray-600">
You need to sign in to continue. Don't have an account? Signing in will
create one for you!
</p>
</div>
<div className="mt-5">
<button onClick={() => { void clickHandler(); }} className="w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none cursor-pointer">
<svg className="w-4 h-auto" width="46" height="47" viewBox="0 0 46 47" fill="none">
<path
d="M46 24.0287C46 22.09 45.8533 20.68 45.5013 19.2112H23.4694V27.9356H36.4069C36.1429 30.1094 34.7347 33.37 31.5957 35.5731L31.5663 35.8669L38.5191 41.2719L38.9885 41.3306C43.4477 37.2181 46 31.1669 46 24.0287Z"
fill="#4285F4"
></path>
<path
d="M23.4694 47C29.8061 47 35.1161 44.9144 39.0179 41.3012L31.625 35.5437C29.6301 36.9244 26.9898 37.8937 23.4987 37.8937C17.2793 37.8937 12.0281 33.7812 10.1505 28.1412L9.88649 28.1706L2.61097 33.7812L2.52296 34.0456C6.36608 41.7125 14.287 47 23.4694 47Z"
fill="#34A853"
></path>
<path
d="M10.1212 28.1413C9.62245 26.6725 9.32908 25.1156 9.32908 23.5C9.32908 21.8844 9.62245 20.3275 10.0918 18.8588V18.5356L2.75765 12.8369L2.52296 12.9544C0.909439 16.1269 0 19.7106 0 23.5C0 27.2894 0.909439 30.8731 2.49362 34.0456L10.1212 28.1413Z"
fill="#FBBC05"
></path>
<path
d="M23.4694 9.07688C27.8699 9.07688 30.8622 10.9863 32.5344 12.5725L39.1645 6.11C35.0867 2.32063 29.8061 0 23.4694 0C14.287 0 6.36607 5.2875 2.49362 12.9544L10.0918 18.8588C11.9987 13.1894 17.25 9.07688 23.4694 9.07688Z"
fill="#EB4335"
></path>
</svg>
Sign in with Google
</button>
</div>
</div>
</div>
</div>
</>
}

View File

@ -1,16 +1,29 @@
import { useEffect, useState } from "react";
import { use, useEffect, useState } from "react";
import type { User } from "../types/user";
import type { Recipe } from "../types/recipe";
import RecipeListItem from "../components/results/RecipeListItem";
import type { Engagement } from "../types/engagement";
import ActivityListItem from "../components/results/ActivityListItem";
import { AuthContext } from "../context/AuthContext";
import { GetAuthenticatedUser } from "../services/UserService";
import { isApiError, type ApiError } from "../types/api/error";
import { Logout } from "../services/AuthService";
import { useNavigate } from "react-router-dom";
export default function Profile() {
// Context
const { getJwt } = use(AuthContext);
const navigate = useNavigate();
// Page state
const [error, setError] = useState<string>("");
const [user, setUser] = useState<User | null>(null);
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [favorites, setFavorites] = useState<Recipe[]>([]);
const [activity, setActivity] = useState<Engagement[]>([]);
const [jwt, setJwt] = useState<string>("");
// BUG: Remove this, used for testing
useEffect(() => {
const recipe: Recipe = {
Id: 1,
@ -99,23 +112,43 @@ export default function Profile() {
Created: new Date(),
};
const user: User = {
Id: 1,
GoogleId: "a",
Name: "Hayden Hargreaves",
Email: "hhargreaves2006@gmail.com",
ImageUrl: "https://lh3.googleusercontent.com/a/ACg8ocLeT6ltjQIkiBy1MgMJDbQxtBfMVfn8sP4e1t7d0bCJeHFdpcea=s96-c",
GoogleRefreshToken: "a",
Created: new Date(),
};
setUser(user);
setRecipes([recipe, recipe2, recipe, recipe, recipe, recipe, recipe]);
setRecipes([recipe, recipe2]);
setFavorites([recipe, recipe2]);
setActivity([eng]);
}, []);
// Log the user out and direct to the home page
const logoutHandler = (): void => {
void Logout();
void navigate("/v2/web/home");
}
// Get the JWT from the cookies
useEffect(() => {
setJwt(getJwt());
}, [getJwt]);
// Get the user when the JWTS change
useEffect(() => {
// No jwt, we can't get a user
if (!jwt) return;
async function fetch() {
const result: User | ApiError = await GetAuthenticatedUser();
if (isApiError(result)) {
setError(result.message);
return;
}
setUser(result);
}
void fetch();
}, [jwt]);
useEffect(() => {
if (error)
console.log("@error", error);
}, [error]);
return (
<>
{/* User Details Section */}
@ -195,9 +228,9 @@ export default function Profile() {
{/* Logout Section TODO: Click event*/}
<section className="w-full flex flex-col justify-center items-center py-8 border-t border-gray-300 mt-auto">
<a href="" className="text-center border border-red-500 text-red-500 w-9/10 md:w-1/3 py-2 rounded-lg hover:cursor-pointer hover:bg-red-100 duration-300">
<button onClick={logoutHandler} className="text-center border border-red-500 text-red-500 w-9/10 md:w-1/3 py-2 rounded-lg hover:cursor-pointer hover:bg-red-100 duration-300">
Logout
</a>
</button>
</section>
</>
);

View File

@ -1,7 +1,10 @@
export default function ShoppingList() {
return (
<>
<p>ShoppingList</p>
<div className="flex flex-col items-center justify-center min-h-[90vh] h-full gap-y-2">
<h1 className="text-4xl text-gray-800 font-semibold text-center">Page Under Construction </h1>
<p className="text-gray-700">Sit tight, this page is coming soon!</p>
</div>
</>
);
}

View File

@ -0,0 +1,26 @@
import axios from "axios";
import type { GetGoogleAuthUrlResponse, LogoutResponse } from "../types/api/auth";
import type { ApiError } from "../types/api/error";
export async function GetGoogleAuthUrl (): Promise<string | ApiError> {
const response = await axios.get<GetGoogleAuthUrlResponse>("http://localhost:3000/v2/api/auth/login");
if (response.status !== 200) {
const err: ApiError = {
status: response.status,
message: "[FAIL] Something went wrong."
};
return err;
}
return response.data.url;
}
export async function Logout (): Promise<void> {
const response = await axios.get<LogoutResponse>("http://localhost:3000/v2/api/auth/logout");
// This should never happen
if (response.status !== 204)
console.error("LOGOUT FAILED");
}

View File

@ -0,0 +1,19 @@
import axios from "axios";
import type { GetRecipeOfTheWeekResponse } from "../types/api/recipe";
import type { Recipe } from "../types/recipe";
import type { ApiError } from "../types/api/error";
export async function GetRecipeOfTheWeek (): Promise<Recipe | ApiError> {
const response = await axios.get<GetRecipeOfTheWeekResponse>("http://localhost:3000/v2/api/recipe/of-the-week");
if (response.status !== 200 || response.data.recipe === undefined) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return response.data.recipe;
}

View File

@ -0,0 +1,19 @@
import axios from "axios";
import type { ApiError } from "../types/api/error";
import type { User } from "../types/user";
import type { GetAuthenticateUserResponse } from "../types/api/user";
export async function GetAuthenticatedUser(): Promise<User | ApiError> {
const response = await axios.get<GetAuthenticateUserResponse>("http://localhost:3000/v2/api/user", { withCredentials: true });
if (response.data.status !== 200 || response.data.user === undefined){
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return response.data.user;
}

10
web/src/types/api/auth.ts Normal file
View File

@ -0,0 +1,10 @@
export interface GetGoogleAuthUrlResponse {
status: number;
message: string;
url: string;
}
export interface LogoutResponse {
stauts: number;
}

View File

@ -0,0 +1,16 @@
export function isApiError(obj: unknown): obj is ApiError {
return (
typeof obj === "object" &&
obj !== null &&
"status" in obj &&
typeof (obj as { status?: unknown }).status === "number" &&
"message" in obj &&
typeof (obj as { message?: unknown }).message === "string"
);
}
export interface ApiError {
status: number;
message: string;
}

View File

@ -0,0 +1,7 @@
import type { Recipe } from "../recipe";
export interface GetRecipeOfTheWeekResponse {
status: number;
message: string;
recipe?: Recipe;
}

View File

@ -0,0 +1,7 @@
import type { User } from "../user";
export interface GetAuthenticateUserResponse {
status: number;
message: string;
user?: User;
}