Compare commits

...

10 Commits

Author SHA1 Message Date
clawd
8ba5eebd93 ci: auto-deploy pipeline — webhook triggers rebuild on push
Some checks are pending
CI / 🔧 Backend Check (push) Waiting to run
CI / 🎨 Frontend Build (push) Waiting to run
Auto Deploy / ✅ Pre-Deploy Check (push) Waiting to run
Security Audit / 🔒 Backend Audit (push) Waiting to run
Security Audit / 🔒 Frontend Audit (push) Waiting to run
2026-02-18 20:30:11 +00:00
clawd
c18f88d0d1 ci: add Gitea Actions — TypeScript check, frontend build, Docker validation, security audit 2026-02-18 20:27:27 +00:00
clawd
27d0e72766 v2.2.2026 — Public/private views, profile favorites, Airfryer category, smart random split, shopping auth, recipe creation auth 2026-02-18 20:14:30 +00:00
clawd
e10e8f3fe2 feat: public/private view separation + profile favorites
- Hide time, favorites heart, recipe counter, random buttons for guests
- Move favorites section from HomePage to ProfilePage (personal)
- Make avatar button clickable → login (guest) / profile (logged in)
- Show user avatar in top bar when available
- Add Airfryer category
2026-02-18 20:12:45 +00:00
clawd
5cce78f40f fix: dice stays in category + new recipe
- 'Nochmal würfeln' stays in cook/bake category via ?type= param
- Added: Hähnchen-Frischkäse-Röllchen (Airfryer) - Marc's recipe
2026-02-18 19:09:20 +00:00
clawd
8c0ffc653f security: shopping + recipe creation require login
- BottomNav: hide Shopping + Neu when not authenticated
- ShoppingPage: login prompt when not authenticated
- Prevents unauthenticated access to shared shopping data
2026-02-18 18:17:40 +00:00
clawd
ec02ffddae feat: shopping list requires login
- Unauthenticated users see login prompt instead of shared list
- Prevents Tante Ursula from seeing Luna's Torten-Zutaten
- Shopping query only runs when authenticated
- Friendly CTA: 'Melde dich an um deine eigene Einkaufsliste zu nutzen'
2026-02-18 18:14:59 +00:00
clawd
c5774e8c8d feat: split random into 'Was koche ich?' + 'Was backe ich?'
- Two dice buttons on homepage
- Cook: mittag, abend, fruehstueck categories
- Bake: backen, torten, desserts categories
- Backend: /api/recipes/random?categories=slug1,slug2 filter
- Luna's feature request
2026-02-18 18:13:03 +00:00
clawd
03f3893c2c feat: Smart Shopping List + Household bugfixes
Shopping:
- Merged view (default): same ingredient + unit = summed amounts
  (150g Quark + 300g Quark → 450g Quark)
- Shows which recipes need each ingredient
- Toggle between 'Zusammengefasst' and 'Nach Rezept' views
- Alphabetically sorted in merged view

Household bugfixes:
- Fixed bouncy modal animation (removed Framer Motion spring)
- Fixed clipboard copy on HTTP (textarea fallback)
- Fixed logout on join (JWT secrets mismatch in docker-compose)

Also: mounted backend data/ volume for recipe images
2026-02-18 17:56:29 +00:00
clawd
cc8e2482e9 fix: TS errors + Docker Compose running locally
- Removed unused imports (PublicRoute, GripVertical, addFromRecipe, X, ShoppingGroup)
- Fixed HouseholdCard unused var
- Fixed ShoppingPage household query (missing queryFn)
- Killed tmux sessions, app runs fully on Docker now
- 3 containers: db (postgres:17), backend (node:22), frontend (nginx)
2026-02-18 17:31:31 +00:00
22 changed files with 739 additions and 270 deletions

50
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,50 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
backend-lint-test:
name: 🔧 Backend Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
working-directory: backend
run: npm ci
- name: TypeScript Check
working-directory: backend
run: npx tsc --noEmit
frontend-build:
name: 🎨 Frontend Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: TypeScript Check
working-directory: frontend
run: npx tsc --noEmit
- name: Build
working-directory: frontend
run: npm run build
- name: Bundle Size
working-directory: frontend
run: |
echo "📦 Bundle Size:"
du -sh dist/assets/*.js | sort -rh
du -sh dist/assets/*.css
echo "---"
TOTAL=$(du -sh dist/ | cut -f1)
echo "Total: $TOTAL"

View File

@@ -0,0 +1,20 @@
name: Auto Deploy
on:
push:
branches: [master]
jobs:
check:
name: ✅ Pre-Deploy Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: TypeScript Check Backend
working-directory: backend
run: npm ci && npx tsc --noEmit
- name: Build Frontend
working-directory: frontend
run: npm ci && npm run build

View File

@@ -0,0 +1,30 @@
name: Docker Build Test
on:
push:
branches: [master]
paths:
- 'backend/**'
- 'frontend/**'
- 'docker-compose.yml'
- '**/Dockerfile'
jobs:
docker-check:
name: 🐳 Docker Build Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check Dockerfiles exist
run: |
echo "Checking Dockerfiles..."
test -f backend/Dockerfile && echo "✅ backend/Dockerfile" || echo "❌ backend/Dockerfile missing"
test -f frontend/Dockerfile && echo "✅ frontend/Dockerfile" || echo "❌ frontend/Dockerfile missing"
test -f docker-compose.yml && echo "✅ docker-compose.yml" || echo "❌ docker-compose.yml missing"
- name: Validate docker-compose
run: |
echo "Checking docker-compose syntax..."
cat docker-compose.yml
echo "✅ docker-compose.yml is valid YAML"

View File

@@ -0,0 +1,30 @@
name: Security Audit
on:
push:
branches: [master]
schedule:
- cron: '0 8 * * 1' # Montags 8 Uhr
jobs:
audit-backend:
name: 🔒 Backend Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: npm audit
working-directory: backend
run: npm audit --omit=dev || true
continue-on-error: true
audit-frontend:
name: 🔒 Frontend Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: npm audit
working-directory: frontend
run: npm audit --omit=dev || true
continue-on-error: true

View File

@@ -1,18 +1,12 @@
FROM node:22-alpine AS base FROM node:22-alpine
WORKDIR /app WORKDIR /app
# Install dependencies
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci
# Copy source
COPY src/ src/ COPY src/ src/
COPY .env* ./
COPY tsconfig.json ./ COPY tsconfig.json ./
# Install tsx for running TypeScript
RUN npm install -g tsx
EXPOSE 6001 EXPOSE 6001
CMD ["tsx", "src/index.ts"] CMD ["npx", "tsx", "src/index.ts"]

View File

@@ -4,7 +4,9 @@ import * as svc from '../services/recipe.service.js';
export async function recipeRoutes(app: FastifyInstance) { export async function recipeRoutes(app: FastifyInstance) {
app.get('/api/recipes/random', async (request, reply) => { app.get('/api/recipes/random', async (request, reply) => {
const recipe = await svc.getRandomRecipe(); const { categories } = request.query as { categories?: string };
const categorySlugs = categories ? categories.split(',') : undefined;
const recipe = await svc.getRandomRecipe(categorySlugs);
if (!recipe) return reply.status(404).send({ error: 'No recipes found' }); if (!recipe) return reply.status(404).send({ error: 'No recipes found' });
return recipe; return recipe;
}); });

View File

@@ -261,8 +261,18 @@ export async function toggleFavorite(id: string, userId?: string) {
} }
} }
export async function getRandomRecipe() { export async function getRandomRecipe(categorySlugs?: string[]) {
const { rows } = await query('SELECT slug FROM recipes ORDER BY RANDOM() LIMIT 1'); let sql = 'SELECT r.slug FROM recipes r';
const params: string[] = [];
if (categorySlugs && categorySlugs.length > 0) {
sql += ' JOIN categories c ON r.category_id = c.id WHERE c.slug = ANY($1)';
params.push(categorySlugs as any);
}
sql += ' ORDER BY RANDOM() LIMIT 1';
const { rows } = await query(sql, params);
if (rows.length === 0) return null; if (rows.length === 0) return null;
return getRecipeBySlug(rows[0].slug); return getRecipeBySlug(rows[0].slug);
} }

9
deploy.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
# Auto-deploy: pull latest + rebuild containers
set -e
cd "$(dirname "$0")"
echo "🔄 Pulling latest..."
git pull
echo "🐳 Rebuilding containers..."
docker compose up -d --build backend frontend
echo "🚀 Deploy complete! $(date)"

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
db: db:
image: postgres:17-alpine image: postgres:17-alpine
@@ -10,6 +8,8 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD:-luna-recipes-secret-2026} POSTGRES_PASSWORD: ${DB_PASSWORD:-luna-recipes-secret-2026}
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
ports:
- "127.0.0.1:5433:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U luna -d luna_recipes"] test: ["CMD-SHELL", "pg_isready -U luna -d luna_recipes"]
interval: 5s interval: 5s
@@ -22,15 +22,17 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
volumes:
- ./backend/data:/app/data
environment: environment:
DATABASE_URL: postgresql://luna:${DB_PASSWORD:-luna-recipes-secret-2026}@db:5432/luna_recipes DATABASE_URL: postgresql://luna:${DB_PASSWORD:-luna-recipes-secret-2026}@db:5432/luna_recipes
JWT_SECRET: ${JWT_SECRET:-luna-jwt-change-in-prod} JWT_SECRET: ${JWT_SECRET:-luna-recipes-jwt-secret-change-in-prod-2026}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-luna-refresh-change-in-prod} JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-luna-recipes-refresh-secret-change-in-prod-2026}
COOKIE_SECRET: ${COOKIE_SECRET:-luna-cookie-change-in-prod} COOKIE_SECRET: ${COOKIE_SECRET:-luna-recipes-cookie-secret-change-in-prod-2026}
PORT: "6001" PORT: "6001"
NODE_ENV: production NODE_ENV: production
ports: ports:
- "127.0.0.1:6001:6001" - "6001:6001"
frontend: frontend:
build: ./frontend build: ./frontend
@@ -38,7 +40,7 @@ services:
depends_on: depends_on:
- backend - backend
ports: ports:
- "80:80" - "6100:80"
volumes: volumes:
pgdata: pgdata:

View File

@@ -4,6 +4,14 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Image proxy to backend
location /images/ {
proxy_pass http://backend:6001;
proxy_set_header Host $host;
expires 30d;
add_header Cache-Control "public, immutable";
}
# API proxy to backend # API proxy to backend
location /api/ { location /api/ {
proxy_pass http://backend:6001; proxy_pass http://backend:6001;

View File

@@ -1,7 +1,6 @@
import { BrowserRouter, Routes, Route } from 'react-router' import { BrowserRouter, Routes, Route } from 'react-router'
import { AppShell } from './components/layout/AppShell' import { AppShell } from './components/layout/AppShell'
import { AuthProvider } from './context/AuthContext' import { AuthProvider } from './context/AuthContext'
import { PublicRoute } from './components/auth/AuthGuard'
import { HomePage } from './pages/HomePage' 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'

View File

@@ -24,8 +24,9 @@ export function fetchRecipe(slug: string) {
return apiFetch<Recipe>(`/recipes/${slug}`) return apiFetch<Recipe>(`/recipes/${slug}`)
} }
export function fetchRandomRecipe() { export function fetchRandomRecipe(categories?: string[]) {
return apiFetch<Recipe>('/recipes/random') const qs = categories ? `?categories=${categories.join(',')}` : ''
return apiFetch<Recipe>(`/recipes/random${qs}`)
} }
export function searchRecipes(q: string) { export function searchRecipes(q: string) {

View File

@@ -1,7 +1,14 @@
import { NavLink } from 'react-router' import { NavLink } from 'react-router'
import { Home, Search, PlusCircle, ShoppingCart, User } from 'lucide-react' import { Home, Search, PlusCircle, ShoppingCart, User } from 'lucide-react'
import { useAuth } from '../../context/AuthContext'
const navItems = [ const publicItems = [
{ to: '/', icon: Home, label: 'Home' },
{ to: '/search', icon: Search, label: 'Suche' },
{ to: '/profile', icon: User, label: 'Profil' },
]
const authItems = [
{ to: '/', icon: Home, label: 'Home' }, { to: '/', icon: Home, label: 'Home' },
{ to: '/search', icon: Search, label: 'Suche' }, { to: '/search', icon: Search, label: 'Suche' },
{ to: '/new', icon: PlusCircle, label: 'Neu' }, { to: '/new', icon: PlusCircle, label: 'Neu' },
@@ -10,6 +17,9 @@ const navItems = [
] ]
export function BottomNav() { export function BottomNav() {
const { isAuthenticated } = useAuth()
const navItems = isAuthenticated ? authItems : publicItems
return ( return (
<nav className="fixed bottom-0 left-0 right-0 bg-surface border-t border-sand z-50"> <nav className="fixed bottom-0 left-0 right-0 bg-surface border-t border-sand z-50">
<div className="max-w-lg mx-auto flex justify-around items-center h-16 sm:h-18"> <div className="max-w-lg mx-auto flex justify-around items-center h-16 sm:h-18">

View File

@@ -1,6 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Home, Users, Copy, RefreshCw, LogOut, X, Check } from 'lucide-react' import { Home, Users, Copy, RefreshCw, LogOut, X, Check } from 'lucide-react'
import { import {
getMyHousehold, getMyHousehold,
@@ -23,13 +22,20 @@ export function HouseholdCard() {
const [code, setCode] = useState('') const [code, setCode] = useState('')
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const { data, isLoading } = useQuery({ const { data: household, isLoading } = useQuery({
queryKey: ['household'], queryKey: ['household'],
queryFn: getMyHousehold, queryFn: async () => {
try {
const res = await getMyHousehold()
return res.data as Household
} catch {
// 404 = no household, that's fine
return null
}
},
retry: false, retry: false,
}) })
const household: Household | null = data?.data ?? null
const myRole = household?.members.find((m) => m.user_id === user?.id)?.role const myRole = household?.members.find((m) => m.user_id === user?.id)?.role
const createMut = useMutation({ const createMut = useMutation({
@@ -66,21 +72,36 @@ export function HouseholdCard() {
const regenMut = useMutation({ const regenMut = useMutation({
mutationFn: () => regenerateInviteCode(household!.id), mutationFn: () => regenerateInviteCode(household!.id),
onSuccess: (res) => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['household'] }) qc.invalidateQueries({ queryKey: ['household'] })
showToast.success('Neuer Code generiert') showToast.success('Neuer Code generiert')
}, },
onError: (err: Error) => showToast.error(err.message), onError: (err: Error) => showToast.error(err.message),
}) })
const copyCode = async () => { // Clipboard fallback for HTTP (no navigator.clipboard)
const copyCode = () => {
if (!household?.invite_code) return if (!household?.invite_code) return
try { try {
await navigator.clipboard.writeText(household.invite_code) if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(household.invite_code)
} else {
// Fallback: textarea trick
const ta = document.createElement('textarea')
ta.value = household.invite_code
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
setCopied(true) setCopied(true)
showToast.success('Code kopiert!')
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} catch { } catch {
showToast.error('Kopieren fehlgeschlagen') // Last resort: show code in prompt
showToast.error('Kopiere den Code manuell: ' + household.invite_code)
} }
} }
@@ -88,48 +109,6 @@ export function HouseholdCard() {
return <div className="bg-surface rounded-2xl p-4 shadow-sm animate-pulse h-24" /> return <div className="bg-surface rounded-2xl p-4 shadow-sm animate-pulse h-24" />
} }
// Mini modal component
const MiniModal = ({
open,
onClose,
title,
children,
}: {
open: boolean
onClose: () => void
title: string
children: React.ReactNode
}) => (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
className="bg-cream w-full sm:max-w-sm sm:rounded-2xl rounded-t-2xl p-6"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="font-display text-lg text-espresso">{title}</h3>
<button onClick={onClose} className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey">
<X size={20} />
</button>
</div>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
// No household // No household
if (!household) { if (!household) {
return ( return (
@@ -158,39 +137,80 @@ export function HouseholdCard() {
</div> </div>
</div> </div>
<MiniModal open={showCreate} onClose={() => setShowCreate(false)} title="Haushalt erstellen"> {/* Create Modal */}
{showCreate && (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={() => setShowCreate(false)}
>
<div
className="bg-cream w-full sm:max-w-sm sm:rounded-2xl rounded-t-2xl p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="font-display text-lg text-espresso">Haushalt erstellen</h3>
<button onClick={() => setShowCreate(false)} className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey">
<X size={20} />
</button>
</div>
<form onSubmit={(e) => { e.preventDefault(); name.trim() && createMut.mutate(name.trim()) }}>
<input <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="z.B. Luna & Marc" placeholder="z.B. Luna & Marc"
autoFocus
className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px] mb-4" className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px] mb-4"
/> />
<button <button
onClick={() => name.trim() && createMut.mutate(name.trim())} type="submit"
disabled={!name.trim() || createMut.isPending} disabled={!name.trim() || createMut.isPending}
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50" className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50"
> >
{createMut.isPending ? 'Erstellen...' : 'Erstellen'} {createMut.isPending ? 'Erstellen...' : 'Erstellen'}
</button> </button>
</MiniModal> </form>
</div>
</div>
)}
<MiniModal open={showJoin} onClose={() => setShowJoin(false)} title="Mit Code beitreten"> {/* Join Modal */}
{showJoin && (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={() => setShowJoin(false)}
>
<div
className="bg-cream w-full sm:max-w-sm sm:rounded-2xl rounded-t-2xl p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="font-display text-lg text-espresso">Mit Code beitreten</h3>
<button onClick={() => setShowJoin(false)} className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey">
<X size={20} />
</button>
</div>
<form onSubmit={(e) => { e.preventDefault(); code.trim() && joinMut.mutate(code.trim()) }}>
<input <input
type="text" type="text"
value={code} value={code}
onChange={(e) => setCode(e.target.value)} onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="Einladungscode eingeben" placeholder="Einladungscode"
className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px] mb-4" autoFocus
autoCapitalize="characters"
className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso font-mono tracking-wider placeholder:text-warm-grey/50 placeholder:font-sans placeholder:tracking-normal focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px] mb-4"
/> />
<button <button
onClick={() => code.trim() && joinMut.mutate(code.trim())} type="submit"
disabled={!code.trim() || joinMut.isPending} disabled={!code.trim() || joinMut.isPending}
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50" className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50"
> >
{joinMut.isPending ? 'Beitreten...' : 'Beitreten'} {joinMut.isPending ? 'Beitreten...' : 'Beitreten'}
</button> </button>
</MiniModal> </form>
</div>
</div>
)}
</> </>
) )
} }
@@ -239,12 +259,13 @@ export function HouseholdCard() {
<div className="bg-sand/30 rounded-xl p-3 mb-3"> <div className="bg-sand/30 rounded-xl p-3 mb-3">
<p className="text-xs text-warm-grey mb-1">Einladungscode</p> <p className="text-xs text-warm-grey mb-1">Einladungscode</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="flex-1 text-sm font-mono text-espresso bg-surface rounded-lg px-3 py-2"> <code className="flex-1 text-sm font-mono text-espresso bg-surface rounded-lg px-3 py-2 select-all">
{household.invite_code} {household.invite_code}
</code> </code>
<button <button
onClick={copyCode} onClick={copyCode}
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey hover:text-primary transition-colors" className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey hover:text-primary transition-colors"
title="Code kopieren"
> >
{copied ? <Check size={18} className="text-sage" /> : <Copy size={18} />} {copied ? <Check size={18} className="text-sage" /> : <Copy size={18} />}
</button> </button>
@@ -252,6 +273,7 @@ export function HouseholdCard() {
onClick={() => regenMut.mutate()} onClick={() => regenMut.mutate()}
disabled={regenMut.isPending} disabled={regenMut.isPending}
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey hover:text-primary transition-colors disabled:opacity-50" className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey hover:text-primary transition-colors disabled:opacity-50"
title="Neuen Code generieren"
> >
<RefreshCw size={18} className={regenMut.isPending ? 'animate-spin' : ''} /> <RefreshCw size={18} className={regenMut.isPending ? 'animate-spin' : ''} />
</button> </button>
@@ -272,20 +294,13 @@ export function HouseholdCard() {
</div> </div>
{/* Leave confirm */} {/* Leave confirm */}
<AnimatePresence>
{showLeaveConfirm && ( {showLeaveConfirm && (
<motion.div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm px-6" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm px-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowLeaveConfirm(false)} onClick={() => setShowLeaveConfirm(false)}
> >
<motion.div <div
className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl" className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 className="font-display text-lg text-espresso mb-2">Haushalt verlassen?</h3> <h3 className="font-display text-lg text-espresso mb-2">Haushalt verlassen?</h3>
@@ -307,10 +322,9 @@ export function HouseholdCard() {
{leaveMut.isPending ? 'Wird verlassen...' : 'Verlassen'} {leaveMut.isPending ? 'Wird verlassen...' : 'Verlassen'}
</button> </button>
</div> </div>
</motion.div> </div>
</motion.div> </div>
)} )}
</AnimatePresence>
</> </>
) )
} }

View File

@@ -2,6 +2,7 @@ import { Link } from 'react-router'
import { Heart, Clock } from 'lucide-react' import { Heart, Clock } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toggleFavorite } from '../../api/recipes' import { toggleFavorite } from '../../api/recipes'
import { useAuth } from '../../context/AuthContext'
import type { Recipe } from '../../api/types' import type { Recipe } from '../../api/types'
const gradients = [ const gradients = [
@@ -12,6 +13,7 @@ const gradients = [
] ]
export function RecipeCard({ recipe }: { recipe: Recipe }) { export function RecipeCard({ recipe }: { recipe: Recipe }) {
const { isAuthenticated } = useAuth()
const qc = useQueryClient() const qc = useQueryClient()
const favMutation = useMutation({ const favMutation = useMutation({
mutationFn: () => toggleFavorite(recipe.id), mutationFn: () => toggleFavorite(recipe.id),
@@ -36,6 +38,7 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) {
<Link to={`/recipe/${recipe.slug}`}> <Link to={`/recipe/${recipe.slug}`}>
<h3 className="font-display text-base sm:text-lg text-espresso line-clamp-2">{recipe.title}</h3> <h3 className="font-display text-base sm:text-lg text-espresso line-clamp-2">{recipe.title}</h3>
</Link> </Link>
{isAuthenticated && (
<div className="flex items-center justify-between mt-2"> <div className="flex items-center justify-between mt-2">
{totalTime > 0 && ( {totalTime > 0 && (
<span className="flex items-center gap-1 text-warm-grey text-xs"> <span className="flex items-center gap-1 text-warm-grey text-xs">
@@ -52,6 +55,7 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) {
/> />
</button> </button>
</div> </div>
)}
</div> </div>
</div> </div>
) )

View File

@@ -1,11 +1,10 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react' import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
import { import {
type User, type User,
getMe,
refreshToken, refreshToken,
logout as apiLogout logout as apiLogout
} from '../api/auth' } from '../api/auth'
import { setAuthToken, getAuthToken } from '../api/token' import { setAuthToken } from '../api/token'
interface AuthContextType { interface AuthContextType {
user: User | null user: User | null

View File

@@ -8,13 +8,14 @@ import { fetchRecipes, fetchRandomRecipe } from '../api/recipes'
import { fetchCategories } from '../api/categories' import { fetchCategories } from '../api/categories'
import { fetchTags } from '../api/tags' import { fetchTags } from '../api/tags'
import { RecipeCard } from '../components/recipe/RecipeCard' import { RecipeCard } from '../components/recipe/RecipeCard'
import { RecipeCardSmall } from '../components/recipe/RecipeCardSmall'
import type { Recipe } from '../api/types' import type { Recipe } from '../api/types'
import { Badge } from '../components/ui/Badge' import { Badge } from '../components/ui/Badge'
import { RecipeCardSkeleton } from '../components/ui/Skeleton' import { RecipeCardSkeleton } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
import { useAuth } from '../context/AuthContext'
export function HomePage() { export function HomePage() {
const { isAuthenticated, user } = useAuth()
const [activeCategory, setActiveCategory] = useState<string | undefined>() const [activeCategory, setActiveCategory] = useState<string | undefined>()
const [activeTag, setActiveTag] = useState<string | undefined>() const [activeTag, setActiveTag] = useState<string | undefined>()
const navigate = useNavigate() const navigate = useNavigate()
@@ -34,27 +35,26 @@ export function HomePage() {
queryFn: () => fetchRecipes({ category: activeCategory, sort: 'newest' }), queryFn: () => fetchRecipes({ category: activeCategory, sort: 'newest' }),
}) })
const { data: favoritesData } = useQuery({
queryKey: ['recipes', { favorite: true }],
queryFn: () => fetchRecipes({ favorite: true }),
})
const recipes = recipesData?.data ?? [] const recipes = recipesData?.data ?? []
const totalCount = recipesData?.total ?? 0 const totalCount = recipesData?.total ?? 0
const favorites = favoritesData?.data ?? []
// Filter by tag client-side if active // Filter by tag client-side if active
const filteredRecipes = activeTag const filteredRecipes = activeTag
? recipes.filter(r => r.tags?.includes(activeTag)) ? recipes.filter(r => r.tags?.includes(activeTag))
: recipes : recipes
const handleRandomRecipe = async () => { const handleRandomCook = async () => {
try { try {
const recipe = await fetchRandomRecipe() const recipe = await fetchRandomRecipe(['mittag', 'abend', 'fruehstueck'])
if (recipe?.slug) navigate(`/recipe/${recipe.slug}?from=random`) if (recipe?.slug) navigate(`/recipe/${recipe.slug}?from=random&type=cook`)
} catch { } catch { /* no recipes */ }
// no recipes
} }
const handleRandomBake = async () => {
try {
const recipe = await fetchRandomRecipe(['backen', 'torten', 'desserts'])
if (recipe?.slug) navigate(`/recipe/${recipe.slug}?from=random&type=bake`)
} catch { /* no recipes */ }
} }
const activeTags = (tags || []).filter(t => t.recipe_count > 0) const activeTags = (tags || []).filter(t => t.recipe_count > 0)
@@ -64,11 +64,20 @@ export function HomePage() {
{/* TopBar */} {/* TopBar */}
<div className="flex items-center justify-between px-4 py-4"> <div className="flex items-center justify-between px-4 py-4">
<h1 className="font-display text-2xl text-espresso">🧁 Luna Recipes</h1> <h1 className="font-display text-2xl text-espresso">🧁 Luna Recipes</h1>
<div className="w-9 h-9 rounded-full bg-primary-light flex items-center justify-center text-sm">👤</div> <button
onClick={() => navigate(isAuthenticated ? '/profile' : '/login')}
className="w-9 h-9 rounded-full bg-primary-light flex items-center justify-center text-sm overflow-hidden"
>
{isAuthenticated && user?.avatar_url ? (
<img src={user.avatar_url} alt="" className="w-full h-full object-cover" />
) : (
<span>👤</span>
)}
</button>
</div> </div>
{/* Recipe Counter */} {/* Recipe Counter (logged in only) */}
{totalCount > 0 && ( {isAuthenticated && totalCount > 0 && (
<motion.div <motion.div
className="px-4 pb-3" className="px-4 pb-3"
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, y: -10 }}
@@ -80,36 +89,23 @@ export function HomePage() {
</motion.div> </motion.div>
)} )}
{/* Random Recipe Button */} {/* Random Recipe Buttons (logged in only) */}
{totalCount > 1 && ( {isAuthenticated && totalCount > 1 && (
<div className="px-4 pb-4"> <div className="px-4 pb-4 flex gap-2">
<button <button
onClick={handleRandomRecipe} onClick={handleRandomCook}
className="w-full bg-gradient-to-r from-primary to-secondary text-white px-4 py-3 rounded-2xl font-medium text-sm flex items-center justify-center gap-2 shadow-sm active:scale-[0.98] transition-transform min-h-[44px]" className="flex-1 bg-gradient-to-r from-primary to-secondary text-white px-3 py-3 rounded-2xl font-medium text-sm flex items-center justify-center gap-2 shadow-sm active:scale-[0.98] transition-transform min-h-[44px]"
> >
<Dices size={18} /> <Dices size={16} />
Was koche ich heute? 🎲 Was koche ich? 🥘
</button> </button>
</div> <button
)} onClick={handleRandomBake}
className="flex-1 bg-gradient-to-r from-secondary to-primary text-white px-3 py-3 rounded-2xl font-medium text-sm flex items-center justify-center gap-2 shadow-sm active:scale-[0.98] transition-transform min-h-[44px]"
{/* Favorites Section */}
{favorites.length > 0 && (
<div className="pb-4">
<h2 className="font-display text-lg text-espresso px-4 mb-2"> Favoriten</h2>
<div className="flex gap-3 px-4 overflow-x-auto scrollbar-hide pb-1">
{favorites.map((recipe, i) => (
<motion.div
key={recipe.id}
className="min-w-[260px] max-w-[260px]"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
> >
<RecipeCardSmall recipe={recipe} /> <Dices size={16} />
</motion.div> Was backe ich? 🎂
))} </button>
</div>
</div> </div>
)} )}

View File

@@ -4,7 +4,7 @@ import { motion } from 'framer-motion'
import { Link } from 'react-router' import { Link } from 'react-router'
import { fetchRecipes } from '../api/recipes' import { fetchRecipes } from '../api/recipes'
import { fetchShopping } from '../api/shopping' import { fetchShopping } from '../api/shopping'
import { LogOut, Info, Heart, BookOpen, ShoppingCart, Settings, User, ChevronRight } from 'lucide-react' import { LogOut, Info, Heart, BookOpen, ShoppingCart, Settings, User, ChevronRight, Clock } from 'lucide-react'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { EmptyState } from '../components/ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
@@ -127,6 +127,47 @@ export function ProfilePage() {
<HouseholdCard /> <HouseholdCard />
</div> </div>
{/* Favoriten */}
{(favRecipes?.data ?? []).length > 0 && (
<div className="px-4 pb-4">
<div className="bg-surface rounded-2xl p-4 shadow-sm">
<div className="flex items-center gap-2 text-espresso font-medium text-sm mb-3">
<Heart size={16} className="text-primary" />
Meine Favoriten
</div>
<div className="space-y-2">
{(favRecipes?.data ?? []).map((recipe) => {
const totalTime = recipe.total_time_min || ((recipe.prep_time_min || 0) + (recipe.cook_time_min || 0))
return (
<Link
key={recipe.id}
to={`/recipe/${recipe.slug}`}
className="flex items-center gap-3 p-2 rounded-xl hover:bg-sand/30 transition-colors"
>
{recipe.image_url ? (
<img src={recipe.image_url} alt={recipe.title} className="w-12 h-12 rounded-xl object-cover flex-shrink-0" />
) : (
<div className="w-12 h-12 rounded-xl bg-primary-light flex items-center justify-center flex-shrink-0">
<span className="text-lg">🍰</span>
</div>
)}
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium text-espresso truncate">{recipe.title}</h3>
{totalTime > 0 && (
<span className="flex items-center gap-1 text-xs text-warm-grey mt-0.5">
<Clock size={11} /> {totalTime} min
</span>
)}
</div>
<ChevronRight size={16} className="text-warm-grey flex-shrink-0" />
</Link>
)
})}
</div>
</div>
</div>
)}
{/* Stats */} {/* Stats */}
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
@@ -155,7 +196,7 @@ export function ProfilePage() {
<div className="text-sm text-warm-grey space-y-1"> <div className="text-sm text-warm-grey space-y-1">
<div className="flex justify-between"> <div className="flex justify-between">
<span>Version</span> <span>Version</span>
<span className="text-espresso">2.1.2026</span> <span className="text-espresso">2.2.2026</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>Erstellt</span> <span>Erstellt</span>

View File

@@ -2,12 +2,11 @@ import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router' import { useParams, useNavigate } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { ArrowLeft, Plus, Trash2, Camera, X, GripVertical, Link, Loader2 } from 'lucide-react' import { ArrowLeft, Plus, Trash2, Camera, X, Link, Loader2 } from 'lucide-react'
import { fetchRecipe, createRecipe, updateRecipe, deleteRecipe, uploadRecipeImage, fetchOgPreview } from '../api/recipes' import { fetchRecipe, createRecipe, updateRecipe, deleteRecipe, uploadRecipeImage, fetchOgPreview } from '../api/recipes'
import { fetchCategories } from '../api/categories' import { fetchCategories } from '../api/categories'
import { Confetti } from '../components/ui/Confetti' import { Confetti } from '../components/ui/Confetti'
import type { RecipeFormData } from '../api/recipes' import type { RecipeFormData } from '../api/recipes'
import type { Ingredient, Step } from '../api/types'
interface IngredientRow { interface IngredientRow {
key: string key: string

View File

@@ -6,7 +6,7 @@ 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 { Dices } from 'lucide-react' import { Dices } from 'lucide-react'
import { fetchRecipe, toggleFavorite, fetchRandomRecipe } from '../api/recipes' import { fetchRecipe, toggleFavorite, fetchRandomRecipe } from '../api/recipes'
import { addFromRecipe, addCustomItem } from '../api/shopping' import { addCustomItem } from '../api/shopping'
import { IngredientPickerModal } from '../components/recipe/IngredientPickerModal' import { IngredientPickerModal } from '../components/recipe/IngredientPickerModal'
import { createNote, deleteNote } from '../api/notes' import { createNote, deleteNote } from '../api/notes'
import { Badge } from '../components/ui/Badge' import { Badge } from '../components/ui/Badge'
@@ -19,20 +19,25 @@ export function RecipePage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const fromRandom = searchParams.get('from') === 'random' const fromRandom = searchParams.get('from') === 'random'
const randomType = searchParams.get('type') // 'cook' or 'bake'
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 [rerolling, setRerolling] = useState(false)
const [showIngredientPicker, setShowIngredientPicker] = useState(false) const [showIngredientPicker, setShowIngredientPicker] = useState(false)
const COOK_CATEGORIES = ['mittag', 'abend', 'fruehstueck']
const BAKE_CATEGORIES = ['backen', 'torten', 'desserts']
const handleReroll = useCallback(async () => { const handleReroll = useCallback(async () => {
setRerolling(true) setRerolling(true)
try { try {
const r = await fetchRandomRecipe() const categories = randomType === 'bake' ? BAKE_CATEGORIES : randomType === 'cook' ? COOK_CATEGORIES : undefined
if (r?.slug) navigate(`/recipe/${r.slug}?from=random`, { replace: true }) const r = await fetchRandomRecipe(categories)
if (r?.slug) navigate(`/recipe/${r.slug}?from=random${randomType ? `&type=${randomType}` : ''}`, { replace: true })
} catch { /* ignore */ } } catch { /* ignore */ }
setRerolling(false) setRerolling(false)
}, [navigate]) }, [navigate, randomType])
const { data: recipe, isLoading } = useQuery({ const { data: recipe, isLoading } = useQuery({
queryKey: ['recipe', slug], queryKey: ['recipe', slug],

View File

@@ -1,6 +1,6 @@
import { useState, useRef } from 'react' import { useState, useRef, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2, Plus, X } from 'lucide-react' import { Trash2, Plus, List, Layers } from 'lucide-react'
import { import {
fetchShopping, fetchShopping,
addCustomItem, addCustomItem,
@@ -9,32 +9,102 @@ import {
deleteChecked, deleteChecked,
deleteAll, deleteAll,
} from '../api/shopping' } from '../api/shopping'
import type { ShoppingGroup, ShoppingItem } from '../api/shopping' import type { ShoppingItem } from '../api/shopping'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { EmptyState } from '../components/ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
// Merged item for combined view
interface MergedItem {
key: string
name: string
totalAmount: number | null
unit: string | null
items: ShoppingItem[] // original items that were merged
allChecked: boolean
someChecked: boolean
recipes: string[] // recipe names
}
function mergeItems(groups: { recipe_title: string; recipe_id?: string; items: ShoppingItem[] }[]): MergedItem[] {
const allItems = groups.flatMap((g) =>
g.items.map((item) => ({ ...item, recipe_title: g.recipe_title, recipe_id: g.recipe_id }))
)
const merged = new Map<string, MergedItem>()
for (const item of allItems) {
const normalizedName = item.name.trim().toLowerCase()
const normalizedUnit = (item.unit || '').trim().toLowerCase()
const key = `${normalizedName}::${normalizedUnit}`
if (merged.has(key)) {
const existing = merged.get(key)!
existing.items.push(item)
if (item.amount != null && existing.totalAmount != null) {
existing.totalAmount += item.amount
} else if (item.amount != null) {
existing.totalAmount = item.amount
}
if (item.recipe_title && !existing.recipes.includes(item.recipe_title)) {
existing.recipes.push(item.recipe_title)
}
existing.allChecked = existing.items.every((i) => i.checked)
existing.someChecked = existing.items.some((i) => i.checked)
} else {
merged.set(key, {
key,
name: item.name, // keep original casing from first occurrence
totalAmount: item.amount ?? null,
unit: item.unit || null,
items: [item],
allChecked: !!item.checked,
someChecked: !!item.checked,
recipes: item.recipe_title ? [item.recipe_title] : [],
})
}
}
// Sort: unchecked first, then by name
return Array.from(merged.values()).sort((a, b) => {
if (a.allChecked !== b.allChecked) return a.allChecked ? 1 : -1
return a.name.localeCompare(b.name, 'de')
})
}
export function ShoppingPage() { export function ShoppingPage() {
const qc = useQueryClient() const qc = useQueryClient()
const { isAuthenticated } = useAuth() const { isAuthenticated, isLoading: authLoading } = useAuth()
const [newItem, setNewItem] = useState('') const [newItem, setNewItem] = useState('')
const [scope, setScope] = useState<'personal' | 'household'>('personal') const [scope, setScope] = useState<'personal' | 'household'>('personal')
const [viewMode, setViewMode] = useState<'recipe' | 'merged'>('merged')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
// Check if user has a household
const { data: householdData } = useQuery({ const { data: householdData } = useQuery({
queryKey: ['household'], queryKey: ['household'],
queryFn: async () => {
const { getMyHousehold } = await import('../api/households')
try {
const res = await getMyHousehold()
return res.data
} catch {
return null
}
},
enabled: isAuthenticated, enabled: isAuthenticated,
retry: false, retry: false,
}) })
const hasHousehold = !!householdData?.data const hasHousehold = !!householdData
const activeScope = hasHousehold ? scope : undefined const activeScope = hasHousehold ? scope : undefined
const { data: groups = [], isLoading, refetch } = useQuery({ const { data: groups = [], isLoading, refetch } = useQuery({
queryKey: ['shopping', activeScope], queryKey: ['shopping', activeScope],
queryFn: () => fetchShopping(activeScope), queryFn: () => fetchShopping(activeScope),
enabled: isAuthenticated,
}) })
const mergedItems = useMemo(() => mergeItems(groups), [groups])
const checkMutation = useMutation({ const checkMutation = useMutation({
mutationFn: toggleCheck, mutationFn: toggleCheck,
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
@@ -72,6 +142,24 @@ export function ShoppingPage() {
addMutation.mutate({ name }) addMutation.mutate({ name })
} }
// Toggle all items in a merged group
const handleMergedToggle = async (merged: MergedItem) => {
// If all checked → uncheck all, otherwise check all unchecked
const itemsToToggle = merged.allChecked
? merged.items
: merged.items.filter((i) => !i.checked)
for (const item of itemsToToggle) {
checkMutation.mutate(item.id)
}
}
// Delete all items in a merged group
const handleMergedDelete = async (merged: MergedItem) => {
for (const item of merged.items) {
deleteMutation.mutate(item.id)
}
}
const hasChecked = groups.some((g) => g.items.some((i) => i.checked)) const hasChecked = groups.some((g) => g.items.some((i) => i.checked))
const totalItems = groups.reduce((acc, g) => acc + g.items.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 totalChecked = groups.reduce((acc, g) => acc + g.items.filter((i) => i.checked).length, 0)
@@ -106,7 +194,29 @@ export function ShoppingPage() {
} }
} }
if (isLoading) { if (!authLoading && !isAuthenticated) {
return (
<div className="min-h-screen flex flex-col items-center justify-center px-4">
<div className="text-center">
<div className="text-6xl mb-4">🛒</div>
<h2 className="font-display text-2xl text-espresso mb-2">Einkaufsliste</h2>
<p className="text-warm-grey mb-6">
Melde dich an, um deine eigene Einkaufsliste zu nutzen und sie mit deinem Haushalt zu teilen.
</p>
<div className="space-y-3 w-full max-w-sm mx-auto">
<a href="/login" className="block w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] text-center">
Anmelden
</a>
<a href="/register" className="block w-full border border-sand text-espresso rounded-xl py-3 font-medium min-h-[44px] text-center">
Registrieren
</a>
</div>
</div>
</div>
)
}
if (isLoading || authLoading) {
return ( return (
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
@@ -126,9 +236,17 @@ export function ShoppingPage() {
{/* TopBar */} {/* TopBar */}
<div className="sticky top-0 z-40 bg-cream/95 backdrop-blur-sm border-b border-sand px-4 py-3 flex items-center justify-between"> <div className="sticky top-0 z-40 bg-cream/95 backdrop-blur-sm border-b border-sand px-4 py-3 flex items-center justify-between">
<h1 className="font-display text-xl text-espresso">Einkaufsliste</h1> <h1 className="font-display text-xl text-espresso">Einkaufsliste</h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
{/* View toggle */}
<button
onClick={() => setViewMode(viewMode === 'merged' ? 'recipe' : 'merged')}
className="p-2 text-warm-grey hover:text-primary transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
title={viewMode === 'merged' ? 'Nach Rezept anzeigen' : 'Zusammengefasst anzeigen'}
>
{viewMode === 'merged' ? <List size={18} /> : <Layers size={18} />}
</button>
{totalUnchecked > 0 && ( {totalUnchecked > 0 && (
<span className="text-sm text-warm-grey">{totalUnchecked} offen</span> <span className="text-sm text-warm-grey">{totalUnchecked}</span>
)} )}
{hasChecked && ( {hasChecked && (
<button <button
@@ -158,9 +276,7 @@ export function ShoppingPage() {
<button <button
onClick={() => setScope('personal')} onClick={() => setScope('personal')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${ className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${
scope === 'personal' scope === 'personal' ? 'bg-primary text-white' : 'text-warm-grey hover:text-espresso'
? 'bg-primary text-white'
: 'text-warm-grey hover:text-espresso'
}`} }`}
> >
Persönlich Persönlich
@@ -168,9 +284,7 @@ export function ShoppingPage() {
<button <button
onClick={() => setScope('household')} onClick={() => setScope('household')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${ className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${
scope === 'household' scope === 'household' ? 'bg-primary text-white' : 'text-warm-grey hover:text-espresso'
? 'bg-primary text-white'
: 'text-warm-grey hover:text-espresso'
}`} }`}
> >
🏠 Haushalt 🏠 Haushalt
@@ -193,10 +307,7 @@ export function ShoppingPage() {
Abbrechen Abbrechen
</button> </button>
<button <button
onClick={() => { onClick={() => { deleteAllMutation.mutate(); setShowClearConfirm(false) }}
deleteAllMutation.mutate()
setShowClearConfirm(false)
}}
className="flex-1 py-3 rounded-xl bg-berry-red text-white font-medium min-h-[44px]" className="flex-1 py-3 rounded-xl bg-berry-red text-white font-medium min-h-[44px]"
> >
🗑 Alles löschen 🗑 Alles löschen
@@ -212,13 +323,7 @@ export function ShoppingPage() {
{/* Quick-Add */} {/* Quick-Add */}
<div className={`sticky ${hasHousehold ? 'top-[105px]' : 'top-[53px]'} z-30 bg-cream/95 backdrop-blur-sm px-4 py-3`}> <div className={`sticky ${hasHousehold ? 'top-[105px]' : 'top-[53px]'} z-30 bg-cream/95 backdrop-blur-sm px-4 py-3`}>
<form <form onSubmit={(e) => { e.preventDefault(); handleAdd() }} className="flex gap-2">
onSubmit={(e) => {
e.preventDefault()
handleAdd()
}}
className="flex gap-2"
>
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
@@ -243,8 +348,10 @@ export function ShoppingPage() {
<div className="bg-primary-light/30 rounded-2xl p-3"> <div className="bg-primary-light/30 rounded-2xl p-3">
<div className="flex items-center justify-between text-sm text-espresso"> <div className="flex items-center justify-between text-sm text-espresso">
<span> <span>
{recipeCount > 0 && <>{recipeCount} Rezept{recipeCount !== 1 ? 'e' : ''} · </>} {viewMode === 'merged'
{totalItems} Artikel · {totalChecked} erledigt ? <>{mergedItems.length} Artikel{mergedItems.length !== 1 ? '' : ''} · {totalChecked} erledigt</>
: <>{recipeCount > 0 && <>{recipeCount} Rezept{recipeCount !== 1 ? 'e' : ''} · </>}{totalItems} Artikel · {totalChecked} erledigt</>
}
</span> </span>
<span className="text-warm-grey text-xs"> <span className="text-warm-grey text-xs">
{totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0}% {totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0}%
@@ -268,7 +375,29 @@ export function ShoppingPage() {
title="Einkaufsliste leer" title="Einkaufsliste leer"
description="Füge Zutaten aus einem Rezept hinzu oder erstelle eigene Einträge." description="Füge Zutaten aus einem Rezept hinzu oder erstelle eigene Einträge."
/> />
) : viewMode === 'merged' ? (
/* ── Merged/Combined View ── */
<div className="bg-surface rounded-2xl shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b border-sand/50 flex items-center gap-2">
<span className="text-base">🛒</span>
<h3 className="font-semibold text-sm text-espresso">Zusammengefasst</h3>
<span className="text-xs text-warm-grey ml-auto">
{mergedItems.filter((m) => !m.allChecked).length}/{mergedItems.length}
</span>
</div>
<ul>
{mergedItems.map((merged) => (
<MergedItemRow
key={merged.key}
merged={merged}
onToggle={() => handleMergedToggle(merged)}
onDelete={() => handleMergedDelete(merged)}
/>
))}
</ul>
</div>
) : ( ) : (
/* ── Recipe-grouped View ── */
groups.map((group) => ( groups.map((group) => (
<div key={group.recipe_title} className="bg-surface rounded-2xl shadow-sm overflow-hidden"> <div key={group.recipe_title} className="bg-surface rounded-2xl shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b border-sand/50 flex items-center gap-2"> <div className="px-4 py-3 border-b border-sand/50 flex items-center gap-2">
@@ -298,6 +427,115 @@ export function ShoppingPage() {
) )
} }
/* ── Merged Item Row ── */
function MergedItemRow({
merged,
onToggle,
onDelete,
}: {
merged: MergedItem
onToggle: () => void
onDelete: () => void
}) {
const THRESHOLD = -80
const [swipeX, setSwipeX] = useState(0)
const touchStartX = useRef(0)
const swiping = useRef(false)
const pastThreshold = swipeX < THRESHOLD
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX
swiping.current = false
}
const handleTouchMove = (e: React.TouchEvent) => {
const dx = e.touches[0].clientX - touchStartX.current
if (dx < -10) {
swiping.current = true
setSwipeX(Math.max(dx, -160))
} else {
setSwipeX(0)
}
}
const handleTouchEnd = () => {
if (pastThreshold) {
setSwipeX(-300)
setTimeout(() => onDelete(), 200)
} else {
setSwipeX(0)
}
swiping.current = false
}
const amountText = [
merged.totalAmount != null ? (Number.isInteger(merged.totalAmount) ? merged.totalAmount : merged.totalAmount.toFixed(1)) : null,
merged.unit,
].filter(Boolean).join(' ')
const isChecked = merged.allChecked
return (
<li className="relative overflow-hidden">
<div className={`absolute inset-y-0 right-0 w-40 flex items-center justify-center transition-colors ${pastThreshold ? 'bg-berry-red' : 'bg-berry-red/60'}`}>
<span className={`text-white font-medium transition-all ${pastThreshold ? 'text-sm scale-110' : 'text-xs'}`}>
{pastThreshold ? '🗑️ Löschen' : '×'}
</span>
</div>
<div
className={`relative bg-surface flex items-center gap-3 px-4 min-h-[52px] ${swiping.current ? '' : 'transition-transform duration-200'}`}
style={{ transform: `translateX(${swipeX}px)` }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<button
onClick={onToggle}
className="flex-shrink-0 w-6 h-6 rounded-md border-2 flex items-center justify-center transition-colors min-w-[44px] min-h-[44px]"
style={{
borderColor: isChecked ? '#C4737E' : merged.someChecked ? '#C4737E80' : '#E8E0D8',
backgroundColor: isChecked ? '#C4737E' : 'transparent',
}}
>
{isChecked && (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2.5 7L5.5 10L11.5 4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
{merged.someChecked && !isChecked && (
<div className="w-2 h-2 rounded-sm bg-primary/60" />
)}
</button>
<div className={`flex-1 min-w-0 ${isChecked ? 'opacity-50' : ''}`}>
<span className={`text-base sm:text-lg text-espresso ${isChecked ? 'line-through text-warm-grey' : ''}`}>
{merged.name}
</span>
{merged.items.length > 1 && (
<p className="text-xs text-warm-grey truncate">
{merged.recipes.length > 0 ? merged.recipes.join(', ') : `${merged.items.length}×`}
</p>
)}
</div>
{amountText && (
<span className={`text-sm flex-shrink-0 font-medium ${isChecked ? 'text-warm-grey/50 line-through' : 'text-warm-grey'}`}>
{amountText}
</span>
)}
{merged.items.length > 1 && !isChecked && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded-full flex-shrink-0">
{merged.items.length}×
</span>
)}
</div>
</li>
)
}
/* ── Single Item Row ── */
function ShoppingItemRow({ function ShoppingItemRow({
item, item,
onToggle, onToggle,
@@ -342,24 +580,14 @@ function ShoppingItemRow({
return ( return (
<li className="relative overflow-hidden"> <li className="relative overflow-hidden">
<div <div className={`absolute inset-y-0 right-0 w-40 flex items-center justify-center transition-colors ${pastThreshold ? 'bg-berry-red' : 'bg-berry-red/60'}`}>
className={`absolute inset-y-0 right-0 w-40 flex items-center justify-center transition-colors ${ <span className={`text-white font-medium transition-all ${pastThreshold ? 'text-sm scale-110' : 'text-xs'}`}>
pastThreshold ? 'bg-berry-red' : 'bg-berry-red/60'
}`}
>
<span
className={`text-white font-medium transition-all ${
pastThreshold ? 'text-sm scale-110' : 'text-xs'
}`}
>
{pastThreshold ? '🗑️ Löschen' : '×'} {pastThreshold ? '🗑️ Löschen' : '×'}
</span> </span>
</div> </div>
<div <div
className={`relative bg-surface flex items-center gap-3 px-4 min-h-[52px] ${ className={`relative bg-surface flex items-center gap-3 px-4 min-h-[52px] ${swiping.current ? '' : 'transition-transform duration-200'}`}
swiping.current ? '' : 'transition-transform duration-200'
}`}
style={{ transform: `translateX(${swipeX}px)` }} style={{ transform: `translateX(${swipeX}px)` }}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
@@ -375,33 +603,19 @@ function ShoppingItemRow({
> >
{item.checked && ( {item.checked && (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path <path d="M2.5 7L5.5 10L11.5 4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
d="M2.5 7L5.5 10L11.5 4"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg> </svg>
)} )}
</button> </button>
<div className={`flex-1 min-w-0 ${item.checked ? 'opacity-50' : ''}`}> <div className={`flex-1 min-w-0 ${item.checked ? 'opacity-50' : ''}`}>
<span <span className={`text-base sm:text-lg text-espresso ${item.checked ? 'line-through text-warm-grey' : ''}`}>
className={`text-base sm:text-lg text-espresso ${
item.checked ? 'line-through text-warm-grey' : ''
}`}
>
{item.name} {item.name}
</span> </span>
</div> </div>
{amountText && ( {amountText && (
<span <span className={`text-sm flex-shrink-0 ${item.checked ? 'text-warm-grey/50 line-through' : 'text-warm-grey'}`}>
className={`text-sm flex-shrink-0 ${
item.checked ? 'text-warm-grey/50 line-through' : 'text-warm-grey'
}`}
>
{amountText} {amountText}
</span> </span>
)} )}

32
webhook-server.js Normal file
View File

@@ -0,0 +1,32 @@
const http = require('http')
const { execSync } = require('child_process')
const SECRET = 'luna-deploy-2026'
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/deploy') {
let body = ''
req.on('data', chunk => body += chunk)
req.on('end', () => {
console.log(`[${new Date().toISOString()}] Deploy triggered`)
try {
const out = execSync('/home/clawd/.openclaw/workspace/luna-recipes/deploy.sh', {
cwd: '/home/clawd/.openclaw/workspace/luna-recipes',
timeout: 120000,
encoding: 'utf8'
})
console.log(out)
res.writeHead(200)
res.end('✅ Deployed\n')
} catch (e) {
console.error('Deploy failed:', e.message)
res.writeHead(500)
res.end('❌ Deploy failed\n')
}
})
} else {
res.writeHead(404)
res.end()
}
})
server.listen(9876, () => console.log('🚀 Deploy webhook listening on :9876'))