Compare commits

...

2 Commits

Author SHA1 Message Date
Hayden Hargreaves
04b6ac918a (FEAT): Implemented serving size toggle 2025-12-28 17:56:54 -07:00
Hayden Hargreaves
54c557bec5 (FEAT): Finished fixing up the search page! 2025-12-28 17:39:28 -07:00
8 changed files with 44 additions and 86 deletions

View File

@ -193,7 +193,12 @@ 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

View File

@ -4,7 +4,7 @@ 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)

View File

@ -221,7 +221,10 @@ func isBitActive(bits, pos int) bool {
// TODO: Pagination is required, to provide infinite scroll.
//
// TODO: This does not work in the current build, the DB does not return valid values.
func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]domain.Recipe, error) {
//
// 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 {
@ -305,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
@ -392,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

View File

@ -40,10 +40,10 @@ export default function RecipeMetaData({ recipe }: RecipeMetaDataProps) {
>
<div className="flex gap-x-1 my-2">
{Array.from({ length: recipe?.Difficulty ?? 0 }).map((_, i) => (
<StarIcon key={`${recipe?.Id}-filled-${i}`} filled={true} />
<StarIcon key={`${recipe?.Id}-filled-${i}`} size={6} filled={true} />
))}
{Array.from({ length: 5 - (recipe?.Difficulty ?? 0) }).map((_, i) => (
<StarIcon key={`${recipe?.Id}-unfilled-${i}`} filled={false} />
<StarIcon key={`${recipe?.Id}-unfilled-${i}`} size={6} filled={false} />
))}
</div>
<p>{displayDifficulty(recipe?.Difficulty ?? 0)}</p>

View File

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

View File

@ -1,16 +1,31 @@
import { Fragment } from "react/jsx-runtime";
import type { RecipeIngredient, RecipeIngredientSection } from "../../types/recipe";
import { useState } from "react";
interface IngredientListProps {
sections: RecipeIngredientSection[];
ingredients: RecipeIngredient[];
}
const CLASSES_ACTIVE = "p-1 bg-blue-100 border border-blue-200 h-fit duration-300 cursor-pointer";
const CLASSES_INACTIVE = "p-1 bg-gray-100 border border-gray-200 h-fit duration-300 cursor-pointer hover:bg-gray-200 hover:border-gray-300";
export default function IngredientList({ sections, ingredients }: IngredientListProps) {
const [scale, setScale] = useState<number>(1);
return (
<>
<div className="px-4 py-8 md:px-8">
<h2 className="text-2xl text-gray-800 font-semibold mb-2">Ingredients</h2>
<div className="flex justify-between items-center">
<h2 className="text-2xl text-gray-800 font-semibold mb-2">Ingredients</h2>
{/* Serving size toggle */}
<div className="flex gap-x-1">
<button className={scale === 0.5 ? CLASSES_ACTIVE : CLASSES_INACTIVE} onClick={() => setScale(0.5)}> .5x </button>
<button className={scale === 1 ? CLASSES_ACTIVE : CLASSES_INACTIVE} onClick={() => setScale(1)}> 1x </button>
<button className={scale === 2 ? CLASSES_ACTIVE : CLASSES_INACTIVE} onClick={() => setScale(2)}> 2x </button>
<button className={scale === 3 ? CLASSES_ACTIVE : CLASSES_INACTIVE} onClick={() => setScale(3)}> 3x </button>
</div>
</div>
<hr className="text-gray-300" />
{sections?.map(section => (
<Fragment key={section.Id}>
@ -33,7 +48,7 @@ export default function IngredientList({ sections, ingredients }: IngredientList
</svg>
</span>
<span className="font-semibold mr-2">
{ingredient.Amount > 0 ? ingredient.Amount : null} {ingredient.Unit}
{ingredient.Amount > 0 ? (ingredient.Amount * scale) : null} {ingredient.Unit}
</span>
{ingredient.Name}
</li>

View File

@ -41,10 +41,10 @@ export default function RecipeSearchResult({ recipe }: RecipeSearchResultProps)
</span>
<span className="flex gap-x-1 align-center">
{Array.from({ length: recipe.Difficulty }).map((_, i) => (
<StarIcon key={`${recipe.Id}-filled-${i}`} filled={true} />
<StarIcon key={`${recipe.Id}-filled-${i}`} size={4} filled={true} />
))}
{Array.from({ length: 5 - (recipe.Difficulty) }).map((_, i) => (
<StarIcon key={`${recipe.Id}-unfilled-${i}`} filled={false} />
<StarIcon key={`${recipe.Id}-unfilled-${i}`} size={4} filled={false} />
))}
</span>
<span className="flex gap-x-1 align-center">

View File

@ -59,7 +59,7 @@ export default function RecipePage() {
return recipe ? (
<>
<img className="bg-gray-100 w-full h-96 mx-auto mb-8" src={RecipePlaceholder} />
<img className="bg-gray-100 w-full h-64 md: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}</h1>
<p className="text-sm mt-2 mb-1 text-gray-700">{author ? author.Name : "Loading..."}</p>