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,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 }
})