feat: stabilization + recipe edit/create UI
This commit is contained in:
26
frontend/src/App.tsx
Normal file
26
frontend/src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
frontend/src/api/categories.ts
Normal file
6
frontend/src/api/categories.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { apiFetch } from './client'
|
||||
import type { Category } from './types'
|
||||
|
||||
export function fetchCategories() {
|
||||
return apiFetch<Category[]>('/categories')
|
||||
}
|
||||
17
frontend/src/api/client.ts
Normal file
17
frontend/src/api/client.ts
Normal 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()
|
||||
}
|
||||
74
frontend/src/api/recipes.ts
Normal file
74
frontend/src/api/recipes.ts
Normal 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 }>
|
||||
})
|
||||
}
|
||||
45
frontend/src/api/shopping.ts
Normal file
45
frontend/src/api/shopping.ts
Normal 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
62
frontend/src/api/types.ts
Normal 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
|
||||
}
|
||||
45
frontend/src/components/ErrorBoundary.tsx
Normal file
45
frontend/src/components/ErrorBoundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
13
frontend/src/components/layout/AppShell.tsx
Normal file
13
frontend/src/components/layout/AppShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/layout/BottomNav.tsx
Normal file
33
frontend/src/components/layout/BottomNav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
frontend/src/components/recipe/RecipeCard.tsx
Normal file
58
frontend/src/components/recipe/RecipeCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/recipe/RecipeCardSmall.tsx
Normal file
34
frontend/src/components/recipe/RecipeCardSmall.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
frontend/src/components/ui/Badge.tsx
Normal file
20
frontend/src/components/ui/Badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
frontend/src/components/ui/Button.tsx
Normal file
22
frontend/src/components/ui/Button.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
15
frontend/src/components/ui/EmptyState.tsx
Normal file
15
frontend/src/components/ui/EmptyState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
frontend/src/components/ui/Skeleton.tsx
Normal file
15
frontend/src/components/ui/Skeleton.tsx
Normal 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
24
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
71
frontend/src/pages/HomePage.tsx
Normal file
71
frontend/src/pages/HomePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
frontend/src/pages/PlaceholderPage.tsx
Normal file
9
frontend/src/pages/PlaceholderPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
486
frontend/src/pages/RecipeFormPage.tsx
Normal file
486
frontend/src/pages/RecipeFormPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
193
frontend/src/pages/RecipePage.tsx
Normal file
193
frontend/src/pages/RecipePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
frontend/src/pages/SearchPage.tsx
Normal file
62
frontend/src/pages/SearchPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
281
frontend/src/pages/ShoppingPage.tsx
Normal file
281
frontend/src/pages/ShoppingPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
frontend/src/styles/globals.css
Normal file
32
frontend/src/styles/globals.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user