From cfaace1bfd7c19bde24be6bd1dc276c63942e9b9 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Tue, 18 Nov 2025 22:28:22 -0700 Subject: [PATCH] (FEAT): Working on the recipe page! This one is making lots of progress. But there is lots to be done! --- internal/app/server/recipe_handler_v2.go | 38 ++++++++-- internal/app/server/server.go | 2 + web/src/App.tsx | 3 +- web/src/components/buttons/FavoriteButton.tsx | 57 +++++++++++++++ web/src/components/buttons/MadeButton.tsx | 44 ++++++++++++ web/src/components/buttons/ShareButton.tsx | 45 ++++++++++++ web/src/components/display/RecipeMetaData.tsx | 58 ++++++++++++++++ web/src/components/icons/ServingSizeIcon.tsx | 28 ++++++++ web/src/components/icons/StarIcon.tsx | 24 +++++++ web/src/components/icons/TimeIcon.tsx | 13 ++++ .../{results => items}/ActivityListItem.tsx | 0 .../{results => items}/FavoriteResult.tsx | 0 .../{results => items}/RecipeListItem.tsx | 0 web/src/pages/Favorites.tsx | 2 +- web/src/pages/Profile.tsx | 4 +- web/src/pages/Recipe.tsx | 69 +++++++++++++++++++ web/src/services/RecipeService.ts | 18 ++++- web/src/types/api/recipe.ts | 6 ++ 18 files changed, 400 insertions(+), 11 deletions(-) create mode 100644 web/src/components/buttons/FavoriteButton.tsx create mode 100644 web/src/components/buttons/MadeButton.tsx create mode 100644 web/src/components/buttons/ShareButton.tsx create mode 100644 web/src/components/display/RecipeMetaData.tsx create mode 100644 web/src/components/icons/ServingSizeIcon.tsx create mode 100644 web/src/components/icons/StarIcon.tsx create mode 100644 web/src/components/icons/TimeIcon.tsx rename web/src/components/{results => items}/ActivityListItem.tsx (100%) rename web/src/components/{results => items}/FavoriteResult.tsx (100%) rename web/src/components/{results => items}/RecipeListItem.tsx (100%) create mode 100644 web/src/pages/Recipe.tsx diff --git a/internal/app/server/recipe_handler_v2.go b/internal/app/server/recipe_handler_v2.go index 7331435..46f90c1 100644 --- a/internal/app/server/recipe_handler_v2.go +++ b/internal/app/server/recipe_handler_v2.go @@ -3,15 +3,15 @@ package server import ( "fmt" "net/http" + "strconv" "github.com/gin-gonic/gin" ) - // GetRecipeOfTheWeekHandler fetchs the current recipe of the week and returns it. // If an error occurs, it will be returned and a recipe will not be returned. // -// Until auth is reimplemented, there is no way to determine what user is making the +// Until auth is reimplemented, there is no way to determine what user is making the // call. func (s *Server) GetRecipeOfTheWeekHandlerV2(ctx *gin.Context) { userId := getUserId(ctx) @@ -19,15 +19,43 @@ func (s *Server) GetRecipeOfTheWeekHandlerV2(ctx *gin.Context) { if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{ - "status": http.StatusBadRequest, + "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, + "status": http.StatusOK, "message": "[OK] Successfully retrieved recipe of the week.", - "recipe": recipe, + "recipe": recipe, + }) +} + +func (s *Server) GetRecipeV2(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, }) } diff --git a/internal/app/server/server.go b/internal/app/server/server.go index dea2a97..abbab58 100644 --- a/internal/app/server/server.go +++ b/internal/app/server/server.go @@ -201,7 +201,9 @@ func (s *Server) Setup() *Server { // ---- VERSION 2 ROUTES ---- // router_api_v2 := router_v2.Group(domain.API) + router_api_v2.GET("/recipe/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeV2) router_api_v2.GET("/recipe/of-the-week", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeOfTheWeekHandlerV2) + router_api_v2.GET("/auth/login", s.GetGoogleAuthUrlHandlerV2) router_api_v2.GET("/auth/callback", s.GoogleCallbackHandlerV2) router_api_v2.GET("/auth/logout", s.LogoutHandlerV2) diff --git a/web/src/App.tsx b/web/src/App.tsx index d6085d0..ba64894 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ 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'; function ProtectedRoute({ children }: { children: ReactNode }) { const { isLoggedIn } = use(AuthContext) @@ -43,7 +44,7 @@ function App() { } /> } /> - {/* } /> */} + } /> diff --git a/web/src/components/buttons/FavoriteButton.tsx b/web/src/components/buttons/FavoriteButton.tsx new file mode 100644 index 0000000..d4fcde9 --- /dev/null +++ b/web/src/components/buttons/FavoriteButton.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; + + +interface FavoriteButtonProps { + favorite: boolean | undefined; + id: number | undefined; +} + +export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) { + const [_favorite, setFavorite] = useState(); + + const clickHandler = () => { + // TODO: Implement action here! + setFavorite(!_favorite); + } + + useEffect(() => { + if (favorite) + setFavorite(favorite); + + }, [favorite]); + + return _favorite ? ( + + + ) : ( + + + ); +} diff --git a/web/src/components/buttons/MadeButton.tsx b/web/src/components/buttons/MadeButton.tsx new file mode 100644 index 0000000..29fcf49 --- /dev/null +++ b/web/src/components/buttons/MadeButton.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; + + +interface MadeButtonProps { + id: number | undefined; +} + +export default function MadeButton({ id }: MadeButtonProps) { + const [clicked, setClicked] = useState(false); + + const clickHandler = () => { + if (!id || clicked) return; + + // TODO: Implement actions + + setClicked(true); + } + + return ( + + + ); +} diff --git a/web/src/components/buttons/ShareButton.tsx b/web/src/components/buttons/ShareButton.tsx new file mode 100644 index 0000000..18759ee --- /dev/null +++ b/web/src/components/buttons/ShareButton.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; + + +interface ShareButtonProps { + id: number | undefined; +} + +export default function ShareButton({ id }: ShareButtonProps) { + const [clicked, setClicked] = useState(false); + + const clickHandler = () => { + if (!id || clicked) return; + + // TODO: Implement action here + + setClicked(true); + }; + + return clicked ? ( + + ) : ( + + ); +} diff --git a/web/src/components/display/RecipeMetaData.tsx b/web/src/components/display/RecipeMetaData.tsx new file mode 100644 index 0000000..6ab7fd2 --- /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/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/StarIcon.tsx b/web/src/components/icons/StarIcon.tsx new file mode 100644 index 0000000..9556498 --- /dev/null +++ b/web/src/components/icons/StarIcon.tsx @@ -0,0 +1,24 @@ +interface StarIconProps { + filled: boolean; +}; + +export default function StarIcon({ filled }: StarIconProps) { + + 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/results/ActivityListItem.tsx b/web/src/components/items/ActivityListItem.tsx similarity index 100% rename from web/src/components/results/ActivityListItem.tsx rename to web/src/components/items/ActivityListItem.tsx diff --git a/web/src/components/results/FavoriteResult.tsx b/web/src/components/items/FavoriteResult.tsx similarity index 100% rename from web/src/components/results/FavoriteResult.tsx rename to web/src/components/items/FavoriteResult.tsx diff --git a/web/src/components/results/RecipeListItem.tsx b/web/src/components/items/RecipeListItem.tsx similarity index 100% rename from web/src/components/results/RecipeListItem.tsx rename to web/src/components/items/RecipeListItem.tsx diff --git a/web/src/pages/Favorites.tsx b/web/src/pages/Favorites.tsx index 5872c98..8b363de 100644 --- a/web/src/pages/Favorites.tsx +++ b/web/src/pages/Favorites.tsx @@ -3,7 +3,7 @@ import Banner from "../components/Banner"; import RecipeSearchBar from "../components/inputs/RecipeSearchBar"; import type { Recipe } from "../types/recipe"; -import FavoriteResult from "../components/results/FavoriteResult"; +import FavoriteResult from "../components/items/FavoriteResult"; export default function Favorites() { const [recipes, setRecipes] = useState([]); diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index fa693c4..6d20c2d 100644 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -1,9 +1,9 @@ import { use, useEffect, useState } from "react"; import type { User } from "../types/user"; import type { Recipe } from "../types/recipe"; -import RecipeListItem from "../components/results/RecipeListItem"; +import RecipeListItem from "../components/items/RecipeListItem"; import type { Engagement } from "../types/engagement"; -import ActivityListItem from "../components/results/ActivityListItem"; +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"; diff --git a/web/src/pages/Recipe.tsx b/web/src/pages/Recipe.tsx new file mode 100644 index 0000000..db6461a --- /dev/null +++ b/web/src/pages/Recipe.tsx @@ -0,0 +1,69 @@ +import { use, useEffect, useState } from "react"; +import { isApiError, type ApiError } from "../types/api/error"; +import { GetRecipe } from "../services/RecipeService"; +import type { Recipe } from "../types/recipe"; +import { AuthContext } from "../context/AuthContext"; +import { useParams } from "react-router-dom"; + +import RecipePlaceholder from "../assets/images/recipe_placeholder_wide.jpg" +import TimeIcon from "../components/icons/TimeIcon"; +import StarIcon from "../components/icons/StarIcon"; +import RecipeMetaData from "../components/display/RecipeMetaData"; +import MadeButton from "../components/buttons/MadeButton"; +import ShareButton from "../components/buttons/ShareButton"; +import FavoriteButton from "../components/buttons/FavoriteButton"; + +export default function RecipePage() { + // Context + const { isLoggedIn } = use(AuthContext); + + // Url params + const { id } = useParams(); + + // Page state + const [recipe, setRecipe] = 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]); + + // BUG: Prob remove + useEffect(() => { + if (error) + console.error(error); + }, [error]); + + return ( + <> + +
+

{recipe?.Title ?? "Loading..."}

+

Author: loading...

+

Category: {recipe?.Category ?? "loading..."}

+
+ +
+ + + +
+
+

About this recipe

+

{recipe?.Description ?? "loading..."}

+
+ @ingredientList(recipe.Ingredients) + @instructionList(recipe.Instructions) + @tagList(recipe.Tags, recipe.Created, recipe.Modified) + + + ) +} diff --git a/web/src/services/RecipeService.ts b/web/src/services/RecipeService.ts index c9e4690..d0245bd 100644 --- a/web/src/services/RecipeService.ts +++ b/web/src/services/RecipeService.ts @@ -1,10 +1,10 @@ import axios from "axios"; -import type { GetRecipeOfTheWeekResponse } from "../types/api/recipe"; +import type { GetRecipeOfTheWeekResponse, GetRecipeResponse } from "../types/api/recipe"; import type { Recipe } from "../types/recipe"; import type { ApiError } from "../types/api/error"; -export async function GetRecipeOfTheWeek (): Promise { +export async function GetRecipeOfTheWeek(): Promise { const response = await axios.get("http://localhost:3000/v2/api/recipe/of-the-week"); if (response.status !== 200 || response.data.recipe === undefined) { @@ -17,3 +17,17 @@ export async function GetRecipeOfTheWeek (): Promise { return response.data.recipe; } + +export async function GetRecipe(id: number): Promise { + const response = await axios.get(`http://localhost:3000/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; +} diff --git a/web/src/types/api/recipe.ts b/web/src/types/api/recipe.ts index fda35ff..701db2e 100644 --- a/web/src/types/api/recipe.ts +++ b/web/src/types/api/recipe.ts @@ -5,3 +5,9 @@ export interface GetRecipeOfTheWeekResponse { message: string; recipe?: Recipe; } + +export interface GetRecipeResponse { + status: number; + message: string; + recipe?: Recipe; +}