Merge pull request 'Merging in the React Refactor' (#56) from refactor/react into master
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 3m51s

Reviewed-on: #56
This commit is contained in:
Hayden Hargreaves 2025-12-28 22:27:51 -07:00
commit 43ef9d9490
145 changed files with 9605 additions and 1324 deletions

View File

@ -19,10 +19,22 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push Docker image - name: Build and push backend Docker image
uses: docker/build-push-action@v3 uses: docker/build-push-action@v3
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
tags: azpect3120/potion.gophernest:latest tags: azpect3120/potion.gophernest:latest
- name: Build and push frontend Docker image
uses: docker/build-push-action@v5
with:
context: ./web
file: ./Dockerfile
push: true
tags: azpect3120/potion.frontend:latest
build-args: |
VITE_ENVIRONMENT=prod
VITE_DOMAIN_DEV=http://localhost:3000
VITE_DOMAIN_PROD=https://potion-backend.gophernest.net

View File

@ -1,57 +1,14 @@
# Fetch stage FROM golang:1.25-alpine
FROM golang:latest AS fetch-stage
COPY . /app
WORKDIR /app WORKDIR /app
RUN go mod tidy COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Generate stage COPY . .
FROM ghcr.io/a-h/templ:latest AS generate-stage
COPY --chown=65532:65532 . /app RUN go build -o server ./cmd/web/main.go
WORKDIR /app EXPOSE 8080
RUN ["templ", "generate"] CMD ["./server"]
# Generate stage two
FROM node:lts-alpine AS tailwind-build-stage
COPY --from=generate-stage /app /app
WORKDIR /app
RUN npm install tailwindcss @tailwindcss/cli && npx @tailwindcss/cli -i ./web/static/css/main.css -o ./web/static/css/tailwind.css --minify -c ./tailwind.config.js
# Build stage
FROM golang:latest AS build-stage
COPY --from=tailwind-build-stage /app /app
WORKDIR /app
RUN go mod tidy
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -o /entrypoint /app/cmd/web/main.go
# Deploy.
FROM gcr.io/distroless/static-debian11 AS release-stage
WORKDIR /
COPY --from=build-stage /entrypoint /entrypoint
COPY --from=build-stage /app/web/static /web/static
EXPOSE 3000
USER nonroot:nonroot
ENTRYPOINT ["/entrypoint"]

57
Dockerfile.old Normal file
View File

@ -0,0 +1,57 @@
# Fetch stage
FROM golang:latest AS fetch-stage
COPY . /app
WORKDIR /app
RUN go mod tidy
RUN go mod download
# Generate stage
FROM ghcr.io/a-h/templ:latest AS generate-stage
COPY --chown=65532:65532 . /app
WORKDIR /app
RUN ["templ", "generate"]
# Generate stage two
FROM node:lts-alpine AS tailwind-build-stage
COPY --from=generate-stage /app /app
WORKDIR /app
RUN npm install tailwindcss @tailwindcss/cli && npx @tailwindcss/cli -i ./web/static/css/main.css -o ./web/static/css/tailwind.css --minify -c ./tailwind.config.js
# Build stage
FROM golang:latest AS build-stage
COPY --from=tailwind-build-stage /app /app
WORKDIR /app
RUN go mod tidy
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -o /entrypoint /app/cmd/web/main.go
# Deploy.
FROM gcr.io/distroless/static-debian11 AS release-stage
WORKDIR /
COPY --from=build-stage /entrypoint /entrypoint
COPY --from=build-stage /app/web/static /web/static
EXPOSE 3000
USER nonroot:nonroot
ENTRYPOINT ["/entrypoint"]

6
docker-compose.yml Normal file
View File

@ -0,0 +1,6 @@
services:
app:
build: ./web/.
container_name: potion.frontend
ports:
- "3002:3002" # host:container

View File

@ -29,6 +29,7 @@
dockerfile-language-server-nodejs dockerfile-language-server-nodejs
gcc_multi gcc_multi
glibc_multi glibc_multi
nodejs
]; ];
# Define the shell that will be executed. # Define the shell that will be executed.

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,48 @@
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")
)
domain := s.deps.EnvironmentConfig.FrontendDomain
if jwt, err := s.deps.AuthService.GoogleAuthSuccess(state, code); err != nil {
url := fmt.Sprintf("%s/v2/web/login?error=%s", domain, url.QueryEscape(err.Error()))
ctx.Redirect(http.StatusSeeOther, url)
} else {
url := fmt.Sprintf("%s/v2/web/home", domain)
s.SetCookie(ctx, "jwt_token", jwt, time.Hour*24*7)
ctx.Redirect(http.StatusSeeOther, url)
}
}
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

@ -0,0 +1,49 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
)
// AuthenticatedFunc is a function that handles authenticated requests
type AuthenticatedFunc func(ctx *gin.Context, user *domain.User)
// withAuthenticatedUser is a helper to run a handler only if user is authenticated. Otherwise
// the function will return an error with a 401 status.
//
// BUG: This is probably not very effecient, since we hit the DB on every single protected request.
//
// If this ends up being a bottle neck we could simply hit the context for the userId, since
// that is usually all we need...Or maybe have two methods, for those that need the whole user
// and those that just need the ID.
func (s *Server) withAuthenticatedUser(ctx *gin.Context, handler AuthenticatedFunc) {
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// User is stale, ensure they are logged out so they can be prompted to log back in
s.SetCookie(ctx, "jwt_token", "", -1)
// s.SetCookie(ctx, "search-filters", "", -1) // TODO: Might need this again
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": "[UNAUTHORIZED] Could not fetch authenticated user.",
})
return
}
handler(ctx, user)
}
// getUserId retrieves the userId from the context and returns a pointer to it. A nil
// pointer can be returned and will if the userId does not exist.
func getUserId(ctx *gin.Context) *int {
userIdAny, exists := ctx.Get("userId")
if !exists {
return nil
}
userIdInt, ok := userIdAny.(int)
if !ok {
return nil
}
return &userIdInt
}

View File

@ -1,6 +1,7 @@
package server package server
import ( import (
"net/http"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -18,10 +19,10 @@ import (
func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.Duration) { func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.Duration) {
var ( var (
path string = "/" path string = "/"
httpOnly bool = true httpOnly bool = false // NOTE: Should use false so React can see it!
maxAge int maxAge int
secure bool secure bool = true
domain string domain string = ""
) )
if duration < 0 { if duration < 0 {
@ -32,22 +33,22 @@ func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.D
maxAge = 0 maxAge = 0
} else { } else {
// Normal calculation // Normal calculation
maxAge = int(time.Now().Add(duration).Sub(time.Now()).Seconds()) maxAge = int(time.Until(time.Now().Add(duration)).Seconds())
} }
// TODO: This whole system is stupid now
if s.deps.EnvironmentConfig.Environment == "prod" { if s.deps.EnvironmentConfig.Environment == "prod" {
secure = true secure = true
domain = s.deps.EnvironmentConfig.Domain // domain = "potion.gophernest"
// domain = s.deps.EnvironmentConfig.Domain
domain = ".gophernest.net"
} else if s.deps.EnvironmentConfig.Environment == "dev" { } else if s.deps.EnvironmentConfig.Environment == "dev" {
secure = false secure = false
domain = s.deps.EnvironmentConfig.Domain // domain = s.deps.EnvironmentConfig.Domain
domain = "localhost"
} else {
// Defaults
secure = false
domain = ""
} }
ctx.SetSameSite(http.SameSiteNoneMode)
ctx.SetCookie(name, value, maxAge, path, domain, secure, httpOnly) ctx.SetCookie(name, value, maxAge, path, domain, secure, httpOnly)
} }

View File

@ -0,0 +1,143 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
)
func (s *Server) EngagementViewRecipeHandlerV2(ctx *gin.Context) {
recipeId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
userId := getUserId(ctx)
if userId == nil {
if engagement, err := s.deps.EngagementService.ViewRecipe(recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
return
}
if engagement, err := s.deps.EngagementService.UserViewRecipe(*userId, recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
}
func (s *Server) EngagementShareRecipeHandlerV2(ctx *gin.Context) {
recipeId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
userId := getUserId(ctx)
if userId == nil {
if engagement, err := s.deps.EngagementService.ShareRecipe(recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
return
}
if engagement, err := s.deps.EngagementService.UserShareRecipe(*userId, recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
}
func (s *Server) EngagementFavoriteRecipeHandlerV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipeId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
if engagement, err := s.deps.EngagementService.UserFavoriteRecipe(user.Id, recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
})
}
func (s *Server) EngagementMakeRecipeHandlerV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipeId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
if engagement, err := s.deps.EngagementService.UserMakeRecipe(user.Id, recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
})
}

View File

@ -0,0 +1,98 @@
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 for 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")
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()
}
}
// JwtOptionalAuthMiddlewareV2 is responsible for collecting user data for routes where
// authentication is optional. Meaning: if the use is not logged in, this function does
// not fail or return, it simply does nothing. But if the user is logged in, then the
// 'userId' and 'userEmail' context values are set.
//
// e.g., `userIdAny, exists := ctx.Get("userId")`
func JwtOptionalAuthMiddlewareV2(jwtSecretKey []byte) gin.HandlerFunc {
return func(ctx *gin.Context) {
tokenString, err := ctx.Cookie("jwt_token")
if err != nil || tokenString == "" {
// No cookie found: not authenticated, but allow access
ctx.Next()
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
})
if err == nil && token.Valid {
// Set user info in context if token is valid
ctx.Set("userId", claims.UserId)
ctx.Set("userEmail", claims.Email)
}
// Otherwise, just continue (user is unauthenticated)
ctx.Next()
}
}

View File

@ -0,0 +1,113 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe"
)
// 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.
//
// BUG: Until auth is reimplemented, there is no way to determine what user is making the
// call.
// NOTE: I believe this issue has been resolved
func (s *Server) GetRecipeOfTheWeekHandlerV2(ctx *gin.Context) {
userId := getUserId(ctx)
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,
})
}
func (s *Server) GetRecipeHandlerV2(ctx *gin.Context) {
id := ctx.Param("id")
parsedId, err := strconv.Atoi(id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
userId := getUserId(ctx)
recipe, err := s.deps.RecipeService.GetRecipe(parsedId, userId)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get recipe. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved recipe.",
"recipe": recipe,
})
}
func (s *Server) SearchRecipeHandlerV2(ctx *gin.Context) {
var filters domain.SearchFilters
// Parse filters
if err := ctx.ShouldBindJSON(&filters); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse filters. %s", err.Error()),
})
return
}
// This is optional, so we can do this
userId := getUserId(ctx)
// Did I really have two APIs...?
// TODO: Fix service at some point, no need to accept the favorites (bool) param
recipes, err := s.deps.RecipeService.SearchRecipes(filters, userId, filters.Favorites)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get searched recipes. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved recipes based on provided filters.",
"recipes": recipes,
})
}
func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) {
recipe, err := s.deps.RecipeService.CreateRecipe(ctx)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create recipe. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created new recipe.",
"recipe": recipe,
})
}

View File

@ -42,7 +42,11 @@ func Init(port int) *Server {
server.Router.SetTrustedProxies(nil) server.Router.SetTrustedProxies(nil)
// Setup the CORS settings and active them // Setup the CORS settings and active them
server.config.AllowAllOrigins = true // server.config.AllowAllOrigins = true
server.config.AllowOrigins = []string{"http://localhost:5173", "https://potion.gophernest.net"}
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)) server.Router.Use(cors.New(server.config))
return server return server
@ -74,7 +78,8 @@ func (s *Server) Setup() *Server {
// SETUP GOOGLE AUTH // SETUP GOOGLE AUTH
var ( 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 clientId string = cfg.GoogleClientId
clientSecret string = cfg.GoogleClientSecret clientSecret string = cfg.GoogleClientSecret
scope []string = []string{ scope []string = []string{
@ -120,13 +125,15 @@ func (s *Server) Setup() *Server {
// Apply middleware // Apply middleware
s.Router.Use(RecoveryMiddleware()) 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 // Redirect index to home page: Update this as needed
s.Router.GET("/", func(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME) }) s.Router.GET("/", func(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME) })
// Wrap all routes with a version // 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 // Domain specific routers
router_web := router_v1.Group(domain.WEB) router_web := router_v1.Group(domain.WEB)
@ -179,7 +186,7 @@ func (s *Server) Setup() *Server {
path := ctx.Request.URL.Path path := ctx.Request.URL.Path
// TODO: Use constants for errors? // 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{ ctx.JSON(http.StatusNotFound, gin.H{
"status": http.StatusNotFound, "status": http.StatusNotFound,
"error": "API_NOT_FOUND", "error": "API_NOT_FOUND",
@ -192,5 +199,34 @@ func (s *Server) Setup() *Server {
ctx.Redirect(http.StatusSeeOther, domain.WEB_NOT_FOUND) ctx.Redirect(http.StatusSeeOther, domain.WEB_NOT_FOUND)
}) })
// ---- VERSION 2 ROUTES ---- //
router_api_v2 := router_v2.Group(domain.API)
router_api_v2.GET("/recipe/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeHandlerV2)
router_api_v2.GET("/recipe/of-the-week", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeOfTheWeekHandlerV2)
router_api_v2.POST("/recipe/search", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.SearchRecipeHandlerV2)
router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2)
router_api_v2.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("/user/:id", s.GetUserV2)
router_api_v2.GET("/user/recipes", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserRecipesV2)
router_api_v2.GET("/user/favorites", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserFavoritesV2)
router_api_v2.GET("/user/engagement", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserEngagementV2)
router_api_v2.GET("/user/recipes/made", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserMadeRecipesV2)
router_api_v2.GET("/user/recipes/viewed", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserViewedRecipesV2)
router_api_v2.GET("/protected", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"msg": "YAY"})
})
router_api_v2.POST("/engagement/view/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementViewRecipeHandlerV2)
router_api_v2.POST("/engagement/share/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementShareRecipeHandlerV2)
router_api_v2.POST("/engagement/favorite/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementFavoriteRecipeHandlerV2)
router_api_v2.POST("/engagement/make/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementMakeRecipeHandlerV2)
return s return s
} }

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/user"
)
func (s *Server) GetUserV2(ctx *gin.Context) {
id := ctx.Param("id")
parsedId, err := strconv.Atoi(id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
user, err := s.deps.UserService.GetUser(parsedId)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get the target user. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved target user.",
"user": user,
})
}
func (s *Server) GetAuthenticatedUserHandlerV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user.",
"user": user,
})
})
}
func (s *Server) GetAuthenicatedUserRecipesV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipes, err := s.deps.RecipeService.GetUserRecipes(user.Id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Could not fetch authenticated users's recipes. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user's recipes.",
"recipes": recipes,
})
})
}
func (s *Server) GetAuthenicatedUserFavoritesV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
favorites, err := s.deps.RecipeService.GetUserFavoriteRecipes(user.Id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Could not fetch authenticated users's favorites. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user's favorites.",
"favorites": favorites,
})
})
}
func (s *Server) GetAuthenicatedUserEngagementV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
engagement, err := s.deps.EngagementService.GetUserEngagement(user.Id, 6)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get authenticated user engagement. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user engagement.",
"engagement": engagement,
})
})
}
func (s *Server) GetAuthenicatedUserMadeRecipesV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipes, err := s.deps.RecipeService.GetUserMadeRecipes(user.Id, 6)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get authenticated user's made recipes. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user's made recipes.",
"recipes": recipes,
})
})
}
func (s *Server) GetAuthenicatedUserViewedRecipesV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipes, err := s.deps.RecipeService.GetUserViewedRecipes(user.Id, 6)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to get authenticated user's viewed recipes. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully retrieved authenticated user's viewed recipes.",
"recipes": recipes,
})
})
}

View File

@ -1,11 +1,7 @@
package service package service
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"strconv"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -45,66 +41,25 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
return nil, fmt.Errorf("User is not logged in.") return nil, fmt.Errorf("User is not logged in.")
} }
title := ctx.PostForm("title")
description := ctx.PostForm("description")
preparation := ctx.PostForm("preparation-time")
cook := ctx.PostForm("cook-time")
serving := ctx.PostForm("serving-size")
category := ctx.PostForm("category")
difficulty := ctx.PostForm("difficulty")
ingredients := ctx.PostFormArray("ingredients")
quantity := ctx.PostFormArray("quantity")
instructions := ctx.PostFormArray("instructions")
tags := strings.Split(ctx.PostForm("tags"), ",")
userId := ctx.MustGet("userId").(int) userId := ctx.MustGet("userId").(int)
var req domain.CreateRecipeRequest
// Have to get the image differently if err := ctx.ShouldBindJSON(&req); err != nil {
image, err := ctx.FormFile("image") return nil, err
if err != nil && !errors.Is(err, http.ErrMissingFile) {
// Error getting image
} }
// Convert to proper values
servingInt, _ := strconv.Atoi(serving)
difficultyInt, _ := strconv.Atoi(difficulty)
prepInt, _ := strconv.Atoi(preparation)
cookInt, _ := strconv.Atoi(cook)
var ingredientSlice []domain.RecipeIngredient
for i := range len(ingredients) {
if strings.TrimSpace(ingredients[i]) != "" {
ins := domain.RecipeIngredient{
Name: ingredients[i],
Quantity: quantity[i],
}
ingredientSlice = append(ingredientSlice, ins)
}
}
var instructionSlice []string
for _, ins := range instructions {
if ins != "" {
instructionSlice = append(instructionSlice, ins)
}
}
// Create the recipe
recipe := domain.Recipe{ recipe := domain.Recipe{
Title: title, Title: req.Title,
Description: description, Description: req.Description,
Instructions: instructionSlice, Instructions: req.Instructions,
Serves: servingInt, Serves: req.Serves,
Difficulty: difficultyInt, Difficulty: req.Difficulty,
Duration: domain.RecipeDuration{ Duration: req.Duration,
Total: prepInt + cookInt, Category: req.Category,
Prep: prepInt, Ingredients: req.Ingredients,
Cook: cookInt, Sections: req.Sections,
}, UserId: userId,
Category: domain.RecipeMeal(category), Created: time.Now(),
Ingredients: ingredientSlice,
UserId: userId,
Created: time.Now(),
} }
if err := s.recipeRepository.CreateRecipe(&recipe); err != nil { if err := s.recipeRepository.CreateRecipe(&recipe); err != nil {
@ -112,17 +67,96 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
} }
// TODO: Upload the image // TODO: Upload the image
if image != nil { // if req.image != nil {
} // }
// Create the tags // Create the tags
if len(tags) > 0 { if len(req.Tags) > 0 {
if err := s.recipeRepository.CreateRecipeTags(recipe, tags); err != nil { if err := s.recipeRepository.CreateRecipeTags(recipe, req.Tags); err != nil {
return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error()) return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error())
} }
} }
return &recipe, nil return &recipe, nil
// title := ctx.PostForm("title")
// description := ctx.PostForm("description")
// preparation := ctx.PostForm("preparation-time")
// cook := ctx.PostForm("cook-time")
// serving := ctx.PostForm("serving-size")
// category := ctx.PostForm("category")
// difficulty := ctx.PostForm("difficulty")
// ingredients := ctx.PostFormArray("ingredients")
// quantity := ctx.PostFormArray("quantity")
// instructions := ctx.PostFormArray("instructions")
// tags := strings.Split(ctx.PostForm("tags"), ",")
// userId := ctx.MustGet("userId").(int)
//
// // Have to get the image differently
// image, err := ctx.FormFile("image")
// if err != nil && !errors.Is(err, http.ErrMissingFile) {
// // Error getting image
// }
//
// // Convert to proper values
// servingInt, _ := strconv.Atoi(serving)
// difficultyInt, _ := strconv.Atoi(difficulty)
// prepInt, _ := strconv.Atoi(preparation)
// cookInt, _ := strconv.Atoi(cook)
//
// var ingredientSlice []domain.RecipeIngredient
// for i := range len(ingredients) {
// if strings.TrimSpace(ingredients[i]) != "" {
// ins := domain.RecipeIngredient{
// Name: ingredients[i],
// Quantity: quantity[i],
// }
//
// ingredientSlice = append(ingredientSlice, ins)
// }
// }
//
// var instructionSlice []string
// for _, ins := range instructions {
// if ins != "" {
// instructionSlice = append(instructionSlice, ins)
// }
// }
//
// // Create the recipe
// recipe := domain.Recipe{
// Title: title,
// Description: description,
// Instructions: instructionSlice,
// Serves: servingInt,
// Difficulty: difficultyInt,
// Duration: domain.RecipeDuration{
// Total: prepInt + cookInt,
// Prep: prepInt,
// Cook: cookInt,
// },
// Category: domain.RecipeMeal(category),
// Ingredients: ingredientSlice,
// UserId: userId,
// Created: time.Now(),
// }
//
// if err := s.recipeRepository.CreateRecipe(&recipe); err != nil {
// return &recipe, err
// }
//
// // TODO: Upload the image
// if image != nil {
// }
//
// // Create the tags
// if len(tags) > 0 {
// if err := s.recipeRepository.CreateRecipeTags(recipe, tags); err != nil {
// return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error())
// }
// }
//
// return &recipe, nil
} }
// GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore, // GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore,
@ -135,7 +169,7 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
func (s *RecipeService) GetRecipe(id int, userId *int) (*domain.Recipe, error) { func (s *RecipeService) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
recipe, err := s.recipeRepository.GetRecipe(id, userId) recipe, err := s.recipeRepository.GetRecipe(id, userId)
if recipe == nil { if recipe == nil && err == nil {
return nil, fmt.Errorf("Recipe does not exist or has been relocated. Please try again.") return nil, fmt.Errorf("Recipe does not exist or has been relocated. Please try again.")
} }
@ -159,19 +193,34 @@ func (s *RecipeService) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
// //
// The favorites parameter is used to only return filters favorited by the userId provided. // The favorites parameter is used to only return filters favorited by the userId provided.
func (s *RecipeService) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]domain.Recipe, error) { func (s *RecipeService) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]domain.Recipe, error) {
return s.recipeRepository.SearchRecipes(filters, userId, favorites) ids, err := s.recipeRepository.SearchRecipes(filters, userId, favorites)
if err != nil {
return []domain.Recipe{}, err
}
return s.recipeRepository.GetRecipes(ids, userId)
} }
// GetUserRecipes returns a list of the recipes that the user has created. The user's // GetUserRecipes returns a list of the recipes that the user has created. The user's
// ID should be provided. Any errors will be bubbled to the caller. // ID should be provided. Any errors will be bubbled to the caller.
func (s *RecipeService) GetUserRecipes(id int) ([]domain.Recipe, error) { func (s *RecipeService) GetUserRecipes(userId int) ([]domain.Recipe, error) {
return s.recipeRepository.GetUserRecipes(id) ids, err := s.recipeRepository.GetUserRecipesIds(userId)
if err != nil {
return []domain.Recipe{}, err
}
return s.recipeRepository.GetRecipes(ids, &userId)
} }
// GetUserFavoriteRecipes returns a list of the recipes that the user has marked as a // GetUserFavoriteRecipes returns a list of the recipes that the user has marked as a
// favorite. The user's ID should be provided. Any errors will be bubbled to the caller. // favorite. The user's ID should be provided. Any errors will be bubbled to the caller.
func (s *RecipeService) GetUserFavoriteRecipes(id int) ([]domain.Recipe, error) { func (s *RecipeService) GetUserFavoriteRecipes(userId int) ([]domain.Recipe, error) {
return s.recipeRepository.GetUserFavoriteRecipes(id) ids, err := s.recipeRepository.GetUserFavoriteRecipesIds(userId)
if err != nil {
return []domain.Recipe{}, err
}
return s.recipeRepository.GetRecipes(ids, &userId)
} }
// GetUserViewedRecipes returns a list of the most recent x (limit) recipes viewed by a user, from // GetUserViewedRecipes returns a list of the most recent x (limit) recipes viewed by a user, from
@ -211,5 +260,14 @@ func (s *RecipeService) GetUserMadeRecipes(userId, limit int) ([]domain.Recipe,
// GetRecipeOfTheWeek searches for the most recent recipe of the week. If there is not a value, // GetRecipeOfTheWeek searches for the most recent recipe of the week. If there is not a value,
// the recipe will be nil. Any errors will be bubbled to the caller. // the recipe will be nil. Any errors will be bubbled to the caller.
func (s *RecipeService) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error) { func (s *RecipeService) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error) {
return s.recipeRepository.GetRecipeOfTheWeek(userId) id, err := s.recipeRepository.GetRecipeOfTheWeekId(userId)
if err != nil {
return nil, err
}
if id == nil {
return nil, fmt.Errorf("[ERROR] Recipe of the week ID could not be found. It may not exist.")
}
return s.recipeRepository.GetRecipe(*id, userId)
} }

View File

@ -5,9 +5,9 @@ import "time"
// RecipeDuration is the duration to prepare recipe. It has JSON tags which allows it to be // RecipeDuration is the duration to prepare recipe. It has JSON tags which allows it to be
// marshaled into a JSON object and stored in the database (JSONB). // marshaled into a JSON object and stored in the database (JSONB).
type RecipeDuration struct { type RecipeDuration struct {
Total int `json:"total"` Total int `json:"Total"`
Prep int `json:"prep"` Prep int `json:"Prep"`
Cook int `json:"cook"` Cook int `json:"Cook"`
} }
// RecipeMeal is the database enum E_MEAL which defines the meal type of a recipe. Postgres enums // RecipeMeal is the database enum E_MEAL which defines the meal type of a recipe. Postgres enums
@ -46,11 +46,62 @@ func ParseMeal(meal int) RecipeMeal {
} }
} }
// TODO: Comment
type RecipeIngredientUnit string
const (
Blank RecipeIngredientUnit = ""
Tsp RecipeIngredientUnit = "tsp"
Tbsp RecipeIngredientUnit = "tbsp"
FlOz RecipeIngredientUnit = "fl oz"
Cup RecipeIngredientUnit = "cup"
Ml RecipeIngredientUnit = "ml"
Litre RecipeIngredientUnit = "l"
Pint RecipeIngredientUnit = "pt"
Quart RecipeIngredientUnit = "qt"
Gallon RecipeIngredientUnit = "gal"
Gram RecipeIngredientUnit = "g"
Kilogram RecipeIngredientUnit = "kg"
Ounce RecipeIngredientUnit = "oz"
Pound RecipeIngredientUnit = "lb"
Piece RecipeIngredientUnit = "piece"
Clove RecipeIngredientUnit = "clove"
Slice RecipeIngredientUnit = "slice"
Stick RecipeIngredientUnit = "stick"
Bunch RecipeIngredientUnit = "bunch"
Pinch RecipeIngredientUnit = "pinch"
Dash RecipeIngredientUnit = "dash"
Splash RecipeIngredientUnit = "splash"
ToTaste RecipeIngredientUnit = "to taste"
)
// RecipeIngredient is a single ingredients in a recipe. These have JSON tags which allow them // RecipeIngredient is a single ingredients in a recipe. These have JSON tags which allow them
// to be marshaled into a JSON array and stored in the database (JSONB). // to be marshaled into a JSON array and stored in the database (JSONB).
type RecipeIngredient struct { type RecipeIngredient struct {
Name string `json:"name"` Id string `json:"Id"`
Quantity string `json:"quantity"` SectionId string `json:"SectionId"`
Name string `json:"Name"`
Amount float64 `json:"Amount"`
Unit RecipeIngredientUnit `json:"Unit"`
}
// TODO: Comment
type RecipeInstruction struct {
Id string `json:"Id"`
Content string `json:"Content"`
}
// TODO: Comment
type RecipeIngredientSection struct {
Id string `json:"Id"`
Name string `json:"Name"`
}
// RecipeIngredientStore is the struct stored in the database Ingredients column. It is simply a
// combindation of the sections and the ingredients so they can be stored together.
type RecipeIngredientStore struct {
Sections []RecipeIngredientSection `json:"Sections"`
Ingredients []RecipeIngredient `json:"Ingredients"`
} }
// Recipe is the database model of a recipe. There is no need to map to a different model so // Recipe is the database model of a recipe. There is no need to map to a different model so
@ -61,12 +112,13 @@ type Recipe struct {
Id int Id int
Title string Title string
Description string Description string
Instructions []string Instructions []RecipeInstruction
Serves int Serves int
Difficulty int Difficulty int
Duration RecipeDuration Duration RecipeDuration
Category RecipeMeal Category RecipeMeal
Ingredients []RecipeIngredient // Just a list of ingredients Ingredients []RecipeIngredient
Sections []RecipeIngredientSection
UserId int UserId int
Modified *time.Time // Pointer to allow null Modified *time.Time // Pointer to allow null
Created time.Time Created time.Time
@ -78,11 +130,12 @@ type Recipe struct {
// The integer values should be provided as bits and used to parse out individual flags. More // The integer values should be provided as bits and used to parse out individual flags. More
// details can be found in the SearchRecipes service function. // details can be found in the SearchRecipes service function.
type SearchFilters struct { type SearchFilters struct {
Search string Search string `json:"Search"`
MealType int MealType int `json:"MealType"`
Time int Time int `json:"Time"`
Difficulty int Difficulty int `json:"Difficulty"`
ServingSize int ServingSize int `json:"ServingSize"`
Favorites bool `json:"Favorites"`
} }
// Tag is a model which represents a single tag in the Tags table. A tag is mapped to a recipe // Tag is a model which represents a single tag in the Tags table. A tag is mapped to a recipe
@ -101,3 +154,17 @@ type RecipeTag struct {
TagId int TagId int
Created time.Time Created time.Time
} }
// TODO: Comment
type CreateRecipeRequest struct {
Title string
Description string
Instructions []RecipeInstruction
Serves int
Difficulty int
Duration RecipeDuration
Category RecipeMeal
Ingredients []RecipeIngredient
Sections []RecipeIngredientSection
Tags []string
}

View File

@ -4,11 +4,11 @@ type RecipeRepository interface {
CreateRecipe(recipe *Recipe) error CreateRecipe(recipe *Recipe) error
GetRecipe(id int, userId *int) (*Recipe, error) GetRecipe(id int, userId *int) (*Recipe, error)
GetRecipes(ids []int, userId *int) ([]Recipe, error) GetRecipes(ids []int, userId *int) ([]Recipe, error)
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error) SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error)
CreateRecipeTags(recipe Recipe, tags []string) error CreateRecipeTags(recipe Recipe, tags []string) error
GetUserRecipes(id int) ([]Recipe, error) GetUserRecipesIds(userId int) ([]int, error)
GetUserFavoriteRecipes(id int) ([]Recipe, error) GetUserFavoriteRecipesIds(userId int) ([]int, error)
GetRecipeTags(recipe *Recipe) error GetRecipeTags(recipe *Recipe) error
GetRecipeFavorite(recipe *Recipe, userId int) error GetRecipeFavorite(recipe *Recipe, userId int) error
GetRecipeOfTheWeek(userId *int) (*Recipe, error) GetRecipeOfTheWeekId(userId *int) (*int, error)
} }

View File

@ -8,8 +8,8 @@ type RecipeService interface {
CreateRecipe(ctx *gin.Context) (*Recipe, error) CreateRecipe(ctx *gin.Context) (*Recipe, error)
GetRecipe(id int, userId *int) (*Recipe, error) GetRecipe(id int, userId *int) (*Recipe, error)
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error) SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error)
GetUserRecipes(id int) ([]Recipe, error) GetUserRecipes(userId int) ([]Recipe, error)
GetUserFavoriteRecipes(id int) ([]Recipe, error) GetUserFavoriteRecipes(userId int) ([]Recipe, error)
GetUserViewedRecipes(userId, limit int) ([]Recipe, error) GetUserViewedRecipes(userId, limit int) ([]Recipe, error)
GetUserMadeRecipes(userId, limit int) ([]Recipe, error) GetUserMadeRecipes(userId, limit int) ([]Recipe, error)
GetRecipeOfTheWeek(userId *int) (*Recipe, error) GetRecipeOfTheWeek(userId *int) (*Recipe, error)

View File

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

View File

@ -23,6 +23,7 @@ type EnvironmentConfig struct {
DatabaseUrl string DatabaseUrl string
Environment string Environment string
Domain string Domain string
FrontendDomain string
} }
// InjectedDependencies is a collection of dependencies that are injected into the application. They // InjectedDependencies is a collection of dependencies that are injected into the application. They
@ -38,8 +39,8 @@ type InjectedDependencies struct {
// JwtClaims is the data stored in the JSON web token. All that is needed is the users ID and their // JwtClaims is the data stored in the JSON web token. All that is needed is the users ID and their
// Google email provided. // Google email provided.
type JwtClaims struct { type JwtClaims struct {
UserId int `json:"id"` UserId int `json:"Id"`
Email string `json:"email"` Email string `json:"Email"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@ -81,16 +82,25 @@ func LoadEnvironment() (*EnvironmentConfig, error) {
} }
var domain string var domain string
var frontendDomain string
if env == "dev" { if env == "dev" {
domain = os.Getenv("DOMAIN_DEV") domain = os.Getenv("DOMAIN_DEV")
if domain == "" { if domain == "" {
return nil, fmt.Errorf("DOMAIN_DEV environment variable is required when ENVIRONMENT is 'dev'.") return nil, fmt.Errorf("DOMAIN_DEV environment variable is required when ENVIRONMENT is 'dev'.")
} }
frontendDomain = os.Getenv("FRONTEND_DOMAIN_DEV")
if frontendDomain == "" {
return nil, fmt.Errorf("FRONTEND_DOMAIN_DEV environment variable is required when ENVIRONMENT is 'dev'.")
}
} else if env == "prod" { } else if env == "prod" {
domain = os.Getenv("DOMAIN_PROD") domain = os.Getenv("DOMAIN_PROD")
if domain == "" { if domain == "" {
return nil, fmt.Errorf("DOMAIN_PROD environment variable is required when ENVIRONMENT is 'prod'.") return nil, fmt.Errorf("DOMAIN_PROD environment variable is required when ENVIRONMENT is 'prod'.")
} }
frontendDomain = os.Getenv("FRONTEND_DOMAIN_PROD")
if frontendDomain == "" {
return nil, fmt.Errorf("FRONTEND_DOMAIN_PROD environment variable is required when ENVIRONMENT is 'dev'.")
}
} else { } else {
return nil, fmt.Errorf("ENVIRONMENT environment variable is required and must be 'dev' or 'prod'.") return nil, fmt.Errorf("ENVIRONMENT environment variable is required and must be 'dev' or 'prod'.")
} }
@ -117,8 +127,11 @@ func LoadEnvironment() (*EnvironmentConfig, error) {
DatabaseUrl: dbUrl, DatabaseUrl: dbUrl,
Environment: env, Environment: env,
Domain: domain, Domain: domain,
FrontendDomain: frontendDomain,
} }
fmt.Printf("Environment Config: %+v\n", cfg)
return cfg, nil return cfg, nil
} }

View File

@ -4,13 +4,13 @@ import "time"
// GoogleUserInfo is a data type which contains a mapping of the Google User Info API call. // GoogleUserInfo is a data type which contains a mapping of the Google User Info API call.
type GoogleUserInfo struct { type GoogleUserInfo struct {
Id string `json:"id"` Id string `json:"Id"`
Email string `json:"email"` Email string `json:"Email"`
Verified bool `json:"verified_email"` Verified bool `json:"VerifiedEmail"`
Name string `json:"name"` Name string `json:"Name"`
GivenName string `json:"given_name"` GivenName string `json:"GivenName"`
FamilyName string `json:"family_name"` FamilyName string `json:"FamilyName"`
Picture string `json:"picture"` Picture string `json:"Picture"`
} }
// User is the database model of a user. There is no need to map to a different model so // User is the database model of a user. There is no need to map to a different model so

View File

@ -46,7 +46,9 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
// NOTE: Data steps // NOTE: Data steps
// cast duration to JSON // cast duration to JSON
// cast ingredients to JSON // convert ingredients to store type
// cast store type to JSON
// extract string instructions from type
// cast category to string // cast category to string
// use nil for the modified time // use nil for the modified time
@ -55,17 +57,27 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
return err return err
} }
ingredientsJSON, err := json.Marshal(recipe.Ingredients) ingredientsStore := domain.RecipeIngredientStore{
Sections: recipe.Sections,
Ingredients: recipe.Ingredients,
}
ingredientsJSON, err := json.Marshal(ingredientsStore)
if err != nil { if err != nil {
return err return err
} }
instructions := make([]string, len(recipe.Instructions))
for i, instruction := range recipe.Instructions {
instructions[i] = instruction.Content
}
var id int var id int
if err = tx.QueryRow( if err = tx.QueryRow(
query, query,
recipe.Title, recipe.Title,
recipe.Description, recipe.Description,
pq.Array(recipe.Instructions), pq.Array(instructions),
recipe.Serves, recipe.Serves,
recipe.Difficulty, recipe.Difficulty,
durationJSON, durationJSON,
@ -94,8 +106,7 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
// for added safety. The repository will not check for a nil result, instead the service will. Callers // for added safety. The repository will not check for a nil result, instead the service will. Callers
// are responsible for protecting against double nil results. Any errors will be bubbled to the caller. // are responsible for protecting against double nil results. Any errors will be bubbled to the caller.
func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) { func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
query := ` query := ` SELECT
SELECT
id, title, description, instructions, serves, difficulty, duration, category, ingredients, id, title, description, instructions, serves, difficulty, duration, category, ingredients,
userid, modified, created userid, modified, created
FROM recipes FROM recipes
@ -103,6 +114,7 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
` `
var durationBytes []byte var durationBytes []byte
var instructions pq.StringArray
var ingredientBytes []byte var ingredientBytes []byte
var recipe domain.Recipe var recipe domain.Recipe
@ -110,7 +122,8 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
&recipe.Id, &recipe.Id,
&recipe.Title, &recipe.Title,
&recipe.Description, &recipe.Description,
pq.Array(&recipe.Instructions), // pq.Array(&instructions),
&instructions,
&recipe.Serves, &recipe.Serves,
&recipe.Difficulty, &recipe.Difficulty,
&durationBytes, &durationBytes,
@ -137,16 +150,23 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
// Parse ingredient // Parse ingredient
if len(ingredientBytes) > 0 { if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient var store domain.RecipeIngredientStore
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil { if err := json.Unmarshal(ingredientBytes, &store); err != nil {
// Check for unmarshal to support backwards compatability
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error()) return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
} }
recipe.Ingredients = ingredients recipe.Ingredients = store.Ingredients
recipe.Sections = store.Sections
} else { } else {
recipe.Ingredients = []domain.RecipeIngredient{} recipe.Ingredients = []domain.RecipeIngredient{}
} }
// Add instructions
for _, instruction := range instructions {
recipe.Instructions = append(recipe.Instructions, domain.RecipeInstruction{Content: instruction})
}
// Add tags // Add tags
if err := r.GetRecipeTags(&recipe); err != nil { if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error()) fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
@ -169,83 +189,18 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
// will. Callers are responsible for protecting against double nil results. Any errors will be bubbled // will. Callers are responsible for protecting against double nil results. Any errors will be bubbled
// to the caller. // to the caller.
func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) { func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) {
query := `
SELECT
id, title, description, instructions, serves, difficulty, duration, category, ingredients, userid, modified, created
FROM recipes
WHERE id = ANY($1)
ORDER BY array_position($1, id);
`
var recipes []domain.Recipe var recipes []domain.Recipe
rows, err := r.db.Query(query, pq.Array(ids)) for _, id := range ids {
if err != nil { recipe, err := r.GetRecipe(id, userId)
return nil, fmt.Errorf("Failed to get recipes. %s", err.Error()) if err != nil {
} return nil, err
defer rows.Close()
for rows.Next() {
var recipe domain.Recipe
var durationBytes []byte
var ingredientBytes []byte
if err := rows.Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("Failed to scan recipe from database: %s", err.Error())
} }
// Parse duration // Skip any un-found recipes...?
if len(durationBytes) > 0 { if recipe != nil {
var duration domain.RecipeDuration recipes = append(recipes, *recipe)
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error())
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
} }
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Get favorite status, if user id is provided
if userId != nil {
if err := r.GetRecipeFavorite(&recipe, *userId); err != nil {
fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
}
} else {
recipe.Favorite = false
}
recipes = append(recipes, recipe)
} }
return recipes, nil return recipes, nil
@ -264,7 +219,12 @@ func isBitActive(bits, pos int) bool {
// The favorites parameter is used to only return filters favorited by the userId provided. // The favorites parameter is used to only return filters favorited by the userId provided.
// //
// TODO: Pagination is required, to provide infinite scroll. // TODO: Pagination is required, to provide infinite scroll.
func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]domain.Recipe, error) { //
// TODO: This does not work in the current build, the DB does not return valid values.
//
// 12/28/25: This function has changed, now longer returns the recipes, but their IDs for fetching
// elsewhere.
func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]int, error) {
// Compute meals type filters (there are 7 bits) // Compute meals type filters (there are 7 bits)
var mealConditions []string var mealConditions []string
for i := range 7 { for i := range 7 {
@ -348,17 +308,6 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
// Define columns to select. More fields can be added if the full text search is required // Define columns to select. More fields can be added if the full text search is required
columns := []string{ columns := []string{
"r.id", "r.id",
"r.title",
"r.description",
"r.instructions",
"r.serves",
"r.difficulty",
"r.duration",
"r.category",
"r.ingredients",
"r.userid",
"r.modified",
"r.created",
} }
// TODO: Need to add these to the query // TODO: Need to add these to the query
@ -435,76 +384,21 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
// Execute the query // Execute the query
rows, err := r.db.Query(query) rows, err := r.db.Query(query)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query recipes: %w", err) return []int{}, fmt.Errorf("failed to query recipes: %w", err)
} }
defer rows.Close() defer rows.Close()
var recipes []domain.Recipe var ids []int
for rows.Next() { for rows.Next() {
// Parsed values location var id int
var recipe domain.Recipe if err := rows.Scan(&id); err != nil {
var durationBytes []byte return []int{}, fmt.Errorf("failed to extract ID: %s\n", err.Error())
var ingredientBytes []byte
if err := rows.Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("failed to scan recipe row: %w", err)
} }
// Parse duration from bytes ids = append(ids, id)
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("failed to parse duration for recipe ID %d: %w", recipe.Id, err)
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
}
// Parse ingredients from bytes
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("failed to parse ingredients for recipe ID %d: %w", recipe.Id, err)
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Add recipe if not a favorite search
if !favorites && userId != nil {
if err := r.GetRecipeFavorite(&recipe, *userId); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
}
if favorites {
recipe.Favorite = true
}
recipes = append(recipes, recipe)
} }
return recipes, nil return ids, nil
} }
// CreateRecipeTags accepts a list of tags (names) and a recipe (already created by the DB) and // CreateRecipeTags accepts a list of tags (names) and a recipe (already created by the DB) and
@ -569,96 +463,43 @@ func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string)
// GetUserRecipes gets a list of a users owned recipes. This function does not ensure the user is // GetUserRecipes gets a list of a users owned recipes. This function does not ensure the user is
// authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list // authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller. // is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
func (r *RecipeRepository) GetUserRecipes(id int) ([]domain.Recipe, error) { //
// 12/28/25: This now returns just the IDs, the service can handle fetching them.
func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) {
query := ` query := `
SELECT id, title, description, instructions, serves, difficulty, duration, category, ingredients, SELECT id
userid, modified, created
FROM recipes FROM recipes
WHERE userid = $1 WHERE userid = $1
ORDER BY created DESC; ORDER BY created DESC;
` `
rows, err := r.db.Query(query, id) rows, err := r.db.Query(query, user_id)
if err != nil { if err != nil {
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error()) return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error())
} }
defer rows.Close() defer rows.Close()
// Prepare statement for tag query var ids []int
// tagQuery := `
// `
var recipes []domain.Recipe
for rows.Next() { for rows.Next() {
var recipe domain.Recipe var r_id int
var durationBytes []byte if err := rows.Scan(&r_id); err != nil {
var ingredientBytes []byte return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
// Scan results from recipe query onto recipe object
if err := rows.Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("Failed to scan row onto recipe object. %s\n", err.Error())
} }
// Parse duration ids = append(ids, r_id)
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error())
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
}
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Get favorite status
if err := r.GetRecipeFavorite(&recipe, id); err != nil {
fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
}
recipes = append(recipes, recipe)
} }
return recipes, nil return ids, nil
} }
// GetUserRecipes gets a list of a users favorited recipes. This function does not ensure the user is // GetUserRecipes gets a list of a users favorited recipes. This function does not ensure the user is
// authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list // authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller. // is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
func (r *RecipeRepository) GetUserFavoriteRecipes(id int) ([]domain.Recipe, error) { //
// 12/28/25: This now just returns the IDs, so the service can handle the fetching.
func (r *RecipeRepository) GetUserFavoriteRecipesIds(id int) ([]int, error) {
query := ` query := `
SELECT r.id, r.title, r.description, r.instructions, r.serves, r.difficulty, r.duration, r.category, r.ingredients, r. SELECT r.id
userid, r.modified, r.created
FROM favorites f FROM favorites f
JOIN recipes r ON r.id = f.recipeid JOIN recipes r ON r.id = f.recipeid
WHERE f.userid = $1 WHERE f.userid = $1
@ -670,66 +511,17 @@ func (r *RecipeRepository) GetUserFavoriteRecipes(id int) ([]domain.Recipe, erro
} }
defer rows.Close() defer rows.Close()
var recipes []domain.Recipe var ids []int
for rows.Next() { for rows.Next() {
var recipe domain.Recipe var r_id int
var durationBytes []byte if err := rows.Scan(&r_id); err != nil {
var ingredientBytes []byte return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
// Scan results from recipe query onto recipe object
if err := rows.Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("Failed to scan row onto recipe object. %s\n", err.Error())
} }
// Parse duration ids = append(ids, r_id)
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error())
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
}
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Set favorite status (they're always true!)
recipe.Favorite = true
recipes = append(recipes, recipe)
} }
return recipes, nil return ids, nil
} }
// GetRecipeTags requires a recipe to be filled with at least an ID. This function will use the ID // GetRecipeTags requires a recipe to be filled with at least an ID. This function will use the ID
@ -793,82 +585,27 @@ func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int)
return nil return nil
} }
// GetRecipeOfTheWeek searches for the most recent recipe of the week. If there is not a value, // GetRecipeOfTheWeekId searches for the most recent recipe of the week. If there is not a value,
// the recipe will be nil. This function simply collects the most recent entry in the recipeoftheweek // the recipe will be nil. This function simply collects the most recent entry in the recipeoftheweek
// table and return it. If there is no entry, nil will be returned Any errors will be bubbled to // table and return it. If there is no entry, nil will be returned. Any errors will be bubbled to
// the caller. // the caller. All that is returned is the recipe ID, that way the caller can handle the fetching.
func (r *RecipeRepository) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error) { func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
query := ` query := `
SELECT SELECT
r.id, r.title, r.description, r.instructions, r.serves, r.difficulty, r.duration, r.category, r.id
r.ingredients, r.userid, r.modified, r.created
FROM recipes r FROM recipes r
JOIN recipeoftheweek rw ON rw.recipeid = r.id JOIN recipeoftheweek rw ON rw.recipeid = r.id
ORDER BY created DESC ORDER BY rw.created DESC
LIMIT 1; LIMIT 1;
` `
var durationBytes []byte var id int
var ingredientBytes []byte if err := r.db.QueryRow(query).Scan(&id); err != nil {
var recipe domain.Recipe
if err := r.db.QueryRow(query).Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error()) return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error())
} }
// Parse duration return &id, nil
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error())
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
}
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
}
// Get favorite status, if user id is provided
if userId != nil {
if err := r.GetRecipeFavorite(&recipe, *userId); err != nil {
fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
}
} else {
recipe.Favorite = false
}
return &recipe, nil
} }

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943 // templ: version: v0.3.960
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.943 // templ: version: v0.3.960
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.943 // templ: version: v0.3.960
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.943 // templ: version: v0.3.960
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.943 // templ: version: v0.3.960
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.943 // templ: version: v0.3.960
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.943 // templ: version: v0.3.960
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.943 // templ: version: v0.3.960
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.943 // templ: version: v0.3.960
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.943 // templ: version: v0.3.960
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.943 // templ: version: v0.3.960
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.943 // templ: version: v0.3.960
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.943 // templ: version: v0.3.960
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.943 // templ: version: v0.3.960
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

@ -112,7 +112,7 @@ templ ingredientList(ingredients []domain.RecipeIngredient) {
<hr class="text-gray-300"/> <hr class="text-gray-300"/>
<ul class="text-lg my-4 text-gray-700"> <ul class="text-lg my-4 text-gray-700">
for _, ingredient := range ingredients { for _, ingredient := range ingredients {
@ingredientListItem(ingredient.Name, ingredient.Quantity) @ingredientListItem(ingredient.Name, "")
} }
</ul> </ul>
</div> </div>
@ -308,7 +308,7 @@ templ RecipePage(recipe domain.Recipe, user domainUser.User, loggedIn bool, doma
<p class="text-gray-700">{ recipe.Description }</p> <p class="text-gray-700">{ recipe.Description }</p>
</div> </div>
@ingredientList(recipe.Ingredients) @ingredientList(recipe.Ingredients)
@instructionList(recipe.Instructions) @instructionList([]string{})
@tagList(recipe.Tags, recipe.Created, recipe.Modified) @tagList(recipe.Tags, recipe.Created, recipe.Modified)
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943 // templ: version: v0.3.960
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.
@ -268,7 +268,7 @@ func ingredientList(ingredients []domain.RecipeIngredient) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, ingredient := range ingredients { for _, ingredient := range ingredients {
templ_7745c5c3_Err = ingredientListItem(ingredient.Name, ingredient.Quantity).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = ingredientListItem(ingredient.Name, "").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -875,7 +875,7 @@ func RecipePage(recipe domain.Recipe, user domainUser.User, loggedIn bool, domai
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = instructionList(recipe.Instructions).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = instructionList([]string{}).Render(ctx, templ_7745c5c3_Buffer)
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.943 // templ: version: v0.3.960
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.

34
shell.nix Normal file
View File

@ -0,0 +1,34 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
go
gopls
go-tools
htmx-lsp2
templ
tailwindcss_4
tailwindcss-language-server
watchman
docker-language-server
dockerfile-language-server-nodejs
gcc_multi
glibc_multi
nodejs
];
shellHook = ''
alias vim="nvim"
alias vi="nvim"
alias v="nvim"
# Modify this
export PS1="\[\e[35m\]\w \$ \[\e[0m\]"
echo ""
echo "The default environment is ready!"
echo ""
exec zsh
'';
}

26
web/.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

32
web/Dockerfile Normal file
View File

@ -0,0 +1,32 @@
# Build stage
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Build-time config: defaults are prod-safe, can be overridden if needed
ARG VITE_ENVIRONMENT=prod
ARG VITE_DOMAIN_DEV=http://localhost:3000
ARG VITE_DOMAIN_PROD=https://potion-backend.gophernest.net
ENV VITE_ENVIRONMENT=$VITE_ENVIRONMENT
ENV VITE_DOMAIN_DEV=$VITE_DOMAIN_DEV
ENV VITE_DOMAIN_PROD=$VITE_DOMAIN_PROD
RUN npm run build
# Runtime stage
FROM node:18-alpine
WORKDIR /app
# Install static file server
RUN npm install -g serve
# Copy build output only
COPY --from=build /app/dist ./dist
EXPOSE 3002
CMD ["serve", "-s", "dist", "-l", "3002"]

4
web/README.md Normal file
View File

@ -0,0 +1,4 @@
# IF BACKEND CANNOT GET COOKIE
Do not forget to send the axios request with the `{ withCredentials: true }` flags.

43
web/eslint.config.js Normal file
View File

@ -0,0 +1,43 @@
import js from "@eslint/js"
import globals from "globals"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import tseslint from "typescript-eslint"
import { defineConfig, globalIgnores } from "eslint/config"
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
reactHooks.configs["recommended-latest"],
reactRefresh.configs.vite,
// 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,
// 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,
},
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

16
web/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gophernest - Potion</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4244
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
web/package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@tailwindcss/vite": "^4.1.16",
"axios": "^1.13.2",
"eslint-plugin-react-dom": "^2.2.4",
"eslint-plugin-react-x": "^2.2.4",
"motion": "^12.23.25",
"react": "^19.1.1",
"react-cookie": "^8.0.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.5",
"tailwindcss": "^4.1.16"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react-swc": "^4.1.0",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}

1
web/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

62
web/src/App.tsx Normal file
View File

@ -0,0 +1,62 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import Home from './pages/Home';
import WebLayout from "./layouts/WebLayout";
import NotFound from './pages/NotFound';
import ROUTE_CONSTANTS from './types/routes';
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';
import RecipePage from './pages/Recipe';
import SearchPage from './pages/Search';
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="search" element={<SearchPage />} />
<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={<RecipePage />} />
</Route>
{/* 404: Not Found */}
<Route path="*" element={<WebLayout />}>
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

1
web/src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,12 @@
interface BannerProps {
content: string;
};
export default function Banner({ content }: BannerProps) {
return (
<h2 className="text-xl md:text-2xl bg-gradient-to-r from-blue-400 to-blue-600 w-full h-fit py-6 text-center text-white">
{content}
</h2>
);
}

View File

@ -0,0 +1,116 @@
import { useState } from "react";
import ROUTE_CONSTANTS from "../types/routes.ts";
import { useLocation } from "react-router-dom";
import ShoppingListIcon from "./icons/ShoppingListIcon.tsx";
export default function Navigation() {
const [displayHamburgerMenu, setDisplayHamburgerMenu] = useState<boolean>(false);
const location = useLocation();
return (
<>
<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"
>
<div>
<a href={ROUTE_CONSTANTS.Home}>
<p className="select-none">Potion</p>
</a>
</div>
<div className="hidden md:flex lg:flex items-center gap-8 select-none">
<NavigationLink name="Home" url={ROUTE_CONSTANTS.Home} current={location.pathname === ROUTE_CONSTANTS.Home} />
<NavigationLink name="Favorites" url={ROUTE_CONSTANTS.Favorites} current={location.pathname === ROUTE_CONSTANTS.Favorites} />
<NavigationLink name="Create" url={ROUTE_CONSTANTS.Create} current={location.pathname === ROUTE_CONSTANTS.Create} />
<NavigationLink name="Profile" url={ROUTE_CONSTANTS.Profile} current={location.pathname === ROUTE_CONSTANTS.Profile} />
<IconNavigationLink icon={<ShoppingListIcon current={location.pathname === ROUTE_CONSTANTS.ShoppingList} />} url={ROUTE_CONSTANTS.ShoppingList} />
</div>
<div className="md:hidden grid place-content-center">
<button onClick={() => setDisplayHamburgerMenu(!displayHamburgerMenu)} className="p-2">
<svg
className={`${displayHamburgerMenu ? "flex" : "hidden"} size-5`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 320 512"
>
<path
d="M182.6 137.4c-12.5-12.5-32.8-12.5-45.3 0l-128 128c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8l256 0c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-128-128z"
></path>
</svg>
<svg
className={`${displayHamburgerMenu ? "hidden" : "flex"} size-5`}
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<path
fill="currentColor"
d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"
></path>
</svg>
</button>
</div>
<HamburgerMenu show={displayHamburgerMenu} />
</div>
</nav>
</>
);
}
interface HamburgerMenuProps {
show: boolean;
};
function HamburgerMenu({ show }: HamburgerMenuProps) {
return (
<div className={`${show ? "flex" : "hidden"} w-full flex-col items-center absolute top-[100%] left-0 py-2 bg-white border-b border-gray-300 shadow-sm shadow-gray-300 z-20`}>
<DropdownLink name="Home" url={ROUTE_CONSTANTS.Home} />
<DropdownLink name="Favorites" url={ROUTE_CONSTANTS.Favorites} />
<DropdownLink name="Create" url={ROUTE_CONSTANTS.Create} />
<DropdownLink name="Profile" url={ROUTE_CONSTANTS.Profile} />
<DropdownLink name="Shopping List" url={ROUTE_CONSTANTS.ShoppingList} />
</div>
);
}
interface DropdownLinkProps {
name: string;
url: string;
}
function DropdownLink({ name, url }: DropdownLinkProps) {
return (
<a className="py-2" href={url}>
{name}
</a>
);
};
interface NavigationLinkProps {
name: string;
url: string;
current: boolean;
}
function NavigationLink({ name, url, current }: NavigationLinkProps) {
return (
<a href={url} className={`${current ? "border-blue-500" : "hover:border-blue-400 border-white"} duration-150 text-gray-700 border-b-2 px-1 cursor-pointer`}>
{name}
</a>
);
}
interface IconNavigationLinkProps {
icon: React.ReactElement;
url: string;
}
function IconNavigationLink({ icon, url }: IconNavigationLinkProps) {
return (
<a href={url} className="px-1 cursor-pointer">
{icon}
</a>
);
}

View File

@ -0,0 +1,12 @@
interface SpinnerProps {
content: string;
}
export default function Spinner({ content }: SpinnerProps) {
return (
<>
<div className="w-8 h-8 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin" />
<h2 className="text-xl text-gray-700"> {content}</h2>
</>
);
}

View File

@ -0,0 +1,19 @@
interface DropdownButtonProps {
content: string;
name: string;
value: string;
selected: boolean;
changeHandler: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function DropdownButton({ content, name, value, selected, changeHandler }: DropdownButtonProps) {
return (
<label className="inline-block cursor-pointer select-none">
<input onChange={changeHandler} type="checkbox" name={name} value={value} className="sr-only peer" checked={selected} />
<span className="peer-checked:bg-blue-600 peer-checked:text-white peer-checked:border-blue-600 px-2 py-1 border border-gray-300 rounded-lg">
{content}
</span>
</label>
);
}

View File

@ -0,0 +1,78 @@
import { use, useEffect, useState } from "react";
import { AuthContext } from "../../context/AuthContext";
import { useNavigate } from "react-router-dom";
import { EngagementFavoriteRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
interface FavoriteButtonProps {
favorite: boolean | undefined;
id: number | undefined;
}
export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
// CONTEXT
const { isLoggedIn } = use(AuthContext);
const navigate = useNavigate();
const [_favorite, setFavorite] = useState<boolean>();
const clickHandler = async () => {
// This button cannot be used if not logged in
if (!isLoggedIn) {
await navigate("/v2/web/login");
return;
}
if (!id) return;
// Toggle button first, to feel fast
setFavorite(!_favorite);
const result = await EngagementFavoriteRecipe(id);
if (isApiError(result)) {
console.error(result.message);
}
}
useEffect(() => {
if (favorite)
setFavorite(favorite);
}, [favorite]);
return _favorite ? (
<button
className="flex items-center justify-center gap-x-1 rounded-lg border border-blue-300 bg-blue-50 text-gray-800 px-6 py-3 flex-grow hover:bg-blue-100 hover:border-blue-500 duration-300 cursor-pointer"
onClick={() => void clickHandler()}
>
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 9.1371C2 14 6.01943 16.5914 8.96173 18.9109C10 19.7294 11 20.5 12 20.5C13 20.5 14 19.7294 15.0383 18.9109C17.9806 16.5914 22 14 22 9.1371C22 4.27416 16.4998 0.825464 12 5.50063C7.50016 0.825464 2 4.27416 2 9.1371Z"
fill="currentColor"
></path>
</svg>
Unfavorite
</button>
) : (
<button
className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
onClick={() => void clickHandler()}
>
<svg className="h-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 6.00019C10.2006 3.90317 7.19377 3.2551 4.93923 5.17534C2.68468 7.09558 2.36727 10.3061 4.13778 12.5772C5.60984 14.4654 10.0648 18.4479 11.5249 19.7369C11.6882 19.8811 11.7699 19.9532 11.8652 19.9815C11.9483 20.0062 12.0393 20.0062 12.1225 19.9815C12.2178 19.9532 12.2994 19.8811 12.4628 19.7369C13.9229 18.4479 18.3778 14.4654 19.8499 12.5772C21.6204 10.3061 21.3417 7.07538 19.0484 5.17534C16.7551 3.2753 13.7994 3.90317 12 6.00019Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
Favorite
</button>
);
}

View File

@ -0,0 +1,36 @@
interface FilterButtonProps {
click: () => void;
}
export default function FilterButton({ click }: FilterButtonProps) {
return (
<button
type="button"
onClick={click}
className="text-gray-400 border border-gray-300 rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<svg className="h-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 11.1707L6 4C6 3.44771 5.55228 3 5 3C4.44771 3 4 3.44771 4 4L4 11.1707C2.83481 11.5825 2 12.6938 2 14C2 15.3062 2.83481 16.4175 4 16.8293L4 20C4 20.5523 4.44772 21 5 21C5.55228 21 6 20.5523 6 20L6 16.8293C7.16519 16.4175 8 15.3062 8 14C8 12.6938 7.16519 11.5825 6 11.1707ZM5 13C4.44772 13 4 13.4477 4 14C4 14.5523 4.44772 15 5 15C5.55228 15 6 14.5523 6 14C6 13.4477 5.55228 13 5 13Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M19 21C18.4477 21 18 20.5523 18 20L18 18C18 17.9435 18.0047 17.8881 18.0137 17.8341C16.8414 17.4262 16 16.3113 16 15C16 13.6887 16.8414 12.5738 18.0137 12.1659C18.0047 12.1119 18 12.0565 18 12L18 4C18 3.44771 18.4477 3 19 3C19.5523 3 20 3.44771 20 4L20 12C20 12.0565 19.9953 12.1119 19.9863 12.1659C21.1586 12.5738 22 13.6887 22 15C22 16.3113 21.1586 17.4262 19.9863 17.8341C19.9953 17.8881 20 17.9435 20 18V20C20 20.5523 19.5523 21 19 21ZM18 15C18 14.4477 18.4477 14 19 14C19.5523 14 20 14.4477 20 15C20 15.5523 19.5523 16 19 16C18.4477 16 18 15.5523 18 15Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9 9C9 7.69378 9.83481 6.58254 11 6.17071V4C11 3.44772 11.4477 3 12 3C12.5523 3 13 3.44772 13 4V6.17071C14.1652 6.58254 15 7.69378 15 9C15 10.3113 14.1586 11.4262 12.9863 11.8341C12.9953 11.8881 13 11.9435 13 12L13 20C13 20.5523 12.5523 21 12 21C11.4477 21 11 20.5523 11 20L11 12C11 11.9435 11.0047 11.8881 11.0137 11.8341C9.84135 11.4262 9 10.3113 9 9ZM11 9C11 8.44772 11.4477 8 12 8C12.5523 8 13 8.44772 13 9C13 9.55229 12.5523 10 12 10C11.4477 10 11 9.55229 11 9Z"
fill="currentColor"
/>
</svg>
</button>
);
}

View File

@ -0,0 +1,10 @@
export default function LikeButton() {
return (
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 9.1371C2 14 6.01943 16.5914 8.96173 18.9109C10 19.7294 11 20.5 12 20.5C13 20.5 14 19.7294 15.0383 18.9109C17.9806 16.5914 22 14 22 9.1371C22 4.27416 16.4998 0.825464 12 5.50063C7.50016 0.825464 2 4.27416 2 9.1371Z"
fill="currentColor"
></path>
</svg>
);
}

View File

@ -0,0 +1,61 @@
import { use, useState } from "react";
import { AuthContext } from "../../context/AuthContext";
import { useNavigate } from "react-router-dom";
import { EngagementMakeRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
interface MadeButtonProps {
id: number;
}
export default function MadeButton({ id }: MadeButtonProps) {
// CONTEXT
const { isLoggedIn } = use(AuthContext);
const navigate = useNavigate();
const [clicked, setClicked] = useState<boolean>(false);
const clickHandler = async () => {
// This button cannot be used if not logged in
if (!isLoggedIn) {
await navigate("/v2/web/login");
return;
}
if (!id || clicked) return;
// Toggle button first, to feel fast
setClicked(true);
const result = await EngagementMakeRecipe(id);
if (isApiError(result)) {
console.error(result.message);
}
}
return (
<button
className={`flex items-center justify-center gap-x-1 rounded-lg border ${clicked ? "border-blue-500 text-blue-500" : "border-gray-300 text-gray-800"} px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer`}
onClick={() => void clickHandler()}
>
<svg
className="h-6"
fill="currentColor"
viewBox="0 -3.84 122.88 122.88"
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
xmlSpace="preserve"
>
<g>
<path
d="M29.03,100.46l20.79-25.21l9.51,12.13L41,110.69C33.98,119.61,20.99,110.21,29.03,100.46L29.03,100.46z M53.31,43.05 c1.98-6.46,1.07-11.98-6.37-20.18L28.76,1c-2.58-3.03-8.66,1.42-6.12,5.09L37.18,24c2.75,3.34-2.36,7.76-5.2,4.32L16.94,9.8 c-2.8-3.21-8.59,1.03-5.66,4.7c4.24,5.1,10.8,13.43,15.04,18.53c2.94,2.99-1.53,7.42-4.43,3.69L6.96,18.32 c-2.19-2.38-5.77-0.9-6.72,1.88c-1.02,2.97,1.49,5.14,3.2,7.34L20.1,49.06c5.17,5.99,10.95,9.54,17.67,7.53 c1.03-0.31,2.29-0.94,3.64-1.77l44.76,57.78c2.41,3.11,7.06,3.44,10.08,0.93l0.69-0.57c3.4-2.83,3.95-8,1.04-11.34L50.58,47.16 C51.96,45.62,52.97,44.16,53.31,43.05L53.31,43.05z M65.98,55.65l7.37-8.94C63.87,23.21,99-8.11,116.03,6.29 C136.72,23.8,105.97,66,84.36,55.57l-8.73,11.09L65.98,55.65L65.98,55.65z"
></path>
</g>
</svg>
Made This!
</button>
);
}

View File

@ -0,0 +1,56 @@
import { useState } from "react";
import { EngagementShareRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
interface ShareButtonProps {
id: number;
}
// TODO: Abstract this somehow, this needs to be loaded from the env
const DOMAIN = "http://localhost:5173/";
export default function ShareButton({ id }: ShareButtonProps) {
const [clicked, setClicked] = useState<boolean>(false);
const clickHandler = async () => {
if (clicked) return;
// Copy first, so it feels fast
await navigator.clipboard.writeText(`${DOMAIN}/v2/web/recipe/${id}`)
const result = await EngagementShareRecipe(id);
if (isApiError(result)) {
console.error(result.message);
}
setClicked(true);
};
return clicked ? (
<button className="flex items-center justify-center gap-x-1 rounded-lg border border-green-500 text-green-500 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300">
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.803 5.33333C13.803 3.49238 15.3022 2 17.1515 2C19.0008 2 20.5 3.49238 20.5 5.33333C20.5 7.17428 19.0008 8.66667 17.1515 8.66667C16.2177 8.66667 15.3738 8.28596 14.7671 7.67347L10.1317 10.8295C10.1745 11.0425 10.197 11.2625 10.197 11.4872C10.197 11.9322 10.109 12.3576 9.94959 12.7464L15.0323 16.0858C15.6092 15.6161 16.3473 15.3333 17.1515 15.3333C19.0008 15.3333 20.5 16.8257 20.5 18.6667C20.5 20.5076 19.0008 22 17.1515 22C15.3022 22 13.803 20.5076 13.803 18.6667C13.803 18.1845 13.9062 17.7255 14.0917 17.3111L9.05007 13.9987C8.46196 14.5098 7.6916 14.8205 6.84848 14.8205C4.99917 14.8205 3.5 13.3281 3.5 11.4872C3.5 9.64623 4.99917 8.15385 6.84848 8.15385C7.9119 8.15385 8.85853 8.64725 9.47145 9.41518L13.9639 6.35642C13.8594 6.03359 13.803 5.6896 13.803 5.33333Z"
fill="currentColor" />
</svg>
Link Copied!
</button>
) : (
<button
className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
onClick={() => void clickHandler()}
>
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.803 5.33333C13.803 3.49238 15.3022 2 17.1515 2C19.0008 2 20.5 3.49238 20.5 5.33333C20.5 7.17428 19.0008 8.66667 17.1515 8.66667C16.2177 8.66667 15.3738 8.28596 14.7671 7.67347L10.1317 10.8295C10.1745 11.0425 10.197 11.2625 10.197 11.4872C10.197 11.9322 10.109 12.3576 9.94959 12.7464L15.0323 16.0858C15.6092 15.6161 16.3473 15.3333 17.1515 15.3333C19.0008 15.3333 20.5 16.8257 20.5 18.6667C20.5 20.5076 19.0008 22 17.1515 22C15.3022 22 13.803 20.5076 13.803 18.6667C13.803 18.1845 13.9062 17.7255 14.0917 17.3111L9.05007 13.9987C8.46196 14.5098 7.6916 14.8205 6.84848 14.8205C4.99917 14.8205 3.5 13.3281 3.5 11.4872C3.5 9.64623 4.99917 8.15385 6.84848 8.15385C7.9119 8.15385 8.85853 8.64725 9.47145 9.41518L13.9639 6.35642C13.8594 6.03359 13.803 5.6896 13.803 5.33333Z"
fill="currentColor" />
</svg>
Share
</button>
);
}

View File

@ -0,0 +1,17 @@
interface ContentCardSmallProps {
content: string;
target: string;
};
export default function ContentCardSmall({ content, target }: ContentCardSmallProps) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border-gray-300 border shadow-md p-4 flex-shrink-0">
<div className="mt-8 w-52 md:w-48 text-center">
<a className="underline" href={target}>
<p className="text-sm">{content}</p>
</a>
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
import type { Recipe } from "../../types/recipe";
import RecipePlaceholder from "../../assets/images/recipe_placeholder.png"
import LikeButton from "../buttons/LikeButton";
import { useNavigate } from "react-router-dom";
import { EngagementViewRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
interface RecipeCardLargeProps {
recipe: Recipe | null;
}
export default function RecipeCardLarge({ recipe }: RecipeCardLargeProps) {
const navigate = useNavigate();
// HANDLERS
const makeButtonHandler = async () => {
if (!recipe) return;
// Navigate first, so it feels faster
await navigate(`/v2/web/recipe/${recipe.Id}`);
const result = await EngagementViewRecipe(recipe.Id);
if (isApiError(result)) {
console.error(result.message);
}
}
if (recipe == null) {
return <h2 className="text-2xl md:text-3xl text-gray-400">Coming soon!</h2>
}
return (
<div className="flex flex-col items-center justify-between rounded-lg border-gray-300 border shadow-md p-4 flex-shrink-0">
<img className="size-80 rounded-sm border border-gray-200 shadow-sm shadow-gray-100" src={RecipePlaceholder} />
<div className="w-full mt-8">
<h2 className="font-semibold overflow-hidden whitespace-nowrap text-ellipsis">
{recipe.Title}
</h2>
<p className="text-xs overflow-hidden whitespace-nowrap text-ellipsis">
Serves {recipe.Serves}
</p>
<p className="text-sm text-wrap w-80">
{recipe.Description}
</p>
<div className="flex items-end justify-between">
<p className="text-xs mt-4 bg-gray-200 rounded-lg w-fit px-2 py-1">
{recipe.Category} - {recipe.Duration.Total} mins
</p>
{recipe.Favorite && <LikeButton />}
</div>
<button
onClick={() => void makeButtonHandler()}
className="w-full rounded-lg py-2 bg-gradient-to-r from-blue-400 to-blue-600 text-white mt-2 hover:ring-blue-700 hover:shadow shadow-blue-300 duration-200 cursor-pointer"
>
Make Now!
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
import type { Recipe } from "../../types/recipe";
import RecipePlaceholder from "../../assets/images/recipe_placeholder.png"
import LikeButton from "../buttons/LikeButton";
import { EngagementViewRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
import { useNavigate } from "react-router-dom";
interface RecipeCardSmallProps {
recipe: Recipe;
}
export default function RecipeCardSmall({ recipe }: RecipeCardSmallProps) {
const navigate = useNavigate();
// HANDLERS
const makeButtonHandler = async () => {
// Navigate first, so it feels faster
await navigate(`/v2/web/recipe/${recipe.Id}`);
const result = await EngagementViewRecipe(recipe.Id);
if (isApiError(result)) {
console.error(result.message);
}
}
return (
<div className="flex flex-col items-center justify-between rounded-lg border-gray-300 border shadow-md p-4 flex-shrink-0">
<img className="size-52 md:size-48 rounded-sm border border-gray-200 shadow-sm shadow-gray-100" src={RecipePlaceholder} />
<div className="w-52 md:w-48 mt-8">
<h2 className="font-semibold overflow-hidden whitespace-nowrap text-ellipsis">
{recipe.Title}
</h2>
<p className="text-xs overflow-hidden whitespace-nowrap text-ellipsis">
Serves {recipe.Serves}
</p>
<div className="flex items-end justify-between">
<p className="text-xs mt-4 bg-gray-200 rounded-lg w-fit px-2 py-1">
{recipe.Category} - {recipe.Duration.Total} mins
</p>
{recipe.Favorite && <LikeButton />}
</div>
<button
onClick={() => void makeButtonHandler()}
className="w-full rounded-lg py-2 bg-gradient-to-r from-blue-400 to-blue-600 text-white mt-2 hover:ring-blue-700 hover:shadow shadow-blue-300 duration-200 cursor-pointer"
>
Make Now!
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,58 @@
import type { Recipe } from "../../types/recipe"
import ServingSizeIcon from "../icons/ServingSizeIcon"
import StarIcon from "../icons/StarIcon"
import TimeIcon from "../icons/TimeIcon"
function displayDifficulty(diff: number): string {
switch (diff) {
case 1:
return "Beginner"
case 2:
return "Easy"
case 3:
return "Intermediate"
case 4:
return "Challenging"
case 5:
return "Extreme"
default:
return ""
}
}
interface RecipeMetaDataProps {
recipe: Recipe | null;
}
export default function RecipeMetaData({ recipe }: RecipeMetaDataProps) {
return (
<div
className="border border-blue-300 bg-blue-50 text-gray-700 mx-4 md:mx-8 rounded-lg flex flex-col
md:flex-row justify-center items-center py-8"
>
<div className="flex flex-col items-center justify-center text-sm my-4 md:my-0 mx-4 w-full md:w-1/4">
<TimeIcon />
<p>Prep: {recipe?.Duration.Prep ?? 0} min</p>
<p>Cook: {recipe?.Duration.Cook ?? 0} min</p>
</div>
<div
className="flex flex-col items-center justify-center text-sm my-4 md:my-0 mx-4 border-y md:border-y-0 md:border-x border-blue-300 py-8 w-9/10 md:w-fit md:py-0 px-8"
>
<div className="flex gap-x-1 my-2">
{Array.from({ length: recipe?.Difficulty ?? 0 }).map((_, i) => (
<StarIcon key={`${recipe?.Id}-filled-${i}`} size={6} filled={true} />
))}
{Array.from({ length: 5 - (recipe?.Difficulty ?? 0) }).map((_, i) => (
<StarIcon key={`${recipe?.Id}-unfilled-${i}`} size={6} filled={false} />
))}
</div>
<p>{displayDifficulty(recipe?.Difficulty ?? 0)}</p>
</div>
<div className="flex flex-col items-center justify-center text-sm my-4 md:my-0 mx-4 w-1/4">
<ServingSizeIcon />
<p>Serves {recipe?.Serves ?? 0}</p>
</div>
</div>
);
}

View File

@ -0,0 +1,94 @@
import { Reorder, useDragControls } from "motion/react";
import { INGREDIENT_UNITS, type RecipeIngredient } from "../../types/recipe";
import DeleteIconSmall from "../icons/DeleteIconSmall";
import DragIconSmall from "../icons/DragIconSmall";
interface IngredientItemProps {
classes: string;
ingredient: RecipeIngredient;
onChange: (id: string, name: "Amount" | "Unit" | "Name", value: string) => void;
removeIngredientHandler: (id: string) => void;
allowDelete: boolean;
valid: boolean;
dirty: boolean;
markDirty: (id: string) => void;
}
export default function IngredientItem({ classes, ingredient, onChange, removeIngredientHandler, allowDelete, valid, dirty, markDirty }: IngredientItemProps) {
const controls = useDragControls();
const changeHandler = (name: "Amount" | "Unit" | "Name", value: string) => {
if (!dirty) markDirty(ingredient.Id);
onChange(ingredient.Id, name, value);
}
return (
<Reorder.Item
key={ingredient.Id}
value={ingredient}
dragListener={false}
dragControls={controls}
className="select-none p-2 flex gap-2 flex-col"
>
<div className="flex gap-2">
<div className="flex-col md:flex-row flex-grow flex gap-2 flex-wrap">
<div className="flex gap-2">
<input
type="number"
step="0.25"
min="0"
placeholder="amount"
required
value={ingredient.Amount}
onChange={(e) => changeHandler("Amount", e.target.value)}
className={`w-1/2 md:w-28 ${classes} ${dirty && ingredient.Amount <= 0 ? "border-red-500" : ""}`}
/>
<select
onChange={(e) => changeHandler("Unit", e.target.value)}
className={`w-1/2 md:w-fit ${classes} ${dirty && ingredient.Unit === "" ? "border-red-500" : ""}`}
>
{INGREDIENT_UNITS.map(unit => (
<option key={unit} value={unit}>{unit ? unit : "Select"}</option>
))}
</select>
</div>
<input
type="text"
placeholder="Ingredient name"
required
value={ingredient.Name}
onChange={(e) => changeHandler("Name", e.target.value)}
className={`flex-grow ${classes} ${dirty && ingredient.Name.trim() === "" ? "border-red-500" : ""}`}
/>
</div>
<div className="flex-col md:flex-row flex gap-x-2 items-center justify-evenly md:justify-center">
<button
tabIndex={-1}
disabled={!allowDelete}
onClick={() => removeIngredientHandler(ingredient.Id)}
className="p-1 md:p-2 md:pr-0 cursor-pointer text-gray-500 hover:text-red-500 disabled:text-gray-200 disabled:cursor-not-allowed duration-300"
>
<DeleteIconSmall />
</button>
<div
tabIndex={-1}
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
}}
className="p-1 md:p-0 cursor-pointer touch-none"
>
<DragIconSmall />
</div>
</div>
</div>
{(dirty && !valid) && (
<p className="text-sm text-red-500"> Please fill out all fields. </p>
)}
</Reorder.Item>
);
}

View File

@ -0,0 +1,47 @@
import { Reorder } from "motion/react";
import type { RecipeIngredient, RecipeIngredientSection } from "../../types/recipe";
import IngredientItem from "./IngredientItem";
import type { RecipeValidationEntry } from "../../pages/Create";
interface IngredientListProps {
classes: string;
section: RecipeIngredientSection;
ingredients: RecipeIngredient[];
setSectionIngredients: (sectionId: string, ingredients: RecipeIngredient[]) => void;
ingredientChangeHandler: (id: string, name: "Amount" | "Unit" | "Name", value: string) => void;
removeIngredientHandler: (id: string) => void;
validList: RecipeValidationEntry[];
dirtyList: Record<string, boolean>;
markDirty: (id: string) => void;
}
export default function IngredientList({ classes, section, ingredients, setSectionIngredients, ingredientChangeHandler, removeIngredientHandler, validList, dirtyList, markDirty }: IngredientListProps) {
const sectionIngredients = ingredients.filter(x => x.SectionId === section.Id);
const reorderHandler = (ingredients: RecipeIngredient[]) => {
setSectionIngredients(section.Id, ingredients);
}
return (
<Reorder.Group
axis="y"
values={ingredients}
onReorder={reorderHandler}
className="flex flex-col"
>
{sectionIngredients.map(ingredient =>
<IngredientItem
key={ingredient.Id}
classes={classes}
ingredient={ingredient}
onChange={ingredientChangeHandler}
removeIngredientHandler={removeIngredientHandler}
allowDelete={sectionIngredients.length > 1}
valid={validList.find(x => x.id === ingredient.Id)?.valid ?? true}
dirty={dirtyList[ingredient.Id] ?? false}
markDirty={markDirty}
/>
)}
</Reorder.Group>
);
}

View File

@ -0,0 +1,59 @@
import { Reorder, useDragControls } from "motion/react";
import type { RecipeIngredientSection } from "../../types/recipe";
import DeleteIconSmall from "../icons/DeleteIconSmall";
import DragIconSmall from "../icons/DragIconSmall";
import type { ReactNode } from "react";
interface IngredientSectionProps {
section: RecipeIngredientSection;
onChange: (id: string, name: string) => void;
removeIngredientSectionHandler: (id: string) => void;
allowDelete: boolean;
children?: ReactNode;
};
export default function IngredientSection({ section, onChange, removeIngredientSectionHandler, allowDelete, children }: IngredientSectionProps) {
const controls = useDragControls();
return (
<Reorder.Item
key={section.Id}
value={section}
dragListener={false}
dragControls={controls}
className="select-none"
>
<div className="w-full bg-gray-100 p-3 flex items-center my-2">
<p className="text-xs md:text-sm font-semibold">Group:</p>
<input
type="text"
value={section.Name}
onChange={(e) => onChange(section.Id, e.target.value)}
placeholder="Group label"
className="mx-2 px-2 py-1 border border-gray-300 flex-grow rounded-sm min-w-1"
/>
<div className="flex gap-x-2 items-center">
<button
disabled={!allowDelete}
className="p-2 pr-0 cursor-pointer text-gray-500 hover:text-red-500 disabled:text-gray-200 disabled:cursor-not-allowed duration-300"
onClick={() => removeIngredientSectionHandler(section.Id)}
>
<DeleteIconSmall />
</button>
<div
className="p-0 cursor-pointer touch-none"
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
}}
>
<DragIconSmall />
</div>
</div>
</div>
{children}
</Reorder.Item>
);
}

View File

@ -0,0 +1,76 @@
import { type ChangeEvent } from "react";
import type { RecipeInstruction } from "../../types/recipe";
import { Reorder, useDragControls } from "motion/react";
import DragIconSmall from "../icons/DragIconSmall";
import DeleteIconSmall from "../icons/DeleteIconSmall";
interface InstructionElementProps {
instruction: RecipeInstruction;
index: number;
allowDelete: boolean;
onChange: (id: string, value: string) => void;
onDelete: (id: string) => void;
valid: boolean;
dirty: boolean;
markDirty: (id: string) => void;
}
export default function InstructionElement({ instruction, index, allowDelete, onChange, onDelete, valid, dirty, markDirty }: InstructionElementProps) {
const controls = useDragControls();
const changeHandler = (e: ChangeEvent<HTMLTextAreaElement>) => {
if (!dirty) markDirty(instruction.Id)
onChange(instruction.Id, e.target.value);
}
return (
<Reorder.Item
value={instruction}
dragListener={false}
dragControls={controls}
className="flex items-center"
>
<div className="flex flex-grow items-center select-none">
<h2 className="text-lg md:text-xl mr-4 text-gray-500">{index + 1}.</h2>
<div className="flex flex-col flex-grow">
<textarea
className={`flex-grow border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all min-h-40 md:min-h-26 shadow-sm ${!valid && dirty ? "border-red-500" : ""}`}
name="instructions"
value={instruction.Content}
onChange={changeHandler}
rows={3}
required
minLength={1}
placeholder="Describe this step..."
/>
{(!valid && dirty) && (
<p className="text-xs text-red-500 my-1">
Please enter an instruction (blank entries are not allowed).
</p>
)}
</div>
</div>
<div className="flex flex-col items-center">
<div
className="p-2 pr-0 cursor-grab touch-none"
onPointerDown={e => {
e.preventDefault();
controls.start(e);
}}
>
<DragIconSmall />
</div>
<button
tabIndex={-1}
disabled={!allowDelete}
onClick={() => onDelete(instruction.Id)}
className="p-2 pr-0 cursor-pointer text-gray-500 hover:text-red-500 disabled:text-gray-200 disabled:cursor-not-allowed duration-300"
>
<DeleteIconSmall />
</button>
</div>
</Reorder.Item>
);
}

View File

@ -0,0 +1,53 @@
import { Reorder } from "motion/react";
import InstructionElement from "./InstructionElement";
import type { Dispatch, SetStateAction } from "react";
import type { RecipeInstruction } from "../../types/recipe";
import type { RecipeValidationEntry } from "../../pages/Create";
interface InstructionListProps {
instructions: RecipeInstruction[];
setInstructions: Dispatch<SetStateAction<RecipeInstruction[]>>;
validList: RecipeValidationEntry[];
dirtyList: Record<string, boolean>;
markDirty: (id: string) => void;
}
export default function InstructionList({ instructions, setInstructions, validList, dirtyList, markDirty }: InstructionListProps) {
const handleChange = (id: string, value: string) => {
setInstructions(prev =>
prev.map(instr =>
instr.Id === id ? { ...instr, Content: value } : instr
)
);
};
const handleDelete = (id: string) => {
setInstructions(prev =>
prev.filter(instr => instr.Id !== id)
);
}
return (
<Reorder.Group
axis="y"
values={instructions}
onReorder={setInstructions}
className="flex flex-col gap-2 my-2"
>
{instructions.map((instruction, i) => (
<InstructionElement
key={instruction.Id}
index={i}
instruction={instruction}
allowDelete={instructions.length > 1}
onChange={handleChange}
onDelete={handleDelete}
valid={validList.find(x => x.id === instruction.Id)?.valid ?? true}
dirty={dirtyList[instruction.Id] ?? false}
markDirty={markDirty}
/>
))}
</Reorder.Group>
);
}

View File

@ -0,0 +1,44 @@
import type { CreateRecipeFormEntries } from "../../pages/Create";
interface ValidationErrorListProps {
validation: CreateRecipeFormEntries;
}
const MESSAGES: Record<keyof CreateRecipeFormEntries, string> = {
title: "Invalid title provided.",
description: "Invalid description provided.",
prepTime: "Invalid preparation time provided.",
cookTime: "Invalid cook time provided.",
servingSize: "Invalid serving size provided.",
category: "Invalid category selected.",
difficulty: "Invalid difficulty selected.",
ingredients: "Invalid ingredients provided.",
instructions: "Invalid instructions provided.",
}
export default function ValidationErrorList({ validation }: ValidationErrorListProps) {
return (
<div className="my-2">
{Object.entries(validation)
.filter(([, isValid]) => !isValid)
.map(([name]) => {
const key = name as keyof CreateRecipeFormEntries;
return (
<p key={name} className="text-sm text-red-500">
{MESSAGES[key]}
</p>
);
})}
{validation.ingredients.filter(x => !x.valid).length > 0 && (
<p className="text-sm text-red-500">
{MESSAGES.ingredients}
</p>
)}
{validation.instructions.filter(x => !x.valid).length > 0 && (
<p className="text-sm text-red-500">
{MESSAGES.instructions}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,7 @@
export default function DeleteIconSmall() {
return (
<svg className="size-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.0004 9.5L17.0004 14.5M17.0004 9.5L12.0004 14.5M4.50823 13.9546L7.43966 17.7546C7.79218 18.2115 7.96843 18.44 8.18975 18.6047C8.38579 18.7505 8.6069 18.8592 8.84212 18.9253C9.10766 19 9.39623 19 9.97336 19H17.8004C18.9205 19 19.4806 19 19.9084 18.782C20.2847 18.5903 20.5907 18.2843 20.7824 17.908C21.0004 17.4802 21.0004 16.9201 21.0004 15.8V8.2C21.0004 7.0799 21.0004 6.51984 20.7824 6.09202C20.5907 5.71569 20.2847 5.40973 19.9084 5.21799C19.4806 5 18.9205 5 17.8004 5H9.97336C9.39623 5 9.10766 5 8.84212 5.07467C8.6069 5.14081 8.38579 5.2495 8.18975 5.39534C7.96843 5.55998 7.79218 5.78846 7.43966 6.24543L4.50823 10.0454C3.96863 10.7449 3.69883 11.0947 3.59505 11.4804C3.50347 11.8207 3.50347 12.1793 3.59505 12.5196C3.69883 12.9053 3.96863 13.2551 4.50823 13.9546Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

View File

@ -0,0 +1,7 @@
export default function DragIconSmall() {
return (
<svg className="text-gray-500 size-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6H20M4 12H20M4 18H20" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

View File

@ -0,0 +1,28 @@
export default function ServingSizeIcon() {
return (
<svg
className="h-8 text-blue-600"
fill="currentColor"
version="1.1"
id="Icons"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox="0 0 32 32"
xmlSpace="preserve"
>
<g>
<circle cx="12" cy="16" r="5"></circle>
<path
d="M12,6C6.5,6,2,10.5,2,16s4.5,10,10,10s10-4.5,10-10S17.5,6,12,6z M12,23c-3.9,0-7-3.1-7-7s3.1-7,7-7s7,3.1,7,7
S15.9,23,12,23z"
></path>
<path
d="M30,10.5V5c0-0.6-0.4-1-1-1s-1,0.4-1,1v5.5c0,0.2,0,0.4,0,0.5h-1V5c0-0.6-0.4-1-1-1s-1,0.4-1,1v6h-1c0-0.2,0-0.4,0-0.5V5
c0-0.6-0.4-1-1-1s-1,0.4-1,1v5.5c0,1.9,0.5,3.4,1.4,4.3c0.7,0.8,1,1.8,0.9,2.7l-1,7.3c-0.1,0.8,0.1,1.6,0.6,2.2S25.2,28,26,28
s1.5-0.3,2.1-0.9s0.8-1.4,0.6-2.2l-1-7.3c-0.1-1,0.2-2,0.9-2.8C29.5,13.8,30,12.3,30,10.5z"
></path>
</g>
</svg>
);
}

View File

@ -0,0 +1,16 @@
export default function ServingSizeIconSmall() {
return <>
<svg className="h-5 text-blue-600" fill="currentColor" version="1.1" id="Icons" xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32" xmlSpace="preserve">
<g>
<circle cx="12" cy="16" r="5"></circle>
<path d="M12,6C6.5,6,2,10.5,2,16s4.5,10,10,10s10-4.5,10-10S17.5,6,12,6z M12,23c-3.9,0-7-3.1-7-7s3.1-7,7-7s7,3.1,7,7
S15.9,23,12,23z"></path>
<path d="M30,10.5V5c0-0.6-0.4-1-1-1s-1,0.4-1,1v5.5c0,0.2,0,0.4,0,0.5h-1V5c0-0.6-0.4-1-1-1s-1,0.4-1,1v6h-1c0-0.2,0-0.4,0-0.5V5
c0-0.6-0.4-1-1-1s-1,0.4-1,1v5.5c0,1.9,0.5,3.4,1.4,4.3c0.7,0.8,1,1.8,0.9,2.7l-1,7.3c-0.1,0.8,0.1,1.6,0.6,2.2S25.2,28,26,28
s1.5-0.3,2.1-0.9s0.8-1.4,0.6-2.2l-1-7.3c-0.1-1,0.2-2,0.9-2.8C29.5,13.8,30,12.3,30,10.5z"></path>
</g>
</svg>
</>
}

View File

@ -0,0 +1,19 @@
interface ShoppingListIconProps {
current: boolean;
};
export default function ShoppingListIcon({ current }: ShoppingListIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 576 512"
className={`${current ? "text-blue-500" : "text-gray-700 hover:text-blue-400"} duration-150 h-4`}
>
<path
fill="currentColor"
d="M0 24C0 10.7 10.7 0 24 0L69.5 0c22 0 41.5 12.8 50.6 32l411 0c26.3 0 45.5 25 38.6 50.4l-41 152.3c-8.5 31.4-37 53.3-69.5 53.3l-288.5 0 5.4 28.5c2.2 11.3 12.1 19.5 23.6 19.5L488 336c13.3 0 24 10.7 24 24s-10.7 24-24 24l-288.3 0c-34.6 0-64.3-24.6-70.7-58.5L77.4 54.5c-.7-3.8-4-6.5-7.9-6.5L24 48C10.7 48 0 37.3 0 24zM128 464a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zm336-48a48 48 0 1 1 0 96 48 48 0 1 1 0-96z"
/>
</svg>
);
}

View File

@ -0,0 +1,25 @@
interface StarIconProps {
filled: boolean;
size: number;
};
export default function StarIcon({ filled, size = 6 }: StarIconProps) {
return <>
{filled ? (
<svg className={`h-${size} text-blue-600`} fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M23.632 9.201a.628.628 0 0 1-.22.678l-5.726 4.96 1.727 7.394a.606.606 0 0 1-.935.676l-6.503-3.953-6.503 3.953a.713.713 0 0 1-.374.112.57.57 0 0 1-.34-.109.629.629 0 0 1-.222-.679l1.729-7.393L.539 9.879A.607.607 0 0 1 .897 8.78l7.536-.635 2.965-7.083a.62.62 0 0 1 1.155.001l2.965 7.082 7.536.635a.63.63 0 0 1 .578.42z"
></path>
<path fill="none" d="M0 0h24v24H0z"></path>
</svg>
) : (
<svg className={`h-${size} text-gray-500`} fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M23.054 8.781l-7.536-.635-2.965-7.082a.619.619 0 0 0-1.155 0L8.433 8.145.896 8.78a.607.607 0 0 0-.357 1.1l5.726 4.96-1.729 7.395a.63.63 0 0 0 .223.679.573.573 0 0 0 .339.108.717.717 0 0 0 .374-.111l6.503-3.954 6.503 3.953a.606.606 0 0 0 .935-.677l-1.727-7.392 5.725-4.96a.607.607 0 0 0-.357-1.099zm-6.48 5.698l1.662 7.113-6.261-3.806-6.262 3.807 1.663-7.114-5.513-4.776 7.257-.611 2.855-6.817 2.855 6.817 7.257.611z"
></path>
<path fill="none" d="M0 0h24v24H0z"></path>
</svg>
)}
</>
}

View File

@ -0,0 +1,25 @@
interface StarIconSmallProps {
filled: boolean;
};
export default function StarIconSmall({ filled }: StarIconSmallProps) {
return <>
{filled ? (
<svg className="h-4 text-blue-600" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M23.632 9.201a.628.628 0 0 1-.22.678l-5.726 4.96 1.727 7.394a.606.606 0 0 1-.935.676l-6.503-3.953-6.503 3.953a.713.713 0 0 1-.374.112.57.57 0 0 1-.34-.109.629.629 0 0 1-.222-.679l1.729-7.393L.539 9.879A.607.607 0 0 1 .897 8.78l7.536-.635 2.965-7.083a.62.62 0 0 1 1.155.001l2.965 7.082 7.536.635a.63.63 0 0 1 .578.42z">
</path>
<path fill="none" d="M0 0h24v24H0z"></path>
</svg>
) : (
<svg className="h-4 text-gray-500" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M23.054 8.781l-7.536-.635-2.965-7.082a.619.619 0 0 0-1.155 0L8.433 8.145.896 8.78a.607.607 0 0 0-.357 1.1l5.726 4.96-1.729 7.395a.63.63 0 0 0 .223.679.573.573 0 0 0 .339.108.717.717 0 0 0 .374-.111l6.503-3.954 6.503 3.953a.606.606 0 0 0 .935-.677l-1.727-7.392 5.725-4.96a.607.607 0 0 0-.357-1.099zm-6.48 5.698l1.662 7.113-6.261-3.806-6.262 3.807 1.663-7.114-5.513-4.776 7.257-.611 2.855-6.817 2.855 6.817 7.257.611z">
</path>
<path fill="none" d="M0 0h24v24H0z"></path>
</svg>
)}
</>
}

View File

@ -0,0 +1,13 @@
export default function TimeIcon() {
return (
<svg className="h-7 text-blue-600" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 7V12L14.5 13.5M21 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"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
);
}

View File

@ -0,0 +1,9 @@
export default function TimeIconSmall() {
return <>
<svg className="h-5 text-blue-600" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 7V12L14.5 13.5M21 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" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
</>;
}

View File

@ -0,0 +1,59 @@
import { type ChangeEvent, type Dispatch, type SetStateAction } from "react";
import type { CreateRecipeFormDirtyEntries } from "../../pages/Create";
export interface RecipeCreateDropdownOption {
value: string;
name: string;
}
interface RecipeCreateFormDropdownProps {
label: string;
name: string;
desc: string;
required?: boolean;
valid: boolean;
value: string;
setValue: Dispatch<SetStateAction<string>>;
setDirty: Dispatch<SetStateAction<CreateRecipeFormDirtyEntries>>;
options: RecipeCreateDropdownOption[];
error: string;
parentClasses?: string;
classes: string;
};
export default function RecipeCreateDropdownInput({ label, name, desc, required = false, valid, value, setDirty, setValue, options, error, parentClasses = "", classes }: RecipeCreateFormDropdownProps) {
const handleChange = (e: ChangeEvent<HTMLSelectElement>) => {
setDirty(prev => ({ ...prev, [name]: true }));
setValue(e.target.value);
}
return (
<div className={`flex flex-col ${parentClasses}`}>
<label htmlFor={name} className="text-sm">
{label}
{required && <span className="text-red-500">*</span>}
</label>
<p className="text-xs pt-1 pb-2 text-gray-700">
{desc}
</p>
<select
className={`${!valid ? "border-red-500" : ""} ${classes}`}
name={name}
value={value}
onChange={handleChange}
required={required}
>
{options?.map(opt => (
<option key={opt.value} value={opt.value}>{opt.name}</option>
))}
</select>
{!valid && (
<p className="text-xs text-red-500 my-1">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,58 @@
import { type ChangeEvent, type Dispatch, type InputHTMLAttributes, type SetStateAction } from "react";
import type { CreateRecipeFormDirtyEntries } from "../../pages/Create";
interface RecipeCreateFormInputProps
extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange" | "name" | "type" | "placeholder" | "required"
> {
label: string;
name: string; // ENSURE THE NAME MATCHES THE VALUE IN THE ENTRIES TYPE
desc: string;
placeholder: string;
type?: string;
required?: boolean;
valid: boolean;
value: string;
setValue: Dispatch<SetStateAction<string>>;
setDirty: Dispatch<SetStateAction<CreateRecipeFormDirtyEntries>>;
error: string;
parentClasses?: string;
classes: string;
};
export default function RecipeCreateFormInput({ label, name, desc, placeholder, type = "text", required = false, valid, value, setDirty, setValue, error, parentClasses = "", classes, ...inputProps }: RecipeCreateFormInputProps) {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setDirty(prev => ({ ...prev, [name]: true }));
setValue(e.target.value);
}
return (
<div className={`flex flex-col ${parentClasses}`}>
<label htmlFor={name} className="text-sm">
{label}
{required && <span className="text-red-500">*</span>}
</label>
<p className="text-xs pt-1 pb-2 text-gray-700">
{desc}
</p>
<input
className={`${!valid ? "border-red-500" : ""} ${classes}`}
type={type}
name={name}
value={value}
onChange={handleChange}
required={required}
placeholder={placeholder}
{...inputProps}
/>
{!valid && (
<p className="text-xs text-red-500 my-1">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,70 @@
import { useState, type ChangeEvent, type Dispatch, type FormEvent, type SetStateAction } from "react";
interface RecipeCreateFormTagsInputsProps {
tags: string[];
setTags: Dispatch<SetStateAction<string[]>>;
classes: string;
}
export default function RecipeCreateFormTagsInputs({ tags, setTags, classes }: RecipeCreateFormTagsInputsProps) {
const [input, setInput] = useState<string>("");
const changeHandler = (e: ChangeEvent<HTMLInputElement>) => setInput(e.target.value);
const tagCreationHandler = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
// why would anyone try this lol
if (input.trim() === "") return;
// Tag already exists, clear input and exit
if (tags.includes(input.toLowerCase())) {
setInput("");
return;
}
setInput("");
setTags(prev => [...prev, input.toLowerCase()]);
}
const tagDeletionHandler = (tag: string) => {
if (!tag) return;
setTags(prev => prev.filter(t => t !== tag));
}
return (
<form onSubmit={tagCreationHandler} className="my-4 flex flex-col gap-x-2">
<div className="flex flex-col flex-grow">
<label htmlFor="tags" className="text-sm">
Recipe Tags
</label>
<p className="text-xs pt-1 pb-2 text-gray-700">
Please provide a list of tags. <span className="italic">e.g., easy, dairy-free, gluten-free, high protein.</span>
</p>
<input
type="text"
value={input}
onChange={changeHandler}
name="tagInput"
maxLength={32}
enterKeyHint="done"
placeholder="e.g., Healthy"
className={classes}
/>
<input type="hidden" name="tags" id="tags" value="" />
</div>
<ul id="tag-list" className="my-2 flex gap-1 flex-wrap">
{tags?.map(tag =>
<li
className="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"
key={tag}
>
<button tabIndex={-1} type="button" onClick={() => tagDeletionHandler(tag)}>
&times; {tag}
</button>
</li>
)}
</ul>
</form>
);
}

View File

@ -0,0 +1,56 @@
import { type ChangeEvent, type Dispatch, type SetStateAction, type TextareaHTMLAttributes } from "react";
import type { CreateRecipeFormDirtyEntries } from "../../pages/Create";
interface RecipeCreateFormInputProps
extends Omit<
TextareaHTMLAttributes<HTMLTextAreaElement>,
"value" | "onChange" | "name" | "type" | "placeholder" | "required"
> {
label: string;
name: string;
desc: string;
placeholder: string;
required?: boolean;
valid: boolean;
value: string;
setValue: Dispatch<SetStateAction<string>>;
setDirty: Dispatch<SetStateAction<CreateRecipeFormDirtyEntries>>;
error: string;
parentClasses?: string;
classes: string;
};
export default function RecipeCreateFormTextArea({ label, name, desc, placeholder, required = false, valid, value, setDirty, setValue, error, parentClasses = "", classes, ...inputProps }: RecipeCreateFormInputProps) {
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setDirty(prev => ({ ...prev, [name]: true }));
setValue(e.target.value);
}
return (
<div className={`flex flex-col ${parentClasses}`}>
<label htmlFor={name} className="text-sm">
{label}
{required && <span className="text-red-500">*</span>}
</label>
<p className="text-xs pt-1 pb-2 text-gray-700">
{desc}
</p>
<textarea
className={`${!valid ? "border-red-500" : ""} ${classes}`}
name={name}
value={value}
onChange={handleChange}
required={required}
placeholder={placeholder}
{...inputProps}
/>
{!valid && (
<p className="text-xs text-red-500 my-1">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,28 @@
import type { ReactNode } from "react";
interface RecipeCreateFormWrapperProps {
label: string;
name: string;
desc: string;
required: boolean;
parentClasses: string;
children: ReactNode;
}
export default function RecipeCreateFormWrapper({ label, name, desc, required = false, parentClasses, children }: RecipeCreateFormWrapperProps) {
const normalized_name = name.toLowerCase().replaceAll(" ", "-");
return (
<div className={`flex flex-col ${parentClasses}`}>
<label htmlFor={normalized_name} className="text-sm">
{label}
{required && <span className="text-red-500">*</span>}
</label>
<p className="text-xs py-1 text-gray-700">
{desc}
</p>
{children}
</div>
);
}

View File

@ -0,0 +1,122 @@
import { use, useEffect, useState, type ChangeEvent, type Dispatch, type FormEvent, type SetStateAction } from "react";
import type { SearchFilters } from "../../types/search";
import FilterButton from "../buttons/FilterButton";
import RecipeSearchFilterDropdown from "./RecipeSearchFilterDropdown";
import { SearchRecipes } from "../../services/RecipeService";
import { isApiError } from "../../types/api/error";
import type { Recipe } from "../../types/recipe";
import { useNavigate } from "react-router-dom";
import { FilterContext } from "../../context/FilterContext";
interface RecipeSearchBarProps {
// filters: SearchFilters;
// setFilters: React.Dispatch<React.SetStateAction<SearchFilters>>;
redirect: boolean;
searchOnLoad: boolean;
favorites: boolean;
setRecipes: Dispatch<SetStateAction<Recipe[]>> | null;
// Loading is optional
loading?: boolean;
setLoading?: Dispatch<SetStateAction<boolean>>;
};
export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, setRecipes, loading, setLoading }: RecipeSearchBarProps) {
const navigate = useNavigate();
const { filters, setFilters } = use(FilterContext);
const [displayDropdown, setDisplayDropdown] = useState<boolean>(false);
// SERVER FUNCTIONS
const fetchSearchResults = async () => {
if (redirect) {
await navigate("/v2/web/search");
return;
}
// Should not allow many queries, thought we should allow redirect through loading
if (loading) return;
if (setLoading) setLoading(true);
try {
const result = await SearchRecipes(filters);
if (isApiError(result)) {
console.error(result.message);
return;
}
if (setRecipes)
setRecipes(result);
} finally {
if (setLoading) setLoading(false);
}
}
// HANDLERS
const toggleDropdownHandler = () => setDisplayDropdown(!displayDropdown);
// TODO: Store filters in a global state somewhere!
const searchHandler = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
await fetchSearchResults();
};
const queryInputHandler = (e: ChangeEvent<HTMLInputElement>) => {
const new_filters: SearchFilters = {
...filters,
Search: e.target.value,
};
setFilters(new_filters);
}
// EFFECTS
// TODO: Learn how to use 'useCallback' here to prevent endless loading and fix warning
useEffect(() => {
if (searchOnLoad)
void fetchSearchResults();
}, [searchOnLoad]);
useEffect(() => {
setFilters({
...filters,
Favorites: favorites
});
}, [favorites]);
return (
<form className="w-full px-4 my-8" onSubmit={(e) => void searchHandler(e)}>
<div className="flex w-full gdisbaledap-x-2">
<div className="relative w-full">
<input type="hidden" name="redirect" value={JSON.stringify(redirect)} />
<input
type="search"
name="search"
placeholder="Search recipes, ingredients..."
value={filters ? filters.Search : ""}
onChange={queryInputHandler}
className="w-[99%] pr-4 pl-10 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button className="absolute left-3 top-1/2 -translate-y-1/2">
<svg
className="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
></path>
</svg>
</button>
</div>
<FilterButton click={toggleDropdownHandler} />
</div>
<RecipeSearchFilterDropdown filters={filters} setFilters={setFilters} display={displayDropdown} />
</form>
);
}

View File

@ -0,0 +1,91 @@
import type { ChangeEvent } from "react";
import type { FilterBitKey, SearchFilters } from "../../types/search";
import DropdownButton from "../buttons/DropdownButton";
interface RecipeSearchFilterDropdownProps {
filters: SearchFilters;
setFilters: (filters: SearchFilters) => void;
display: boolean;
};
function isBitActive(bits: number, bit: number): boolean {
return (bits & bit) === bit;
}
export default function RecipeSearchFilterDropdown({ filters, setFilters, display }: RecipeSearchFilterDropdownProps) {
const changeHandler = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const key: FilterBitKey = name as FilterBitKey;
const [current, bit] = [filters[key], Number(value)];
const new_filters: SearchFilters = {
...filters,
[key]: isBitActive(current, bit) ? current - bit : current + bit,
};
setFilters(new_filters);
}
return (
<div className={`${display ? "block" : "hidden"} w-full p-2 border border-gray-300 my-2 rounded-lg`}>
<div className="w-full border-b border-gray-300 py-2">
<h3 className="mb-2">
Meal
</h3>
<div className="flex text-xs flex-wrap gap-1 gap-y-3">
<DropdownButton content="Breakfast" name="MealType" value="1" selected={isBitActive(filters.MealType, 1)} changeHandler={changeHandler} />
<DropdownButton content="Lunch" name="MealType" value="2" selected={isBitActive(filters.MealType, 2)} changeHandler={changeHandler} />
<DropdownButton content="Dinner" name="MealType" value="4" selected={isBitActive(filters.MealType, 4)} changeHandler={changeHandler} />
<DropdownButton content="Desert" name="MealType" value="8" selected={isBitActive(filters.MealType, 8)} changeHandler={changeHandler} />
<DropdownButton content="Snack" name="MealType" value="16" selected={isBitActive(filters.MealType, 16)} changeHandler={changeHandler} />
<DropdownButton content="Side" name="MealType" value="32" selected={isBitActive(filters.MealType, 32)} changeHandler={changeHandler} />
<DropdownButton content="Other" name="MealType" value="64" selected={isBitActive(filters.MealType, 64)} changeHandler={changeHandler} />
</div>
</div>
<div className="w-full border-b border-gray-300 py-2">
<h3 className="mb-2">
Cook Time
</h3>
<div className="flex text-xs flex-wrap gap-1 gap-y-3">
<DropdownButton content="< 15 min" name="Time" value="1" selected={isBitActive(filters.Time, 1)} changeHandler={changeHandler} />
<DropdownButton content="15 to 30 min" name="Time" value="2" selected={isBitActive(filters.Time, 2)} changeHandler={changeHandler} />
<DropdownButton content="30 to 60 min" name="Time" value="4" selected={isBitActive(filters.Time, 4)} changeHandler={changeHandler} />
<DropdownButton content="60 to 120 min" name="Time" value="8" selected={isBitActive(filters.Time, 8)} changeHandler={changeHandler} />
<DropdownButton content="+120 min" name="Time" value="16" selected={isBitActive(filters.Time, 16)} changeHandler={changeHandler} />
</div>
</div>
<div className="w-full border-b border-gray-300 py-2">
<h3 className="mb-2">
Difficulty
</h3>
<div className="flex text-xs flex-wrap gap-1 gap-y-3">
<DropdownButton content="Beginner" name="Difficulty" value="1" selected={isBitActive(filters.Difficulty, 1)} changeHandler={changeHandler} />
<DropdownButton content="Easy" name="Difficulty" value="2" selected={isBitActive(filters.Difficulty, 2)} changeHandler={changeHandler} />
<DropdownButton content="Intermediate" name="Difficulty" value="4" selected={isBitActive(filters.Difficulty, 4)} changeHandler={changeHandler} />
<DropdownButton content="Challenging" name="Difficulty" value="8" selected={isBitActive(filters.Difficulty, 8)} changeHandler={changeHandler} />
<DropdownButton content="Extreme" name="Difficulty" value="16" selected={isBitActive(filters.Difficulty, 16)} changeHandler={changeHandler} />
</div>
</div>
<div className="w-full border-b border-gray-300 py-2">
<h3 className="mb-2">
Serving Size
</h3>
<div className="flex text-xs flex-wrap gap-1 gap-y-3">
<DropdownButton content="1 to 2" name="ServingSize" value="1" selected={isBitActive(filters.ServingSize, 1)} changeHandler={changeHandler} />
<DropdownButton content="2 to 4" name="ServingSize" value="2" selected={isBitActive(filters.ServingSize, 2)} changeHandler={changeHandler} />
<DropdownButton content="4 to 6" name="ServingSize" value="4" selected={isBitActive(filters.ServingSize, 4)} changeHandler={changeHandler} />
<DropdownButton content="6 to 8" name="ServingSize" value="8" selected={isBitActive(filters.ServingSize, 8)} changeHandler={changeHandler} />
<DropdownButton content="8+" name="ServingSize" value="16" selected={isBitActive(filters.ServingSize, 16)} changeHandler={changeHandler} />
</div>
</div>
<div className="w-full pt-2 flex justify-end items-end">
<button
type="submit"
className="w-full text-sm md:text-base text-white rounded-lg py-1.5 md:py-2 bg-blue-600 hover:bg-blue-700 duration-300">
Apply Filters
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import type { Engagement } from "../../types/engagement";
interface ActivityListItemProps {
engagement: Engagement;
}
function FormatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit"
}).format(date);
}
export default function ActivityListItem({ engagement }: ActivityListItemProps) {
return <>
<li className="w-full border-b border-gray-300 px-2 py-4 even:bg-gray-50 hover:bg-gray-100 duration-150 flex justify-between items-center">
<p className="text-sm md:text-base text-gray-800">
{engagement.Message}
</p>
<p className="text-xs md:text-sm text-gray-600 w-fit shrink-0">
{FormatDate(new Date(engagement.Created))}
</p>
</li>
</>;
}

View File

@ -0,0 +1,62 @@
import { Fragment } from "react/jsx-runtime";
import type { RecipeIngredient, RecipeIngredientSection } from "../../types/recipe";
import { useState } from "react";
interface IngredientListProps {
sections: RecipeIngredientSection[];
ingredients: RecipeIngredient[];
}
const CLASSES_ACTIVE = "p-1 bg-blue-100 border border-blue-200 h-fit duration-300 cursor-pointer";
const CLASSES_INACTIVE = "p-1 bg-gray-100 border border-gray-200 h-fit duration-300 cursor-pointer hover:bg-gray-200 hover:border-gray-300";
export default function IngredientList({ sections, ingredients }: IngredientListProps) {
const [scale, setScale] = useState<number>(1);
return (
<>
<div className="px-4 py-8 md:px-8">
<div className="flex justify-between items-center">
<h2 className="text-2xl text-gray-800 font-semibold mb-2">Ingredients</h2>
{/* Serving size toggle */}
<div className="flex gap-x-1">
<button className={scale === 0.5 ? CLASSES_ACTIVE : CLASSES_INACTIVE} onClick={() => setScale(0.5)}> .5x </button>
<button className={scale === 1 ? CLASSES_ACTIVE : CLASSES_INACTIVE} onClick={() => setScale(1)}> 1x </button>
<button className={scale === 2 ? CLASSES_ACTIVE : CLASSES_INACTIVE} onClick={() => setScale(2)}> 2x </button>
<button className={scale === 3 ? CLASSES_ACTIVE : CLASSES_INACTIVE} onClick={() => setScale(3)}> 3x </button>
</div>
</div>
<hr className="text-gray-300" />
{sections?.map(section => (
<Fragment key={section.Id}>
{/* NOTE: If there is a only one section, do not display a name. */}
{sections.length > 1 && (
<h3 className="text-xl text-gray-800 font-semibold my-4">{section.Name}</h3>
)}
<ul className="text-lg my-2 text-gray-700">
{ingredients?.filter(x => x.SectionId === section.Id).map(ingredient => (
<li key={ingredient.Id} className="p-2 hover:bg-gray-100 transition-all duration-300 rounded-sm flex items-center justify-start odd:bg-[#f8f8f8]">
<span className="mr-4">
<svg className="h-4 text-gray-400" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21 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"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
</span>
<span className="font-semibold mr-2">
{ingredient.Amount > 0 ? (ingredient.Amount * scale) : null} {ingredient.Unit}
</span>
{ingredient.Name}
</li>
))}
</ul>
</Fragment>
))}
</div>
</>
);
}

View File

@ -0,0 +1,26 @@
import type { RecipeInstruction } from "../../types/recipe";
interface InstructionListProps {
instructions: RecipeInstruction[];
}
export default function InstructionList({ instructions }: InstructionListProps) {
return (
<>
<div className="px-4 py-8 md:px-8">
<h2 className="text-2xl text-gray-800 font-semibold mb-2">Instructions</h2>
<hr className="text-gray-300" />
<ul className="text-lg my-4 text-gray-700">
{instructions?.map((instruction, i) => (
<li key={instruction.Id || crypto.randomUUID()} className="p-4 flex items-start gap-x-4 odd:bg-[#f8f8f8]">
<div className="size-8 md:size-10 bg-blue-50 rounded-full flex items-center justify-center flex-shrink-0">
<h3 className="text-base md:text-xl text-blue-600 font-semibold">{i + 1}</h3>
</div>
<p className="text-base">{instruction.Content}</p>
</li>
))}
</ul>
</div>
</>
);
}

View File

@ -0,0 +1,73 @@
import { useNavigate } from "react-router-dom";
import { EngagementViewRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
import type { Recipe, Tag } from "../../types/recipe"
interface RecipeListItemProps {
recipe: Recipe;
};
function displayDifficulty(diff: number): string {
switch (diff) {
case 1:
return "Beginner"
case 2:
return "Easy"
case 3:
return "Intermediate"
case 4:
return "Challenging"
case 5:
return "Extreme"
default:
return ""
}
}
function displayTags(tags: Tag[]): string {
return tags.map(tag => tag.Name).join(", ");
}
export default function RecipeListItem({ recipe }: RecipeListItemProps) {
const navigate = useNavigate();
// HANDLERS
const clickHandler = async () => {
if (!recipe) return;
// Navigate first, so it feels faster
await navigate(`/v2/web/recipe/${recipe.Id}`);
const result = await EngagementViewRecipe(recipe.Id);
if (isApiError(result)) {
console.error(result.message);
}
}
return (
<li className="w-full border-b border-gray-300 px-2 py-4 even:bg-gray-50 hover:bg-gray-100 duration-150">
<h2 onClick={() => void clickHandler()} className="text-base md:text-lg hover:text-blue-600 duration-100 cursor-pointer">
{recipe.Title}
</h2>
<p className="hidden md:block text-sm text-gray-700 my-1.5">
Difficulty: <span className="font-semibold">{displayDifficulty(recipe.Difficulty)}</span>
{" "} | Duration: <span className="font-semibold">{recipe.Duration.Total} min</span>
{" "} | Category: <span className="font-semibold">{recipe.Category}</span>
</p>
<p className="md:hidden text-xs md:text-sm text-gray-700 my-1">
Difficulty: <span className="font-semibold">{displayDifficulty(recipe.Difficulty)}</span>
</p>
<p className="md:hidden text-xs md:text-sm text-gray-700 my-1">
Duration: <span className="font-semibold">{recipe.Duration.Total} min</span>
</p>
<p className="md:hidden text-xs md:text-sm text-gray-700 my-1">
Category: <span className="font-semibold">{recipe.Category}</span>
</p>
{recipe.Tags && (
<p className="text-xs italic text-gray-500">
Tags: {displayTags(recipe.Tags)}
</p>
)}
</li>
);
}

View File

@ -0,0 +1,76 @@
import { useNavigate } from "react-router-dom";
import { EngagementViewRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
import type { Recipe } from "../../types/recipe";
import ServingSizeIconSmall from "../icons/ServingSizeIconSmall";
import StarIcon from "../icons/StarIcon";
import TimeIconSmall from "../icons/TimeIconSmall";
import RecipePlaceholder from "../../assets/images/recipe_placeholder.png";
interface RecipeSearchResultProps {
recipe: Recipe;
};
export default function RecipeSearchResult({ recipe }: RecipeSearchResultProps) {
const navigate = useNavigate();
// HANDLERS
const clickHandler = async () => {
// Navigate first, so it feels faster
await navigate(`/v2/web/recipe/${recipe.Id}`);
const result = await EngagementViewRecipe(recipe.Id);
if (isApiError(result)) {
console.error(result.message);
}
}
return (
<div onClick={() => void clickHandler()} className="w-full p-2 border-b border-gray-200 hover:bg-gray-100 duration-200 flex items-center flex-col md:flex-row even:bg-[#f8f8f8] cursor-pointer">
<img className="bg-gray-50 size-56 md:size-40 rounded-md border border-gray-200 shadow-sm shadow-gray-100" src={RecipePlaceholder} alt="Recipe placeholder image" />
<div className="text-gray-700 p-4 flex flex-col items-center md:items-start w-full">
<div className="flex flex-col md:flex-row items-center md:items-start justify-between w-full">
<div className="flex flex-col items-center md:items-start">
<h3 className="text-xl font-semibold text-black pb-1 text-center">
{recipe.Title} <span className="text-sm font-normal hidden md:inline">{recipe.Category}</span>
</h3>
<div className="text-sm flex gap-x-3 gap-y-1 items-center flex-wrap">
<span className="flex gap-x-1 align-center">
<TimeIconSmall />
{recipe.Duration.Total} min
</span>
<span className="flex gap-x-1 align-center">
{Array.from({ length: recipe.Difficulty }).map((_, i) => (
<StarIcon key={`${recipe.Id}-filled-${i}`} size={4} filled={true} />
))}
{Array.from({ length: 5 - (recipe.Difficulty) }).map((_, i) => (
<StarIcon key={`${recipe.Id}-unfilled-${i}`} size={4} filled={false} />
))}
</span>
<span className="flex gap-x-1 align-center">
<ServingSizeIconSmall />
Serves {recipe.Serves}
</span>
</div>
</div>
<div className="mb-2 mt-4 md:my-0 hidden md:block">
{recipe.Favorite && (
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 9.1371C2 14 6.01943 16.5914 8.96173 18.9109C10 19.7294 11 20.5 12 20.5C13 20.5 14 19.7294 15.0383 18.9109C17.9806 16.5914 22 14 22 9.1371C22 4.27416 16.4998 0.825464 12 5.50063C7.50016 0.825464 2 4.27416 2 9.1371Z"
fill="currentColor"></path>
</svg>
)}
</div>
</div>
<div className="my-1">
<p className="text-xs text-gray-500 italic">{recipe.Tags.map(x => x.Name).join(", ")}</p>
</div>
<p className="text-sm text-center md:text-left overflow-hidden text-ellipsis break-all"
style={{ display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical" }}>
{recipe.Description}
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
import type { Tag } from "../../types/recipe";
interface TagListProps {
tags: Tag[]
created: Date
modified: Date | null;
}
function FormatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit"
}).format(date);
}
export default function TagList({ tags, created, modified }: TagListProps) {
return (
<div className="px-4 py-4 md:px-8">
{tags && (
<>
<h2 className="text-2xl text-gray-800 font-semibold mb-2">Tags</h2>
<hr className="text-gray-300" />
<ul id="tag-list" className="my-4 flex gap-1 flex-wrap">
{tags.map(tag => (
<li key={tag.Id} className="text-sm items-center bg-blue-100 text-blue-700 w-fit px-3 py-1.5 rounded-full">
{tag.Name}
</li>
))}
</ul>
</>
)}
<hr className="text-gray-300" />
<p className="my-4 mb-1.5 text-sm text-gray-700">Created: {FormatDate(new Date(created))}</p>
{modified && (
<p className="mb-4 text-sm text-gray-700">Last Modified: {FormatDate(new Date(modified))}</p>
)}
</div>
);
}

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: () => ""
});

Some files were not shown because too many files have changed in this diff Show More