From 1d5b730e11acc1ae03729f4d804d3510656c28b2 Mon Sep 17 00:00:00 2001 From: igor Date: Sat, 14 Feb 2026 08:22:35 +0100 Subject: [PATCH] added toast and modal confirmation --- AGENTS.md | 22 +++- doc/prompt.txt | 5 +- frontend/src/App.vue | 6 +- frontend/src/assets/css/style.css | 103 ++++++++++++++++++ .../components/common/ConfirmModalHost.vue | 27 +++++ frontend/src/components/common/ToastHost.vue | 28 +++++ frontend/src/locales/cs.json | 30 +++++ frontend/src/locales/de.json | 30 +++++ frontend/src/locales/en.json | 30 +++++ frontend/src/locales/es.json | 30 +++++ frontend/src/locales/sk.json | 30 +++++ frontend/src/stores/ui.ts | 84 ++++++++++++++ frontend/src/utils/error.ts | 11 ++ frontend/src/views/IngredientsView.vue | 56 ++++++++-- frontend/src/views/MealDetailView.vue | 103 +++++++++++++++--- frontend/src/views/MealsView.vue | 24 +++- frontend/src/views/TodayView.vue | 27 ++++- 17 files changed, 604 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/common/ConfirmModalHost.vue create mode 100644 frontend/src/components/common/ToastHost.vue create mode 100644 frontend/src/stores/ui.ts create mode 100644 frontend/src/utils/error.ts diff --git a/AGENTS.md b/AGENTS.md index 6b59c5a..c425b53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/doc/prompt.txt b/doc/prompt.txt index 47949a1..cdafb77 100644 --- a/doc/prompt.txt +++ b/doc/prompt.txt @@ -44,6 +44,5 @@ dopln kluce pre UI texty pre preklad, pre kluce pouzivaj anglictinu, dopln potom ----- 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 - ---- -doplniť jemnejší UX flow (toasty, confirm modaly pri delete, loading/error states per card) \ No newline at end of file +----- 2026-02-14 08:08:33 ----------------------------------------------------- +dopln jemnejší UX flow (toasty, confirm modaly pri delete, loading/error states per card) \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a78fb6f..b7bf45c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,7 +1,11 @@ diff --git a/frontend/src/assets/css/style.css b/frontend/src/assets/css/style.css index 6570916..bae23f2 100644 --- a/frontend/src/assets/css/style.css +++ b/frontend/src/assets/css/style.css @@ -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%; + } } diff --git a/frontend/src/components/common/ConfirmModalHost.vue b/frontend/src/components/common/ConfirmModalHost.vue new file mode 100644 index 0000000..09f1fe0 --- /dev/null +++ b/frontend/src/components/common/ConfirmModalHost.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/common/ToastHost.vue b/frontend/src/components/common/ToastHost.vue new file mode 100644 index 0000000..eff1603 --- /dev/null +++ b/frontend/src/components/common/ToastHost.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/locales/cs.json b/frontend/src/locales/cs.json index c21fd7d..387716d 100644 --- a/frontend/src/locales/cs.json +++ b/frontend/src/locales/cs.json @@ -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", diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index e74a2a8..eb440a7 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -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", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 6240fc8..8de4816 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -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", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 6116bf0..9da5395 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -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", diff --git a/frontend/src/locales/sk.json b/frontend/src/locales/sk.json index a5f9353..8cb5b40 100644 --- a/frontend/src/locales/sk.json +++ b/frontend/src/locales/sk.json @@ -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", diff --git a/frontend/src/stores/ui.ts b/frontend/src/stores/ui.ts new file mode 100644 index 0000000..96ae003 --- /dev/null +++ b/frontend/src/stores/ui.ts @@ -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([]) + + const confirmState = ref({ + 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 => { + if (confirmResolver) { + confirmResolver(false) + } + + confirmState.value = { + open: true, + ...options, + } + + return new Promise((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, + } +}) diff --git a/frontend/src/utils/error.ts b/frontend/src/utils/error.ts new file mode 100644 index 0000000..21856b6 --- /dev/null +++ b/frontend/src/utils/error.ts @@ -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 +} diff --git a/frontend/src/views/IngredientsView.vue b/frontend/src/views/IngredientsView.vue index 94a55c9..a0ab33a 100644 --- a/frontend/src/views/IngredientsView.vue +++ b/frontend/src/views/IngredientsView.vue @@ -3,17 +3,28 @@ 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(null) const saving = ref(false) const formMode = ref<'hidden' | 'create' | 'edit'>('hidden') +const loadError = ref('') +const actionError = ref('') const { t } = useI18n() onMounted(async () => { - if (ingredientsStore.items.length <= 0) { - await ingredientsStore.loadIngredients() + 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) } }) @@ -25,11 +36,13 @@ const editingIngredient = computed(() => { }) const startCreate = () => { + actionError.value = '' editingId.value = null formMode.value = 'create' } const startEdit = (ingredientId: number) => { + actionError.value = '' editingId.value = ingredientId formMode.value = 'edit' } @@ -37,6 +50,7 @@ const startEdit = (ingredientId: number) => { const cancelEdit = () => { editingId.value = null formMode.value = 'hidden' + actionError.value = '' } const saveIngredient = async (payload: { @@ -49,24 +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) => { - await ingredientsStore.deleteIngredient(ingredientId) - if (editingId.value === ingredientId) { - editingId.value = null - formMode.value = 'hidden' + 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) } } @@ -86,7 +124,11 @@ const removeIngredient = async (ingredientId: number) => {

{{ t('ingredients.databaseTitle') }}

-
+ +

{{ loadError }}

+

{{ t('common.loading') }}

+

{{ actionError }}

+
{{ ingredient.name }} diff --git a/frontend/src/views/MealDetailView.vue b/frontend/src/views/MealDetailView.vue index 5333f6b..27291f5 100644 --- a/frontend/src/views/MealDetailView.vue +++ b/frontend/src/views/MealDetailView.vue @@ -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('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 } - await mealsStore.deleteMeal(meal.value.meal_id) - await router.replace({ name: 'meals' }) + + 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 } - await mealsStore.addMealItem(meal.value.meal_id, { - ...payload, - position: (meal.value.items?.length ?? 0) + 1, - }) + 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,28 +138,54 @@ const updateItem = async (payload: { 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, - ) + actionError.value = '' + try { + await mealsStore.updateMealItem( + payload.meal_item_id, + { + ingredient_id: payload.ingredient_id, + grams: payload.grams, + position: payload.position, + }, + 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 } - await mealsStore.deleteMealItem(meal.value.meal_id, mealItemId) + + 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) + } } diff --git a/frontend/src/views/MealsView.vue b/frontend/src/views/MealsView.vue index 7fc8829..a6f1260 100644 --- a/frontend/src/views/MealsView.vue +++ b/frontend/src/views/MealsView.vue @@ -4,20 +4,30 @@ 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('') const newMealName = ref('') const newMealType = ref('breakfast') const creating = ref(false) +const errorMessage = ref('') onMounted(async () => { - if (mealsStore.list.length <= 0) { - await mealsStore.loadMeals() + 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) } }) @@ -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 () => {

{{ t('meals.libraryTitle') }}

-
+

{{ errorMessage }}

+

{{ t('common.loading') }}

+
{ 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) => { - if (mealId === null) { - await diaryStore.unsetMealForType(selectedDate.value, mealType) - return + 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) } - await diaryStore.setMealForType(selectedDate.value, mealType, mealId) } const dayMeals = computed(() => diaryStore.mealsByType) @@ -75,7 +91,8 @@ const dayTotals = computed(() => diaryStore.computedTotals)
-
{{ t('today.loadingDay') }}
+
{{ errorMessage }}
+
{{ t('today.loadingDay') }}