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

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>