added implementation frontend by prompt @ 2026-02-14 05:35:18 #CODEX
This commit is contained in:
@ -19,11 +19,11 @@ Ciele:
|
|||||||
Pre položku s grams: macro = grams/100 * macro_100.
|
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.)
|
kcal = protein*4 + carbs*4 + fat*9. (Sugar je podmnožina carbs, neráta sa zvlášť do kcal.)
|
||||||
5) Navrhni štruktúru projektu:
|
5) Navrhni štruktúru projektu:
|
||||||
- /src/router
|
- frontend/src/router
|
||||||
- /src/views
|
- frontend/src/views
|
||||||
- /src/components
|
- frontend/src/components
|
||||||
- /src/stores (Pinia)
|
- frontend/src/stores (Pinia)
|
||||||
- /src/utils (nutrition math)
|
- frontend/src/utils (nutrition math)
|
||||||
6) Daj konkrétny návrh názvov súborov a exportov + ukážku kódu:
|
6) Daj konkrétny návrh názvov súborov a exportov + ukážku kódu:
|
||||||
- router index.ts s routes + guard
|
- router index.ts s routes + guard
|
||||||
- auth store (token/user)
|
- auth store (token/user)
|
||||||
@ -34,6 +34,6 @@ Preferencie:
|
|||||||
- Vue 3 + Composition API + TypeScript
|
- Vue 3 + Composition API + TypeScript
|
||||||
- Pinia
|
- Pinia
|
||||||
- Vue Router
|
- 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).
|
- 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.
|
Výstup: konkrétny návrh + ukážky kódu, nie všeobecné rady.
|
||||||
|
|||||||
61
frontend/package-lock.json
generated
61
frontend/package-lock.json
generated
@ -11,6 +11,7 @@
|
|||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/vue-fontawesome": "^3.1.3",
|
"@fortawesome/vue-fontawesome": "^3.1.3",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.27",
|
"vue": "^3.5.27",
|
||||||
"vue-i18n": "^11.2.8",
|
"vue-i18n": "^11.2.8",
|
||||||
"vue-router": "^5.0.1"
|
"vue-router": "^5.0.1"
|
||||||
@ -4251,6 +4252,66 @@
|
|||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/pkg-types": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/vue-fontawesome": "^3.1.3",
|
"@fortawesome/vue-fontawesome": "^3.1.3",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.27",
|
"vue": "^3.5.27",
|
||||||
"vue-i18n": "^11.2.8",
|
"vue-i18n": "^11.2.8",
|
||||||
"vue-router": "^5.0.1"
|
"vue-router": "^5.0.1"
|
||||||
|
|||||||
@ -325,3 +325,350 @@ select {
|
|||||||
justify-content: center;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
96
frontend/src/components/ingredients/IngredientForm.vue
Normal file
96
frontend/src/components/ingredients/IngredientForm.vue
Normal 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>
|
||||||
108
frontend/src/components/meals/MealItemsEditor.vue
Normal file
108
frontend/src/components/meals/MealItemsEditor.vue
Normal 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>
|
||||||
27
frontend/src/components/meals/MealTypeFilter.vue
Normal file
27
frontend/src/components/meals/MealTypeFilter.vue
Normal 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>
|
||||||
22
frontend/src/components/navigation/AppBottomTabs.vue
Normal file
22
frontend/src/components/navigation/AppBottomTabs.vue
Normal 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>
|
||||||
28
frontend/src/components/navigation/AppSidebar.vue
Normal file
28
frontend/src/components/navigation/AppSidebar.vue
Normal 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>
|
||||||
38
frontend/src/components/navigation/AppTopbar.vue
Normal file
38
frontend/src/components/navigation/AppTopbar.vue
Normal 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>
|
||||||
47
frontend/src/components/today/DayMealCard.vue
Normal file
47
frontend/src/components/today/DayMealCard.vue
Normal 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>
|
||||||
31
frontend/src/components/today/DayTotalsCard.vue
Normal file
31
frontend/src/components/today/DayTotalsCard.vue
Normal 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>
|
||||||
5
frontend/src/components/today/MealPickerModal.vue
Normal file
5
frontend/src/components/today/MealPickerModal.vue
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card" aria-hidden="true">
|
||||||
|
<p>MealPickerModal placeholder</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -4,10 +4,12 @@ import './assets/css/style.css'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import i18n from './i18n'
|
import i18n from './i18n'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { pinia } from './stores'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.component('font-awesome-icon', FontAwesomeIcon)
|
app.component('font-awesome-icon', FontAwesomeIcon)
|
||||||
|
app.use(pinia)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
|
|
||||||
|
|||||||
@ -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 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({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes,
|
||||||
{
|
})
|
||||||
path: '/',
|
|
||||||
name: 'auth',
|
router.beforeEach((to) => {
|
||||||
component: AuthView,
|
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
|
export default router
|
||||||
|
|||||||
73
frontend/src/stores/auth.ts
Normal file
73
frontend/src/stores/auth.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
127
frontend/src/stores/diary.ts
Normal file
127
frontend/src/stores/diary.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
3
frontend/src/stores/index.ts
Normal file
3
frontend/src/stores/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
export const pinia = createPinia()
|
||||||
100
frontend/src/stores/ingredients.ts
Normal file
100
frontend/src/stores/ingredients.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
164
frontend/src/stores/meals.ts
Normal file
164
frontend/src/stores/meals.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
77
frontend/src/types/domain.ts
Normal file
77
frontend/src/types/domain.ts
Normal 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
17
frontend/src/utils/api.ts
Normal 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
|
||||||
|
}
|
||||||
8
frontend/src/utils/date.ts
Normal file
8
frontend/src/utils/date.ts
Normal 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())
|
||||||
83
frontend/src/utils/nutrition.ts
Normal file
83
frontend/src/utils/nutrition.ts
Normal 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 ?? []),
|
||||||
|
}
|
||||||
|
}
|
||||||
19
frontend/src/views/AppLayout.vue
Normal file
19
frontend/src/views/AppLayout.vue
Normal 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>
|
||||||
@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import {
|
import {
|
||||||
faEnvelope,
|
faEnvelope,
|
||||||
@ -12,23 +13,16 @@ import {
|
|||||||
faSun,
|
faSun,
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
import BackendAPI from '@/BackendAPI.ts'
|
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { SUPPORTED_LOCALES, type AppLocale } from '@/i18n'
|
import { SUPPORTED_LOCALES, type AppLocale } from '@/i18n'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
type ThemeMode = 'light' | 'dark'
|
type ThemeMode = 'light' | 'dark'
|
||||||
|
|
||||||
type LoginResponse = {
|
|
||||||
data?: {
|
|
||||||
auto_registered?: boolean
|
|
||||||
user?: {
|
|
||||||
email?: string | null
|
|
||||||
token?: string | null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n({ useScope: 'global' })
|
const { t } = useI18n({ useScope: 'global' })
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
@ -126,27 +120,23 @@ const submitForm = async () => {
|
|||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
const response = (await BackendAPI.userLogin(
|
const result = await authStore.login(
|
||||||
normalizedEmail,
|
normalizedEmail,
|
||||||
normalizedPassword,
|
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.successAutoRegistered')
|
||||||
: t('auth.successLoggedIn')
|
: t('auth.successLoggedIn')
|
||||||
|
|
||||||
email.value = normalizedEmail
|
email.value = normalizedEmail
|
||||||
password.value = ''
|
password.value = ''
|
||||||
showPassword.value = false
|
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) {
|
} catch (error) {
|
||||||
errorMessage.value = mapApiError(error)
|
errorMessage.value = mapApiError(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
103
frontend/src/views/IngredientsView.vue
Normal file
103
frontend/src/views/IngredientsView.vue
Normal 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>
|
||||||
157
frontend/src/views/MealDetailView.vue
Normal file
157
frontend/src/views/MealDetailView.vue
Normal 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>
|
||||||
86
frontend/src/views/MealsView.vue
Normal file
86
frontend/src/views/MealsView.vue
Normal 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>
|
||||||
22
frontend/src/views/SettingsView.vue
Normal file
22
frontend/src/views/SettingsView.vue
Normal 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>
|
||||||
8
frontend/src/views/StatsView.vue
Normal file
8
frontend/src/views/StatsView.vue
Normal 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>
|
||||||
93
frontend/src/views/TodayView.vue
Normal file
93
frontend/src/views/TodayView.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user