Auth v2: Register/Login/Profile, Households, per-user Favorites/Notes/Shopping, Frontend Auth Pages
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router'
|
||||
import { AppShell } from './components/layout/AppShell'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import { PublicRoute } from './components/auth/AuthGuard'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { RecipePage } from './pages/RecipePage'
|
||||
import { SearchPage } from './pages/SearchPage'
|
||||
@@ -7,21 +9,44 @@ import { PlaceholderPage } from './pages/PlaceholderPage'
|
||||
import { ProfilePage } from './pages/ProfilePage'
|
||||
import { RecipeFormPage } from './pages/RecipeFormPage'
|
||||
import { ShoppingPage } from './pages/ShoppingPage'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { RegisterPage } from './pages/RegisterPage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="recipe/:slug" element={<RecipePage />} />
|
||||
<Route path="search" element={<SearchPage />} />
|
||||
<Route path="new" element={<RecipeFormPage />} />
|
||||
<Route path="recipe/:slug/edit" element={<RecipeFormPage />} />
|
||||
<Route path="shopping" element={<ShoppingPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public Auth Routes */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<RegisterPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Main App Routes */}
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="recipe/:slug" element={<RecipePage />} />
|
||||
<Route path="search" element={<SearchPage />} />
|
||||
<Route path="new" element={<RecipeFormPage />} />
|
||||
<Route path="recipe/:slug/edit" element={<RecipeFormPage />} />
|
||||
<Route path="shopping" element={<ShoppingPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,100 @@
|
||||
import { apiFetch } from './client'
|
||||
|
||||
// v2: Auth API — placeholder functions
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
email?: string
|
||||
email: string
|
||||
display_name: string
|
||||
avatar_url?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export function login(_email: string, _password: string) {
|
||||
return apiFetch<User>('/auth/login', { method: 'POST', body: JSON.stringify({ email: _email, password: _password }) })
|
||||
export interface AuthResponse {
|
||||
user: User
|
||||
access_token: string
|
||||
}
|
||||
|
||||
export function register(_email: string, _password: string, _name: string) {
|
||||
return apiFetch<User>('/auth/register', { method: 'POST', body: JSON.stringify({ email: _email, password: _password, name: _name }) })
|
||||
export interface RegisterData {
|
||||
email: string
|
||||
password: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export function fetchMe() {
|
||||
return apiFetch<User>('/auth/me')
|
||||
export interface LoginData {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface UpdateProfileData {
|
||||
display_name?: string
|
||||
avatar_url?: string
|
||||
}
|
||||
|
||||
export interface ChangePasswordData {
|
||||
current_password: string
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
export function login(data: LoginData): Promise<AuthResponse> {
|
||||
return apiFetch<AuthResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export function logout(): Promise<void> {
|
||||
return authFetch<void>('/auth/logout', {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMe(): Promise<User> {
|
||||
return authFetch<User>('/auth/me')
|
||||
}
|
||||
|
||||
export function updateProfile(data: UpdateProfileData): Promise<User> {
|
||||
return authFetch<User>('/auth/me', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export function changePassword(data: ChangePasswordData): Promise<void> {
|
||||
return authFetch<void>('/auth/me/password', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export function refreshToken(): Promise<AuthResponse> {
|
||||
return apiFetch<AuthResponse>('/auth/refresh', {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
@@ -40,6 +40,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 deleteChecked() {
|
||||
return apiFetch<void>('/shopping/checked', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
59
frontend/src/components/auth/AuthGuard.tsx
Normal file
59
frontend/src/components/auth/AuthGuard.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { Skeleton } from '../ui/Skeleton'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen p-4">
|
||||
<div className="max-w-md mx-auto space-y-4">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to login and remember where they were trying to go
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
interface PublicRouteProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function PublicRoute({ children }: PublicRouteProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen p-4">
|
||||
<div className="max-w-md mx-auto space-y-4">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Already logged in, redirect to home
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
117
frontend/src/context/AuthContext.tsx
Normal file
117
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||
import {
|
||||
User,
|
||||
getMe,
|
||||
setAuthToken,
|
||||
getAuthToken,
|
||||
refreshToken,
|
||||
logout as apiLogout
|
||||
} from '../api/auth'
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
login: (token: string, user: User) => void
|
||||
logout: () => void
|
||||
updateUser: (user: User) => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const isAuthenticated = !!user
|
||||
|
||||
// Initialize auth on app start
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
async function initAuth() {
|
||||
try {
|
||||
// Try to refresh token first (cookie-based)
|
||||
const authResponse = await refreshToken()
|
||||
if (mounted) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initAuth()
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = (token: string, userData: User) => {
|
||||
setAuthToken(token)
|
||||
setUser(userData)
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await apiLogout()
|
||||
} catch (error) {
|
||||
// Ignore logout API errors
|
||||
console.warn('Logout API call failed:', error)
|
||||
} finally {
|
||||
setAuthToken(null)
|
||||
setUser(null)
|
||||
}
|
||||
}
|
||||
|
||||
const updateUser = (userData: User) => {
|
||||
setUser(userData)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
updateUser
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
146
frontend/src/pages/LoginPage.tsx
Normal file
146
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Mail, Lock, Eye, EyeOff } from 'lucide-react'
|
||||
import { login as apiLogin } from '../api/auth'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { showToast } from '../utils/toast'
|
||||
|
||||
export function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
// Get the page they were trying to visit, default to home
|
||||
const from = location.state?.from?.pathname || '/'
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await apiLogin({ email, password })
|
||||
login(response.access_token, response.user)
|
||||
showToast.success(`Willkommen zurück, ${response.user.display_name}!`)
|
||||
navigate(from, { replace: true })
|
||||
} catch (err) {
|
||||
setError('Ungültige E-Mail oder Passwort')
|
||||
showToast.error('Anmeldung fehlgeschlagen')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
className="w-full max-w-md"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
className="text-6xl mb-4"
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
>
|
||||
👨🍳
|
||||
</motion.div>
|
||||
<h1 className="font-display text-3xl text-espresso mb-2">
|
||||
Willkommen zurück
|
||||
</h1>
|
||||
<p className="text-warm-grey">
|
||||
Melde dich an, um deine Rezepte zu verwalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-espresso mb-2">
|
||||
E-Mail
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 rounded-xl border border-sand focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors"
|
||||
placeholder="deine@email.de"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-espresso mb-2">
|
||||
Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-12 py-3 rounded-xl border border-sand focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-warm-grey hover:text-espresso transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<motion.div
|
||||
className="text-berry-red text-sm text-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full min-h-[44px] py-3"
|
||||
>
|
||||
{isLoading ? 'Wird angemeldet...' : 'Anmelden'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Sign Up Link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-warm-grey">
|
||||
Noch kein Account?{' '}
|
||||
<Link to="/register" className="text-primary hover:underline font-medium">
|
||||
Jetzt registrieren
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
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 } from 'lucide-react'
|
||||
import { LogOut, Info, Heart, BookOpen, ShoppingCart, Settings, User } from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { EmptyState } from '../components/ui/EmptyState'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { showToast } from '../utils/toast'
|
||||
|
||||
export function ProfilePage() {
|
||||
const { user, isAuthenticated, logout, isLoading } = useAuth()
|
||||
|
||||
const { data: allRecipes } = useQuery({
|
||||
queryKey: ['recipes', {}],
|
||||
queryFn: () => fetchRecipes({}),
|
||||
@@ -30,6 +37,41 @@ export function ProfilePage() {
|
||||
{ icon: <ShoppingCart size={18} />, label: 'Einkauf', value: totalShoppingItems },
|
||||
]
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout()
|
||||
showToast.success('Erfolgreich abgemeldet')
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
showToast.error('Abmeldung fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
// Show login prompt if not authenticated
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<EmptyState
|
||||
icon="🔐"
|
||||
title="Anmeldung erforderlich"
|
||||
description="Melde dich an, um dein Profil zu verwalten"
|
||||
/>
|
||||
<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>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button variant="ghost" className="w-full min-h-[44px]">
|
||||
Registrieren
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
@@ -39,10 +81,22 @@ export function ProfilePage() {
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
>
|
||||
👤
|
||||
{user?.avatar_url ? (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt={user.display_name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User size={28} className="text-primary" />
|
||||
)}
|
||||
</motion.div>
|
||||
<h1 className="font-display text-2xl text-espresso">Luna</h1>
|
||||
<p className="text-sm text-warm-grey mt-1">Hobbyköchin & Rezeptsammlerin</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -63,6 +117,34 @@ 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">
|
||||
@@ -73,7 +155,7 @@ export function ProfilePage() {
|
||||
<div className="text-sm text-warm-grey space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Version</span>
|
||||
<span className="text-espresso">1.0</span>
|
||||
<span className="text-espresso">2.0</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Erstellt</span>
|
||||
@@ -86,12 +168,11 @@ export function ProfilePage() {
|
||||
{/* Logout Button */}
|
||||
<div className="px-4 pb-8">
|
||||
<button
|
||||
disabled
|
||||
title="Kommt in v2"
|
||||
className="w-full flex items-center justify-center gap-2 bg-sand/50 text-warm-grey px-4 py-3 rounded-xl font-medium text-sm cursor-not-allowed opacity-60 min-h-[44px]"
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center justify-center gap-2 bg-berry-red text-white px-4 py-3 rounded-xl font-medium text-sm hover:bg-berry-red/90 transition-colors min-h-[44px]"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Abmelden — kommt in v2
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
259
frontend/src/pages/RegisterPage.tsx
Normal file
259
frontend/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Mail, Lock, User, Eye, EyeOff } from 'lucide-react'
|
||||
import { register as apiRegister } from '../api/auth'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { showToast } from '../utils/toast'
|
||||
|
||||
export function RegisterPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
// Name validation
|
||||
if (!formData.display_name.trim()) {
|
||||
newErrors.display_name = 'Name ist erforderlich'
|
||||
} else if (formData.display_name.trim().length < 2) {
|
||||
newErrors.display_name = 'Name muss mindestens 2 Zeichen lang sein'
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'E-Mail ist erforderlich'
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = 'Ungültige E-Mail-Adresse'
|
||||
}
|
||||
|
||||
// Password validation
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Passwort ist erforderlich'
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'Passwort muss mindestens 6 Zeichen lang sein'
|
||||
}
|
||||
|
||||
// Confirm password validation
|
||||
if (!formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwort bestätigen ist erforderlich'
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwörter stimmen nicht überein'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleChange = (field: keyof typeof formData) => (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
setFormData({ ...formData, [field]: e.target.value })
|
||||
// Clear error for this field when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors({ ...errors, [field]: '' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setErrors({})
|
||||
|
||||
try {
|
||||
const response = await apiRegister({
|
||||
display_name: formData.display_name.trim(),
|
||||
email: formData.email,
|
||||
password: formData.password
|
||||
})
|
||||
login(response.access_token, response.user)
|
||||
showToast.success(`Willkommen bei Luna Rezepte, ${response.user.display_name}!`)
|
||||
navigate('/', { replace: true })
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('409')) {
|
||||
setErrors({ email: 'E-Mail-Adresse ist bereits registriert' })
|
||||
showToast.error('E-Mail bereits registriert')
|
||||
} else {
|
||||
setErrors({ general: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' })
|
||||
showToast.error('Registrierung fehlgeschlagen')
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
className="w-full max-w-md"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
className="text-6xl mb-4"
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
>
|
||||
👨🍳
|
||||
</motion.div>
|
||||
<h1 className="font-display text-3xl text-espresso mb-2">
|
||||
Account erstellen
|
||||
</h1>
|
||||
<p className="text-warm-grey">
|
||||
Registriere dich und starte deine Rezeptsammlung
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Registration Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Name Field */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-espresso mb-2">
|
||||
Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={formData.display_name}
|
||||
onChange={handleChange('display_name')}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border ${errors.display_name ? 'border-berry-red' : 'border-sand'} focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors`}
|
||||
placeholder="Dein Name"
|
||||
/>
|
||||
</div>
|
||||
{errors.display_name && (
|
||||
<p className="text-berry-red text-sm mt-1">{errors.display_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-espresso mb-2">
|
||||
E-Mail
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange('email')}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border ${errors.email ? 'border-berry-red' : 'border-sand'} focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors`}
|
||||
placeholder="deine@email.de"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="text-berry-red text-sm mt-1">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-espresso mb-2">
|
||||
Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={handleChange('password')}
|
||||
className={`w-full pl-10 pr-12 py-3 rounded-xl border ${errors.password ? 'border-berry-red' : 'border-sand'} focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-warm-grey hover:text-espresso transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-berry-red text-sm mt-1">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-espresso mb-2">
|
||||
Passwort bestätigen
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-warm-grey" size={18} />
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange('confirmPassword')}
|
||||
className={`w-full pl-10 pr-12 py-3 rounded-xl border ${errors.confirmPassword ? 'border-berry-red' : 'border-sand'} focus:ring-2 focus:ring-primary/30 focus:border-primary outline-none transition-colors`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-warm-grey hover:text-espresso transition-colors"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-berry-red text-sm mt-1">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* General Error Message */}
|
||||
{errors.general && (
|
||||
<motion.div
|
||||
className="text-berry-red text-sm text-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
{errors.general}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full min-h-[44px] py-3"
|
||||
>
|
||||
{isLoading ? 'Account wird erstellt...' : 'Registrieren'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-warm-grey">
|
||||
Schon einen Account?{' '}
|
||||
<Link to="/login" className="text-primary hover:underline font-medium">
|
||||
Jetzt anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
toggleCheck,
|
||||
deleteItem,
|
||||
deleteChecked,
|
||||
deleteAll,
|
||||
} from '../api/shopping'
|
||||
import type { ShoppingGroup, ShoppingItem } from '../api/shopping'
|
||||
import { EmptyState } from '../components/ui/EmptyState'
|
||||
@@ -36,6 +37,13 @@ export function ShoppingPage() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
|
||||
})
|
||||
|
||||
const deleteAllMutation = useMutation({
|
||||
mutationFn: deleteAll,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
|
||||
})
|
||||
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: addCustomItem,
|
||||
onSuccess: () => {
|
||||
@@ -121,12 +129,48 @@ export function ShoppingPage() {
|
||||
className="p-2 text-warm-grey hover:text-berry-red transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
title="Erledigte löschen"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
{totalItems > 0 && (
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
className="p-2 text-warm-grey hover:text-berry-red transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
title="Alles löschen"
|
||||
>
|
||||
<Trash2 size={20} className="text-berry-red/70" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear All Confirm Dialog */}
|
||||
{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">
|
||||
<h3 className="font-display text-lg text-espresso mb-2">Einkaufsliste leeren?</h3>
|
||||
<p className="text-warm-grey text-sm mb-5">Alle Artikel werden unwiderruflich gelöscht.</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
className="flex-1 py-3 rounded-xl border border-sand text-espresso font-medium min-h-[44px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteAllMutation.mutate()
|
||||
setShowClearConfirm(false)
|
||||
}}
|
||||
className="flex-1 py-3 rounded-xl bg-berry-red text-white font-medium min-h-[44px]"
|
||||
>
|
||||
🗑️ Alles löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pull indicator */}
|
||||
{pulling && (
|
||||
<div className="text-center text-warm-grey text-sm py-2">↓ Loslassen zum Aktualisieren</div>
|
||||
@@ -230,9 +274,11 @@ function ShoppingItemRow({
|
||||
onToggle: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const THRESHOLD = -80
|
||||
const [swipeX, setSwipeX] = useState(0)
|
||||
const touchStartX = useRef(0)
|
||||
const swiping = useRef(false)
|
||||
const pastThreshold = swipeX < THRESHOLD
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX
|
||||
@@ -243,17 +289,19 @@ function ShoppingItemRow({
|
||||
const dx = e.touches[0].clientX - touchStartX.current
|
||||
if (dx < -10) {
|
||||
swiping.current = true
|
||||
setSwipeX(Math.max(dx, -80))
|
||||
setSwipeX(Math.max(dx, -160))
|
||||
} else {
|
||||
setSwipeX(0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (swipeX < -60) {
|
||||
onDelete()
|
||||
if (pastThreshold) {
|
||||
setSwipeX(-300)
|
||||
setTimeout(() => onDelete(), 200)
|
||||
} else {
|
||||
setSwipeX(0)
|
||||
}
|
||||
setSwipeX(0)
|
||||
swiping.current = false
|
||||
}
|
||||
|
||||
@@ -262,12 +310,14 @@ function ShoppingItemRow({
|
||||
return (
|
||||
<li className="relative overflow-hidden">
|
||||
{/* Delete background */}
|
||||
<div className="absolute inset-y-0 right-0 w-20 bg-berry-red flex items-center justify-center">
|
||||
<X size={18} className="text-white" />
|
||||
<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] transition-transform"
|
||||
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}
|
||||
|
||||
53
frontend/src/utils/toast.ts
Normal file
53
frontend/src/utils/toast.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export const showToast = {
|
||||
success: (message: string) => {
|
||||
toast.success(message, {
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#C4737E',
|
||||
color: 'white',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
},
|
||||
iconTheme: {
|
||||
primary: '#C4737E',
|
||||
secondary: 'white',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
error: (message: string) => {
|
||||
toast.error(message, {
|
||||
duration: 5000,
|
||||
style: {
|
||||
background: '#C94C4C',
|
||||
color: 'white',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
},
|
||||
iconTheme: {
|
||||
primary: '#C94C4C',
|
||||
secondary: 'white',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
loading: (message: string) => {
|
||||
return toast.loading(message, {
|
||||
style: {
|
||||
background: '#7A6E65',
|
||||
color: 'white',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
dismiss: (toastId: string) => {
|
||||
toast.dismiss(toastId)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user