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:
164
frontend/src/views/WeekPlanView.vue
Normal file
164
frontend/src/views/WeekPlanView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user