(FEAT): Working on the recipe page! This one is making lots of progress.

But there is lots to be done!
This commit is contained in:
Hayden Hargreaves 2025-11-18 22:28:22 -07:00
parent 3905557511
commit cfaace1bfd
18 changed files with 400 additions and 11 deletions

View File

@ -3,11 +3,11 @@ 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.
//
@ -31,3 +31,31 @@ func (s *Server) GetRecipeOfTheWeekHandlerV2(ctx *gin.Context) {
"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,
})
}

View File

@ -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)

View File

@ -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() {
<Route path="profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<Route path="list" element={<ProtectedRoute><ShoppingList /></ProtectedRoute>} />
{/* <Route path="recipe/:id" element={<Home />} /> */}
<Route path="recipe/:id" element={<RecipePage />} />
</Route>

View File

@ -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<boolean>();
const clickHandler = () => {
// TODO: Implement action here!
setFavorite(!_favorite);
}
useEffect(() => {
if (favorite)
setFavorite(favorite);
}, [favorite]);
return _favorite ? (
<button
className="flex items-center justify-center gap-x-1 rounded-lg border border-blue-300 bg-blue-50 text-gray-800 px-6 py-3 flex-grow hover:bg-blue-100 hover:border-blue-500 duration-300 cursor-pointer"
onClick={clickHandler}
>
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 9.1371C2 14 6.01943 16.5914 8.96173 18.9109C10 19.7294 11 20.5 12 20.5C13 20.5 14 19.7294 15.0383 18.9109C17.9806 16.5914 22 14 22 9.1371C22 4.27416 16.4998 0.825464 12 5.50063C7.50016 0.825464 2 4.27416 2 9.1371Z"
fill="currentColor"
></path>
</svg>
Unfavorite
</button>
) : (
<button
className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
onClick={clickHandler}
>
<svg className="h-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 6.00019C10.2006 3.90317 7.19377 3.2551 4.93923 5.17534C2.68468 7.09558 2.36727 10.3061 4.13778 12.5772C5.60984 14.4654 10.0648 18.4479 11.5249 19.7369C11.6882 19.8811 11.7699 19.9532 11.8652 19.9815C11.9483 20.0062 12.0393 20.0062 12.1225 19.9815C12.2178 19.9532 12.2994 19.8811 12.4628 19.7369C13.9229 18.4479 18.3778 14.4654 19.8499 12.5772C21.6204 10.3061 21.3417 7.07538 19.0484 5.17534C16.7551 3.2753 13.7994 3.90317 12 6.00019Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
Favorite
</button>
);
}

View File

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

View File

@ -0,0 +1,45 @@
import { useState } from "react";
interface ShareButtonProps {
id: number | undefined;
}
export default function ShareButton({ id }: ShareButtonProps) {
const [clicked, setClicked] = useState<boolean>(false);
const clickHandler = () => {
if (!id || clicked) return;
// TODO: Implement action here
setClicked(true);
};
return clicked ? (
<button className="flex items-center justify-center gap-x-1 rounded-lg border border-green-500 text-green-500 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300">
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.803 5.33333C13.803 3.49238 15.3022 2 17.1515 2C19.0008 2 20.5 3.49238 20.5 5.33333C20.5 7.17428 19.0008 8.66667 17.1515 8.66667C16.2177 8.66667 15.3738 8.28596 14.7671 7.67347L10.1317 10.8295C10.1745 11.0425 10.197 11.2625 10.197 11.4872C10.197 11.9322 10.109 12.3576 9.94959 12.7464L15.0323 16.0858C15.6092 15.6161 16.3473 15.3333 17.1515 15.3333C19.0008 15.3333 20.5 16.8257 20.5 18.6667C20.5 20.5076 19.0008 22 17.1515 22C15.3022 22 13.803 20.5076 13.803 18.6667C13.803 18.1845 13.9062 17.7255 14.0917 17.3111L9.05007 13.9987C8.46196 14.5098 7.6916 14.8205 6.84848 14.8205C4.99917 14.8205 3.5 13.3281 3.5 11.4872C3.5 9.64623 4.99917 8.15385 6.84848 8.15385C7.9119 8.15385 8.85853 8.64725 9.47145 9.41518L13.9639 6.35642C13.8594 6.03359 13.803 5.6896 13.803 5.33333Z"
fill="currentColor" />
</svg>
Link Copied!
</button>
) : (
<button
className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
onClick={clickHandler}
>
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.803 5.33333C13.803 3.49238 15.3022 2 17.1515 2C19.0008 2 20.5 3.49238 20.5 5.33333C20.5 7.17428 19.0008 8.66667 17.1515 8.66667C16.2177 8.66667 15.3738 8.28596 14.7671 7.67347L10.1317 10.8295C10.1745 11.0425 10.197 11.2625 10.197 11.4872C10.197 11.9322 10.109 12.3576 9.94959 12.7464L15.0323 16.0858C15.6092 15.6161 16.3473 15.3333 17.1515 15.3333C19.0008 15.3333 20.5 16.8257 20.5 18.6667C20.5 20.5076 19.0008 22 17.1515 22C15.3022 22 13.803 20.5076 13.803 18.6667C13.803 18.1845 13.9062 17.7255 14.0917 17.3111L9.05007 13.9987C8.46196 14.5098 7.6916 14.8205 6.84848 14.8205C4.99917 14.8205 3.5 13.3281 3.5 11.4872C3.5 9.64623 4.99917 8.15385 6.84848 8.15385C7.9119 8.15385 8.85853 8.64725 9.47145 9.41518L13.9639 6.35642C13.8594 6.03359 13.803 5.6896 13.803 5.33333Z"
fill="currentColor" />
</svg>
Share
</button>
);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,13 @@
export default function TimeIcon() {
return (
<svg className="h-7 text-blue-600" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 7V12L14.5 13.5M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
);
}

View File

@ -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<Recipe[]>([]);

View File

@ -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";

69
web/src/pages/Recipe.tsx Normal file
View File

@ -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<Recipe | null>(null);
const [error, setError] = useState<string>("");
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 (
<>
<img className="bg-gray-100 w-full h-96 mx-auto mb-8" src={RecipePlaceholder} />
<div className="px-4 py-8 md:px-8">
<h1 className="text-3xl md:text-4xl font-bold text-gray-800">{recipe?.Title ?? "Loading..."}</h1>
<p className="text-sm mt-2 mb-1 text-gray-700">Author: loading...</p>
<p className="text-sm mb-2 text-gray-700">Category: {recipe?.Category ?? "loading..."}</p>
</div>
<RecipeMetaData recipe={recipe} />
<section className="w-full flex flex-col md:flex-row gap-x-4 gap-y-2 py-8 px-4 md:px-8">
<FavoriteButton favorite={recipe?.Favorite} id={recipe?.Id} />
<MadeButton id={recipe?.Id} />
<ShareButton id={recipe?.Id} />
</section>
<div className="px-4 py-8 md:px-8">
<h3 className="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3>
<p className="text-gray-700">{recipe?.Description ?? "loading..."}</p>
</div>
@ingredientList(recipe.Ingredients)
@instructionList(recipe.Instructions)
@tagList(recipe.Tags, recipe.Created, recipe.Modified)
</>
)
}

View File

@ -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<Recipe | ApiError> {
export async function GetRecipeOfTheWeek(): Promise<Recipe | ApiError> {
const response = await axios.get<GetRecipeOfTheWeekResponse>("http://localhost:3000/v2/api/recipe/of-the-week");
if (response.status !== 200 || response.data.recipe === undefined) {
@ -17,3 +17,17 @@ export async function GetRecipeOfTheWeek (): Promise<Recipe | ApiError> {
return response.data.recipe;
}
export async function GetRecipe(id: number): Promise<Recipe | ApiError> {
const response = await axios.get<GetRecipeResponse>(`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;
}

View File

@ -5,3 +5,9 @@ export interface GetRecipeOfTheWeekResponse {
message: string;
recipe?: Recipe;
}
export interface GetRecipeResponse {
status: number;
message: string;
recipe?: Recipe;
}