Compare commits
10 Commits
1d5b730e11
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 02e9aa1ac4 | |||
| b3be74e652 | |||
| af437963bb | |||
| 6ff28dae13 | |||
| 69e5f4e320 | |||
| b0b5d0972a | |||
| 2468b31462 | |||
| be3c355b37 | |||
| ee144847bd | |||
| 2b237d3d71 |
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
||||
/backend/config/Config.cust.php
|
||||
/frontend/node_modules
|
||||
/frontend/dist
|
||||
/dist
|
||||
|
||||
24
AGENTS.md
@ -39,16 +39,35 @@ It describes what the project is, what is already implemented, and what still ne
|
||||
- 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.
|
||||
- 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:
|
||||
- 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`.
|
||||
@ -58,7 +77,7 @@ It describes what the project is, what is already implemented, and what still ne
|
||||
- `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ť`
|
||||
- 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).
|
||||
@ -168,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.
|
||||
|
||||
@ -206,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).
|
||||
|
||||
|
||||
@ -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
@ -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
@ -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.
|
||||
192
doc/prompt.txt
@ -45,4 +45,194 @@ dopln kluce pre UI texty pre preklad, pre kluce pouzivaj anglictinu, dopln potom
|
||||
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)
|
||||
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. 10–15% 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
|
||||
@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 66 KiB |
BIN
frontend/public/icons/icon-16.png
Normal file
|
After Width: | Height: | Size: 874 B |
BIN
frontend/public/icons/icon-180.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/icons/icon-32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/icons/icon-48.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
20
frontend/public/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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,
|
||||
@ -747,10 +942,6 @@ select {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.totals-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
BIN
frontend/src/assets/fonts/dm-sans-latin-400-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/dm-sans-latin-500-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/dm-sans-latin-700-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/dm-sans-latin-ext-400-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/dm-sans-latin-ext-500-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/dm-sans-latin-ext-700-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/space-grotesk-latin-500-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/space-grotesk-latin-700-normal.woff2
Normal file
29
frontend/src/components/common/MacroBadge.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -81,7 +81,8 @@
|
||||
"created": "Položka byla vytvořena.",
|
||||
"saved": "Změny byly uloženy.",
|
||||
"updated": "Změny byly aplikovány.",
|
||||
"deleted": "Položka byla smazána."
|
||||
"deleted": "Položka byla smazána.",
|
||||
"sessionExpired": "Relace vypršela. Přihlas se znovu."
|
||||
},
|
||||
"errors": {
|
||||
"loadDay": "Nepodařilo se načíst den.",
|
||||
@ -109,7 +110,8 @@
|
||||
"short": {
|
||||
"protein": "B",
|
||||
"carbs": "S",
|
||||
"fat": "T"
|
||||
"fat": "T",
|
||||
"fiber": "V"
|
||||
},
|
||||
"labels": {
|
||||
"protein": "Bílkoviny",
|
||||
@ -165,6 +167,7 @@
|
||||
"accountTitle": "Nastavení účtu",
|
||||
"loggedInAs": "Přihlášený uživatel",
|
||||
"themeTitle": "Vzhled",
|
||||
"languageTitle": "Jazyk",
|
||||
"logout": "Odhlásit se"
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,7 +81,8 @@
|
||||
"created": "Element wurde erstellt.",
|
||||
"saved": "Änderungen wurden gespeichert.",
|
||||
"updated": "Änderungen wurden übernommen.",
|
||||
"deleted": "Element wurde gelöscht."
|
||||
"deleted": "Element wurde gelöscht.",
|
||||
"sessionExpired": "Sitzung ist abgelaufen. Bitte melde dich erneut an."
|
||||
},
|
||||
"errors": {
|
||||
"loadDay": "Tag konnte nicht geladen werden.",
|
||||
@ -109,7 +110,8 @@
|
||||
"short": {
|
||||
"protein": "E",
|
||||
"carbs": "K",
|
||||
"fat": "F"
|
||||
"fat": "F",
|
||||
"fiber": "Ba"
|
||||
},
|
||||
"labels": {
|
||||
"protein": "Eiweiß",
|
||||
@ -165,6 +167,7 @@
|
||||
"accountTitle": "Kontoeinstellungen",
|
||||
"loggedInAs": "Angemeldet als",
|
||||
"themeTitle": "Darstellung",
|
||||
"languageTitle": "Sprache",
|
||||
"logout": "Abmelden"
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,7 +81,8 @@
|
||||
"created": "Item was created.",
|
||||
"saved": "Changes were saved.",
|
||||
"updated": "Changes were applied.",
|
||||
"deleted": "Item was deleted."
|
||||
"deleted": "Item was deleted.",
|
||||
"sessionExpired": "Session expired. Please sign in again."
|
||||
},
|
||||
"errors": {
|
||||
"loadDay": "Failed to load the day.",
|
||||
@ -109,7 +110,8 @@
|
||||
"short": {
|
||||
"protein": "P",
|
||||
"carbs": "C",
|
||||
"fat": "F"
|
||||
"fat": "F",
|
||||
"fiber": "Fi"
|
||||
},
|
||||
"labels": {
|
||||
"protein": "Protein",
|
||||
@ -165,6 +167,7 @@
|
||||
"accountTitle": "Account settings",
|
||||
"loggedInAs": "Logged in as",
|
||||
"themeTitle": "Appearance",
|
||||
"languageTitle": "Language",
|
||||
"logout": "Log out"
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,7 +81,8 @@
|
||||
"created": "Elemento creado.",
|
||||
"saved": "Cambios guardados.",
|
||||
"updated": "Cambios aplicados.",
|
||||
"deleted": "Elemento eliminado."
|
||||
"deleted": "Elemento eliminado.",
|
||||
"sessionExpired": "La sesión ha caducado. Inicia sesión de nuevo."
|
||||
},
|
||||
"errors": {
|
||||
"loadDay": "No se pudo cargar el día.",
|
||||
@ -109,7 +110,8 @@
|
||||
"short": {
|
||||
"protein": "P",
|
||||
"carbs": "C",
|
||||
"fat": "G"
|
||||
"fat": "G",
|
||||
"fiber": "Fi"
|
||||
},
|
||||
"labels": {
|
||||
"protein": "Proteínas",
|
||||
@ -165,6 +167,7 @@
|
||||
"accountTitle": "Ajustes de cuenta",
|
||||
"loggedInAs": "Sesión iniciada como",
|
||||
"themeTitle": "Apariencia",
|
||||
"languageTitle": "Idioma",
|
||||
"logout": "Cerrar sesión"
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,7 +81,8 @@
|
||||
"created": "Položka bola vytvorená.",
|
||||
"saved": "Zmeny boli uložené.",
|
||||
"updated": "Zmeny boli aplikované.",
|
||||
"deleted": "Položka bola zmazaná."
|
||||
"deleted": "Položka bola zmazaná.",
|
||||
"sessionExpired": "Relácia vypršala. Prihlás sa znova."
|
||||
},
|
||||
"errors": {
|
||||
"loadDay": "Nepodarilo sa načítať deň.",
|
||||
@ -109,7 +110,8 @@
|
||||
"short": {
|
||||
"protein": "B",
|
||||
"carbs": "S",
|
||||
"fat": "T"
|
||||
"fat": "T",
|
||||
"fiber": "V"
|
||||
},
|
||||
"labels": {
|
||||
"protein": "Bielkoviny",
|
||||
@ -165,6 +167,7 @@
|
||||
"accountTitle": "Nastavenia účtu",
|
||||
"loggedInAs": "Prihlásený používateľ",
|
||||
"themeTitle": "Vzhľad",
|
||||
"languageTitle": "Jazyk",
|
||||
"logout": "Odhlásiť sa"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,4 +1,46 @@
|
||||
export const toErrorMessage = (error: unknown, fallback: string): string => {
|
||||
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
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
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'
|
||||
@ -132,11 +133,28 @@ const removeIngredient = async (ingredientId: number) => {
|
||||
<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>
|
||||
|
||||
@ -3,6 +3,7 @@ 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'
|
||||
@ -96,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>
|
||||
|
||||
@ -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>
|
||||
|
||||