feat: implement frontend Web UI pages

- Add types for all API models (MealPlan, Recipe, Ingredient, etc.)
- Add API client with mealPlannerApi wrapper for all endpoints
- Implement Dashboard with weekly meal plan grid view
- Implement Pantry page with add/remove functionality
- Implement MealDetail page with recipe display
- Implement ShoppingList page with aisle grouping
- Add ShoppingList route to App.tsx
- Add vite-env.d.ts for Vite env type support
This commit is contained in:
2026-05-04 20:54:54 -07:00
parent 933a0cc9db
commit 08e196b0ab
8 changed files with 773 additions and 11 deletions
+174 -3
View File
@@ -1,8 +1,179 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { mealPlannerApi } from '../api'
import type { HomePantryItem, Ingredient } from '../types'
import { useState } from 'react'
export default function Pantry() {
const queryClient = useQueryClient()
const [showAddForm, setShowAddForm] = useState(false)
const [selectedIngredient, setSelectedIngredient] = useState<string>('')
const [quantity, setQuantity] = useState<string>('')
const [unit, setUnit] = useState<string>('')
const { data: pantryItems, isLoading } = useQuery<HomePantryItem[]>({
queryKey: ['pantry'],
queryFn: () => mealPlannerApi.pantry.list().then(r => r.data),
})
const { data: ingredients } = useQuery<Ingredient[]>({
queryKey: ['ingredients'],
queryFn: () => mealPlannerApi.recipes.listIngredients().then(r => r.data),
})
const addMutation = useMutation({
mutationFn: (data: { ingredient_id: string; quantity?: number; unit?: string }) =>
mealPlannerApi.pantry.add(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pantry'] })
setShowAddForm(false)
setSelectedIngredient('')
setQuantity('')
setUnit('')
},
})
const removeMutation = useMutation({
mutationFn: (id: string) => mealPlannerApi.pantry.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pantry'] })
},
})
const handleAdd = () => {
if (!selectedIngredient) return
addMutation.mutate({
ingredient_id: selectedIngredient,
quantity: quantity ? parseFloat(quantity) : undefined,
unit: unit || undefined,
})
}
if (isLoading) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
</div>
)
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Home Pantry</h1>
<p className="text-gray-500">Manage your pantry items...</p>
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Home Pantry</h1>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
>
{showAddForm ? 'Cancel' : 'Add Item'}
</button>
</div>
{showAddForm && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Add Pantry Item</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ingredient</label>
<select
value={selectedIngredient}
onChange={(e) => setSelectedIngredient(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
>
<option value="">Select ingredient...</option>
{ingredients?.map(ing => (
<option key={ing.id} value={ing.id}>
{ing.name} {ing.aisle ? `(${ing.aisle})` : ''}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Quantity</label>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
placeholder="e.g., 5"
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
<input
type="text"
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder="e.g., cans, lbs"
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div className="flex items-end">
<button
onClick={handleAdd}
disabled={!selectedIngredient || addMutation.isPending}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{addMutation.isPending ? 'Adding...' : 'Add'}
</button>
</div>
</div>
</div>
)}
{pantryItems && pantryItems.length > 0 ? (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Item</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Aisle</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Quantity</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expires</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{pantryItems.map((item) => (
<tr key={item.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-medium text-gray-900">
{item.ingredient?.name || 'Unknown'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.ingredient?.aisle || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.quantity} {item.unit || ''}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.expires_at ? new Date(item.expires_at).toLocaleDateString() : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={() => removeMutation.mutate(item.id)}
className="text-red-600 hover:text-red-800"
disabled={removeMutation.isPending}
>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="bg-white rounded-lg shadow p-8 text-center">
<p className="text-gray-500 mb-4">Your pantry is empty</p>
<button
onClick={() => setShowAddForm(true)}
className="text-blue-600 hover:text-blue-800"
>
Add your first item
</button>
</div>
)}
</div>
)
}
}