From c222c880a3a56d3c55de79933f83a7afc03c7f3b Mon Sep 17 00:00:00 2001 From: clawd Date: Wed, 18 Feb 2026 10:32:12 +0000 Subject: [PATCH] feat: v1.1 - random re-roll, shopping summary, offline PWA, auth prep, profile page --- backend/data/recipes.db-shm | Bin 32768 -> 32768 bytes backend/data/recipes.db-wal | Bin 1800472 -> 2191872 bytes backend/src/app.ts | 4 ++ backend/src/middleware/auth.ts | 7 ++ backend/src/routes/auth.ts | 15 +++++ features/AUTH-V2.md | 63 ++++++++++++++++++ frontend/src/App.tsx | 3 +- frontend/src/api/auth.ts | 21 ++++++ frontend/src/pages/HomePage.tsx | 2 +- frontend/src/pages/ProfilePage.tsx | 99 ++++++++++++++++++++++++++++ frontend/src/pages/RecipePage.tsx | 31 ++++++++- frontend/src/pages/ShoppingPage.tsx | 26 +++++++- frontend/vite.config.ts | 25 +++++++ 13 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 features/AUTH-V2.md create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/pages/ProfilePage.tsx diff --git a/backend/data/recipes.db-shm b/backend/data/recipes.db-shm index 60343033011f596e14cdef2f5f480e5507ae677a..626b8f9dcdd919e895637a2ecab0a93a9ad3dd6a 100644 GIT binary patch delta 661 zcmb7=-AmI^7{`B~v)tCnx7vajbQ9~MG7yEj=_U{(iXbTHu0{|+QD8_>phN!w;RIcF z5kzzm$^l)o_xG}wWz%Y@=_*@GZJI4B)6Us`LbpEfy*Que`+U#C6OD>!R5Y(jZ*&$4 z$Egeo$FU^R#`(R0nG*=1%SH&0WU&yw(^Z%90Jx)+`w4}VM+E=P_ zYymaGj@9c;dxKGxk=>@usNAT+D4;q-?MmBSWT&Jsqa(h)&He$tfd~lmT7yn*-3N~p z^=BB=)UQML)+O-|kwb`}Yf5aZ@yhEY_(&eVP!V5N=XXgVZ}5^&{A6j7P471BCcTU@ zgDkSCJsBx)@ruvwACK;JY1$F1x@t$W=Y_@7Psxiuj`{lQ zC8_E`W`n>gpt=8%07MjM?IgyHO}E%KKjAoLzWD>kCZ^3-93vPvKjK)%xcQRH6-J<# z7USj%?nZ2rKSh1n{LPPx3C#Es2xWWt7$K||$xznhXK7|Yp?Qp( zcV+m1BsVcm-jS^WWUw(#ev|`ZY+#(cEe|5H`9c0a#>Jna7&qT5f*Ek9R0^nQ7vtm& f*T~?Adkt|D&6*;mbEL$yYlvs9L%d#uk4TO7{ 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 U#uZ+AZlUF2+}EWoL|n@KZz0`5@Bjb+ delta 64 zcmV~$NfCko006+@mP=4MH6sD|@mM+##7HC{0lV9|2gkK+0-;DOk;**e3Z+WzsnL3Q P`{?urqsi?1|4r)+%A6F5 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',