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
This commit is contained in:
154
frontend/src/components/recipe/IngredientPickerModal.tsx
Normal file
154
frontend/src/components/recipe/IngredientPickerModal.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user