added implementation frontend by prompt @ 2026-02-14 05:35:18 #CODEX

This commit is contained in:
2026-02-14 07:13:06 +01:00
parent 92086055dc
commit 3010a66d59
32 changed files with 2024 additions and 39 deletions

View File

@ -19,11 +19,11 @@ Ciele:
Pre položku s grams: macro = grams/100 * macro_100.
kcal = protein*4 + carbs*4 + fat*9. (Sugar je podmnožina carbs, neráta sa zvlášť do kcal.)
5) Navrhni štruktúru projektu:
- /src/router
- /src/views
- /src/components
- /src/stores (Pinia)
- /src/utils (nutrition math)
- frontend/src/router
- frontend/src/views
- frontend/src/components
- frontend/src/stores (Pinia)
- frontend/src/utils (nutrition math)
6) Daj konkrétny návrh názvov súborov a exportov + ukážku kódu:
- router index.ts s routes + guard
- auth store (token/user)
@ -34,6 +34,6 @@ Preferencie:
- Vue 3 + Composition API + TypeScript
- Pinia
- Vue Router
- UI môže byť čisté bez knižnice, alebo minimalisticky (napr. jednoduché CSS/Tailwind rozhodni a drž konzistentne).
- UI môže byť čisté bez knižnice, alebo minimalisticky (napr. jednoduché CSS rozhodni a drž konzistentne a pokracuj v pouzivani suboru frontend/src/assets/css/style.css).
- Použi slovenské názvy v UI (Raňajky, Obed, Večera), ale kľúče v kóde nech sú anglické (breakfast/lunch/dinner).
Výstup: konkrétny návrh + ukážky kódu, nie všeobecné rady.

View File

@ -11,6 +11,7 @@
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/vue-fontawesome": "^3.1.3",
"pinia": "^3.0.4",
"vue": "^3.5.27",
"vue-i18n": "^11.2.8",
"vue-router": "^5.0.1"
@ -4251,6 +4252,66 @@
"node": ">=0.10"
}
},
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/pinia/node_modules/@vue/devtools-kit": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/pinia/node_modules/@vue/devtools-shared": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/pinia/node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",

View File

@ -18,6 +18,7 @@
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/vue-fontawesome": "^3.1.3",
"pinia": "^3.0.4",
"vue": "^3.5.27",
"vue-i18n": "^11.2.8",
"vue-router": "^5.0.1"

View File

@ -325,3 +325,350 @@ select {
justify-content: center;
}
}
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 250px 1fr;
}
.app-sidebar {
background: var(--color-surface);
border-right: 1px solid var(--color-border);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.app-sidebar__brand {
display: flex;
align-items: center;
gap: var(--space-sm);
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
font-size: 1.1rem;
}
.app-sidebar__logo {
width: 36px;
height: 36px;
}
.app-sidebar__nav {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.app-nav-link {
text-decoration: none;
color: var(--color-text);
padding: 0.62rem 0.8rem;
border-radius: 0.7rem;
border: 1px solid transparent;
transition: all var(--transition-fast);
}
.app-nav-link.router-link-active {
border-color: var(--color-green);
background: color-mix(in srgb, var(--color-green) 16%, transparent);
}
.app-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.app-topbar {
position: sticky;
top: 0;
z-index: 2;
background: color-mix(in srgb, var(--color-bg) 84%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--color-border);
padding: var(--space-md) var(--space-xl);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.app-topbar h1 {
margin: 0;
font-size: 1.25rem;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
}
.app-topbar__user {
font-size: var(--fs-sm);
color: var(--color-muted);
}
.app-content {
padding: var(--space-lg) var(--space-xl);
}
.page {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.page-header {
display: flex;
gap: var(--space-md);
align-items: center;
}
.page-header--split {
justify-content: space-between;
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
padding: var(--space-lg);
}
.grid-three {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-md);
}
.grid-two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-md);
}
.filter-control {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.filter-control span {
font-size: var(--fs-sm);
color: var(--color-muted);
}
.input-text,
.input-number,
.input-select,
.input-date {
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
padding: 0.64rem 0.72rem;
}
.btn {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
border-radius: var(--radius-md);
padding: 0.62rem 0.86rem;
cursor: pointer;
}
.btn-primary {
border-color: var(--color-green);
background: var(--color-green);
color: var(--color-white);
}
.btn-danger {
border-color: color-mix(in srgb, var(--color-error) 24%, var(--color-border));
color: var(--color-error);
}
.form-inline {
display: grid;
grid-template-columns: 1.3fr 0.9fr auto;
gap: var(--space-sm);
}
.form-actions {
margin-top: var(--space-md);
display: flex;
gap: var(--space-sm);
}
.list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.list-row {
display: flex;
justify-content: space-between;
gap: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.72rem 0.82rem;
color: inherit;
text-decoration: none;
}
.list-row p {
margin: 0.3rem 0 0;
font-size: var(--fs-sm);
color: var(--color-muted);
}
.list-row__meta {
align-self: center;
color: var(--color-muted);
font-size: var(--fs-sm);
}
.list-row__actions {
display: flex;
gap: var(--space-xs);
}
.empty-state {
margin: 0;
color: var(--color-muted);
}
.day-meal-card__header h3,
.totals-card h3 {
margin-top: 0;
}
.day-meal-card__summary {
margin: var(--space-sm) 0 0;
font-size: var(--fs-sm);
color: var(--color-muted);
}
.totals-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-sm);
}
.totals-grid span {
display: block;
color: var(--color-muted);
font-size: var(--fs-sm);
}
.totals-grid strong {
font-size: 1.1rem;
}
.meal-items-editor h3,
.ingredient-form h3 {
margin-top: 0;
}
.meal-items-editor__add-row {
display: grid;
grid-template-columns: 1fr 140px auto;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
text-align: left;
border-bottom: 1px solid var(--color-border);
padding: 0.55rem 0.2rem;
}
.ingredient-form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.ingredient-form label span {
font-size: var(--fs-sm);
color: var(--color-muted);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.app-bottom-tabs {
display: none;
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
padding-bottom: 62px;
}
.desktop-nav {
display: none;
}
.app-content {
padding: var(--space-md);
}
.grid-three {
grid-template-columns: 1fr;
}
.totals-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-two {
grid-template-columns: 1fr;
}
.form-inline {
grid-template-columns: 1fr;
}
.meal-items-editor__add-row {
grid-template-columns: 1fr;
}
.app-bottom-tabs {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
z-index: 4;
}
.app-bottom-tabs__link {
text-align: center;
text-decoration: none;
color: var(--color-muted);
padding: 0.62rem 0.2rem;
font-size: 0.76rem;
}
.app-bottom-tabs__link.router-link-active {
color: var(--color-green);
font-weight: 700;
}
}

View File

@ -0,0 +1,96 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import type { Ingredient } from '@/types/domain'
const props = defineProps<{
initial?: Ingredient | null
submitLabel?: string
}>()
const emit = defineEmits<{
(event: 'save', payload: {
name: string
protein_g_100: number
carbs_g_100: number
sugar_g_100: number
fat_g_100: number
fiber_g_100: number
kcal_100: number
}): void
(event: 'cancel'): void
}>()
const form = reactive({
name: '',
protein_g_100: 0,
carbs_g_100: 0,
sugar_g_100: 0,
fat_g_100: 0,
fiber_g_100: 0,
kcal_100: 0,
})
const fillFromInitial = () => {
form.name = props.initial?.name ?? ''
form.protein_g_100 = props.initial?.protein_g_100 ?? 0
form.carbs_g_100 = props.initial?.carbs_g_100 ?? 0
form.sugar_g_100 = props.initial?.sugar_g_100 ?? 0
form.fat_g_100 = props.initial?.fat_g_100 ?? 0
form.fiber_g_100 = props.initial?.fiber_g_100 ?? 0
form.kcal_100 = props.initial?.kcal_100 ?? 0
}
watch(() => props.initial, fillFromInitial, { immediate: true })
const onSubmit = () => {
emit('save', {
name: form.name.trim(),
protein_g_100: Number(form.protein_g_100),
carbs_g_100: Number(form.carbs_g_100),
sugar_g_100: Number(form.sugar_g_100),
fat_g_100: Number(form.fat_g_100),
fiber_g_100: Number(form.fiber_g_100),
kcal_100: Number(form.kcal_100),
})
}
</script>
<template>
<form class="card ingredient-form" @submit.prevent="onSubmit">
<h3>{{ props.initial ? 'Upraviť surovinu' : 'Nová surovina' }}</h3>
<div class="grid-two">
<label>
<span>Názov</span>
<input v-model="form.name" class="input-text" type="text" required />
</label>
<label>
<span>Protein / 100 g</span>
<input v-model.number="form.protein_g_100" class="input-number" type="number" min="0" step="0.01" required />
</label>
<label>
<span>Carbs / 100 g</span>
<input v-model.number="form.carbs_g_100" class="input-number" type="number" min="0" step="0.01" required />
</label>
<label>
<span>Sugar / 100 g</span>
<input v-model.number="form.sugar_g_100" class="input-number" type="number" min="0" step="0.01" required />
</label>
<label>
<span>Fat / 100 g</span>
<input v-model.number="form.fat_g_100" class="input-number" type="number" min="0" step="0.01" required />
</label>
<label>
<span>Fiber / 100 g</span>
<input v-model.number="form.fiber_g_100" class="input-number" type="number" min="0" step="0.01" required />
</label>
<label>
<span>Kcal / 100 g (0 = auto)</span>
<input v-model.number="form.kcal_100" class="input-number" type="number" min="0" step="0.01" required />
</label>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">{{ props.submitLabel ?? 'Uložiť' }}</button>
<button class="btn" type="button" @click="emit('cancel')">Zrušiť</button>
</div>
</form>
</template>

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Ingredient, MealItem } from '@/types/domain'
const props = defineProps<{
items: MealItem[]
ingredients: Ingredient[]
}>()
const emit = defineEmits<{
(event: 'add-item', payload: { ingredient_id: number; grams: number }): void
(event: 'update-item', payload: { meal_item_id: number; ingredient_id: number; grams: number; position: number }): void
(event: 'remove-item', mealItemId: number): void
}>()
const addIngredientId = ref<number | null>(null)
const addGrams = ref<number>(100)
const updateIngredient = (item: MealItem, ingredientId: number) => {
emit('update-item', {
meal_item_id: item.meal_item_id,
ingredient_id: ingredientId,
grams: item.grams,
position: item.position,
})
}
const updateGrams = (item: MealItem, grams: number) => {
emit('update-item', {
meal_item_id: item.meal_item_id,
ingredient_id: item.ingredient_id,
grams,
position: item.position,
})
}
const addItem = () => {
if (!addIngredientId.value || addGrams.value <= 0) {
return
}
emit('add-item', {
ingredient_id: addIngredientId.value,
grams: addGrams.value,
})
addGrams.value = 100
}
</script>
<template>
<section class="card meal-items-editor">
<h3>Položky jedálnička</h3>
<div class="meal-items-editor__add-row">
<select v-model.number="addIngredientId" class="input-select">
<option :value="null">Vyber surovinu</option>
<option v-for="ingredient in props.ingredients" :key="ingredient.ingredient_id" :value="ingredient.ingredient_id">
{{ ingredient.name }}
</option>
</select>
<input v-model.number="addGrams" type="number" min="1" class="input-number" />
<button class="btn btn-primary" type="button" @click="addItem">Pridať</button>
</div>
<table class="table" v-if="props.items.length > 0">
<thead>
<tr>
<th>Surovina</th>
<th>Gramáž</th>
<th>Akcia</th>
</tr>
</thead>
<tbody>
<tr v-for="item in props.items" :key="item.meal_item_id">
<td>
<select
class="input-select"
:value="item.ingredient_id"
@change="updateIngredient(item, Number(($event.target as HTMLSelectElement).value))"
>
<option
v-for="ingredient in props.ingredients"
:key="ingredient.ingredient_id"
:value="ingredient.ingredient_id"
>
{{ ingredient.name }}
</option>
</select>
</td>
<td>
<input
class="input-number"
type="number"
min="1"
:value="item.grams"
@change="updateGrams(item, Number(($event.target as HTMLInputElement).value))"
/>
</td>
<td>
<button class="btn btn-danger" type="button" @click="emit('remove-item', item.meal_item_id)">
Zmazať
</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-state">Tento jedálniček zatiaľ nemá položky.</p>
</section>
</template>

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import type { MealType } from '@/types/domain'
const props = defineProps<{
modelValue: MealType | ''
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: MealType | ''): void
}>()
</script>
<template>
<label class="filter-control">
<span>Typ jedla</span>
<select
class="input-select"
:value="props.modelValue"
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value as MealType | '')"
>
<option value="">Všetky</option>
<option value="breakfast">Raňajky</option>
<option value="lunch">Obed</option>
<option value="dinner">Večera</option>
</select>
</label>
</template>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
const tabs = [
{ to: { name: 'today' }, label: 'Dnes' },
{ to: { name: 'meals' }, label: 'Jedlá' },
{ to: { name: 'ingredients' }, label: 'Suroviny' },
{ to: { name: 'stats' }, label: 'Stats' },
{ to: { name: 'settings' }, label: 'Viac' },
]
</script>
<template>
<nav class="app-bottom-tabs">
<RouterLink
v-for="tab in tabs"
:key="tab.label"
:to="tab.to"
class="app-bottom-tabs__link"
>
{{ tab.label }}
</RouterLink>
</nav>
</template>

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
const navItems = [
{ to: { name: 'today' }, label: 'Dnes' },
{ to: { name: 'meals' }, label: 'Jedálničky' },
{ to: { name: 'ingredients' }, label: 'Suroviny' },
{ to: { name: 'stats' }, label: 'Štatistiky' },
{ to: { name: 'settings' }, label: 'Nastavenia' },
]
</script>
<template>
<aside class="app-sidebar">
<div class="app-sidebar__brand">
<img src="/Nutrio.png" alt="Nutrio" class="app-sidebar__logo" />
<strong>Nutrio</strong>
</div>
<nav class="app-sidebar__nav">
<RouterLink
v-for="item in navItems"
:key="item.label"
:to="item.to"
class="app-nav-link"
>
{{ item.label }}
</RouterLink>
</nav>
</aside>
</template>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const auth = useAuthStore()
const title = computed(() => {
switch (route.name) {
case 'today':
return 'Denný prehľad'
case 'meals':
return 'Jedálničky'
case 'meal-detail':
return 'Detail jedálnička'
case 'ingredients':
return 'Suroviny'
case 'stats':
return 'Štatistiky'
case 'settings':
return 'Nastavenia'
default:
return 'Nutrio'
}
})
</script>
<template>
<header class="app-topbar">
<div>
<h1>{{ title }}</h1>
</div>
<div class="app-topbar__user" v-if="auth.userEmail">
{{ auth.userEmail }}
</div>
</header>
</template>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Meal, MealType } from '@/types/domain'
const props = defineProps<{
mealType: MealType
label: string
mealOptions: Array<{ value: number; label: string }>
selectedMealId: number | null
meal: Meal | null
}>()
const emit = defineEmits<{
(event: 'select-meal', mealType: MealType, mealId: number | null): void
}>()
const selectedValue = computed({
get: () => (props.selectedMealId === null ? '' : String(props.selectedMealId)),
set: (value: string) => {
if (value.length <= 0) {
emit('select-meal', props.mealType, null)
return
}
emit('select-meal', props.mealType, Number(value))
},
})
</script>
<template>
<section class="card day-meal-card">
<div class="day-meal-card__header">
<h3>{{ label }}</h3>
</div>
<select v-model="selectedValue" class="input-select">
<option value="">Bez jedálnička</option>
<option v-for="option in mealOptions" :key="option.value" :value="String(option.value)">
{{ option.label }}
</option>
</select>
<p class="day-meal-card__summary" v-if="meal?.totals">
{{ meal.totals.kcal }} kcal · B {{ meal.totals.protein_g }} g · S {{ meal.totals.carbs_g }} g · T {{ meal.totals.fat_g }} g
</p>
<p class="day-meal-card__summary" v-else>
Zatiaľ bez položiek.
</p>
</section>
</template>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { NutritionTotals } from '@/types/domain'
defineProps<{
totals: NutritionTotals
}>()
</script>
<template>
<section class="card totals-card">
<h3>Súčty dňa</h3>
<div class="totals-grid">
<div>
<span>Kcal</span>
<strong>{{ totals.kcal }}</strong>
</div>
<div>
<span>Bielkoviny</span>
<strong>{{ totals.protein_g }} g</strong>
</div>
<div>
<span>Sacharidy</span>
<strong>{{ totals.carbs_g }} g</strong>
</div>
<div>
<span>Tuky</span>
<strong>{{ totals.fat_g }} g</strong>
</div>
</div>
</section>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div class="card" aria-hidden="true">
<p>MealPickerModal placeholder</p>
</div>
</template>

View File

@ -4,10 +4,12 @@ import './assets/css/style.css'
import App from './App.vue'
import i18n from './i18n'
import router from './router'
import { pinia } from './stores'
const app = createApp(App)
app.component('font-awesome-icon', FontAwesomeIcon)
app.use(pinia)
app.use(router)
app.use(i18n)

View File

@ -1,15 +1,57 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import AuthView from '@/views/AuthView.vue'
import AppLayout from '@/views/AppLayout.vue'
import TodayView from '@/views/TodayView.vue'
import MealsView from '@/views/MealsView.vue'
import MealDetailView from '@/views/MealDetailView.vue'
import IngredientsView from '@/views/IngredientsView.vue'
import StatsView from '@/views/StatsView.vue'
import SettingsView from '@/views/SettingsView.vue'
import { pinia } from '@/stores'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'auth',
component: AuthView,
meta: { guestOnly: true },
},
{
path: '/app',
component: AppLayout,
meta: { requiresAuth: true },
children: [
{ path: '', redirect: { name: 'today' } },
{ path: 'today/:date(\\d{4}-\\d{2}-\\d{2})?', name: 'today', component: TodayView },
{ path: 'meals', name: 'meals', component: MealsView },
{ path: 'meals/:mealId(\\d+)', name: 'meal-detail', component: MealDetailView, props: true },
{ path: 'ingredients', name: 'ingredients', component: IngredientsView },
{ path: 'stats', name: 'stats', component: StatsView },
{ path: 'settings', name: 'settings', component: SettingsView },
],
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'auth',
component: AuthView,
},
],
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
router.beforeEach((to) => {
const auth = useAuthStore(pinia)
const requiresAuth = to.matched.some((route) => route.meta.requiresAuth === true)
const guestOnly = to.matched.some((route) => route.meta.guestOnly === true)
if (requiresAuth && !auth.isAuthenticated) {
return { name: 'auth', query: { redirect: to.fullPath } }
}
if (guestOnly && auth.isAuthenticated) {
return { name: 'today' }
}
return true
})
export default router

View File

@ -0,0 +1,73 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import BackendAPI from '@/BackendAPI.ts'
import { unwrapApiData } from '@/utils/api'
type LoginPayload = {
auto_registered?: boolean
user?: {
email?: string | null
token?: string | null
}
}
const TOKEN_KEY = 'token'
const USER_EMAIL_KEY = 'user_email'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
const userEmail = ref<string | null>(localStorage.getItem(USER_EMAIL_KEY))
const isAuthenticated = computed(() => typeof token.value === 'string' && token.value.length > 0)
const setSession = (nextToken: string, nextUserEmail: string) => {
token.value = nextToken
userEmail.value = nextUserEmail
localStorage.setItem(TOKEN_KEY, nextToken)
localStorage.setItem(USER_EMAIL_KEY, nextUserEmail)
}
const clearSession = () => {
token.value = null
userEmail.value = null
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_EMAIL_KEY)
}
const login = async (email: string, password: string): Promise<{ autoRegistered: boolean }> => {
const payload = unwrapApiData<LoginPayload>(await BackendAPI.userLogin(email, password))
const nextToken = payload.user?.token ?? null
const nextUserEmail = payload.user?.email ?? email
if (!nextToken) {
throw new Error('Token missing in login response')
}
setSession(nextToken, nextUserEmail)
return {
autoRegistered: payload.auto_registered === true,
}
}
const logout = async () => {
if (token.value) {
try {
await BackendAPI.userLogout(token.value)
} catch {
// Keep local logout flow even if API call fails.
}
}
clearSession()
}
return {
token,
userEmail,
isAuthenticated,
setSession,
clearSession,
login,
logout,
}
})

View File

@ -0,0 +1,127 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import BackendAPI from '@/BackendAPI.ts'
import { useAuthStore } from '@/stores/auth'
import { useMealsStore } from '@/stores/meals'
import type { DayMealsByType, DiaryDay, MealType } from '@/types/domain'
import { MEAL_TYPES } from '@/types/domain'
import { computeDayTotals, emptyTotals } from '@/utils/nutrition'
import { unwrapApiData } from '@/utils/api'
const createEmptyDay = (date: string): DiaryDay => ({
user_id: 0,
day_date: date,
diary_day_id: null,
entries: [],
totals: emptyTotals(),
})
export const useDiaryStore = defineStore('diary', () => {
const auth = useAuthStore()
const mealsStore = useMealsStore()
const currentDay = ref<DiaryDay | null>(null)
const loading = ref(false)
const requireToken = (): string => {
if (!auth.token) {
throw new Error('User is not authenticated')
}
return auth.token
}
const mealsByType = computed<DayMealsByType>(() => {
const ret: DayMealsByType = {
breakfast: null,
lunch: null,
dinner: null,
}
for (const entry of currentDay.value?.entries ?? []) {
const cachedMeal = mealsStore.getMealFromCache(entry.meal_id)
ret[entry.meal_type] = cachedMeal ?? entry.meal ?? null
}
return ret
})
const computedTotals = computed(() => computeDayTotals(mealsByType.value))
const hydrateDayMeals = async (day: DiaryDay) => {
const loadTasks: Promise<unknown>[] = []
for (const entry of day.entries) {
loadTasks.push(mealsStore.ensureMeal(entry.meal_id))
}
if (loadTasks.length > 0) {
await Promise.all(loadTasks)
}
}
const setCurrentDay = async (day: DiaryDay) => {
currentDay.value = day
await hydrateDayMeals(day)
}
const loadDay = async (date: string) => {
loading.value = true
try {
const token = requireToken()
const payload = unwrapApiData<DiaryDay>(await BackendAPI.diaryDayGet(token, date, true))
await setCurrentDay(payload)
} finally {
loading.value = false
}
}
const setMealForType = async (date: string, mealType: MealType, mealId: number) => {
const token = requireToken()
const payload = unwrapApiData<DiaryDay>(await BackendAPI.diaryDaySetMeal(token, date, mealType, mealId))
await setCurrentDay(payload)
}
const unsetMealForType = async (date: string, mealType: MealType) => {
const token = requireToken()
const payload = unwrapApiData<DiaryDay>(await BackendAPI.diaryDayUnsetMeal(token, date, mealType))
await setCurrentDay(payload)
}
const selectedMealId = (mealType: MealType): number | null => {
const entry = currentDay.value?.entries.find((item) => item.meal_type === mealType)
return entry?.meal_id ?? null
}
const ensureCurrentDay = (date: string) => {
if (!currentDay.value || currentDay.value.day_date !== date) {
currentDay.value = createEmptyDay(date)
}
}
const selectedMealOptions = computed(() => {
const map: Record<MealType, { value: number; label: string }[]> = {
breakfast: [],
lunch: [],
dinner: [],
}
for (const type of MEAL_TYPES) {
map[type] = mealsStore.sortedMeals
.filter((meal) => meal.meal_type === type)
.map((meal) => ({ value: meal.meal_id, label: meal.name }))
}
return map
})
return {
currentDay,
loading,
mealsByType,
computedTotals,
selectedMealOptions,
selectedMealId,
ensureCurrentDay,
loadDay,
setMealForType,
unsetMealForType,
}
})

View File

@ -0,0 +1,3 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()

View File

@ -0,0 +1,100 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import BackendAPI from '@/BackendAPI.ts'
import { useAuthStore } from '@/stores/auth'
import type { Ingredient } from '@/types/domain'
import { unwrapApiData } from '@/utils/api'
export type IngredientInput = {
name: string
protein_g_100: number
carbs_g_100: number
sugar_g_100: number
fat_g_100: number
fiber_g_100: number
kcal_100: number
}
export const useIngredientsStore = defineStore('ingredients', () => {
const auth = useAuthStore()
const items = ref<Ingredient[]>([])
const loading = ref(false)
const sortedItems = computed(() => {
return [...items.value].sort((a, b) => a.name.localeCompare(b.name, 'sk'))
})
const requireToken = (): string => {
if (!auth.token) {
throw new Error('User is not authenticated')
}
return auth.token
}
const loadIngredients = async (query = '', includeGlobal = true) => {
loading.value = true
try {
const token = requireToken()
const data = unwrapApiData<Ingredient[]>(
await BackendAPI.ingredientList(token, query, includeGlobal),
)
items.value = Array.isArray(data) ? data : []
} finally {
loading.value = false
}
}
const createIngredient = async (payload: IngredientInput) => {
const token = requireToken()
const created = unwrapApiData<Ingredient>(
await BackendAPI.ingredientCreate(
token,
payload.name,
payload.protein_g_100,
payload.carbs_g_100,
payload.sugar_g_100,
payload.fat_g_100,
payload.fiber_g_100,
payload.kcal_100,
),
)
items.value = [...items.value, created]
return created
}
const updateIngredient = async (ingredientId: number, payload: IngredientInput) => {
const token = requireToken()
const updated = unwrapApiData<Ingredient>(
await BackendAPI.ingredientUpdate(
token,
ingredientId,
payload.name,
payload.protein_g_100,
payload.carbs_g_100,
payload.sugar_g_100,
payload.fat_g_100,
payload.fiber_g_100,
payload.kcal_100,
),
)
items.value = items.value.map((item) => (item.ingredient_id === ingredientId ? updated : item))
return updated
}
const deleteIngredient = async (ingredientId: number) => {
const token = requireToken()
await BackendAPI.ingredientDelete(token, ingredientId)
items.value = items.value.filter((item) => item.ingredient_id !== ingredientId)
}
return {
items,
sortedItems,
loading,
loadIngredients,
createIngredient,
updateIngredient,
deleteIngredient,
}
})

View File

@ -0,0 +1,164 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import BackendAPI from '@/BackendAPI.ts'
import { useAuthStore } from '@/stores/auth'
import type { Meal, MealItem, MealType } from '@/types/domain'
import { withComputedMealTotals } from '@/utils/nutrition'
import { unwrapApiData } from '@/utils/api'
export type MealInput = {
name: string
meal_type: MealType
}
export type MealItemInput = {
ingredient_id: number
grams: number
position: number
}
type MealItemListResponse = {
meal_id: number
items: MealItem[]
}
export const useMealsStore = defineStore('meals', () => {
const auth = useAuthStore()
const list = ref<Meal[]>([])
const mealById = ref<Record<number, Meal>>({})
const loading = ref(false)
const sortedMeals = computed(() => {
return [...list.value].sort((a, b) => a.name.localeCompare(b.name, 'sk'))
})
const requireToken = (): string => {
if (!auth.token) {
throw new Error('User is not authenticated')
}
return auth.token
}
const upsertMeal = (meal: Meal) => {
const computedMeal = withComputedMealTotals(meal)
mealById.value[computedMeal.meal_id] = computedMeal
const index = list.value.findIndex((item) => item.meal_id === computedMeal.meal_id)
if (index >= 0) {
list.value.splice(index, 1, computedMeal)
} else {
list.value.push(computedMeal)
}
}
const loadMeals = async (mealType = '') => {
loading.value = true
try {
const token = requireToken()
const rows = unwrapApiData<Meal[]>(await BackendAPI.mealList(token, mealType, false, false))
const nextList = Array.isArray(rows)
? rows.map((meal) => withComputedMealTotals({ ...meal, items: meal.items ?? [] }))
: []
list.value = nextList
const nextById: Record<number, Meal> = {}
for (const meal of nextList) {
nextById[meal.meal_id] = meal
}
mealById.value = nextById
} finally {
loading.value = false
}
}
const loadMeal = async (mealId: number): Promise<Meal> => {
const token = requireToken()
const mealData = unwrapApiData<Meal>(await BackendAPI.mealGet(token, mealId, true, false))
const itemsData = unwrapApiData<MealItemListResponse>(await BackendAPI.mealItemList(token, mealId, false))
const mealWithItems = withComputedMealTotals({
...mealData,
items: itemsData.items ?? [],
})
upsertMeal(mealWithItems)
return mealWithItems
}
const getMealFromCache = (mealId: number): Meal | null => {
return mealById.value[mealId] ?? null
}
const ensureMeal = async (mealId: number): Promise<Meal> => {
const cached = getMealFromCache(mealId)
if (cached?.items?.length) {
return cached
}
return loadMeal(mealId)
}
const createMeal = async (payload: MealInput): Promise<Meal> => {
const token = requireToken()
const created = unwrapApiData<Meal>(await BackendAPI.mealCreate(token, payload.name, payload.meal_type))
const nextMeal = withComputedMealTotals({ ...created, items: [] })
upsertMeal(nextMeal)
return nextMeal
}
const updateMeal = async (mealId: number, payload: MealInput): Promise<Meal> => {
const token = requireToken()
const updated = unwrapApiData<Meal>(await BackendAPI.mealUpdate(token, mealId, payload.name, payload.meal_type))
const existingItems = mealById.value[mealId]?.items ?? []
const nextMeal = withComputedMealTotals({ ...updated, items: existingItems })
upsertMeal(nextMeal)
return nextMeal
}
const deleteMeal = async (mealId: number) => {
const token = requireToken()
await BackendAPI.mealDelete(token, mealId)
list.value = list.value.filter((meal) => meal.meal_id !== mealId)
const nextById = { ...mealById.value }
delete nextById[mealId]
mealById.value = nextById
}
const addMealItem = async (mealId: number, payload: MealItemInput): Promise<Meal> => {
const token = requireToken()
await BackendAPI.mealItemAdd(token, mealId, payload.ingredient_id, payload.grams, payload.position)
return loadMeal(mealId)
}
const updateMealItem = async (mealItemId: number, payload: MealItemInput, mealId: number): Promise<Meal> => {
const token = requireToken()
await BackendAPI.mealItemUpdate(
token,
mealItemId,
payload.ingredient_id,
payload.grams,
payload.position,
)
return loadMeal(mealId)
}
const deleteMealItem = async (mealId: number, mealItemId: number): Promise<Meal> => {
const token = requireToken()
await BackendAPI.mealItemDelete(token, mealItemId)
return loadMeal(mealId)
}
return {
list,
mealById,
sortedMeals,
loading,
loadMeals,
loadMeal,
getMealFromCache,
ensureMeal,
createMeal,
updateMeal,
deleteMeal,
addMealItem,
updateMealItem,
deleteMealItem,
}
})

View File

@ -0,0 +1,77 @@
export type MealType = 'breakfast' | 'lunch' | 'dinner'
export interface Ingredient {
ingredient_id: number
user_id: number | null
name: string
protein_g_100: number
carbs_g_100: number
sugar_g_100: number
fat_g_100: number
fiber_g_100: number
kcal_100: number
}
export interface MealItemNutrition {
protein_g: number
carbs_g: number
sugar_g: number
fat_g: number
fiber_g: number
kcal: number
}
export interface MealItem {
meal_item_id: number
meal_id: number
ingredient_id: number
grams: number
position: number
ingredient?: Ingredient
nutrition?: MealItemNutrition
}
export interface NutritionTotals {
protein_g: number
carbs_g: number
sugar_g: number
fat_g: number
fiber_g: number
kcal: number
}
export interface Meal {
meal_id: number
user_id: number
name: string
meal_type: MealType
items?: MealItem[]
totals?: NutritionTotals
}
export interface DiaryEntry {
diary_entry_id: number
diary_day_id: number
meal_type: MealType
meal_id: number
meal?: Meal
meal_totals?: NutritionTotals
}
export interface DiaryDay {
user_id: number
day_date: string
diary_day_id: number | null
entries: DiaryEntry[]
totals?: NutritionTotals
}
export type DayMealsByType = Record<MealType, Meal | null>
export const MEAL_TYPES: MealType[] = ['breakfast', 'lunch', 'dinner']
export const MEAL_TYPE_LABELS_SK: Record<MealType, string> = {
breakfast: 'Raňajky',
lunch: 'Obed',
dinner: 'Večera',
}

17
frontend/src/utils/api.ts Normal file
View File

@ -0,0 +1,17 @@
export type ApiEnvelope<T> = {
status: 'OK'
data: T
}
export const unwrapApiData = <T>(payload: unknown): T => {
if (
typeof payload === 'object' &&
payload !== null &&
'status' in payload &&
'data' in payload
) {
return (payload as ApiEnvelope<T>).data
}
return payload as T
}

View File

@ -0,0 +1,8 @@
export const formatISODate = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export const todayISO = (): string => formatISODate(new Date())

View File

@ -0,0 +1,83 @@
import type {
DayMealsByType,
Ingredient,
Meal,
MealItem,
NutritionTotals,
} from '@/types/domain'
const round2 = (value: number): number => Math.round(value * 100) / 100
export const emptyTotals = (): NutritionTotals => ({
protein_g: 0,
carbs_g: 0,
sugar_g: 0,
fat_g: 0,
fiber_g: 0,
kcal: 0,
})
export const computeItemNutrition = (ingredient: Ingredient, grams: number): NutritionTotals => {
const factor = grams / 100
const protein = round2(ingredient.protein_g_100 * factor)
const carbs = round2(ingredient.carbs_g_100 * factor)
const sugar = round2(ingredient.sugar_g_100 * factor)
const fat = round2(ingredient.fat_g_100 * factor)
const fiber = round2(ingredient.fiber_g_100 * factor)
const kcal = round2(protein * 4 + carbs * 4 + fat * 9)
return {
protein_g: protein,
carbs_g: carbs,
sugar_g: sugar,
fat_g: fat,
fiber_g: fiber,
kcal,
}
}
export const addTotals = (base: NutritionTotals, add: NutritionTotals): NutritionTotals => {
return {
protein_g: round2(base.protein_g + add.protein_g),
carbs_g: round2(base.carbs_g + add.carbs_g),
sugar_g: round2(base.sugar_g + add.sugar_g),
fat_g: round2(base.fat_g + add.fat_g),
fiber_g: round2(base.fiber_g + add.fiber_g),
kcal: round2(base.kcal + add.kcal),
}
}
export const computeMealTotals = (items: MealItem[]): NutritionTotals => {
let totals = emptyTotals()
for (const item of items) {
if (!item.ingredient || item.grams <= 0) {
continue
}
const itemTotals = computeItemNutrition(item.ingredient, item.grams)
totals = addTotals(totals, itemTotals)
}
return totals
}
export const computeDayTotals = (mealsByType: Partial<DayMealsByType>): NutritionTotals => {
let totals = emptyTotals()
for (const meal of Object.values(mealsByType)) {
if (!meal) {
continue
}
const mealTotals = computeMealTotals(meal.items ?? [])
totals = addTotals(totals, mealTotals)
}
return totals
}
export const withComputedMealTotals = (meal: Meal): Meal => {
return {
...meal,
totals: computeMealTotals(meal.items ?? []),
}
}

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import AppSidebar from '@/components/navigation/AppSidebar.vue'
import AppBottomTabs from '@/components/navigation/AppBottomTabs.vue'
import AppTopbar from '@/components/navigation/AppTopbar.vue'
</script>
<template>
<div class="app-shell">
<AppSidebar class="desktop-nav" />
<div class="app-main">
<AppTopbar />
<main class="app-content">
<RouterView />
</main>
</div>
<AppBottomTabs class="mobile-nav" />
</div>
</template>

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
faEnvelope,
@ -12,23 +13,16 @@ import {
faSun,
} from '@fortawesome/free-solid-svg-icons'
import BackendAPI from '@/BackendAPI.ts'
import i18n from '@/i18n'
import { SUPPORTED_LOCALES, type AppLocale } from '@/i18n'
import { useAuthStore } from '@/stores/auth'
type ThemeMode = 'light' | 'dark'
type LoginResponse = {
data?: {
auto_registered?: boolean
user?: {
email?: string | null
token?: string | null
}
}
}
const { t } = useI18n({ useScope: 'global' })
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
@ -126,27 +120,23 @@ const submitForm = async () => {
isLoading.value = true
try {
const response = (await BackendAPI.userLogin(
const result = await authStore.login(
normalizedEmail,
normalizedPassword,
)) as LoginResponse
const token = response.data?.user?.token ?? null
const userEmail = response.data?.user?.email ?? normalizedEmail
console.log(response)
if (token) {
localStorage.setItem('token', token)
} else {
localStorage.removeItem('token')
}
localStorage.setItem('user_email', userEmail)
)
successMessage.value = response.data?.auto_registered
successMessage.value = result.autoRegistered
? t('auth.successAutoRegistered')
: t('auth.successLoggedIn')
email.value = normalizedEmail
password.value = ''
showPassword.value = false
const redirect =
typeof route.query.redirect === 'string' && route.query.redirect.length > 0
? route.query.redirect
: '/app/today'
await router.replace(redirect)
} catch (error) {
errorMessage.value = mapApiError(error)
} finally {

View File

@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import IngredientForm from '@/components/ingredients/IngredientForm.vue'
import { useIngredientsStore } from '@/stores/ingredients'
import type { Ingredient } from '@/types/domain'
const ingredientsStore = useIngredientsStore()
const editingId = ref<number | null>(null)
const saving = ref(false)
onMounted(async () => {
if (ingredientsStore.items.length <= 0) {
await ingredientsStore.loadIngredients()
}
})
const editingIngredient = computed<Ingredient | null>(() => {
if (editingId.value === null) {
return null
}
return ingredientsStore.items.find((item) => item.ingredient_id === editingId.value) ?? null
})
const startCreate = () => {
editingId.value = null
}
const startEdit = (ingredientId: number) => {
editingId.value = ingredientId
}
const cancelEdit = () => {
editingId.value = null
}
const saveIngredient = async (payload: {
name: string
protein_g_100: number
carbs_g_100: number
sugar_g_100: number
fat_g_100: number
fiber_g_100: number
kcal_100: number
}) => {
saving.value = true
try {
if (editingIngredient.value) {
await ingredientsStore.updateIngredient(editingIngredient.value.ingredient_id, payload)
} else {
await ingredientsStore.createIngredient(payload)
}
editingId.value = null
} finally {
saving.value = false
}
}
const removeIngredient = async (ingredientId: number) => {
await ingredientsStore.deleteIngredient(ingredientId)
if (editingId.value === ingredientId) {
editingId.value = null
}
}
</script>
<template>
<section class="page">
<IngredientForm
:initial="editingIngredient"
:submit-label="saving ? 'Ukladám...' : 'Uložiť'"
@save="saveIngredient"
@cancel="cancelEdit"
/>
<section class="card">
<div class="section-header">
<h3>Databáza surovín</h3>
<button class="btn" type="button" @click="startCreate">Nová surovina</button>
</div>
<div class="list" v-if="ingredientsStore.sortedItems.length > 0">
<div class="list-row" v-for="ingredient in ingredientsStore.sortedItems" :key="ingredient.ingredient_id">
<div>
<strong>{{ ingredient.name }}</strong>
<p>
B {{ ingredient.protein_g_100 }} · S {{ ingredient.carbs_g_100 }} · T {{ ingredient.fat_g_100 }}
</p>
</div>
<div class="list-row__actions">
<button class="btn" type="button" @click="startEdit(ingredient.ingredient_id)">Upraviť</button>
<button
class="btn btn-danger"
type="button"
@click="removeIngredient(ingredient.ingredient_id)"
>
Zmazať
</button>
</div>
</div>
</div>
<p v-else class="empty-state">Zatiaľ nemáš uložené suroviny.</p>
</section>
</section>
</template>

View File

@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MealItemsEditor from '@/components/meals/MealItemsEditor.vue'
import { useIngredientsStore } from '@/stores/ingredients'
import { useMealsStore } from '@/stores/meals'
import type { MealType } from '@/types/domain'
const route = useRoute()
const router = useRouter()
const mealsStore = useMealsStore()
const ingredientsStore = useIngredientsStore()
const loading = ref(false)
const saving = ref(false)
const mealName = ref('')
const mealType = ref<MealType>('breakfast')
const mealId = computed(() => Number(route.params.mealId))
const meal = computed(() => {
const id = mealId.value
if (!Number.isFinite(id)) {
return null
}
return mealsStore.mealById[id] ?? null
})
const syncForm = () => {
if (!meal.value) {
return
}
mealName.value = meal.value.name
mealType.value = meal.value.meal_type
}
watch(meal, syncForm, { immediate: true })
const loadData = async () => {
if (!Number.isFinite(mealId.value)) {
return
}
loading.value = true
try {
if (ingredientsStore.items.length <= 0) {
await ingredientsStore.loadIngredients()
}
await mealsStore.loadMeal(mealId.value)
} finally {
loading.value = false
}
}
onMounted(loadData)
watch(mealId, loadData)
const saveMeal = async () => {
if (!meal.value) {
return
}
saving.value = true
try {
await mealsStore.updateMeal(meal.value.meal_id, {
name: mealName.value,
meal_type: mealType.value,
})
} finally {
saving.value = false
}
}
const removeMeal = async () => {
if (!meal.value) {
return
}
await mealsStore.deleteMeal(meal.value.meal_id)
await router.replace({ name: 'meals' })
}
const addItem = async (payload: { ingredient_id: number; grams: number }) => {
if (!meal.value) {
return
}
await mealsStore.addMealItem(meal.value.meal_id, {
...payload,
position: (meal.value.items?.length ?? 0) + 1,
})
}
const updateItem = async (payload: {
meal_item_id: number
ingredient_id: number
grams: number
position: number
}) => {
if (!meal.value) {
return
}
await mealsStore.updateMealItem(
payload.meal_item_id,
{
ingredient_id: payload.ingredient_id,
grams: payload.grams,
position: payload.position,
},
meal.value.meal_id,
)
}
const removeItem = async (mealItemId: number) => {
if (!meal.value) {
return
}
await mealsStore.deleteMealItem(meal.value.meal_id, mealItemId)
}
</script>
<template>
<section class="page">
<div v-if="loading" class="card">Načítavam jedálniček...</div>
<template v-else-if="meal">
<section class="card">
<div class="grid-two">
<label>
<span>Názov</span>
<input v-model="mealName" class="input-text" type="text" />
</label>
<label>
<span>Typ jedla</span>
<select v-model="mealType" class="input-select">
<option value="breakfast">Raňajky</option>
<option value="lunch">Obed</option>
<option value="dinner">Večera</option>
</select>
</label>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="button" :disabled="saving" @click="saveMeal">
{{ saving ? 'Ukladám...' : 'Uložiť zmeny' }}
</button>
<button class="btn btn-danger" type="button" @click="removeMeal">Zmazať jedálniček</button>
</div>
</section>
<MealItemsEditor
:items="meal.items ?? []"
:ingredients="ingredientsStore.sortedItems"
@add-item="addItem"
@update-item="updateItem"
@remove-item="removeItem"
/>
</template>
<div v-else class="card">Jedálniček neexistuje.</div>
</section>
</template>

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import MealTypeFilter from '@/components/meals/MealTypeFilter.vue'
import { useMealsStore } from '@/stores/meals'
import type { MealType } from '@/types/domain'
import { MEAL_TYPE_LABELS_SK } from '@/types/domain'
const router = useRouter()
const mealsStore = useMealsStore()
const filterType = ref<MealType | ''>('')
const newMealName = ref('')
const newMealType = ref<MealType>('breakfast')
const creating = ref(false)
onMounted(async () => {
if (mealsStore.list.length <= 0) {
await mealsStore.loadMeals()
}
})
const filteredMeals = computed(() => {
if (!filterType.value) {
return mealsStore.sortedMeals
}
return mealsStore.sortedMeals.filter((meal) => meal.meal_type === filterType.value)
})
const createMeal = async () => {
const name = newMealName.value.trim()
if (name.length <= 0) {
return
}
creating.value = true
try {
const meal = await mealsStore.createMeal({
name,
meal_type: newMealType.value,
})
newMealName.value = ''
await router.push({ name: 'meal-detail', params: { mealId: meal.meal_id } })
} finally {
creating.value = false
}
}
</script>
<template>
<section class="page">
<div class="page-header page-header--split">
<MealTypeFilter v-model="filterType" />
</div>
<section class="card form-inline">
<input v-model="newMealName" class="input-text" type="text" placeholder="Názov jedálnička" />
<select v-model="newMealType" class="input-select">
<option value="breakfast">Raňajky</option>
<option value="lunch">Obed</option>
<option value="dinner">Večera</option>
</select>
<button class="btn btn-primary" type="button" :disabled="creating" @click="createMeal">
{{ creating ? 'Ukladám...' : 'Vytvoriť' }}
</button>
</section>
<section class="card">
<h3>Knižnica jedálničkov</h3>
<div class="list" v-if="filteredMeals.length > 0">
<RouterLink
v-for="meal in filteredMeals"
:key="meal.meal_id"
:to="{ name: 'meal-detail', params: { mealId: meal.meal_id } }"
class="list-row"
>
<div>
<strong>{{ meal.name }}</strong>
<p>{{ MEAL_TYPE_LABELS_SK[meal.meal_type] }}</p>
</div>
<div class="list-row__meta">{{ meal.totals?.kcal ?? 0 }} kcal</div>
</RouterLink>
</div>
<p v-else class="empty-state">Zatiaľ nemáš žiadne jedálničky.</p>
</section>
</section>
</template>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const onLogout = async () => {
await auth.logout()
await router.replace({ name: 'auth' })
}
</script>
<template>
<section class="page">
<section class="card settings-card">
<h3>Nastavenia účtu</h3>
<p v-if="auth.userEmail">Prihlásený používateľ: <strong>{{ auth.userEmail }}</strong></p>
<button class="btn btn-danger" type="button" @click="onLogout">Odhlásiť sa</button>
</section>
</section>
</template>

View File

@ -0,0 +1,8 @@
<template>
<section class="page">
<section class="card">
<h3>Štatistiky</h3>
<p>Základný prehľad bude doplnený v ďalšej iterácii.</p>
</section>
</section>
</template>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import DayMealCard from '@/components/today/DayMealCard.vue'
import DayTotalsCard from '@/components/today/DayTotalsCard.vue'
import { useDiaryStore } from '@/stores/diary'
import { useMealsStore } from '@/stores/meals'
import { MEAL_TYPES, MEAL_TYPE_LABELS_SK, type MealType } from '@/types/domain'
import { todayISO } from '@/utils/date'
const route = useRoute()
const router = useRouter()
const diaryStore = useDiaryStore()
const mealsStore = useMealsStore()
const isWorking = ref(false)
const selectedDate = ref(todayISO())
const resolveDate = (): string => {
const dateFromRoute = typeof route.params.date === 'string' ? route.params.date : ''
return dateFromRoute.length > 0 ? dateFromRoute : todayISO()
}
const reloadDay = async (date: string) => {
selectedDate.value = date
diaryStore.ensureCurrentDay(date)
isWorking.value = true
try {
if (mealsStore.list.length <= 0) {
await mealsStore.loadMeals()
}
await diaryStore.loadDay(date)
} finally {
isWorking.value = false
}
}
watch(
() => route.params.date,
async () => {
await reloadDay(resolveDate())
},
)
onMounted(async () => {
await reloadDay(resolveDate())
})
const onDateChange = async () => {
await router.replace({ name: 'today', params: { date: selectedDate.value } })
}
const onSelectMeal = async (mealType: MealType, mealId: number | null) => {
if (mealId === null) {
await diaryStore.unsetMealForType(selectedDate.value, mealType)
return
}
await diaryStore.setMealForType(selectedDate.value, mealType, mealId)
}
const dayMeals = computed(() => diaryStore.mealsByType)
const dayTotals = computed(() => diaryStore.computedTotals)
</script>
<template>
<section class="page">
<div class="page-header">
<label class="filter-control">
<span>Dátum</span>
<input v-model="selectedDate" class="input-date" type="date" @change="onDateChange" />
</label>
</div>
<div v-if="isWorking || diaryStore.loading" class="card">Načítavam deň...</div>
<template v-else>
<div class="grid-three">
<DayMealCard
v-for="mealType in MEAL_TYPES"
:key="mealType"
:meal-type="mealType"
:label="MEAL_TYPE_LABELS_SK[mealType]"
:meal-options="diaryStore.selectedMealOptions[mealType]"
:selected-meal-id="diaryStore.selectedMealId(mealType)"
:meal="dayMeals[mealType]"
@select-meal="onSelectMeal"
/>
</div>
<DayTotalsCard :totals="dayTotals" />
</template>
</section>
</template>