diff --git a/docker-compose.yml b/docker-compose.yml index 5e25558..4cb908d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,11 +22,13 @@ services: depends_on: db: condition: service_healthy + volumes: + - ./backend/data:/app/data environment: DATABASE_URL: postgresql://luna:${DB_PASSWORD:-luna-recipes-secret-2026}@db:5432/luna_recipes - JWT_SECRET: ${JWT_SECRET:-luna-jwt-change-in-prod} - JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-luna-refresh-change-in-prod} - COOKIE_SECRET: ${COOKIE_SECRET:-luna-cookie-change-in-prod} + JWT_SECRET: ${JWT_SECRET:-luna-recipes-jwt-secret-change-in-prod-2026} + JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-luna-recipes-refresh-secret-change-in-prod-2026} + COOKIE_SECRET: ${COOKIE_SECRET:-luna-recipes-cookie-secret-change-in-prod-2026} PORT: "6001" NODE_ENV: production ports: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 7284860..6926b82 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,14 @@ server { root /usr/share/nginx/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 location /api/ { proxy_pass http://backend:6001; diff --git a/frontend/src/components/profile/HouseholdCard.tsx b/frontend/src/components/profile/HouseholdCard.tsx index 762829a..231a845 100644 --- a/frontend/src/components/profile/HouseholdCard.tsx +++ b/frontend/src/components/profile/HouseholdCard.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' 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 { getMyHousehold, @@ -23,13 +22,20 @@ export function HouseholdCard() { const [code, setCode] = useState('') const [copied, setCopied] = useState(false) - const { data, isLoading } = useQuery({ + const { data: household, isLoading } = useQuery({ 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, }) - const household: Household | null = data?.data ?? null const myRole = household?.members.find((m) => m.user_id === user?.id)?.role const createMut = useMutation({ @@ -73,14 +79,29 @@ export function HouseholdCard() { onError: (err: Error) => showToast.error(err.message), }) - const copyCode = async () => { + // Clipboard fallback for HTTP (no navigator.clipboard) + const copyCode = () => { if (!household?.invite_code) return 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) + showToast.success('Code kopiert!') setTimeout(() => setCopied(false), 2000) } 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
} - // Mini modal component - const MiniModal = ({ - open, - onClose, - title, - children, - }: { - open: boolean - onClose: () => void - title: string - children: React.ReactNode - }) => ( - - {open && ( - - e.stopPropagation()} - > -
-

{title}

- -
- {children} -
-
- )} -
- ) - // No household if (!household) { return ( @@ -158,39 +137,80 @@ export function HouseholdCard() {
- setShowCreate(false)} title="Haushalt erstellen"> - setName(e.target.value)} - placeholder="z.B. Luna & Marc" - 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" - /> - - +
e.stopPropagation()} + > +
+

Haushalt erstellen

+ +
+
{ e.preventDefault(); name.trim() && createMut.mutate(name.trim()) }}> + setName(e.target.value)} + 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" + /> + +
+
+ + )} - setShowJoin(false)} title="Mit Code beitreten"> - setCode(e.target.value)} - placeholder="Einladungscode eingeben" - 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" - /> - - +
e.stopPropagation()} + > +
+

Mit Code beitreten

+ +
+
{ e.preventDefault(); code.trim() && joinMut.mutate(code.trim()) }}> + setCode(e.target.value.toUpperCase())} + placeholder="Einladungscode" + 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" + /> + +
+
+ + )} ) } @@ -239,12 +259,13 @@ export function HouseholdCard() {

Einladungscode

- + {household.invite_code} @@ -252,6 +273,7 @@ export function HouseholdCard() { onClick={() => regenMut.mutate()} 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" + title="Neuen Code generieren" > @@ -272,45 +294,37 @@ export function HouseholdCard() {
{/* Leave confirm */} - - {showLeaveConfirm && ( - setShowLeaveConfirm(false)} + {showLeaveConfirm && ( +
setShowLeaveConfirm(false)} + > +
e.stopPropagation()} > - e.stopPropagation()} - > -

Haushalt verlassen?

-

- Du verlierst Zugriff auf die gemeinsame Einkaufsliste. -

-
- - -
-
- - )} - +

Haushalt verlassen?

+

+ Du verlierst Zugriff auf die gemeinsame Einkaufsliste. +

+
+ + +
+
+
+ )} ) } diff --git a/frontend/src/pages/ShoppingPage.tsx b/frontend/src/pages/ShoppingPage.tsx index 263e036..4d5d50e 100644 --- a/frontend/src/pages/ShoppingPage.tsx +++ b/frontend/src/pages/ShoppingPage.tsx @@ -1,6 +1,6 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Trash2, Plus } from 'lucide-react' +import { Trash2, Plus, List, Layers } from 'lucide-react' import { fetchShopping, addCustomItem, @@ -13,14 +13,72 @@ import type { ShoppingItem } from '../api/shopping' import { useAuth } from '../context/AuthContext' 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() + + 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() { const qc = useQueryClient() const { isAuthenticated } = useAuth() const [newItem, setNewItem] = useState('') const [scope, setScope] = useState<'personal' | 'household'>('personal') + const [viewMode, setViewMode] = useState<'recipe' | 'merged'>('merged') const inputRef = useRef(null) - // Check if user has a household const { data: householdData } = useQuery({ queryKey: ['household'], queryFn: async () => { @@ -44,6 +102,8 @@ export function ShoppingPage() { queryFn: () => fetchShopping(activeScope), }) + const mergedItems = useMemo(() => mergeItems(groups), [groups]) + const checkMutation = useMutation({ mutationFn: toggleCheck, onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }), @@ -81,6 +141,24 @@ export function ShoppingPage() { 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 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) @@ -135,9 +213,17 @@ export function ShoppingPage() { {/* TopBar */}

Einkaufsliste

-
+
+ {/* View toggle */} + {totalUnchecked > 0 && ( - {totalUnchecked} offen + {totalUnchecked} )} {hasChecked && ( + +
+ + {merged.name} + + {merged.items.length > 1 && ( +

+ {merged.recipes.length > 0 ? merged.recipes.join(', ') : `${merged.items.length}×`} +

+ )} +
+ + {amountText && ( + + {amountText} + + )} + + {merged.items.length > 1 && !isChecked && ( + + {merged.items.length}× + + )} +
+ + ) +} + +/* ── Single Item Row ── */ function ShoppingItemRow({ item, onToggle, @@ -351,24 +557,14 @@ function ShoppingItemRow({ return (
  • -
    - +
    + {pastThreshold ? '🗑️ Löschen' : '×'}
    {item.checked && ( - + )}
    - + {item.name}
    {amountText && ( - + {amountText} )}