From c6c5d0e3f4d522977b076c54bddaeaa0d7bd7f5d Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Tue, 24 Mar 2026 03:12:27 -0400 Subject: [PATCH] feat(frontend): implement recipe list page with search and pagination - Create API client service (src/services/api.ts) with all CRUD operations - Create useRecipes hook for data fetching with search and pagination - Create RecipeCard component for displaying individual recipes - Implement RecipeListPage with search bar, empty state, and error handling - Add grid layout with responsive design (1-3 columns) - Implement 'Load More' button for pagination - Add recipe metadata display (servings, time, last cooked) - Update TODO.md to mark task as complete --- TODO.md | 12 +- frontend/src/components/RecipeCard.tsx | 87 ++++++++++++++ frontend/src/hooks/useRecipes.ts | 90 +++++++++++++++ frontend/src/pages/RecipeListPage.tsx | 153 +++++++++++++++++++++++-- frontend/src/services/api.ts | 140 ++++++++++++++++++++++ 5 files changed, 471 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/RecipeCard.tsx create mode 100644 frontend/src/hooks/useRecipes.ts create mode 100644 frontend/src/services/api.ts diff --git a/TODO.md b/TODO.md index 4637707..df8b4e3 100644 --- a/TODO.md +++ b/TODO.md @@ -19,7 +19,7 @@ - [x] Initialize React + Vite project - [x] Configure Tailwind CSS - [x] Set up React Router -- [ ] Create recipe list page +- [x] Create recipe list page - [ ] Create recipe detail/edit page - [ ] Implement cook mode UI @@ -46,6 +46,16 @@ ## ✅ Completed Tasks ### 2026-03-24 +- **Recipe list page implementation** + - Created API client service (src/services/api.ts) with all CRUD operations + - Created useRecipes hook for data fetching with search and pagination + - Created RecipeCard component for displaying individual recipes + - Implemented RecipeListPage with search bar, empty state, loading state, error handling + - Added grid layout with responsive design (1-3 columns) + - Implemented "Load More" button for pagination + - Added recipe count display and meta information (servings, time, last cooked) + - Verified TypeScript compilation and Vite build succeed + - **React Router setup** - Updated main.tsx to wrap App in BrowserRouter - Configured routes in App.tsx with navigation header diff --git a/frontend/src/components/RecipeCard.tsx b/frontend/src/components/RecipeCard.tsx new file mode 100644 index 0000000..f35cb2e --- /dev/null +++ b/frontend/src/components/RecipeCard.tsx @@ -0,0 +1,87 @@ +/** + * RecipeCard - Displays a single recipe in the list view + */ + +import { Link } from 'react-router-dom'; +import type { Recipe } from '../types/recipe'; + +interface RecipeCardProps { + recipe: Recipe; +} + +/** + * Format time in minutes to readable string + */ +function formatTime(minutes?: number): string { + if (!minutes) return ''; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; +} + +/** + * Format date timestamp to readable string + */ +function formatDate(timestamp?: number): string { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + return date.toLocaleDateString(); +} + +export function RecipeCard({ recipe }: RecipeCardProps) { + const totalTime = (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0); + + return ( + +
+ {/* Title */} +

+ {recipe.title} +

+ + {/* Description */} + {recipe.description && ( +

+ {recipe.description} +

+ )} + + {/* Meta information */} +
+ {recipe.servings && ( +
+ 🍽️ + {recipe.servings} servings +
+ )} + + {totalTime > 0 && ( +
+ ⏱️ + {formatTime(totalTime)} +
+ )} + + {recipe.last_cooked_at && ( +
+ 👨‍🍳 + Last cooked {formatDate(recipe.last_cooked_at)} +
+ )} +
+ + {/* Footer with ingredient count */} +
+
+ {recipe.ingredients.length} ingredients + View Recipe → +
+
+
+ + ); +} diff --git a/frontend/src/hooks/useRecipes.ts b/frontend/src/hooks/useRecipes.ts new file mode 100644 index 0000000..58681af --- /dev/null +++ b/frontend/src/hooks/useRecipes.ts @@ -0,0 +1,90 @@ +/** + * Hook for fetching and managing recipes + */ + +import { useState, useEffect } from 'react'; +import { fetchRecipes } from '../services/api'; +import type { Recipe } from '../types/recipe'; + +interface UseRecipesOptions { + search?: string; + limit?: number; +} + +interface UseRecipesResult { + recipes: Recipe[]; + loading: boolean; + error: string | null; + hasMore: boolean; + loadMore: () => void; + refresh: () => void; +} + +/** + * Hook to fetch recipes with search and pagination + */ +export function useRecipes(options: UseRecipesOptions = {}): UseRecipesResult { + const { search = '', limit = 20 } = options; + const [recipes, setRecipes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(true); + + const loadRecipes = async (currentOffset: number, append: boolean = false) => { + setLoading(true); + setError(null); + + try { + const data = await fetchRecipes({ + search: search || undefined, + offset: currentOffset, + limit, + }); + + if (append) { + setRecipes(prev => [...prev, ...data]); + } else { + setRecipes(data); + } + + // If we got fewer recipes than requested, we've reached the end + setHasMore(data.length === limit); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load recipes'); + setRecipes([]); + } finally { + setLoading(false); + } + }; + + // Load recipes when search term changes + useEffect(() => { + setOffset(0); + setHasMore(true); + loadRecipes(0, false); + }, [search]); + + const loadMore = () => { + if (!loading && hasMore) { + const newOffset = offset + limit; + setOffset(newOffset); + loadRecipes(newOffset, true); + } + }; + + const refresh = () => { + setOffset(0); + setHasMore(true); + loadRecipes(0, false); + }; + + return { + recipes, + loading, + error, + hasMore, + loadMore, + refresh, + }; +} diff --git a/frontend/src/pages/RecipeListPage.tsx b/frontend/src/pages/RecipeListPage.tsx index c5544c1..bbac56f 100644 --- a/frontend/src/pages/RecipeListPage.tsx +++ b/frontend/src/pages/RecipeListPage.tsx @@ -1,21 +1,154 @@ /** * RecipeListPage - Displays a list of all recipes with search and filtering */ + +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useRecipes } from '../hooks/useRecipes'; +import { RecipeCard } from '../components/RecipeCard'; + export function RecipeListPage() { + const [searchTerm, setSearchTerm] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + + const { recipes, loading, error, hasMore, loadMore } = useRecipes({ + search: searchQuery, + limit: 20, + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setSearchQuery(searchTerm); + }; + + const handleClearSearch = () => { + setSearchTerm(''); + setSearchQuery(''); + }; + return (
+ {/* Header */}
-

My Recipes

-

- Browse and search your recipe collection -

-
- -
-

- Recipe list will be implemented here (next task) -

+
+
+

My Recipes

+

+ Browse and search your recipe collection +

+
+ + + New Recipe + +
+ + {/* Search Bar */} +
+
+ setSearchTerm(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + {searchQuery && ( + + )} +
+ +
+ + {searchQuery && ( +

+ Searching for: "{searchQuery}" +

+ )}
+ + {/* Error State */} + {error && ( +
+

+ Error: {error} +

+
+ )} + + {/* Loading State (first load) */} + {loading && recipes.length === 0 && ( +
+
+

Loading recipes...

+
+ )} + + {/* Empty State */} + {!loading && !error && recipes.length === 0 && ( +
+
🍳
+

+ {searchQuery ? 'No recipes found' : 'No recipes yet'} +

+

+ {searchQuery + ? 'Try a different search term' + : 'Get started by adding your first recipe'} +

+ {!searchQuery && ( + + Add Your First Recipe + + )} +
+ )} + + {/* Recipe Grid */} + {recipes.length > 0 && ( + <> +
+ {recipes.map((recipe) => ( + + ))} +
+ + {/* Load More Button */} + {hasMore && ( +
+ +
+ )} + + {/* Results summary */} +
+ Showing {recipes.length} recipe{recipes.length !== 1 ? 's' : ''} +
+ + )}
); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..d9824b5 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,140 @@ +/** + * API client for Recipe Manager backend + */ + +import type { Recipe, Tag, ApiResponse } from '../types/recipe'; + +const API_BASE_URL = 'http://localhost:3000/api'; + +/** + * Fetch recipes with optional filters + */ +export async function fetchRecipes(params?: { + search?: string; + offset?: number; + limit?: number; +}): Promise { + const url = new URL(`${API_BASE_URL}/recipes`); + + if (params?.search) { + url.searchParams.set('search', params.search); + } + if (params?.offset !== undefined) { + url.searchParams.set('offset', params.offset.toString()); + } + if (params?.limit !== undefined) { + url.searchParams.set('limit', params.limit.toString()); + } + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Failed to fetch recipes: ${response.statusText}`); + } + + const result: ApiResponse = await response.json(); + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to fetch recipes'); + } + + return result.data; +} + +/** + * Fetch a single recipe by ID + */ +export async function fetchRecipe(id: number): Promise { + const response = await fetch(`${API_BASE_URL}/recipes/${id}`); + if (!response.ok) { + throw new Error(`Failed to fetch recipe: ${response.statusText}`); + } + + const result: ApiResponse = await response.json(); + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to fetch recipe'); + } + + return result.data; +} + +/** + * Create a new recipe + */ +export async function createRecipe(recipe: Omit): Promise { + const response = await fetch(`${API_BASE_URL}/recipes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(recipe), + }); + + if (!response.ok) { + throw new Error(`Failed to create recipe: ${response.statusText}`); + } + + const result: ApiResponse = await response.json(); + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to create recipe'); + } + + return result.data; +} + +/** + * Update a recipe + */ +export async function updateRecipe(id: number, updates: Partial>): Promise { + const response = await fetch(`${API_BASE_URL}/recipes/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updates), + }); + + if (!response.ok) { + throw new Error(`Failed to update recipe: ${response.statusText}`); + } + + const result: ApiResponse = await response.json(); + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to update recipe'); + } + + return result.data; +} + +/** + * Delete a recipe + */ +export async function deleteRecipe(id: number): Promise { + const response = await fetch(`${API_BASE_URL}/recipes/${id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(`Failed to delete recipe: ${response.statusText}`); + } + + const result: ApiResponse<{ deleted: number }> = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Failed to delete recipe'); + } +} + +/** + * Fetch all tags + */ +export async function fetchTags(): Promise { + const response = await fetch(`${API_BASE_URL}/tags`); + if (!response.ok) { + throw new Error(`Failed to fetch tags: ${response.statusText}`); + } + + const result: ApiResponse = await response.json(); + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to fetch tags'); + } + + return result.data; +}