feat: v1.1 - random re-roll, shopping summary, offline PWA, auth prep, profile page

This commit is contained in:
clawd
2026-02-18 10:32:12 +00:00
parent de567f93db
commit c222c880a3
13 changed files with 290 additions and 6 deletions

View File

@@ -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
View 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')
}

View File

@@ -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
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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 ? (

View File

@@ -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',