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

Binary file not shown.

Binary file not shown.

View File

@@ -9,6 +9,7 @@ import { tagRoutes } from './routes/tags.js';
import { imageRoutes } from './routes/images.js'; import { imageRoutes } from './routes/images.js';
import { botRoutes } from './routes/bot.js'; import { botRoutes } from './routes/bot.js';
import { ogScrapeRoutes } from './routes/og-scrape.js'; import { ogScrapeRoutes } from './routes/og-scrape.js';
import { authRoutes } from './routes/auth.js';
export async function buildApp() { export async function buildApp() {
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
@@ -43,5 +44,8 @@ export async function buildApp() {
await app.register(ogScrapeRoutes); await app.register(ogScrapeRoutes);
await app.after(); await app.after();
await app.register(authRoutes);
await app.after();
return app; return app;
} }

View File

@@ -0,0 +1,7 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
// v2: Auth middleware — currently passes through everything
export async function authMiddleware(request: FastifyRequest, _reply: FastifyReply) {
// TODO v2: Verify JWT token, set request.user
(request as any).user = { id: 'default', name: 'Luna' };
}

View File

@@ -0,0 +1,15 @@
import type { FastifyInstance } from 'fastify';
export async function authRoutes(app: FastifyInstance) {
app.post('/api/auth/login', async (_request, reply) => {
reply.status(501).send({ error: 'not implemented' });
});
app.post('/api/auth/register', async (_request, reply) => {
reply.status(501).send({ error: 'not implemented' });
});
app.get('/api/auth/me', async (_request, reply) => {
reply.status(501).send({ error: 'not implemented' });
});
}

63
features/AUTH-V2.md Normal file
View File

@@ -0,0 +1,63 @@
# AUTH v2 — Spezifikation
## Übersicht
Simple Authentifizierung für Luna Recipes, damit mehrere User die App nutzen können.
## Auth-Methode
- **Email + Passwort** (primär)
- **Optional: PIN-Login** (4-6 Ziffern, für schnellen Zugang auf vertrautem Gerät)
- **Kein OAuth/Social Login** — zu komplex für v2
## Token-Strategie
- **JWT Access Token** — kurze Laufzeit (15 min)
- **Refresh Token** — lange Laufzeit (30 Tage), httpOnly Cookie
- Token-Rotation bei jedem Refresh
- Logout invalidiert Refresh Token
## Multi-User Support
- Vorgesehene User: **Luna**, **Marc**, **Gäste**
- Gast-Zugang: Read-only, kein Login nötig (Feature-Flag)
- Jeder User hat eigene Favoriten und Notizen
- Rezepte gehören einem User (created_by)
- Einkaufsliste: Shared per Haushalt (alle sehen dieselbe)
## Datenmodell-Änderungen
```sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
pin_hash TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Bestehende Tabellen erweitern:
ALTER TABLE recipes ADD COLUMN created_by UUID REFERENCES users(id);
ALTER TABLE notes ADD COLUMN user_id UUID REFERENCES users(id);
ALTER TABLE favorites ADD COLUMN user_id UUID REFERENCES users(id);
```
## API-Endpunkte
- `POST /api/auth/register` — { email, password, name }
- `POST /api/auth/login` — { email, password } → { accessToken, user }
- `POST /api/auth/refresh` — Cookie → { accessToken }
- `POST /api/auth/logout` — Invalidiert Refresh Token
- `GET /api/auth/me` — Aktueller User
## Sharing
- Rezept-Links sind öffentlich teilbar (kein Login zum Ansehen nötig)
- Format: `/recipe/:slug` (bleibt gleich)
- Optional: "Rezept kopieren" Button für eingeloggte User
## Migration
- Alle bestehenden Rezepte werden dem Default-User (Luna) zugewiesen
- Bestehende Favoriten/Notizen → Luna
- Keine Breaking Changes für nicht-eingeloggte Nutzung in v2.0
## Nicht in v2
- Social Login (Google, Apple)
- Email-Verifizierung
- Passwort-Reset per Email
- Admin-Panel
- Rollen/Permissions

View File

@@ -4,6 +4,7 @@ import { HomePage } from './pages/HomePage'
import { RecipePage } from './pages/RecipePage' import { RecipePage } from './pages/RecipePage'
import { SearchPage } from './pages/SearchPage' import { SearchPage } from './pages/SearchPage'
import { PlaceholderPage } from './pages/PlaceholderPage' import { PlaceholderPage } from './pages/PlaceholderPage'
import { ProfilePage } from './pages/ProfilePage'
import { RecipeFormPage } from './pages/RecipeFormPage' import { RecipeFormPage } from './pages/RecipeFormPage'
import { ShoppingPage } from './pages/ShoppingPage' import { ShoppingPage } from './pages/ShoppingPage'
@@ -18,7 +19,7 @@ export default function App() {
<Route path="new" element={<RecipeFormPage />} /> <Route path="new" element={<RecipeFormPage />} />
<Route path="recipe/:slug/edit" element={<RecipeFormPage />} /> <Route path="recipe/:slug/edit" element={<RecipeFormPage />} />
<Route path="shopping" element={<ShoppingPage />} /> <Route path="shopping" element={<ShoppingPage />} />
<Route path="profile" element={<PlaceholderPage title="Profil" icon="👤" />} /> <Route path="profile" element={<ProfilePage />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </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 () => { const handleRandomRecipe = async () => {
try { try {
const recipe = await fetchRandomRecipe() const recipe = await fetchRandomRecipe()
if (recipe?.slug) navigate(`/recipe/${recipe.slug}`) if (recipe?.slug) navigate(`/recipe/${recipe.slug}?from=random`)
} catch { } catch {
// no recipes // 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 { useState, useCallback } from 'react'
import { useParams, useNavigate, Link } from 'react-router' import { useParams, useNavigate, useSearchParams, Link } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { ArrowLeft, Heart, Clock, Users, ChefHat, ShoppingCart, Pencil, Minus, Plus, Send, Trash2 } from 'lucide-react' 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 { addFromRecipe } from '../api/shopping'
import { createNote, deleteNote } from '../api/notes' import { createNote, deleteNote } from '../api/notes'
import { Badge } from '../components/ui/Badge' 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() { export function RecipePage() {
const { slug } = useParams<{ slug: string }>() const { slug } = useParams<{ slug: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
const fromRandom = searchParams.get('from') === 'random'
const qc = useQueryClient() const qc = useQueryClient()
const [servingScale, setServingScale] = useState<number | null>(null) const [servingScale, setServingScale] = useState<number | null>(null)
const [noteText, setNoteText] = useState('') 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({ const { data: recipe, isLoading } = useQuery({
queryKey: ['recipe', slug], queryKey: ['recipe', slug],
@@ -298,6 +311,18 @@ export function RecipePage() {
</div> </div>
</div> </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> </motion.div>
) )
} }

View File

@@ -52,7 +52,10 @@ export function ShoppingPage() {
} }
const hasChecked = groups.some((g) => g.items.some((i) => i.checked)) 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 // Sort items: unchecked first, checked last
const sortItems = (items: ShoppingItem[]) => { const sortItems = (items: ShoppingItem[]) => {
@@ -156,6 +159,27 @@ export function ShoppingPage() {
</form> </form>
</div> </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 */} {/* Content */}
<div className="px-4 pb-24 space-y-4"> <div className="px-4 pb-24 space-y-4">
{groups.length === 0 ? ( {groups.length === 0 ? (

View File

@@ -9,6 +9,31 @@ export default defineConfig({
tailwindcss(), tailwindcss(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', 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: { manifest: {
name: 'Luna Recipes', name: 'Luna Recipes',
short_name: 'Luna', short_name: 'Luna',