(FEAT): Searching is working!

So much progress! Yay!! Whats missing is the global storage of the
filters. That is the final touch for searching.
This commit is contained in:
Hayden Hargreaves 2025-11-30 21:53:07 -07:00
parent 00acb981b0
commit 25ac0fd527
18 changed files with 299 additions and 261 deletions

View File

@ -6,6 +6,7 @@ import (
"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.
@ -32,7 +33,7 @@ func (s *Server) GetRecipeOfTheWeekHandlerV2(ctx *gin.Context) {
})
}
func (s *Server) GetRecipeV2(ctx *gin.Context) {
func (s *Server) GetRecipeHandlerV2(ctx *gin.Context) {
id := ctx.Param("id")
parsedId, err := strconv.Atoi(id)
if err != nil {
@ -59,3 +60,36 @@ func (s *Server) GetRecipeV2(ctx *gin.Context) {
"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,
})
}

View File

@ -201,8 +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/: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.GET("/auth/login", s.GetGoogleAuthUrlHandlerV2)
router_api_v2.GET("/auth/callback", s.GoogleCallbackHandlerV2)

View File

@ -78,11 +78,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

View File

@ -12,6 +12,7 @@ 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)
@ -39,6 +40,7 @@ function App() {
<Route path="/v2/web" element={<WebLayout />}>
<Route index element={<Navigate to={ROUTE_CONSTANTS.Home} replace />} />
<Route path="home" element={<Home />} />
<Route path="search" element={<SearchPage />} />
<Route path="favorites" element={<ProtectedRoute><Favorites /></ProtectedRoute>} />
<Route path="create" element={<ProtectedRoute><Create /></ProtectedRoute>} />
<Route path="profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />

View File

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

View File

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

View File

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

View File

@ -1,25 +1,78 @@
import { useState } from "react";
import { useEffect, useState, type ChangeEvent, type FormEvent } 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";
interface RecipeSearchBarProps {
filters: SearchFilters | null;
// filters: SearchFilters;
// setFilters: React.Dispatch<React.SetStateAction<SearchFilters>>;
redirect: boolean;
searchOnLoad: boolean;
favorites: boolean;
setRecipes: React.Dispatch<React.SetStateAction<Recipe[]>> | null;
};
export default function RecipeSearchBar({ filters, redirect, searchOnLoad, favorites }: RecipeSearchBarProps) {
export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, setRecipes }: RecipeSearchBarProps) {
const navigate = useNavigate();
const [displayDropdown, setDisplayDropdown] = useState<boolean>(false);
const [filters, setFilters] = useState<SearchFilters>({
Search: "",
MealType: 0,
Time: 0,
Difficulty: 0,
ServingSize: 0,
Favorites: favorites
});
// SERVER FUNCTIONS
const fetchSearchResults = async () => {
if (redirect) {
await navigate("/v2/web/search");
return;
}
const result = await SearchRecipes(filters);
if (isApiError(result)) {
console.error(result.message);
return;
}
if (setRecipes)
setRecipes(result);
}
// HANDLERS
const formSubmitHandler = () => console.log("formSubmitHandler()");
const toggleDropdownHandler = () => setDisplayDropdown(!displayDropdown);
// TODO: Store filters in a global state somewhere!
const searchHandler = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
await fetchSearchResults();
};
const queryInputHandler = (e: ChangeEvent<HTMLInputElement>) => {
const new_filters: SearchFilters = {
...filters,
Search: e.target.value,
};
setFilters(new_filters);
}
// EFFECTS
// TODO: Learn how to use 'useCallback' here to prevent endless loading and fix warning
useEffect(() => {
if (searchOnLoad)
void fetchSearchResults();
}, [searchOnLoad]);
return (
<form onSubmit={formSubmitHandler} className="w-full px-4 my-8">
<div className="flex w-full gap-x-2">
<form className="w-full px-4 my-8" onSubmit={(e) => void searchHandler(e)}>
<div className="flex w-full gdisbaledap-x-2">
<div className="relative w-full">
<input type="hidden" name="redirect" value={JSON.stringify(redirect)} />
<input
@ -27,9 +80,10 @@ export default function RecipeSearchBar({ filters, redirect, searchOnLoad, favor
name="search"
placeholder="Search recipes, ingredients..."
value={filters ? filters.Search : ""}
className="w-full pr-4 pl-10 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={queryInputHandler}
className="w-[99%] pr-4 pl-10 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button type="submit" className="absolute left-3 top-1/2 -translate-y-1/2">
<button className="absolute left-3 top-1/2 -translate-y-1/2">
<svg
className="h-5 w-5 text-gray-400"
fill="none"
@ -38,9 +92,9 @@ export default function RecipeSearchBar({ filters, redirect, searchOnLoad, favor
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
></path>
</svg>
@ -48,7 +102,7 @@ export default function RecipeSearchBar({ filters, redirect, searchOnLoad, favor
</div>
<FilterButton click={toggleDropdownHandler} />
</div>
<RecipeSearchFilterDropdown filters={filters} display={displayDropdown} />
<RecipeSearchFilterDropdown filters={filters} setFilters={setFilters} display={displayDropdown} />
</form>
);
}

View File

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

View File

@ -1,56 +0,0 @@
import type { Recipe } from "../../types/recipe";
import ServingSizeIconSmall from "../icons/ServingSizeIconSmall";
import StarIconSmall from "../icons/StarIconSmall";
import TimeIconSmall from "../icons/TimeIconSmall";
interface FavoriteResultProps {
recipe: Recipe;
};
export default function FavoriteResult({ recipe }: FavoriteResultProps) {
return <>
<div className="w-full p-2 border-b border-gray-200 hover:bg-gray-100 duration-200 flex items-center flex-col md:flex-row even:bg-[#f8f8f8] cursor-pointer">
<img className="bg-gray-50 size-56 md:size-40 rounded-md border-0" src="/v1/web/static/img/recipe_placeholder.png" />
<div className="text-gray-700 p-4 flex flex-col items-center md:items-start w-full">
<div className="flex flex-col md:flex-row items-center md:items-start justify-between w-full">
<div className="flex flex-col items-center md:items-start">
<h3 className="text-xl font-semibold text-black pb-1">
{recipe.Title} <span className="text-sm font-normal hidden md:inline">{recipe.Category}</span>
</h3>
<div className="text-sm flex gap-x-3 gap-y-1 items-center flex-wrap">
<span className="flex gap-x-1 align-center">
<TimeIconSmall />
{recipe.Duration.Total} min
</span>
<span className="flex gap-x-1 align-center">
{Array.from({ length: recipe.Difficulty }).map((_, i) => (
<StarIconSmall key={`${recipe.Id}-filled-${i}`} filled={true} />
))}
{Array.from({ length: 5 - recipe.Difficulty }).map((_, i) => (
<StarIconSmall key={`${recipe.Id}-unfilled-${i}`} filled={false} />
))}
</span>
<span className="flex gap-x-1 align-center">
<ServingSizeIconSmall />
Serves {recipe.Serves}
</span>
</div>
</div>
<div className="mb-2 mt-4 md:my-0 hidden md:block">
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 9.1371C2 14 6.01943 16.5914 8.96173 18.9109C10 19.7294 11 20.5 12 20.5C13 20.5 14 19.7294 15.0383 18.9109C17.9806 16.5914 22 14 22 9.1371C22 4.27416 16.4998 0.825464 12 5.50063C7.50016 0.825464 2 4.27416 2 9.1371Z"
fill="currentColor"></path>
</svg>
</div>
</div>
<p className="text-sm my-2 text-center md:text-left overflow-hidden text-ellipsis"
style={{ display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical" }}>
{recipe.Description}
</p>
</div>
</div>
</>;
}

View File

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

View File

@ -1,110 +1,22 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import Banner from "../components/Banner";
import RecipeSearchBar from "../components/inputs/RecipeSearchBar";
import type { Recipe } from "../types/recipe";
import FavoriteResult from "../components/items/FavoriteResult";
import RecipeSearchResult from "../components/items/RecipeSearchResult";
export default function Favorites() {
const [recipes, setRecipes] = useState<Recipe[]>([]);
// BUG: Remove this
useEffect(() => {
const recipe: Recipe = {
Id: 1,
Title: "Classic Pancakes",
Description: "Fluffy and delicious pancakes perfect for breakfast.",
Instructions: [
"In a bowl, mix all the dry ingredients.",
"In another bowl, whisk the wet ingredients.",
"Combine both mixes until smooth.",
"Heat a non-stick skillet and pour batter.",
"Cook until bubbles form, flip and cook the other side.",
"Serve warm with syrup."
],
Serves: 4,
Difficulty: 2, // scale 1-5 (example)
Duration: {
Total: 20,
Prep: 5,
Cook: 15
},
Category: "breakfast",
Ingredients: [
{ Name: "Flour", Quantity: "2 cups" },
{ Name: "Milk", Quantity: "1.5 cups" },
{ Name: "Egg", Quantity: "1 large" },
{ Name: "Baking Powder", Quantity: "2 teaspoons" },
{ Name: "Salt", Quantity: "0.5 teaspoon" },
{ Name: "Sugar", Quantity: "1 tablespoon" }
],
UserId: 101,
Modified: new Date("2025-10-30T09:00:00"),
Created: new Date("2025-10-01T08:30:00"),
Tags: [
{ Id: 1, Name: "easy", Created: new Date("2025-01-01T12:00:00") },
{ Id: 2, Name: "quick", Created: new Date("2025-01-02T12:00:00") },
{ Id: 3, Name: "breakfast", Created: new Date("2025-01-03T12:00:00") }
],
Favorite: true
};
const recipe2: Recipe = {
Id: 2,
Title: "Classic Pancakes",
Description: "Fluffy and delicious pancakes perfect for breakfast.",
Instructions: [
"In a bowl, mix all the dry ingredients.",
"In another bowl, whisk the wet ingredients.",
"Combine both mixes until smooth.",
"Heat a non-stick skillet and pour batter.",
"Cook until bubbles form, flip and cook the other side.",
"Serve warm with syrup."
],
Serves: 4,
Difficulty: 2, // scale 1-5 (example)
Duration: {
Total: 20,
Prep: 5,
Cook: 15
},
Category: "breakfast",
Ingredients: [
{ Name: "Flour", Quantity: "2 cups" },
{ Name: "Milk", Quantity: "1.5 cups" },
{ Name: "Egg", Quantity: "1 large" },
{ Name: "Baking Powder", Quantity: "2 teaspoons" },
{ Name: "Salt", Quantity: "0.5 teaspoon" },
{ Name: "Sugar", Quantity: "1 tablespoon" }
],
UserId: 101,
Modified: new Date("2025-10-30T09:00:00"),
Created: new Date("2025-10-01T08:30:00"),
Tags: [
{ Id: 1, Name: "easy", Created: new Date("2025-01-01T12:00:00") },
{ Id: 2, Name: "quick", Created: new Date("2025-01-02T12:00:00") },
{ Id: 3, Name: "breakfast", Created: new Date("2025-01-03T12:00:00") }
],
Favorite: true
};
setRecipes([recipe, recipe2]);
}, []);
return (
<>
<Banner content="Favorites" />
<RecipeSearchBar filters={null} redirect={false} searchOnLoad={true} favorites={true} />
<RecipeSearchBar redirect={false} searchOnLoad={true} favorites={true} setRecipes={setRecipes}/>
<hr className="text-gray-300 w-full" />
<div id="result-list" className="flex flex-col w-full p-4 items-center">
{recipes.length < 1 ? (
<p className="text-gray-700 text-sm py-4">No results</p>
) : (
<>
{recipes.map(recipe => <FavoriteResult key={recipe.Id} recipe={recipe} />)}
<p className="text-gray-700 text-sm py-4">End of results</p>
</>
)}
<div className="flex flex-col w-full p-4 items-center">
{recipes?.map(recipe => <RecipeSearchResult key={recipe.Id} recipe={recipe} />)}
<p className="text-gray-700 text-sm py-4">{recipes ? "End of results" : "No results"}</p>
</div>
</>
);

View File

@ -12,6 +12,7 @@ import { GetRecipeOfTheWeek } from "../services/RecipeService";
import { isApiError, type ApiError } from "../types/api/error";
import { AuthContext } from "../context/AuthContext";
import { GetAuthenticatedUserMadeRecipes, GetAuthenticateUserViewedRecipes } from "../services/UserService";
import { type SearchFilters } from "../types/search";
export default function Home() {
// Context
@ -54,7 +55,7 @@ export default function Home() {
}
void fetch();
}, []);
}, [isLoggedIn]);
// BUG: Prob remove
useEffect(() => {
@ -87,7 +88,7 @@ export default function Home() {
<section className="w-full flex flex-col items-center justify-center my-8 py-4">
<Banner content="Craving Something Specific?" />
<div className="w-full md:w-3/4">
<RecipeSearchBar filters={null} redirect={true} favorites={false} searchOnLoad={false} />
<RecipeSearchBar redirect={true} favorites={false} searchOnLoad={false} setRecipes={null} />
</div>
<div className="hidden" id="result-list"></div>
</section>

22
web/src/pages/Search.tsx Normal file
View File

@ -0,0 +1,22 @@
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";
export default function SearchPage() {
const [recipes, setRecipes] = useState<Recipe[]>([]);
return (
<>
<Banner content="Recipe Search" />
<RecipeSearchBar redirect={false} searchOnLoad={true} favorites={false} setRecipes={setRecipes} />
<hr className="text-gray-300 w-full" />
<div className="flex flex-col w-full p-4 items-center">
{recipes?.map(recipe => <RecipeSearchResult key={recipe.Id} recipe={recipe} />)}
<p className="text-gray-700 text-sm py-4">{recipes ? "End of results" : "No reuslts"}</p>
</div>
</>
);
}

View File

@ -1,7 +1,8 @@
import axios from "axios";
import type { GetRecipeOfTheWeekResponse, GetRecipeResponse } from "../types/api/recipe";
import type { 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";
export async function GetRecipeOfTheWeek(): Promise<Recipe | ApiError> {
@ -31,3 +32,17 @@ export async function GetRecipe(id: number): Promise<Recipe | ApiError> {
return response.data.recipe;
}
export async function SearchRecipes(filters: SearchFilters): Promise<Recipe[] | ApiError> {
const response = await axios.post<SearchRecipesResponse>("http://localhost:3000/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;
}

View File

@ -11,3 +11,9 @@ export interface GetRecipeResponse {
message: string;
recipe?: Recipe;
}
export interface SearchRecipesResponse {
status: number;
message: string;
recipes?: Recipe[];
}

4
web/src/types/filters.ts Normal file
View File

@ -0,0 +1,4 @@
export interface SearchFilters {
}

View File

@ -1,8 +1,11 @@
export type FilterBitKey = "MealType" | "Time" | "Difficulty" | "ServingSize";
export interface SearchFilters {
Search: string;
MealType: number;
Time: number;
Difficulty: number;
ServingSize: number;
Favorites: boolean;
};