- Add GermanTranslator service with 200+ ingredient and meal name mappings - Translate titles and ingredients when fetching from TheMealDB - Add click-to-view recipe detail modal on week plan meal cards - Import RecipeDetail component in WeekPlanView Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
176 lines
6.5 KiB
Vue
176 lines
6.5 KiB
Vue
<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.weekStart) }}
|
||
</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.date)"
|
||
@swap="openSwapModal(entry)"
|
||
@reroll="handleReroll(entry)"
|
||
@click="selectedRecipe = entry.recipe"
|
||
/>
|
||
</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>
|
||
|
||
<!-- Recipe detail modal -->
|
||
<RecipeDetail
|
||
v-if="selectedRecipe"
|
||
:recipe="selectedRecipe"
|
||
@close="selectedRecipe = null"
|
||
/>
|
||
|
||
<!-- 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 RecipeDetail from '../components/RecipeDetail.vue'
|
||
import SwapModal from '../components/SwapModal.vue'
|
||
import { useMealPlanStore, useRecipesStore } from '../stores/mealPlan'
|
||
import type { MealPlanEntry, Recipe } from '../stores/mealPlan'
|
||
|
||
const store = useMealPlanStore()
|
||
const recipesStore = useRecipesStore()
|
||
|
||
const swapEntry = ref<MealPlanEntry | null>(null)
|
||
const selectedRecipe = ref<Recipe | null>(null)
|
||
|
||
const DAY_NAMES = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']
|
||
|
||
function getDayName(dateStr: string): string {
|
||
const d = new Date(dateStr + 'T00:00:00')
|
||
return DAY_NAMES[d.getDay()] ?? dateStr
|
||
}
|
||
|
||
const sortedEntries = computed(() => {
|
||
return [...(store.currentPlan?.entries ?? [])].sort((a, b) => a.date.localeCompare(b.date))
|
||
})
|
||
|
||
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: string): 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>
|