added toast and modal confirmation

This commit is contained in:
2026-02-14 08:22:35 +01:00
parent 072f44c213
commit 1d5b730e11
17 changed files with 604 additions and 42 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`. - Top bar/title: `frontend/src/components/navigation/AppTopbar.vue`.
- Pages: `TodayView`, `MealsView`, `MealDetailView`, `IngredientsView`, `StatsView`, `SettingsView`. - Pages: `TodayView`, `MealsView`, `MealDetailView`, `IngredientsView`, `StatsView`, `SettingsView`.
- Route guard is active for `/app/*` (requires token), guest-only for `/`. - 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: - Frontend i18n is wired and used across new UI:
- setup in `frontend/src/i18n/index.ts` - setup in `frontend/src/i18n/index.ts`
- locale files in `frontend/src/locales/{sk,cs,en,es,de}.json` - locale files in `frontend/src/locales/{sk,cs,en,es,de}.json`
- language switcher updates locale dynamically. - 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: - 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). - 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`). - 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`. - 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`. - Pinia is installed and configured in `frontend/src/main.ts` via `frontend/src/stores/index.ts`.
- Frontend domain/store structure exists: - Frontend domain/store structure exists:
- `frontend/src/types/domain.ts` - `frontend/src/types/domain.ts`
- `frontend/src/stores/{auth,ingredients,meals,diary}.ts` - `frontend/src/stores/{auth,theme,ui,ingredients,meals,diary}.ts`
- `frontend/src/utils/{nutrition,api,date}.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. - `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). - `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) ## Product Behavior Target (what to build next)
- Harden auth (token transport/header strategy, token revoke strategy, brute-force/rate-limits). - 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 diary range screen/workflow on frontend (backend endpoint already exists).
- Add i18n coverage for any future UI additions and keep keys stable. - Add i18n coverage for any future UI additions and keep keys stable.
- Add API tests for validation, ownership checks, and totals calculation consistency. - Add API tests for validation, ownership checks, and totals calculation consistency.

View File

@ -44,6 +44,5 @@ dopln kluce pre UI texty pre preklad, pre kluce pouzivaj anglictinu, dopln potom
----- 2026-02-14 08:05:38 ----------------------------------------------------- ----- 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 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)
doplniť jemnejší UX flow (toasty, confirm modaly pri delete, loading/error states per card)

View File

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

View File

@ -633,6 +633,98 @@ select {
font-size: var(--fs-sm); 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 { .app-bottom-tabs {
display: none; display: none;
} }
@ -699,4 +791,15 @@ select {
.app-topbar__actions { .app-topbar__actions {
gap: var(--space-xs); 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", "create": "Vytvořit",
"edit": "Upravit", "edit": "Upravit",
"delete": "Smazat", "delete": "Smazat",
"close": "Zavřít",
"actions": "Akce", "actions": "Akce",
"loading": "Načítám...", "loading": "Načítám...",
"kcalUnit": "kcal" "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": { "nutrition": {
"short": { "short": {
"protein": "B", "protein": "B",

View File

@ -71,10 +71,40 @@
"create": "Erstellen", "create": "Erstellen",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"delete": "Löschen", "delete": "Löschen",
"close": "Schließen",
"actions": "Aktion", "actions": "Aktion",
"loading": "Lädt...", "loading": "Lädt...",
"kcalUnit": "kcal" "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": { "nutrition": {
"short": { "short": {
"protein": "E", "protein": "E",

View File

@ -71,10 +71,40 @@
"create": "Create", "create": "Create",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"close": "Close",
"actions": "Action", "actions": "Action",
"loading": "Loading...", "loading": "Loading...",
"kcalUnit": "kcal" "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": { "nutrition": {
"short": { "short": {
"protein": "P", "protein": "P",

View File

@ -71,10 +71,40 @@
"create": "Crear", "create": "Crear",
"edit": "Editar", "edit": "Editar",
"delete": "Eliminar", "delete": "Eliminar",
"close": "Cerrar",
"actions": "Acción", "actions": "Acción",
"loading": "Cargando...", "loading": "Cargando...",
"kcalUnit": "kcal" "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": { "nutrition": {
"short": { "short": {
"protein": "P", "protein": "P",

View File

@ -71,10 +71,40 @@
"create": "Vytvoriť", "create": "Vytvoriť",
"edit": "Upraviť", "edit": "Upraviť",
"delete": "Zmazať", "delete": "Zmazať",
"close": "Zavrieť",
"actions": "Akcia", "actions": "Akcia",
"loading": "Načítavam...", "loading": "Načítavam...",
"kcalUnit": "kcal" "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": { "nutrition": {
"short": { "short": {
"protein": "B", "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,18 +3,29 @@ import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IngredientForm from '@/components/ingredients/IngredientForm.vue' import IngredientForm from '@/components/ingredients/IngredientForm.vue'
import { useIngredientsStore } from '@/stores/ingredients' import { useIngredientsStore } from '@/stores/ingredients'
import { useUIStore } from '@/stores/ui'
import type { Ingredient } from '@/types/domain' import type { Ingredient } from '@/types/domain'
import { toErrorMessage } from '@/utils/error'
const ingredientsStore = useIngredientsStore() const ingredientsStore = useIngredientsStore()
const ui = useUIStore()
const editingId = ref<number | null>(null) const editingId = ref<number | null>(null)
const saving = ref(false) const saving = ref(false)
const formMode = ref<'hidden' | 'create' | 'edit'>('hidden') const formMode = ref<'hidden' | 'create' | 'edit'>('hidden')
const loadError = ref('')
const actionError = ref('')
const { t } = useI18n() const { t } = useI18n()
onMounted(async () => { onMounted(async () => {
try {
loadError.value = ''
if (ingredientsStore.items.length <= 0) { if (ingredientsStore.items.length <= 0) {
await ingredientsStore.loadIngredients() await ingredientsStore.loadIngredients()
} }
} catch (error) {
loadError.value = toErrorMessage(error, t('ux.errors.loadIngredients'))
ui.error(loadError.value)
}
}) })
const editingIngredient = computed<Ingredient | null>(() => { const editingIngredient = computed<Ingredient | null>(() => {
@ -25,11 +36,13 @@ const editingIngredient = computed<Ingredient | null>(() => {
}) })
const startCreate = () => { const startCreate = () => {
actionError.value = ''
editingId.value = null editingId.value = null
formMode.value = 'create' formMode.value = 'create'
} }
const startEdit = (ingredientId: number) => { const startEdit = (ingredientId: number) => {
actionError.value = ''
editingId.value = ingredientId editingId.value = ingredientId
formMode.value = 'edit' formMode.value = 'edit'
} }
@ -37,6 +50,7 @@ const startEdit = (ingredientId: number) => {
const cancelEdit = () => { const cancelEdit = () => {
editingId.value = null editingId.value = null
formMode.value = 'hidden' formMode.value = 'hidden'
actionError.value = ''
} }
const saveIngredient = async (payload: { const saveIngredient = async (payload: {
@ -49,25 +63,49 @@ const saveIngredient = async (payload: {
kcal_100: number kcal_100: number
}) => { }) => {
saving.value = true saving.value = true
actionError.value = ''
try { try {
if (editingIngredient.value) { if (editingIngredient.value) {
await ingredientsStore.updateIngredient(editingIngredient.value.ingredient_id, payload) await ingredientsStore.updateIngredient(editingIngredient.value.ingredient_id, payload)
ui.success(t('ux.toast.saved'))
} else { } else {
await ingredientsStore.createIngredient(payload) await ingredientsStore.createIngredient(payload)
ui.success(t('ux.toast.created'))
} }
editingId.value = null editingId.value = null
formMode.value = 'hidden' formMode.value = 'hidden'
} catch (error) {
actionError.value = toErrorMessage(error, t('ux.errors.saveIngredient'))
ui.error(actionError.value)
} finally { } finally {
saving.value = false saving.value = false
} }
} }
const removeIngredient = async (ingredientId: number) => { 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) await ingredientsStore.deleteIngredient(ingredientId)
ui.success(t('ux.toast.deleted'))
if (editingId.value === ingredientId) { if (editingId.value === ingredientId) {
editingId.value = null editingId.value = null
formMode.value = 'hidden' formMode.value = 'hidden'
} }
} catch (error) {
actionError.value = toErrorMessage(error, t('ux.errors.deleteIngredient'))
ui.error(actionError.value)
}
} }
</script> </script>
@ -86,7 +124,11 @@ const removeIngredient = async (ingredientId: number) => {
<h3>{{ t('ingredients.databaseTitle') }}</h3> <h3>{{ t('ingredients.databaseTitle') }}</h3>
<button class="btn" type="button" @click="startCreate">{{ t('ingredients.newButton') }}</button> <button class="btn" type="button" @click="startCreate">{{ t('ingredients.newButton') }}</button>
</div> </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 class="list-row" v-for="ingredient in ingredientsStore.sortedItems" :key="ingredient.ingredient_id">
<div> <div>
<strong>{{ ingredient.name }}</strong> <strong>{{ ingredient.name }}</strong>

View File

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

View File

@ -4,21 +4,31 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import MealTypeFilter from '@/components/meals/MealTypeFilter.vue' import MealTypeFilter from '@/components/meals/MealTypeFilter.vue'
import { useMealsStore } from '@/stores/meals' import { useMealsStore } from '@/stores/meals'
import { useUIStore } from '@/stores/ui'
import type { MealType } from '@/types/domain' import type { MealType } from '@/types/domain'
import { toErrorMessage } from '@/utils/error'
const router = useRouter() const router = useRouter()
const mealsStore = useMealsStore() const mealsStore = useMealsStore()
const ui = useUIStore()
const { t } = useI18n() const { t } = useI18n()
const filterType = ref<MealType | ''>('') const filterType = ref<MealType | ''>('')
const newMealName = ref('') const newMealName = ref('')
const newMealType = ref<MealType>('breakfast') const newMealType = ref<MealType>('breakfast')
const creating = ref(false) const creating = ref(false)
const errorMessage = ref('')
onMounted(async () => { onMounted(async () => {
try {
errorMessage.value = ''
if (mealsStore.list.length <= 0) { if (mealsStore.list.length <= 0) {
await mealsStore.loadMeals() await mealsStore.loadMeals()
} }
} catch (error) {
errorMessage.value = toErrorMessage(error, t('ux.errors.loadMeals'))
ui.error(errorMessage.value)
}
}) })
const filteredMeals = computed(() => { const filteredMeals = computed(() => {
@ -33,14 +43,20 @@ const createMeal = async () => {
if (name.length <= 0) { if (name.length <= 0) {
return return
} }
errorMessage.value = ''
creating.value = true creating.value = true
try { try {
const meal = await mealsStore.createMeal({ const meal = await mealsStore.createMeal({
name, name,
meal_type: newMealType.value, meal_type: newMealType.value,
}) })
ui.success(t('ux.toast.created'))
newMealName.value = '' newMealName.value = ''
await router.push({ name: 'meal-detail', params: { mealId: meal.meal_id } }) 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 { } finally {
creating.value = false creating.value = false
} }
@ -67,7 +83,9 @@ const createMeal = async () => {
<section class="card"> <section class="card">
<h3>{{ t('meals.libraryTitle') }}</h3> <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 <RouterLink
v-for="meal in filteredMeals" v-for="meal in filteredMeals"
:key="meal.meal_id" :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 DayTotalsCard from '@/components/today/DayTotalsCard.vue'
import { useDiaryStore } from '@/stores/diary' import { useDiaryStore } from '@/stores/diary'
import { useMealsStore } from '@/stores/meals' import { useMealsStore } from '@/stores/meals'
import { useUIStore } from '@/stores/ui'
import { MEAL_TYPES, type MealType } from '@/types/domain' import { MEAL_TYPES, type MealType } from '@/types/domain'
import { todayISO } from '@/utils/date' import { todayISO } from '@/utils/date'
import { toErrorMessage } from '@/utils/error'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const diaryStore = useDiaryStore() const diaryStore = useDiaryStore()
const mealsStore = useMealsStore() const mealsStore = useMealsStore()
const ui = useUIStore()
const { t } = useI18n() const { t } = useI18n()
const isWorking = ref(false) const isWorking = ref(false)
const selectedDate = ref(todayISO()) const selectedDate = ref(todayISO())
const errorMessage = ref('')
const resolveDate = (): string => { const resolveDate = (): string => {
const dateFromRoute = typeof route.params.date === 'string' ? route.params.date : '' 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) => { const reloadDay = async (date: string) => {
selectedDate.value = date selectedDate.value = date
diaryStore.ensureCurrentDay(date) diaryStore.ensureCurrentDay(date)
errorMessage.value = ''
isWorking.value = true isWorking.value = true
try { try {
if (mealsStore.list.length <= 0) { if (mealsStore.list.length <= 0) {
await mealsStore.loadMeals() await mealsStore.loadMeals()
} }
await diaryStore.loadDay(date) await diaryStore.loadDay(date)
} catch (error) {
errorMessage.value = toErrorMessage(error, t('ux.errors.loadDay'))
ui.error(errorMessage.value)
} finally { } finally {
isWorking.value = false isWorking.value = false
} }
@ -55,11 +63,19 @@ const onDateChange = async () => {
} }
const onSelectMeal = async (mealType: MealType, mealId: number | null) => { const onSelectMeal = async (mealType: MealType, mealId: number | null) => {
try {
errorMessage.value = ''
if (mealId === null) { if (mealId === null) {
await diaryStore.unsetMealForType(selectedDate.value, mealType) await diaryStore.unsetMealForType(selectedDate.value, mealType)
ui.success(t('ux.toast.updated'))
return return
} }
await diaryStore.setMealForType(selectedDate.value, mealType, mealId) 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) const dayMeals = computed(() => diaryStore.mealsByType)
@ -75,7 +91,8 @@ const dayTotals = computed(() => diaryStore.computedTotals)
</label> </label>
</div> </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> <template v-else>
<div class="grid-three"> <div class="grid-three">