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
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 3m51s
Reviewed-on: #56
This commit is contained in:
commit
43ef9d9490
16
.github/workflows/deploy.yml
vendored
16
.github/workflows/deploy.yml
vendored
@ -1,6 +1,6 @@
|
|||||||
name: Deploy application with Docker
|
name: Deploy application with Docker
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
@ -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
|
||||||
|
|||||||
55
Dockerfile
55
Dockerfile
@ -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
57
Dockerfile.old
Normal 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
6
docker-compose.yml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: ./web/.
|
||||||
|
container_name: potion.frontend
|
||||||
|
ports:
|
||||||
|
- "3002:3002" # host:container
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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)
|
|
||||||
// }
|
|
||||||
@ -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)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
@ -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))
|
|
||||||
// }
|
|
||||||
@ -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)
|
|
||||||
// }
|
|
||||||
@ -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">
|
|
||||||
// × %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)
|
|
||||||
// }
|
|
||||||
@ -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,
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
48
internal/app/server/auth_handler_v2.go
Normal file
48
internal/app/server/auth_handler_v2.go
Normal 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)
|
||||||
|
}
|
||||||
49
internal/app/server/authentication.go
Normal file
49
internal/app/server/authentication.go
Normal 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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
143
internal/app/server/engagement_handler_v2.go
Normal file
143
internal/app/server/engagement_handler_v2.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
98
internal/app/server/middleware_v2.go
Normal file
98
internal/app/server/middleware_v2.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
113
internal/app/server/recipe_handler_v2.go
Normal file
113
internal/app/server/recipe_handler_v2.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
142
internal/app/server/user_handler_v2.go
Normal file
142
internal/app/server/user_handler_v2.go
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
34
shell.nix
Normal 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
26
web/.gitignore
vendored
Normal 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
32
web/Dockerfile
Normal 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
4
web/README.md
Normal 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
43
web/eslint.config.js
Normal 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
16
web/index.html
Normal 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
4244
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
web/package.json
Normal file
39
web/package.json
Normal 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
1
web/public/vite.svg
Normal 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
62
web/src/App.tsx
Normal 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
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.7 KiB |
1
web/src/assets/react.svg
Normal file
1
web/src/assets/react.svg
Normal 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 |
12
web/src/components/Banner.tsx
Normal file
12
web/src/components/Banner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
web/src/components/Navigation.tsx
Normal file
116
web/src/components/Navigation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
web/src/components/Spinner.tsx
Normal file
12
web/src/components/Spinner.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
web/src/components/buttons/DropdownButton.tsx
Normal file
19
web/src/components/buttons/DropdownButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
web/src/components/buttons/FavoriteButton.tsx
Normal file
78
web/src/components/buttons/FavoriteButton.tsx
Normal 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>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
36
web/src/components/buttons/FilterButton.tsx
Normal file
36
web/src/components/buttons/FilterButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
web/src/components/buttons/LikeButton.tsx
Normal file
10
web/src/components/buttons/LikeButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
web/src/components/buttons/MadeButton.tsx
Normal file
61
web/src/components/buttons/MadeButton.tsx
Normal 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>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
56
web/src/components/buttons/ShareButton.tsx
Normal file
56
web/src/components/buttons/ShareButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
web/src/components/cards/ContentCardSmall.tsx
Normal file
17
web/src/components/cards/ContentCardSmall.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
web/src/components/cards/RecipeCardLarge.tsx
Normal file
60
web/src/components/cards/RecipeCardLarge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
web/src/components/cards/RecipeCardSmall.tsx
Normal file
51
web/src/components/cards/RecipeCardSmall.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
web/src/components/display/RecipeMetaData.tsx
Normal file
58
web/src/components/display/RecipeMetaData.tsx
Normal 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>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
94
web/src/components/forms/IngredientItem.tsx
Normal file
94
web/src/components/forms/IngredientItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
web/src/components/forms/IngredientList.tsx
Normal file
47
web/src/components/forms/IngredientList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
web/src/components/forms/IngredientSection.tsx
Normal file
59
web/src/components/forms/IngredientSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
76
web/src/components/forms/InstructionElement.tsx
Normal file
76
web/src/components/forms/InstructionElement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
web/src/components/forms/InstructionList.tsx
Normal file
53
web/src/components/forms/InstructionList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
web/src/components/forms/ValidationErrorList.tsx
Normal file
44
web/src/components/forms/ValidationErrorList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
web/src/components/icons/DeleteIconSmall.tsx
Normal file
7
web/src/components/icons/DeleteIconSmall.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
web/src/components/icons/DragIconSmall.tsx
Normal file
7
web/src/components/icons/DragIconSmall.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
web/src/components/icons/ServingSizeIcon.tsx
Normal file
28
web/src/components/icons/ServingSizeIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
web/src/components/icons/ServingSizeIconSmall.tsx
Normal file
16
web/src/components/icons/ServingSizeIconSmall.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
19
web/src/components/icons/ShoppingListIcon.tsx
Normal file
19
web/src/components/icons/ShoppingListIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
web/src/components/icons/StarIcon.tsx
Normal file
25
web/src/components/icons/StarIcon.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
25
web/src/components/icons/StarIconSmall.tsx
Normal file
25
web/src/components/icons/StarIconSmall.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
13
web/src/components/icons/TimeIcon.tsx
Normal file
13
web/src/components/icons/TimeIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
web/src/components/icons/TimeIconSmall.tsx
Normal file
9
web/src/components/icons/TimeIconSmall.tsx
Normal 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>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
59
web/src/components/inputs/RecipeCreateFormDropdown.tsx
Normal file
59
web/src/components/inputs/RecipeCreateFormDropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
58
web/src/components/inputs/RecipeCreateFormInput.tsx
Normal file
58
web/src/components/inputs/RecipeCreateFormInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
70
web/src/components/inputs/RecipeCreateFormTagsInput.tsx
Normal file
70
web/src/components/inputs/RecipeCreateFormTagsInput.tsx
Normal 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)}>
|
||||||
|
× {tag}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
web/src/components/inputs/RecipeCreateFormTextArea.tsx
Normal file
56
web/src/components/inputs/RecipeCreateFormTextArea.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
28
web/src/components/inputs/RecipeCreateFormWrapper.tsx
Normal file
28
web/src/components/inputs/RecipeCreateFormWrapper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
122
web/src/components/inputs/RecipeSearchBar.tsx
Normal file
122
web/src/components/inputs/RecipeSearchBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
web/src/components/inputs/RecipeSearchFilterDropdown.tsx
Normal file
91
web/src/components/inputs/RecipeSearchFilterDropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
web/src/components/items/ActivityListItem.tsx
Normal file
27
web/src/components/items/ActivityListItem.tsx
Normal 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>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
62
web/src/components/items/IngredientList.tsx
Normal file
62
web/src/components/items/IngredientList.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
web/src/components/items/InstructionList.tsx
Normal file
26
web/src/components/items/InstructionList.tsx
Normal 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>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
web/src/components/items/RecipeListItem.tsx
Normal file
73
web/src/components/items/RecipeListItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
web/src/components/items/RecipeSearchResult.tsx
Normal file
76
web/src/components/items/RecipeSearchResult.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
web/src/components/items/TagList.tsx
Normal file
43
web/src/components/items/TagList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
web/src/context/AuthContext.tsx
Normal file
13
web/src/context/AuthContext.tsx
Normal 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
Loading…
x
Reference in New Issue
Block a user