Compare commits

..

2 Commits

Author SHA1 Message Date
1d5b730e11 added toast and modal confirmation 2026-02-14 08:22:35 +01:00
072f44c213 added show/hide for ingrediens form 2026-02-14 08:08:26 +01:00
17 changed files with 613 additions and 40 deletions

View File

@ -33,21 +33,33 @@ It describes what the project is, what is already implemented, and what still ne
- Top bar/title: `frontend/src/components/navigation/AppTopbar.vue`.
- Pages: `TodayView`, `MealsView`, `MealDetailView`, `IngredientsView`, `StatsView`, `SettingsView`.
- Route guard is active for `/app/*` (requires token), guest-only for `/`.
- Frontend UX layer is implemented:
- global toasts: `frontend/src/components/common/ToastHost.vue`
- global confirm modal: `frontend/src/components/common/ConfirmModalHost.vue`
- state management: `frontend/src/stores/ui.ts`
- mounted globally in `frontend/src/App.vue`
- card-level loading/error states are wired in `TodayView`, `MealsView`, `MealDetailView`, `IngredientsView`
- Frontend i18n is wired and used across new UI:
- setup in `frontend/src/i18n/index.ts`
- locale files in `frontend/src/locales/{sk,cs,en,es,de}.json`
- language switcher updates locale dynamically.
- new app UI keys are added (`nav`, `pageTitles`, `mealTypes`, `common`, `nutrition`, `today`, `meals`, `ingredients`, `stats`, `settings`).
- app UI keys include (`nav`, `pageTitles`, `mealTypes`, `common`, `nutrition`, `today`, `meals`, `ingredients`, `stats`, `settings`, `ux`)
- Frontend theme system is implemented:
- light/dark mode toggle in auth page
- centralized theme store: `frontend/src/stores/theme.ts`
- shared toggle component: `frontend/src/components/common/ThemeToggle.vue`
- toggle available in auth, app topbar, and settings
- design tokens in `frontend/src/assets/css/style.css` (`:root` variables).
- App logo is served from `frontend/public/Nutrio.png` (copied from `doc/Nutrio.png`).
- Font Awesome is installed and registered globally in `frontend/src/main.ts`.
- Pinia is installed and configured in `frontend/src/main.ts` via `frontend/src/stores/index.ts`.
- Frontend domain/store structure exists:
- `frontend/src/types/domain.ts`
- `frontend/src/stores/{auth,ingredients,meals,diary}.ts`
- `frontend/src/utils/{nutrition,api,date}.ts`
- `frontend/src/stores/{auth,theme,ui,ingredients,meals,diary}.ts`
- `frontend/src/utils/{nutrition,api,date,error}.ts`
- Ingredients form UX detail:
- create/edit form in `IngredientsView` is hidden by default
- shows only after `Nová surovina` or `Upraviť`
- hides again after successful save (form remount resets fields)
- `frontend/src/BackendAPI.ts` is generated via `backend/scripts/buildTypeScript.php` and should not be edited manually.
- `backend/data.json` contains sample meal data (not currently wired into DB/API flow).
@ -181,7 +193,7 @@ Frontend:
## Product Behavior Target (what to build next)
- Harden auth (token transport/header strategy, token revoke strategy, brute-force/rate-limits).
- Polish authenticated frontend UX (validation messages, delete confirmations, optimistic updates, better loading/error states).
- Polish authenticated frontend UX further (more granular field validation, retry actions, richer empty states).
- Add diary range screen/workflow on frontend (backend endpoint already exists).
- Add i18n coverage for any future UI additions and keep keys stable.
- Add API tests for validation, ownership checks, and totals calculation consistency.

View File

@ -41,5 +41,8 @@ Výstup: konkrétny návrh + ukážky kódu, nie všeobecné rady.
----- 2026-02-14 07:16:25 -----------------------------------------------------
dopln kluce pre UI texty pre preklad, pre kluce pouzivaj anglictinu, dopln potom aj vsetky preklady vo frontend/src/locales/*.json
----- 2026-02-14 07:33:30 -----------------------------------------------------
doplniť jemnejší UX flow (toasty, confirm modaly pri delete, loading/error states per card)
----- 2026-02-14 08:05:38 -----------------------------------------------------
na zobrazeni app/ingredients sprav aby formular pre pridanie novej suroviny sa zobrazil az ked kliknem na tlacitko Nova surovina, a po uspesnom ulozeni ten formular pre pridanie novej suroviny skry, nezabudni vynulovat udaje vo formulari po ulozeni
----- 2026-02-14 08:08:33 -----------------------------------------------------
dopln jemnejší UX flow (toasty, confirm modaly pri delete, loading/error states per card)

View File

@ -1,7 +1,11 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import ToastHost from '@/components/common/ToastHost.vue'
import ConfirmModalHost from '@/components/common/ConfirmModalHost.vue'
</script>
<template>
<RouterView />
<ToastHost />
<ConfirmModalHost />
</template>

View File

@ -633,6 +633,98 @@ select {
font-size: var(--fs-sm);
}
.card-state {
margin: 0;
color: var(--color-muted);
}
.card-state--error {
color: var(--color-error);
background: var(--color-error-bg);
border-radius: var(--radius-md);
padding: 0.65rem 0.75rem;
}
.toast-host {
position: fixed;
right: var(--space-md);
bottom: var(--space-md);
z-index: 40;
display: flex;
flex-direction: column;
gap: var(--space-xs);
pointer-events: none;
}
.toast-item {
min-width: min(360px, calc(100vw - 2rem));
max-width: min(460px, calc(100vw - 2rem));
border: 1px solid var(--color-border);
background: var(--color-surface);
box-shadow: var(--shadow-soft);
border-radius: var(--radius-md);
padding: 0.65rem 0.75rem;
display: flex;
align-items: start;
justify-content: space-between;
gap: var(--space-sm);
pointer-events: auto;
}
.toast-item--success {
border-color: color-mix(in srgb, var(--color-green) 50%, var(--color-border));
}
.toast-item--error {
border-color: color-mix(in srgb, var(--color-error) 50%, var(--color-border));
background: color-mix(in srgb, var(--color-error-bg) 50%, var(--color-surface));
}
.toast-item--info {
border-color: color-mix(in srgb, var(--color-gray) 40%, var(--color-border));
}
.toast-item__close {
border: none;
background: transparent;
color: var(--color-muted);
cursor: pointer;
line-height: 1;
padding: 0;
}
.confirm-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.42);
backdrop-filter: blur(2px);
z-index: 50;
display: grid;
place-items: center;
padding: var(--space-md);
}
.confirm-modal {
width: min(520px, 100%);
}
.confirm-modal h3 {
margin-top: 0;
margin-bottom: var(--space-sm);
}
.confirm-modal p {
margin-top: 0;
margin-bottom: var(--space-md);
color: var(--color-muted);
}
.confirm-modal__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
}
.app-bottom-tabs {
display: none;
}
@ -699,4 +791,15 @@ select {
.app-topbar__actions {
gap: var(--space-xs);
}
.toast-host {
left: var(--space-sm);
right: var(--space-sm);
bottom: calc(62px + var(--space-sm));
}
.toast-item {
min-width: auto;
max-width: 100%;
}
}

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import { useUIStore } from '@/stores/ui'
const ui = useUIStore()
</script>
<template>
<div
v-if="ui.confirmState.open"
class="confirm-backdrop"
role="dialog"
aria-modal="true"
>
<div class="confirm-modal card">
<h3>{{ ui.confirmState.title }}</h3>
<p>{{ ui.confirmState.message }}</p>
<div class="confirm-modal__actions">
<button type="button" class="btn" @click="ui.resolveConfirm(false)">
{{ ui.confirmState.cancelLabel }}
</button>
<button type="button" class="btn btn-danger" @click="ui.resolveConfirm(true)">
{{ ui.confirmState.confirmLabel }}
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUIStore } from '@/stores/ui'
const ui = useUIStore()
const { t } = useI18n()
</script>
<template>
<div class="toast-host" aria-live="polite" aria-atomic="true">
<div
v-for="toast in ui.toasts"
:key="toast.id"
class="toast-item"
:class="`toast-item--${toast.type}`"
>
<span>{{ toast.message }}</span>
<button
type="button"
class="toast-item__close"
@click="ui.removeToast(toast.id)"
:aria-label="t('common.close')"
>
×
</button>
</div>
</div>
</template>

View File

@ -71,10 +71,40 @@
"create": "Vytvořit",
"edit": "Upravit",
"delete": "Smazat",
"close": "Zavřít",
"actions": "Akce",
"loading": "Načítám...",
"kcalUnit": "kcal"
},
"ux": {
"toast": {
"created": "Položka byla vytvořena.",
"saved": "Změny byly uloženy.",
"updated": "Změny byly aplikovány.",
"deleted": "Položka byla smazána."
},
"errors": {
"loadDay": "Nepodařilo se načíst den.",
"updateDay": "Nepodařilo se upravit denní jídla.",
"loadMeals": "Nepodařilo se načíst jídelníčky.",
"createMeal": "Nepodařilo se vytvořit jídelníček.",
"loadMeal": "Nepodařilo se načíst jídelníček.",
"saveMeal": "Nepodařilo se uložit jídelníček.",
"deleteMeal": "Nepodařilo se smazat jídelníček.",
"addMealItem": "Nepodařilo se přidat položku jídelníčku.",
"updateMealItem": "Nepodařilo se upravit položku jídelníčku.",
"deleteMealItem": "Nepodařilo se smazat položku jídelníčku.",
"loadIngredients": "Nepodařilo se načíst suroviny.",
"saveIngredient": "Nepodařilo se uložit surovinu.",
"deleteIngredient": "Nepodařilo se smazat surovinu."
},
"confirm": {
"deleteTitle": "Potvrď smazání",
"deleteMeal": "Opravdu chceš smazat tento jídelníček?",
"deleteMealItem": "Opravdu chceš smazat tuto položku jídelníčku?",
"deleteIngredient": "Opravdu chceš smazat tuto surovinu?"
}
},
"nutrition": {
"short": {
"protein": "B",

View File

@ -71,10 +71,40 @@
"create": "Erstellen",
"edit": "Bearbeiten",
"delete": "Löschen",
"close": "Schließen",
"actions": "Aktion",
"loading": "Lädt...",
"kcalUnit": "kcal"
},
"ux": {
"toast": {
"created": "Element wurde erstellt.",
"saved": "Änderungen wurden gespeichert.",
"updated": "Änderungen wurden übernommen.",
"deleted": "Element wurde gelöscht."
},
"errors": {
"loadDay": "Tag konnte nicht geladen werden.",
"updateDay": "Tagesmahlzeiten konnten nicht aktualisiert werden.",
"loadMeals": "Mahlzeiten konnten nicht geladen werden.",
"createMeal": "Mahlzeitenplan konnte nicht erstellt werden.",
"loadMeal": "Mahlzeitenplan konnte nicht geladen werden.",
"saveMeal": "Mahlzeitenplan konnte nicht gespeichert werden.",
"deleteMeal": "Mahlzeitenplan konnte nicht gelöscht werden.",
"addMealItem": "Mahlzeiten-Element konnte nicht hinzugefügt werden.",
"updateMealItem": "Mahlzeiten-Element konnte nicht aktualisiert werden.",
"deleteMealItem": "Mahlzeiten-Element konnte nicht gelöscht werden.",
"loadIngredients": "Zutaten konnten nicht geladen werden.",
"saveIngredient": "Zutat konnte nicht gespeichert werden.",
"deleteIngredient": "Zutat konnte nicht gelöscht werden."
},
"confirm": {
"deleteTitle": "Löschen bestätigen",
"deleteMeal": "Möchtest du diesen Mahlzeitenplan wirklich löschen?",
"deleteMealItem": "Möchtest du dieses Mahlzeiten-Element wirklich löschen?",
"deleteIngredient": "Möchtest du diese Zutat wirklich löschen?"
}
},
"nutrition": {
"short": {
"protein": "E",

View File

@ -71,10 +71,40 @@
"create": "Create",
"edit": "Edit",
"delete": "Delete",
"close": "Close",
"actions": "Action",
"loading": "Loading...",
"kcalUnit": "kcal"
},
"ux": {
"toast": {
"created": "Item was created.",
"saved": "Changes were saved.",
"updated": "Changes were applied.",
"deleted": "Item was deleted."
},
"errors": {
"loadDay": "Failed to load the day.",
"updateDay": "Failed to update day meals.",
"loadMeals": "Failed to load meals.",
"createMeal": "Failed to create meal plan.",
"loadMeal": "Failed to load meal plan.",
"saveMeal": "Failed to save meal plan.",
"deleteMeal": "Failed to delete meal plan.",
"addMealItem": "Failed to add meal item.",
"updateMealItem": "Failed to update meal item.",
"deleteMealItem": "Failed to delete meal item.",
"loadIngredients": "Failed to load ingredients.",
"saveIngredient": "Failed to save ingredient.",
"deleteIngredient": "Failed to delete ingredient."
},
"confirm": {
"deleteTitle": "Confirm delete",
"deleteMeal": "Do you really want to delete this meal plan?",
"deleteMealItem": "Do you really want to delete this meal item?",
"deleteIngredient": "Do you really want to delete this ingredient?"
}
},
"nutrition": {
"short": {
"protein": "P",

View File

@ -71,10 +71,40 @@
"create": "Crear",
"edit": "Editar",
"delete": "Eliminar",
"close": "Cerrar",
"actions": "Acción",
"loading": "Cargando...",
"kcalUnit": "kcal"
},
"ux": {
"toast": {
"created": "Elemento creado.",
"saved": "Cambios guardados.",
"updated": "Cambios aplicados.",
"deleted": "Elemento eliminado."
},
"errors": {
"loadDay": "No se pudo cargar el día.",
"updateDay": "No se pudieron actualizar las comidas del día.",
"loadMeals": "No se pudieron cargar los menús.",
"createMeal": "No se pudo crear el menú.",
"loadMeal": "No se pudo cargar el menú.",
"saveMeal": "No se pudo guardar el menú.",
"deleteMeal": "No se pudo eliminar el menú.",
"addMealItem": "No se pudo añadir el elemento del menú.",
"updateMealItem": "No se pudo actualizar el elemento del menú.",
"deleteMealItem": "No se pudo eliminar el elemento del menú.",
"loadIngredients": "No se pudieron cargar los ingredientes.",
"saveIngredient": "No se pudo guardar el ingrediente.",
"deleteIngredient": "No se pudo eliminar el ingrediente."
},
"confirm": {
"deleteTitle": "Confirmar eliminación",
"deleteMeal": "¿De verdad quieres eliminar este menú?",
"deleteMealItem": "¿De verdad quieres eliminar este elemento del menú?",
"deleteIngredient": "¿De verdad quieres eliminar este ingrediente?"
}
},
"nutrition": {
"short": {
"protein": "P",

View File

@ -71,10 +71,40 @@
"create": "Vytvoriť",
"edit": "Upraviť",
"delete": "Zmazať",
"close": "Zavrieť",
"actions": "Akcia",
"loading": "Načítavam...",
"kcalUnit": "kcal"
},
"ux": {
"toast": {
"created": "Položka bola vytvorená.",
"saved": "Zmeny boli uložené.",
"updated": "Zmeny boli aplikované.",
"deleted": "Položka bola zmazaná."
},
"errors": {
"loadDay": "Nepodarilo sa načítať deň.",
"updateDay": "Nepodarilo sa upraviť denné jedlá.",
"loadMeals": "Nepodarilo sa načítať jedálničky.",
"createMeal": "Nepodarilo sa vytvoriť jedálniček.",
"loadMeal": "Nepodarilo sa načítať jedálniček.",
"saveMeal": "Nepodarilo sa uložiť jedálniček.",
"deleteMeal": "Nepodarilo sa zmazať jedálniček.",
"addMealItem": "Nepodarilo sa pridať položku jedálnička.",
"updateMealItem": "Nepodarilo sa upraviť položku jedálnička.",
"deleteMealItem": "Nepodarilo sa zmazať položku jedálnička.",
"loadIngredients": "Nepodarilo sa načítať suroviny.",
"saveIngredient": "Nepodarilo sa uložiť surovinu.",
"deleteIngredient": "Nepodarilo sa zmazať surovinu."
},
"confirm": {
"deleteTitle": "Potvrď zmazanie",
"deleteMeal": "Naozaj chceš zmazať tento jedálniček?",
"deleteMealItem": "Naozaj chceš zmazať túto položku jedálnička?",
"deleteIngredient": "Naozaj chceš zmazať túto surovinu?"
}
},
"nutrition": {
"short": {
"protein": "B",

84
frontend/src/stores/ui.ts Normal file
View File

@ -0,0 +1,84 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export type ToastType = 'success' | 'error' | 'info'
export type ToastItem = {
id: number
type: ToastType
message: string
}
export type ConfirmOptions = {
title: string
message: string
confirmLabel: string
cancelLabel: string
}
type ConfirmState = ConfirmOptions & {
open: boolean
}
let toastId = 0
let confirmResolver: ((value: boolean) => void) | null = null
export const useUIStore = defineStore('ui', () => {
const toasts = ref<ToastItem[]>([])
const confirmState = ref<ConfirmState>({
open: false,
title: '',
message: '',
confirmLabel: '',
cancelLabel: '',
})
const removeToast = (id: number) => {
toasts.value = toasts.value.filter((item) => item.id !== id)
}
const pushToast = (type: ToastType, message: string, timeoutMs = 3200) => {
const id = ++toastId
toasts.value = [...toasts.value, { id, type, message }]
setTimeout(() => removeToast(id), timeoutMs)
}
const success = (message: string) => pushToast('success', message)
const error = (message: string) => pushToast('error', message)
const info = (message: string) => pushToast('info', message)
const confirm = (options: ConfirmOptions): Promise<boolean> => {
if (confirmResolver) {
confirmResolver(false)
}
confirmState.value = {
open: true,
...options,
}
return new Promise<boolean>((resolve) => {
confirmResolver = resolve
})
}
const resolveConfirm = (value: boolean) => {
confirmState.value.open = false
if (confirmResolver) {
confirmResolver(value)
confirmResolver = null
}
}
return {
toasts,
confirmState,
removeToast,
success,
error,
info,
confirm,
resolveConfirm,
}
})

View File

@ -0,0 +1,11 @@
export const toErrorMessage = (error: unknown, fallback: string): string => {
if (typeof error === 'string' && error.length > 0) {
return error
}
if (error instanceof Error && typeof error.message === 'string' && error.message.length > 0) {
return error.message
}
return fallback
}

View File

@ -3,17 +3,29 @@ import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IngredientForm from '@/components/ingredients/IngredientForm.vue'
import { useIngredientsStore } from '@/stores/ingredients'
import { useUIStore } from '@/stores/ui'
import type { Ingredient } from '@/types/domain'
import { toErrorMessage } from '@/utils/error'
const ingredientsStore = useIngredientsStore()
const ui = useUIStore()
const editingId = ref<number | null>(null)
const saving = ref(false)
const formMode = ref<'hidden' | 'create' | 'edit'>('hidden')
const loadError = ref('')
const actionError = ref('')
const { t } = useI18n()
onMounted(async () => {
try {
loadError.value = ''
if (ingredientsStore.items.length <= 0) {
await ingredientsStore.loadIngredients()
}
} catch (error) {
loadError.value = toErrorMessage(error, t('ux.errors.loadIngredients'))
ui.error(loadError.value)
}
})
const editingIngredient = computed<Ingredient | null>(() => {
@ -24,15 +36,21 @@ const editingIngredient = computed<Ingredient | null>(() => {
})
const startCreate = () => {
actionError.value = ''
editingId.value = null
formMode.value = 'create'
}
const startEdit = (ingredientId: number) => {
actionError.value = ''
editingId.value = ingredientId
formMode.value = 'edit'
}
const cancelEdit = () => {
editingId.value = null
formMode.value = 'hidden'
actionError.value = ''
}
const saveIngredient = async (payload: {
@ -45,22 +63,48 @@ const saveIngredient = async (payload: {
kcal_100: number
}) => {
saving.value = true
actionError.value = ''
try {
if (editingIngredient.value) {
await ingredientsStore.updateIngredient(editingIngredient.value.ingredient_id, payload)
ui.success(t('ux.toast.saved'))
} else {
await ingredientsStore.createIngredient(payload)
ui.success(t('ux.toast.created'))
}
editingId.value = null
formMode.value = 'hidden'
} catch (error) {
actionError.value = toErrorMessage(error, t('ux.errors.saveIngredient'))
ui.error(actionError.value)
} finally {
saving.value = false
}
}
const removeIngredient = async (ingredientId: number) => {
const confirmed = await ui.confirm({
title: t('ux.confirm.deleteTitle'),
message: t('ux.confirm.deleteIngredient'),
confirmLabel: t('common.delete'),
cancelLabel: t('common.cancel'),
})
if (!confirmed) {
return
}
actionError.value = ''
try {
await ingredientsStore.deleteIngredient(ingredientId)
ui.success(t('ux.toast.deleted'))
if (editingId.value === ingredientId) {
editingId.value = null
formMode.value = 'hidden'
}
} catch (error) {
actionError.value = toErrorMessage(error, t('ux.errors.deleteIngredient'))
ui.error(actionError.value)
}
}
</script>
@ -68,6 +112,7 @@ const removeIngredient = async (ingredientId: number) => {
<template>
<section class="page">
<IngredientForm
v-if="formMode !== 'hidden'"
:initial="editingIngredient"
:submit-label="saving ? t('common.saving') : t('common.save')"
@save="saveIngredient"
@ -79,7 +124,11 @@ const removeIngredient = async (ingredientId: number) => {
<h3>{{ t('ingredients.databaseTitle') }}</h3>
<button class="btn" type="button" @click="startCreate">{{ t('ingredients.newButton') }}</button>
</div>
<div class="list" v-if="ingredientsStore.sortedItems.length > 0">
<p v-if="loadError" class="card-state card-state--error">{{ loadError }}</p>
<p v-else-if="ingredientsStore.loading" class="card-state">{{ t('common.loading') }}</p>
<p v-else-if="actionError" class="card-state card-state--error">{{ actionError }}</p>
<div class="list" v-else-if="ingredientsStore.sortedItems.length > 0">
<div class="list-row" v-for="ingredient in ingredientsStore.sortedItems" :key="ingredient.ingredient_id">
<div>
<strong>{{ ingredient.name }}</strong>

View File

@ -5,18 +5,23 @@ import { useI18n } from 'vue-i18n'
import MealItemsEditor from '@/components/meals/MealItemsEditor.vue'
import { useIngredientsStore } from '@/stores/ingredients'
import { useMealsStore } from '@/stores/meals'
import { useUIStore } from '@/stores/ui'
import type { MealType } from '@/types/domain'
import { toErrorMessage } from '@/utils/error'
const route = useRoute()
const router = useRouter()
const mealsStore = useMealsStore()
const ingredientsStore = useIngredientsStore()
const ui = useUIStore()
const { t } = useI18n()
const loading = ref(false)
const saving = ref(false)
const mealName = ref('')
const mealType = ref<MealType>('breakfast')
const loadError = ref('')
const actionError = ref('')
const mealId = computed(() => Number(route.params.mealId))
@ -43,11 +48,15 @@ const loadData = async () => {
return
}
loading.value = true
loadError.value = ''
try {
if (ingredientsStore.items.length <= 0) {
await ingredientsStore.loadIngredients()
}
await mealsStore.loadMeal(mealId.value)
} catch (error) {
loadError.value = toErrorMessage(error, t('ux.errors.loadMeal'))
ui.error(loadError.value)
} finally {
loading.value = false
}
@ -60,12 +69,17 @@ const saveMeal = async () => {
if (!meal.value) {
return
}
actionError.value = ''
saving.value = true
try {
await mealsStore.updateMeal(meal.value.meal_id, {
name: mealName.value,
meal_type: mealType.value,
})
ui.success(t('ux.toast.saved'))
} catch (error) {
actionError.value = toErrorMessage(error, t('ux.errors.saveMeal'))
ui.error(actionError.value)
} finally {
saving.value = false
}
@ -75,18 +89,44 @@ const removeMeal = async () => {
if (!meal.value) {
return
}
const confirmed = await ui.confirm({
title: t('ux.confirm.deleteTitle'),
message: t('ux.confirm.deleteMeal'),
confirmLabel: t('common.delete'),
cancelLabel: t('common.cancel'),
})
if (!confirmed) {
return
}
actionError.value = ''
try {
await mealsStore.deleteMeal(meal.value.meal_id)
ui.success(t('ux.toast.deleted'))
await router.replace({ name: 'meals' })
} catch (error) {
actionError.value = toErrorMessage(error, t('ux.errors.deleteMeal'))
ui.error(actionError.value)
}
}
const addItem = async (payload: { ingredient_id: number; grams: number }) => {
if (!meal.value) {
return
}
actionError.value = ''
try {
await mealsStore.addMealItem(meal.value.meal_id, {
...payload,
position: (meal.value.items?.length ?? 0) + 1,
})
ui.success(t('ux.toast.created'))
} catch (error) {
actionError.value = toErrorMessage(error, t('ux.errors.addMealItem'))
ui.error(actionError.value)
}
}
const updateItem = async (payload: {
@ -98,6 +138,8 @@ const updateItem = async (payload: {
if (!meal.value) {
return
}
actionError.value = ''
try {
await mealsStore.updateMealItem(
payload.meal_item_id,
{
@ -107,19 +149,43 @@ const updateItem = async (payload: {
},
meal.value.meal_id,
)
} catch (error) {
actionError.value = toErrorMessage(error, t('ux.errors.updateMealItem'))
ui.error(actionError.value)
}
}
const removeItem = async (mealItemId: number) => {
if (!meal.value) {
return
}
const confirmed = await ui.confirm({
title: t('ux.confirm.deleteTitle'),
message: t('ux.confirm.deleteMealItem'),
confirmLabel: t('common.delete'),
cancelLabel: t('common.cancel'),
})
if (!confirmed) {
return
}
actionError.value = ''
try {
await mealsStore.deleteMealItem(meal.value.meal_id, mealItemId)
ui.success(t('ux.toast.deleted'))
} catch (error) {
actionError.value = toErrorMessage(error, t('ux.errors.deleteMealItem'))
ui.error(actionError.value)
}
}
</script>
<template>
<section class="page">
<div v-if="loading" class="card">{{ t('meals.loadingMeal') }}</div>
<div v-if="loading" class="card card-state">{{ t('meals.loadingMeal') }}</div>
<div v-else-if="loadError" class="card card-state card-state--error">{{ loadError }}</div>
<template v-else-if="meal">
<section class="card">
@ -137,6 +203,7 @@ const removeItem = async (mealItemId: number) => {
</select>
</label>
</div>
<p v-if="actionError" class="card-state card-state--error">{{ actionError }}</p>
<div class="form-actions">
<button class="btn btn-primary" type="button" :disabled="saving" @click="saveMeal">
{{ saving ? t('common.saving') : t('meals.saveChanges') }}
@ -154,6 +221,6 @@ const removeItem = async (mealItemId: number) => {
/>
</template>
<div v-else class="card">{{ t('meals.notFound') }}</div>
<div v-else class="card card-state card-state--error">{{ t('meals.notFound') }}</div>
</section>
</template>

View File

@ -4,21 +4,31 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import MealTypeFilter from '@/components/meals/MealTypeFilter.vue'
import { useMealsStore } from '@/stores/meals'
import { useUIStore } from '@/stores/ui'
import type { MealType } from '@/types/domain'
import { toErrorMessage } from '@/utils/error'
const router = useRouter()
const mealsStore = useMealsStore()
const ui = useUIStore()
const { t } = useI18n()
const filterType = ref<MealType | ''>('')
const newMealName = ref('')
const newMealType = ref<MealType>('breakfast')
const creating = ref(false)
const errorMessage = ref('')
onMounted(async () => {
try {
errorMessage.value = ''
if (mealsStore.list.length <= 0) {
await mealsStore.loadMeals()
}
} catch (error) {
errorMessage.value = toErrorMessage(error, t('ux.errors.loadMeals'))
ui.error(errorMessage.value)
}
})
const filteredMeals = computed(() => {
@ -33,14 +43,20 @@ const createMeal = async () => {
if (name.length <= 0) {
return
}
errorMessage.value = ''
creating.value = true
try {
const meal = await mealsStore.createMeal({
name,
meal_type: newMealType.value,
})
ui.success(t('ux.toast.created'))
newMealName.value = ''
await router.push({ name: 'meal-detail', params: { mealId: meal.meal_id } })
} catch (error) {
errorMessage.value = toErrorMessage(error, t('ux.errors.createMeal'))
ui.error(errorMessage.value)
} finally {
creating.value = false
}
@ -67,7 +83,9 @@ const createMeal = async () => {
<section class="card">
<h3>{{ t('meals.libraryTitle') }}</h3>
<div class="list" v-if="filteredMeals.length > 0">
<p v-if="errorMessage" class="card-state card-state--error">{{ errorMessage }}</p>
<p v-else-if="mealsStore.loading" class="card-state">{{ t('common.loading') }}</p>
<div class="list" v-else-if="filteredMeals.length > 0">
<RouterLink
v-for="meal in filteredMeals"
:key="meal.meal_id"

View File

@ -6,17 +6,21 @@ 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 { useUIStore } from '@/stores/ui'
import { MEAL_TYPES, type MealType } from '@/types/domain'
import { todayISO } from '@/utils/date'
import { toErrorMessage } from '@/utils/error'
const route = useRoute()
const router = useRouter()
const diaryStore = useDiaryStore()
const mealsStore = useMealsStore()
const ui = useUIStore()
const { t } = useI18n()
const isWorking = ref(false)
const selectedDate = ref(todayISO())
const errorMessage = ref('')
const resolveDate = (): string => {
const dateFromRoute = typeof route.params.date === 'string' ? route.params.date : ''
@ -28,12 +32,16 @@ const mealTypeLabel = (mealType: MealType): string => t(`mealTypes.${mealType}`)
const reloadDay = async (date: string) => {
selectedDate.value = date
diaryStore.ensureCurrentDay(date)
errorMessage.value = ''
isWorking.value = true
try {
if (mealsStore.list.length <= 0) {
await mealsStore.loadMeals()
}
await diaryStore.loadDay(date)
} catch (error) {
errorMessage.value = toErrorMessage(error, t('ux.errors.loadDay'))
ui.error(errorMessage.value)
} finally {
isWorking.value = false
}
@ -55,11 +63,19 @@ const onDateChange = async () => {
}
const onSelectMeal = async (mealType: MealType, mealId: number | null) => {
try {
errorMessage.value = ''
if (mealId === null) {
await diaryStore.unsetMealForType(selectedDate.value, mealType)
ui.success(t('ux.toast.updated'))
return
}
await diaryStore.setMealForType(selectedDate.value, mealType, mealId)
ui.success(t('ux.toast.updated'))
} catch (error) {
errorMessage.value = toErrorMessage(error, t('ux.errors.updateDay'))
ui.error(errorMessage.value)
}
}
const dayMeals = computed(() => diaryStore.mealsByType)
@ -75,7 +91,8 @@ const dayTotals = computed(() => diaryStore.computedTotals)
</label>
</div>
<div v-if="isWorking || diaryStore.loading" class="card">{{ t('today.loadingDay') }}</div>
<div v-if="errorMessage" class="card card-state card-state--error">{{ errorMessage }}</div>
<div v-else-if="isWorking || diaryStore.loading" class="card card-state">{{ t('today.loadingDay') }}</div>
<template v-else>
<div class="grid-three">