feat: v1.1 - random re-roll, shopping summary, offline PWA, auth prep, profile page
This commit is contained in:
@@ -4,6 +4,7 @@ import { HomePage } from './pages/HomePage'
|
||||
import { RecipePage } from './pages/RecipePage'
|
||||
import { SearchPage } from './pages/SearchPage'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage'
|
||||
import { ProfilePage } from './pages/ProfilePage'
|
||||
import { RecipeFormPage } from './pages/RecipeFormPage'
|
||||
import { ShoppingPage } from './pages/ShoppingPage'
|
||||
|
||||
@@ -18,7 +19,7 @@ export default function App() {
|
||||
<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 path="profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
21
frontend/src/api/auth.ts
Normal file
21
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { apiFetch } from './client'
|
||||
|
||||
// v2: Auth API — placeholder functions
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export function login(_email: string, _password: string) {
|
||||
return apiFetch<User>('/auth/login', { method: 'POST', body: JSON.stringify({ email: _email, password: _password }) })
|
||||
}
|
||||
|
||||
export function register(_email: string, _password: string, _name: string) {
|
||||
return apiFetch<User>('/auth/register', { method: 'POST', body: JSON.stringify({ email: _email, password: _password, name: _name }) })
|
||||
}
|
||||
|
||||
export function fetchMe() {
|
||||
return apiFetch<User>('/auth/me')
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export function HomePage() {
|
||||
const handleRandomRecipe = async () => {
|
||||
try {
|
||||
const recipe = await fetchRandomRecipe()
|
||||
if (recipe?.slug) navigate(`/recipe/${recipe.slug}`)
|
||||
if (recipe?.slug) navigate(`/recipe/${recipe.slug}?from=random`)
|
||||
} catch {
|
||||
// no recipes
|
||||
}
|
||||
|
||||
99
frontend/src/pages/ProfilePage.tsx
Normal file
99
frontend/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { fetchRecipes } from '../api/recipes'
|
||||
import { fetchShopping } from '../api/shopping'
|
||||
import { LogOut, Info, Heart, BookOpen, ShoppingCart } from 'lucide-react'
|
||||
|
||||
export function ProfilePage() {
|
||||
const { data: allRecipes } = useQuery({
|
||||
queryKey: ['recipes', {}],
|
||||
queryFn: () => fetchRecipes({}),
|
||||
})
|
||||
|
||||
const { data: favRecipes } = useQuery({
|
||||
queryKey: ['recipes', { favorite: true }],
|
||||
queryFn: () => fetchRecipes({ favorite: true }),
|
||||
})
|
||||
|
||||
const { data: shoppingGroups } = useQuery({
|
||||
queryKey: ['shopping'],
|
||||
queryFn: fetchShopping,
|
||||
})
|
||||
|
||||
const totalRecipes = allRecipes?.total ?? 0
|
||||
const totalFavorites = favRecipes?.total ?? 0
|
||||
const totalShoppingItems = (shoppingGroups ?? []).reduce((acc, g) => acc + g.items.length, 0)
|
||||
|
||||
const stats = [
|
||||
{ icon: <BookOpen size={18} />, label: 'Rezepte', value: totalRecipes },
|
||||
{ icon: <Heart size={18} />, label: 'Favoriten', value: totalFavorites },
|
||||
{ icon: <ShoppingCart size={18} />, label: 'Einkauf', value: totalShoppingItems },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-6 text-center">
|
||||
<motion.div
|
||||
className="w-20 h-20 rounded-full bg-primary-light flex items-center justify-center text-3xl mx-auto mb-3"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
>
|
||||
👤
|
||||
</motion.div>
|
||||
<h1 className="font-display text-2xl text-espresso">Luna</h1>
|
||||
<p className="text-sm text-warm-grey mt-1">Hobbyköchin & Rezeptsammlerin</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="px-4 pb-6">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{stats.map((stat) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
className="bg-surface rounded-2xl p-4 text-center shadow-sm"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<div className="flex justify-center text-primary mb-1">{stat.icon}</div>
|
||||
<div className="font-display text-xl text-espresso">{stat.value}</div>
|
||||
<div className="text-xs text-warm-grey">{stat.label}</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App Info */}
|
||||
<div className="px-4 pb-6">
|
||||
<div className="bg-surface rounded-2xl p-4 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2 text-espresso font-medium text-sm">
|
||||
<Info size={16} className="text-primary" />
|
||||
App-Info
|
||||
</div>
|
||||
<div className="text-sm text-warm-grey space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Version</span>
|
||||
<span className="text-espresso">1.0</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Erstellt</span>
|
||||
<span className="text-espresso">Made with 💕 by Moldi</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logout Button */}
|
||||
<div className="px-4 pb-8">
|
||||
<button
|
||||
disabled
|
||||
title="Kommt in v2"
|
||||
className="w-full flex items-center justify-center gap-2 bg-sand/50 text-warm-grey px-4 py-3 rounded-xl font-medium text-sm cursor-not-allowed opacity-60 min-h-[44px]"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Abmelden — kommt in v2
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams, Link } from 'react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, Heart, Clock, Users, ChefHat, ShoppingCart, Pencil, Minus, Plus, Send, Trash2 } from 'lucide-react'
|
||||
import { fetchRecipe, toggleFavorite } from '../api/recipes'
|
||||
import { Dices } from 'lucide-react'
|
||||
import { fetchRecipe, toggleFavorite, fetchRandomRecipe } from '../api/recipes'
|
||||
import { addFromRecipe } from '../api/shopping'
|
||||
import { createNote, deleteNote } from '../api/notes'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
@@ -15,9 +16,21 @@ const gradients = ['from-primary/40 to-secondary/40', 'from-secondary/40 to-sage
|
||||
export function RecipePage() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const fromRandom = searchParams.get('from') === 'random'
|
||||
const qc = useQueryClient()
|
||||
const [servingScale, setServingScale] = useState<number | null>(null)
|
||||
const [noteText, setNoteText] = useState('')
|
||||
const [rerolling, setRerolling] = useState(false)
|
||||
|
||||
const handleReroll = useCallback(async () => {
|
||||
setRerolling(true)
|
||||
try {
|
||||
const r = await fetchRandomRecipe()
|
||||
if (r?.slug) navigate(`/recipe/${r.slug}?from=random`, { replace: true })
|
||||
} catch { /* ignore */ }
|
||||
setRerolling(false)
|
||||
}, [navigate])
|
||||
|
||||
const { data: recipe, isLoading } = useQuery({
|
||||
queryKey: ['recipe', slug],
|
||||
@@ -298,6 +311,18 @@ export function RecipePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Re-Roll Button */}
|
||||
{fromRandom && (
|
||||
<button
|
||||
onClick={handleReroll}
|
||||
disabled={rerolling}
|
||||
className="fixed bottom-20 right-4 z-50 w-12 h-12 rounded-full bg-gradient-to-r from-primary to-secondary text-white shadow-lg flex items-center justify-center active:scale-95 transition-transform disabled:opacity-50"
|
||||
title="Nochmal würfeln"
|
||||
>
|
||||
<Dices size={20} className={rerolling ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,7 +52,10 @@ export function ShoppingPage() {
|
||||
}
|
||||
|
||||
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)
|
||||
const totalItems = groups.reduce((acc, g) => acc + g.items.length, 0)
|
||||
const totalChecked = groups.reduce((acc, g) => acc + g.items.filter((i) => i.checked).length, 0)
|
||||
const totalUnchecked = totalItems - totalChecked
|
||||
const recipeCount = groups.filter((g) => g.recipe_id).length
|
||||
|
||||
// Sort items: unchecked first, checked last
|
||||
const sortItems = (items: ShoppingItem[]) => {
|
||||
@@ -156,6 +159,27 @@ export function ShoppingPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{totalItems > 0 && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="bg-primary-light/30 rounded-2xl p-3">
|
||||
<div className="flex items-center justify-between text-sm text-espresso">
|
||||
<span>
|
||||
{recipeCount > 0 && <>{recipeCount} Rezept{recipeCount !== 1 ? 'e' : ''} · </>}
|
||||
{totalItems} Artikel · {totalChecked} erledigt
|
||||
</span>
|
||||
<span className="text-warm-grey text-xs">{totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 bg-sand rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-300"
|
||||
style={{ width: `${totalItems > 0 ? (totalChecked / totalItems) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 pb-24 space-y-4">
|
||||
{groups.length === 0 ? (
|
||||
|
||||
@@ -9,6 +9,31 @@ export default defineConfig({
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /\/api\/shopping/,
|
||||
handler: 'NetworkFirst',
|
||||
options: { cacheName: 'shopping-api', expiration: { maxEntries: 10, maxAgeSeconds: 86400 } },
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\/recipes/,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: { cacheName: 'recipes-api', expiration: { maxEntries: 50, maxAgeSeconds: 86400 } },
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\/categories/,
|
||||
handler: 'CacheFirst',
|
||||
options: { cacheName: 'categories-api', expiration: { maxEntries: 10, maxAgeSeconds: 604800 } },
|
||||
},
|
||||
{
|
||||
urlPattern: /\/images\//,
|
||||
handler: 'CacheFirst',
|
||||
options: { cacheName: 'recipe-images', expiration: { maxEntries: 100, maxAgeSeconds: 2592000 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: {
|
||||
name: 'Luna Recipes',
|
||||
short_name: 'Luna',
|
||||
|
||||
Reference in New Issue
Block a user