diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 72ef0ee..d39f022 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -1,6 +1,6 @@
name: Deploy application with Docker
-on:
+on:
push:
branches:
- master
@@ -19,10 +19,22 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- - name: Build and push Docker image
+ - name: Build and push backend Docker image
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile
push: true
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
diff --git a/Dockerfile b/Dockerfile
index 3e3b3f1..cbf0426 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,57 +1,14 @@
-# Fetch stage
-FROM golang:latest AS fetch-stage
-
-COPY . /app
+FROM golang:1.25-alpine
WORKDIR /app
-RUN go mod tidy
+COPY go.mod go.sum ./
RUN go mod download
-# Generate stage
-FROM ghcr.io/a-h/templ:latest AS generate-stage
+COPY . .
-COPY --chown=65532:65532 . /app
+RUN go build -o server ./cmd/web/main.go
-WORKDIR /app
+EXPOSE 8080
-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"]
+CMD ["./server"]
diff --git a/Dockerfile.old b/Dockerfile.old
new file mode 100644
index 0000000..3e3b3f1
--- /dev/null
+++ b/Dockerfile.old
@@ -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"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..026ee38
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,6 @@
+services:
+ app:
+ build: ./web/.
+ container_name: potion.frontend
+ ports:
+ - "3002:3002" # host:container
diff --git a/flake.nix b/flake.nix
index 6efaf29..2465d44 100644
--- a/flake.nix
+++ b/flake.nix
@@ -29,6 +29,7 @@
dockerfile-language-server-nodejs
gcc_multi
glibc_multi
+ nodejs
];
# Define the shell that will be executed.
diff --git a/internal/app/handlers/auth_handler.go b/internal/app/handlers/auth_handler.go
deleted file mode 100644
index 50d48df..0000000
--- a/internal/app/handlers/auth_handler.go
+++ /dev/null
@@ -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)
-// }
diff --git a/internal/app/handlers/engagement_handler.go b/internal/app/handlers/engagement_handler.go
deleted file mode 100644
index 3620d04..0000000
--- a/internal/app/handlers/engagement_handler.go
+++ /dev/null
@@ -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)
-// }
-// }
diff --git a/internal/app/handlers/page_handler.go b/internal/app/handlers/page_handler.go
deleted file mode 100755
index 1165ecd..0000000
--- a/internal/app/handlers/page_handler.go
+++ /dev/null
@@ -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))
-// }
diff --git a/internal/app/handlers/recipe_handler.go b/internal/app/handlers/recipe_handler.go
deleted file mode 100644
index 868de1b..0000000
--- a/internal/app/handlers/recipe_handler.go
+++ /dev/null
@@ -1,136 +0,0 @@
-package handlers
-
-// DEPRECATED: As of September 4th, 2025.
-// const CREATE_ERROR_HTML = `
-//
-// Uh oh! Something went wrong when creating your recipe. Please try again. %s
-//
-// `
-
-// 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)
-// }
diff --git a/internal/app/handlers/state_handler.go b/internal/app/handlers/state_handler.go
deleted file mode 100644
index d2c55b5..0000000
--- a/internal/app/handlers/state_handler.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package handlers
-
-// DEPRECATED: As of September 4th, 2025.
-// const TAG_HTML = `
-//
-// × %s
-//
-// `
-
-// DEPRECATED: As of September 4th, 2025.
-// const TAG_LIST_HTML = `
-//
-// `
-
-// 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)
-// }
diff --git a/internal/app/handlers/user_handler.go b/internal/app/handlers/user_handler.go
deleted file mode 100644
index d63dd61..0000000
--- a/internal/app/handlers/user_handler.go
+++ /dev/null
@@ -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,
-// })
-// }
diff --git a/internal/app/server/auth_handler_v2.go b/internal/app/server/auth_handler_v2.go
new file mode 100644
index 0000000..5ab70f3
--- /dev/null
+++ b/internal/app/server/auth_handler_v2.go
@@ -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)
+}
diff --git a/internal/app/server/authentication.go b/internal/app/server/authentication.go
new file mode 100644
index 0000000..81d2283
--- /dev/null
+++ b/internal/app/server/authentication.go
@@ -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
+}
diff --git a/internal/app/server/cookies.go b/internal/app/server/cookies.go
index 8cb46b3..78ac8f6 100644
--- a/internal/app/server/cookies.go
+++ b/internal/app/server/cookies.go
@@ -1,6 +1,7 @@
package server
import (
+ "net/http"
"time"
"github.com/gin-gonic/gin"
@@ -18,10 +19,10 @@ import (
func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.Duration) {
var (
path string = "/"
- httpOnly bool = true
+ httpOnly bool = false // NOTE: Should use false so React can see it!
maxAge int
- secure bool
- domain string
+ secure bool = true
+ domain string = ""
)
if duration < 0 {
@@ -32,22 +33,22 @@ func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.D
maxAge = 0
} else {
// Normal calculation
- maxAge = int(time.Now().Add(duration).Sub(time.Now()).Seconds())
+ maxAge = int(time.Until(time.Now().Add(duration)).Seconds())
}
+ // TODO: This whole system is stupid now
if s.deps.EnvironmentConfig.Environment == "prod" {
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" {
secure = false
- domain = s.deps.EnvironmentConfig.Domain
-
- } else {
- // Defaults
- secure = false
- domain = ""
+ // domain = s.deps.EnvironmentConfig.Domain
+ domain = "localhost"
}
+ ctx.SetSameSite(http.SameSiteNoneMode)
ctx.SetCookie(name, value, maxAge, path, domain, secure, httpOnly)
}
diff --git a/internal/app/server/engagement_handler_v2.go b/internal/app/server/engagement_handler_v2.go
new file mode 100644
index 0000000..2fcd806
--- /dev/null
+++ b/internal/app/server/engagement_handler_v2.go
@@ -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,
+ })
+ }
+ })
+}
diff --git a/internal/app/server/middleware_v2.go b/internal/app/server/middleware_v2.go
new file mode 100644
index 0000000..fbdaa5f
--- /dev/null
+++ b/internal/app/server/middleware_v2.go
@@ -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()
+ }
+}
diff --git a/internal/app/server/recipe_handler_v2.go b/internal/app/server/recipe_handler_v2.go
new file mode 100644
index 0000000..7c1a9fb
--- /dev/null
+++ b/internal/app/server/recipe_handler_v2.go
@@ -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,
+ })
+}
diff --git a/internal/app/server/server.go b/internal/app/server/server.go
index 3e8f85f..a2a7b52 100644
--- a/internal/app/server/server.go
+++ b/internal/app/server/server.go
@@ -42,7 +42,11 @@ func Init(port int) *Server {
server.Router.SetTrustedProxies(nil)
// Setup the CORS settings and active them
- server.config.AllowAllOrigins = true
+ // server.config.AllowAllOrigins = true
+ server.config.AllowOrigins = []string{"http://localhost:5173", "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))
return server
@@ -74,7 +78,8 @@ func (s *Server) Setup() *Server {
// SETUP GOOGLE AUTH
var (
- redirectUrl string = fmt.Sprintf("%s%s", cfg.Domain, domain.API_AUTH_CALLBACK)
+ // NOTE: USING V2 NOW
+ redirectUrl string = fmt.Sprintf("%s%s", cfg.Domain, domain.API_AUTH_CALLBACK_V2)
clientId string = cfg.GoogleClientId
clientSecret string = cfg.GoogleClientSecret
scope []string = []string{
@@ -120,13 +125,15 @@ func (s *Server) Setup() *Server {
// Apply middleware
s.Router.Use(RecoveryMiddleware())
- s.Router.Use(JwtAuthMiddleWare(jwtSecret))
+ // NOTE: No longer running on every connection!
+ // s.Router.Use(JwtAuthMiddleWare(jwtSecret))
// Redirect index to home page: Update this as needed
s.Router.GET("/", func(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME) })
// Wrap all routes with a version
- router_v1 := s.Router.Group(domain.VERSION)
+ router_v1 := s.Router.Group(domain.VERSION_1)
+ router_v2 := s.Router.Group(domain.VERSION_2)
// Domain specific routers
router_web := router_v1.Group(domain.WEB)
@@ -179,7 +186,7 @@ func (s *Server) Setup() *Server {
path := ctx.Request.URL.Path
// TODO: Use constants for errors?
- if strings.HasPrefix(path, domain.VERSION+domain.API) {
+ if strings.HasPrefix(path, domain.VERSION_1+domain.API) {
ctx.JSON(http.StatusNotFound, gin.H{
"status": http.StatusNotFound,
"error": "API_NOT_FOUND",
@@ -192,5 +199,34 @@ func (s *Server) Setup() *Server {
ctx.Redirect(http.StatusSeeOther, domain.WEB_NOT_FOUND)
})
+ // ---- VERSION 2 ROUTES ---- //
+ router_api_v2 := router_v2.Group(domain.API)
+ router_api_v2.GET("/recipe/: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
}
diff --git a/internal/app/server/user_handler_v2.go b/internal/app/server/user_handler_v2.go
new file mode 100644
index 0000000..120e04a
--- /dev/null
+++ b/internal/app/server/user_handler_v2.go
@@ -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,
+ })
+ })
+}
diff --git a/internal/app/service/recipe_service.go b/internal/app/service/recipe_service.go
index fc89a87..acb5baa 100644
--- a/internal/app/service/recipe_service.go
+++ b/internal/app/service/recipe_service.go
@@ -1,11 +1,7 @@
package service
import (
- "errors"
"fmt"
- "net/http"
- "strconv"
- "strings"
"time"
"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.")
}
- 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)
+ var req domain.CreateRecipeRequest
- // Have to get the image differently
- image, err := ctx.FormFile("image")
- if err != nil && !errors.Is(err, http.ErrMissingFile) {
- // Error getting image
+ if err := ctx.ShouldBindJSON(&req); err != nil {
+ return nil, err
}
- // 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(),
+ Title: req.Title,
+ Description: req.Description,
+ Instructions: req.Instructions,
+ Serves: req.Serves,
+ Difficulty: req.Difficulty,
+ Duration: req.Duration,
+ Category: req.Category,
+ Ingredients: req.Ingredients,
+ Sections: req.Sections,
+ UserId: userId,
+ Created: time.Now(),
}
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
- if image != nil {
- }
+ // if req.image != nil {
+ // }
// Create the tags
- if len(tags) > 0 {
- if err := s.recipeRepository.CreateRecipeTags(recipe, tags); err != nil {
+ if len(req.Tags) > 0 {
+ 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, 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,
@@ -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) {
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.")
}
@@ -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.
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
// ID should be provided. Any errors will be bubbled to the caller.
-func (s *RecipeService) GetUserRecipes(id int) ([]domain.Recipe, error) {
- return s.recipeRepository.GetUserRecipes(id)
+func (s *RecipeService) GetUserRecipes(userId int) ([]domain.Recipe, error) {
+ 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
// 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) {
- return s.recipeRepository.GetUserFavoriteRecipes(id)
+func (s *RecipeService) GetUserFavoriteRecipes(userId int) ([]domain.Recipe, error) {
+ 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
@@ -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,
// the recipe will be nil. Any errors will be bubbled to the caller.
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)
}
diff --git a/internal/domain/recipe/recipe.go b/internal/domain/recipe/recipe.go
index bad14a2..52ddbd6 100644
--- a/internal/domain/recipe/recipe.go
+++ b/internal/domain/recipe/recipe.go
@@ -5,9 +5,9 @@ import "time"
// 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).
type RecipeDuration struct {
- Total int `json:"total"`
- Prep int `json:"prep"`
- Cook int `json:"cook"`
+ Total int `json:"Total"`
+ Prep int `json:"Prep"`
+ Cook int `json:"Cook"`
}
// 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
// to be marshaled into a JSON array and stored in the database (JSONB).
type RecipeIngredient struct {
- Name string `json:"name"`
- Quantity string `json:"quantity"`
+ Id string `json:"Id"`
+ 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
@@ -61,12 +112,13 @@ type Recipe struct {
Id int
Title string
Description string
- Instructions []string
+ Instructions []RecipeInstruction
Serves int
Difficulty int
Duration RecipeDuration
Category RecipeMeal
- Ingredients []RecipeIngredient // Just a list of ingredients
+ Ingredients []RecipeIngredient
+ Sections []RecipeIngredientSection
UserId int
Modified *time.Time // Pointer to allow null
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
// details can be found in the SearchRecipes service function.
type SearchFilters struct {
- Search string
- MealType int
- Time int
- Difficulty int
- ServingSize int
+ Search string `json:"Search"`
+ MealType int `json:"MealType"`
+ Time int `json:"Time"`
+ Difficulty int `json:"Difficulty"`
+ 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
@@ -101,3 +154,17 @@ type RecipeTag struct {
TagId int
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
+}
diff --git a/internal/domain/recipe/repository.go b/internal/domain/recipe/repository.go
index 8574035..93de1f4 100644
--- a/internal/domain/recipe/repository.go
+++ b/internal/domain/recipe/repository.go
@@ -4,11 +4,11 @@ type RecipeRepository interface {
CreateRecipe(recipe *Recipe) error
GetRecipe(id 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
- GetUserRecipes(id int) ([]Recipe, error)
- GetUserFavoriteRecipes(id int) ([]Recipe, error)
+ GetUserRecipesIds(userId int) ([]int, error)
+ GetUserFavoriteRecipesIds(userId int) ([]int, error)
GetRecipeTags(recipe *Recipe) error
GetRecipeFavorite(recipe *Recipe, userId int) error
- GetRecipeOfTheWeek(userId *int) (*Recipe, error)
+ GetRecipeOfTheWeekId(userId *int) (*int, error)
}
diff --git a/internal/domain/recipe/service.go b/internal/domain/recipe/service.go
index 91bf12f..9c6a75f 100644
--- a/internal/domain/recipe/service.go
+++ b/internal/domain/recipe/service.go
@@ -8,8 +8,8 @@ type RecipeService interface {
CreateRecipe(ctx *gin.Context) (*Recipe, error)
GetRecipe(id int, userId *int) (*Recipe, error)
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error)
- GetUserRecipes(id int) ([]Recipe, error)
- GetUserFavoriteRecipes(id int) ([]Recipe, error)
+ GetUserRecipes(userId int) ([]Recipe, error)
+ GetUserFavoriteRecipes(userId int) ([]Recipe, error)
GetUserViewedRecipes(userId, limit int) ([]Recipe, error)
GetUserMadeRecipes(userId, limit int) ([]Recipe, error)
GetRecipeOfTheWeek(userId *int) (*Recipe, error)
diff --git a/internal/domain/server/routes.go b/internal/domain/server/routes.go
index 19053ce..c65f1af 100644
--- a/internal/domain/server/routes.go
+++ b/internal/domain/server/routes.go
@@ -1,36 +1,38 @@
package domain
// Sub-routes
-const VERSION = "/v1"
+const VERSION_1 = "/v1"
+const VERSION_2 = "/v2"
const WEB = "/web"
const API = "/api"
const STATE = "/state"
// Web prefixed routes
-const WEB_LOGIN = VERSION + WEB + "/login"
-const WEB_INDEX = VERSION + WEB
-const WEB_HOME = VERSION + WEB + "/home"
-const WEB_FAVORITES = VERSION + WEB + "/favorites"
-const WEB_CREATE = VERSION + WEB + "/create"
-const WEB_PROFIlE = VERSION + WEB + "/profile"
-const WEB_LIST = VERSION + WEB + "/list"
-const WEB_RECIPE = VERSION + WEB + "/recipe/%d"
-const WEB_SEARCH = VERSION + WEB + "/search"
-const WEB_NOT_FOUND = VERSION + WEB + "/404"
+const WEB_LOGIN = VERSION_1 + WEB + "/login"
+const WEB_INDEX = VERSION_1 + WEB
+const WEB_HOME = VERSION_1 + WEB + "/home"
+const WEB_FAVORITES = VERSION_1 + WEB + "/favorites"
+const WEB_CREATE = VERSION_1 + WEB + "/create"
+const WEB_PROFIlE = VERSION_1 + WEB + "/profile"
+const WEB_LIST = VERSION_1 + WEB + "/list"
+const WEB_RECIPE = VERSION_1 + WEB + "/recipe/%d"
+const WEB_SEARCH = VERSION_1 + WEB + "/search"
+const WEB_NOT_FOUND = VERSION_1 + WEB + "/404"
// API prefixed routes
-const API_AUTH_LOGIN = VERSION + API + "/auth/login"
-const API_AUTH_CALLBACK = VERSION + API + "/auth/callback"
-const API_AUTH_LOGOUT = VERSION + API + "/auth/logout"
-const API_CREATE_RECIPE = VERSION + API + "/recipe"
-const API_SEARCH_RECIPES = VERSION + API + "/recipe/search"
-const API_SEARCH_FAVORITES = VERSION + API + "/recipe/search/favorites"
+const API_AUTH_LOGIN = VERSION_1 + API + "/auth/login"
+const API_AUTH_CALLBACK = VERSION_1 + API + "/auth/callback"
+const API_AUTH_CALLBACK_V2 = VERSION_2 + API + "/auth/callback"
+const API_AUTH_LOGOUT = VERSION_1 + API + "/auth/logout"
+const API_CREATE_RECIPE = VERSION_1 + API + "/recipe"
+const API_SEARCH_RECIPES = VERSION_1 + API + "/recipe/search"
+const API_SEARCH_FAVORITES = VERSION_1 + API + "/recipe/search/favorites"
-const API_ENGAGEMENT_VIEW = VERSION + API + "/engagement/view/%d"
-const API_ENGAGEMENT_SHARE = VERSION + API + "/engagement/share/%d"
-const API_ENGAGEMENT_FAVORITE = VERSION + API + "/engagement/favorite/%d"
-const API_ENGAGEMENT_MAKE = VERSION + API + "/engagement/make/%d"
+const API_ENGAGEMENT_VIEW = VERSION_1 + API + "/engagement/view/%d"
+const API_ENGAGEMENT_SHARE = VERSION_1 + API + "/engagement/share/%d"
+const API_ENGAGEMENT_FAVORITE = VERSION_1 + API + "/engagement/favorite/%d"
+const API_ENGAGEMENT_MAKE = VERSION_1 + API + "/engagement/make/%d"
// State prefixed routes
-const STATE_TAGS_CREATE = VERSION + WEB + STATE + "/tags"
-const STATE_TAGS_DELETE = VERSION + WEB + STATE + "/tags/delete"
+const STATE_TAGS_CREATE = VERSION_1 + WEB + STATE + "/tags"
+const STATE_TAGS_DELETE = VERSION_1 + WEB + STATE + "/tags/delete"
diff --git a/internal/domain/server/server.go b/internal/domain/server/server.go
index da17824..7afc797 100644
--- a/internal/domain/server/server.go
+++ b/internal/domain/server/server.go
@@ -23,6 +23,7 @@ type EnvironmentConfig struct {
DatabaseUrl string
Environment string
Domain string
+ FrontendDomain string
}
// 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
// Google email provided.
type JwtClaims struct {
- UserId int `json:"id"`
- Email string `json:"email"`
+ UserId int `json:"Id"`
+ Email string `json:"Email"`
jwt.RegisteredClaims
}
@@ -81,16 +82,25 @@ func LoadEnvironment() (*EnvironmentConfig, error) {
}
var domain string
+ var frontendDomain string
if env == "dev" {
domain = os.Getenv("DOMAIN_DEV")
if domain == "" {
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" {
domain = os.Getenv("DOMAIN_PROD")
if domain == "" {
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 {
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,
Environment: env,
Domain: domain,
+ FrontendDomain: frontendDomain,
}
+ fmt.Printf("Environment Config: %+v\n", cfg)
+
return cfg, nil
}
diff --git a/internal/domain/user/user.go b/internal/domain/user/user.go
index ff744a0..0c3b167 100644
--- a/internal/domain/user/user.go
+++ b/internal/domain/user/user.go
@@ -4,13 +4,13 @@ import "time"
// GoogleUserInfo is a data type which contains a mapping of the Google User Info API call.
type GoogleUserInfo struct {
- Id string `json:"id"`
- Email string `json:"email"`
- Verified bool `json:"verified_email"`
- Name string `json:"name"`
- GivenName string `json:"given_name"`
- FamilyName string `json:"family_name"`
- Picture string `json:"picture"`
+ Id string `json:"Id"`
+ Email string `json:"Email"`
+ Verified bool `json:"VerifiedEmail"`
+ Name string `json:"Name"`
+ GivenName string `json:"GivenName"`
+ FamilyName string `json:"FamilyName"`
+ Picture string `json:"Picture"`
}
// User is the database model of a user. There is no need to map to a different model so
diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go
index acc793f..ea8376a 100644
--- a/internal/infrastructure/database/repository/recipe_repository.go
+++ b/internal/infrastructure/database/repository/recipe_repository.go
@@ -46,7 +46,9 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
// NOTE: Data steps
// 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
// use nil for the modified time
@@ -55,17 +57,27 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
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 {
return err
}
+ instructions := make([]string, len(recipe.Instructions))
+ for i, instruction := range recipe.Instructions {
+ instructions[i] = instruction.Content
+ }
+
var id int
if err = tx.QueryRow(
query,
recipe.Title,
recipe.Description,
- pq.Array(recipe.Instructions),
+ pq.Array(instructions),
recipe.Serves,
recipe.Difficulty,
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
// 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) {
- query := `
- SELECT
+ query := ` SELECT
id, title, description, instructions, serves, difficulty, duration, category, ingredients,
userid, modified, created
FROM recipes
@@ -103,6 +114,7 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
`
var durationBytes []byte
+ var instructions pq.StringArray
var ingredientBytes []byte
var recipe domain.Recipe
@@ -110,7 +122,8 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
&recipe.Id,
&recipe.Title,
&recipe.Description,
- pq.Array(&recipe.Instructions),
+ // pq.Array(&instructions),
+ &instructions,
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
@@ -137,16 +150,23 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
// Parse ingredient
if len(ingredientBytes) > 0 {
- var ingredients []domain.RecipeIngredient
- if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
+ var store domain.RecipeIngredientStore
+ 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())
}
- recipe.Ingredients = ingredients
+ recipe.Ingredients = store.Ingredients
+ recipe.Sections = store.Sections
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
+ // Add instructions
+ for _, instruction := range instructions {
+ recipe.Instructions = append(recipe.Instructions, domain.RecipeInstruction{Content: instruction})
+ }
+
// Add tags
if err := r.GetRecipeTags(&recipe); err != nil {
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
// to the caller.
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
- rows, err := r.db.Query(query, pq.Array(ids))
- if err != nil {
- return nil, fmt.Errorf("Failed to get recipes. %s", err.Error())
- }
- 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())
+ for _, id := range ids {
+ recipe, err := r.GetRecipe(id, userId)
+ if err != nil {
+ return nil, err
}
- // Parse duration
- 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{}
+ // Skip any un-found recipes...?
+ if recipe != nil {
+ recipes = append(recipes, *recipe)
}
-
- // 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
@@ -264,7 +219,12 @@ func isBitActive(bits, pos int) bool {
// The favorites parameter is used to only return filters favorited by the userId provided.
//
// 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)
var mealConditions []string
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
columns := []string{
"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
@@ -435,76 +384,21 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
// Execute the query
rows, err := r.db.Query(query)
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()
- var recipes []domain.Recipe
+ var ids []int
for rows.Next() {
- // Parsed values location
- 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 row: %w", err)
+ var id int
+ if err := rows.Scan(&id); err != nil {
+ return []int{}, fmt.Errorf("failed to extract ID: %s\n", err.Error())
}
- // Parse duration from bytes
- 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)
+ ids = append(ids, id)
}
- return recipes, nil
+ return ids, nil
}
// 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
// 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.
-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 := `
- SELECT id, title, description, instructions, serves, difficulty, duration, category, ingredients,
- userid, modified, created
+ SELECT id
FROM recipes
WHERE userid = $1
ORDER BY created DESC;
`
- rows, err := r.db.Query(query, id)
+ rows, err := r.db.Query(query, user_id)
if err != nil {
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error())
}
defer rows.Close()
- // Prepare statement for tag query
- // tagQuery := `
- // `
-
- var recipes []domain.Recipe
+ var ids []int
for rows.Next() {
- var recipe domain.Recipe
- var durationBytes []byte
- var ingredientBytes []byte
-
- // 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())
+ var r_id int
+ if err := rows.Scan(&r_id); err != nil {
+ return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
}
- // Parse duration
- 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)
+ ids = append(ids, r_id)
}
- return recipes, nil
+ return ids, nil
}
// 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
// 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 := `
- SELECT r.id, r.title, r.description, r.instructions, r.serves, r.difficulty, r.duration, r.category, r.ingredients, r.
- userid, r.modified, r.created
+ SELECT r.id
FROM favorites f
JOIN recipes r ON r.id = f.recipeid
WHERE f.userid = $1
@@ -670,66 +511,17 @@ func (r *RecipeRepository) GetUserFavoriteRecipes(id int) ([]domain.Recipe, erro
}
defer rows.Close()
- var recipes []domain.Recipe
+ var ids []int
for rows.Next() {
- var recipe domain.Recipe
- var durationBytes []byte
- var ingredientBytes []byte
-
- // 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())
+ var r_id int
+ if err := rows.Scan(&r_id); err != nil {
+ return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
}
- // Parse duration
- 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)
+ ids = append(ids, r_id)
}
- return recipes, nil
+ return ids, nil
}
// 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
}
-// 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
-// table and return it. If there is no entry, nil will be returned Any errors will be bubbled to
-// the caller.
-func (r *RecipeRepository) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error) {
+// table and return it. If there is no entry, nil will be returned. Any errors will be bubbled to
+// the caller. All that is returned is the recipe ID, that way the caller can handle the fetching.
+func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
query := `
SELECT
- r.id, r.title, r.description, r.instructions, r.serves, r.difficulty, r.duration, r.category,
- r.ingredients, r.userid, r.modified, r.created
+ r.id
FROM recipes r
JOIN recipeoftheweek rw ON rw.recipeid = r.id
- ORDER BY created DESC
+ ORDER BY rw.created DESC
LIMIT 1;
`
- var durationBytes []byte
- var ingredientBytes []byte
-
- 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 {
+ var id int
+ if err := r.db.QueryRow(query).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error())
}
- // Parse duration
- 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
+ return &id, nil
}
diff --git a/internal/templates/components/banner_templ.go b/internal/templates/components/banner_templ.go
index 8d5dc82..f4a8c3e 100644
--- a/internal/templates/components/banner_templ.go
+++ b/internal/templates/components/banner_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/components/cards_templ.go b/internal/templates/components/cards_templ.go
index 4fcd054..0f6f16c 100644
--- a/internal/templates/components/cards_templ.go
+++ b/internal/templates/components/cards_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/components/dropdowns_templ.go b/internal/templates/components/dropdowns_templ.go
index 5f39d15..d749993 100644
--- a/internal/templates/components/dropdowns_templ.go
+++ b/internal/templates/components/dropdowns_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/components/error_templ.go b/internal/templates/components/error_templ.go
index 1bfb31b..5055698 100644
--- a/internal/templates/components/error_templ.go
+++ b/internal/templates/components/error_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/components/navbar_templ.go b/internal/templates/components/navbar_templ.go
index ba562fd..fde40cf 100644
--- a/internal/templates/components/navbar_templ.go
+++ b/internal/templates/components/navbar_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/components/search_bar_templ.go b/internal/templates/components/search_bar_templ.go
index 2e78e1e..92bbc52 100644
--- a/internal/templates/components/search_bar_templ.go
+++ b/internal/templates/components/search_bar_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/layouts/app_layout_templ.go b/internal/templates/layouts/app_layout_templ.go
index 0608ea8..f2fc957 100644
--- a/internal/templates/layouts/app_layout_templ.go
+++ b/internal/templates/layouts/app_layout_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/pages/create_templ.go b/internal/templates/pages/create_templ.go
index 28b33db..30930d8 100644
--- a/internal/templates/pages/create_templ.go
+++ b/internal/templates/pages/create_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/pages/favorites_templ.go b/internal/templates/pages/favorites_templ.go
index e888fc2..0d4c599 100644
--- a/internal/templates/pages/favorites_templ.go
+++ b/internal/templates/pages/favorites_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/pages/home_templ.go b/internal/templates/pages/home_templ.go
index 447abab..26d06ee 100644
--- a/internal/templates/pages/home_templ.go
+++ b/internal/templates/pages/home_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/pages/list_templ.go b/internal/templates/pages/list_templ.go
index 4a91ce0..e10a7d1 100644
--- a/internal/templates/pages/list_templ.go
+++ b/internal/templates/pages/list_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/pages/login_templ.go b/internal/templates/pages/login_templ.go
index 1cb6d29..de877a1 100644
--- a/internal/templates/pages/login_templ.go
+++ b/internal/templates/pages/login_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/pages/notFound_templ.go b/internal/templates/pages/notFound_templ.go
index 9ebcde1..59f71ec 100644
--- a/internal/templates/pages/notFound_templ.go
+++ b/internal/templates/pages/notFound_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/pages/profile_templ.go b/internal/templates/pages/profile_templ.go
index a99005c..afdf4e3 100644
--- a/internal/templates/pages/profile_templ.go
+++ b/internal/templates/pages/profile_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/internal/templates/pages/recipe.templ b/internal/templates/pages/recipe.templ
index 69d6c02..5009f9d 100644
--- a/internal/templates/pages/recipe.templ
+++ b/internal/templates/pages/recipe.templ
@@ -112,7 +112,7 @@ templ ingredientList(ingredients []domain.RecipeIngredient) {
for _, ingredient := range ingredients {
- @ingredientListItem(ingredient.Name, ingredient.Quantity)
+ @ingredientListItem(ingredient.Name, "")
}
@@ -308,7 +308,7 @@ templ RecipePage(recipe domain.Recipe, user domainUser.User, loggedIn bool, doma
{ recipe.Description }
@ingredientList(recipe.Ingredients)
- @instructionList(recipe.Instructions)
+ @instructionList([]string{})
@tagList(recipe.Tags, recipe.Created, recipe.Modified)
diff --git a/internal/templates/pages/recipe_templ.go b/internal/templates/pages/recipe_templ.go
index 7a3ba58..d1a3b13 100644
--- a/internal/templates/pages/recipe_templ.go
+++ b/internal/templates/pages/recipe_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package templates
//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
}
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 {
return templ_7745c5c3_Err
}
@@ -875,7 +875,7 @@ func RecipePage(recipe domain.Recipe, user domainUser.User, loggedIn bool, domai
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
diff --git a/internal/templates/pages/search_templ.go b/internal/templates/pages/search_templ.go
index 2e7d66b..f90dba4 100644
--- a/internal/templates/pages/search_templ.go
+++ b/internal/templates/pages/search_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.943
+// templ: version: v0.3.960
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
diff --git a/shell.nix b/shell.nix
new file mode 100644
index 0000000..b51a1fa
--- /dev/null
+++ b/shell.nix
@@ -0,0 +1,34 @@
+{ pkgs ? import {} }:
+
+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
+ '';
+}
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..50c8dda
--- /dev/null
+++ b/web/.gitignore
@@ -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
diff --git a/web/Dockerfile b/web/Dockerfile
new file mode 100644
index 0000000..99fc94d
--- /dev/null
+++ b/web/Dockerfile
@@ -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"]
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..cb7e169
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,4 @@
+
+# IF BACKEND CANNOT GET COOKIE
+
+Do not forget to send the axios request with the `{ withCredentials: true }` flags.
diff --git a/web/eslint.config.js b/web/eslint.config.js
new file mode 100644
index 0000000..a1279f8
--- /dev/null
+++ b/web/eslint.config.js
@@ -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,
+ },
+ },
+])
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..567acc0
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+ Gophernest - Potion
+
+
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..6df06f1
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,4244 @@
+{
+ "name": "web",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "web",
+ "version": "0.0.0",
+ "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"
+ }
+ },
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
+ "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz",
+ "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz",
+ "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz",
+ "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz",
+ "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz",
+ "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz",
+ "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz",
+ "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz",
+ "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz",
+ "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz",
+ "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz",
+ "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz",
+ "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz",
+ "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz",
+ "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz",
+ "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz",
+ "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz",
+ "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz",
+ "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz",
+ "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz",
+ "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz",
+ "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz",
+ "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz",
+ "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz",
+ "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz",
+ "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
+ "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint-react/ast": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-2.2.4.tgz",
+ "integrity": "sha512-kdG9yMJ2QpEbVPfgvlWqTUAF2L7dZYBAaF8/LPrjDIKB1pCbygxXUoRBPVthxTsE2XTqiLbhPqcWGnI8Q3UNTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/eff": "2.2.4",
+ "@typescript-eslint/types": "^8.46.2",
+ "@typescript-eslint/typescript-estree": "^8.46.2",
+ "@typescript-eslint/utils": "^8.46.2",
+ "string-ts": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@eslint-react/core": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-2.2.4.tgz",
+ "integrity": "sha512-uEfUX2GoIymsBbWccJGpuyz8KCtxyNBxJb2FMyqE37nLtNVPbNsFTHRr6uX1WwkBxw+bUOYDbVDy9zFVbmAJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/ast": "2.2.4",
+ "@eslint-react/eff": "2.2.4",
+ "@eslint-react/shared": "2.2.4",
+ "@eslint-react/var": "2.2.4",
+ "@typescript-eslint/scope-manager": "^8.46.2",
+ "@typescript-eslint/types": "^8.46.2",
+ "@typescript-eslint/utils": "^8.46.2",
+ "birecord": "^0.1.1",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@eslint-react/eff": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/@eslint-react/eff/-/eff-2.2.4.tgz",
+ "integrity": "sha512-I26FQr5IEjJDXlcuyL1h/shmUdyyAXZrG+Op/E0Lc6cpGvXg5hn1ptcdKJ23o8BAxq2UY2gwyltGxE2t4ixoJQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@eslint-react/shared": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-2.2.4.tgz",
+ "integrity": "sha512-jDL17njTyVj/cmveNThHtLLJpHqLRd/z76q+38Zcq+kiA3DfZ8mXyy+EYV4lLwD9dvg1FOMomHBTgV/woqWsRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/eff": "2.2.4",
+ "@typescript-eslint/utils": "^8.46.2",
+ "ts-pattern": "^5.9.0",
+ "zod": "^4.1.12"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@eslint-react/var": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-2.2.4.tgz",
+ "integrity": "sha512-MBh64lfHI6Cr2qjaYlJx7x3FcYqgGK9SSB5/7weRsxv63ZfGiJY+aRi0ahSGsE2JhM0/OhWu0T6T1z4nnEbQxA==",
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/ast": "2.2.4",
+ "@eslint-react/eff": "2.2.4",
+ "@typescript-eslint/scope-manager": "^8.46.2",
+ "@typescript-eslint/types": "^8.46.2",
+ "@typescript-eslint/utils": "^8.46.2",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
+ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers/node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
+ "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
+ "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.38.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz",
+ "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.43",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz",
+ "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz",
+ "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz",
+ "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz",
+ "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz",
+ "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz",
+ "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz",
+ "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz",
+ "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz",
+ "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz",
+ "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz",
+ "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz",
+ "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz",
+ "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz",
+ "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz",
+ "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz",
+ "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz",
+ "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz",
+ "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz",
+ "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz",
+ "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz",
+ "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz",
+ "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz",
+ "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@swc/core": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.14.0.tgz",
+ "integrity": "sha512-oExhY90bes5pDTVrei0xlMVosTxwd/NMafIpqsC4dMbRYZ5KB981l/CX8tMnGsagTplj/RcG9BeRYmV6/J5m3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "@swc/types": "^0.1.25"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/swc"
+ },
+ "optionalDependencies": {
+ "@swc/core-darwin-arm64": "1.14.0",
+ "@swc/core-darwin-x64": "1.14.0",
+ "@swc/core-linux-arm-gnueabihf": "1.14.0",
+ "@swc/core-linux-arm64-gnu": "1.14.0",
+ "@swc/core-linux-arm64-musl": "1.14.0",
+ "@swc/core-linux-x64-gnu": "1.14.0",
+ "@swc/core-linux-x64-musl": "1.14.0",
+ "@swc/core-win32-arm64-msvc": "1.14.0",
+ "@swc/core-win32-ia32-msvc": "1.14.0",
+ "@swc/core-win32-x64-msvc": "1.14.0"
+ },
+ "peerDependencies": {
+ "@swc/helpers": ">=0.5.17"
+ },
+ "peerDependenciesMeta": {
+ "@swc/helpers": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@swc/core-darwin-arm64": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.14.0.tgz",
+ "integrity": "sha512-uHPC8rlCt04nvYNczWzKVdgnRhxCa3ndKTBBbBpResOZsRmiwRAvByIGh599j+Oo6Z5eyTPrgY+XfJzVmXnN7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-darwin-x64": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.14.0.tgz",
+ "integrity": "sha512-2SHrlpl68vtePRknv9shvM9YKKg7B9T13tcTg9aFCwR318QTYo+FzsKGmQSv9ox/Ua0Q2/5y2BNjieffJoo4nA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm-gnueabihf": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.14.0.tgz",
+ "integrity": "sha512-SMH8zn01dxt809svetnxpeg/jWdpi6dqHKO3Eb11u4OzU2PK7I5uKS6gf2hx5LlTbcJMFKULZiVwjlQLe8eqtg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-gnu": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.14.0.tgz",
+ "integrity": "sha512-q2JRu2D8LVqGeHkmpVCljVNltG0tB4o4eYg+dElFwCS8l2Mnt9qurMCxIeo9mgoqz0ax+k7jWtIRHktnVCbjvQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-musl": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.14.0.tgz",
+ "integrity": "sha512-uofpVoPCEUjYIv454ZEZ3sLgMD17nIwlz2z7bsn7rl301Kt/01umFA7MscUovFfAK2IRGck6XB+uulMu6aFhKQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-gnu": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.14.0.tgz",
+ "integrity": "sha512-quTTx1Olm05fBfv66DEBuOsOgqdypnZ/1Bh3yGXWY7ANLFeeRpCDZpljD9BSjdsNdPOlwJmEUZXMHtGm3v1TZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-musl": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.14.0.tgz",
+ "integrity": "sha512-caaNAu+aIqT8seLtCf08i8C3/UC5ttQujUjejhMcuS1/LoCKtNiUs4VekJd2UGt+pyuuSrQ6dKl8CbCfWvWeXw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-arm64-msvc": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.14.0.tgz",
+ "integrity": "sha512-EeW3jFlT3YNckJ6V/JnTfGcX7UHGyh6/AiCPopZ1HNaGiXVCKHPpVQZicmtyr/UpqxCXLrTgjHOvyMke7YN26A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-ia32-msvc": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.14.0.tgz",
+ "integrity": "sha512-dPai3KUIcihV5hfoO4QNQF5HAaw8+2bT7dvi8E5zLtecW2SfL3mUZipzampXq5FHll0RSCLzlrXnSx+dBRZIIQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-x64-msvc": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.14.0.tgz",
+ "integrity": "sha512-nm+JajGrTqUA6sEHdghDlHMNfH1WKSiuvljhdmBACW4ta4LC3gKurX2qZuiBARvPkephW9V/i5S8QPY1PzFEqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@swc/types": {
+ "version": "0.1.25",
+ "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz",
+ "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
+ "integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "enhanced-resolve": "^5.18.3",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.30.2",
+ "magic-string": "^0.30.19",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.16"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz",
+ "integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.16",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.16",
+ "@tailwindcss/oxide-darwin-x64": "4.1.16",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.16",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.16",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.16",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.16",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.16",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.16"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz",
+ "integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz",
+ "integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz",
+ "integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz",
+ "integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz",
+ "integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz",
+ "integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz",
+ "integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz",
+ "integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz",
+ "integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz",
+ "integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.5.0",
+ "@emnapi/runtime": "^1.5.0",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.0.7",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz",
+ "integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz",
+ "integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.16.tgz",
+ "integrity": "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.1.16",
+ "@tailwindcss/oxide": "4.1.16",
+ "tailwindcss": "4.1.16"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/hoist-non-react-statics": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz",
+ "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
+ "license": "MIT",
+ "dependencies": {
+ "hoist-non-react-statics": "^3.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.9.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz",
+ "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.2",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
+ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.2",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
+ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.46.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
+ "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "8.46.2",
+ "@typescript-eslint/type-utils": "8.46.2",
+ "@typescript-eslint/utils": "8.46.2",
+ "@typescript-eslint/visitor-keys": "8.46.2",
+ "graphemer": "^1.4.0",
+ "ignore": "^7.0.0",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.46.2",
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.46.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz",
+ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.46.2",
+ "@typescript-eslint/types": "8.46.2",
+ "@typescript-eslint/typescript-estree": "8.46.2",
+ "@typescript-eslint/visitor-keys": "8.46.2",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.46.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz",
+ "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.46.2",
+ "@typescript-eslint/types": "^8.46.2",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.46.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz",
+ "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.46.2",
+ "@typescript-eslint/visitor-keys": "8.46.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.46.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz",
+ "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.46.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz",
+ "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==",
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.46.2",
+ "@typescript-eslint/typescript-estree": "8.46.2",
+ "@typescript-eslint/utils": "8.46.2",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.46.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz",
+ "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.46.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz",
+ "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.46.2",
+ "@typescript-eslint/tsconfig-utils": "8.46.2",
+ "@typescript-eslint/types": "8.46.2",
+ "@typescript-eslint/visitor-keys": "8.46.2",
+ "debug": "^4.3.4",
+ "fast-glob": "^3.3.2",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.46.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz",
+ "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==",
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.7.0",
+ "@typescript-eslint/scope-manager": "8.46.2",
+ "@typescript-eslint/types": "8.46.2",
+ "@typescript-eslint/typescript-estree": "8.46.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.46.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz",
+ "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==",
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.46.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitejs/plugin-react-swc": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.0.tgz",
+ "integrity": "sha512-/tesahXD1qpkGC6FzMoFOJj0RyZdw9xLELOL+6jbElwmWfwOnIVy+IfpY+o9JfD9PKaR/Eyb6DNrvbXpuvA+8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-beta.43",
+ "@swc/core": "^1.13.5"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4 || ^5 || ^6 || ^7"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
+ "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "license": "MIT"
+ },
+ "node_modules/birecord": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/birecord/-/birecord-0.1.1.tgz",
+ "integrity": "sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==",
+ "license": "(MIT OR Apache-2.0)"
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/compare-versions": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
+ "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==",
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
+ "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "license": "MIT"
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.18.3",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
+ "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.11",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz",
+ "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.11",
+ "@esbuild/android-arm": "0.25.11",
+ "@esbuild/android-arm64": "0.25.11",
+ "@esbuild/android-x64": "0.25.11",
+ "@esbuild/darwin-arm64": "0.25.11",
+ "@esbuild/darwin-x64": "0.25.11",
+ "@esbuild/freebsd-arm64": "0.25.11",
+ "@esbuild/freebsd-x64": "0.25.11",
+ "@esbuild/linux-arm": "0.25.11",
+ "@esbuild/linux-arm64": "0.25.11",
+ "@esbuild/linux-ia32": "0.25.11",
+ "@esbuild/linux-loong64": "0.25.11",
+ "@esbuild/linux-mips64el": "0.25.11",
+ "@esbuild/linux-ppc64": "0.25.11",
+ "@esbuild/linux-riscv64": "0.25.11",
+ "@esbuild/linux-s390x": "0.25.11",
+ "@esbuild/linux-x64": "0.25.11",
+ "@esbuild/netbsd-arm64": "0.25.11",
+ "@esbuild/netbsd-x64": "0.25.11",
+ "@esbuild/openbsd-arm64": "0.25.11",
+ "@esbuild/openbsd-x64": "0.25.11",
+ "@esbuild/openharmony-arm64": "0.25.11",
+ "@esbuild/sunos-x64": "0.25.11",
+ "@esbuild/win32-arm64": "0.25.11",
+ "@esbuild/win32-ia32": "0.25.11",
+ "@esbuild/win32-x64": "0.25.11"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.38.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
+ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.1",
+ "@eslint/config-helpers": "^0.4.1",
+ "@eslint/core": "^0.16.0",
+ "@eslint/eslintrc": "^3.3.1",
+ "@eslint/js": "9.38.0",
+ "@eslint/plugin-kit": "^0.4.0",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-dom": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-2.2.4.tgz",
+ "integrity": "sha512-mrr56eZsNF0m6NrZxV3wojQnxADLqYGB0A5FHYRuMEX8jmkOy0Jb7v6B4IdzLt0kI1HhAhriOogxOkFlCch/4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/ast": "2.2.4",
+ "@eslint-react/core": "2.2.4",
+ "@eslint-react/eff": "2.2.4",
+ "@eslint-react/shared": "2.2.4",
+ "@eslint-react/var": "2.2.4",
+ "@typescript-eslint/scope-manager": "^8.46.2",
+ "@typescript-eslint/types": "^8.46.2",
+ "@typescript-eslint/utils": "^8.46.2",
+ "compare-versions": "^6.1.1",
+ "string-ts": "^2.2.1",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "eslint": "^9.38.0",
+ "typescript": "^5.9.3"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
+ "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz",
+ "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=8.40"
+ }
+ },
+ "node_modules/eslint-plugin-react-x": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-2.2.4.tgz",
+ "integrity": "sha512-mdoxE1SPt653/udAGQvwPob7ZgaPjIk47G0MWNwKtzLyuI0oD5X+6uq1QAn99TOM1q+sqjsCvvL7aMwGDuZ6aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/ast": "2.2.4",
+ "@eslint-react/core": "2.2.4",
+ "@eslint-react/eff": "2.2.4",
+ "@eslint-react/shared": "2.2.4",
+ "@eslint-react/var": "2.2.4",
+ "@typescript-eslint/scope-manager": "^8.46.2",
+ "@typescript-eslint/type-utils": "^8.46.2",
+ "@typescript-eslint/types": "^8.46.2",
+ "@typescript-eslint/utils": "^8.46.2",
+ "compare-versions": "^6.1.1",
+ "is-immutable-type": "^5.0.1",
+ "string-ts": "^2.2.1",
+ "ts-api-utils": "^2.1.0",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "eslint": "^9.38.0",
+ "typescript": "^5.9.3"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
+ "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.19.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
+ "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "license": "ISC"
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/framer-motion": {
+ "version": "12.23.25",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.25.tgz",
+ "integrity": "sha512-gUHGl2e4VG66jOcH0JHhuJQr6ZNwrET9g31ZG0xdXzT0CznP7fHX4P8Bcvuc4MiUB90ysNnWX2ukHRIggkl6hQ==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.23.23",
+ "motion-utils": "^12.23.6",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "16.4.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
+ "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "react-is": "^16.7.0"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-immutable-type": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/is-immutable-type/-/is-immutable-type-5.0.1.tgz",
+ "integrity": "sha512-LkHEOGVZZXxGl8vDs+10k3DvP++SEoYEAJLRk6buTFi6kD7QekThV7xHS0j6gpnUCQ0zpud/gMDGiV4dQneLTg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@typescript-eslint/type-utils": "^8.0.0",
+ "ts-api-utils": "^2.0.0",
+ "ts-declaration-location": "^1.0.4"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": ">=4.7.4"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "license": "MIT"
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.30.2",
+ "lightningcss-darwin-arm64": "1.30.2",
+ "lightningcss-darwin-x64": "1.30.2",
+ "lightningcss-freebsd-x64": "1.30.2",
+ "lightningcss-linux-arm-gnueabihf": "1.30.2",
+ "lightningcss-linux-arm64-gnu": "1.30.2",
+ "lightningcss-linux-arm64-musl": "1.30.2",
+ "lightningcss-linux-x64-gnu": "1.30.2",
+ "lightningcss-linux-x64-musl": "1.30.2",
+ "lightningcss-win32-arm64-msvc": "1.30.2",
+ "lightningcss-win32-x64-msvc": "1.30.2"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+ "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+ "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+ "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+ "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+ "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+ "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+ "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+ "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+ "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+ "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+ "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/motion": {
+ "version": "12.23.25",
+ "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.25.tgz",
+ "integrity": "sha512-Fk5Y1kcgxYiTYOUjmwfXQAP7tP+iGqw/on1UID9WEL/6KpzxPr9jY2169OsjgZvXJdpraKXy0orkjaCVIl5fgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "framer-motion": "^12.23.25",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "12.23.23",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
+ "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.23.6"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.23.6",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
+ "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "license": "MIT"
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
+ "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-cookie": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-8.0.1.tgz",
+ "integrity": "sha512-QNdAd0MLuAiDiLcDU/2s/eyKmmfMHtjPUKJ2dZ/5CcQ9QKUium4B3o61/haq6PQl/YWFqC5PO8GvxeHKhy3GFA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hoist-non-react-statics": "^3.3.6",
+ "hoist-non-react-statics": "^3.3.2",
+ "universal-cookie": "^8.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.3.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
+ "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/react-router": {
+ "version": "7.9.5",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz",
+ "integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.9.5",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.5.tgz",
+ "integrity": "sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.9.5"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz",
+ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.52.5",
+ "@rollup/rollup-android-arm64": "4.52.5",
+ "@rollup/rollup-darwin-arm64": "4.52.5",
+ "@rollup/rollup-darwin-x64": "4.52.5",
+ "@rollup/rollup-freebsd-arm64": "4.52.5",
+ "@rollup/rollup-freebsd-x64": "4.52.5",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.52.5",
+ "@rollup/rollup-linux-arm-musleabihf": "4.52.5",
+ "@rollup/rollup-linux-arm64-gnu": "4.52.5",
+ "@rollup/rollup-linux-arm64-musl": "4.52.5",
+ "@rollup/rollup-linux-loong64-gnu": "4.52.5",
+ "@rollup/rollup-linux-ppc64-gnu": "4.52.5",
+ "@rollup/rollup-linux-riscv64-gnu": "4.52.5",
+ "@rollup/rollup-linux-riscv64-musl": "4.52.5",
+ "@rollup/rollup-linux-s390x-gnu": "4.52.5",
+ "@rollup/rollup-linux-x64-gnu": "4.52.5",
+ "@rollup/rollup-linux-x64-musl": "4.52.5",
+ "@rollup/rollup-openharmony-arm64": "4.52.5",
+ "@rollup/rollup-win32-arm64-msvc": "4.52.5",
+ "@rollup/rollup-win32-ia32-msvc": "4.52.5",
+ "@rollup/rollup-win32-x64-gnu": "4.52.5",
+ "@rollup/rollup-win32-x64-msvc": "4.52.5",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/string-ts": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/string-ts/-/string-ts-2.2.1.tgz",
+ "integrity": "sha512-Q2u0gko67PLLhbte5HmPfdOjNvUKbKQM+mCNQae6jE91DmoFHY6HH9GcdqCeNx87DZ2KKjiFxmA0R/42OneGWw==",
+ "license": "MIT"
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
+ "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
+ "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/ts-declaration-location": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/ts-declaration-location/-/ts-declaration-location-1.0.7.tgz",
+ "integrity": "sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==",
+ "funding": [
+ {
+ "type": "ko-fi",
+ "url": "https://ko-fi.com/rebeccastevens"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/ts-declaration-location"
+ }
+ ],
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "picomatch": "^4.0.2"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.0.0"
+ }
+ },
+ "node_modules/ts-declaration-location/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/ts-pattern": {
+ "version": "5.9.0",
+ "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz",
+ "integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==",
+ "license": "MIT"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.46.2",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz",
+ "integrity": "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.46.2",
+ "@typescript-eslint/parser": "8.46.2",
+ "@typescript-eslint/typescript-estree": "8.46.2",
+ "@typescript-eslint/utils": "8.46.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/universal-cookie": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-8.0.1.tgz",
+ "integrity": "sha512-B6ks9FLLnP1UbPPcveOidfvB9pHjP+wekP2uRYB9YDfKVpvcjKgy1W5Zj+cEXJ9KTPnqOKGfVDQBmn8/YCQfRg==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.2"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.1.12",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
+ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
+ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..8ff4695
--- /dev/null
+++ b/web/package.json
@@ -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"
+ }
+}
diff --git a/web/public/vite.svg b/web/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/web/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/src/App.tsx b/web/src/App.tsx
new file mode 100644
index 0000000..a4ef3b0
--- /dev/null
+++ b/web/src/App.tsx
@@ -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
+ }
+
+ if (isLoggedIn) return children;
+
+ // Redirect to login page if not authenicated
+ return
+}
+
+function App() {
+ return (
+
+
+ } />
+
+ {/* Login page does not inherit WebLayout */}
+ } />
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ } />
+
+
+
+ {/* 404: Not Found */}
+ }>
+ } />
+
+
+
+ );
+}
+
+export default App
diff --git a/web/static/img/recipe_placeholder.png b/web/src/assets/images/recipe_placeholder.png
similarity index 100%
rename from web/static/img/recipe_placeholder.png
rename to web/src/assets/images/recipe_placeholder.png
diff --git a/web/static/img/recipe_placeholder_wide.jpg b/web/src/assets/images/recipe_placeholder_wide.jpg
similarity index 100%
rename from web/static/img/recipe_placeholder_wide.jpg
rename to web/src/assets/images/recipe_placeholder_wide.jpg
diff --git a/web/src/assets/react.svg b/web/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/web/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/static/img/salmon_video.mp4 b/web/src/assets/videos/salmon_video.mp4
similarity index 100%
rename from web/static/img/salmon_video.mp4
rename to web/src/assets/videos/salmon_video.mp4
diff --git a/web/src/components/Banner.tsx b/web/src/components/Banner.tsx
new file mode 100644
index 0000000..f7f2fe6
--- /dev/null
+++ b/web/src/components/Banner.tsx
@@ -0,0 +1,12 @@
+
+interface BannerProps {
+ content: string;
+};
+
+export default function Banner({ content }: BannerProps) {
+ return (
+
+ {content}
+
+ );
+}
diff --git a/web/src/components/Navigation.tsx b/web/src/components/Navigation.tsx
new file mode 100644
index 0000000..9efb742
--- /dev/null
+++ b/web/src/components/Navigation.tsx
@@ -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(false);
+
+ const location = useLocation();
+
+ return (
+ <>
+
+
+ >
+
+ );
+
+}
+
+interface HamburgerMenuProps {
+ show: boolean;
+};
+
+function HamburgerMenu({ show }: HamburgerMenuProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+interface DropdownLinkProps {
+ name: string;
+ url: string;
+}
+
+function DropdownLink({ name, url }: DropdownLinkProps) {
+ return (
+
+ {name}
+
+ );
+};
+
+interface NavigationLinkProps {
+ name: string;
+ url: string;
+ current: boolean;
+}
+
+function NavigationLink({ name, url, current }: NavigationLinkProps) {
+ return (
+
+ {name}
+
+ );
+}
+
+interface IconNavigationLinkProps {
+ icon: React.ReactElement;
+ url: string;
+}
+
+function IconNavigationLink({ icon, url }: IconNavigationLinkProps) {
+ return (
+
+ {icon}
+
+ );
+}
diff --git a/web/src/components/Spinner.tsx b/web/src/components/Spinner.tsx
new file mode 100644
index 0000000..e8c536e
--- /dev/null
+++ b/web/src/components/Spinner.tsx
@@ -0,0 +1,12 @@
+interface SpinnerProps {
+ content: string;
+}
+
+export default function Spinner({ content }: SpinnerProps) {
+ return (
+ <>
+
+ {content}
+ >
+ );
+}
diff --git a/web/src/components/buttons/DropdownButton.tsx b/web/src/components/buttons/DropdownButton.tsx
new file mode 100644
index 0000000..f1de8b3
--- /dev/null
+++ b/web/src/components/buttons/DropdownButton.tsx
@@ -0,0 +1,19 @@
+
+interface DropdownButtonProps {
+ content: string;
+ name: string;
+ value: string;
+ selected: boolean;
+ changeHandler: (e: React.ChangeEvent) => void;
+}
+
+export default function DropdownButton({ content, name, value, selected, changeHandler }: DropdownButtonProps) {
+ return (
+
+ );
+}
diff --git a/web/src/components/buttons/FavoriteButton.tsx b/web/src/components/buttons/FavoriteButton.tsx
new file mode 100644
index 0000000..d7a71be
--- /dev/null
+++ b/web/src/components/buttons/FavoriteButton.tsx
@@ -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();
+
+ 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 ? (
+
+
+ ) : (
+
+
+ );
+}
diff --git a/web/src/components/buttons/FilterButton.tsx b/web/src/components/buttons/FilterButton.tsx
new file mode 100644
index 0000000..86bdfca
--- /dev/null
+++ b/web/src/components/buttons/FilterButton.tsx
@@ -0,0 +1,36 @@
+
+interface FilterButtonProps {
+ click: () => void;
+}
+
+export default function FilterButton({ click }: FilterButtonProps) {
+
+ return (
+
+ );
+}
diff --git a/web/src/components/buttons/LikeButton.tsx b/web/src/components/buttons/LikeButton.tsx
new file mode 100644
index 0000000..d2ffe83
--- /dev/null
+++ b/web/src/components/buttons/LikeButton.tsx
@@ -0,0 +1,10 @@
+export default function LikeButton() {
+ return (
+
+ );
+}
diff --git a/web/src/components/buttons/MadeButton.tsx b/web/src/components/buttons/MadeButton.tsx
new file mode 100644
index 0000000..d0d5165
--- /dev/null
+++ b/web/src/components/buttons/MadeButton.tsx
@@ -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(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 (
+
+
+ );
+}
diff --git a/web/src/components/buttons/ShareButton.tsx b/web/src/components/buttons/ShareButton.tsx
new file mode 100644
index 0000000..08e886e
--- /dev/null
+++ b/web/src/components/buttons/ShareButton.tsx
@@ -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(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 ? (
+
+ ) : (
+
+ );
+}
diff --git a/web/src/components/cards/ContentCardSmall.tsx b/web/src/components/cards/ContentCardSmall.tsx
new file mode 100644
index 0000000..882b7ad
--- /dev/null
+++ b/web/src/components/cards/ContentCardSmall.tsx
@@ -0,0 +1,17 @@
+
+interface ContentCardSmallProps {
+ content: string;
+ target: string;
+};
+
+export default function ContentCardSmall({ content, target }: ContentCardSmallProps) {
+ return (
+
+ );
+}
diff --git a/web/src/components/cards/RecipeCardLarge.tsx b/web/src/components/cards/RecipeCardLarge.tsx
new file mode 100644
index 0000000..00da616
--- /dev/null
+++ b/web/src/components/cards/RecipeCardLarge.tsx
@@ -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 Coming soon!
+ }
+
+ return (
+
+

+
+
+ {recipe.Title}
+
+
+ Serves {recipe.Serves}
+
+
+ {recipe.Description}
+
+
+
+ {recipe.Category} - {recipe.Duration.Total} mins
+
+ {recipe.Favorite &&
}
+
+
+
+
+ );
+}
diff --git a/web/src/components/cards/RecipeCardSmall.tsx b/web/src/components/cards/RecipeCardSmall.tsx
new file mode 100644
index 0000000..e8a86b0
--- /dev/null
+++ b/web/src/components/cards/RecipeCardSmall.tsx
@@ -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 (
+
+

+
+
+ {recipe.Title}
+
+
+ Serves {recipe.Serves}
+
+
+
+ {recipe.Category} - {recipe.Duration.Total} mins
+
+ {recipe.Favorite &&
}
+
+
+
+
+ );
+}
diff --git a/web/src/components/display/RecipeMetaData.tsx b/web/src/components/display/RecipeMetaData.tsx
new file mode 100644
index 0000000..bbaccad
--- /dev/null
+++ b/web/src/components/display/RecipeMetaData.tsx
@@ -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 (
+
+
+
+
Prep: {recipe?.Duration.Prep ?? 0} min
+
Cook: {recipe?.Duration.Cook ?? 0} min
+
+
+
+ {Array.from({ length: recipe?.Difficulty ?? 0 }).map((_, i) => (
+
+ ))}
+ {Array.from({ length: 5 - (recipe?.Difficulty ?? 0) }).map((_, i) => (
+
+ ))}
+
+
{displayDifficulty(recipe?.Difficulty ?? 0)}
+
+
+
+
Serves {recipe?.Serves ?? 0}
+
+
+
+ );
+}
diff --git a/web/src/components/forms/IngredientItem.tsx b/web/src/components/forms/IngredientItem.tsx
new file mode 100644
index 0000000..a6174b7
--- /dev/null
+++ b/web/src/components/forms/IngredientItem.tsx
@@ -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 (
+
+
+
+
+
+
+
{
+ e.preventDefault();
+ controls.start(e);
+ }}
+ className="p-1 md:p-0 cursor-pointer touch-none"
+ >
+
+
+
+
+
+ {(dirty && !valid) && (
+ Please fill out all fields.
+ )}
+
+ );
+}
diff --git a/web/src/components/forms/IngredientList.tsx b/web/src/components/forms/IngredientList.tsx
new file mode 100644
index 0000000..8d36b29
--- /dev/null
+++ b/web/src/components/forms/IngredientList.tsx
@@ -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;
+ 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 (
+
+ {sectionIngredients.map(ingredient =>
+ 1}
+ valid={validList.find(x => x.id === ingredient.Id)?.valid ?? true}
+ dirty={dirtyList[ingredient.Id] ?? false}
+ markDirty={markDirty}
+ />
+ )}
+
+ );
+}
diff --git a/web/src/components/forms/IngredientSection.tsx b/web/src/components/forms/IngredientSection.tsx
new file mode 100644
index 0000000..1748a1a
--- /dev/null
+++ b/web/src/components/forms/IngredientSection.tsx
@@ -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 (
+
+
+
Group:
+
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"
+ />
+
+
+
+
{
+ e.preventDefault();
+ controls.start(e);
+ }}
+ >
+
+
+
+
+ {children}
+
+ );
+}
+
diff --git a/web/src/components/forms/InstructionElement.tsx b/web/src/components/forms/InstructionElement.tsx
new file mode 100644
index 0000000..79c8c0b
--- /dev/null
+++ b/web/src/components/forms/InstructionElement.tsx
@@ -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) => {
+ if (!dirty) markDirty(instruction.Id)
+ onChange(instruction.Id, e.target.value);
+ }
+
+ return (
+
+
+
{index + 1}.
+
+
+ {(!valid && dirty) && (
+
+ Please enter an instruction (blank entries are not allowed).
+
+ )}
+
+
+
+
+
{
+ e.preventDefault();
+ controls.start(e);
+ }}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/forms/InstructionList.tsx b/web/src/components/forms/InstructionList.tsx
new file mode 100644
index 0000000..498653b
--- /dev/null
+++ b/web/src/components/forms/InstructionList.tsx
@@ -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>;
+ validList: RecipeValidationEntry[];
+ dirtyList: Record;
+ 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 (
+
+ {instructions.map((instruction, i) => (
+ 1}
+ onChange={handleChange}
+ onDelete={handleDelete}
+ valid={validList.find(x => x.id === instruction.Id)?.valid ?? true}
+ dirty={dirtyList[instruction.Id] ?? false}
+ markDirty={markDirty}
+ />
+ ))}
+
+ );
+}
diff --git a/web/src/components/forms/ValidationErrorList.tsx b/web/src/components/forms/ValidationErrorList.tsx
new file mode 100644
index 0000000..6bb73ff
--- /dev/null
+++ b/web/src/components/forms/ValidationErrorList.tsx
@@ -0,0 +1,44 @@
+import type { CreateRecipeFormEntries } from "../../pages/Create";
+
+interface ValidationErrorListProps {
+ validation: CreateRecipeFormEntries;
+}
+
+const MESSAGES: Record = {
+ 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 (
+
+ {Object.entries(validation)
+ .filter(([, isValid]) => !isValid)
+ .map(([name]) => {
+ const key = name as keyof CreateRecipeFormEntries;
+ return (
+
+ {MESSAGES[key]}
+
+ );
+ })}
+ {validation.ingredients.filter(x => !x.valid).length > 0 && (
+
+ {MESSAGES.ingredients}
+
+ )}
+ {validation.instructions.filter(x => !x.valid).length > 0 && (
+
+ {MESSAGES.instructions}
+
+ )}
+
+ );
+}
diff --git a/web/src/components/icons/DeleteIconSmall.tsx b/web/src/components/icons/DeleteIconSmall.tsx
new file mode 100644
index 0000000..4039fec
--- /dev/null
+++ b/web/src/components/icons/DeleteIconSmall.tsx
@@ -0,0 +1,7 @@
+export default function DeleteIconSmall() {
+ return (
+
+ );
+}
diff --git a/web/src/components/icons/DragIconSmall.tsx b/web/src/components/icons/DragIconSmall.tsx
new file mode 100644
index 0000000..1e626f2
--- /dev/null
+++ b/web/src/components/icons/DragIconSmall.tsx
@@ -0,0 +1,7 @@
+export default function DragIconSmall() {
+ return (
+
+ );
+}
diff --git a/web/src/components/icons/ServingSizeIcon.tsx b/web/src/components/icons/ServingSizeIcon.tsx
new file mode 100644
index 0000000..02c9e3a
--- /dev/null
+++ b/web/src/components/icons/ServingSizeIcon.tsx
@@ -0,0 +1,28 @@
+
+export default function ServingSizeIcon() {
+ return (
+
+ );
+}
diff --git a/web/src/components/icons/ServingSizeIconSmall.tsx b/web/src/components/icons/ServingSizeIconSmall.tsx
new file mode 100644
index 0000000..6b5b342
--- /dev/null
+++ b/web/src/components/icons/ServingSizeIconSmall.tsx
@@ -0,0 +1,16 @@
+
+export default function ServingSizeIconSmall() {
+ return <>
+
+ >
+}
diff --git a/web/src/components/icons/ShoppingListIcon.tsx b/web/src/components/icons/ShoppingListIcon.tsx
new file mode 100644
index 0000000..b7da984
--- /dev/null
+++ b/web/src/components/icons/ShoppingListIcon.tsx
@@ -0,0 +1,19 @@
+
+interface ShoppingListIconProps {
+ current: boolean;
+};
+
+export default function ShoppingListIcon({ current }: ShoppingListIconProps) {
+ return (
+
+ );
+}
diff --git a/web/src/components/icons/StarIcon.tsx b/web/src/components/icons/StarIcon.tsx
new file mode 100644
index 0000000..fa35e82
--- /dev/null
+++ b/web/src/components/icons/StarIcon.tsx
@@ -0,0 +1,25 @@
+interface StarIconProps {
+ filled: boolean;
+ size: number;
+};
+
+export default function StarIcon({ filled, size = 6 }: StarIconProps) {
+
+ return <>
+ {filled ? (
+
+ ) : (
+
+ )}
+ >
+}
diff --git a/web/src/components/icons/StarIconSmall.tsx b/web/src/components/icons/StarIconSmall.tsx
new file mode 100644
index 0000000..e377ec3
--- /dev/null
+++ b/web/src/components/icons/StarIconSmall.tsx
@@ -0,0 +1,25 @@
+
+interface StarIconSmallProps {
+ filled: boolean;
+};
+
+export default function StarIconSmall({ filled }: StarIconSmallProps) {
+
+ return <>
+ {filled ? (
+
+ ) : (
+
+ )}
+ >
+}
diff --git a/web/src/components/icons/TimeIcon.tsx b/web/src/components/icons/TimeIcon.tsx
new file mode 100644
index 0000000..7dbed49
--- /dev/null
+++ b/web/src/components/icons/TimeIcon.tsx
@@ -0,0 +1,13 @@
+export default function TimeIcon() {
+ return (
+
+ );
+}
diff --git a/web/src/components/icons/TimeIconSmall.tsx b/web/src/components/icons/TimeIconSmall.tsx
new file mode 100644
index 0000000..40ef6f9
--- /dev/null
+++ b/web/src/components/icons/TimeIconSmall.tsx
@@ -0,0 +1,9 @@
+export default function TimeIconSmall() {
+ return <>
+
+ >;
+}
diff --git a/web/src/components/inputs/RecipeCreateFormDropdown.tsx b/web/src/components/inputs/RecipeCreateFormDropdown.tsx
new file mode 100644
index 0000000..1b1c861
--- /dev/null
+++ b/web/src/components/inputs/RecipeCreateFormDropdown.tsx
@@ -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>;
+ setDirty: Dispatch>;
+ 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) => {
+ setDirty(prev => ({ ...prev, [name]: true }));
+ setValue(e.target.value);
+ }
+
+ return (
+
+
+
+ {desc}
+
+
+ {!valid && (
+
+ {error}
+
+ )}
+
+ );
+}
+
+
diff --git a/web/src/components/inputs/RecipeCreateFormInput.tsx b/web/src/components/inputs/RecipeCreateFormInput.tsx
new file mode 100644
index 0000000..b8a69cd
--- /dev/null
+++ b/web/src/components/inputs/RecipeCreateFormInput.tsx
@@ -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,
+ "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>;
+ setDirty: Dispatch>;
+ 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) => {
+ setDirty(prev => ({ ...prev, [name]: true }));
+ setValue(e.target.value);
+ }
+
+ return (
+
+
+
+ {desc}
+
+
+ {!valid && (
+
+ {error}
+
+ )}
+
+ );
+}
+
+
diff --git a/web/src/components/inputs/RecipeCreateFormTagsInput.tsx b/web/src/components/inputs/RecipeCreateFormTagsInput.tsx
new file mode 100644
index 0000000..690105d
--- /dev/null
+++ b/web/src/components/inputs/RecipeCreateFormTagsInput.tsx
@@ -0,0 +1,70 @@
+import { useState, type ChangeEvent, type Dispatch, type FormEvent, type SetStateAction } from "react";
+
+interface RecipeCreateFormTagsInputsProps {
+ tags: string[];
+ setTags: Dispatch>;
+ classes: string;
+}
+
+export default function RecipeCreateFormTagsInputs({ tags, setTags, classes }: RecipeCreateFormTagsInputsProps) {
+ const [input, setInput] = useState("");
+
+ const changeHandler = (e: ChangeEvent) => setInput(e.target.value);
+
+ const tagCreationHandler = (e: FormEvent) => {
+ 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 (
+
+ );
+}
diff --git a/web/src/components/inputs/RecipeCreateFormTextArea.tsx b/web/src/components/inputs/RecipeCreateFormTextArea.tsx
new file mode 100644
index 0000000..b14d4eb
--- /dev/null
+++ b/web/src/components/inputs/RecipeCreateFormTextArea.tsx
@@ -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,
+ "value" | "onChange" | "name" | "type" | "placeholder" | "required"
+ > {
+ label: string;
+ name: string;
+ desc: string;
+ placeholder: string;
+ required?: boolean;
+ valid: boolean;
+ value: string;
+ setValue: Dispatch>;
+ setDirty: Dispatch>;
+ 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) => {
+ setDirty(prev => ({ ...prev, [name]: true }));
+ setValue(e.target.value);
+ }
+
+ return (
+
+
+
+ {desc}
+
+
+ {!valid && (
+
+ {error}
+
+ )}
+
+ );
+}
+
+
diff --git a/web/src/components/inputs/RecipeCreateFormWrapper.tsx b/web/src/components/inputs/RecipeCreateFormWrapper.tsx
new file mode 100644
index 0000000..b7ac0b8
--- /dev/null
+++ b/web/src/components/inputs/RecipeCreateFormWrapper.tsx
@@ -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 (
+
+
+
+ {desc}
+
+ {children}
+
+ );
+}
+
diff --git a/web/src/components/inputs/RecipeSearchBar.tsx b/web/src/components/inputs/RecipeSearchBar.tsx
new file mode 100644
index 0000000..f387064
--- /dev/null
+++ b/web/src/components/inputs/RecipeSearchBar.tsx
@@ -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>;
+ redirect: boolean;
+ searchOnLoad: boolean;
+ favorites: boolean;
+ setRecipes: Dispatch> | null;
+
+ // Loading is optional
+ loading?: boolean;
+ setLoading?: Dispatch>;
+};
+
+export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, setRecipes, loading, setLoading }: RecipeSearchBarProps) {
+ const navigate = useNavigate();
+ const { filters, setFilters } = use(FilterContext);
+
+ const [displayDropdown, setDisplayDropdown] = useState(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): Promise => {
+ e.preventDefault();
+ await fetchSearchResults();
+ };
+
+ const queryInputHandler = (e: ChangeEvent) => {
+ 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 (
+
+ );
+}
diff --git a/web/src/components/inputs/RecipeSearchFilterDropdown.tsx b/web/src/components/inputs/RecipeSearchFilterDropdown.tsx
new file mode 100644
index 0000000..a3a59bf
--- /dev/null
+++ b/web/src/components/inputs/RecipeSearchFilterDropdown.tsx
@@ -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) => {
+ 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 (
+
+
+
+ Meal
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cook Time
+
+
+
+
+
+
+
+
+
+
+
+ Difficulty
+
+
+
+
+
+
+
+
+
+
+
+ Serving Size
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/items/ActivityListItem.tsx b/web/src/components/items/ActivityListItem.tsx
new file mode 100644
index 0000000..813edc8
--- /dev/null
+++ b/web/src/components/items/ActivityListItem.tsx
@@ -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 <>
+
+
+ {engagement.Message}
+
+
+ {FormatDate(new Date(engagement.Created))}
+
+
+ >;
+}
diff --git a/web/src/components/items/IngredientList.tsx b/web/src/components/items/IngredientList.tsx
new file mode 100644
index 0000000..160a522
--- /dev/null
+++ b/web/src/components/items/IngredientList.tsx
@@ -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(1);
+
+ return (
+ <>
+
+
+
Ingredients
+ {/* Serving size toggle */}
+
+
+
+
+
+
+
+
+ {sections?.map(section => (
+
+ {/* NOTE: If there is a only one section, do not display a name. */}
+ {sections.length > 1 && (
+ {section.Name}
+ )}
+
+ {ingredients?.filter(x => x.SectionId === section.Id).map(ingredient => (
+ -
+
+
+
+
+ {ingredient.Amount > 0 ? (ingredient.Amount * scale) : null} {ingredient.Unit}
+
+ {ingredient.Name}
+
+ ))}
+
+
+ ))}
+
+ >
+ );
+}
diff --git a/web/src/components/items/InstructionList.tsx b/web/src/components/items/InstructionList.tsx
new file mode 100644
index 0000000..401237c
--- /dev/null
+++ b/web/src/components/items/InstructionList.tsx
@@ -0,0 +1,26 @@
+import type { RecipeInstruction } from "../../types/recipe";
+
+interface InstructionListProps {
+ instructions: RecipeInstruction[];
+}
+export default function InstructionList({ instructions }: InstructionListProps) {
+ return (
+ <>
+
+
Instructions
+
+
+ {instructions?.map((instruction, i) => (
+ -
+
+
{i + 1}
+
+ {instruction.Content}
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/web/src/components/items/RecipeListItem.tsx b/web/src/components/items/RecipeListItem.tsx
new file mode 100644
index 0000000..defd44e
--- /dev/null
+++ b/web/src/components/items/RecipeListItem.tsx
@@ -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 (
+
+ void clickHandler()} className="text-base md:text-lg hover:text-blue-600 duration-100 cursor-pointer">
+ {recipe.Title}
+
+
+ Difficulty: {displayDifficulty(recipe.Difficulty)}
+ {" "} | Duration: {recipe.Duration.Total} min
+ {" "} | Category: {recipe.Category}
+
+
+ Difficulty: {displayDifficulty(recipe.Difficulty)}
+
+
+ Duration: {recipe.Duration.Total} min
+
+
+ Category: {recipe.Category}
+
+ {recipe.Tags && (
+
+ Tags: {displayTags(recipe.Tags)}
+
+ )}
+
+ );
+}
diff --git a/web/src/components/items/RecipeSearchResult.tsx b/web/src/components/items/RecipeSearchResult.tsx
new file mode 100644
index 0000000..756a823
--- /dev/null
+++ b/web/src/components/items/RecipeSearchResult.tsx
@@ -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 (
+ 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">
+

+
+
+
+
+ {recipe.Title} {recipe.Category}
+
+
+
+
+ {recipe.Duration.Total} min
+
+
+ {Array.from({ length: recipe.Difficulty }).map((_, i) => (
+
+ ))}
+ {Array.from({ length: 5 - (recipe.Difficulty) }).map((_, i) => (
+
+ ))}
+
+
+
+ Serves {recipe.Serves}
+
+
+
+
+ {recipe.Favorite && (
+
+ )}
+
+
+
+
{recipe.Tags.map(x => x.Name).join(", ")}
+
+
+ {recipe.Description}
+
+
+
+ );
+}
diff --git a/web/src/components/items/TagList.tsx b/web/src/components/items/TagList.tsx
new file mode 100644
index 0000000..51c0060
--- /dev/null
+++ b/web/src/components/items/TagList.tsx
@@ -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 (
+
+ {tags && (
+ <>
+
Tags
+
+
+ {tags.map(tag => (
+ -
+ {tag.Name}
+
+
+ ))}
+
+ >
+ )}
+
+
Created: {FormatDate(new Date(created))}
+ {modified && (
+
Last Modified: {FormatDate(new Date(modified))}
+ )}
+
+ );
+}
diff --git a/web/src/context/AuthContext.tsx b/web/src/context/AuthContext.tsx
new file mode 100644
index 0000000..dbdc177
--- /dev/null
+++ b/web/src/context/AuthContext.tsx
@@ -0,0 +1,13 @@
+import { createContext } from "react";
+
+interface AuthContextType {
+ isLoggedIn: boolean | undefined;
+ setIsLoggedIn: (state: boolean) => void;
+ getJwt: () => string;
+}
+
+export const AuthContext = createContext({
+ isLoggedIn: undefined,
+ setIsLoggedIn: () => { return },
+ getJwt: () => ""
+});
diff --git a/web/src/context/AuthProvider.tsx b/web/src/context/AuthProvider.tsx
new file mode 100644
index 0000000..e983087
--- /dev/null
+++ b/web/src/context/AuthProvider.tsx
@@ -0,0 +1,34 @@
+import { useEffect, useState, type ReactNode } from "react";
+import { AuthContext } from "./AuthContext";
+import { useCookies } from 'react-cookie';
+
+// BUG: The rerender issue is ridiclious, and needs to be updated. Maybe using a global
+// state management tool instead of a context
+//
+// BUG: We do not want to have access to these cookies in the UI, for security reasons.
+// Instead, we should implement an api `/auth/status` can be requested to validate
+// a token. Not sure how often this should get called, but it should be implemented.
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [cookies] = useCookies(["jwt_token"]);
+ const [isLoggedIn, setIsLoggedIn] = useState(cookies.jwt_token !== undefined);
+
+ const getJwt = (): string => {
+ if (!cookies.jwt_token) return "";
+ return cookies.jwt_token as string;
+ }
+
+ useEffect(() => {
+ setIsLoggedIn(cookies.jwt_token !== undefined);
+ }, [cookies]);
+
+ // NOTE: Display some loading page, maybe...
+ // if (isLoggedIn === undefined) {
+ // return Loading authentication...
; // or null for no flicker
+ // }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web/src/context/FilterContext.tsx b/web/src/context/FilterContext.tsx
new file mode 100644
index 0000000..2523729
--- /dev/null
+++ b/web/src/context/FilterContext.tsx
@@ -0,0 +1,19 @@
+import { createContext } from "react";
+import type { SearchFilters } from "../types/search";
+
+interface FilterContextType {
+ filters: SearchFilters;
+ setFilters: (filters: SearchFilters) => void;
+}
+
+export const FilterContext = createContext({
+ filters: {
+ Search: "",
+ MealType: 0,
+ Time: 0,
+ Difficulty: 0,
+ ServingSize: 0,
+ Favorites: false,
+ },
+ setFilters: () => { return },
+});
diff --git a/web/src/context/FilterProvider.tsx b/web/src/context/FilterProvider.tsx
new file mode 100644
index 0000000..4ffe7dd
--- /dev/null
+++ b/web/src/context/FilterProvider.tsx
@@ -0,0 +1,43 @@
+import { useEffect, useState, type ReactNode } from "react";
+import { FilterContext } from "./FilterContext";
+import type { SearchFilters } from "../types/search";
+
+const STORE_KEY = "potion_app_search_filters";
+
+const DEFAULT_FILTERS: SearchFilters = {
+ Search: "",
+ MealType: 0,
+ Time: 0,
+ Difficulty: 0,
+ ServingSize: 0,
+ Favorites: false,
+};
+
+export function FilterProvider({ children }: { children: ReactNode }) {
+ const [filters, setFilters] = useState(() => {
+ // Window would not be found, something is wrong with the browser
+ if (typeof window === "undefined") return DEFAULT_FILTERS;
+
+ try {
+ const stored = window.localStorage.getItem(STORE_KEY);
+ return stored ? (JSON.parse(stored) as SearchFilters) : DEFAULT_FILTERS;
+ } catch {
+ return DEFAULT_FILTERS;
+ }
+ });
+
+ useEffect(() => {
+ try {
+ window.localStorage.setItem(STORE_KEY, JSON.stringify(filters));
+ } catch {
+ // TODO: Error here?
+ // ignore quota / access errors
+ }
+ }, [filters]);
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/web/src/hooks/useIngredients.ts b/web/src/hooks/useIngredients.ts
new file mode 100644
index 0000000..3344539
--- /dev/null
+++ b/web/src/hooks/useIngredients.ts
@@ -0,0 +1,84 @@
+import { useState } from "react";
+import type { RecipeIngredient, RecipeIngredientSection, RecipeIngredientUnit } from "../types/recipe";
+
+export function useIngredients() {
+ // State values
+ const [sections, setSections] = useState([
+ { Id: "initial-section", Name: "Unnamed group" },
+ ]);
+
+ const [ingredients, setIngredients] = useState([
+ { Id: crypto.randomUUID(), SectionId: "initial-section", Name: "", Amount: 0, Unit: "" as RecipeIngredientUnit },
+ ]);
+
+ // Section handlers
+ const sectionChange = (id: string, name: string) => {
+ setSections(prev => prev.map(s => (s.Id === id ? { ...s, Name: name } : s)));
+ };
+
+ const setSectionIngredients = (sectionId: string, list: RecipeIngredient[]) => {
+ const sorted = [
+ ...list.filter(x => x.SectionId === sectionId),
+ ...ingredients.filter(x => x.SectionId !== sectionId),
+ ].sort((a, b) => a.SectionId.localeCompare(b.SectionId));
+ setIngredients(sorted);
+ };
+
+ const addSection = (index: number) => {
+ const id = crypto.randomUUID();
+ setSections(prev => [
+ ...prev.slice(0, index + 1),
+ { Id: id, Name: "Unnamed group" },
+ ...prev.slice(index + 1),
+ ]);
+ setIngredients(prev => [
+ ...prev,
+ { Id: crypto.randomUUID(), SectionId: id, Amount: 0, Name: "", Unit: "" as RecipeIngredientUnit },
+ ]);
+ };
+
+ const removeSection = (id: string) => {
+ setSections(prev => prev.filter(sec => sec.Id !== id));
+ setIngredients(prev => prev.filter(ing => ing.SectionId !== id));
+ };
+
+ // Ingredient handlers
+ const ingredientChange = (id: string, name: "Amount" | "Unit" | "Name", value: string) => {
+ setIngredients(prev =>
+ prev.map(ing =>
+ ing.Id === id
+ ? { ...ing, [name]: name === "Amount" ? Number(value) : value }
+ : ing
+ )
+ );
+ };
+
+ const addIngredient = (sectionId: string) => {
+ setIngredients(prev =>
+ [...prev, {
+ Id: crypto.randomUUID(),
+ SectionId: sectionId,
+ Amount: 0,
+ Name: "",
+ Unit: "" as RecipeIngredientUnit,
+ }].sort((a, b) => a.SectionId.localeCompare(b.SectionId))
+ );
+ };
+
+ const removeIngredient = (id: string) => {
+ setIngredients(prev => prev.filter(ing => ing.Id !== id));
+ };
+
+ return {
+ sections,
+ ingredients,
+ setSections,
+ sectionChange,
+ ingredientChange,
+ setSectionIngredients,
+ addIngredient,
+ removeIngredient,
+ addSection,
+ removeSection,
+ };
+}
diff --git a/web/src/hooks/validation.ts b/web/src/hooks/validation.ts
new file mode 100644
index 0000000..28a4205
--- /dev/null
+++ b/web/src/hooks/validation.ts
@@ -0,0 +1,67 @@
+import type { CreateRecipeFormDirtyEntries, CreateRecipeFormEntries } from "../pages/Create";
+import { isRecipeMeal, type RecipeIngredient, type RecipeInstruction } from "../types/recipe";
+
+export interface CreateRecipeFormValues {
+ title: string;
+ description: string;
+ prepTime: string;
+ cookTime: string;
+ servingSize: string;
+ category: string;
+ difficulty: string;
+ ingredients: RecipeIngredient[];
+ instructions: RecipeInstruction[];
+}
+
+export function validateCreateRecipeForm(values: CreateRecipeFormValues, dirty: CreateRecipeFormDirtyEntries): CreateRecipeFormEntries {
+ return {
+ title: dirty.title
+ ? values.title.length >= 1 && values.title.length <= 128
+ : true,
+ description: dirty.description
+ ? values.description.length >= 1 && values.description.length <= 1000
+ : true,
+ prepTime: dirty.prepTime
+ ? values.prepTime !== "" &&
+ Number(values.prepTime) >= 0 &&
+ Number(values.prepTime) <= 300
+ : true,
+ cookTime: dirty.cookTime
+ ? values.cookTime !== "" &&
+ Number(values.cookTime) >= 0 &&
+ Number(values.cookTime) <= 300
+ : true,
+ servingSize: dirty.servingSize
+ ? values.servingSize !== "" &&
+ Number(values.servingSize) >= 1 &&
+ Number(values.servingSize) <= 16
+ : true,
+ category: dirty.category
+ ? values.category !== "" && isRecipeMeal(values.category)
+ : true,
+ difficulty: dirty.difficulty
+ ? values.difficulty !== "" &&
+ Number(values.difficulty) >= 1 &&
+ Number(values.difficulty) <= 5
+ : true,
+ ingredients: values.ingredients.map(ingredient => {
+ if (!dirty.ingredients[ingredient.Id]) {
+ return { id: ingredient.Id, valid: true };
+ }
+ let valid = true;
+ if (ingredient.Name.trim() === "") valid = false;
+ if (ingredient.Unit === "") valid = false;
+ if (ingredient.Amount <= 0) valid = false;
+ return { id: ingredient.Id, valid };
+ }),
+ instructions: values.instructions.map(instruction => {
+ if (!dirty.instructions[instruction.Id]) {
+ return { id: instruction.Id, valid: true }
+ }
+ return {
+ id: instruction.Id,
+ valid: instruction.Content.trim() !== ""
+ }
+ }),
+ }
+}
diff --git a/web/static/css/main.css b/web/src/index.css
similarity index 100%
rename from web/static/css/main.css
rename to web/src/index.css
diff --git a/web/src/layouts/WebLayout.tsx b/web/src/layouts/WebLayout.tsx
new file mode 100644
index 0000000..67a23d5
--- /dev/null
+++ b/web/src/layouts/WebLayout.tsx
@@ -0,0 +1,20 @@
+import { Outlet } from "react-router-dom";
+import Navigation from "../components/Navigation";
+
+
+export default function WebLayout() {
+
+ return (
+ <>
+
+ >
+
+ );
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..6b6173d
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,23 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+import { AuthProvider } from './context/AuthProvider.tsx'
+import { CookiesProvider } from 'react-cookie'
+import axios from "axios";
+import { FilterProvider } from './context/FilterProvider.tsx'
+
+// Set the with 'withCredentials' by default
+axios.defaults.withCredentials = true;
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+
+
+ ,
+)
diff --git a/web/src/pages/Create.tsx b/web/src/pages/Create.tsx
new file mode 100644
index 0000000..118e290
--- /dev/null
+++ b/web/src/pages/Create.tsx
@@ -0,0 +1,531 @@
+import { useEffect, useState } from "react";
+import Banner from "../components/Banner";
+import { isRecipeMeal, type RecipeInstruction } from "../types/recipe";
+import InstructionList from "../components/forms/InstructionList";
+import ValidationErrorList from "../components/forms/ValidationErrorList";
+import IngredientSection from "../components/forms/IngredientSection";
+import { Reorder } from "motion/react";
+import IngredientList from "../components/forms/IngredientList";
+import RecipeCreateFormInput from "../components/inputs/RecipeCreateFormInput";
+import RecipeCreateDropdownInput, { type RecipeCreateDropdownOption } from "../components/inputs/RecipeCreateFormDropdown";
+import RecipeCreateFormTextArea from "../components/inputs/RecipeCreateFormTextArea";
+import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrapper";
+import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput";
+import { useIngredients } from "../hooks/useIngredients";
+import { validateCreateRecipeForm } from "../hooks/validation";
+import { CreateRecipe } from "../services/RecipeService";
+import type { CreateRecipeRequest } from "../types/api/recipe";
+import { isApiError } from "../types/api/error";
+import { useNavigate } from "react-router-dom";
+
+// TODO: Move these
+export interface RecipeValidationEntry {
+ id: string;
+ valid: boolean;
+}
+
+export interface CreateRecipeFormEntries {
+ title: boolean;
+ description: boolean;
+ prepTime: boolean;
+ cookTime: boolean;
+ servingSize: boolean;
+ category: boolean;
+ difficulty: boolean;
+ ingredients: RecipeValidationEntry[];
+ instructions: RecipeValidationEntry[];
+ // TODO: Image
+}
+
+export interface CreateRecipeFormDirtyEntries {
+ title: boolean;
+ description: boolean;
+ prepTime: boolean;
+ cookTime: boolean;
+ servingSize: boolean;
+ category: boolean;
+ difficulty: boolean;
+ ingredients: Record;
+ instructions: Record;
+ // TODO: Image
+}
+
+/**
+ * Classes which are applied to all of the input elements.
+ */
+const INPUT_CLASSES = "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 shadow-sm";
+
+/**
+ * Options passed to the category dropdown.
+ */
+const CATEGORY_OPTIONS: RecipeCreateDropdownOption[] = [
+ { value: "", name: "Select a category" },
+ { value: "breakfast", name: "Breakfast" },
+ { value: "lunch", name: "Lunch" },
+ { value: "dinner", name: "Dinner" },
+ { value: "dessert", name: "Dessert" },
+ { value: "snack", name: "Snack" },
+ { value: "side", name: "Side" },
+ { value: "other", name: "Other" },
+];
+
+/**
+ * Options passed to the difficulty dropdown.
+ */
+const DIFFICULTY_OPTIONS: RecipeCreateDropdownOption[] = [
+ { value: "", name: "Select a difficulty" },
+ { value: "1", name: "Beginner" },
+ { value: "2", name: "Easy" },
+ { value: "3", name: "Intermediate" },
+ { value: "4", name: "Challenging" },
+];
+
+export default function Create() {
+ // Inputs
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [tags, setTags] = useState([]);
+ const [prepTime, setPrepTime] = useState("");
+ const [cookTime, setCookTime] = useState("");
+ const [servingSize, setServingSize] = useState("");
+ const [category, setCategory] = useState("");
+ const [difficulty, setDifficulty] = useState("");
+
+ const [instructions, setInstructions] = useState([
+ { Id: crypto.randomUUID(), Content: "" }
+ ]);
+
+ // Validation State
+ const [validation, setValidation] = useState({
+ title: true,
+ description: true,
+ prepTime: true,
+ cookTime: true,
+ servingSize: true,
+ category: true,
+ difficulty: true,
+ ingredients: [],
+ instructions: [],
+ });
+ const [isFormValid, setIsFormValid] = useState(false);
+
+ // Dirty State
+ const [dirty, setDirty] = useState({
+ title: false,
+ description: false,
+ prepTime: false,
+ cookTime: false,
+ servingSize: false,
+ category: false,
+ difficulty: false,
+ ingredients: {},
+ instructions: {},
+ });
+
+ const navigate = useNavigate();
+
+ // Functions
+ const createRecipe = async (): Promise => {
+ console.log({ title, description, tags, prepTime, cookTime, servingSize, category, difficulty, sections, ingredients, instructions });
+
+ // Exit if not valid recipe meal
+ if (!isRecipeMeal(category)) {
+ console.error("[ERROR] Recipe meal is invalid.");
+ return;
+ }
+
+ const recipe: CreateRecipeRequest = {
+ Title: title,
+ Description: description,
+ Instructions: instructions,
+ Serves: Number(servingSize),
+ Difficulty: Number(difficulty),
+ Duration: {
+ Prep: Number(prepTime),
+ Cook: Number(cookTime),
+ Total: Number(prepTime) + Number(cookTime)
+ },
+ Category: category,
+ Ingredients: ingredients,
+ Sections: sections,
+ Tags: tags,
+ };
+
+
+ const response = await CreateRecipe(recipe);
+ if (isApiError(response)) {
+ console.error(response);
+ return;
+ }
+ // TODO: Success toast!
+ await navigate(`/web/recipe/${response.Id}`);
+ };
+
+ // Import ingredients
+ const {
+ sections,
+ ingredients,
+ setSections,
+ sectionChange,
+ ingredientChange,
+ setSectionIngredients,
+ addIngredient,
+ removeIngredient,
+ addSection,
+ removeSection,
+ } = useIngredients();
+
+ // Instruction handlers
+ const addInstructionHandler = () => {
+ setInstructions([...instructions, { Id: crypto.randomUUID(), Content: "" }]);
+ }
+
+ // Dirty handlers
+ const markInstructionDirty = (id: string) => {
+ setDirty(prev => ({
+ ...prev,
+ instructions: {
+ ...prev.instructions,
+ [id]: true,
+ },
+ }));
+ };
+
+ const markIngredientDirty = (id: string) => {
+ setDirty(prev => ({
+ ...prev,
+ ingredients: {
+ ...prev.ingredients,
+ [id]: true,
+ },
+ }));
+ };
+
+ const markAllIngredientsDirty = (): Record => {
+ const all: Record = {};
+ for (const ing of ingredients) all[ing.Id] = true;
+ return all;
+ };
+
+ const markAllInstructionsDirty = (): Record => {
+ const all: Record = {};
+ for (const instr of instructions) all[instr.Id] = true;
+ return all;
+ };
+
+ // HANDLERS
+ const submitHandler = () => {
+ // If any inputs are not dirty, simply dirty them all and return
+ const scalar_dirty = [
+ dirty.title,
+ dirty.description,
+ dirty.prepTime,
+ dirty.cookTime,
+ dirty.servingSize,
+ dirty.category,
+ dirty.difficulty,
+ ];
+ const ingredients_dirty = Object.values(dirty.ingredients).every(Boolean);
+ const instructions_dirty = Object.values(dirty.instructions).every(Boolean);
+
+ const all_dirty = scalar_dirty.every(Boolean) && ingredients_dirty && instructions_dirty;
+
+ if (!all_dirty) {
+ setDirty({
+ title: true,
+ description: true,
+ prepTime: true,
+ cookTime: true,
+ servingSize: true,
+ category: true,
+ difficulty: true,
+ ingredients: markAllIngredientsDirty(),
+ instructions: markAllInstructionsDirty(),
+ });
+ return;
+ }
+
+ void createRecipe();
+ }
+
+
+ // EFFECTS
+ useEffect(() => {
+ // Execute validation every time inputs change
+ setValidation(
+ validateCreateRecipeForm(
+ { title, description, prepTime, cookTime, servingSize, category, difficulty, ingredients, instructions },
+ dirty
+ )
+ );
+ }, [title, description, prepTime, cookTime, servingSize, category, difficulty, instructions, ingredients, dirty]);
+
+ useEffect(() => {
+ // The form is only valid when every item is valid!
+ const bools_valid = Object.values(validation).filter(x => typeof x === "boolean").every(x => x);
+ const ingredients_valid = validation.ingredients.filter(x => !x.valid).length === 0;
+ const instructions_valid = validation.instructions.filter(x => !x.valid).length === 0;
+ setIsFormValid(bools_valid && ingredients_valid && instructions_valid);
+ }, [validation, dirty]);
+
+ useEffect(() => {
+ console.debug("@validation", validation);
+ }, [validation]);
+
+ useEffect(() => {
+ console.debug("@dirty", dirty);
+ }, [dirty]);
+
+ return (
+ <>
+
+
+
+ Welcome to the Recipe Creation Wizard! Simply fill in the details about your culinary creation,
+ including the recipe's name, a description, and other specifics like its category, duration,
+ and difficulty. Don't forget to dynamically add all your ingredients and instructions using
+ the dedicated buttons, and feel free to upload an appealing image. All required fields are
+ marked with an *. Once everything looks perfect, just hit the "Create Recipe"
+ button to
+ share your masterpiece!
+
+
+ {/* Title Input */}
+
+
+ {/* Description Input */}
+
+
+ {/* Tag Input */}
+
+
+ {/* Time Input */}
+
+
+
+
+
+
+
+
+ {/* Dropdown Inputs */}
+
+
+
+
+
+
+ {/* Ingredient Inputs */}
+
+
+ {sections.map((section, i) => (
+ 1}
+ >
+
+
+
+
+ ))}
+
+
+
+ {/* Instruction Inputs */}
+
+ <>
+
+
+ >
+
+
+ {/* TODO: Images Input */}
+
+
+
+ Please provide an image of your creation. This is optional but is a nice touch!
+
+
+
+
+ {/* Display the reason for the invalidation */}
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/pages/Favorites.tsx b/web/src/pages/Favorites.tsx
new file mode 100644
index 0000000..3cf5e2c
--- /dev/null
+++ b/web/src/pages/Favorites.tsx
@@ -0,0 +1,35 @@
+import { useState } from "react";
+import Banner from "../components/Banner";
+import RecipeSearchBar from "../components/inputs/RecipeSearchBar";
+
+import type { Recipe } from "../types/recipe";
+import RecipeSearchResult from "../components/items/RecipeSearchResult";
+import Spinner from "../components/Spinner";
+
+export default function Favorites() {
+ const [recipes, setRecipes] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ return (
+ <>
+
+
+
+
+
+ {loading && (
+
+
+
+ )}
+ {!loading && (
+ <>
+ {recipes?.map(recipe =>
)}
+
{recipes ? "End of results" : "No reuslts"}
+ >
+ )}
+
+
+ >
+ );
+}
diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx
new file mode 100644
index 0000000..e298408
--- /dev/null
+++ b/web/src/pages/Home.tsx
@@ -0,0 +1,178 @@
+import { use, useEffect, useState } from "react";
+import SalmonVideo from "../assets/videos/salmon_video.mp4";
+import Banner from "../components/Banner";
+import ROUTE_CONSTANTS from "../types/routes";
+
+import RecipeLarge from "../components/cards/RecipeCardLarge";
+import type { Recipe } from "../types/recipe";
+import RecipeCardSmall from "../components/cards/RecipeCardSmall";
+import ContentCardSmall from "../components/cards/ContentCardSmall";
+import RecipeSearchBar from "../components/inputs/RecipeSearchBar";
+import { GetRecipeOfTheWeek } from "../services/RecipeService";
+import { isApiError, type ApiError } from "../types/api/error";
+import { AuthContext } from "../context/AuthContext";
+import { GetAuthenticatedUserMadeRecipes, GetAuthenticateUserViewedRecipes } from "../services/UserService";
+
+export default function Home() {
+ // Context
+ const { isLoggedIn } = use(AuthContext);
+
+ // Page state
+ const [recipeOfTheWeek, setRecipeOfTheWeek] = useState(null);
+
+ const [madeRecipes, setMadeRecipes] = useState([]);
+ const [viewedRecipes, setViewedRecipes] = useState([]);
+
+ const [error, setError] = useState("");
+
+ // Fetch the recipe of the week
+ useEffect(() => {
+ async function fetch() {
+ const result_rotw: Recipe | ApiError = await GetRecipeOfTheWeek();
+ if (isApiError(result_rotw)) {
+ setError(result_rotw.message);
+ } else {
+ setRecipeOfTheWeek(result_rotw);
+ }
+
+ if (isLoggedIn) {
+ const result_made: Recipe[] | ApiError = await GetAuthenticatedUserMadeRecipes();
+ if (isApiError(result_made)) {
+ setError(result_made.message);
+ } else {
+ setMadeRecipes(result_made);
+ }
+
+ const result_viewed: Recipe[] | ApiError = await GetAuthenticateUserViewedRecipes();
+ if (isApiError(result_viewed)) {
+ setError(result_viewed.message);
+ } else {
+ setViewedRecipes(result_viewed);
+ }
+ }
+
+ }
+ void fetch();
+ }, [isLoggedIn]);
+
+ // BUG: Prob remove
+ useEffect(() => {
+ if (error)
+ console.error(error);
+ }, [error]);
+
+ return (
+ <>
+ {/* Intro Section */}
+
+
+
+
+ Discover Your Next Favorite Meal
+
+
+
+ Welcome to your ultimate recipe hub! Whether you're a seasoned chef or just starting your culinary adventure,
+ we're here to inspire. Explore thousands of delicious recipes, from quick weeknight dinners to gourmet delights,
+ all at your fingertips. Find exactly what you're craving with our powerful search and intuitive filters, or
+ browse our trending dishes for fresh ideas.
+
+
+
+ {/* Search Section */}
+
+
+ {/* Highlight Section */}
+
+
+
+ Our 'Recipe of the Week' is the cream of the crop! We handpick it by looking at what recipes
+ our community loves most. This isn't just about how many people view a recipe; it's also about
+ how many times it's been made, liked, reviewed, and its average rating, all combined to find
+ the true fan favorite of the week. It's our way of highlighting the best recipes that truly
+ resonate with our users!
+
+
+
+
+
+
+ {/* Lists Section */}
+
+
+
+
Recently viewed
+ {isLoggedIn ?
+
+ {viewedRecipes && viewedRecipes.length > 0 ? (
+ <>
+ {viewedRecipes.map((recipe: Recipe) => (
+
+ ))}
+
+ >
+ ) : (
+
No recently viewed recipes
+ )}
+
+ :
+
+ }
+
Make again
+ {isLoggedIn ?
+
+ {madeRecipes && madeRecipes.length > 0 ? (
+ <>
+ {madeRecipes.map((recipe: Recipe) => (
+
+ ))}
+
+ >
+ ) : (
+
No recently made recipes
+ )}
+
+ :
+
+ }
+
+
+
+ {/* Call-to-Action Section */}
+ < section
+ className="w-full flex flex-col items-center justify-center mt-16 py-8 md:py-12 bg-gradient-to-br from-blue-100 to-purple-100 text-center" >
+
+ Unleash Your Inner Chef!
+
+
+ Have a unique recipe idea? Want to share your culinary masterpiece with the world?
+ It's time to bring your creations to life!
+
+
+ Create Your Recipe!
+
+
+ >
+ );
+}
diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx
new file mode 100644
index 0000000..b2b8401
--- /dev/null
+++ b/web/src/pages/Login.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useState } from "react";
+import { GetGoogleAuthUrl } from "../services/AuthService"
+import { isApiError, type ApiError } from "../types/api/error"
+import { useSearchParams } from "react-router-dom";
+
+
+export default function LoginPage() {
+ const [error, setError] = useState("");
+ const [searchParams, ] = useSearchParams();
+
+ const clickHandler = async (): Promise => {
+ const result: string | ApiError = await GetGoogleAuthUrl();
+
+ if (isApiError(result)) {
+ setError(result.message);
+ return;
+ }
+
+ window.location.href = result;
+ }
+
+ useEffect(() => {
+ if (error)
+ console.error(error);
+ }, [error]);
+
+ useEffect(() => {
+ if (searchParams.has("error")) {
+ const error: string = searchParams.get("error")!;
+ setError(error);
+ }
+ }, [searchParams]);
+
+ // TODO: Implement an error display!
+ return <>
+
+
+
+
+
+ Sign in to Continue
+
+
+ You need to sign in to continue. Don't have an account? Signing in will
+ create one for you!
+
+
+
+
+
+
+
+
+ >
+}
diff --git a/web/src/pages/NotFound.tsx b/web/src/pages/NotFound.tsx
new file mode 100644
index 0000000..086acee
--- /dev/null
+++ b/web/src/pages/NotFound.tsx
@@ -0,0 +1,24 @@
+import ROUTE_CONSTANTS from "../types/routes";
+
+export default function NotFound() {
+ return (
+
+
+
+
404
+
This page could not be found!
+
Back Home
+
+
+ );
+}
diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx
new file mode 100644
index 0000000..1d97f58
--- /dev/null
+++ b/web/src/pages/Profile.tsx
@@ -0,0 +1,181 @@
+import { use, useEffect, useState } from "react";
+import type { User } from "../types/user";
+import type { Recipe } from "../types/recipe";
+import RecipeListItem from "../components/items/RecipeListItem";
+import type { Engagement } from "../types/engagement";
+import ActivityListItem from "../components/items/ActivityListItem";
+import { AuthContext } from "../context/AuthContext";
+import { GetAuthenticatedUser, GetAuthenticatedUserEngagement, GetAuthenticatedUserFavorites, GetAuthenticatedUserRecipes } from "../services/UserService";
+import { isApiError, type ApiError } from "../types/api/error";
+import { Logout } from "../services/AuthService";
+import { useNavigate } from "react-router-dom";
+
+// TODO: Need to cache the image, it returns nothing because it fails with 429 (rate limits)
+// Though this might just be a development issue, it seems to work in production.
+
+export default function Profile() {
+ // Context
+ const { getJwt } = use(AuthContext);
+ const navigate = useNavigate();
+
+ // Page state
+ const [error, setError] = useState("");
+ const [user, setUser] = useState(null);
+ const [dummyProfileSrc, setDummyProfileSrc] = useState("");
+ const [recipes, setRecipes] = useState([]);
+ const [favorites, setFavorites] = useState([]);
+ const [activity, setActivity] = useState([]);
+ const [jwt, setJwt] = useState("");
+
+ // Log the user out and direct to the home page
+ const logoutHandler = (): void => {
+ void Logout();
+ void navigate("/v2/web/home");
+ }
+
+ const seeAllRecipesHandler = (): void => void navigate("/v2/web/404");
+ const seeAllFavoritesHandler = (): void => void navigate("/v2/web/favorites");
+ const seeAllEngagementHandler = (): void => void navigate("/v2/web/404");
+
+ const fetchProfileData = async (): Promise => {
+ const result_user: User | ApiError = await GetAuthenticatedUser();
+ if (isApiError(result_user)) {
+ setError(result_user.message);
+ } else {
+ setUser(result_user);
+ }
+
+ const result_recipes: Recipe[] | ApiError = await GetAuthenticatedUserRecipes();
+ if (isApiError(result_recipes)) {
+ setError(result_recipes.message);
+ } else {
+ setRecipes(result_recipes);
+ }
+
+ const result_favorites: Recipe[] | ApiError = await GetAuthenticatedUserFavorites();
+ if (isApiError(result_favorites)) {
+ setError(result_favorites.message);
+ } else {
+ setFavorites(result_favorites);
+ }
+
+ const result_engagement: Engagement[] | ApiError = await GetAuthenticatedUserEngagement();
+ if (isApiError(result_engagement)) {
+ setError(result_engagement.message);
+ } else {
+ setActivity(result_engagement);
+ }
+ }
+
+ // Get the JWT from the cookies
+ useEffect(() => {
+ setJwt(getJwt());
+ }, [getJwt]);
+
+ // Get the user when the JWTS change
+ useEffect(() => {
+ // No jwt, we can't get user data
+ if (jwt)
+ void fetchProfileData();
+ }, [jwt]);
+
+ useEffect(() => {
+ if (error)
+ console.log("@error", error);
+ }, [error]);
+
+ useEffect(() => {
+ if (user)
+ setDummyProfileSrc(`https://ui-avatars.com/api/?name=${user?.Name.split(" ")[0]}+${user?.Name.split(" ")[1]}&size=150`);
+ }, [user]);
+
+ return (
+ <>
+ {/* User Details Section */}
+
+
+ {user?.ImageUrl != "" ? (
+

{
+ e.currentTarget.onerror = null; // prevent infinite loop
+ e.currentTarget.src = dummyProfileSrc;
+ }}
+ />
+ ) : (
+

+ )}
+
+
+
{user?.Name}
+
{user?.Email}
+
+
+
{recipes.length} recipes
+
{favorites.length} favorites
+
+
+
+
+
+ {/* Recipe Section */}
+
+ My Recipes
+
+ {recipes.length <= 4 ? (
+ recipes.map(recipe => )
+ ) : (
+ recipes.slice(0, 4).map(recipe => )
+ )}
+
+
+
+
+ {/* Favorites Section */}
+
+ My Favorites
+
+ {favorites.length <= 4 ? (
+ favorites.map(recipe => )
+ ) : (
+ favorites.slice(0, 4).map(recipe => )
+ )}
+
+
+
+
+ {/* Activity Section */}
+
+ Recent Activity
+
+ {activity?.map(act => )}
+
+
+
+
+
+ {/* Logout Section */}
+
+ >
+ );
+}
diff --git a/web/src/pages/Recipe.tsx b/web/src/pages/Recipe.tsx
new file mode 100644
index 0000000..0ff0dfb
--- /dev/null
+++ b/web/src/pages/Recipe.tsx
@@ -0,0 +1,87 @@
+import { useEffect, useState } from "react";
+import { isApiError, type ApiError } from "../types/api/error";
+import { GetRecipe } from "../services/RecipeService";
+import type { Recipe } from "../types/recipe";
+import { useParams } from "react-router-dom";
+
+import RecipePlaceholder from "../assets/images/recipe_placeholder_wide.jpg"
+import RecipeMetaData from "../components/display/RecipeMetaData";
+import MadeButton from "../components/buttons/MadeButton";
+import ShareButton from "../components/buttons/ShareButton";
+import FavoriteButton from "../components/buttons/FavoriteButton";
+import TagList from "../components/items/TagList";
+import IngredientList from "../components/items/IngredientList";
+import InstructionList from "../components/items/InstructionList";
+import Spinner from "../components/Spinner";
+import { GetUser } from "../services/UserService";
+import type { User } from "../types/user";
+
+export default function RecipePage() {
+ // Url params
+ const { id } = useParams();
+
+ // Page state
+ const [recipe, setRecipe] = useState(null);
+ const [author, setAuthor] = useState(null);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ async function fetch() {
+ const result: Recipe | ApiError = await GetRecipe(Number(id));
+ if (isApiError(result)) {
+ setError(result.message);
+ } else {
+ setRecipe(result);
+ }
+ }
+ void fetch();
+ }, [id]);
+
+ useEffect(() => {
+ async function fetch() {
+ if (!recipe) return;
+
+ const result: User | ApiError = await GetUser(recipe.UserId);
+ if (isApiError(result)) {
+ setError(result.message);
+ } else {
+ setAuthor(result);
+ }
+ }
+ void fetch();
+ }, [recipe]);
+
+ // BUG: Prob remove
+ useEffect(() => {
+ if (error)
+ console.error(error);
+ }, [error]);
+
+ return recipe ? (
+ <>
+
+
+
{recipe.Title}
+
{author ? author.Name : "Loading..."}
+
Category: {recipe.Category}
+
+
+
+
+
About this recipe
+
{recipe.Description}
+
+
+
+
+ >
+ ) : (
+
+
+
+ );
+}
diff --git a/web/src/pages/Search.tsx b/web/src/pages/Search.tsx
new file mode 100644
index 0000000..666a674
--- /dev/null
+++ b/web/src/pages/Search.tsx
@@ -0,0 +1,33 @@
+import { useState } from "react";
+import Banner from "../components/Banner";
+import RecipeSearchBar from "../components/inputs/RecipeSearchBar";
+import { type Recipe } from "../types/recipe";
+import RecipeSearchResult from "../components/items/RecipeSearchResult";
+import Spinner from "../components/Spinner";
+
+export default function SearchPage() {
+ const [recipes, setRecipes] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ return (
+ <>
+
+
+
+
+
+ {loading && (
+
+
+
+ )}
+ {!loading && (
+ <>
+ {recipes?.map(recipe =>
)}
+
{recipes ? "End of results" : "No reuslts"}
+ >
+ )}
+
+ >
+ );
+}
diff --git a/web/src/pages/ShoppingList.tsx b/web/src/pages/ShoppingList.tsx
new file mode 100644
index 0000000..45d79bc
--- /dev/null
+++ b/web/src/pages/ShoppingList.tsx
@@ -0,0 +1,10 @@
+export default function ShoppingList() {
+ return (
+ <>
+
+
Page Under Construction
+
Sit tight, this page is coming soon!
+
+ >
+ );
+}
diff --git a/web/src/services/AuthService.ts b/web/src/services/AuthService.ts
new file mode 100644
index 0000000..1adb4b3
--- /dev/null
+++ b/web/src/services/AuthService.ts
@@ -0,0 +1,29 @@
+import axios from "axios";
+import type { GetGoogleAuthUrlResponse, LogoutResponse } from "../types/api/auth";
+import type { ApiError } from "../types/api/error";
+import { GetBackendUrl } from "./util";
+
+const BACKEND_URL = GetBackendUrl();
+
+
+export async function GetGoogleAuthUrl(): Promise {
+ const response = await axios.get(`${BACKEND_URL}/v2/api/auth/login`);
+
+ if (response.status !== 200) {
+ const err: ApiError = {
+ status: response.status,
+ message: "[FAIL] Something went wrong."
+ };
+ return err;
+ }
+
+ return response.data.url;
+}
+
+export async function Logout(): Promise {
+ const response = await axios.get(`${BACKEND_URL}/v2/api/auth/logout`);
+
+ // This should never happen
+ if (response.status !== 204)
+ console.error("LOGOUT FAILED");
+}
diff --git a/web/src/services/EngagementService.ts b/web/src/services/EngagementService.ts
new file mode 100644
index 0000000..6677c20
--- /dev/null
+++ b/web/src/services/EngagementService.ts
@@ -0,0 +1,63 @@
+import axios from "axios";
+import type { ApiError } from "../types/api/error";
+import type { Engagement } from "../types/engagement";
+import type { EngagementFavoriteRecipeResponse, EngagementMakeRecipeResponse, EngagementShareRecipeResponse, EngagementViewRecipeResponse } from "../types/api/engagement";
+import { GetBackendUrl } from "./util";
+
+const BACKEND_URL = GetBackendUrl();
+
+export async function EngagementViewRecipe(recipeId: number): Promise {
+ const response = await axios.post(`${BACKEND_URL}/v2/api/engagement/view/${recipeId}`);
+
+ if (response.status !== 200 || response.data.engagement === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.engagement;
+}
+
+export async function EngagementShareRecipe(recipeId: number): Promise {
+ const response = await axios.post(`${BACKEND_URL}/v2/api/engagement/share/${recipeId}`);
+
+ if (response.status !== 200 || response.data.engagement === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.engagement;
+}
+
+export async function EngagementFavoriteRecipe(recipeId: number): Promise {
+ const response = await axios.post(`${BACKEND_URL}/v2/api/engagement/favorite/${recipeId}`);
+
+ if (response.status !== 200 || response.data.engagement === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.engagement;
+}
+
+export async function EngagementMakeRecipe(recipeId: number): Promise {
+ const response = await axios.post(`${BACKEND_URL}/v2/api/engagement/make/${recipeId}`);
+
+ if (response.status !== 200 || response.data.engagement === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.engagement;
+}
diff --git a/web/src/services/RecipeService.ts b/web/src/services/RecipeService.ts
new file mode 100644
index 0000000..fd2a7c2
--- /dev/null
+++ b/web/src/services/RecipeService.ts
@@ -0,0 +1,65 @@
+import axios from "axios";
+import type { CreateRecipeRequest, CreateRecipeResponse, GetRecipeOfTheWeekResponse, GetRecipeResponse, SearchRecipesResponse } from "../types/api/recipe";
+import type { Recipe } from "../types/recipe";
+import type { ApiError } from "../types/api/error";
+import type { SearchFilters } from "../types/search";
+import { GetBackendUrl } from "./util";
+
+const BACKEND_URL = GetBackendUrl();
+
+
+export async function GetRecipeOfTheWeek(): Promise {
+ const response = await axios.get(`${BACKEND_URL}/v2/api/recipe/of-the-week`);
+
+ if (response.status !== 200 || response.data.recipe === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.recipe;
+}
+
+export async function GetRecipe(id: number): Promise {
+ const response = await axios.get(`${BACKEND_URL}/v2/api/recipe/${id}`);
+
+ if (response.status !== 200 || response.data.recipe === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.recipe;
+}
+
+export async function SearchRecipes(filters: SearchFilters): Promise {
+ const response = await axios.post(`${BACKEND_URL}/v2/api/recipe/search`, filters);
+
+ if (response.status !== 200 || response.data.recipes === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.recipes;
+}
+
+export async function CreateRecipe(data: CreateRecipeRequest): Promise {
+ const response = await axios.post(`${BACKEND_URL}/v2/api/recipe`, data);
+
+ if (response.status !== 200 || response.data.recipe === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.recipe;
+}
diff --git a/web/src/services/UserService.ts b/web/src/services/UserService.ts
new file mode 100644
index 0000000..b9f8b63
--- /dev/null
+++ b/web/src/services/UserService.ts
@@ -0,0 +1,107 @@
+import axios from "axios";
+import type { ApiError } from "../types/api/error";
+import type { User } from "../types/user";
+import type { GetAuthenticateUserEngagementResponse, GetAuthenticateUserFavoritesResponse, GetAuthenticateUserMadeRecipesResponse, GetAuthenticateUserRecipesResponse, GetAuthenticateUserResponse, GetAuthenticateUserViewedRecipesResponse, GetUserResponse } from "../types/api/user";
+import type { Recipe } from "../types/recipe";
+import type { Engagement } from "../types/engagement";
+import { GetBackendUrl } from "./util";
+
+const BACKEND_URL = GetBackendUrl();
+
+export async function GetUser(id: number): Promise {
+ const response = await axios.get(`${BACKEND_URL}/v2/api/user/${id}`);
+
+ if (response.data.status !== 200 || response.data.user === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.user;
+}
+
+export async function GetAuthenticatedUser(): Promise {
+ const response = await axios.get(`${BACKEND_URL}/v2/api/user`);
+
+ if (response.data.status !== 200 || response.data.user === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.user;
+}
+
+export async function GetAuthenticatedUserRecipes(): Promise {
+ const response = await axios.get(`${BACKEND_URL}/v2/api/user/recipes`);
+
+ if (response.data.status !== 200 || response.data.recipes === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.recipes;
+}
+
+export async function GetAuthenticatedUserFavorites(): Promise {
+ const response = await axios.get(`${BACKEND_URL}/v2/api/user/favorites`);
+
+ if (response.data.status !== 200 || response.data.favorites === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.favorites;
+}
+
+export async function GetAuthenticatedUserEngagement(): Promise {
+ const response = await axios.get(`${BACKEND_URL}/v2/api/user/engagement`);
+
+ if (response.data.status !== 200 || response.data.engagement === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.engagement;
+}
+
+export async function GetAuthenticatedUserMadeRecipes(): Promise {
+ const response = await axios.get(`${BACKEND_URL}/v2/api/user/recipes/made`);
+
+ if (response.data.status !== 200 || response.data.recipes === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.recipes;
+}
+
+export async function GetAuthenticateUserViewedRecipes(): Promise {
+ const response = await axios.get(`${BACKEND_URL}/v2/api/user/recipes/viewed`);
+
+ if (response.data.status !== 200 || response.data.recipes === undefined) {
+ const err: ApiError = {
+ status: response.data.status,
+ message: response.data.message
+ };
+ return err;
+ }
+
+ return response.data.recipes;
+}
diff --git a/web/src/services/util.ts b/web/src/services/util.ts
new file mode 100644
index 0000000..e749dce
--- /dev/null
+++ b/web/src/services/util.ts
@@ -0,0 +1,15 @@
+const ENV = import.meta.env;
+
+export function GetBackendUrl(): string {
+ const env = ENV.VITE_ENVIRONMENT as string;
+ if (!env) return "";
+
+ switch (env.toLowerCase()) {
+ case "dev":
+ return ENV.VITE_DOMAIN_DEV as string;
+ case "prod":
+ return ENV.VITE_DOMAIN_PROD as string;
+ default:
+ return ""
+ }
+}
diff --git a/web/src/types/api/auth.ts b/web/src/types/api/auth.ts
new file mode 100644
index 0000000..fbd3e14
--- /dev/null
+++ b/web/src/types/api/auth.ts
@@ -0,0 +1,10 @@
+
+export interface GetGoogleAuthUrlResponse {
+ status: number;
+ message: string;
+ url: string;
+}
+
+export interface LogoutResponse {
+ stauts: number;
+}
diff --git a/web/src/types/api/engagement.ts b/web/src/types/api/engagement.ts
new file mode 100644
index 0000000..dc23f35
--- /dev/null
+++ b/web/src/types/api/engagement.ts
@@ -0,0 +1,26 @@
+import type { Engagement } from "../engagement";
+
+
+export interface EngagementViewRecipeResponse {
+ status: number;
+ message: string;
+ engagement?: Engagement;
+}
+
+export interface EngagementShareRecipeResponse {
+ status: number;
+ message: string;
+ engagement?: Engagement;
+}
+
+export interface EngagementFavoriteRecipeResponse {
+ status: number;
+ message: string;
+ engagement?: Engagement;
+}
+
+export interface EngagementMakeRecipeResponse {
+ status: number;
+ message: string;
+ engagement?: Engagement;
+}
diff --git a/web/src/types/api/error.ts b/web/src/types/api/error.ts
new file mode 100644
index 0000000..0878ac0
--- /dev/null
+++ b/web/src/types/api/error.ts
@@ -0,0 +1,16 @@
+
+export function isApiError(obj: unknown): obj is ApiError {
+ return (
+ typeof obj === "object" &&
+ obj !== null &&
+ "status" in obj &&
+ typeof (obj as { status?: unknown }).status === "number" &&
+ "message" in obj &&
+ typeof (obj as { message?: unknown }).message === "string"
+ );
+}
+
+export interface ApiError {
+ status: number;
+ message: string;
+}
diff --git a/web/src/types/api/recipe.ts b/web/src/types/api/recipe.ts
new file mode 100644
index 0000000..814228d
--- /dev/null
+++ b/web/src/types/api/recipe.ts
@@ -0,0 +1,38 @@
+import type { Recipe, RecipeDuration, RecipeIngredient, RecipeIngredientSection, RecipeInstruction, RecipeMeal } from "../recipe";
+
+export interface GetRecipeOfTheWeekResponse {
+ status: number;
+ message: string;
+ recipe?: Recipe;
+}
+
+export interface GetRecipeResponse {
+ status: number;
+ message: string;
+ recipe?: Recipe;
+}
+
+export interface SearchRecipesResponse {
+ status: number;
+ message: string;
+ recipes?: Recipe[];
+}
+
+export interface CreateRecipeResponse {
+ status: number;
+ message: string;
+ recipe?: Recipe;
+}
+
+export interface CreateRecipeRequest {
+ Title: string;
+ Description: string;
+ Instructions: RecipeInstruction[];
+ Serves: number;
+ Difficulty: number;
+ Duration: RecipeDuration;
+ Category: RecipeMeal;
+ Ingredients: RecipeIngredient[];
+ Sections: RecipeIngredientSection[];
+ Tags: string[];
+}
diff --git a/web/src/types/api/user.ts b/web/src/types/api/user.ts
new file mode 100644
index 0000000..8ac1f9f
--- /dev/null
+++ b/web/src/types/api/user.ts
@@ -0,0 +1,45 @@
+import type { Engagement } from "../engagement";
+import type { Recipe } from "../recipe";
+import type { User } from "../user";
+
+export interface GetUserResponse {
+ status: number;
+ message: string;
+ user?: User;
+}
+
+export interface GetAuthenticateUserResponse {
+ status: number;
+ message: string;
+ user?: User;
+}
+
+export interface GetAuthenticateUserRecipesResponse {
+ status: number;
+ message: string;
+ recipes?: Recipe[];
+}
+
+export interface GetAuthenticateUserFavoritesResponse {
+ status: number;
+ message: string;
+ favorites?: Recipe[];
+}
+
+export interface GetAuthenticateUserEngagementResponse {
+ status: number;
+ message: string;
+ engagement?: Engagement[];
+}
+
+export interface GetAuthenticateUserMadeRecipesResponse {
+ status: number;
+ message: string;
+ recipes?: Recipe[];
+}
+
+export interface GetAuthenticateUserViewedRecipesResponse {
+ status: number;
+ message: string;
+ recipes?: Recipe[];
+}
diff --git a/web/src/types/engagement.ts b/web/src/types/engagement.ts
new file mode 100644
index 0000000..233e6c9
--- /dev/null
+++ b/web/src/types/engagement.ts
@@ -0,0 +1,11 @@
+
+export type EngagementType = "made" | "liked" | "viewed" | "shared" | "reviewed" | "rated";
+
+export interface Engagement {
+ Id: number;
+ Type: EngagementType;
+ Message: string;
+ Entity: number;
+ UserId: number;
+ Created: Date;
+};
diff --git a/web/src/types/filters.ts b/web/src/types/filters.ts
new file mode 100644
index 0000000..e70248e
--- /dev/null
+++ b/web/src/types/filters.ts
@@ -0,0 +1,4 @@
+export interface SearchFilters {
+
+
+}
diff --git a/web/static/img/.gitkeep b/web/src/types/index.ts
similarity index 100%
rename from web/static/img/.gitkeep
rename to web/src/types/index.ts
diff --git a/web/src/types/recipe.ts b/web/src/types/recipe.ts
new file mode 100644
index 0000000..080991a
--- /dev/null
+++ b/web/src/types/recipe.ts
@@ -0,0 +1,96 @@
+
+export interface RecipeDuration {
+ Total: number;
+ Prep: number;
+ Cook: number;
+}
+
+export const RECIPE_MEALS = [
+ "breakfast",
+ "lunch",
+ "dinner",
+ "dessert",
+ "snack",
+ "side",
+ "other"
+] as const;
+
+export type RecipeMeal = (typeof RECIPE_MEALS)[number];
+
+export function isRecipeMeal(value: string): value is RecipeMeal {
+ return RECIPE_MEALS.includes(value as RecipeMeal);
+}
+
+export const INGREDIENT_UNITS = [
+ "",
+ "tsp",
+ "tbsp",
+ "fl oz",
+ "cup",
+ "ml",
+ "l",
+ "pt",
+ "qt",
+ "gal",
+ "g",
+ "kg",
+ "oz",
+ "lb",
+ "piece",
+ "clove",
+ "slice",
+ "stick",
+ "bunch",
+ "pinch",
+ "dash",
+ "splash",
+ "to taste",
+] as const;
+
+export type RecipeIngredientUnit = (typeof INGREDIENT_UNITS)[number];
+
+export function isRecipeUnit(value: string): value is RecipeIngredientUnit {
+ return INGREDIENT_UNITS.includes(value as RecipeIngredientUnit);
+}
+
+export interface RecipeIngredient {
+ Id: string;
+ SectionId: string;
+ Name: string;
+ Amount: number;
+ Unit: RecipeIngredientUnit;
+}
+
+export interface RecipeIngredientSection {
+ Id: string;
+ Name: string;
+};
+
+export interface RecipeInstruction {
+ Id: string;
+ Content: string;
+}
+
+export interface Tag {
+ Id: number;
+ Name: string;
+ Created: Date;
+}
+
+export interface Recipe {
+ Id: number;
+ Title: string;
+ Description: string;
+ Instructions: RecipeInstruction[];
+ Serves: number;
+ Difficulty: number;
+ Duration: RecipeDuration;
+ Category: RecipeMeal;
+ Ingredients: RecipeIngredient[];
+ Sections: RecipeIngredientSection[];
+ UserId: number;
+ Modified: Date;
+ Created: Date;
+ Tags: Tag[];
+ Favorite: boolean;
+}
diff --git a/web/src/types/routes.ts b/web/src/types/routes.ts
new file mode 100644
index 0000000..955102b
--- /dev/null
+++ b/web/src/types/routes.ts
@@ -0,0 +1,21 @@
+const VERSION_FLAG = "/v2";
+
+const ROUTE_CONSTANTS: {
+ Home: string;
+ Favorites: string;
+ Create: string;
+ Profile: string;
+ ShoppingList: string;
+ Login: string;
+ History: string;
+} = {
+ Home: `${VERSION_FLAG}/web/home`,
+ Favorites: `${VERSION_FLAG}/web/favorites`,
+ Create: `${VERSION_FLAG}/web/create`,
+ Profile: `${VERSION_FLAG}/web/profile`,
+ ShoppingList: `${VERSION_FLAG}/web/list`,
+ Login: `${VERSION_FLAG}/web/login`,
+ History: `${VERSION_FLAG}/web/history`,
+};
+
+export default ROUTE_CONSTANTS;
diff --git a/web/src/types/search.ts b/web/src/types/search.ts
new file mode 100644
index 0000000..272180f
--- /dev/null
+++ b/web/src/types/search.ts
@@ -0,0 +1,11 @@
+
+export type FilterBitKey = "MealType" | "Time" | "Difficulty" | "ServingSize";
+
+export interface SearchFilters {
+ Search: string;
+ MealType: number;
+ Time: number;
+ Difficulty: number;
+ ServingSize: number;
+ Favorites: boolean;
+};
diff --git a/web/src/types/user.ts b/web/src/types/user.ts
new file mode 100644
index 0000000..60d0d04
--- /dev/null
+++ b/web/src/types/user.ts
@@ -0,0 +1,19 @@
+export interface GoogleUserInfo {
+ Id: string;
+ Email: string;
+ Verified: boolean;
+ Name: string;
+ GivenName: string;
+ FamilyName: string;
+ Picture: string;
+}
+
+export interface User {
+ Id: number;
+ GoogleId: string;
+ Name: string;
+ Email: string;
+ ImageUrl: string;
+ GoogleRefreshToken: string;
+ Created: Date;
+}
diff --git a/web/tailwind.config.js b/web/tailwind.config.js
new file mode 100644
index 0000000..0b7814b
--- /dev/null
+++ b/web/tailwind.config.js
@@ -0,0 +1,8 @@
+module.exports = {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}"
+ ],
+ theme: { extend: {}, },
+ plugins: [],
+}
diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json
new file mode 100644
index 0000000..a9b5a59
--- /dev/null
+++ b/web/tsconfig.app.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..ea9d0cd
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json
new file mode 100644
index 0000000..8a67f62
--- /dev/null
+++ b/web/tsconfig.node.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..fa09b44
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react-swc';
+import tailwindcss from '@tailwindcss/vite';
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+})
diff --git a/web_old/static/css/main.css b/web_old/static/css/main.css
new file mode 100644
index 0000000..f1d8c73
--- /dev/null
+++ b/web_old/static/css/main.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/web/static/css/tailwind.css b/web_old/static/css/tailwind.css
similarity index 100%
rename from web/static/css/tailwind.css
rename to web_old/static/css/tailwind.css
diff --git a/web_old/static/img/.gitkeep b/web_old/static/img/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/web_old/static/img/recipe_placeholder.png b/web_old/static/img/recipe_placeholder.png
new file mode 100644
index 0000000..c8664d9
Binary files /dev/null and b/web_old/static/img/recipe_placeholder.png differ
diff --git a/web_old/static/img/recipe_placeholder_wide.jpg b/web_old/static/img/recipe_placeholder_wide.jpg
new file mode 100644
index 0000000..07d0520
Binary files /dev/null and b/web_old/static/img/recipe_placeholder_wide.jpg differ
diff --git a/web_old/static/img/salmon_video.mp4 b/web_old/static/img/salmon_video.mp4
new file mode 100644
index 0000000..de55dd5
Binary files /dev/null and b/web_old/static/img/salmon_video.mp4 differ