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:
clawd
2026-02-18 17:26:24 +00:00
parent 30e44370a1
commit 301e42b1dc
49 changed files with 2167 additions and 1474 deletions

View File

@@ -5,7 +5,6 @@ import { PublicRoute } from './components/auth/AuthGuard'
import { HomePage } from './pages/HomePage'
import { RecipePage } from './pages/RecipePage'
import { SearchPage } from './pages/SearchPage'
import { PlaceholderPage } from './pages/PlaceholderPage'
import { ProfilePage } from './pages/ProfilePage'
import { RecipeFormPage } from './pages/RecipeFormPage'
import { ShoppingPage } from './pages/ShoppingPage'
@@ -18,22 +17,8 @@ export default function App() {
<BrowserRouter>
<Routes>
{/* Public Auth Routes */}
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
<Route
path="/register"
element={
<PublicRoute>
<RegisterPage />
</PublicRoute>
}
/>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* Main App Routes */}
<Route element={<AppShell />}>

View File

@@ -1,4 +1,8 @@
import { apiFetch } from './client'
import { setAuthToken, getAuthToken } from './token'
// Re-export for convenience
export { setAuthToken, getAuthToken }
export interface User {
id: string
@@ -35,66 +39,50 @@ export interface ChangePasswordData {
new_password: string
}
// Auth token storage (in memory, not localStorage)
let authToken: string | null = null
export function setAuthToken(token: string | null) {
authToken = token
}
export function getAuthToken() {
return authToken
}
// Add Authorization header to apiFetch when token exists
const authFetch = <T>(path: string, options?: RequestInit): Promise<T> => {
const headers = { ...options?.headers } as Record<string, string>
if (authToken) {
headers.Authorization = `Bearer ${authToken}`
}
return apiFetch(path, { ...options, headers })
}
export function register(data: RegisterData): Promise<AuthResponse> {
return apiFetch<AuthResponse>('/auth/register', {
method: 'POST',
body: JSON.stringify(data)
body: JSON.stringify(data),
})
}
export function login(data: LoginData): Promise<AuthResponse> {
return apiFetch<AuthResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(data)
body: JSON.stringify(data),
})
}
export function logout(): Promise<void> {
return authFetch<void>('/auth/logout', {
method: 'POST'
return apiFetch<void>('/auth/logout', {
method: 'POST',
})
}
export function getMe(): Promise<User> {
return authFetch<User>('/auth/me')
return apiFetch<User>('/auth/me')
}
export function updateProfile(data: UpdateProfileData): Promise<User> {
return authFetch<User>('/auth/me', {
return apiFetch<User>('/auth/me', {
method: 'PUT',
body: JSON.stringify(data)
body: JSON.stringify(data),
})
}
export function changePassword(data: ChangePasswordData): Promise<void> {
return authFetch<void>('/auth/me/password', {
return apiFetch<void>('/auth/me/password', {
method: 'PUT',
body: JSON.stringify(data)
body: JSON.stringify(data),
})
}
export function refreshToken(): Promise<AuthResponse> {
return apiFetch<AuthResponse>('/auth/refresh', {
method: 'POST'
return fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
}).then((res) => {
if (!res.ok) throw new Error('Refresh failed')
return res.json()
})
}
}

View File

@@ -1,17 +1,29 @@
import { getAuthToken } from './token'
const BASE_URL = '/api'
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const method = options?.method?.toUpperCase() || 'GET';
const headers: Record<string, string> = { ...options?.headers as Record<string, string> };
if (['POST', 'PUT', 'PATCH'].includes(method) && options?.body) {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
const method = options?.method?.toUpperCase() || 'GET'
const headers: Record<string, string> = { ...options?.headers as Record<string, string> }
// Auto-attach auth token
const token = getAuthToken()
if (token) {
headers.Authorization = `Bearer ${token}`
}
if (['POST', 'PUT', 'PATCH'].includes(method) && options?.body) {
headers['Content-Type'] = headers['Content-Type'] || 'application/json'
}
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers,
credentials: 'include',
})
if (!res.ok) {
throw new Error(`API Error: ${res.status} ${res.statusText}`)
const errorData = await res.json().catch(() => ({}))
throw new Error(errorData.message || `API Error: ${res.status}`)
}
return res.json()
}

View File

@@ -0,0 +1,49 @@
import { apiFetch } from './client'
export interface Household {
id: string
name: string
invite_code: string
created_at: string
members: HouseholdMember[]
}
export interface HouseholdMember {
user_id: string
email: string
display_name: string
avatar_url?: string
role: 'owner' | 'member'
joined_at: string
}
export function getMyHousehold() {
return apiFetch<{ success: boolean; data: Household }>('/households/mine')
}
export function createHousehold(name: string) {
return apiFetch<{ success: boolean; data: Household }>('/households', {
method: 'POST',
body: JSON.stringify({ name }),
})
}
export function joinHousehold(inviteCode: string) {
return apiFetch<{ success: boolean; data: Household }>('/households/join', {
method: 'POST',
body: JSON.stringify({ inviteCode }),
})
}
export function leaveHousehold(id: string) {
return apiFetch<{ success: boolean }>(`/households/${id}/leave`, {
method: 'DELETE',
})
}
export function regenerateInviteCode(id: string) {
return apiFetch<{ success: boolean; data: { invite_code: string } }>(
`/households/${id}/invite`,
{ method: 'POST' }
)
}

View File

@@ -17,21 +17,32 @@ export interface ShoppingGroup {
items: ShoppingItem[]
}
export function fetchShopping() {
return apiFetch<ShoppingGroup[]>('/shopping')
function scopeQuery(scope?: string) {
return scope ? `?scope=${scope}` : ''
}
export function addFromRecipe(recipeId: string) {
return apiFetch<{ added: number }>(`/shopping/from-recipe/${recipeId}`, { method: 'POST' })
export function fetchShopping(scope?: string) {
return apiFetch<ShoppingGroup[]>(`/shopping${scopeQuery(scope)}`)
}
export function addCustomItem(item: { name: string; amount?: number; unit?: string }) {
return apiFetch<ShoppingItem>('/shopping', {
export function addFromRecipe(recipeId: string, scope?: string) {
return apiFetch<{ added: number }>(`/shopping/from-recipe/${recipeId}${scopeQuery(scope)}`, { method: 'POST' })
}
export function addCustomItem(item: { name: string; amount?: number; unit?: string }, scope?: string) {
return apiFetch<ShoppingItem>(`/shopping${scopeQuery(scope)}`, {
method: 'POST',
body: JSON.stringify(item),
})
}
export function addItems(items: { name: string; amount?: number; unit?: string }[], scope?: string) {
return apiFetch<{ added: number }>(`/shopping/batch${scopeQuery(scope)}`, {
method: 'POST',
body: JSON.stringify({ items }),
})
}
export function toggleCheck(id: string) {
return apiFetch<ShoppingItem>(`/shopping/${id}/check`, { method: 'PATCH' })
}
@@ -40,10 +51,10 @@ export function deleteItem(id: string) {
return apiFetch<void>(`/shopping/${id}`, { method: 'DELETE' })
}
export function deleteAll() {
return apiFetch<void>('/shopping/all', { method: 'DELETE' })
export function deleteAll(scope?: string) {
return apiFetch<void>(`/shopping/all${scopeQuery(scope)}`, { method: 'DELETE' })
}
export function deleteChecked() {
return apiFetch<void>('/shopping/checked', { method: 'DELETE' })
export function deleteChecked(scope?: string) {
return apiFetch<void>(`/shopping/checked${scopeQuery(scope)}`, { method: 'DELETE' })
}

10
frontend/src/api/token.ts Normal file
View File

@@ -0,0 +1,10 @@
// Token storage — separate file to avoid circular imports
let authToken: string | null = null
export function setAuthToken(token: string | null) {
authToken = token
}
export function getAuthToken(): string | null {
return authToken
}

View File

@@ -0,0 +1,159 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Eye, EyeOff } from 'lucide-react'
import { changePassword } from '../../api/auth'
import { showToast } from '../../utils/toast'
interface Props {
open: boolean
onClose: () => void
}
export function ChangePasswordModal({ open, onClose }: Props) {
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showCurrent, setShowCurrent] = useState(false)
const [showNew, setShowNew] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [saving, setSaving] = useState(false)
const errors: string[] = []
if (newPassword && newPassword.length < 8) errors.push('Mindestens 8 Zeichen')
if (confirmPassword && newPassword !== confirmPassword) errors.push('Passwörter stimmen nicht überein')
const canSubmit =
currentPassword.trim() &&
newPassword.length >= 8 &&
newPassword === confirmPassword &&
!saving
const handleSubmit = async () => {
if (!canSubmit) return
setSaving(true)
try {
await changePassword({
current_password: currentPassword,
new_password: newPassword,
})
showToast.success('Passwort geändert ✅')
onClose()
} catch (err) {
showToast.error(err instanceof Error ? err.message : 'Fehler beim Ändern')
} finally {
setSaving(false)
}
}
const PasswordField = ({
label,
value,
onChange,
show,
onToggle,
placeholder,
}: {
label: string
value: string
onChange: (v: string) => void
show: boolean
onToggle: () => void
placeholder: string
}) => (
<div>
<label className="block text-sm font-medium text-espresso mb-1">{label}</label>
<div className="relative">
<input
type={show ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full bg-surface border border-sand rounded-xl px-4 py-3 pr-12 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px]"
placeholder={placeholder}
/>
<button
type="button"
onClick={onToggle}
className="absolute right-2 top-1/2 -translate-y-1/2 min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey"
>
{show ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
)
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 p-6 max-h-[90vh] overflow-y-auto"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-6">
<h2 className="font-display text-xl text-espresso">Passwort ändern</h2>
<button
onClick={onClose}
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey"
>
<X size={20} />
</button>
</div>
<div className="space-y-4">
<PasswordField
label="Aktuelles Passwort"
value={currentPassword}
onChange={setCurrentPassword}
show={showCurrent}
onToggle={() => setShowCurrent(!showCurrent)}
placeholder="••••••••"
/>
<PasswordField
label="Neues Passwort"
value={newPassword}
onChange={setNewPassword}
show={showNew}
onToggle={() => setShowNew(!showNew)}
placeholder="Min. 8 Zeichen"
/>
<PasswordField
label="Passwort bestätigen"
value={confirmPassword}
onChange={setConfirmPassword}
show={showConfirm}
onToggle={() => setShowConfirm(!showConfirm)}
placeholder="Nochmal eingeben"
/>
{errors.length > 0 && (
<div className="text-sm text-berry-red space-y-1">
{errors.map((e) => (
<p key={e}> {e}</p>
))}
</div>
)}
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50 transition-opacity"
>
{saving ? 'Wird geändert...' : 'Passwort ändern'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,105 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X } from 'lucide-react'
import { updateProfile } from '../../api/auth'
import { useAuth } from '../../context/AuthContext'
import { showToast } from '../../utils/toast'
interface Props {
open: boolean
onClose: () => void
}
export function EditProfileModal({ open, onClose }: Props) {
const { user, updateUser } = useAuth()
const [displayName, setDisplayName] = useState(user?.display_name || '')
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || '')
const [saving, setSaving] = useState(false)
const handleSave = async () => {
if (!displayName.trim()) return
setSaving(true)
try {
const updated = await updateProfile({
display_name: displayName.trim(),
avatar_url: avatarUrl.trim() || undefined,
})
updateUser(updated)
showToast.success('Profil aktualisiert ✅')
onClose()
} catch (err) {
showToast.error(err instanceof Error ? err.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
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 p-6 max-h-[90vh] overflow-y-auto"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-6">
<h2 className="font-display text-xl text-espresso">Profil bearbeiten</h2>
<button
onClick={onClose}
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey"
>
<X size={20} />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-espresso mb-1">
Anzeigename *
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px]"
placeholder="Dein Name"
/>
</div>
<div>
<label className="block text-sm font-medium text-espresso mb-1">
Avatar URL (optional)
</label>
<input
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px]"
placeholder="https://..."
/>
</div>
<button
onClick={handleSave}
disabled={saving || !displayName.trim()}
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50 transition-opacity"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,316 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Home, Users, Copy, RefreshCw, LogOut, X, Check } from 'lucide-react'
import {
getMyHousehold,
createHousehold,
joinHousehold,
leaveHousehold,
regenerateInviteCode,
type Household,
} from '../../api/households'
import { useAuth } from '../../context/AuthContext'
import { showToast } from '../../utils/toast'
export function HouseholdCard() {
const { user } = useAuth()
const qc = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [showJoin, setShowJoin] = useState(false)
const [showLeaveConfirm, setShowLeaveConfirm] = useState(false)
const [name, setName] = useState('')
const [code, setCode] = useState('')
const [copied, setCopied] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['household'],
queryFn: getMyHousehold,
retry: false,
})
const household: Household | null = data?.data ?? null
const myRole = household?.members.find((m) => m.user_id === user?.id)?.role
const createMut = useMutation({
mutationFn: (n: string) => createHousehold(n),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['household'] })
setShowCreate(false)
setName('')
showToast.success('Haushalt erstellt! 🏠')
},
onError: (err: Error) => showToast.error(err.message),
})
const joinMut = useMutation({
mutationFn: (c: string) => joinHousehold(c),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['household'] })
setShowJoin(false)
setCode('')
showToast.success('Haushalt beigetreten! 🎉')
},
onError: (err: Error) => showToast.error(err.message),
})
const leaveMut = useMutation({
mutationFn: () => leaveHousehold(household!.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['household'] })
setShowLeaveConfirm(false)
showToast.success('Haushalt verlassen')
},
onError: (err: Error) => showToast.error(err.message),
})
const regenMut = useMutation({
mutationFn: () => regenerateInviteCode(household!.id),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ['household'] })
showToast.success('Neuer Code generiert')
},
onError: (err: Error) => showToast.error(err.message),
})
const copyCode = async () => {
if (!household?.invite_code) return
try {
await navigator.clipboard.writeText(household.invite_code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
showToast.error('Kopieren fehlgeschlagen')
}
}
if (isLoading) {
return <div className="bg-surface rounded-2xl p-4 shadow-sm animate-pulse h-24" />
}
// Mini modal component
const MiniModal = ({
open,
onClose,
title,
children,
}: {
open: boolean
onClose: () => void
title: string
children: React.ReactNode
}) => (
<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-sm sm:rounded-2xl rounded-t-2xl p-6"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="font-display text-lg text-espresso">{title}</h3>
<button onClick={onClose} className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey">
<X size={20} />
</button>
</div>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
// No household
if (!household) {
return (
<>
<div className="bg-surface rounded-2xl p-4 shadow-sm">
<div className="flex items-center gap-2 text-espresso font-medium text-sm mb-3">
<Home size={16} className="text-primary" />
Haushalt
</div>
<p className="text-sm text-warm-grey mb-4">
Teile deine Einkaufsliste mit deiner Familie
</p>
<div className="space-y-2">
<button
onClick={() => setShowCreate(true)}
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] transition-opacity hover:opacity-90"
>
Haushalt erstellen
</button>
<button
onClick={() => setShowJoin(true)}
className="w-full border border-sand text-espresso rounded-xl py-3 font-medium min-h-[44px] transition-colors hover:bg-sand/30"
>
Mit Code beitreten
</button>
</div>
</div>
<MiniModal open={showCreate} onClose={() => setShowCreate(false)} title="Haushalt erstellen">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z.B. Luna & Marc"
className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px] mb-4"
/>
<button
onClick={() => name.trim() && createMut.mutate(name.trim())}
disabled={!name.trim() || createMut.isPending}
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50"
>
{createMut.isPending ? 'Erstellen...' : 'Erstellen'}
</button>
</MiniModal>
<MiniModal open={showJoin} onClose={() => setShowJoin(false)} title="Mit Code beitreten">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Einladungscode eingeben"
className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px] mb-4"
/>
<button
onClick={() => code.trim() && joinMut.mutate(code.trim())}
disabled={!code.trim() || joinMut.isPending}
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50"
>
{joinMut.isPending ? 'Beitreten...' : 'Beitreten'}
</button>
</MiniModal>
</>
)
}
// Has household
return (
<>
<div className="bg-surface rounded-2xl p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 text-espresso font-medium text-sm">
<Home size={16} className="text-primary" />
Haushalt
</div>
<span className="flex items-center gap-1 text-xs text-warm-grey">
<Users size={14} /> {household.members.length}
</span>
</div>
<h3 className="font-display text-lg text-espresso mb-3">{household.name}</h3>
{/* Members */}
<div className="space-y-2 mb-4">
{household.members.map((m) => (
<div key={m.user_id} className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary-light flex items-center justify-center text-sm">
{m.avatar_url ? (
<img src={m.avatar_url} alt={m.display_name} className="w-full h-full rounded-full object-cover" />
) : (
<span className="text-primary font-medium">
{m.display_name.charAt(0).toUpperCase()}
</span>
)}
</div>
<span className="text-sm text-espresso flex-1">{m.display_name}</span>
{m.role === 'owner' && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
Admin
</span>
)}
</div>
))}
</div>
{/* Invite code (owner only) */}
{myRole === 'owner' && (
<div className="bg-sand/30 rounded-xl p-3 mb-3">
<p className="text-xs text-warm-grey mb-1">Einladungscode</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm font-mono text-espresso bg-surface rounded-lg px-3 py-2">
{household.invite_code}
</code>
<button
onClick={copyCode}
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey hover:text-primary transition-colors"
>
{copied ? <Check size={18} className="text-sage" /> : <Copy size={18} />}
</button>
<button
onClick={() => regenMut.mutate()}
disabled={regenMut.isPending}
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey hover:text-primary transition-colors disabled:opacity-50"
>
<RefreshCw size={18} className={regenMut.isPending ? 'animate-spin' : ''} />
</button>
</div>
</div>
)}
{/* Actions */}
{myRole === 'member' && (
<button
onClick={() => setShowLeaveConfirm(true)}
className="w-full flex items-center justify-center gap-2 border border-berry-red/30 text-berry-red rounded-xl py-3 font-medium min-h-[44px] transition-colors hover:bg-berry-red/5"
>
<LogOut size={16} />
Haushalt verlassen
</button>
)}
</div>
{/* Leave confirm */}
<AnimatePresence>
{showLeaveConfirm && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm px-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowLeaveConfirm(false)}
>
<motion.div
className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-display text-lg text-espresso mb-2">Haushalt verlassen?</h3>
<p className="text-warm-grey text-sm mb-5">
Du verlierst Zugriff auf die gemeinsame Einkaufsliste.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowLeaveConfirm(false)}
className="flex-1 py-3 rounded-xl border border-sand text-espresso font-medium min-h-[44px]"
>
Abbrechen
</button>
<button
onClick={() => leaveMut.mutate()}
disabled={leaveMut.isPending}
className="flex-1 py-3 rounded-xl bg-berry-red text-white font-medium min-h-[44px] disabled:opacity-50"
>
{leaveMut.isPending ? 'Wird verlassen...' : 'Verlassen'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)
}

View 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>
)
}

View File

@@ -1,12 +1,11 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
import {
User,
type User,
getMe,
setAuthToken,
getAuthToken,
refreshToken,
logout as apiLogout
} from '../api/auth'
import { setAuthToken, getAuthToken } from '../api/token'
interface AuthContextType {
user: User | null
@@ -45,25 +44,15 @@ export function AuthProvider({ children }: AuthProviderProps) {
try {
// Try to refresh token first (cookie-based)
const authResponse = await refreshToken()
if (mounted) {
if (mounted && authResponse?.access_token) {
setAuthToken(authResponse.access_token)
setUser(authResponse.user)
}
} catch (error) {
// If refresh fails, check if we already have a token
const existingToken = getAuthToken()
if (existingToken) {
try {
const userData = await getMe()
if (mounted) {
setUser(userData)
}
} catch (meError) {
// Token is invalid, clear it
if (mounted) {
setAuthToken(null)
}
}
} catch {
// No valid session — that's fine, app works without auth
if (mounted) {
setAuthToken(null)
setUser(null)
}
} finally {
if (mounted) {

View File

@@ -1,17 +1,23 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { Link } from 'react-router'
import { fetchRecipes } from '../api/recipes'
import { fetchShopping } from '../api/shopping'
import { LogOut, Info, Heart, BookOpen, ShoppingCart, Settings, User } from 'lucide-react'
import { LogOut, Info, Heart, BookOpen, ShoppingCart, Settings, User, ChevronRight } from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import { EmptyState } from '../components/ui/EmptyState'
import { Button } from '../components/ui/Button'
import { EditProfileModal } from '../components/profile/EditProfileModal'
import { ChangePasswordModal } from '../components/profile/ChangePasswordModal'
import { HouseholdCard } from '../components/profile/HouseholdCard'
import { showToast } from '../utils/toast'
export function ProfilePage() {
const { user, isAuthenticated, logout, isLoading } = useAuth()
const [showEditProfile, setShowEditProfile] = useState(false)
const [showChangePassword, setShowChangePassword] = useState(false)
const { data: allRecipes } = useQuery({
queryKey: ['recipes', {}],
queryFn: () => fetchRecipes({}),
@@ -24,7 +30,7 @@ export function ProfilePage() {
const { data: shoppingGroups } = useQuery({
queryKey: ['shopping'],
queryFn: fetchShopping,
queryFn: () => fetchShopping(),
})
const totalRecipes = allRecipes?.total ?? 0
@@ -47,7 +53,6 @@ export function ProfilePage() {
}
}
// Show login prompt if not authenticated
if (!isLoading && !isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -58,14 +63,10 @@ export function ProfilePage() {
/>
<div className="absolute bottom-32 left-1/2 transform -translate-x-1/2 space-y-3 w-full max-w-sm px-4">
<Link to="/login">
<Button className="w-full min-h-[44px]">
Anmelden
</Button>
<Button className="w-full min-h-[44px]">Anmelden</Button>
</Link>
<Link to="/register">
<Button variant="ghost" className="w-full min-h-[44px]">
Registrieren
</Button>
<Button variant="ghost" className="w-full min-h-[44px]">Registrieren</Button>
</Link>
</div>
</div>
@@ -73,8 +74,8 @@ export function ProfilePage() {
}
return (
<div className="min-h-screen">
{/* Header */}
<div className="min-h-screen pb-24">
{/* Avatar & Name */}
<div className="px-4 py-6 text-center">
<motion.div
className="w-20 h-20 rounded-full bg-primary-light flex items-center justify-center text-3xl mx-auto mb-3"
@@ -82,8 +83,8 @@ export function ProfilePage() {
animate={{ scale: 1, opacity: 1 }}
>
{user?.avatar_url ? (
<img
src={user.avatar_url}
<img
src={user.avatar_url}
alt={user.display_name}
className="w-full h-full rounded-full object-cover"
/>
@@ -94,13 +95,40 @@ export function ProfilePage() {
<h1 className="font-display text-2xl text-espresso">
{user?.display_name || 'Benutzer'}
</h1>
<p className="text-sm text-warm-grey mt-1">
{user?.email || 'Hobbyköchin & Rezeptsammlerin'}
</p>
<p className="text-sm text-warm-grey mt-1">{user?.email}</p>
</div>
{/* Profil verwalten */}
<div className="px-4 pb-4">
<div className="bg-surface rounded-2xl p-4 shadow-sm space-y-1">
<div className="flex items-center gap-2 text-espresso font-medium text-sm mb-3">
<Settings size={16} className="text-primary" />
Profil verwalten
</div>
<button
onClick={() => setShowEditProfile(true)}
className="w-full flex items-center justify-between p-3 rounded-xl hover:bg-sand/30 transition-colors min-h-[44px]"
>
<span className="text-sm text-espresso">Profil bearbeiten</span>
<ChevronRight size={16} className="text-warm-grey" />
</button>
<button
onClick={() => setShowChangePassword(true)}
className="w-full flex items-center justify-between p-3 rounded-xl hover:bg-sand/30 transition-colors min-h-[44px]"
>
<span className="text-sm text-espresso">Passwort ändern</span>
<ChevronRight size={16} className="text-warm-grey" />
</button>
</div>
</div>
{/* Haushalt */}
<div className="px-4 pb-4">
<HouseholdCard />
</div>
{/* Stats */}
<div className="px-4 pb-6">
<div className="px-4 pb-4">
<div className="grid grid-cols-3 gap-3">
{stats.map((stat) => (
<motion.div
@@ -117,45 +145,17 @@ export function ProfilePage() {
</div>
</div>
{/* Profile Actions */}
<div className="px-4 pb-6">
<div className="bg-surface rounded-2xl p-4 shadow-sm space-y-3">
<div className="flex items-center gap-2 text-espresso font-medium text-sm mb-3">
<Settings size={16} className="text-primary" />
Profil verwalten
</div>
<button
disabled
title="Kommt bald"
className="w-full flex items-center justify-between p-3 rounded-xl bg-sand/30 text-warm-grey cursor-not-allowed opacity-60 transition-colors min-h-[44px]"
>
<span className="text-sm">Profil bearbeiten</span>
<span className="text-xs">Kommt bald</span>
</button>
<button
disabled
title="Kommt bald"
className="w-full flex items-center justify-between p-3 rounded-xl bg-sand/30 text-warm-grey cursor-not-allowed opacity-60 transition-colors min-h-[44px]"
>
<span className="text-sm">Passwort ändern</span>
<span className="text-xs">Kommt bald</span>
</button>
</div>
</div>
{/* App Info */}
<div className="px-4 pb-6">
<div className="bg-surface rounded-2xl p-4 shadow-sm space-y-3">
<div className="flex items-center gap-2 text-espresso font-medium text-sm">
<div className="px-4 pb-4">
<div className="bg-surface rounded-2xl p-4 shadow-sm">
<div className="flex items-center gap-2 text-espresso font-medium text-sm mb-3">
<Info size={16} className="text-primary" />
App-Info
</div>
<div className="text-sm text-warm-grey space-y-1">
<div className="flex justify-between">
<span>Version</span>
<span className="text-espresso">2.0</span>
<span className="text-espresso">2.1.2026</span>
</div>
<div className="flex justify-between">
<span>Erstellt</span>
@@ -165,7 +165,7 @@ export function ProfilePage() {
</div>
</div>
{/* Logout Button */}
{/* Logout */}
<div className="px-4 pb-8">
<button
onClick={handleLogout}
@@ -175,6 +175,10 @@ export function ProfilePage() {
Abmelden
</button>
</div>
{/* Modals */}
<EditProfileModal open={showEditProfile} onClose={() => setShowEditProfile(false)} />
<ChangePasswordModal open={showChangePassword} onClose={() => setShowChangePassword(false)} />
</div>
)
}

View File

@@ -6,7 +6,8 @@ import toast from 'react-hot-toast'
import { ArrowLeft, Heart, Clock, Users, ChefHat, ShoppingCart, Pencil, Minus, Plus, Send, Trash2 } from 'lucide-react'
import { Dices } from 'lucide-react'
import { fetchRecipe, toggleFavorite, fetchRandomRecipe } from '../api/recipes'
import { addFromRecipe } from '../api/shopping'
import { addFromRecipe, addCustomItem } from '../api/shopping'
import { IngredientPickerModal } from '../components/recipe/IngredientPickerModal'
import { createNote, deleteNote } from '../api/notes'
import { Badge } from '../components/ui/Badge'
import { Skeleton } from '../components/ui/Skeleton'
@@ -22,6 +23,7 @@ export function RecipePage() {
const [servingScale, setServingScale] = useState<number | null>(null)
const [noteText, setNoteText] = useState('')
const [rerolling, setRerolling] = useState(false)
const [showIngredientPicker, setShowIngredientPicker] = useState(false)
const handleReroll = useCallback(async () => {
setRerolling(true)
@@ -47,14 +49,25 @@ export function RecipePage() {
onError: () => toast.error('Fehler beim Ändern des Favoriten-Status'),
})
const shoppingMutation = useMutation({
mutationFn: () => addFromRecipe(recipe!.id),
onSuccess: (data) => {
const [addingToShopping, setAddingToShopping] = useState(false)
const handleAddToShopping = async (selected: { name: string; amount?: number; unit?: string }[]) => {
setAddingToShopping(true)
try {
let count = 0
for (const item of selected) {
await addCustomItem({ name: item.name, amount: item.amount, unit: item.unit })
count++
}
qc.invalidateQueries({ queryKey: ['shopping'] })
toast.success(`${data.added} Zutaten zur Einkaufsliste hinzugefügt!`)
},
onError: () => toast.error('Fehler beim Hinzufügen zur Einkaufsliste'),
})
toast.success(`${count} Zutaten zur Einkaufsliste hinzugefügt!`)
setShowIngredientPicker(false)
} catch {
toast.error('Fehler beim Hinzufügen zur Einkaufsliste')
} finally {
setAddingToShopping(false)
}
}
const noteMutation = useMutation({
mutationFn: (content: string) => createNote(recipe!.id, content),
@@ -225,14 +238,26 @@ export function RecipePage() {
{/* Add to shopping list */}
{recipe.ingredients && recipe.ingredients.length > 0 && (
<button
onClick={() => shoppingMutation.mutate()}
disabled={shoppingMutation.isPending}
className="w-full flex items-center justify-center gap-2 bg-secondary text-white px-4 py-3 rounded-xl font-medium transition-colors hover:bg-secondary/90 disabled:opacity-50 min-h-[44px]"
>
<ShoppingCart size={18} />
{shoppingMutation.isPending ? 'Wird hinzugefügt...' : '🛒 Zur Einkaufsliste'}
</button>
<>
<button
onClick={() => setShowIngredientPicker(true)}
className="w-full flex items-center justify-center gap-2 bg-secondary text-white px-4 py-3 rounded-xl font-medium transition-colors hover:bg-secondary/90 min-h-[44px]"
>
<ShoppingCart size={18} />
🛒 Zur Einkaufsliste
</button>
<IngredientPickerModal
open={showIngredientPicker}
onClose={() => setShowIngredientPicker(false)}
ingredients={recipe.ingredients.map((ing) => ({
name: ing.name,
amount: ing.amount,
unit: ing.unit,
}))}
onSubmit={handleAddToShopping}
loading={addingToShopping}
/>
</>
)}
{/* Steps */}

View File

@@ -1,6 +1,6 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2, Plus, ShoppingCart, X } from 'lucide-react'
import { Trash2, Plus, X } from 'lucide-react'
import {
fetchShopping,
addCustomItem,
@@ -10,16 +10,29 @@ import {
deleteAll,
} from '../api/shopping'
import type { ShoppingGroup, ShoppingItem } from '../api/shopping'
import { useAuth } from '../context/AuthContext'
import { EmptyState } from '../components/ui/EmptyState'
export function ShoppingPage() {
const qc = useQueryClient()
const { isAuthenticated } = useAuth()
const [newItem, setNewItem] = useState('')
const [scope, setScope] = useState<'personal' | 'household'>('personal')
const inputRef = useRef<HTMLInputElement>(null)
// Check if user has a household
const { data: householdData } = useQuery({
queryKey: ['household'],
enabled: isAuthenticated,
retry: false,
})
const hasHousehold = !!householdData?.data
const activeScope = hasHousehold ? scope : undefined
const { data: groups = [], isLoading, refetch } = useQuery({
queryKey: ['shopping'],
queryFn: fetchShopping,
queryKey: ['shopping', activeScope],
queryFn: () => fetchShopping(activeScope),
})
const checkMutation = useMutation({
@@ -33,19 +46,19 @@ export function ShoppingPage() {
})
const deleteCheckedMutation = useMutation({
mutationFn: deleteChecked,
mutationFn: () => deleteChecked(activeScope),
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
})
const deleteAllMutation = useMutation({
mutationFn: deleteAll,
mutationFn: () => deleteAll(activeScope),
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
})
const [showClearConfirm, setShowClearConfirm] = useState(false)
const addMutation = useMutation({
mutationFn: addCustomItem,
mutationFn: (item: { name: string }) => addCustomItem(item, activeScope),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['shopping'] })
setNewItem('')
@@ -65,29 +78,23 @@ export function ShoppingPage() {
const totalUnchecked = totalItems - totalChecked
const recipeCount = groups.filter((g) => g.recipe_id).length
// Sort items: unchecked first, checked last
const sortItems = (items: ShoppingItem[]) => {
const unchecked = items.filter((i) => !i.checked)
const checked = items.filter((i) => i.checked)
return [...unchecked, ...checked]
}
// Pull-to-refresh via touch
const [pulling, setPulling] = useState(false)
const touchStartY = useRef(0)
const handleTouchStart = (e: React.TouchEvent) => {
if (window.scrollY === 0) {
touchStartY.current = e.touches[0].clientY
}
if (window.scrollY === 0) touchStartY.current = e.touches[0].clientY
}
const handleTouchEnd = (e: React.TouchEvent) => {
if (pulling) {
const dy = e.changedTouches[0].clientY - touchStartY.current
if (dy > 80) {
refetch()
}
if (dy > 80) refetch()
setPulling(false)
}
}
@@ -144,7 +151,35 @@ export function ShoppingPage() {
</div>
</div>
{/* Clear All Confirm Dialog */}
{/* Scope Toggle */}
{hasHousehold && (
<div className="sticky top-[53px] z-35 bg-cream/95 backdrop-blur-sm px-4 py-2">
<div className="flex bg-sand/50 rounded-xl p-1">
<button
onClick={() => setScope('personal')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${
scope === 'personal'
? 'bg-primary text-white'
: 'text-warm-grey hover:text-espresso'
}`}
>
Persönlich
</button>
<button
onClick={() => setScope('household')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${
scope === 'household'
? 'bg-primary text-white'
: 'text-warm-grey hover:text-espresso'
}`}
>
🏠 Haushalt
</button>
</div>
</div>
)}
{/* Clear All Confirm */}
{showClearConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm px-6">
<div className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl">
@@ -171,13 +206,12 @@ export function ShoppingPage() {
</div>
)}
{/* Pull indicator */}
{pulling && (
<div className="text-center text-warm-grey text-sm py-2"> Loslassen zum Aktualisieren</div>
)}
{/* Quick-Add */}
<div className="sticky top-[53px] z-30 bg-cream/95 backdrop-blur-sm px-4 py-3">
<div className={`sticky ${hasHousehold ? 'top-[105px]' : 'top-[53px]'} z-30 bg-cream/95 backdrop-blur-sm px-4 py-3`}>
<form
onSubmit={(e) => {
e.preventDefault()
@@ -212,7 +246,9 @@ export function ShoppingPage() {
{recipeCount > 0 && <>{recipeCount} Rezept{recipeCount !== 1 ? 'e' : ''} · </>}
{totalItems} Artikel · {totalChecked} erledigt
</span>
<span className="text-warm-grey text-xs">{totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0}%</span>
<span className="text-warm-grey text-xs">
{totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0}%
</span>
</div>
<div className="mt-2 h-1.5 bg-sand rounded-full overflow-hidden">
<div
@@ -235,7 +271,6 @@ export function ShoppingPage() {
) : (
groups.map((group) => (
<div key={group.recipe_title} className="bg-surface rounded-2xl shadow-sm overflow-hidden">
{/* Group header */}
<div className="px-4 py-3 border-b border-sand/50 flex items-center gap-2">
<span className="text-base">{group.recipe_id ? '🍰' : '📝'}</span>
<h3 className="font-semibold text-sm text-espresso truncate">
@@ -245,8 +280,6 @@ export function ShoppingPage() {
{group.items.filter((i) => !i.checked).length}/{group.items.length}
</span>
</div>
{/* Items */}
<ul>
{sortItems(group.items).map((item) => (
<ShoppingItemRow
@@ -309,15 +342,24 @@ function ShoppingItemRow({
return (
<li className="relative overflow-hidden">
{/* Delete background */}
<div className={`absolute inset-y-0 right-0 w-40 flex items-center justify-center transition-colors ${pastThreshold ? 'bg-berry-red' : 'bg-berry-red/60'}`}>
<span className={`text-white font-medium transition-all ${pastThreshold ? 'text-sm scale-110' : 'text-xs'}`}>
<div
className={`absolute inset-y-0 right-0 w-40 flex items-center justify-center transition-colors ${
pastThreshold ? 'bg-berry-red' : 'bg-berry-red/60'
}`}
>
<span
className={`text-white font-medium transition-all ${
pastThreshold ? 'text-sm scale-110' : 'text-xs'
}`}
>
{pastThreshold ? '🗑️ Löschen' : '×'}
</span>
</div>
<div
className={`relative bg-surface flex items-center gap-3 px-4 min-h-[52px] ${swiping.current ? '' : 'transition-transform duration-200'}`}
className={`relative bg-surface flex items-center gap-3 px-4 min-h-[52px] ${
swiping.current ? '' : 'transition-transform duration-200'
}`}
style={{ transform: `translateX(${swipeX}px)` }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
@@ -333,19 +375,33 @@ function ShoppingItemRow({
>
{item.checked && (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2.5 7L5.5 10L11.5 4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path
d="M2.5 7L5.5 10L11.5 4"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
<div className={`flex-1 min-w-0 ${item.checked ? 'opacity-50' : ''}`}>
<span className={`text-base sm:text-lg text-espresso ${item.checked ? 'line-through text-warm-grey' : ''}`}>
<span
className={`text-base sm:text-lg text-espresso ${
item.checked ? 'line-through text-warm-grey' : ''
}`}
>
{item.name}
</span>
</div>
{amountText && (
<span className={`text-sm flex-shrink-0 ${item.checked ? 'text-warm-grey/50 line-through' : 'text-warm-grey'}`}>
<span
className={`text-sm flex-shrink-0 ${
item.checked ? 'text-warm-grey/50 line-through' : 'text-warm-grey'
}`}
>
{amountText}
</span>
)}