feat: stabilization + recipe edit/create UI

This commit is contained in:
clawd
2026-02-18 09:55:39 +00:00
commit ee452efa6a
75 changed files with 15160 additions and 0 deletions

26
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { BrowserRouter, Routes, Route } from 'react-router'
import { AppShell } from './components/layout/AppShell'
import { HomePage } from './pages/HomePage'
import { RecipePage } from './pages/RecipePage'
import { SearchPage } from './pages/SearchPage'
import { PlaceholderPage } from './pages/PlaceholderPage'
import { RecipeFormPage } from './pages/RecipeFormPage'
import { ShoppingPage } from './pages/ShoppingPage'
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={<PlaceholderPage title="Profil" icon="👤" />} />
</Route>
</Routes>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,6 @@
import { apiFetch } from './client'
import type { Category } from './types'
export function fetchCategories() {
return apiFetch<Category[]>('/categories')
}

View File

@@ -0,0 +1,17 @@
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)) {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
}
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers,
})
if (!res.ok) {
throw new Error(`API Error: ${res.status} ${res.statusText}`)
}
return res.json()
}

View File

@@ -0,0 +1,74 @@
import { apiFetch } from './client'
import type { Recipe, PaginatedResponse } from './types'
interface RecipeListParams {
category?: string
favorite?: boolean
sort?: string
page?: number
limit?: number
}
export function fetchRecipes(params?: RecipeListParams) {
const sp = new URLSearchParams()
if (params?.category) sp.set('category', params.category)
if (params?.favorite) sp.set('favorite', 'true')
if (params?.sort) sp.set('sort', params.sort)
if (params?.page) sp.set('page', String(params.page))
if (params?.limit) sp.set('limit', String(params.limit))
const qs = sp.toString()
return apiFetch<PaginatedResponse<Recipe>>(`/recipes${qs ? `?${qs}` : ''}`)
}
export function fetchRecipe(slug: string) {
return apiFetch<Recipe>(`/recipes/${slug}`)
}
export function searchRecipes(q: string) {
return apiFetch<PaginatedResponse<Recipe>>(`/recipes/search?q=${encodeURIComponent(q)}`)
}
export function toggleFavorite(id: string) {
return apiFetch<Recipe>(`/recipes/${id}/favorite`, { method: 'PATCH' })
}
export interface RecipeFormData {
title: string
description?: string
category_id?: string
difficulty?: 'easy' | 'medium' | 'hard'
prep_time?: number
cook_time?: number
servings?: number
image_url?: string
source_url?: string
ingredients?: { amount?: number; unit?: string; name: string; group_name?: string; sort_order?: number }[]
steps?: { step_number: number; instruction: string; duration_minutes?: number }[]
}
export function createRecipe(data: RecipeFormData) {
return apiFetch<Recipe>('/recipes', {
method: 'POST',
body: JSON.stringify(data),
})
}
export function updateRecipe(id: string, data: RecipeFormData) {
return apiFetch<Recipe>(`/recipes/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export function deleteRecipe(id: string) {
return apiFetch<{ ok: boolean }>(`/recipes/${id}`, { method: 'DELETE' })
}
export function uploadRecipeImage(id: string, file: File) {
const formData = new FormData()
formData.append('file', file)
return fetch(`/api/recipes/${id}/image`, { method: 'POST', body: formData }).then(r => {
if (!r.ok) throw new Error('Upload failed')
return r.json() as Promise<{ image_url: string }>
})
}

View File

@@ -0,0 +1,45 @@
import { apiFetch } from './client'
export interface ShoppingItem {
id: string
name: string
amount?: number
unit?: string
checked: boolean
recipe_id?: string
recipe_title?: string
created_at?: string
}
export interface ShoppingGroup {
recipe_title: string
recipe_id?: string
items: ShoppingItem[]
}
export function fetchShopping() {
return apiFetch<ShoppingGroup[]>('/shopping')
}
export function addFromRecipe(recipeId: string) {
return apiFetch<{ added: number }>(`/shopping/from-recipe/${recipeId}`, { method: 'POST' })
}
export function addCustomItem(item: { name: string; amount?: number; unit?: string }) {
return apiFetch<ShoppingItem>('/shopping', {
method: 'POST',
body: JSON.stringify(item),
})
}
export function toggleCheck(id: string) {
return apiFetch<ShoppingItem>(`/shopping/${id}/check`, { method: 'PATCH' })
}
export function deleteItem(id: string) {
return apiFetch<void>(`/shopping/${id}`, { method: 'DELETE' })
}
export function deleteChecked() {
return apiFetch<void>('/shopping/checked', { method: 'DELETE' })
}

62
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,62 @@
export interface Category {
id: string
name: string
slug: string
icon?: string
recipe_count?: number
}
export interface Recipe {
id: string
title: string
slug: string
description?: string
category_id?: string
category_name?: string
category_slug?: string
servings?: number
prep_time_min?: number
cook_time_min?: number
total_time_min?: number
difficulty?: 'easy' | 'medium' | 'hard'
image_url?: string
thumbnail_url?: string
is_favorite: boolean
tags?: string[]
ingredients?: Ingredient[]
steps?: Step[]
notes?: Note[]
created_at?: string
updated_at?: string
}
export interface Ingredient {
id?: string
amount?: number
unit?: string
name: string
group_name?: string
}
export interface Step {
id?: string
step_number: number
instruction: string
timer_minutes?: number
timer_label?: string
image_url?: string
}
export interface Note {
id: string
content: string
created_at: string
}
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
limit: number
totalPages: number
}

View File

@@ -0,0 +1,45 @@
import { Component, type ReactNode } from 'react'
interface Props {
children: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-cream">
<div className="text-center space-y-4">
<span className="text-5xl">😵</span>
<h1 className="font-display text-xl text-espresso">Etwas ist schiefgelaufen</h1>
<p className="text-warm-grey text-sm">{this.state.error?.message}</p>
<button
onClick={() => {
this.setState({ hasError: false })
window.location.href = '/'
}}
className="bg-primary text-white px-6 py-3 rounded-xl text-sm font-medium min-h-[44px]"
>
Zurück zur Startseite
</button>
</div>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,13 @@
import { Outlet } from 'react-router'
import { BottomNav } from './BottomNav'
export function AppShell() {
return (
<div className="min-h-screen bg-cream">
<div className="max-w-lg mx-auto pb-20">
<Outlet />
</div>
<BottomNav />
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { NavLink } from 'react-router'
import { Home, Search, PlusCircle, ShoppingCart, User } from 'lucide-react'
const navItems = [
{ to: '/', icon: Home, label: 'Home' },
{ to: '/search', icon: Search, label: 'Suche' },
{ to: '/new', icon: PlusCircle, label: 'Neu' },
{ to: '/shopping', icon: ShoppingCart, label: 'Einkauf' },
{ to: '/profile', icon: User, label: 'Profil' },
]
export function BottomNav() {
return (
<nav className="fixed bottom-0 left-0 right-0 bg-surface border-t border-sand z-50">
<div className="max-w-lg mx-auto flex justify-around items-center h-16">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex flex-col items-center gap-0.5 text-xs transition-colors ${
isActive ? 'text-primary' : 'text-warm-grey'
}`
}
>
<Icon size={22} />
<span>{label}</span>
</NavLink>
))}
</div>
</nav>
)
}

View File

@@ -0,0 +1,58 @@
import { Link } from 'react-router'
import { Heart, Clock } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toggleFavorite } from '../../api/recipes'
import type { Recipe } from '../../api/types'
const gradients = [
'from-primary/60 to-secondary/60',
'from-secondary/60 to-sage/60',
'from-primary-light to-primary/40',
'from-sage/40 to-secondary/60',
]
export function RecipeCard({ recipe }: { recipe: Recipe }) {
const qc = useQueryClient()
const favMutation = useMutation({
mutationFn: () => toggleFavorite(recipe.id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['recipes'] }),
})
const gradient = gradients[recipe.title.length % gradients.length]
const totalTime = recipe.total_time_min || ((recipe.prep_time_min || 0) + (recipe.cook_time_min || 0))
return (
<div className="bg-surface rounded-2xl overflow-hidden shadow-sm break-inside-avoid mb-4">
<Link to={`/recipe/${recipe.slug}`}>
{recipe.image_url ? (
<img src={recipe.image_url} alt={recipe.title} className="w-full h-auto object-cover" loading="lazy" />
) : (
<div className={`w-full aspect-[3/4] bg-gradient-to-br ${gradient} flex items-center justify-center`}>
<span className="text-4xl">🍰</span>
</div>
)}
</Link>
<div className="p-3">
<Link to={`/recipe/${recipe.slug}`}>
<h3 className="font-display text-base text-espresso line-clamp-2">{recipe.title}</h3>
</Link>
<div className="flex items-center justify-between mt-2">
{totalTime > 0 && (
<span className="flex items-center gap-1 text-warm-grey text-xs">
<Clock size={14} /> {totalTime} min
</span>
)}
<button
onClick={(e) => { e.preventDefault(); favMutation.mutate() }}
className="ml-auto"
>
<Heart
size={20}
className={recipe.is_favorite ? 'fill-primary text-primary' : 'text-warm-grey'}
/>
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { Link } from 'react-router'
import { Clock } from 'lucide-react'
import type { Recipe } from '../../api/types'
const gradients = [
'from-primary/60 to-secondary/60',
'from-secondary/60 to-sage/60',
]
export function RecipeCardSmall({ recipe }: { recipe: Recipe }) {
const gradient = gradients[recipe.title.length % gradients.length]
const totalTime = recipe.total_time_min || ((recipe.prep_time_min || 0) + (recipe.cook_time_min || 0))
return (
<Link to={`/recipe/${recipe.slug}`} className="flex bg-surface rounded-2xl overflow-hidden shadow-sm">
{recipe.image_url ? (
<img src={recipe.image_url} alt={recipe.title} className="w-24 h-24 object-cover flex-shrink-0" />
) : (
<div className={`w-24 h-24 flex-shrink-0 bg-gradient-to-br ${gradient} flex items-center justify-center`}>
<span className="text-2xl">🍰</span>
</div>
)}
<div className="p-3 flex flex-col justify-center min-w-0">
<h3 className="font-display text-sm text-espresso line-clamp-2">{recipe.title}</h3>
<div className="flex items-center gap-2 mt-1 text-warm-grey text-xs">
{totalTime > 0 && (
<span className="flex items-center gap-1"><Clock size={12} /> {totalTime} min</span>
)}
{recipe.difficulty && <span className="capitalize"> {recipe.difficulty}</span>}
</div>
</div>
</Link>
)
}

View File

@@ -0,0 +1,20 @@
interface Props {
children: React.ReactNode
active?: boolean
onClick?: () => void
}
export function Badge({ children, active, onClick }: Props) {
return (
<span
onClick={onClick}
className={`inline-block px-3 py-1 rounded-full text-sm font-medium cursor-pointer transition-colors whitespace-nowrap ${
active
? 'bg-primary text-white'
: 'bg-primary-light text-primary'
}`}
>
{children}
</span>
)
}

View File

@@ -0,0 +1,22 @@
import type { ButtonHTMLAttributes } from 'react'
type Variant = 'primary' | 'secondary' | 'ghost'
const styles: Record<Variant, string> = {
primary: 'bg-primary text-white hover:bg-primary/90',
secondary: 'bg-secondary text-white hover:bg-secondary/90',
ghost: 'bg-transparent text-espresso hover:bg-sand/50',
}
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant
}
export function Button({ variant = 'primary', className = '', ...props }: Props) {
return (
<button
className={`px-4 py-2 rounded-xl font-medium transition-colors ${styles[variant]} ${className}`}
{...props}
/>
)
}

View File

@@ -0,0 +1,15 @@
interface Props {
icon?: string
title: string
description?: string
}
export function EmptyState({ icon = '🍰', title, description }: Props) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<span className="text-5xl mb-4">{icon}</span>
<h3 className="font-display text-xl text-espresso mb-2">{title}</h3>
{description && <p className="text-warm-grey text-sm">{description}</p>}
</div>
)
}

View File

@@ -0,0 +1,15 @@
export function Skeleton({ className = '' }: { className?: string }) {
return <div className={`animate-pulse bg-sand/50 rounded-xl ${className}`} />
}
export function RecipeCardSkeleton() {
return (
<div className="bg-surface rounded-2xl overflow-hidden shadow-sm">
<Skeleton className="w-full h-48 rounded-none" />
<div className="p-3 space-y-2">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
)
}

24
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'react-hot-toast'
import { ErrorBoundary } from './components/ErrorBoundary'
import App from './App'
import './styles/globals.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 1000 * 60 * 5, retry: 1 },
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<App />
</ErrorBoundary>
<Toaster position="top-center" toastOptions={{ duration: 2000 }} />
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,71 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Masonry from 'react-masonry-css'
import { fetchRecipes } from '../api/recipes'
import { fetchCategories } from '../api/categories'
import { RecipeCard } from '../components/recipe/RecipeCard'
import type { Recipe } from '../api/types'
import { Badge } from '../components/ui/Badge'
import { RecipeCardSkeleton } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
export function HomePage() {
const [activeCategory, setActiveCategory] = useState<string | undefined>()
const { data: categories } = useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
})
const { data: recipesData, isLoading } = useQuery({
queryKey: ['recipes', { category: activeCategory }],
queryFn: () => fetchRecipes({ category: activeCategory, sort: 'newest' }),
})
const recipes = recipesData?.data ?? []
return (
<div>
{/* TopBar */}
<div className="flex items-center justify-between px-4 py-4">
<h1 className="font-display text-2xl text-espresso">🍰 Luna Recipes</h1>
<div className="w-9 h-9 rounded-full bg-primary-light flex items-center justify-center text-sm">👤</div>
</div>
{/* Category Chips */}
{categories && (Array.isArray(categories) ? categories : []).length > 0 && (
<div className="flex gap-2 px-4 pb-4 overflow-x-auto scrollbar-hide">
<Badge active={!activeCategory} onClick={() => setActiveCategory(undefined)}>
Alle
</Badge>
{(Array.isArray(categories) ? categories : []).map((cat) => (
<Badge
key={cat.id}
active={activeCategory === cat.slug}
onClick={() => setActiveCategory(activeCategory === cat.slug ? undefined : cat.slug)}
>
{cat.icon || ''} {cat.name}
</Badge>
))}
</div>
)}
{/* Recipe Grid */}
<div className="px-4">
{isLoading ? (
<Masonry breakpointCols={2} className="flex -ml-4" columnClassName="pl-4">
{Array.from({ length: 6 }).map((_, i) => <RecipeCardSkeleton key={i} />)}
</Masonry>
) : recipes.length === 0 ? (
<EmptyState title="Noch keine Rezepte" description="Füge dein erstes Rezept hinzu!" />
) : (
<Masonry breakpointCols={2} className="flex -ml-4" columnClassName="pl-4">
{recipes.map((recipe: Recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</Masonry>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,9 @@
import { EmptyState } from '../components/ui/EmptyState'
export function PlaceholderPage({ title, icon }: { title: string; icon: string }) {
return (
<div className="p-4">
<EmptyState icon={icon} title={title} description="Kommt bald!" />
</div>
)
}

View File

@@ -0,0 +1,486 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { ArrowLeft, Plus, Trash2, Camera, X, GripVertical } from 'lucide-react'
import { fetchRecipe, createRecipe, updateRecipe, deleteRecipe, uploadRecipeImage } from '../api/recipes'
import { fetchCategories } from '../api/categories'
import type { RecipeFormData } from '../api/recipes'
import type { Ingredient, Step } from '../api/types'
interface IngredientRow {
key: string
name: string
amount: string
unit: string
group_name: string
}
interface StepRow {
key: string
instruction: string
}
let keyCounter = 0
function nextKey() { return `k${++keyCounter}` }
export function RecipeFormPage() {
const { slug } = useParams<{ slug: string }>()
const isEdit = !!slug
const navigate = useNavigate()
const qc = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const { data: recipe, isLoading: recipeLoading } = useQuery({
queryKey: ['recipe', slug],
queryFn: () => fetchRecipe(slug!),
enabled: isEdit,
})
const { data: categories } = useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
})
// Form state
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [categoryId, setCategoryId] = useState('')
const [difficulty, setDifficulty] = useState<'easy' | 'medium' | 'hard'>('medium')
const [prepTime, setPrepTime] = useState('')
const [cookTime, setCookTime] = useState('')
const [servings, setServings] = useState('4')
const [sourceUrl, setSourceUrl] = useState('')
const [imageUrl, setImageUrl] = useState('')
const [imageFile, setImageFile] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const [ingredients, setIngredients] = useState<IngredientRow[]>([
{ key: nextKey(), name: '', amount: '', unit: '', group_name: '' },
])
const [steps, setSteps] = useState<StepRow[]>([
{ key: nextKey(), instruction: '' },
])
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
// Populate form when editing
useEffect(() => {
if (recipe && isEdit) {
setTitle(recipe.title)
setDescription(recipe.description || '')
setCategoryId(recipe.category_id || '')
setDifficulty(recipe.difficulty || 'medium')
setPrepTime(recipe.prep_time_min ? String(recipe.prep_time_min) : '')
setCookTime(recipe.cook_time_min ? String(recipe.cook_time_min) : '')
setServings(recipe.servings ? String(recipe.servings) : '4')
setSourceUrl((recipe as any).source_url || '')
setImageUrl(recipe.image_url || '')
if (recipe.image_url) setImagePreview(recipe.image_url)
if (recipe.ingredients && recipe.ingredients.length > 0) {
setIngredients(recipe.ingredients.map(ing => ({
key: nextKey(),
name: ing.name,
amount: ing.amount ? String(ing.amount) : '',
unit: ing.unit || '',
group_name: ing.group_name || '',
})))
}
if (recipe.steps && recipe.steps.length > 0) {
setSteps(recipe.steps.map(s => ({
key: nextKey(),
instruction: s.instruction,
})))
}
}
}, [recipe, isEdit])
const saveMutation = useMutation({
mutationFn: async (data: RecipeFormData) => {
if (isEdit && recipe) {
return updateRecipe(recipe.id, data)
}
return createRecipe(data)
},
onSuccess: async (saved) => {
// Upload image if selected
if (imageFile && saved?.id) {
try {
await uploadRecipeImage(saved.id, imageFile)
} catch {
toast.error('Rezept gespeichert, aber Bild-Upload fehlgeschlagen')
}
}
qc.invalidateQueries({ queryKey: ['recipes'] })
qc.invalidateQueries({ queryKey: ['recipe', slug] })
toast.success(isEdit ? 'Rezept aktualisiert!' : 'Rezept erstellt!')
navigate(saved?.slug ? `/recipe/${saved.slug}` : '/')
},
onError: () => toast.error('Fehler beim Speichern'),
})
const deleteMutation = useMutation({
mutationFn: () => deleteRecipe(recipe!.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['recipes'] })
toast.success('Rezept gelöscht')
navigate('/')
},
onError: () => toast.error('Fehler beim Löschen'),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) {
toast.error('Titel ist erforderlich')
return
}
const data: RecipeFormData = {
title: title.trim(),
description: description.trim() || undefined,
category_id: categoryId || undefined,
difficulty,
prep_time: prepTime ? Number(prepTime) : undefined,
cook_time: cookTime ? Number(cookTime) : undefined,
servings: servings ? Number(servings) : undefined,
source_url: sourceUrl.trim() || undefined,
image_url: imageUrl || undefined,
ingredients: ingredients
.filter(i => i.name.trim())
.map((i, idx) => ({
name: i.name.trim(),
amount: i.amount ? Number(i.amount) : undefined,
unit: i.unit.trim() || undefined,
group_name: i.group_name.trim() || undefined,
sort_order: idx,
})),
steps: steps
.filter(s => s.instruction.trim())
.map((s, idx) => ({
step_number: idx + 1,
instruction: s.instruction.trim(),
})),
}
saveMutation.mutate(data)
}
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setImageFile(file)
const url = URL.createObjectURL(file)
setImagePreview(url)
}
const addIngredient = () => {
setIngredients(prev => [...prev, { key: nextKey(), name: '', amount: '', unit: '', group_name: '' }])
}
const removeIngredient = (key: string) => {
setIngredients(prev => prev.filter(i => i.key !== key))
}
const updateIngredient = (key: string, field: keyof IngredientRow, value: string) => {
setIngredients(prev => prev.map(i => i.key === key ? { ...i, [field]: value } : i))
}
const addStep = () => {
setSteps(prev => [...prev, { key: nextKey(), instruction: '' }])
}
const removeStep = (key: string) => {
setSteps(prev => prev.filter(s => s.key !== key))
}
const updateStep = (key: string, instruction: string) => {
setSteps(prev => prev.map(s => s.key === key ? { ...s, instruction } : s))
}
if (isEdit && recipeLoading) {
return <div className="p-4 text-warm-grey">Laden...</div>
}
const inputClass = "w-full bg-surface border border-sand rounded-xl px-4 py-3 text-base text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px]"
const labelClass = "block text-sm font-semibold text-espresso mb-1"
return (
<div className="min-h-screen bg-cream">
{/* Header */}
<div className="sticky top-0 z-40 bg-cream/95 backdrop-blur-sm border-b border-sand px-4 py-3 flex items-center gap-3">
<button onClick={() => navigate(-1)} className="p-2 -ml-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
<ArrowLeft size={20} className="text-espresso" />
</button>
<h1 className="font-display text-lg text-espresso flex-1">
{isEdit ? 'Rezept bearbeiten' : 'Neues Rezept'}
</h1>
</div>
<form onSubmit={handleSubmit} className="p-4 pb-32 space-y-6">
{/* Image */}
<div>
<label className={labelClass}>Bild</label>
<input
ref={fileInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleImageSelect}
className="hidden"
/>
{imagePreview ? (
<div className="relative">
<img src={imagePreview} alt="Preview" className="w-full h-48 object-cover rounded-2xl" />
<button
type="button"
onClick={() => { setImageFile(null); setImagePreview(null); setImageUrl('') }}
className="absolute top-2 right-2 bg-surface/80 backdrop-blur-sm rounded-full p-1.5"
>
<X size={16} className="text-espresso" />
</button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full h-48 border-2 border-dashed border-sand rounded-2xl flex flex-col items-center justify-center gap-2 text-warm-grey hover:border-primary/50 transition-colors"
>
<Camera size={32} />
<span className="text-sm">Foto aufnehmen oder auswählen</span>
</button>
)}
</div>
{/* Title */}
<div>
<label className={labelClass}>Titel *</label>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="z.B. Omas Apfelkuchen"
className={inputClass}
required
/>
</div>
{/* Description */}
<div>
<label className={labelClass}>Beschreibung</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Kurze Beschreibung..."
rows={3}
className={`${inputClass} resize-none`}
/>
</div>
{/* Category + Difficulty row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>Kategorie</label>
<select value={categoryId} onChange={e => setCategoryId(e.target.value)} className={inputClass}>
<option value="">Keine</option>
{(categories || []).map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Schwierigkeit</label>
<select value={difficulty} onChange={e => setDifficulty(e.target.value as any)} className={inputClass}>
<option value="easy">Einfach</option>
<option value="medium">Mittel</option>
<option value="hard">Schwer</option>
</select>
</div>
</div>
{/* Times + Servings */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelClass}>Vorbereitung</label>
<input
type="number"
value={prepTime}
onChange={e => setPrepTime(e.target.value)}
placeholder="Min"
min="0"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Kochzeit</label>
<input
type="number"
value={cookTime}
onChange={e => setCookTime(e.target.value)}
placeholder="Min"
min="0"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Portionen</label>
<input
type="number"
value={servings}
onChange={e => setServings(e.target.value)}
placeholder="4"
min="1"
className={inputClass}
/>
</div>
</div>
{/* Source URL */}
<div>
<label className={labelClass}>Quelle (URL)</label>
<input
type="url"
value={sourceUrl}
onChange={e => setSourceUrl(e.target.value)}
placeholder="https://pinterest.com/..."
className={inputClass}
/>
</div>
{/* Ingredients */}
<div>
<div className="flex items-center justify-between mb-2">
<label className={labelClass}>Zutaten</label>
<button type="button" onClick={addIngredient} className="flex items-center gap-1 text-primary text-sm font-medium min-h-[44px] px-2">
<Plus size={16} /> Hinzufügen
</button>
</div>
<div className="space-y-2">
{ingredients.map((ing) => (
<div key={ing.key} className="flex gap-2 items-start">
<input
type="text"
value={ing.amount}
onChange={e => updateIngredient(ing.key, 'amount', e.target.value)}
placeholder="Menge"
className="w-16 bg-surface border border-sand rounded-xl px-3 py-3 text-sm text-espresso min-h-[44px]"
/>
<input
type="text"
value={ing.unit}
onChange={e => updateIngredient(ing.key, 'unit', e.target.value)}
placeholder="Einheit"
className="w-16 bg-surface border border-sand rounded-xl px-3 py-3 text-sm text-espresso min-h-[44px]"
/>
<input
type="text"
value={ing.name}
onChange={e => updateIngredient(ing.key, 'name', e.target.value)}
placeholder="Zutat"
className="flex-1 bg-surface border border-sand rounded-xl px-3 py-3 text-sm text-espresso min-h-[44px]"
/>
<button
type="button"
onClick={() => removeIngredient(ing.key)}
className="p-2 text-warm-grey hover:text-berry-red min-w-[44px] min-h-[44px] flex items-center justify-center"
>
<X size={16} />
</button>
</div>
))}
</div>
</div>
{/* Steps */}
<div>
<div className="flex items-center justify-between mb-2">
<label className={labelClass}>Zubereitung</label>
<button type="button" onClick={addStep} className="flex items-center gap-1 text-primary text-sm font-medium min-h-[44px] px-2">
<Plus size={16} /> Schritt
</button>
</div>
<div className="space-y-3">
{steps.map((step, idx) => (
<div key={step.key} className="flex gap-2 items-start">
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-primary text-white text-sm flex items-center justify-center font-medium mt-2">
{idx + 1}
</span>
<textarea
value={step.instruction}
onChange={e => updateStep(step.key, e.target.value)}
placeholder={`Schritt ${idx + 1}...`}
rows={2}
className="flex-1 bg-surface border border-sand rounded-xl px-3 py-3 text-sm text-espresso resize-none min-h-[44px]"
/>
<button
type="button"
onClick={() => removeStep(step.key)}
className="p-2 text-warm-grey hover:text-berry-red min-w-[44px] min-h-[44px] flex items-center justify-center"
>
<X size={16} />
</button>
</div>
))}
</div>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<button
type="submit"
disabled={saveMutation.isPending}
className="w-full bg-primary text-white px-6 py-4 rounded-2xl font-medium text-base shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-50 min-h-[44px]"
>
{saveMutation.isPending ? 'Speichern...' : (isEdit ? 'Änderungen speichern' : 'Rezept erstellen')}
</button>
<button
type="button"
onClick={() => navigate(-1)}
className="w-full bg-surface text-espresso px-6 py-4 rounded-2xl font-medium text-base border border-sand min-h-[44px]"
>
Abbrechen
</button>
{isEdit && (
<>
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="w-full text-berry-red px-6 py-4 rounded-2xl font-medium text-base min-h-[44px]"
>
<Trash2 size={16} className="inline mr-2" />
Rezept löschen
</button>
{/* Delete Confirmation Dialog */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setShowDeleteConfirm(false)}>
<div className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl space-y-4" onClick={e => e.stopPropagation()}>
<h2 className="font-display text-lg text-espresso">Rezept löschen?</h2>
<p className="text-sm text-warm-grey">
Möchtest du "{recipe?.title}" wirklich löschen? Das kann nicht rückgängig gemacht werden.
</p>
<div className="flex gap-3">
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 bg-surface border border-sand text-espresso px-4 py-3 rounded-xl font-medium min-h-[44px]"
>
Abbrechen
</button>
<button
type="button"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="flex-1 bg-berry-red text-white px-4 py-3 rounded-xl font-medium min-h-[44px] disabled:opacity-50"
>
{deleteMutation.isPending ? 'Löschen...' : 'Löschen'}
</button>
</div>
</div>
</div>
)}
</>
)}
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,193 @@
import { useParams, useNavigate, Link } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { ArrowLeft, Heart, Clock, Users, ChefHat, ShoppingCart, Pencil } from 'lucide-react'
import { fetchRecipe, toggleFavorite } from '../api/recipes'
import { addFromRecipe } from '../api/shopping'
import { Badge } from '../components/ui/Badge'
import { Skeleton } from '../components/ui/Skeleton'
const gradients = ['from-primary/40 to-secondary/40', 'from-secondary/40 to-sage/40']
export function RecipePage() {
const { slug } = useParams<{ slug: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const { data: recipe, isLoading } = useQuery({
queryKey: ['recipe', slug],
queryFn: () => fetchRecipe(slug!),
enabled: !!slug,
})
const favMutation = useMutation({
mutationFn: () => toggleFavorite(recipe!.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['recipe', slug] })
qc.invalidateQueries({ queryKey: ['recipes'] })
},
onError: () => toast.error('Fehler beim Ändern des Favoriten-Status'),
})
const shoppingMutation = useMutation({
mutationFn: () => addFromRecipe(recipe!.id),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['shopping'] })
toast.success(`${data.added} Zutaten zur Einkaufsliste hinzugefügt!`)
},
onError: () => toast.error('Fehler beim Hinzufügen zur Einkaufsliste'),
})
if (isLoading) {
return (
<div className="p-4 space-y-4">
<Skeleton className="w-full h-64" />
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
)
}
if (!recipe) return <div className="p-4">Rezept nicht gefunden.</div>
const totalTime = recipe.total_time_min || ((recipe.prep_time_min || 0) + (recipe.cook_time_min || 0))
const gradient = gradients[recipe.title.length % gradients.length]
// Group ingredients
const ingredientGroups = (recipe.ingredients || []).reduce<Record<string, typeof recipe.ingredients>>((acc, ing) => {
const group = ing.group_name || 'Zutaten'
if (!acc[group]) acc[group] = []
acc[group]!.push(ing)
return acc
}, {})
return (
<div>
{/* Hero */}
<div className="relative">
{recipe.image_url ? (
<img src={recipe.image_url} alt={recipe.title} className="w-full h-64 object-cover" />
) : (
<div className={`w-full h-64 bg-gradient-to-br ${gradient} flex items-center justify-center`}>
<span className="text-6xl">🍰</span>
</div>
)}
<button onClick={() => navigate(-1)} className="absolute top-4 left-4 bg-surface/80 backdrop-blur-sm rounded-full p-2">
<ArrowLeft size={20} className="text-espresso" />
</button>
<div className="absolute top-4 right-4 flex gap-2">
<Link to={`/recipe/${recipe.slug}/edit`} className="bg-surface/80 backdrop-blur-sm rounded-full p-2">
<Pencil size={20} className="text-espresso" />
</Link>
<button onClick={() => favMutation.mutate()} className="bg-surface/80 backdrop-blur-sm rounded-full p-2">
<Heart size={20} className={recipe.is_favorite ? 'fill-primary text-primary' : 'text-espresso'} />
</button>
</div>
</div>
<div className="p-4 space-y-6">
{/* Title & Meta */}
<div>
<h1 className="font-display text-2xl text-espresso">{recipe.title}</h1>
<div className="flex flex-wrap items-center gap-2 mt-2">
{recipe.category_name && <Badge>{recipe.category_name}</Badge>}
{totalTime > 0 && (
<span className="flex items-center gap-1 text-warm-grey text-sm"><Clock size={14} /> {totalTime} min</span>
)}
{recipe.servings && (
<span className="flex items-center gap-1 text-warm-grey text-sm"><Users size={14} /> {recipe.servings} Portionen</span>
)}
{recipe.difficulty && (
<span className="flex items-center gap-1 text-warm-grey text-sm"><ChefHat size={14} /> {recipe.difficulty}</span>
)}
</div>
{recipe.description && <p className="text-warm-grey mt-2 text-sm">{recipe.description}</p>}
</div>
{/* Tags */}
{recipe.tags && recipe.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{recipe.tags.map((tag) => (
<span key={tag} className="text-xs bg-sand/50 text-warm-grey px-2 py-0.5 rounded-full">#{tag}</span>
))}
</div>
)}
{/* Ingredients */}
{Object.keys(ingredientGroups).length > 0 && (
<div>
<h2 className="font-display text-lg text-espresso mb-3">Zutaten</h2>
{Object.entries(ingredientGroups).map(([group, items]) => (
<div key={group} className="mb-3">
{Object.keys(ingredientGroups).length > 1 && (
<h3 className="font-semibold text-sm text-warm-grey mb-1">{group}</h3>
)}
<ul className="space-y-1">
{items!.map((ing, i) => (
<li key={i} className="flex gap-2 text-sm text-espresso py-1 border-b border-sand/50">
<span className="text-warm-grey min-w-[60px]">
{ing.amount ? `${ing.amount} ${ing.unit || ''}`.trim() : ''}
</span>
<span>{ing.name}</span>
</li>
))}
</ul>
</div>
))}
</div>
)}
{/* 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>
)}
{/* Steps */}
{recipe.steps && recipe.steps.length > 0 && (
<div>
<h2 className="font-display text-lg text-espresso mb-3">Zubereitung</h2>
<ol className="space-y-4">
{recipe.steps.map((step, i) => (
<li key={i} className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-primary text-white text-sm flex items-center justify-center font-medium">
{step.step_number || i + 1}
</span>
<div className="text-sm text-espresso pt-1">
<p>{step.instruction}</p>
{step.timer_minutes && (
<span className="inline-flex items-center gap-1 mt-1 text-xs text-primary">
<Clock size={12} /> {step.timer_label || 'Timer'}: {step.timer_minutes} min
</span>
)}
</div>
</li>
))}
</ol>
</div>
)}
{/* Notes */}
{recipe.notes && recipe.notes.length > 0 && (
<div>
<h2 className="font-display text-lg text-espresso mb-3">Notizen</h2>
<div className="space-y-2">
{recipe.notes.map((note) => (
<div key={note.id} className="bg-primary-light/30 rounded-xl p-3 text-sm text-espresso">
📝 {note.content}
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Search } from 'lucide-react'
import { searchRecipes } from '../api/recipes'
import { RecipeCardSmall } from '../components/recipe/RecipeCardSmall'
import { EmptyState } from '../components/ui/EmptyState'
export function SearchPage() {
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { inputRef.current?.focus() }, [])
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300)
return () => clearTimeout(timer)
}, [query])
const { data, isLoading } = useQuery({
queryKey: ['search', debouncedQuery],
queryFn: () => searchRecipes(debouncedQuery),
enabled: debouncedQuery.length >= 2,
})
const results = data?.data ?? (Array.isArray(data) ? data as any[] : [])
return (
<div className="p-4">
{/* Search Input */}
<div className="relative mb-4">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-warm-grey" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Rezept suchen..."
className="w-full pl-10 pr-4 py-3 bg-surface rounded-xl border border-sand text-espresso text-sm focus:outline-none focus:border-primary"
/>
</div>
{/* Results */}
{debouncedQuery.length < 2 ? (
<EmptyState icon="🔍" title="Suche starten" description="Gib mindestens 2 Zeichen ein" />
) : isLoading ? (
<p className="text-warm-grey text-sm text-center py-8">Suche...</p>
) : results.length === 0 ? (
<EmptyState icon="😔" title="Nichts gefunden" description={`Keine Ergebnisse für "${debouncedQuery}"`} />
) : (
<>
<p className="text-warm-grey text-xs mb-3">{results.length} Ergebnis{results.length !== 1 ? 'se' : ''}</p>
<div className="space-y-3">
{results.map((recipe: any) => (
<RecipeCardSmall key={recipe.id} recipe={recipe} />
))}
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,281 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2, Plus, ShoppingCart, X } from 'lucide-react'
import {
fetchShopping,
addCustomItem,
toggleCheck,
deleteItem,
deleteChecked,
} from '../api/shopping'
import type { ShoppingGroup, ShoppingItem } from '../api/shopping'
import { EmptyState } from '../components/ui/EmptyState'
export function ShoppingPage() {
const qc = useQueryClient()
const [newItem, setNewItem] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const { data: groups = [], isLoading, refetch } = useQuery({
queryKey: ['shopping'],
queryFn: fetchShopping,
})
const checkMutation = useMutation({
mutationFn: toggleCheck,
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
})
const deleteMutation = useMutation({
mutationFn: deleteItem,
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
})
const deleteCheckedMutation = useMutation({
mutationFn: deleteChecked,
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
})
const addMutation = useMutation({
mutationFn: addCustomItem,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['shopping'] })
setNewItem('')
inputRef.current?.focus()
},
})
const handleAdd = () => {
const name = newItem.trim()
if (!name) return
addMutation.mutate({ name })
}
const hasChecked = groups.some((g) => g.items.some((i) => i.checked))
const totalUnchecked = groups.reduce((acc, g) => acc + g.items.filter((i) => !i.checked).length, 0)
// 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
}
}
const handleTouchEnd = (e: React.TouchEvent) => {
if (pulling) {
const dy = e.changedTouches[0].clientY - touchStartY.current
if (dy > 80) {
refetch()
}
setPulling(false)
}
}
const handleTouchMove = (e: React.TouchEvent) => {
if (window.scrollY === 0) {
const dy = e.touches[0].clientY - touchStartY.current
if (dy > 30) setPulling(true)
}
}
if (isLoading) {
return (
<div className="p-4 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-surface rounded-2xl p-4 animate-pulse h-20" />
))}
</div>
)
}
return (
<div
className="min-h-screen"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* TopBar */}
<div className="sticky top-0 z-40 bg-cream/95 backdrop-blur-sm border-b border-sand px-4 py-3 flex items-center justify-between">
<h1 className="font-display text-xl text-espresso">Einkaufsliste</h1>
<div className="flex items-center gap-2">
{totalUnchecked > 0 && (
<span className="text-sm text-warm-grey">{totalUnchecked} offen</span>
)}
{hasChecked && (
<button
onClick={() => deleteCheckedMutation.mutate()}
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} />
</button>
)}
</div>
</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">
<form
onSubmit={(e) => {
e.preventDefault()
handleAdd()
}}
className="flex gap-2"
>
<input
ref={inputRef}
type="text"
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
placeholder="Artikel hinzufügen..."
className="flex-1 bg-surface border border-sand rounded-xl px-4 py-3 text-base text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<button
type="submit"
disabled={!newItem.trim() || addMutation.isPending}
className="bg-primary text-white rounded-xl px-4 py-3 min-w-[44px] min-h-[44px] flex items-center justify-center disabled:opacity-40 transition-opacity"
>
<Plus size={20} />
</button>
</form>
</div>
{/* Content */}
<div className="px-4 pb-24 space-y-4">
{groups.length === 0 ? (
<EmptyState
icon="🛒"
title="Einkaufsliste leer"
description="Füge Zutaten aus einem Rezept hinzu oder erstelle eigene Einträge."
/>
) : (
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">
{group.recipe_title || 'Eigene'}
</h3>
<span className="text-xs text-warm-grey ml-auto">
{group.items.filter((i) => !i.checked).length}/{group.items.length}
</span>
</div>
{/* Items */}
<ul>
{sortItems(group.items).map((item) => (
<ShoppingItemRow
key={item.id}
item={item}
onToggle={() => checkMutation.mutate(item.id)}
onDelete={() => deleteMutation.mutate(item.id)}
/>
))}
</ul>
</div>
))
)}
</div>
</div>
)
}
function ShoppingItemRow({
item,
onToggle,
onDelete,
}: {
item: ShoppingItem
onToggle: () => void
onDelete: () => void
}) {
const [swipeX, setSwipeX] = useState(0)
const touchStartX = useRef(0)
const swiping = useRef(false)
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX
swiping.current = false
}
const handleTouchMove = (e: React.TouchEvent) => {
const dx = e.touches[0].clientX - touchStartX.current
if (dx < -10) {
swiping.current = true
setSwipeX(Math.max(dx, -80))
} else {
setSwipeX(0)
}
}
const handleTouchEnd = () => {
if (swipeX < -60) {
onDelete()
}
setSwipeX(0)
swiping.current = false
}
const amountText = [item.amount, item.unit].filter(Boolean).join(' ')
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>
<div
className="relative bg-surface flex items-center gap-3 px-4 min-h-[52px] transition-transform"
style={{ transform: `translateX(${swipeX}px)` }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<button
onClick={onToggle}
className="flex-shrink-0 w-6 h-6 rounded-md border-2 flex items-center justify-center transition-colors min-w-[44px] min-h-[44px]"
style={{
borderColor: item.checked ? '#C4737E' : '#E8E0D8',
backgroundColor: item.checked ? '#C4737E' : 'transparent',
}}
>
{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" />
</svg>
)}
</button>
<div className={`flex-1 min-w-0 ${item.checked ? 'opacity-50' : ''}`}>
<span className={`text-base 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'}`}>
{amountText}
</span>
)}
</div>
</li>
)
}

View File

@@ -0,0 +1,32 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap');
@import "tailwindcss";
@theme {
--color-primary: #C4737E;
--color-primary-light: #F2D7DB;
--color-secondary: #D4A574;
--color-cream: #FBF8F5;
--color-surface: #FFFFFF;
--color-espresso: #2D2016;
--color-warm-grey: #7A6E65;
--color-sage: #7BAE7F;
--color-berry: #C94C4C;
--color-sand: #E8E0D8;
--font-display: 'Playfair Display', serif;
--font-sans: 'Inter', system-ui, sans-serif;
}
body {
font-family: var(--font-sans);
background-color: #FBF8F5;
color: #2D2016;
-webkit-font-smoothing: antialiased;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}