diff --git a/backend/data/recipes.db-shm b/backend/data/recipes.db-shm index 6034303..626b8f9 100644 Binary files a/backend/data/recipes.db-shm and b/backend/data/recipes.db-shm differ diff --git a/backend/data/recipes.db-wal b/backend/data/recipes.db-wal index a4b8b97..78845cc 100644 Binary files a/backend/data/recipes.db-wal and b/backend/data/recipes.db-wal differ diff --git a/backend/src/app.ts b/backend/src/app.ts index 26a2b71..32c3cad 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,6 +9,7 @@ import { tagRoutes } from './routes/tags.js'; import { imageRoutes } from './routes/images.js'; import { botRoutes } from './routes/bot.js'; import { ogScrapeRoutes } from './routes/og-scrape.js'; +import { authRoutes } from './routes/auth.js'; export async function buildApp() { const app = Fastify({ logger: true }); @@ -43,5 +44,8 @@ export async function buildApp() { await app.register(ogScrapeRoutes); await app.after(); + await app.register(authRoutes); + await app.after(); + return app; } diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..d0705f6 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -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' }; +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..818ace3 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -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' }); + }); +} diff --git a/features/AUTH-V2.md b/features/AUTH-V2.md new file mode 100644 index 0000000..e628a76 --- /dev/null +++ b/features/AUTH-V2.md @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 57978e3..7396592 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> - } /> + } /> diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..c1015f5 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -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('/auth/login', { method: 'POST', body: JSON.stringify({ email: _email, password: _password }) }) +} + +export function register(_email: string, _password: string, _name: string) { + return apiFetch('/auth/register', { method: 'POST', body: JSON.stringify({ email: _email, password: _password, name: _name }) }) +} + +export function fetchMe() { + return apiFetch('/auth/me') +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 5d87138..b1d3fe5 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -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 } diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..8370b9e --- /dev/null +++ b/frontend/src/pages/ProfilePage.tsx @@ -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: , label: 'Rezepte', value: totalRecipes }, + { icon: , label: 'Favoriten', value: totalFavorites }, + { icon: , label: 'Einkauf', value: totalShoppingItems }, + ] + + return ( +
+ {/* Header */} +
+ + 👤 + +

Luna

+

Hobbyköchin & Rezeptsammlerin

+
+ + {/* Stats */} +
+
+ {stats.map((stat) => ( + +
{stat.icon}
+
{stat.value}
+
{stat.label}
+
+ ))} +
+
+ + {/* App Info */} +
+
+
+ + App-Info +
+
+
+ Version + 1.0 +
+
+ Erstellt + Made with 💕 by Moldi +
+
+
+
+ + {/* Logout Button */} +
+ +
+
+ ) +} diff --git a/frontend/src/pages/RecipePage.tsx b/frontend/src/pages/RecipePage.tsx index 3f277b7..3a304e2 100644 --- a/frontend/src/pages/RecipePage.tsx +++ b/frontend/src/pages/RecipePage.tsx @@ -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(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() { + + {/* Floating Re-Roll Button */} + {fromRandom && ( + + )} ) } diff --git a/frontend/src/pages/ShoppingPage.tsx b/frontend/src/pages/ShoppingPage.tsx index 294eb09..b9b2e07 100644 --- a/frontend/src/pages/ShoppingPage.tsx +++ b/frontend/src/pages/ShoppingPage.tsx @@ -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() { + {/* Summary */} + {totalItems > 0 && ( +
+
+
+ + {recipeCount > 0 && <>{recipeCount} Rezept{recipeCount !== 1 ? 'e' : ''} · } + {totalItems} Artikel · {totalChecked} erledigt + + {totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0}% +
+
+
0 ? (totalChecked / totalItems) * 100 : 0}%` }} + /> +
+
+
+ )} + {/* Content */}
{groups.length === 0 ? ( diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9aac067..4207f70 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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',