Files
luna-recipes/frontend/src/components/recipe/IngredientPickerModal.tsx
clawd 301e42b1dc v2.1.2026 — PostgreSQL, Auth, Household, Shopping Smart-Add, Docker
Backend:
- SQLite → PostgreSQL (pg_trgm search, async services)
- All services rewritten to async with pg Pool
- Data imported (50 recipes, 8 categories)
- better-sqlite3 removed

Frontend:
- ProfilePage complete (edit profile, change password, no more stubs)
- HouseholdCard (create, join via code, manage members, leave)
- Shopping scope toggle (personal/household)
- IngredientPickerModal (smart add with basics filter)
- Auth token auto-attached to all API calls (token.ts)
- Removed PlaceholderPage

Infrastructure:
- Docker Compose (backend + frontend + postgres)
- Dockerfile for backend (node:22-alpine + tsx)
- Dockerfile for frontend (vite build + nginx)
- nginx.conf with API proxy + SPA fallback
- .env.example for production secrets

Spec:
- AUTH-V2-SPEC updated: household join flow, manual shopping items
2026-02-18 17:26:24 +00:00

155 lines
5.5 KiB
TypeScript

import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, ShoppingCart } from 'lucide-react'
const BASICS = ['salz', 'pfeffer', 'zucker', 'mehl', 'öl', 'wasser', 'essig']
interface Ingredient {
name: string
amount?: number
unit?: string
}
interface Props {
open: boolean
onClose: () => void
ingredients: Ingredient[]
onSubmit: (selected: Ingredient[]) => void
loading?: boolean
}
export function IngredientPickerModal({ open, onClose, ingredients, onSubmit, loading }: Props) {
const [checked, setChecked] = useState<Set<number>>(() => new Set(ingredients.map((_, i) => i)))
const toggle = (i: number) => {
setChecked((prev) => {
const next = new Set(prev)
if (next.has(i)) next.delete(i)
else next.add(i)
return next
})
}
const selectAll = () => setChecked(new Set(ingredients.map((_, i) => i)))
const selectNone = () => setChecked(new Set())
const deselectBasics = () => {
setChecked((prev) => {
const next = new Set(prev)
ingredients.forEach((ing, i) => {
if (BASICS.some((b) => ing.name.toLowerCase().includes(b))) {
next.delete(i)
}
})
return next
})
}
const handleSubmit = () => {
const selected = ingredients.filter((_, i) => checked.has(i))
if (selected.length > 0) onSubmit(selected)
}
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
className="bg-cream w-full sm:max-w-md sm:rounded-2xl rounded-t-2xl max-h-[85vh] flex flex-col"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-sand">
<h2 className="font-display text-lg text-espresso">Zutaten auswählen</h2>
<button
onClick={onClose}
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey"
>
<X size={20} />
</button>
</div>
{/* Quick actions */}
<div className="flex gap-2 px-4 py-3 border-b border-sand/50">
<button
onClick={selectAll}
className="px-3 py-1.5 rounded-lg bg-sand/50 text-xs font-medium text-espresso hover:bg-sand transition-colors"
>
Alles
</button>
<button
onClick={deselectBasics}
className="px-3 py-1.5 rounded-lg bg-sand/50 text-xs font-medium text-espresso hover:bg-sand transition-colors"
>
Basics ab
</button>
<button
onClick={selectNone}
className="px-3 py-1.5 rounded-lg bg-sand/50 text-xs font-medium text-espresso hover:bg-sand transition-colors"
>
Nichts
</button>
</div>
{/* Ingredient list */}
<div className="flex-1 overflow-y-auto p-4 space-y-1">
{ingredients.map((ing, i) => (
<button
key={i}
onClick={() => toggle(i)}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-sand/30 transition-colors min-h-[44px]"
>
<div
className="w-5 h-5 rounded-md border-2 flex-shrink-0 flex items-center justify-center transition-colors"
style={{
borderColor: checked.has(i) ? '#C4737E' : '#E8E0D8',
backgroundColor: checked.has(i) ? '#C4737E' : 'transparent',
}}
>
{checked.has(i) && (
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M2.5 7L5.5 10L11.5 4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
<span className={`text-sm flex-1 text-left ${checked.has(i) ? 'text-espresso' : 'text-warm-grey line-through'}`}>
{ing.name}
</span>
{(ing.amount || ing.unit) && (
<span className="text-xs text-warm-grey">
{[ing.amount, ing.unit].filter(Boolean).join(' ')}
</span>
)}
</button>
))}
</div>
{/* Submit */}
<div className="p-4 border-t border-sand">
<button
onClick={handleSubmit}
disabled={checked.size === 0 || loading}
className="w-full flex items-center justify-center gap-2 bg-secondary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50 transition-opacity"
>
<ShoppingCart size={18} />
{loading
? 'Wird hinzugefügt...'
: `${checked.size} Artikel hinzufügen`}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}