Compare commits
10 Commits
301e42b1dc
...
8ba5eebd93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba5eebd93 | ||
|
|
c18f88d0d1 | ||
|
|
27d0e72766 | ||
|
|
e10e8f3fe2 | ||
|
|
5cce78f40f | ||
|
|
8c0ffc653f | ||
|
|
ec02ffddae | ||
|
|
c5774e8c8d | ||
|
|
03f3893c2c | ||
|
|
cc8e2482e9 |
50
.gitea/workflows/ci.yml
Normal file
50
.gitea/workflows/ci.yml
Normal 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"
|
||||||
20
.gitea/workflows/deploy.yml
Normal file
20
.gitea/workflows/deploy.yml
Normal 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
|
||||||
30
.gitea/workflows/docker-build.yml
Normal file
30
.gitea/workflows/docker-build.yml
Normal 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"
|
||||||
30
.gitea/workflows/security.yml
Normal file
30
.gitea/workflows/security.yml
Normal 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
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
9
deploy.sh
Executable 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)"
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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
32
webhook-server.js
Normal 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'))
|
||||||
Reference in New Issue
Block a user