feat: complete mealplanner app (backend + frontend + deployment)

.NET 8 backend with Zitadel JWT auth, TheMealDB integration,
weekly meal plan generation, shopping list aggregation.
Vue 3 + Tailwind 4 frontend with dark emerald theme,
manual OIDC PKCE auth, all views implemented.
Multi-stage Dockerfile with nginx reverse proxy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 19:10:10 +00:00
parent 660bcd1953
commit f58782774b
51 changed files with 4061 additions and 0 deletions

234
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,234 @@
<template>
<!-- Auth loading -->
<div v-if="isLoading" class="min-h-screen bg-zinc-950 flex items-center justify-center">
<div class="flex flex-col items-center gap-4">
<svg class="w-10 h-10 text-accent animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<p class="text-zinc-400 text-sm">Wird geladen...</p>
</div>
</div>
<!-- Not authenticated -->
<div v-else-if="!isAuthenticated" class="min-h-screen bg-zinc-950 flex items-center justify-center p-4">
<div class="w-full max-w-sm bg-zinc-900 border border-zinc-800 rounded-2xl p-8 flex flex-col items-center gap-6">
<!-- App icon -->
<div class="p-4 bg-accent-dim rounded-xl">
<svg class="w-10 h-10 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div class="text-center">
<h1 class="text-2xl font-bold text-zinc-100">Mealplanner</h1>
<p class="text-zinc-500 text-sm mt-1">Plane deine Woche, stressfrei.</p>
</div>
<div v-if="authError" class="w-full px-4 py-3 bg-red-950 border border-error rounded-lg text-error text-sm text-center">
{{ authError }}
</div>
<button
@click="login()"
class="w-full flex items-center justify-center gap-2 py-3 rounded-lg bg-accent hover:bg-accent-hover text-white font-medium transition-colors"
>
<!-- User icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Anmelden
</button>
</div>
</div>
<!-- Authenticated app -->
<div v-else class="flex h-screen bg-zinc-950 overflow-hidden">
<!-- Sidebar -->
<aside
class="flex flex-col bg-zinc-900 border-r border-zinc-800 transition-all duration-300 z-20"
:class="sidebarOpen ? 'w-56' : 'w-14'"
>
<!-- Logo / toggle -->
<div class="flex items-center h-14 px-3 border-b border-zinc-800">
<button
@click="sidebarOpen = !sidebarOpen"
class="p-2 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors flex-shrink-0"
:title="sidebarOpen ? 'Sidebar schließen' : 'Sidebar öffnen'"
>
<!-- Menu icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<Transition name="fade-text">
<span v-if="sidebarOpen" class="ml-3 font-bold text-zinc-100 text-sm whitespace-nowrap overflow-hidden">Mealplanner</span>
</Transition>
</div>
<!-- Nav -->
<nav class="flex-1 py-4 space-y-1 px-2">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="flex items-center gap-3 px-2 py-2.5 rounded-lg transition-colors group"
:class="isActiveRoute(item.to)
? 'bg-accent-dim text-accent'
: 'text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800'"
:title="!sidebarOpen ? item.label : undefined"
>
<span class="flex-shrink-0 w-5 h-5" v-html="item.icon" />
<Transition name="fade-text">
<span v-if="sidebarOpen" class="text-sm font-medium whitespace-nowrap overflow-hidden">{{ item.label }}</span>
</Transition>
</RouterLink>
</nav>
<!-- User footer -->
<div class="border-t border-zinc-800 p-2">
<div
class="flex items-center gap-3 px-2 py-2.5 rounded-lg"
:class="sidebarOpen ? 'bg-zinc-800/50' : ''"
>
<div class="flex-shrink-0 w-7 h-7 rounded-full bg-accent-dim flex items-center justify-center">
<svg class="w-4 h-4 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<Transition name="fade-text">
<div v-if="sidebarOpen" class="flex-1 min-w-0 overflow-hidden">
<p class="text-xs font-medium text-zinc-200 truncate">{{ currentUser?.name || 'Benutzer' }}</p>
<p v-if="currentUser?.email" class="text-xs text-zinc-500 truncate">{{ currentUser.email }}</p>
</div>
</Transition>
<Transition name="fade-text">
<button
v-if="sidebarOpen"
@click="logout()"
class="flex-shrink-0 p-1.5 rounded text-zinc-500 hover:text-error hover:bg-zinc-800 transition-colors"
title="Abmelden"
>
<!-- Logout icon -->
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</Transition>
</div>
</div>
</aside>
<!-- Mobile overlay -->
<div
v-if="sidebarOpen && isMobile"
class="fixed inset-0 z-10 bg-black/50"
@click="sidebarOpen = false"
/>
<!-- Main content -->
<main class="flex-1 overflow-y-auto">
<div class="p-6 max-w-7xl mx-auto">
<RouterView />
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { isAuthenticated, isLoading, authError, currentUser, login, logout } from './auth'
const sidebarOpen = ref(true)
const windowWidth = ref(window.innerWidth)
function onResize(): void {
windowWidth.value = window.innerWidth
if (window.innerWidth < 768) {
sidebarOpen.value = false
}
}
onMounted(() => {
window.addEventListener('resize', onResize)
if (window.innerWidth < 768) sidebarOpen.value = false
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
})
const isMobile = computed(() => windowWidth.value < 768)
const route = useRoute()
function isActiveRoute(path: string): boolean {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
const navItems = [
{
to: '/',
label: 'Wochenplan',
icon: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>`,
},
{
to: '/shopping',
label: 'Einkaufsliste',
icon: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>`,
},
{
to: '/recipes',
label: 'Rezepte',
icon: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0118 18a8.966 8.966 0 00-6 2.292m0-14.25v14.25" />
</svg>`,
},
{
to: '/settings',
label: 'Einstellungen',
icon: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>`,
},
]
</script>
<style>
html, body {
background-color: #09090b;
}
</style>
<style scoped>
.fade-text-enter-active {
transition: opacity 0.15s ease 0.1s, max-width 0.2s ease;
}
.fade-text-leave-active {
transition: opacity 0.1s ease, max-width 0.2s ease;
}
.fade-text-enter-from,
.fade-text-leave-to {
opacity: 0;
max-width: 0;
}
.fade-text-enter-to,
.fade-text-leave-from {
opacity: 1;
max-width: 200px;
}
</style>

185
frontend/src/auth.ts Normal file
View File

@@ -0,0 +1,185 @@
import { ref } from 'vue'
const ZITADEL_ISSUER = import.meta.env.VITE_ZITADEL_ISSUER || 'https://auth.kuns.dev'
const CLIENT_ID = import.meta.env.VITE_ZITADEL_CLIENT_ID || ''
const REDIRECT_URI = `${window.location.origin}/auth/callback`
const SCOPES = 'openid profile email urn:zitadel:iam:org:project:roles'
export const isAuthenticated = ref(false)
export const isLoading = ref(true)
export const authError = ref<string | null>(null)
export const currentUser = ref<{ name?: string; email?: string } | null>(null)
function randomString(len: number): string {
const arr = new Uint8Array(len)
crypto.getRandomValues(arr)
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('')
}
async function sha256(plain: string): Promise<string> {
const data = new TextEncoder().encode(plain)
const hash = await crypto.subtle.digest('SHA-256', data)
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function checkRedirectLoop(): boolean {
const key = 'oidc_redirect_count'
const tsKey = 'oidc_redirect_ts'
const now = Date.now()
const ts = parseInt(sessionStorage.getItem(tsKey) || '0')
let count = parseInt(sessionStorage.getItem(key) || '0')
if (now - ts > 30000) count = 0
count++
sessionStorage.setItem(key, String(count))
sessionStorage.setItem(tsKey, String(now))
if (count > 3) {
console.error('[auth] Redirect loop detected')
return true
}
return false
}
function clearRedirectCounter(): void {
sessionStorage.removeItem('oidc_redirect_count')
sessionStorage.removeItem('oidc_redirect_ts')
}
export function getAccessToken(): string | null {
return localStorage.getItem('oidc_access_token')
}
export async function initAuth(): Promise<void> {
try {
if (window.location.pathname === '/auth/callback') {
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const error = params.get('error')
const verifier = localStorage.getItem('oidc_code_verifier')
if (error) {
authError.value = `Login fehlgeschlagen: ${params.get('error_description') || error}`
localStorage.removeItem('oidc_code_verifier')
window.history.replaceState({}, '', '/')
return
}
if (!code || !verifier) {
authError.value = 'Login fehlgeschlagen: Auth-State verloren.'
localStorage.removeItem('oidc_code_verifier')
window.history.replaceState({}, '', '/')
return
}
const res = await fetch(`${ZITADEL_ISSUER}/oauth/v2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier,
}),
})
if (!res.ok) {
authError.value = `Token-Exchange fehlgeschlagen (HTTP ${res.status})`
localStorage.removeItem('oidc_code_verifier')
window.history.replaceState({}, '', '/')
return
}
const tokens = await res.json() as { access_token: string; id_token?: string }
localStorage.setItem('oidc_access_token', tokens.access_token)
if (tokens.id_token) localStorage.setItem('oidc_id_token', tokens.id_token)
localStorage.removeItem('oidc_code_verifier')
try {
if (tokens.id_token) {
const payload = JSON.parse(atob(tokens.id_token.split('.')[1] ?? '')) as {
name?: string
email?: string
sub?: string
exp?: number
}
localStorage.setItem('oidc_user_name', payload.name || '')
localStorage.setItem('oidc_user_email', payload.email || '')
currentUser.value = {
...(payload.name !== undefined ? { name: payload.name } : {}),
...(payload.email !== undefined ? { email: payload.email } : {}),
}
}
} catch { /* ignore */ }
isAuthenticated.value = true
clearRedirectCounter()
const returnUrl = localStorage.getItem('oidc_return_url') || '/'
localStorage.removeItem('oidc_return_url')
window.location.replace(returnUrl)
return
}
const token = localStorage.getItem('oidc_access_token')
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1] ?? '')) as {
exp?: number
sub?: string
}
if (payload.exp && payload.exp * 1000 > Date.now()) {
isAuthenticated.value = true
const storedName = localStorage.getItem('oidc_user_name') || payload.sub
const storedEmail = localStorage.getItem('oidc_user_email')
currentUser.value = {
...(storedName !== undefined ? { name: storedName } : {}),
...(storedEmail ? { email: storedEmail } : {}),
}
clearRedirectCounter()
return
}
} catch { /* ignore */ }
localStorage.removeItem('oidc_access_token')
localStorage.removeItem('oidc_id_token')
}
} catch (e) {
authError.value = `Auth-Initialisierung fehlgeschlagen: ${e}`
} finally {
isLoading.value = false
}
}
let redirecting = false
export async function login(returnUrl?: string): Promise<void> {
if (redirecting) return
if (checkRedirectLoop()) {
authError.value = 'Redirect-Loop erkannt. Bitte Browser-Cache leeren.'
return
}
redirecting = true
authError.value = null
const verifier = randomString(32)
const challenge = await sha256(verifier)
localStorage.setItem('oidc_code_verifier', verifier)
localStorage.setItem('oidc_return_url', returnUrl || '/')
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: SCOPES,
code_challenge: challenge,
code_challenge_method: 'S256',
})
window.location.href = `${ZITADEL_ISSUER}/oauth/v2/authorize?${params}`
}
export function logout(): void {
localStorage.removeItem('oidc_access_token')
localStorage.removeItem('oidc_id_token')
localStorage.removeItem('oidc_code_verifier')
localStorage.removeItem('oidc_return_url')
clearRedirectCounter()
isAuthenticated.value = false
currentUser.value = null
window.location.href = `${ZITADEL_ISSUER}/oidc/v1/end_session?post_logout_redirect_uri=${encodeURIComponent(window.location.origin)}`
}

View File

@@ -0,0 +1,82 @@
<template>
<div class="flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden shadow-md hover:border-zinc-700 transition-colors">
<!-- Day header -->
<div class="px-4 py-2 bg-zinc-800/60 border-b border-zinc-800">
<span class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">{{ dayName }}</span>
</div>
<!-- Image -->
<div class="relative w-full h-40 bg-zinc-800 overflow-hidden">
<img
v-if="entry.recipe.imageUrl"
:src="entry.recipe.imageUrl"
:alt="entry.recipe.title"
class="w-full h-full object-cover"
@error="imgError = true"
/>
<div v-if="!entry.recipe.imageUrl || imgError" class="w-full h-full flex items-center justify-center">
<svg class="w-14 h-14 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.966 8.966 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
</div>
<!-- Content -->
<div class="flex-1 px-4 py-3">
<h3 class="text-zinc-100 font-medium text-base leading-snug line-clamp-2">{{ entry.recipe.title }}</h3>
<p v-if="entry.recipe.ingredients?.length" class="mt-1 text-zinc-500 text-xs">
{{ entry.recipe.ingredients.length }} Zutaten
</p>
</div>
<!-- Actions -->
<div class="px-4 py-3 border-t border-zinc-800 flex gap-2">
<button
@click="$emit('swap', entry)"
class="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 hover:text-zinc-100 text-xs font-medium transition-colors"
title="Rezept austauschen"
>
<!-- Swap arrows icon -->
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
Tauschen
</button>
<button
@click="$emit('reroll', entry)"
class="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 hover:text-zinc-100 text-xs font-medium transition-colors"
title="Zufälliges Rezept"
>
<!-- Dice icon -->
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" stroke-width="2" />
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
<circle cx="15.5" cy="8.5" r="1.5" fill="currentColor" />
<circle cx="8.5" cy="15.5" r="1.5" fill="currentColor" />
<circle cx="15.5" cy="15.5" r="1.5" fill="currentColor" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
</svg>
Würfeln
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { MealPlanEntry } from '../stores/mealPlan'
defineProps<{
entry: MealPlanEntry
dayName: string
}>()
defineEmits<{
swap: [entry: MealPlanEntry]
reroll: [entry: MealPlanEntry]
}>()
const imgError = ref(false)
</script>

View File

@@ -0,0 +1,78 @@
<template>
<!-- Backdrop -->
<Teleport to="body">
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl">
<!-- Close button -->
<button
@click="$emit('close')"
class="absolute top-4 right-4 p-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors z-10"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Image -->
<div v-if="recipe.imageUrl && !imgError" class="w-full h-56 overflow-hidden rounded-t-2xl">
<img
:src="recipe.imageUrl"
:alt="recipe.title"
class="w-full h-full object-cover"
@error="imgError = true"
/>
</div>
<div v-else class="w-full h-40 bg-zinc-800 rounded-t-2xl flex items-center justify-center">
<svg class="w-16 h-16 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.966 8.966 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
<div class="p-6">
<h2 class="text-2xl font-bold text-zinc-100 mb-4">{{ recipe.title }}</h2>
<!-- Ingredients -->
<div v-if="recipe.ingredients?.length" class="mb-6">
<h3 class="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-3">Zutaten</h3>
<div class="space-y-2">
<div
v-for="(ing, idx) in recipe.ingredients"
:key="idx"
class="flex items-center gap-3 py-1.5 px-3 rounded-lg bg-zinc-800/50"
>
<span class="text-zinc-300 flex-1">{{ ing.name }}</span>
<span class="text-zinc-400 text-sm">{{ ing.amount }} {{ ing.unit }}</span>
<span v-if="ing.category" class="text-xs text-zinc-600 bg-zinc-800 px-2 py-0.5 rounded-full">{{ ing.category }}</span>
</div>
</div>
</div>
<!-- Instructions -->
<div v-if="recipe.instructions">
<h3 class="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-3">Zubereitung</h3>
<p class="text-zinc-300 leading-relaxed whitespace-pre-wrap">{{ recipe.instructions }}</p>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { Recipe } from '../stores/mealPlan'
defineProps<{
recipe: Recipe
}>()
defineEmits<{
close: []
}>()
const imgError = ref(false)
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div
class="flex items-center gap-3 py-2 px-3 rounded-lg hover:bg-zinc-800/50 transition-colors group cursor-pointer"
@click="$emit('toggle', item.id)"
>
<!-- Checkbox -->
<div
class="flex-shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-all"
:class="item.isChecked
? 'bg-accent border-accent'
: 'border-zinc-600 group-hover:border-zinc-400'"
>
<svg v-if="item.isChecked" class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</div>
<!-- Name -->
<span
class="flex-1 text-sm transition-colors"
:class="item.isChecked ? 'line-through text-zinc-600' : 'text-zinc-200'"
>{{ item.name }}</span>
<!-- Amount + unit -->
<span
class="text-sm tabular-nums transition-colors"
:class="item.isChecked ? 'text-zinc-700' : 'text-zinc-400'"
>{{ item.amount }} {{ item.unit }}</span>
</div>
</template>
<script setup lang="ts">
import type { ShoppingItem } from '../stores/mealPlan'
defineProps<{
item: ShoppingItem
}>()
defineEmits<{
toggle: [id: number]
}>()
</script>

View File

@@ -0,0 +1,88 @@
<template>
<Teleport to="body">
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
<h2 class="text-lg font-semibold text-zinc-100">Rezept austauschen</h2>
<button
@click="$emit('close')"
class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Search -->
<div class="px-6 py-4">
<input
v-model="query"
type="text"
placeholder="Rezept suchen..."
class="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2.5 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm"
autofocus
/>
</div>
<!-- Recipe list -->
<div class="px-6 pb-4 max-h-80 overflow-y-auto space-y-1">
<div v-if="!filtered.length" class="text-center py-8 text-zinc-500 text-sm">
Keine Rezepte gefunden.
</div>
<button
v-for="recipe in filtered"
:key="recipe.id"
@click="$emit('select', recipe.id)"
class="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-zinc-800 transition-colors text-left"
>
<div class="w-10 h-10 rounded-lg bg-zinc-800 overflow-hidden flex-shrink-0">
<img
v-if="recipe.imageUrl"
:src="recipe.imageUrl"
:alt="recipe.title"
class="w-full h-full object-cover"
/>
<div v-else class="w-full h-full flex items-center justify-center">
<svg class="w-5 h-5 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.966 8.966 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-zinc-100 text-sm font-medium truncate">{{ recipe.title }}</p>
<p v-if="recipe.ingredients?.length" class="text-zinc-500 text-xs">{{ recipe.ingredients.length }} Zutaten</p>
</div>
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Recipe } from '../stores/mealPlan'
const props = defineProps<{
recipes: Recipe[]
}>()
defineEmits<{
close: []
select: [recipeId: number]
}>()
const query = ref('')
const filtered = computed(() => {
const q = query.value.toLowerCase().trim()
if (!q) return props.recipes
return props.recipes.filter(r => r.title.toLowerCase().includes(q))
})
</script>

View File

@@ -0,0 +1,24 @@
import { getAccessToken, login } from '../auth'
const BASE = '/api'
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getAccessToken()
if (!token) { login(window.location.pathname); throw new Error('Not authenticated') }
const res = await fetch(`${BASE}${path}`, {
...options,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...options.headers },
})
if (res.status === 401) { login(window.location.pathname); throw new Error('Unauthorized') }
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<T>
}
export function useApi() {
return {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }),
put: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }),
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
}
}

10
frontend/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ZITADEL_ISSUER: string
readonly VITE_ZITADEL_CLIENT_ID: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

11
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { router } from './router'
import { initAuth } from './auth'
import './theme.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
initAuth().then(() => app.mount('#app'))

16
frontend/src/router.ts Normal file
View File

@@ -0,0 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router'
import WeekPlanView from './views/WeekPlanView.vue'
import ShoppingListView from './views/ShoppingListView.vue'
import RecipesView from './views/RecipesView.vue'
import SettingsView from './views/SettingsView.vue'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: WeekPlanView },
{ path: '/shopping/:mealPlanId?', component: ShoppingListView },
{ path: '/recipes', component: RecipesView },
{ path: '/settings', component: SettingsView },
{ path: '/auth/callback', redirect: '/' },
],
})

View File

@@ -0,0 +1,232 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi'
export interface Ingredient {
id?: number
name: string
amount: number
unit: string
category: string
}
export interface Recipe {
id: number
title: string
imageUrl?: string
instructions?: string
ingredients: Ingredient[]
}
export interface MealPlanEntry {
id: number
date: string
dayOfWeek: number
recipeId: number
recipe: Recipe
}
export interface MealPlan {
id: number
weekStartDate: string
entries: MealPlanEntry[]
}
export interface ShoppingItem {
id: number
name: string
amount: number
unit: string
category: string
isChecked: boolean
}
export const useMealPlanStore = defineStore('mealPlan', () => {
const api = useApi()
const currentPlan = ref<MealPlan | null>(null)
const loading = ref(false)
const generating = ref(false)
const error = ref<string | null>(null)
async function fetchCurrentPlan(): Promise<void> {
loading.value = true
error.value = null
try {
currentPlan.value = await api.get<MealPlan>('/mealplans/current')
} catch (e) {
if (e instanceof Error && e.message === 'HTTP 404') {
currentPlan.value = null
} else {
error.value = 'Fehler beim Laden des Wochenplans.'
}
} finally {
loading.value = false
}
}
async function generatePlan(): Promise<void> {
generating.value = true
error.value = null
try {
currentPlan.value = await api.post<MealPlan>('/mealplans/generate')
} catch {
error.value = 'Fehler beim Generieren des Plans.'
} finally {
generating.value = false
}
}
async function swapMeal(entryId: number, recipeId: number): Promise<void> {
error.value = null
try {
const updated = await api.put<MealPlanEntry>(`/mealplans/entries/${entryId}/swap`, { recipeId })
if (currentPlan.value) {
const idx = currentPlan.value.entries.findIndex(e => e.id === entryId)
if (idx !== -1) {
currentPlan.value.entries[idx] = updated
}
}
} catch {
error.value = 'Fehler beim Austauschen des Rezepts.'
}
}
async function rerollMeal(entryId: number): Promise<void> {
error.value = null
try {
const updated = await api.post<MealPlanEntry>(`/mealplans/entries/${entryId}/reroll`)
if (currentPlan.value) {
const idx = currentPlan.value.entries.findIndex(e => e.id === entryId)
if (idx !== -1) {
currentPlan.value.entries[idx] = updated
}
}
} catch {
error.value = 'Fehler beim Neu-Würfeln des Rezepts.'
}
}
return { currentPlan, loading, generating, error, fetchCurrentPlan, generatePlan, swapMeal, rerollMeal }
})
export const useShoppingStore = defineStore('shopping', () => {
const api = useApi()
const items = ref<ShoppingItem[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchItems(mealPlanId?: number | string): Promise<void> {
loading.value = true
error.value = null
try {
const path = mealPlanId ? `/shopping/${mealPlanId}` : '/shopping/current'
items.value = await api.get<ShoppingItem[]>(path)
} catch (e) {
if (e instanceof Error && e.message === 'HTTP 404') {
items.value = []
} else {
error.value = 'Fehler beim Laden der Einkaufsliste.'
}
} finally {
loading.value = false
}
}
async function toggleItem(id: number): Promise<void> {
const item = items.value.find(i => i.id === id)
if (!item) return
const prev = item.isChecked
item.isChecked = !prev
try {
await api.put(`/shopping/${id}/toggle`)
} catch {
item.isChecked = prev
error.value = 'Fehler beim Aktualisieren.'
}
}
const uncheckedCount = () => items.value.filter(i => !i.isChecked).length
return { items, loading, error, fetchItems, toggleItem, uncheckedCount }
})
export const useRecipesStore = defineStore('recipes', () => {
const api = useApi()
const recipes = ref<Recipe[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchRecipes(): Promise<void> {
loading.value = true
error.value = null
try {
recipes.value = await api.get<Recipe[]>('/recipes')
} catch {
error.value = 'Fehler beim Laden der Rezepte.'
} finally {
loading.value = false
}
}
async function createRecipe(data: Omit<Recipe, 'id'>): Promise<Recipe> {
const created = await api.post<Recipe>('/recipes', data)
recipes.value.push(created)
return created
}
async function updateRecipe(id: number, data: Partial<Omit<Recipe, 'id'>>): Promise<void> {
const updated = await api.put<Recipe>(`/recipes/${id}`, data)
const idx = recipes.value.findIndex(r => r.id === id)
if (idx !== -1) recipes.value[idx] = updated
}
async function deleteRecipe(id: number): Promise<void> {
await api.del(`/recipes/${id}`)
recipes.value = recipes.value.filter(r => r.id !== id)
}
return { recipes, loading, error, fetchRecipes, createRecipe, updateRecipe, deleteRecipe }
})
export const useSettingsStore = defineStore('settings', () => {
const api = useApi()
const householdSize = ref(2)
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const saved = ref(false)
async function fetchSettings(): Promise<void> {
loading.value = true
error.value = null
try {
const data = await api.get<{ householdSize: number }>('/settings')
householdSize.value = data.householdSize
} catch {
// use defaults
} finally {
loading.value = false
}
}
async function saveSettings(): Promise<void> {
saving.value = true
error.value = null
saved.value = false
try {
await api.put('/settings', { householdSize: householdSize.value })
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
} catch {
error.value = 'Fehler beim Speichern.'
} finally {
saving.value = false
}
}
return { householdSize, loading, saving, error, saved, fetchSettings, saveSettings }
})

11
frontend/src/theme.css Normal file
View File

@@ -0,0 +1,11 @@
@import "tailwindcss";
@plugin "@tailwindcss/vite";
@theme {
--color-accent: #10b981;
--color-accent-hover: #059669;
--color-accent-dim: #064e3b;
--color-success: #4ade80;
--color-error: #ef4444;
--color-warning: #f59e0b;
}

View File

@@ -0,0 +1,369 @@
<template>
<div class="flex flex-col gap-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-zinc-100">Meine Rezepte</h1>
<button
@click="openCreate"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors"
>
<!-- Plus icon -->
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neues Rezept
</button>
</div>
<!-- Error -->
<div v-if="store.error" class="px-4 py-3 bg-red-950 border border-error rounded-lg text-error text-sm">
{{ store.error }}
</div>
<!-- Loading -->
<div v-if="store.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div v-for="i in 6" :key="i" class="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden animate-pulse">
<div class="h-40 bg-zinc-800" />
<div class="p-4 space-y-2">
<div class="h-4 bg-zinc-800 rounded w-3/4" />
<div class="h-3 bg-zinc-800 rounded w-1/2" />
</div>
</div>
</div>
<!-- Empty state -->
<div v-else-if="!store.recipes.length" class="flex flex-col items-center justify-center py-24 gap-4">
<div class="p-6 bg-zinc-900 rounded-full border border-zinc-800">
<svg class="w-12 h-12 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.966 8.966 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
<div class="text-center">
<p class="text-zinc-300 text-lg font-medium">Keine Rezepte vorhanden</p>
<p class="text-zinc-500 text-sm mt-1">Erstelle dein erstes Rezept.</p>
</div>
</div>
<!-- Recipe grid -->
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div
v-for="recipe in store.recipes"
:key="recipe.id"
class="flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden shadow-md hover:border-zinc-700 cursor-pointer transition-colors"
@click="selectedRecipe = recipe"
>
<!-- Image -->
<div class="relative h-40 bg-zinc-800 overflow-hidden">
<img
v-if="recipe.imageUrl && !imgErrors.has(recipe.id)"
:src="recipe.imageUrl"
:alt="recipe.title"
class="w-full h-full object-cover"
@error="imgErrors.add(recipe.id)"
/>
<div v-else class="w-full h-full flex items-center justify-center">
<svg class="w-12 h-12 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.966 8.966 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
</div>
<!-- Info -->
<div class="flex-1 px-4 py-3">
<h3 class="text-zinc-100 font-medium text-sm leading-snug line-clamp-2">{{ recipe.title }}</h3>
<p v-if="recipe.ingredients?.length" class="mt-1 text-zinc-500 text-xs">
{{ recipe.ingredients.length }} Zutaten
</p>
</div>
<!-- Edit/Delete -->
<div class="px-4 py-3 border-t border-zinc-800 flex gap-2" @click.stop>
<button
@click="openEdit(recipe)"
class="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 hover:text-zinc-100 text-xs font-medium transition-colors"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Bearbeiten
</button>
<button
@click="handleDelete(recipe.id)"
class="flex items-center justify-center px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-red-950 text-zinc-500 hover:text-error transition-colors"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Recipe detail modal -->
<RecipeDetail
v-if="selectedRecipe"
:recipe="selectedRecipe"
@close="selectedRecipe = null"
/>
<!-- Create/Edit modal -->
<Teleport to="body">
<div
v-if="showForm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
@click.self="closeForm"
>
<div class="w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl">
<!-- Form header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-zinc-800 sticky top-0 bg-zinc-900 z-10">
<h2 class="text-lg font-semibold text-zinc-100">
{{ editingId ? 'Rezept bearbeiten' : 'Neues Rezept' }}
</h2>
<button
@click="closeForm"
class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
<!-- Title -->
<div>
<label class="block text-sm font-medium text-zinc-300 mb-1.5">Titel *</label>
<input
v-model="form.title"
required
type="text"
placeholder="Rezeptname"
class="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2.5 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm"
/>
</div>
<!-- Image URL -->
<div>
<label class="block text-sm font-medium text-zinc-300 mb-1.5">Bild-URL</label>
<input
v-model="form.imageUrl"
type="url"
placeholder="https://..."
class="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2.5 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm"
/>
</div>
<!-- Instructions -->
<div>
<label class="block text-sm font-medium text-zinc-300 mb-1.5">Zubereitung</label>
<textarea
v-model="form.instructions"
rows="5"
placeholder="Zubereitungsschritte..."
class="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2.5 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm resize-y"
/>
</div>
<!-- Ingredients -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-zinc-300">Zutaten</label>
<button
type="button"
@click="addIngredient"
class="flex items-center gap-1 text-xs text-accent hover:text-accent-hover transition-colors"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Zutat hinzufügen
</button>
</div>
<div class="space-y-2">
<div
v-for="(ing, idx) in form.ingredients"
:key="idx"
class="flex gap-2 items-start"
>
<input
v-model="ing.name"
type="text"
placeholder="Name"
class="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm"
/>
<input
v-model.number="ing.amount"
type="number"
placeholder="Menge"
min="0"
step="any"
class="w-20 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm"
/>
<input
v-model="ing.unit"
type="text"
placeholder="Einheit"
class="w-20 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm"
/>
<select
v-model="ing.category"
class="w-32 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 focus:outline-none focus:border-accent text-sm"
>
<option value="Gemüse">Gemüse</option>
<option value="Fleisch">Fleisch</option>
<option value="Milchprodukte">Milchprodukte</option>
<option value="Gewürze">Gewürze</option>
<option value="Sonstiges">Sonstiges</option>
</select>
<button
type="button"
@click="removeIngredient(idx)"
class="p-2 text-zinc-500 hover:text-error transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
<!-- Form error -->
<div v-if="formError" class="px-4 py-3 bg-red-950 border border-error rounded-lg text-error text-sm">
{{ formError }}
</div>
<!-- Actions -->
<div class="flex gap-3 pt-2">
<button
type="button"
@click="closeForm"
class="flex-1 py-2.5 rounded-lg border border-zinc-700 text-zinc-300 hover:text-zinc-100 hover:bg-zinc-800 text-sm font-medium transition-colors"
>
Abbrechen
</button>
<button
type="submit"
:disabled="formSaving"
class="flex-1 py-2.5 rounded-lg bg-accent hover:bg-accent-hover disabled:opacity-60 text-white text-sm font-medium transition-colors"
>
{{ formSaving ? 'Speichern...' : 'Speichern' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import RecipeDetail from '../components/RecipeDetail.vue'
import { useRecipesStore } from '../stores/mealPlan'
import type { Recipe, Ingredient } from '../stores/mealPlan'
const store = useRecipesStore()
const selectedRecipe = ref<Recipe | null>(null)
const imgErrors = ref(new Set<number>())
const showForm = ref(false)
const editingId = ref<number | null>(null)
const formSaving = ref(false)
const formError = ref<string | null>(null)
interface FormState {
title: string
imageUrl: string
instructions: string
ingredients: Array<{
name: string
amount: number
unit: string
category: string
}>
}
const form = reactive<FormState>({
title: '',
imageUrl: '',
instructions: '',
ingredients: [],
})
function resetForm(): void {
form.title = ''
form.imageUrl = ''
form.instructions = ''
form.ingredients = []
editingId.value = null
formError.value = null
formSaving.value = false
}
function openCreate(): void {
resetForm()
showForm.value = true
}
function openEdit(recipe: Recipe): void {
resetForm()
editingId.value = recipe.id
form.title = recipe.title
form.imageUrl = recipe.imageUrl ?? ''
form.instructions = recipe.instructions ?? ''
form.ingredients = (recipe.ingredients ?? []).map(i => ({ ...i }))
showForm.value = true
}
function closeForm(): void {
showForm.value = false
resetForm()
}
function addIngredient(): void {
form.ingredients.push({ name: '', amount: 1, unit: 'g', category: 'Sonstiges' })
}
function removeIngredient(idx: number): void {
form.ingredients.splice(idx, 1)
}
async function handleSubmit(): Promise<void> {
formSaving.value = true
formError.value = null
try {
const data: Omit<Recipe, 'id'> = {
title: form.title,
...(form.imageUrl ? { imageUrl: form.imageUrl } : {}),
...(form.instructions ? { instructions: form.instructions } : {}),
ingredients: form.ingredients.filter(i => i.name.trim()) as Ingredient[],
}
if (editingId.value !== null) {
await store.updateRecipe(editingId.value, data)
} else {
await store.createRecipe(data)
}
closeForm()
} catch {
formError.value = 'Fehler beim Speichern des Rezepts.'
} finally {
formSaving.value = false
}
}
async function handleDelete(id: number): Promise<void> {
if (!confirm('Rezept wirklich löschen?')) return
await store.deleteRecipe(id)
}
onMounted(() => {
store.fetchRecipes()
})
</script>

View File

@@ -0,0 +1,103 @@
<template>
<div class="flex flex-col gap-6 max-w-lg">
<!-- Header -->
<h1 class="text-2xl font-bold text-zinc-100">Einstellungen</h1>
<!-- Card -->
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-6 space-y-5">
<div>
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-4">Haushalt</h2>
<div>
<label class="block text-sm font-medium text-zinc-300 mb-1.5">Haushaltsgröße</label>
<p class="text-xs text-zinc-500 mb-3">Anzahl der Personen, für die der Wochenplan geplant wird.</p>
<div class="flex items-center gap-3">
<button
type="button"
@click="decrement"
:disabled="store.householdSize <= 1"
class="w-9 h-9 flex items-center justify-center rounded-lg bg-zinc-800 hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed text-zinc-300 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
</svg>
</button>
<input
v-model.number="store.householdSize"
type="number"
min="1"
max="20"
class="w-20 text-center bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 focus:outline-none focus:border-accent text-sm"
/>
<button
type="button"
@click="increment"
:disabled="store.householdSize >= 20"
class="w-9 h-9 flex items-center justify-center rounded-lg bg-zinc-800 hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed text-zinc-300 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
<span class="text-zinc-500 text-sm">
{{ store.householdSize === 1 ? 'Person' : 'Personen' }}
</span>
</div>
</div>
</div>
<!-- Error -->
<div v-if="store.error" class="px-4 py-3 bg-red-950 border border-error rounded-lg text-error text-sm">
{{ store.error }}
</div>
<!-- Success -->
<Transition name="fade">
<div v-if="store.saved" class="px-4 py-3 bg-accent-dim border border-accent rounded-lg text-success text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Einstellungen gespeichert.
</div>
</Transition>
<button
@click="store.saveSettings"
:disabled="store.saving"
class="w-full py-2.5 rounded-lg bg-accent hover:bg-accent-hover disabled:opacity-60 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
>
{{ store.saving ? 'Speichern...' : 'Speichern' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useSettingsStore } from '../stores/mealPlan'
const store = useSettingsStore()
function decrement(): void {
if (store.householdSize > 1) store.householdSize--
}
function increment(): void {
if (store.householdSize < 20) store.householdSize++
}
onMounted(() => {
store.fetchSettings()
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div class="flex flex-col gap-6">
<!-- Header -->
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-zinc-100">Einkaufsliste</h1>
<span
v-if="unchecked > 0"
class="inline-flex items-center justify-center min-w-6 h-6 px-2 rounded-full bg-accent text-white text-xs font-bold"
>{{ unchecked }}</span>
</div>
<!-- Error -->
<div v-if="store.error" class="px-4 py-3 bg-red-950 border border-error rounded-lg text-error text-sm">
{{ store.error }}
</div>
<!-- Loading -->
<div v-if="store.loading" class="space-y-4">
<div v-for="i in 3" :key="i" class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 animate-pulse space-y-3">
<div class="h-4 bg-zinc-800 rounded w-1/4" />
<div v-for="j in 4" :key="j" class="h-3 bg-zinc-800/60 rounded" />
</div>
</div>
<!-- Empty state -->
<div v-else-if="!store.items.length" class="flex flex-col items-center justify-center py-24 gap-4">
<div class="p-6 bg-zinc-900 rounded-full border border-zinc-800">
<svg class="w-12 h-12 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div class="text-center">
<p class="text-zinc-300 text-lg font-medium">Keine Einkaufsliste vorhanden</p>
<p class="text-zinc-500 text-sm mt-1">Erstelle zuerst einen Wochenplan.</p>
</div>
</div>
<!-- Grouped items -->
<div v-else class="space-y-3">
<!-- Progress bar -->
<div class="bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-zinc-400">Fortschritt</span>
<span class="text-sm text-zinc-400">{{ checked }} / {{ store.items.length }}</span>
</div>
<div class="w-full h-2 bg-zinc-800 rounded-full overflow-hidden">
<div
class="h-full bg-accent rounded-full transition-all duration-300"
:style="{ width: `${progress}%` }"
/>
</div>
</div>
<div
v-for="(groupItems, category) in grouped"
:key="category"
class="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden"
>
<!-- Category header -->
<button
class="w-full flex items-center justify-between px-4 py-3 border-b border-zinc-800 hover:bg-zinc-800/40 transition-colors"
@click="toggleGroup(String(category))"
>
<div class="flex items-center gap-2">
<span class="font-medium text-zinc-200 text-sm">{{ category }}</span>
<span class="text-xs text-zinc-500">
({{ groupItems.filter(i => !i.isChecked).length }} verbleibend)
</span>
</div>
<!-- Chevron -->
<svg
class="w-4 h-4 text-zinc-500 transition-transform"
:class="{ 'rotate-180': !collapsed.has(String(category)) }"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Items -->
<div v-if="!collapsed.has(String(category))" class="px-2 py-1">
<ShoppingItem
v-for="item in groupItems"
:key="item.id"
:item="item"
@toggle="store.toggleItem"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import ShoppingItem from '../components/ShoppingItem.vue'
import { useShoppingStore } from '../stores/mealPlan'
const route = useRoute()
const store = useShoppingStore()
const collapsed = ref<Set<string>>(new Set())
function toggleGroup(cat: string): void {
if (collapsed.value.has(cat)) {
collapsed.value.delete(cat)
} else {
collapsed.value.add(cat)
}
}
const CATEGORY_ORDER = ['Gemüse', 'Fleisch', 'Milchprodukte', 'Gewürze', 'Sonstiges']
const grouped = computed(() => {
const result: Record<string, typeof store.items> = {}
for (const item of store.items) {
const cat = item.category || 'Sonstiges'
if (!result[cat]) result[cat] = []
result[cat]!.push(item)
}
// Sort by defined order, unknown categories go last
const sorted: Record<string, typeof store.items> = {}
const allCats = Object.keys(result).sort((a, b) => {
const ai = CATEGORY_ORDER.indexOf(a)
const bi = CATEGORY_ORDER.indexOf(b)
if (ai === -1 && bi === -1) return a.localeCompare(b)
if (ai === -1) return 1
if (bi === -1) return -1
return ai - bi
})
for (const cat of allCats) {
sorted[cat] = result[cat]!
}
return sorted
})
const checked = computed(() => store.items.filter(i => i.isChecked).length)
const unchecked = computed(() => store.items.filter(i => !i.isChecked).length)
const progress = computed(() => store.items.length ? Math.round((checked.value / store.items.length) * 100) : 0)
onMounted(() => {
const id = route.params['mealPlanId']
store.fetchItems(Array.isArray(id) ? id[0] : id)
})
</script>

View File

@@ -0,0 +1,164 @@
<template>
<div class="flex flex-col gap-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-zinc-100">Wochenplan</h1>
<p v-if="store.currentPlan" class="text-zinc-500 text-sm mt-0.5">
{{ formatWeek(store.currentPlan.weekStartDate) }}
</p>
</div>
<button
@click="handleGenerate"
:disabled="store.generating"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover disabled:opacity-60 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
>
<svg v-if="store.generating" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" stroke-width="2" />
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
<circle cx="15.5" cy="8.5" r="1.5" fill="currentColor" />
<circle cx="8.5" cy="15.5" r="1.5" fill="currentColor" />
<circle cx="15.5" cy="15.5" r="1.5" fill="currentColor" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
</svg>
{{ store.generating ? 'Generiere...' : 'Neue Woche generieren' }}
</button>
</div>
<!-- Error -->
<div v-if="store.error" class="px-4 py-3 bg-red-950 border border-error rounded-lg text-error text-sm">
{{ store.error }}
</div>
<!-- Loading skeleton -->
<div v-if="store.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div
v-for="i in 7"
:key="i"
class="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden animate-pulse"
>
<div class="h-8 bg-zinc-800" />
<div class="h-40 bg-zinc-800/60" />
<div class="p-4 space-y-2">
<div class="h-4 bg-zinc-800 rounded w-3/4" />
<div class="h-3 bg-zinc-800 rounded w-1/2" />
</div>
<div class="px-4 py-3 border-t border-zinc-800 flex gap-2">
<div class="flex-1 h-8 bg-zinc-800 rounded-lg" />
<div class="flex-1 h-8 bg-zinc-800 rounded-lg" />
</div>
</div>
</div>
<!-- Meal grid -->
<div
v-else-if="store.currentPlan?.entries?.length"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
>
<MealCard
v-for="entry in sortedEntries"
:key="entry.id"
:entry="entry"
:day-name="getDayName(entry.dayOfWeek)"
@swap="openSwapModal(entry)"
@reroll="handleReroll(entry)"
/>
</div>
<!-- Empty state -->
<div v-else-if="!store.loading" class="flex flex-col items-center justify-center py-24 gap-6">
<div class="p-6 bg-zinc-900 rounded-full border border-zinc-800">
<svg class="w-12 h-12 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div class="text-center">
<p class="text-zinc-300 text-lg font-medium">Noch kein Plan erstellt</p>
<p class="text-zinc-500 text-sm mt-1">Generiere einen Wochenplan mit zufälligen Rezepten.</p>
</div>
<button
@click="handleGenerate"
:disabled="store.generating"
class="flex items-center gap-2 px-6 py-3 rounded-lg bg-accent hover:bg-accent-hover disabled:opacity-60 text-white font-medium transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" stroke-width="2" />
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
<circle cx="15.5" cy="8.5" r="1.5" fill="currentColor" />
<circle cx="8.5" cy="15.5" r="1.5" fill="currentColor" />
<circle cx="15.5" cy="15.5" r="1.5" fill="currentColor" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
</svg>
Jetzt generieren
</button>
</div>
</div>
<!-- Swap modal -->
<SwapModal
v-if="swapEntry && recipesStore.recipes.length"
:recipes="recipesStore.recipes"
@close="swapEntry = null"
@select="handleSwapSelect"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import MealCard from '../components/MealCard.vue'
import SwapModal from '../components/SwapModal.vue'
import { useMealPlanStore, useRecipesStore } from '../stores/mealPlan'
import type { MealPlanEntry } from '../stores/mealPlan'
const store = useMealPlanStore()
const recipesStore = useRecipesStore()
const swapEntry = ref<MealPlanEntry | null>(null)
const DAY_NAMES = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']
function getDayName(dayOfWeek: number): string {
return DAY_NAMES[dayOfWeek] ?? `Tag ${dayOfWeek}`
}
const sortedEntries = computed(() => {
return [...(store.currentPlan?.entries ?? [])].sort((a, b) => a.dayOfWeek - b.dayOfWeek)
})
function formatWeek(dateStr: string): string {
const d = new Date(dateStr)
const end = new Date(d)
end.setDate(end.getDate() + 6)
const fmt = (dt: Date) => dt.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
return `${fmt(d)} ${fmt(end)}.${end.getFullYear()}`
}
async function handleGenerate(): Promise<void> {
await store.generatePlan()
}
function openSwapModal(entry: MealPlanEntry): void {
swapEntry.value = entry
if (!recipesStore.recipes.length) {
recipesStore.fetchRecipes()
}
}
async function handleSwapSelect(recipeId: number): Promise<void> {
if (!swapEntry.value) return
await store.swapMeal(swapEntry.value.id, recipeId)
swapEntry.value = null
}
async function handleReroll(entry: MealPlanEntry): Promise<void> {
await store.rerollMeal(entry.id)
}
onMounted(() => {
store.fetchCurrentPlan()
})
</script>