Public Access
feat: allow adding pantry items by text input with auto-ingredient creation
- backend: expose POST /api/ingredients on public router so frontend can create ingredients without admin token - frontend/api: point listIngredients and createIngredient to /api/ingredients - frontend/pantry: replace ingredient dropdown with searchable text input + fuzzy matching + auto-create
This commit is contained in:
@@ -55,6 +55,26 @@ def get_ingredient(ingredient_id: UUID, db: Session = Depends(get_db)) -> Ingred
|
||||
return row
|
||||
|
||||
|
||||
@public_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_ingredient_public(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient:
|
||||
row = Ingredient(
|
||||
name=payload.name,
|
||||
name_lower=payload.name.lower(),
|
||||
aliases=payload.aliases,
|
||||
aisle=payload.aisle,
|
||||
unit=payload.unit,
|
||||
typical_price=payload.typical_price,
|
||||
)
|
||||
db.add(row)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="ingredient name already exists")
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@admin_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_ingredient(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient:
|
||||
row = Ingredient(
|
||||
|
||||
@@ -29,8 +29,8 @@ export const mealPlannerApi = {
|
||||
get: (id: string) => api.get(`/recipes/${id}`),
|
||||
create: (data: any) => api.post('/recipes', data),
|
||||
delete: (id: string) => api.delete(`/recipes/${id}`),
|
||||
listIngredients: () => api.get('/recipes/ingredients'),
|
||||
createIngredient: (data: any) => api.post('/recipes/ingredients', data),
|
||||
listIngredients: () => api.get('/ingredients?limit=500'),
|
||||
createIngredient: (data: any) => api.post('/ingredients', data),
|
||||
},
|
||||
|
||||
meals: {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { HomePantryItem, Ingredient } from '../types'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Card, CardBody } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Select } from '../components/ui/Select'
|
||||
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||
import { EmptyState } from '../components/ui/EmptyState'
|
||||
import { showToast } from '../lib/toast'
|
||||
@@ -14,10 +13,13 @@ import { showToast } from '../lib/toast'
|
||||
export default function Pantry() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [selectedIngredient, setSelectedIngredient] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
/* ingredient name typed by user */
|
||||
const [ingredientName, setIngredientName] = useState('')
|
||||
const [quantity, setQuantity] = useState('')
|
||||
const [unit, setUnit] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [aisle, setAisle] = useState('')
|
||||
|
||||
const { data: pantryItems, isLoading } = useQuery<HomePantryItem[]>({
|
||||
queryKey: ['pantry'],
|
||||
@@ -29,15 +31,23 @@ export default function Pantry() {
|
||||
queryFn: () => mealPlannerApi.recipes.listIngredients().then(r => r.data),
|
||||
})
|
||||
|
||||
/* fuzzy match existing ingredient */
|
||||
const matchedIngredient = ingredientName.trim().length > 0
|
||||
? ingredients?.find(
|
||||
ing => ing.name.toLowerCase() === ingredientName.trim().toLowerCase()
|
||||
)
|
||||
: undefined
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: { ingredient_id: string; quantity?: number; unit?: string }) =>
|
||||
mealPlannerApi.pantry.add(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pantry'] })
|
||||
setShowAddForm(false)
|
||||
setSelectedIngredient('')
|
||||
setIngredientName('')
|
||||
setQuantity('')
|
||||
setUnit('')
|
||||
setAisle('')
|
||||
showToast.success('Item added to pantry')
|
||||
},
|
||||
onError: () => {
|
||||
@@ -56,12 +66,45 @@ export default function Pantry() {
|
||||
},
|
||||
})
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!selectedIngredient) return
|
||||
async function handleAdd() {
|
||||
const name = ingredientName.trim()
|
||||
if (!name) {
|
||||
showToast.error('Please enter an ingredient name')
|
||||
return
|
||||
}
|
||||
|
||||
let ingredientId = matchedIngredient?.id
|
||||
|
||||
if (!ingredientId) {
|
||||
try {
|
||||
const res = await mealPlannerApi.recipes.createIngredient({
|
||||
name,
|
||||
aisle: aisle.trim() || undefined,
|
||||
unit: unit.trim() || undefined,
|
||||
})
|
||||
const created = res.data as any
|
||||
ingredientId = created?.id
|
||||
// refresh ingredient list so next add sees it
|
||||
await queryClient.invalidateQueries({ queryKey: ['ingredients'] })
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 409) {
|
||||
showToast.error('Ingredient name already exists')
|
||||
} else {
|
||||
showToast.error('Failed to create ingredient')
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!ingredientId) {
|
||||
showToast.error('Could not resolve ingredient')
|
||||
return
|
||||
}
|
||||
|
||||
addMutation.mutate({
|
||||
ingredient_id: selectedIngredient,
|
||||
ingredient_id: ingredientId,
|
||||
quantity: quantity ? parseFloat(quantity) : undefined,
|
||||
unit: unit || undefined,
|
||||
unit: unit.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,11 +113,6 @@ export default function Pantry() {
|
||||
(item.ingredient?.aisle || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const ingredientOptions = ingredients?.map(ing => ({
|
||||
value: ing.id,
|
||||
label: ing.name + (ing.aisle ? ` (${ing.aisle})` : ''),
|
||||
})) || []
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
@@ -132,14 +170,17 @@ export default function Pantry() {
|
||||
<Card className="animate-slide-down">
|
||||
<CardBody>
|
||||
<h3 className="text-lg font-semibold text-surface-900 mb-4">Add Pantry Item</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<Select
|
||||
label="Ingredient"
|
||||
options={[{ value: '', label: 'Select ingredient...' }, ...ingredientOptions]}
|
||||
value={selectedIngredient}
|
||||
onChange={(e) => setSelectedIngredient(e.target.value)}
|
||||
<Input
|
||||
label="Ingredient name"
|
||||
value={ingredientName}
|
||||
onChange={(e) => setIngredientName(e.target.value)}
|
||||
placeholder="e.g., Avocado"
|
||||
/>
|
||||
{matchedIngredient && (
|
||||
<p className="mt-1 text-xs text-success-600">Matched existing ingredient ✓</p>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
label="Quantity"
|
||||
@@ -154,11 +195,17 @@ export default function Pantry() {
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
placeholder="cans, lbs, etc."
|
||||
/>
|
||||
<Input
|
||||
label="Aisle"
|
||||
value={aisle}
|
||||
onChange={(e) => setAisle(e.target.value)}
|
||||
placeholder="e.g., Produce"
|
||||
/>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
loading={addMutation.isPending}
|
||||
disabled={!selectedIngredient || addMutation.isPending}
|
||||
disabled={!ingredientName.trim() || addMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
Add
|
||||
|
||||
Reference in New Issue
Block a user