Compare commits

...

12 Commits

Author SHA1 Message Date
02e9aa1ac4 token validity increase from 1 hour to 7 days 2026-02-16 07:41:49 +01:00
b3be74e652 changed path to SFTPsync utility in deploy script 2026-02-16 07:35:34 +01:00
af437963bb added change language into settings 2026-02-15 18:16:01 +01:00
6ff28dae13 added PWA manifest,
added deploy script for Windows
2026-02-15 17:28:16 +01:00
69e5f4e320 added Windows batch script for build /dist for distribution of project to server 2026-02-14 14:14:19 +01:00
b0b5d0972a update AGENTS.md 2026-02-14 13:27:04 +01:00
2468b31462 added fonts as local files 2026-02-14 13:20:15 +01:00
be3c355b37 improved label in MacroBadge.vue 2026-02-14 13:09:33 +01:00
ee144847bd added component for MacroBadge,
used MacroBadge.vue in Day Meal, Meal View and Ingrediens View
2026-02-14 13:04:37 +01:00
2b237d3d71 auto-logout after session expired 2026-02-14 12:41:32 +01:00
1d5b730e11 added toast and modal confirmation 2026-02-14 08:22:35 +01:00
072f44c213 added show/hide for ingrediens form 2026-02-14 08:08:26 +01:00
45 changed files with 1390 additions and 94 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
/backend/config/Config.cust.php
/frontend/node_modules
/frontend/dist
/dist

View File

@ -33,21 +33,52 @@ 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`
- expired-session handling is global:
- token error detector in `frontend/src/utils/error.ts` emits `auth:session-expired`
- listener in `frontend/src/main.ts` clears session, shows info toast, and redirects to auth route
- 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`)
- `nutrition.short.fiber` exists in all locales and is used in macro badges
- `ux.toast.sessionExpired` exists in all locales for auto-logout flow
- 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).
- Frontend macro-badge visual system is implemented:
- shared component: `frontend/src/components/common/MacroBadge.vue`
- shared colors/tokens/styles in `frontend/src/assets/css/style.css`
- used in `IngredientsView`, `MealsView`, `DayTotalsCard`, and `DayMealCard`
- `Today` day totals card layout: kcal block on the left, macros on the right in one row
- macro colors:
- protein `#3B82F6`
- carbs `#F59E0B`
- fat `#EF4444`
- fiber `#10B981`
- style uses subtle tinted background + full-color text, pill shape (no saturated full backgrounds)
- Frontend typography is local (no remote font CDN):
- `frontend/src/assets/css/style.css` uses local `@font-face` for `DM Sans` and `Space Grotesk`
- font files are stored in `frontend/src/assets/fonts/*.woff2`
- 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 `Nova surovina` or `Upravit`
- 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).
@ -156,6 +187,8 @@ All actions are invoked through `backend/public/API.php` with `?action=<method_n
- raw API response is wrapped as `{ status, data }`
- generated `BackendAPI.ts` currently resolves `response.data` in `callPromise` for non-`__HELP__` actions
- frontend stores use `frontend/src/utils/api.ts` (`unwrapApiData`) to normalize both envelope and unwrapped runtime shapes
- Auto-logout on expired token currently depends on the shared error mapper:
- if a view/store handles API errors without `toErrorMessage(...)`, global `auth:session-expired` event is not emitted there
- `frontend/src/BackendAPI.ts` is generated output; regenerate when backend API changes, do not patch manually.
- In vue-i18n locale strings, `@` must be escaped as `{'@'}` to avoid "Invalid linked format" errors.
@ -181,7 +214,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.
@ -194,3 +227,4 @@ Frontend:
- When changing schema, always bump DB version in `Maintenance.php` with forward-only migration steps.
- Keep API action names stable unless frontend is updated at the same time.
- In source files, use tab characters for indentation (do not add space-based indentation).

View File

@ -105,7 +105,7 @@ class Users extends \TPsoft\DBmodel\DBmodel {
return password_verify($password, $password_hash);
}
public function generateToken(int $user_id, int $ttl_seconds = 3600): string {
public function generateToken(int $user_id, int $ttl_seconds = 604800): string {
if ($user_id <= 0) {
throw new \Exception('Invalid user_id');
}
@ -152,7 +152,7 @@ class Users extends \TPsoft\DBmodel\DBmodel {
if (!hash_equals($stored_token, $token)) {
return false;
}
$refresh_expires = date('Y-m-d H:i:s', time() + 3600);
$refresh_expires = date('Y-m-d H:i:s', time() + 604800); // 7 days
$updated = $this->user($user_id, array(
'token_expires' => $refresh_expires
));

94
build.bat Normal file
View File

@ -0,0 +1,94 @@
@echo off
setlocal EnableExtensions
set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
set "DIST_DIR=%ROOT%\dist"
set "DIST_PUBLIC=%DIST_DIR%\public"
set "DIST_APP=%DIST_PUBLIC%"
set "FRONTEND_DIR=%ROOT%\frontend"
set "BACKEND_DIR=%ROOT%\backend"
echo [1/6] Cleaning dist...
if exist "%DIST_DIR%" (
rmdir /S /Q "%DIST_DIR%"
if exist "%DIST_DIR%" (
echo ERROR: Failed to remove "%DIST_DIR%".
exit /b 1
)
)
mkdir "%DIST_DIR%" >nul 2>&1 || (
echo ERROR: Failed to create "%DIST_DIR%".
exit /b 1
)
echo [2/6] Building frontend...
pushd "%FRONTEND_DIR%" >nul 2>&1 || (
echo ERROR: Frontend directory not found: "%FRONTEND_DIR%".
exit /b 1
)
echo - npm install
call npm install
if errorlevel 1 (
popd >nul
echo ERROR: npm install failed.
exit /b 1
)
echo - npm run build
call npm run build
if errorlevel 1 (
popd >nul
echo ERROR: npm run build failed.
exit /b 1
)
if not exist "%FRONTEND_DIR%\dist" (
popd >nul
echo ERROR: Frontend build output not found at "%FRONTEND_DIR%\dist".
exit /b 1
)
popd >nul
echo [3/6] Copy backend root to dist/...
call :RunRobocopy "%BACKEND_DIR%" "%DIST_DIR%" /E /R:2 /W:1 /NFL /NDL /NJH /NJS /NP ^
/XD "%BACKEND_DIR%\.git" "%BACKEND_DIR%\.vscode" "%BACKEND_DIR%\tests" "%BACKEND_DIR%\node_modules" "%BACKEND_DIR%\frontend" "%BACKEND_DIR%\dist" ^
/XF ".env" ".env.*"
if errorlevel 1 exit /b 1
echo [4/6] Copy frontend/dist to dist/public...
call :RunRobocopy "%FRONTEND_DIR%\dist" "%DIST_APP%" /E /R:2 /W:1 /NFL /NDL /NJH /NJS /NP
if errorlevel 1 exit /b 1
echo [5/6] Applying .env.production (if present)...
set "ENV_NOTE=No backend/.env.production found"
if exist "%BACKEND_DIR%\.env.production" (
copy /Y "%BACKEND_DIR%\.env.production" "%DIST_DIR%\.env" >nul
if errorlevel 1 (
echo ERROR: Failed to copy backend/.env.production to dist/.env.
exit /b 1
)
set "ENV_NOTE=Copied backend/.env.production to dist/.env"
)
echo [6/6] Summary
echo - Frontend build: OK
echo - Backend root copied to: "%DIST_DIR%" (excluding excluded dirs)
echo - Frontend assets copied to: "%DIST_APP%"
echo - Env: %ENV_NOTE%
echo - DocumentRoot should be: "%DIST_PUBLIC%"
echo - Frontend app is served from: "%DIST_APP%"
echo DONE "%DIST_DIR%"
exit /b 0
:RunRobocopy
robocopy %*
if errorlevel 8 (
echo ERROR: robocopy failed with exit code %ERRORLEVEL%.
exit /b 1
)
exit /b 0

9
deploy.bat Normal file
View File

@ -0,0 +1,9 @@
@echo off
call php d:\www\sftpsync\src\sftpsync.php --host nutrio.tpsoft.org --user igor ^
--delete-dir /storage/tpsoft.org/nutrio/public ^
--sync d:/www/Nutrio/dist /storage/tpsoft.org/nutrio ^
--skip .git ^
--print-relative
echo ✔️ Done.

View File

@ -41,5 +41,198 @@ Výstup: konkrétny návrh + ukážky kódu, nie všeobecné rady.
----- 2026-02-14 07:16:25 -----------------------------------------------------
dopln kluce pre UI texty pre preklad, pre kluce pouzivaj anglictinu, dopln potom aj vsetky preklady vo frontend/src/locales/*.json
----- 2026-02-14 07:33:30 -----------------------------------------------------
doplniť jemnejší UX flow (toasty, confirm modaly pri delete, loading/error states per card)
----- 2026-02-14 08:05:38 -----------------------------------------------------
na zobrazeni app/ingredients sprav aby formular pre pridanie novej suroviny sa zobrazil az ked kliknem na tlacitko Nova surovina, a po uspesnom ulozeni ten formular pre pridanie novej suroviny skry, nezabudni vynulovat udaje vo formulari po ulozeni
----- 2026-02-14 08:08:33 -----------------------------------------------------
dopln jemnejší UX flow (toasty, confirm modaly pri delete, loading/error states per card)
----- 2026-02-14 12:44:33 -----------------------------------------------------
v zozname surovin app/ingredients sa zobrazuju len bielkoviny, sacharidy a tuky, pridaj tam aj vlakninu,
zaroven to chcem farebne rozlisit, urob to ako farebny stitok, ze bielkoviny budu modre, sacharidy oranzove, tuky cervene (mierne tlmena) a vlaknina tmavsia zelena
podobne farebne to rozlis aj na denndom prehlade app/today v sucte dna a tiez tam pridaj vlakninu
v zozname jedalnickov app/meals je pri kazdom len pocet kalorii, pridaj tam tiez sumar makrozivin (bielkoviny, sacharidy, tuky a vlaknina)
GPT upravil takto:
Rozšír UI aplikácie Nutrio nasledovne:
1) Ingredients list (route: /app/ingredients)
- Aktuálne sa zobrazujú len bielkoviny, sacharidy a tuky.
- Pridaj zobrazenie vlákniny (fiber_g_100).
- Makrá zobraz ako malé farebné štítky (badge), nie ako obyčajný text.
Farby štítkov:
- Bielkoviny (protein) → modrá #3B82F6
- Sacharidy (carbs) → oranžová #F59E0B
- Tuky (fat) → tlmená červená #EF4444
- Vláknina (fiber) → tmavšia zelená #10B981
Štýl badge:
- malé zaoblené pill tvary
- jemné svetlé pozadie (napr. 1015% opacity farby)
- text farba plná farba
- formát: B 12g / S 24g / T 5g / V 6g
2) Today page (route: /app/today)
- V dennom súčte pridaj vlákninu.
- Makrá zobraz rovnakým farebným štýlom ako v ingredients.
- Kalórie nech ostanú neutrálne (tmavá šedá), vizuálne dominantné veľkosťou písma, nie farbou.
Layout:
- hore veľké číslo kcal
- pod tým horizontálne makrá ako farebné štítky
3) Meals list (route: /app/meals)
- Aktuálne sa zobrazuje len počet kalórií.
- Pridaj aj súhrn makier (protein, carbs, fat, fiber).
- Zobraz ich rovnakým badge systémom pre konzistentnosť.
- Kcal nech je oddelené (napr. nad makrami alebo výraznejšie písmo).
4) Konzistentnosť:
- Použi jeden spoločný komponent napr. <MacroBadge />
- Nepoužívaj sýte plné farebné pozadia.
- Použi minimalistický SaaS štýl.
- Farby makier musia byť identické naprieč celou aplikáciou.
- Nepridávaj nové knižnice.
Výsledok:
Čistý, konzistentný, moderný vzhľad bez prehnaných farieb.
----- 2026-02-14 13:35:31 -----------------------------------------------------
V koreňovom adresári projektu vytvor Windows batch skript build.bat.
Štruktúra projektu:
- frontend/ (Vue 3 + Vite) -> build do frontend/dist
- backend/ (PHP) -> web root je backend/public a PHP súbory v public používajú relatívne cesty typu ../src/Init.php
=> Musíme zachovať štruktúru, aby v dist platilo: dist/public/.. = dist/
Cieľ:
- v koreňi vytvoriť root/dist tak, aby som na serveri nastavil DocumentRoot na dist/public
- relatívne cesty v PHP z dist/public na dist/src musia fungovať (napr. dist/public/index.php -> ../src/Init.php)
- frontend (Vite) nasadiť ako statické súbory do dist/public/app (aby nekolidoval s backend/public/index.php)
Výsledná štruktúra dist:
- dist/public/ (WEB ROOT) = kópia backend/public
- dist/src/ = kópia backend/src
- dist/vendor/ = kópia backend/vendor (ak existuje)
- dist/config/ = kópia backend/config (ak existuje)
- dist/… = ostatné potrebné backend súbory/adresáre z backend/ (mimo public)
- dist/public/app/ = Vite build (obsah frontend/dist)
Požiadavky na build.bat:
1) Vyčistenie:
- ak existuje root/dist, zmaž ho celý
- vytvor root/dist
2) Frontend build (nepoužívaj npm ci):
- cd frontend
- spusti "npm install"
- spusti "npm run build"
- ak build zlyhá, ukonči skript s exit code 1
- vráť sa do root
3) Kopírovanie backend do dist:
- skopíruj backend/public -> dist/public
- skopíruj všetko potrebné z backend/ do dist/ TAK, aby platili relatívne cesty z dist/public na dist/src atď.
Konkrétne:
- kopíruj backend/src -> dist/src (ak existuje)
- kopíruj backend/vendor -> dist/vendor (ak existuje)
- kopíruj ďalšie bežné backend adresáre (napr. config, templates, storage…) do dist/ na rovnakú úroveň ako public
- NEkopíruj backend/public druhýkrát mimo dist/public
- NEkopíruj: .git, .vscode, tests, node_modules, frontend, dist
4) Skopírovanie Vite buildu do dist/public:
- vytvor dist/public
- skopíruj obsah frontend/dist -> dist/public
5) .env:
- ak existuje backend/.env.production, skopíruj ho do dist/.env
- inak nič
6) Robustnosť:
- použi robocopy na kopírovanie (rýchle a s exclude)
- kontroluj ERRORLEVEL po každom kritickom kroku a pri chybe ukonči skript s exit code 1
- používaj echo logy (kroky + DONE)
7) Výstup:
- na konci vypíš, že DocumentRoot má byť nastavený na dist/public a frontend je v dist/public
Dodaj kompletný obsah súboru build.bat.
----- 2026-02-15 17:05:23 -----------------------------------------------------
Úloha:
V projekte vytvor plne funkčnú konfiguráciu PWA.
1⃣ Manifest
Vytvor súbor: frontend/public/manifest.json
Obsah musí:
- byť validný JSON
obsahovať:
- name
- short_name
- start_url
- display = "standalone"
- background_color
- theme_color
- icons pole
Použi tieto hodnoty:
{
"name": "Nutrio",
"short_name": "Nutrio",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#22c55e"
}
2⃣ Ikony
Zdrojový obrázok:
frontend/public/Nutrio.600.png
Z neho vytvor PNG ikony do adresára:
frontend/public/icons/
Vygeneruj tieto veľkosti:
16x16
32x32
48x48
180x180 (apple touch icon)
192x192 (PWA required)
512x512 (PWA required)
Požiadavky:
zachovať pomer strán
zachovať priehľadnosť
nepoužívať žiadne orezávanie
výstupné názvy:
icon-16.png
icon-32.png
icon-48.png
icon-180.png
icon-192.png
icon-512.png
3⃣ Manifest icons sekcia
Pole icons musí obsahovať iba:
[
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
"src": "/icons/icon-512.png",
"sizes": "512x512",
}
]
4⃣ Úprava index.html
Do frontend/index.html pridaj do <head>:
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/icon-16.png">
<link rel="apple-touch-icon" href="/icons/icon-180.png">
<meta name="theme-color" content="#22c55e">
5⃣ Výstup
nevysvetľuj
nevypisuj komentáre
vytvor alebo uprav iba potrebné súbory
ak adresár icons neexistuje, vytvor ho

View File

@ -2,7 +2,11 @@
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/icon-16.png">
<link rel="apple-touch-icon" href="/icons/icon-180.png">
<meta name="theme-color" content="#22c55e">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nutrio</title>
</head>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -0,0 +1,20 @@
{
"name": "Nutrio",
"short_name": "Nutrio",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#22c55e",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

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

View File

@ -1,4 +1,92 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Grotesk:wght@500;700&display=swap');
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('../fonts/dm-sans-latin-ext-400-normal.woff2') format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('../fonts/dm-sans-latin-400-normal.woff2') format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url('../fonts/dm-sans-latin-ext-500-normal.woff2') format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url('../fonts/dm-sans-latin-500-normal.woff2') format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url('../fonts/dm-sans-latin-ext-700-normal.woff2') format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url('../fonts/dm-sans-latin-700-normal.woff2') format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url('../fonts/space-grotesk-latin-ext-500-normal.woff2') format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url('../fonts/space-grotesk-latin-500-normal.woff2') format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url('../fonts/space-grotesk-latin-ext-700-normal.woff2') format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url('../fonts/space-grotesk-latin-700-normal.woff2') format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
:root {
--color-white: #ffffff;
@ -13,6 +101,10 @@
--color-success-bg: #e7f4d8;
--color-error-bg: #f6dde0;
--color-error: #7d2430;
--macro-protein: #3B82F6;
--macro-carbs: #F59E0B;
--macro-fat: #EF4444;
--macro-fiber: #10B981;
--radius-md: 0.875rem;
--radius-lg: 1.25rem;
--space-xs: 0.5rem;
@ -27,6 +119,8 @@
--fs-xl: 1.875rem;
--shadow-soft: 0 20px 40px -30px var(--color-shadow);
--transition-fast: 160ms ease;
--font-body: 'DM Sans', 'Segoe UI', Tahoma, sans-serif;
--font-display: 'Space Grotesk', 'Segoe UI Semibold', 'Trebuchet MS', sans-serif;
}
:root[data-theme='dark'] {
@ -55,7 +149,7 @@ body,
body {
margin: 0;
font-family: 'DM Sans', 'Segoe UI', sans-serif;
font-family: var(--font-body);
font-size: var(--fs-md);
color: var(--color-text);
background:
@ -105,7 +199,7 @@ select {
.auth-brand h1 {
margin: 0;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
font-family: var(--font-display);
font-size: clamp(2rem, 5vw, 2.75rem);
letter-spacing: 0.04em;
}
@ -176,7 +270,7 @@ select {
.auth-card h2 {
margin: var(--space-sm) 0 0;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
font-family: var(--font-display);
font-size: var(--fs-xl);
}
@ -345,7 +439,7 @@ select {
display: flex;
align-items: center;
gap: var(--space-sm);
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
font-family: var(--font-display);
font-size: 1.1rem;
}
@ -397,7 +491,7 @@ select {
.app-topbar h1 {
margin: 0;
font-size: 1.25rem;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
font-family: var(--font-display);
}
.app-topbar__user {
@ -535,6 +629,20 @@ select {
font-size: var(--fs-sm);
}
.list-row__meta--nutrition {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.42rem;
}
.list-row__kcal {
font-size: 1rem;
font-weight: 700;
color: color-mix(in srgb, var(--color-text) 88%, var(--color-muted));
}
.list-row__actions {
display: flex;
gap: var(--space-xs);
@ -556,20 +664,107 @@ select {
color: var(--color-muted);
}
.totals-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-sm);
.day-meal-card__summary--nutrition {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.totals-grid span {
.day-meal-card__kcal {
font-size: var(--fs-sm);
font-weight: 700;
color: color-mix(in srgb, var(--color-text) 88%, var(--color-muted));
}
.totals-card__kcal {
display: flex;
flex-direction: column;
}
.totals-card__row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-md);
}
.totals-card__kcal-label {
display: block;
color: var(--color-muted);
font-size: var(--fs-sm);
}
.totals-grid strong {
font-size: 1.1rem;
.totals-card__kcal-value {
font-size: clamp(2rem, 5vw, 2.75rem);
line-height: 1.05;
letter-spacing: -0.015em;
color: color-mix(in srgb, var(--color-text) 90%, var(--color-muted));
}
.macro-badge-group {
display: flex;
flex-wrap: wrap;
gap: 0.42rem;
}
.macro-badge-group--with-top-gap {
margin-top: 0.45rem;
}
.macro-badge-group--compact {
gap: 0.32rem;
}
.macro-badge-group--right {
justify-content: flex-end;
}
.macro-badge-group--nowrap {
flex-wrap: nowrap;
overflow-x: auto;
max-width: 100%;
scrollbar-width: thin;
}
.macro-badge {
display: inline-flex;
align-items: center;
gap: 0.2rem;
border-radius: 999px;
padding: 0.24rem 0.56rem;
border: 1px solid transparent;
font-size: 0.76rem;
font-weight: 400;
line-height: 1;
white-space: nowrap;
}
.macro-badge__value {
font-weight: 700;
}
.macro-badge--protein {
color: var(--macro-protein);
border-color: color-mix(in srgb, var(--macro-protein) 30%, var(--color-border));
background: color-mix(in srgb, var(--macro-protein) 13%, var(--color-surface));
}
.macro-badge--carbs {
color: var(--macro-carbs);
border-color: color-mix(in srgb, var(--macro-carbs) 30%, var(--color-border));
background: color-mix(in srgb, var(--macro-carbs) 13%, var(--color-surface));
}
.macro-badge--fat {
color: var(--macro-fat);
border-color: color-mix(in srgb, var(--macro-fat) 30%, var(--color-border));
background: color-mix(in srgb, var(--macro-fat) 13%, var(--color-surface));
}
.macro-badge--fiber {
color: var(--macro-fiber);
border-color: color-mix(in srgb, var(--macro-fiber) 30%, var(--color-border));
background: color-mix(in srgb, var(--macro-fiber) 13%, var(--color-surface));
}
.meal-items-editor h3,
@ -633,6 +828,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;
}
@ -655,10 +942,6 @@ select {
grid-template-columns: 1fr;
}
.totals-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-two {
grid-template-columns: 1fr;
}
@ -699,4 +982,15 @@ select {
.app-topbar__actions {
gap: var(--space-xs);
}
.toast-host {
left: var(--space-sm);
right: var(--space-sm);
bottom: calc(62px + var(--space-sm));
}
.toast-item {
min-width: auto;
max-width: 100%;
}
}

View File

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

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue'
export type MacroType = 'protein' | 'carbs' | 'fat' | 'fiber'
const props = defineProps<{
macro: MacroType
label: string
grams: number
}>()
const formattedGrams = computed(() => {
if (!Number.isFinite(props.grams)) {
return '0'
}
return props.grams.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})
})
</script>
<template>
<span :class="['macro-badge', `macro-badge--${macro}`]">
<span>{{ label }}</span>
<strong class="macro-badge__value">{{ formattedGrams }}g</strong>
</span>
</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

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import MacroBadge from '@/components/common/MacroBadge.vue'
import type { Meal, MealType } from '@/types/domain'
const props = defineProps<{
@ -40,12 +41,15 @@ const selectedValue = computed({
{{ option.label }}
</option>
</select>
<p class="day-meal-card__summary" v-if="meal?.totals">
{{ meal.totals.kcal }} {{ t('common.kcalUnit') }}
· {{ t('nutrition.short.protein') }} {{ meal.totals.protein_g }} g
· {{ t('nutrition.short.carbs') }} {{ meal.totals.carbs_g }} g
· {{ t('nutrition.short.fat') }} {{ meal.totals.fat_g }} g
</p>
<div class="day-meal-card__summary day-meal-card__summary--nutrition" v-if="meal?.totals">
<span class="day-meal-card__kcal">{{ meal.totals.kcal }} {{ t('common.kcalUnit') }}</span>
<div class="macro-badge-group macro-badge-group--compact">
<MacroBadge macro="protein" :label="t('nutrition.short.protein')" :grams="meal.totals.protein_g" />
<MacroBadge macro="carbs" :label="t('nutrition.short.carbs')" :grams="meal.totals.carbs_g" />
<MacroBadge macro="fat" :label="t('nutrition.short.fat')" :grams="meal.totals.fat_g" />
<MacroBadge macro="fiber" :label="t('nutrition.short.fiber')" :grams="meal.totals.fiber_g" />
</div>
</div>
<p class="day-meal-card__summary" v-else>
{{ t('today.noItems') }}
</p>

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import MacroBadge from '@/components/common/MacroBadge.vue'
import type { NutritionTotals } from '@/types/domain'
defineProps<{
@ -12,22 +13,16 @@ const { t } = useI18n()
<template>
<section class="card totals-card">
<h3>{{ t('today.dayTotals') }}</h3>
<div class="totals-grid">
<div>
<span>{{ t('common.kcalUnit') }}</span>
<strong>{{ totals.kcal }}</strong>
<div class="totals-card__row">
<div class="totals-card__kcal">
<span class="totals-card__kcal-label">{{ t('common.kcalUnit') }}</span>
<strong class="totals-card__kcal-value">{{ totals.kcal }}</strong>
</div>
<div>
<span>{{ t('nutrition.labels.protein') }}</span>
<strong>{{ totals.protein_g }} g</strong>
</div>
<div>
<span>{{ t('nutrition.labels.carbs') }}</span>
<strong>{{ totals.carbs_g }} g</strong>
</div>
<div>
<span>{{ t('nutrition.labels.fat') }}</span>
<strong>{{ totals.fat_g }} g</strong>
<div class="macro-badge-group macro-badge-group--right macro-badge-group--compact macro-badge-group--nowrap">
<MacroBadge macro="protein" :label="t('nutrition.short.protein')" :grams="totals.protein_g" />
<MacroBadge macro="carbs" :label="t('nutrition.short.carbs')" :grams="totals.carbs_g" />
<MacroBadge macro="fat" :label="t('nutrition.short.fat')" :grams="totals.fat_g" />
<MacroBadge macro="fiber" :label="t('nutrition.short.fiber')" :grams="totals.fiber_g" />
</div>
</div>
</section>

View File

@ -71,15 +71,47 @@
"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.",
"sessionExpired": "Relace vypršela. Přihlas se znovu."
},
"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",
"carbs": "S",
"fat": "T"
"fat": "T",
"fiber": "V"
},
"labels": {
"protein": "Bílkoviny",
@ -135,6 +167,7 @@
"accountTitle": "Nastavení účtu",
"loggedInAs": "Přihlášený uživatel",
"themeTitle": "Vzhled",
"languageTitle": "Jazyk",
"logout": "Odhlásit se"
}
}

View File

@ -71,15 +71,47 @@
"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.",
"sessionExpired": "Sitzung ist abgelaufen. Bitte melde dich erneut an."
},
"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",
"carbs": "K",
"fat": "F"
"fat": "F",
"fiber": "Ba"
},
"labels": {
"protein": "Eiweiß",
@ -135,6 +167,7 @@
"accountTitle": "Kontoeinstellungen",
"loggedInAs": "Angemeldet als",
"themeTitle": "Darstellung",
"languageTitle": "Sprache",
"logout": "Abmelden"
}
}

View File

@ -71,15 +71,47 @@
"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.",
"sessionExpired": "Session expired. Please sign in again."
},
"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",
"carbs": "C",
"fat": "F"
"fat": "F",
"fiber": "Fi"
},
"labels": {
"protein": "Protein",
@ -135,6 +167,7 @@
"accountTitle": "Account settings",
"loggedInAs": "Logged in as",
"themeTitle": "Appearance",
"languageTitle": "Language",
"logout": "Log out"
}
}

View File

@ -71,15 +71,47 @@
"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.",
"sessionExpired": "La sesión ha caducado. Inicia sesión de nuevo."
},
"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",
"carbs": "C",
"fat": "G"
"fat": "G",
"fiber": "Fi"
},
"labels": {
"protein": "Proteínas",
@ -135,6 +167,7 @@
"accountTitle": "Ajustes de cuenta",
"loggedInAs": "Sesión iniciada como",
"themeTitle": "Apariencia",
"languageTitle": "Idioma",
"logout": "Cerrar sesión"
}
}

View File

@ -71,15 +71,47 @@
"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á.",
"sessionExpired": "Relácia vypršala. Prihlás sa znova."
},
"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",
"carbs": "S",
"fat": "T"
"fat": "T",
"fiber": "V"
},
"labels": {
"protein": "Bielkoviny",
@ -135,6 +167,7 @@
"accountTitle": "Nastavenia účtu",
"loggedInAs": "Prihlásený používateľ",
"themeTitle": "Vzhľad",
"languageTitle": "Jazyk",
"logout": "Odhlásiť sa"
}
}

View File

@ -5,12 +5,37 @@ import App from './App.vue'
import i18n from './i18n'
import router from './router'
import { pinia } from './stores'
import { useAuthStore } from './stores/auth'
import { useThemeStore } from './stores/theme'
import { useUIStore } from './stores/ui'
import { SESSION_EXPIRED_EVENT } from './utils/error'
const app = createApp(App)
const themeStore = useThemeStore(pinia)
const authStore = useAuthStore(pinia)
const uiStore = useUIStore(pinia)
themeStore.initialize()
const handleSessionExpired = async () => {
const wasAuthenticated = authStore.isAuthenticated
authStore.clearSession()
if (wasAuthenticated) {
uiStore.info(String(i18n.global.t('ux.toast.sessionExpired')))
}
await router.isReady()
if (router.currentRoute.value.name !== 'auth') {
await router.replace({ name: 'auth' })
}
}
if (typeof window !== 'undefined') {
window.addEventListener(SESSION_EXPIRED_EVENT, () => {
void handleSessionExpired()
})
}
app.component('font-awesome-icon', FontAwesomeIcon)
app.use(pinia)
app.use(router)

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,53 @@
export const SESSION_EXPIRED_EVENT = 'auth:session-expired'
const TOKEN_ERROR_MESSAGES = new Set(['Invalid or expired token'])
const SESSION_EXPIRED_EVENT_THROTTLE_MS = 500
let lastSessionExpiredEventAt = 0
const resolveErrorMessage = (error: unknown): string => {
if (typeof error === 'string') {
return error
}
if (error instanceof Error && typeof error.message === 'string') {
return error.message
}
return ''
}
const emitSessionExpiredEvent = () => {
if (typeof window === 'undefined') {
return
}
const now = Date.now()
if (now - lastSessionExpiredEventAt < SESSION_EXPIRED_EVENT_THROTTLE_MS) {
return
}
lastSessionExpiredEventAt = now
window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT))
}
export const isSessionExpiredError = (error: unknown): boolean => {
return TOKEN_ERROR_MESSAGES.has(resolveErrorMessage(error))
}
export const toErrorMessage = (error: unknown, fallback: string): string => {
if (isSessionExpiredError(error)) {
emitSessionExpiredEvent()
return fallback
}
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

@ -2,18 +2,31 @@
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IngredientForm from '@/components/ingredients/IngredientForm.vue'
import MacroBadge from '@/components/common/MacroBadge.vue'
import { useIngredientsStore } from '@/stores/ingredients'
import { useUIStore } from '@/stores/ui'
import type { Ingredient } from '@/types/domain'
import { toErrorMessage } from '@/utils/error'
const ingredientsStore = useIngredientsStore()
const ui = useUIStore()
const editingId = ref<number | null>(null)
const saving = ref(false)
const formMode = ref<'hidden' | 'create' | 'edit'>('hidden')
const loadError = ref('')
const actionError = ref('')
const { t } = useI18n()
onMounted(async () => {
try {
loadError.value = ''
if (ingredientsStore.items.length <= 0) {
await ingredientsStore.loadIngredients()
}
} catch (error) {
loadError.value = toErrorMessage(error, t('ux.errors.loadIngredients'))
ui.error(loadError.value)
}
})
const editingIngredient = computed<Ingredient | null>(() => {
@ -24,15 +37,21 @@ const editingIngredient = computed<Ingredient | null>(() => {
})
const startCreate = () => {
actionError.value = ''
editingId.value = null
formMode.value = 'create'
}
const startEdit = (ingredientId: number) => {
actionError.value = ''
editingId.value = ingredientId
formMode.value = 'edit'
}
const cancelEdit = () => {
editingId.value = null
formMode.value = 'hidden'
actionError.value = ''
}
const saveIngredient = async (payload: {
@ -45,22 +64,48 @@ const saveIngredient = async (payload: {
kcal_100: number
}) => {
saving.value = true
actionError.value = ''
try {
if (editingIngredient.value) {
await ingredientsStore.updateIngredient(editingIngredient.value.ingredient_id, payload)
ui.success(t('ux.toast.saved'))
} else {
await ingredientsStore.createIngredient(payload)
ui.success(t('ux.toast.created'))
}
editingId.value = null
formMode.value = 'hidden'
} catch (error) {
actionError.value = toErrorMessage(error, t('ux.errors.saveIngredient'))
ui.error(actionError.value)
} finally {
saving.value = false
}
}
const removeIngredient = async (ingredientId: number) => {
const confirmed = await ui.confirm({
title: t('ux.confirm.deleteTitle'),
message: t('ux.confirm.deleteIngredient'),
confirmLabel: t('common.delete'),
cancelLabel: t('common.cancel'),
})
if (!confirmed) {
return
}
actionError.value = ''
try {
await ingredientsStore.deleteIngredient(ingredientId)
ui.success(t('ux.toast.deleted'))
if (editingId.value === ingredientId) {
editingId.value = null
formMode.value = 'hidden'
}
} catch (error) {
actionError.value = toErrorMessage(error, t('ux.errors.deleteIngredient'))
ui.error(actionError.value)
}
}
</script>
@ -68,6 +113,7 @@ const removeIngredient = async (ingredientId: number) => {
<template>
<section class="page">
<IngredientForm
v-if="formMode !== 'hidden'"
:initial="editingIngredient"
:submit-label="saving ? t('common.saving') : t('common.save')"
@save="saveIngredient"
@ -79,15 +125,36 @@ 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>
<p>
{{ t('nutrition.short.protein') }} {{ ingredient.protein_g_100 }}
· {{ t('nutrition.short.carbs') }} {{ ingredient.carbs_g_100 }}
· {{ t('nutrition.short.fat') }} {{ ingredient.fat_g_100 }}
</p>
<div class="macro-badge-group macro-badge-group--with-top-gap">
<MacroBadge
macro="protein"
:label="t('nutrition.short.protein')"
:grams="ingredient.protein_g_100"
/>
<MacroBadge
macro="carbs"
:label="t('nutrition.short.carbs')"
:grams="ingredient.carbs_g_100"
/>
<MacroBadge
macro="fat"
:label="t('nutrition.short.fat')"
:grams="ingredient.fat_g_100"
/>
<MacroBadge
macro="fiber"
:label="t('nutrition.short.fiber')"
:grams="ingredient.fiber_g_100"
/>
</div>
</div>
<div class="list-row__actions">
<button class="btn" type="button" @click="startEdit(ingredient.ingredient_id)">{{ t('common.edit') }}</button>

View File

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

View File

@ -3,22 +3,33 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import MealTypeFilter from '@/components/meals/MealTypeFilter.vue'
import MacroBadge from '@/components/common/MacroBadge.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 +44,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 +84,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"
@ -78,7 +97,31 @@ const createMeal = async () => {
<strong>{{ meal.name }}</strong>
<p>{{ t(`mealTypes.${meal.meal_type}`) }}</p>
</div>
<div class="list-row__meta">{{ meal.totals?.kcal ?? 0 }} {{ t('common.kcalUnit') }}</div>
<div class="list-row__meta list-row__meta--nutrition">
<div class="list-row__kcal">{{ meal.totals?.kcal ?? 0 }} {{ t('common.kcalUnit') }}</div>
<div class="macro-badge-group macro-badge-group--right macro-badge-group--compact">
<MacroBadge
macro="protein"
:label="t('nutrition.short.protein')"
:grams="meal.totals?.protein_g ?? 0"
/>
<MacroBadge
macro="carbs"
:label="t('nutrition.short.carbs')"
:grams="meal.totals?.carbs_g ?? 0"
/>
<MacroBadge
macro="fat"
:label="t('nutrition.short.fat')"
:grams="meal.totals?.fat_g ?? 0"
/>
<MacroBadge
macro="fiber"
:label="t('nutrition.short.fiber')"
:grams="meal.totals?.fiber_g ?? 0"
/>
</div>
</div>
</RouterLink>
</div>
<p v-else class="empty-state">{{ t('meals.empty') }}</p>

View File

@ -1,12 +1,41 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import i18n from '@/i18n'
import { SUPPORTED_LOCALES, type AppLocale } from '@/i18n'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const { t } = useI18n()
const { t } = useI18n({ useScope: 'global' })
const isSupportedLocale = (value: string): value is AppLocale => {
return SUPPORTED_LOCALES.includes(value as AppLocale)
}
const setLocale = (value: string) => {
if (!isSupportedLocale(value)) {
return
}
i18n.global.locale.value = value
document.documentElement.setAttribute('lang', value)
localStorage.setItem('locale', value)
}
const localeValue = computed({
get: () => i18n.global.locale.value as AppLocale,
set: (value: string) => setLocale(value),
})
watch(
() => i18n.global.locale.value,
(nextLocale) => {
document.documentElement.setAttribute('lang', nextLocale)
},
{ immediate: true },
)
const onLogout = async () => {
await auth.logout()
@ -25,6 +54,16 @@ const onLogout = async () => {
<span>{{ t('settings.themeTitle') }}</span>
<ThemeToggle />
</div>
<div class="settings-card__theme">
<span>{{ t('settings.languageTitle') }}</span>
<label class="locale-control" :aria-label="t('settings.languageTitle')">
<select v-model="localeValue">
<option v-for="lang in SUPPORTED_LOCALES" :key="lang" :value="lang">
{{ t(`locale.${lang}`) }}
</option>
</select>
</label>
</div>
<button class="btn btn-danger" type="button" @click="onLogout">{{ t('settings.logout') }}</button>
</section>
</section>

View File

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