feat: v1.1 - random re-roll, shopping summary, offline PWA, auth prep, profile page
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
7
backend/src/middleware/auth.ts
Normal file
7
backend/src/middleware/auth.ts
Normal 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' };
|
||||||
|
}
|
||||||
15
backend/src/routes/auth.ts
Normal file
15
backend/src/routes/auth.ts
Normal 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
63
features/AUTH-V2.md
Normal 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
|
||||||
@@ -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
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 () => {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user