added toast and modal confirmation
This commit is contained in:
22
AGENTS.md
22
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.
|
||||
|
||||
@ -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)
|
||||
----- 2026-02-14 08:08:33 -----------------------------------------------------
|
||||
dopln jemnejší UX flow (toasty, confirm modaly pri delete, loading/error states per card)
|
||||
@ -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>
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
27
frontend/src/components/common/ConfirmModalHost.vue
Normal file
27
frontend/src/components/common/ConfirmModalHost.vue
Normal 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>
|
||||
28
frontend/src/components/common/ToastHost.vue
Normal file
28
frontend/src/components/common/ToastHost.vue
Normal 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>
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
84
frontend/src/stores/ui.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
11
frontend/src/utils/error.ts
Normal file
11
frontend/src/utils/error.ts
Normal 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
|
||||
}
|
||||
@ -3,18 +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>(() => {
|
||||
@ -25,11 +36,13 @@ 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'
|
||||
}
|
||||
@ -37,6 +50,7 @@ const startEdit = (ingredientId: number) => {
|
||||
const cancelEdit = () => {
|
||||
editingId.value = null
|
||||
formMode.value = 'hidden'
|
||||
actionError.value = ''
|
||||
}
|
||||
|
||||
const saveIngredient = async (payload: {
|
||||
@ -49,25 +63,49 @@ 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>
|
||||
|
||||
@ -86,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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
|
||||
Reference in New Issue
Block a user