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:
@@ -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 />}>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
49
frontend/src/api/households.ts
Normal file
49
frontend/src/api/households.ts
Normal 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' }
|
||||
)
|
||||
}
|
||||
@@ -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
10
frontend/src/api/token.ts
Normal 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
|
||||
}
|
||||
159
frontend/src/components/profile/ChangePasswordModal.tsx
Normal file
159
frontend/src/components/profile/ChangePasswordModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
frontend/src/components/profile/EditProfileModal.tsx
Normal file
105
frontend/src/components/profile/EditProfileModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
316
frontend/src/components/profile/HouseholdCard.tsx
Normal file
316
frontend/src/components/profile/HouseholdCard.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user