Compare commits

..

17 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
276cc21c5a added theme toggle,
fixed create entityies in API
2026-02-14 08:01:07 +01:00
9b2f2c4e91 added transalations for UI texts 2026-02-14 07:34:04 +01:00
3010a66d59 added implementation frontend by prompt @ 2026-02-14 05:35:18 #CODEX 2026-02-14 07:13:06 +01:00
92086055dc changed locales from .ts to .json,
configured i18n-ally,
upgrade APIlite and regenerated BackendAPI.ts,
fixed successMessage value
2026-02-14 06:47:14 +01:00
210ab43a0b fixed response data for login,
fixed tabs
2026-02-13 22:01:40 +01:00
81 changed files with 4490 additions and 489 deletions

1
.gitignore vendored
View File

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

25
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,25 @@
{
"i18n-ally.localesPaths": [
"frontend/src/locales"
],
"vue-i18n.sourceLocale": "sk",
"vue-i18n.i18nPaths": "frontend/src/locales",
"i18n-ally.dirStructure": "file",
"i18n-ally.displayLanguage": "sk",
"i18n-ally.enabledFrameworks": [
"vue",
"php-gettext"
],
"i18n-ally.enabledParsers": [
"json",
"js"
],
"i18n-ally.extract.keyMaxLength": 128,
"i18n-ally.keystyle": "flat",
"i18n-ally.tabStyle": "tab",
"i18n-ally.sourceLanguage": "sk",
"i18n-ally.extract.autoDetect": true,
"i18n-ally.extract.ignoredByFiles": {
}
}

View File

@ -15,27 +15,71 @@ It describes what the project is, what is already implemented, and what still ne
- `backend/` - `backend/`
- `frontend/` - `frontend/`
## Current State (as of 2026-02-13) ## Current State (as of 2026-02-14)
- `README.md` already contains product specification in Slovak and English. - `README.md` already contains product specification in Slovak and English.
- Backend DB migrations exist in `backend/src/Maintenance.php` up to version `7`. - Backend DB migrations exist in `backend/src/Maintenance.php` up to version `7`.
- Backend API methods are implemented in `backend/src/API.php`. - Backend API methods are implemented in `backend/src/API.php`.
- Frontend auth page is now implemented: - Frontend auth page is implemented:
- `frontend/src/App.vue` renders router view. - `frontend/src/App.vue` renders router view.
- `frontend/src/router/index.ts` maps `/` to `frontend/src/views/AuthView.vue`. - `frontend/src/router/index.ts` maps `/` to `frontend/src/views/AuthView.vue` for guests.
- `AuthView` serves as login + registration entry (single form, email + password). - `AuthView` serves as login + registration entry (single form, email + password).
- Successful login stores `token` and `user_email` in `localStorage`. - Login now uses `frontend/src/stores/auth.ts` (`Pinia`) and redirects to authenticated app routes.
- Frontend i18n is wired: - `frontend/src/views/AuthView.vue` formatting now uses tab-based indentation.
- Frontend authenticated area is implemented:
- `frontend/src/views/AppLayout.vue` is shell layout.
- Desktop navigation: `frontend/src/components/navigation/AppSidebar.vue`.
- Mobile navigation: `frontend/src/components/navigation/AppBottomTabs.vue`.
- 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` - setup in `frontend/src/i18n/index.ts`
- locale files in `frontend/src/locales/{sk,cs,en,es,de}.ts` - locale files in `frontend/src/locales/{sk,cs,en,es,de}.json`
- language switcher updates locale dynamically. - 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: - Frontend theme system is implemented:
- light/dark mode toggle in auth page - centralized theme store: `frontend/src/stores/theme.ts`
- shared toggle component: `frontend/src/components/common/ThemeToggle.vue`
- toggle available in auth, app topbar, and settings
- design tokens in `frontend/src/assets/css/style.css` (`:root` variables). - design tokens in `frontend/src/assets/css/style.css` (`:root` variables).
- 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`). - App logo is served from `frontend/public/Nutrio.png` (copied from `doc/Nutrio.png`).
- Font Awesome is installed and registered globally in `frontend/src/main.ts`. - Font Awesome is installed and registered globally in `frontend/src/main.ts`.
- `frontend/src/BackendAPI.js` is generated via `backend/scripts/buildTypeScript.php` and should not be edited manually. - Pinia is installed and configured in `frontend/src/main.ts` via `frontend/src/stores/index.ts`.
- `frontend/src/BackendAPI.d.ts` provides TS declarations for generated `BackendAPI.js`. - Frontend domain/store structure exists:
- `frontend/src/types/domain.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). - `backend/data.json` contains sample meal data (not currently wired into DB/API flow).
## Backend Architecture ## Backend Architecture
@ -139,8 +183,13 @@ All actions are invoked through `backend/public/API.php` with `?action=<method_n
- Some comments in `Maintenance.php` show encoding artifacts, but SQL structure is valid. - Some comments in `Maintenance.php` show encoding artifacts, but SQL structure is valid.
- Basic token auth is implemented, but token is still passed as plain API parameter. - Basic token auth is implemented, but token is still passed as plain API parameter.
- For `array` parameters (for example `ordered_item_ids`), APIlite expects JSON in request payload. - For `array` parameters (for example `ordered_item_ids`), APIlite expects JSON in request payload.
- APIlite wraps responses with a nested `data` object. Keep this in mind on frontend parsing. - APIlite response handling detail:
- `frontend/src/BackendAPI.js` is generated output; regenerate when backend API changes, do not patch manually. - 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. - In vue-i18n locale strings, `@` must be escaped as `{'@'}` to avoid "Invalid linked format" errors.
## Local Runbook ## Local Runbook
@ -165,8 +214,9 @@ Frontend:
## Product Behavior Target (what to build next) ## Product Behavior Target (what to build next)
- Harden auth (token transport/header strategy, token revoke strategy, brute-force/rate-limits). - Harden auth (token transport/header strategy, token revoke strategy, brute-force/rate-limits).
- Build remaining frontend screens for ingredients, meals, meal item editor, diary day, diary range. - Polish authenticated frontend UX further (more granular field validation, retry actions, richer empty states).
- Connect frontend to implemented backend actions. - 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. - Add API tests for validation, ownership checks, and totals calculation consistency.
- Add pagination/filter strategy where list endpoints grow. - Add pagination/filter strategy where list endpoints grow.
@ -176,3 +226,5 @@ Frontend:
- Keep MySQL + SQLite compatibility in SQL where possible (project supports both). - Keep MySQL + SQLite compatibility in SQL where possible (project supports both).
- When changing schema, always bump DB version in `Maintenance.php` with forward-only migration steps. - 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. - 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).

6
backend/composer.lock generated
View File

@ -8,11 +8,11 @@
"packages": [ "packages": [
{ {
"name": "tpsoft/apilite", "name": "tpsoft/apilite",
"version": "v1.2.0", "version": "v1.2.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://gitea.tpsoft.org/TPsoft.org/APIlite.git", "url": "https://gitea.tpsoft.org/TPsoft.org/APIlite.git",
"reference": "d258bcc91948424711dd79fde57254e1384d0091" "reference": "951fe36da3184bf29398a067f2218af768e5d280"
}, },
"require": { "require": {
"php": ">=8.2" "php": ">=8.2"
@ -53,7 +53,7 @@
"type": "other" "type": "other"
} }
], ],
"time": "2026-02-13T08:54:08+00:00" "time": "2026-02-14T05:28:03+00:00"
}, },
{ {
"name": "tpsoft/dbmodel", "name": "tpsoft/dbmodel",

View File

@ -46,7 +46,7 @@ class API extends APIlite {
if (is_array($existing)) { if (is_array($existing)) {
throw new \Exception('User with this email already exists'); throw new \Exception('User with this email already exists');
} }
$userId = $this->users()->userSave(array( $userId = $this->users()->user(null, array(
'email' => $email, 'email' => $email,
'password_hash' => $this->users()->hashString($password), 'password_hash' => $this->users()->hashString($password),
'token' => null, 'token' => null,
@ -192,7 +192,7 @@ class API extends APIlite {
if ($kcal_100 == 0) { if ($kcal_100 == 0) {
$kcal_100 = $this->computeKcal100($protein_g_100, $carbs_g_100, $fat_g_100); $kcal_100 = $this->computeKcal100($protein_g_100, $carbs_g_100, $fat_g_100);
} }
$ingredientId = $this->ingredients()->ingredientSave(array( $ingredientId = $this->ingredients()->ingredient(null, array(
'user_id' => $user_id, 'user_id' => $user_id,
'name' => $name, 'name' => $name,
'protein_g_100' => $this->round2($protein_g_100), 'protein_g_100' => $this->round2($protein_g_100),
@ -326,7 +326,7 @@ class API extends APIlite {
$user_id = $this->requireUserIDbyToken($token); $user_id = $this->requireUserIDbyToken($token);
$name = $this->normalizeName($name); $name = $this->normalizeName($name);
$this->assertMealType($meal_type); $this->assertMealType($meal_type);
$mealId = $this->meals()->mealSave(array( $mealId = $this->meals()->meal(null, array(
'user_id' => $user_id, 'user_id' => $user_id,
'name' => $name, 'name' => $name,
'meal_type' => $meal_type, 'meal_type' => $meal_type,
@ -387,7 +387,7 @@ class API extends APIlite {
throw new \Exception('position must be >= 1'); throw new \Exception('position must be >= 1');
} }
$this->getIngredientAccessible($user_id, $ingredient_id); $this->getIngredientAccessible($user_id, $ingredient_id);
$mealItemId = $this->mealItems()->mealItemSave(array( $mealItemId = $this->mealItems()->mealItem(null, array(
'meal_id' => $meal_id, 'meal_id' => $meal_id,
'ingredient_id' => $ingredient_id, 'ingredient_id' => $ingredient_id,
'grams' => $this->round2($grams), 'grams' => $this->round2($grams),
@ -508,7 +508,7 @@ class API extends APIlite {
throw new \Exception('Failed to update diary entry'); throw new \Exception('Failed to update diary entry');
} }
} else { } else {
$inserted = $this->diaryEntries()->diaryEntrySave(array( $inserted = $this->diaryEntries()->diaryEntry(null, array(
'diary_day_id' => (int) $day['diary_day_id'], 'diary_day_id' => (int) $day['diary_day_id'],
'meal_type' => $meal_type, 'meal_type' => $meal_type,
'meal_id' => $meal_id, 'meal_id' => $meal_id,
@ -890,7 +890,7 @@ class API extends APIlite {
if (is_array($day)) { if (is_array($day)) {
return $this->mapDiaryDay($day); return $this->mapDiaryDay($day);
} }
$dayId = $this->diaryDays()->diaryDaySave(array( $dayId = $this->diaryDays()->diaryDay(null, array(
'user_id' => $user_id, 'user_id' => $user_id,
'day_date' => $day_date, 'day_date' => $day_date,
'created_at' => '`NOW`' 'created_at' => '`NOW`'

View File

@ -105,7 +105,7 @@ class Users extends \TPsoft\DBmodel\DBmodel {
return password_verify($password, $password_hash); 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) { if ($user_id <= 0) {
throw new \Exception('Invalid user_id'); throw new \Exception('Invalid user_id');
} }
@ -152,7 +152,7 @@ class Users extends \TPsoft\DBmodel\DBmodel {
if (!hash_equals($stored_token, $token)) { if (!hash_equals($stored_token, $token)) {
return false; 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( $updated = $this->user($user_id, array(
'token_expires' => $refresh_expires '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.

238
doc/prompt.txt Normal file
View File

@ -0,0 +1,238 @@
----- 2026-02-14 05:35:18 -----------------------------------------------------
Si senior Vue 3 + TypeScript architekt. Robím aplikáciu Nutrio (nutrition tracker).
Backend API v PHP je hotové. Frontend má zatiaľ iba login obrazovku, kde sa pri prihlásení automaticky zaregistruje používateľ.
Po prihlásení chcem vnútornú časť aplikácie (authenticated area) s jednoduchým, čistým SaaS dizajnom.
Ciele:
1) Navrhni routing štruktúru (Vue Router) pre verejnú časť (login) a privátnu časť (app), vrátane guards.
2) Navrhni layouty:
- AuthLayout (pre login) uz je spraveny v frontend\src\views\AuthView.vue
- AppLayout (sidebar na desktope, bottom tabs na mobile; aspoň navrhni komponentovú štruktúru)
3) Navrhni pages a komponenty pre minimálne:
- Today (diár pre konkrétny deň): raňajky/obed/večera; výber jedálnička pre čas dňa; zobrazenie súčtov (kcal, B/S/T)
- Meals (knižnica jedálničkov): list + filter podľa meal_type; detail/edit jedálnička s položkami (ingredient + grams)
- Ingredients (databáza surovín): list + create/edit
- Stats (základný prehľad; môže byť placeholder)
- Settings (logout)
4) Výpočty totals nerobím cez SQL VIEW počítaj ich na FE pri zobrazení (alebo cez 1 jednoduchú helper funkciu).
Makrá sú uložené na 100g: protein_g_100, carbs_g_100, sugar_g_100, fat_g_100, fiber_g_100.
Pre položku s grams: macro = grams/100 * macro_100.
kcal = protein*4 + carbs*4 + fat*9. (Sugar je podmnožina carbs, neráta sa zvlášť do kcal.)
5) Navrhni štruktúru projektu:
- frontend/src/router
- frontend/src/views
- frontend/src/components
- frontend/src/stores (Pinia)
- frontend/src/utils (nutrition math)
6) Daj konkrétny návrh názvov súborov a exportov + ukážku kódu:
- router index.ts s routes + guard
- auth store (token/user)
- nutrition utils (computeMealTotals, computeDayTotals)
- typy (Ingredient, Meal, MealItem, DiaryEntry)
Preferencie:
- Vue 3 + Composition API + TypeScript
- Pinia
- Vue Router
- UI môže byť čisté bez knižnice, alebo minimalisticky (napr. jednoduché CSS rozhodni a drž konzistentne a pokracuj v pouzivani suboru frontend/src/assets/css/style.css).
- Použi slovenské názvy v UI (Raňajky, Obed, Večera), ale kľúče v kóde nech sú anglické (breakfast/lunch/dinner).
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 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

9
frontend/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}

View File

@ -2,9 +2,13 @@
<html lang=""> <html lang="">
<head> <head>
<meta charset="UTF-8"> <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"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>Nutrio</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -1,16 +1,17 @@
{ {
"name": "nutrio", "name": "nutrio",
"version": "0.0.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nutrio", "name": "nutrio",
"version": "0.0.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/vue-fontawesome": "^3.1.3", "@fortawesome/vue-fontawesome": "^3.1.3",
"pinia": "^3.0.4",
"vue": "^3.5.27", "vue": "^3.5.27",
"vue-i18n": "^11.2.8", "vue-i18n": "^11.2.8",
"vue-router": "^5.0.1" "vue-router": "^5.0.1"
@ -2292,13 +2293,10 @@
} }
}, },
"node_modules/@vue/devtools-api": { "node_modules/@vue/devtools-api": {
"version": "8.0.6", "version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.6.tgz", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA==", "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT", "license": "MIT"
"dependencies": {
"@vue/devtools-kit": "^8.0.6"
}
}, },
"node_modules/@vue/devtools-core": { "node_modules/@vue/devtools-core": {
"version": "8.0.6", "version": "8.0.6",
@ -4254,6 +4252,66 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/pinia/node_modules/@vue/devtools-kit": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/pinia/node_modules/@vue/devtools-shared": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/pinia/node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/pkg-types": { "node_modules/pkg-types": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
@ -5128,16 +5186,16 @@
} }
}, },
"node_modules/vue-eslint-parser": { "node_modules/vue-eslint-parser": {
"version": "10.2.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.4.0", "debug": "^4.4.0",
"eslint-scope": "^8.2.0", "eslint-scope": "^8.2.0 || ^9.0.0",
"eslint-visitor-keys": "^4.2.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0",
"espree": "^10.3.0", "espree": "^10.3.0 || ^11.0.0",
"esquery": "^1.6.0", "esquery": "^1.6.0",
"semver": "^7.6.3" "semver": "^7.6.3"
}, },
@ -5148,17 +5206,17 @@
"url": "https://github.com/sponsors/mysticatea" "url": "https://github.com/sponsors/mysticatea"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0" "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0"
} }
}, },
"node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": {
"version": "4.2.1", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
@ -5184,12 +5242,6 @@
"vue": "^3.0.0" "vue": "^3.0.0"
} }
}, },
"node_modules/vue-i18n/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-router": { "node_modules/vue-router": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.2.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.2.tgz",
@ -5235,6 +5287,15 @@
} }
} }
}, },
"node_modules/vue-router/node_modules/@vue/devtools-api": {
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.6.tgz",
"integrity": "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^8.0.6"
}
},
"node_modules/vue-router/node_modules/picomatch": { "node_modules/vue-router/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "nutrio", "name": "nutrio",
"version": "0.0.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -18,6 +18,7 @@
"@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/vue-fontawesome": "^3.1.3", "@fortawesome/vue-fontawesome": "^3.1.3",
"pinia": "^3.0.4",
"vue": "^3.5.27", "vue": "^3.5.27",
"vue-i18n": "^11.2.8", "vue-i18n": "^11.2.8",
"vue-router": "^5.0.1" "vue-router": "^5.0.1"

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"> <script setup lang="ts">
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import ToastHost from '@/components/common/ToastHost.vue'
import ConfirmModalHost from '@/components/common/ConfirmModalHost.vue'
</script> </script>
<template> <template>
<RouterView /> <RouterView />
<ToastHost />
<ConfirmModalHost />
</template> </template>

View File

@ -2,11 +2,12 @@
* Generated by APIlite * Generated by APIlite
* https://gitea.tpsoft.org/TPsoft.org/APIlite * https://gitea.tpsoft.org/TPsoft.org/APIlite
* *
* 2026-02-13 10:00:58 */ * 2026-02-14 06:30:12 */
export interface APIliteActionResponse<T> { export interface APIliteActionResponse<T> {
status: 'OK'; status: 'OK';
data: T; data: T;
msg: string;
} }
export interface APIliteErrorResponse { export interface APIliteErrorResponse {
@ -36,6 +37,9 @@ export interface APIliteHelpResponse {
javascript_version: string; javascript_version: string;
typescript_version: string; typescript_version: string;
actions: APIliteMethodDoc[]; actions: APIliteMethodDoc[];
status: string;
data: string;
msg: string;
} }
class BackendAPI { class BackendAPI {

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 { :root {
--color-white: #ffffff; --color-white: #ffffff;
@ -13,6 +101,10 @@
--color-success-bg: #e7f4d8; --color-success-bg: #e7f4d8;
--color-error-bg: #f6dde0; --color-error-bg: #f6dde0;
--color-error: #7d2430; --color-error: #7d2430;
--macro-protein: #3B82F6;
--macro-carbs: #F59E0B;
--macro-fat: #EF4444;
--macro-fiber: #10B981;
--radius-md: 0.875rem; --radius-md: 0.875rem;
--radius-lg: 1.25rem; --radius-lg: 1.25rem;
--space-xs: 0.5rem; --space-xs: 0.5rem;
@ -27,6 +119,8 @@
--fs-xl: 1.875rem; --fs-xl: 1.875rem;
--shadow-soft: 0 20px 40px -30px var(--color-shadow); --shadow-soft: 0 20px 40px -30px var(--color-shadow);
--transition-fast: 160ms ease; --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'] { :root[data-theme='dark'] {
@ -55,7 +149,7 @@ body,
body { body {
margin: 0; margin: 0;
font-family: 'DM Sans', 'Segoe UI', sans-serif; font-family: var(--font-body);
font-size: var(--fs-md); font-size: var(--fs-md);
color: var(--color-text); color: var(--color-text);
background: background:
@ -105,7 +199,7 @@ select {
.auth-brand h1 { .auth-brand h1 {
margin: 0; margin: 0;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif; font-family: var(--font-display);
font-size: clamp(2rem, 5vw, 2.75rem); font-size: clamp(2rem, 5vw, 2.75rem);
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
@ -176,7 +270,7 @@ select {
.auth-card h2 { .auth-card h2 {
margin: var(--space-sm) 0 0; margin: var(--space-sm) 0 0;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif; font-family: var(--font-display);
font-size: var(--fs-xl); font-size: var(--fs-xl);
} }
@ -325,3 +419,578 @@ select {
justify-content: center; justify-content: center;
} }
} }
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 250px 1fr;
}
.app-sidebar {
background: var(--color-surface);
border-right: 1px solid var(--color-border);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.app-sidebar__brand {
display: flex;
align-items: center;
gap: var(--space-sm);
font-family: var(--font-display);
font-size: 1.1rem;
}
.app-sidebar__logo {
width: 36px;
height: 36px;
}
.app-sidebar__nav {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.app-nav-link {
text-decoration: none;
color: var(--color-text);
padding: 0.62rem 0.8rem;
border-radius: 0.7rem;
border: 1px solid transparent;
transition: all var(--transition-fast);
}
.app-nav-link.router-link-active {
border-color: var(--color-green);
background: color-mix(in srgb, var(--color-green) 16%, transparent);
}
.app-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.app-topbar {
position: sticky;
top: 0;
z-index: 2;
background: color-mix(in srgb, var(--color-bg) 84%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--color-border);
padding: var(--space-md) var(--space-xl);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.app-topbar h1 {
margin: 0;
font-size: 1.25rem;
font-family: var(--font-display);
}
.app-topbar__user {
font-size: var(--fs-sm);
color: var(--color-muted);
}
.app-topbar__actions {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.app-content {
padding: var(--space-lg) var(--space-xl);
}
.page {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.page-header {
display: flex;
gap: var(--space-md);
align-items: center;
}
.page-header--split {
justify-content: space-between;
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
padding: var(--space-lg);
}
.grid-three {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-md);
}
.grid-two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-md);
}
.filter-control {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.filter-control span {
font-size: var(--fs-sm);
color: var(--color-muted);
}
.input-text,
.input-number,
.input-select,
.input-date {
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
padding: 0.64rem 0.72rem;
}
.btn {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
border-radius: var(--radius-md);
padding: 0.62rem 0.86rem;
cursor: pointer;
}
.btn-primary {
border-color: var(--color-green);
background: var(--color-green);
color: var(--color-white);
}
.btn-danger {
border-color: color-mix(in srgb, var(--color-error) 24%, var(--color-border));
color: var(--color-error);
}
.form-inline {
display: grid;
grid-template-columns: 1.3fr 0.9fr auto;
gap: var(--space-sm);
}
.form-actions {
margin-top: var(--space-md);
display: flex;
gap: var(--space-sm);
}
.list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.list-row {
display: flex;
justify-content: space-between;
gap: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.72rem 0.82rem;
color: inherit;
text-decoration: none;
}
.list-row p {
margin: 0.3rem 0 0;
font-size: var(--fs-sm);
color: var(--color-muted);
}
.list-row__meta {
align-self: center;
color: var(--color-muted);
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);
}
.empty-state {
margin: 0;
color: var(--color-muted);
}
.day-meal-card__header h3,
.totals-card h3 {
margin-top: 0;
}
.day-meal-card__summary {
margin: var(--space-sm) 0 0;
font-size: var(--fs-sm);
color: var(--color-muted);
}
.day-meal-card__summary--nutrition {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.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-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,
.ingredient-form h3 {
margin-top: 0;
}
.meal-items-editor__add-row {
display: grid;
grid-template-columns: 1fr 140px auto;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
text-align: left;
border-bottom: 1px solid var(--color-border);
padding: 0.55rem 0.2rem;
}
.ingredient-form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.ingredient-form label span {
font-size: var(--fs-sm);
color: var(--color-muted);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.settings-card {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.settings-card__theme {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.settings-card__theme span {
color: var(--color-muted);
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;
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
padding-bottom: 62px;
}
.desktop-nav {
display: none;
}
.app-content {
padding: var(--space-md);
}
.grid-three {
grid-template-columns: 1fr;
}
.grid-two {
grid-template-columns: 1fr;
}
.form-inline {
grid-template-columns: 1fr;
}
.meal-items-editor__add-row {
grid-template-columns: 1fr;
}
.app-bottom-tabs {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
z-index: 4;
}
.app-bottom-tabs__link {
text-align: center;
text-decoration: none;
color: var(--color-muted);
padding: 0.62rem 0.2rem;
font-size: 0.76rem;
}
.app-bottom-tabs__link.router-link-active {
color: var(--color-green);
font-weight: 700;
}
.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,32 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons'
import { useThemeStore } from '@/stores/theme'
const props = withDefaults(defineProps<{
showLabel?: boolean
}>(), {
showLabel: true,
})
const { t } = useI18n()
const themeStore = useThemeStore()
themeStore.initialize()
const icon = computed(() => (themeStore.isDarkMode ? faMoon : faSun))
const themeLabel = computed(() => t(themeStore.currentThemeLabelKey))
</script>
<template>
<button
type="button"
class="theme-btn"
:aria-label="t('theme.toggle')"
@click="themeStore.toggle"
>
<font-awesome-icon :icon="icon" />
<span v-if="props.showLabel">{{ themeLabel }}</span>
</button>
</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

@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Ingredient } from '@/types/domain'
const props = defineProps<{
initial?: Ingredient | null
submitLabel?: string
}>()
const emit = defineEmits<{
(event: 'save', payload: {
name: string
protein_g_100: number
carbs_g_100: number
sugar_g_100: number
fat_g_100: number
fiber_g_100: number
kcal_100: number
}): void
(event: 'cancel'): void
}>()
const { t } = useI18n()
const formTitle = computed(() => {
return props.initial ? t('ingredients.editTitle') : t('ingredients.newTitle')
})
const form = reactive({
name: '',
protein_g_100: 0,
carbs_g_100: 0,
sugar_g_100: 0,
fat_g_100: 0,
fiber_g_100: 0,
kcal_100: 0,
})
const fillFromInitial = () => {
form.name = props.initial?.name ?? ''
form.protein_g_100 = props.initial?.protein_g_100 ?? 0
form.carbs_g_100 = props.initial?.carbs_g_100 ?? 0
form.sugar_g_100 = props.initial?.sugar_g_100 ?? 0
form.fat_g_100 = props.initial?.fat_g_100 ?? 0
form.fiber_g_100 = props.initial?.fiber_g_100 ?? 0
form.kcal_100 = props.initial?.kcal_100 ?? 0
}
watch(() => props.initial, fillFromInitial, { immediate: true })
const onSubmit = () => {
emit('save', {
name: form.name.trim(),
protein_g_100: Number(form.protein_g_100),
carbs_g_100: Number(form.carbs_g_100),
sugar_g_100: Number(form.sugar_g_100),
fat_g_100: Number(form.fat_g_100),
fiber_g_100: Number(form.fiber_g_100),
kcal_100: Number(form.kcal_100),
})
}
</script>
<template>
<form class="card ingredient-form" @submit.prevent="onSubmit">
<h3>{{ formTitle }}</h3>
<div class="grid-two">
<label>
<span>{{ t('ingredients.name') }}</span>
<input v-model="form.name" class="input-text" type="text" required />
</label>
<label>
<span>{{ t('ingredients.protein100') }}</span>
<input v-model.number="form.protein_g_100" class="input-number" type="number" min="0" step="0.01" required />
</label>
<label>
<span>{{ t('ingredients.carbs100') }}</span>
<input v-model.number="form.carbs_g_100" class="input-number" type="number" min="0" step="0.01" required />
</label>
<label>
<span>{{ t('ingredients.sugar100') }}</span>
<input v-model.number="form.sugar_g_100" class="input-number" type="number" min="0" step="0.01" required />
</label>
<label>
<span>{{ t('ingredients.fat100') }}</span>
<input v-model.number="form.fat_g_100" class="input-number" type="number" min="0" step="0.01" required />
</label>
<label>
<span>{{ t('ingredients.fiber100') }}</span>
<input v-model.number="form.fiber_g_100" class="input-number" type="number" min="0" step="0.01" required />
</label>
<label>
<span>{{ t('ingredients.kcal100Auto') }}</span>
<input v-model.number="form.kcal_100" class="input-number" type="number" min="0" step="0.01" required />
</label>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">{{ props.submitLabel ?? t('ingredients.submitDefault') }}</button>
<button class="btn" type="button" @click="emit('cancel')">{{ t('common.cancel') }}</button>
</div>
</form>
</template>

View File

@ -0,0 +1,111 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Ingredient, MealItem } from '@/types/domain'
const props = defineProps<{
items: MealItem[]
ingredients: Ingredient[]
}>()
const emit = defineEmits<{
(event: 'add-item', payload: { ingredient_id: number; grams: number }): void
(event: 'update-item', payload: { meal_item_id: number; ingredient_id: number; grams: number; position: number }): void
(event: 'remove-item', mealItemId: number): void
}>()
const { t } = useI18n()
const addIngredientId = ref<number | null>(null)
const addGrams = ref<number>(100)
const updateIngredient = (item: MealItem, ingredientId: number) => {
emit('update-item', {
meal_item_id: item.meal_item_id,
ingredient_id: ingredientId,
grams: item.grams,
position: item.position,
})
}
const updateGrams = (item: MealItem, grams: number) => {
emit('update-item', {
meal_item_id: item.meal_item_id,
ingredient_id: item.ingredient_id,
grams,
position: item.position,
})
}
const addItem = () => {
if (!addIngredientId.value || addGrams.value <= 0) {
return
}
emit('add-item', {
ingredient_id: addIngredientId.value,
grams: addGrams.value,
})
addGrams.value = 100
}
</script>
<template>
<section class="card meal-items-editor">
<h3>{{ t('meals.itemsTitle') }}</h3>
<div class="meal-items-editor__add-row">
<select v-model.number="addIngredientId" class="input-select">
<option :value="null">{{ t('meals.selectIngredient') }}</option>
<option v-for="ingredient in props.ingredients" :key="ingredient.ingredient_id" :value="ingredient.ingredient_id">
{{ ingredient.name }}
</option>
</select>
<input v-model.number="addGrams" type="number" min="1" class="input-number" />
<button class="btn btn-primary" type="button" @click="addItem">{{ t('meals.addItem') }}</button>
</div>
<table class="table" v-if="props.items.length > 0">
<thead>
<tr>
<th>{{ t('ingredients.name') }}</th>
<th>{{ t('meals.grams') }}</th>
<th>{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in props.items" :key="item.meal_item_id">
<td>
<select
class="input-select"
:value="item.ingredient_id"
@change="updateIngredient(item, Number(($event.target as HTMLSelectElement).value))"
>
<option
v-for="ingredient in props.ingredients"
:key="ingredient.ingredient_id"
:value="ingredient.ingredient_id"
>
{{ ingredient.name }}
</option>
</select>
</td>
<td>
<input
class="input-number"
type="number"
min="1"
:value="item.grams"
@change="updateGrams(item, Number(($event.target as HTMLInputElement).value))"
/>
</td>
<td>
<button class="btn btn-danger" type="button" @click="emit('remove-item', item.meal_item_id)">
{{ t('common.delete') }}
</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-state">{{ t('meals.noItems') }}</p>
</section>
</template>

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { MealType } from '@/types/domain'
const props = defineProps<{
modelValue: MealType | ''
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: MealType | ''): void
}>()
const { t } = useI18n()
</script>
<template>
<label class="filter-control">
<span>{{ t('meals.mealTypeFilter') }}</span>
<select
class="input-select"
:value="props.modelValue"
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value as MealType | '')"
>
<option value="">{{ t('common.all') }}</option>
<option value="breakfast">{{ t('mealTypes.breakfast') }}</option>
<option value="lunch">{{ t('mealTypes.lunch') }}</option>
<option value="dinner">{{ t('mealTypes.dinner') }}</option>
</select>
</label>
</template>

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const tabs = computed(() => [
{ to: { name: 'today' }, label: t('nav.today') },
{ to: { name: 'meals' }, label: t('nav.meals') },
{ to: { name: 'ingredients' }, label: t('nav.ingredients') },
{ to: { name: 'stats' }, label: t('nav.stats') },
{ to: { name: 'settings' }, label: t('nav.more') },
])
</script>
<template>
<nav class="app-bottom-tabs">
<RouterLink
v-for="tab in tabs"
:key="tab.label"
:to="tab.to"
class="app-bottom-tabs__link"
>
{{ tab.label }}
</RouterLink>
</nav>
</template>

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const navItems = computed(() => [
{ to: { name: 'today' }, label: t('nav.today') },
{ to: { name: 'meals' }, label: t('nav.meals') },
{ to: { name: 'ingredients' }, label: t('nav.ingredients') },
{ to: { name: 'stats' }, label: t('nav.stats') },
{ to: { name: 'settings' }, label: t('nav.settings') },
])
</script>
<template>
<aside class="app-sidebar">
<div class="app-sidebar__brand">
<img src="/Nutrio.png" :alt="t('app.name')" class="app-sidebar__logo" />
<strong>{{ t('app.name') }}</strong>
</div>
<nav class="app-sidebar__nav">
<RouterLink
v-for="item in navItems"
:key="item.label"
:to="item.to"
class="app-nav-link"
>
{{ item.label }}
</RouterLink>
</nav>
</aside>
</template>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const auth = useAuthStore()
const { t } = useI18n()
const title = computed(() => {
switch (route.name) {
case 'today':
return t('pageTitles.today')
case 'meals':
return t('pageTitles.meals')
case 'meal-detail':
return t('pageTitles.mealDetail')
case 'ingredients':
return t('pageTitles.ingredients')
case 'stats':
return t('pageTitles.stats')
case 'settings':
return t('pageTitles.settings')
default:
return t('pageTitles.default')
}
})
</script>
<template>
<header class="app-topbar">
<div>
<h1>{{ title }}</h1>
</div>
<div class="app-topbar__actions">
<ThemeToggle :show-label="false" />
<div class="app-topbar__user" v-if="auth.userEmail">
{{ auth.userEmail }}
</div>
</div>
</header>
</template>

View File

@ -0,0 +1,57 @@
<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<{
mealType: MealType
label: string
mealOptions: Array<{ value: number; label: string }>
selectedMealId: number | null
meal: Meal | null
}>()
const emit = defineEmits<{
(event: 'select-meal', mealType: MealType, mealId: number | null): void
}>()
const { t } = useI18n()
const selectedValue = computed({
get: () => (props.selectedMealId === null ? '' : String(props.selectedMealId)),
set: (value: string) => {
if (value.length <= 0) {
emit('select-meal', props.mealType, null)
return
}
emit('select-meal', props.mealType, Number(value))
},
})
</script>
<template>
<section class="card day-meal-card">
<div class="day-meal-card__header">
<h3>{{ label }}</h3>
</div>
<select v-model="selectedValue" class="input-select">
<option value="">{{ t('today.noMealPlan') }}</option>
<option v-for="option in mealOptions" :key="option.value" :value="String(option.value)">
{{ option.label }}
</option>
</select>
<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>
</section>
</template>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import MacroBadge from '@/components/common/MacroBadge.vue'
import type { NutritionTotals } from '@/types/domain'
defineProps<{
totals: NutritionTotals
}>()
const { t } = useI18n()
</script>
<template>
<section class="card totals-card">
<h3>{{ t('today.dayTotals') }}</h3>
<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 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>
</template>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<div class="card" aria-hidden="true">
<p>{{ t('today.mealPickerPlaceholder') }}</p>
</div>
</template>

View File

@ -1,10 +1,10 @@
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import cs from '@/locales/cs' import cs from '@/locales/cs.json'
import de from '@/locales/de' import de from '@/locales/de.json'
import en from '@/locales/en' import en from '@/locales/en.json'
import es from '@/locales/es' import es from '@/locales/es.json'
import sk from '@/locales/sk' import sk from '@/locales/sk.json'
export const SUPPORTED_LOCALES = ['sk', 'cs', 'en', 'es', 'de'] as const export const SUPPORTED_LOCALES = ['sk', 'cs', 'en', 'es', 'de'] as const
export type AppLocale = (typeof SUPPORTED_LOCALES)[number] export type AppLocale = (typeof SUPPORTED_LOCALES)[number]

View File

@ -0,0 +1,173 @@
{
"app": {
"name": "Nutrio",
"slogan": "Plánování jídel, výživa a denní přehled na jednom místě."
},
"auth": {
"title": "Přihlášení",
"subtitle": "Použij e-mail a heslo. Pokud účet neexistuje, vytvoří se automaticky.",
"emailLabel": "E-mail",
"emailPlaceholder": "např. jméno{'@'}domena.cz",
"passwordLabel": "Heslo",
"passwordPlaceholder": "Zadej heslo",
"submit": "Přihlásit se",
"submitting": "Zpracovávám...",
"helper": "Po úspěšném přihlášení bude token uložen do prohlížeče.",
"tokenSaved": "Token byl uložen.",
"successLoggedIn": "Přihlášení bylo úspěšné.",
"successAutoRegistered": "Účet neexistoval, byl vytvořen a uživatel je přihlášen.",
"themeToggle": "Přepnout režim",
"languageLabel": "Jazyk",
"showPassword": "Zobrazit heslo",
"hidePassword": "Skrýt heslo",
"errors": {
"invalidEmail": "Zadej platný email.",
"passwordRequired": "Zadej heslo.",
"invalidCredentials": "Email nebo heslo nejsou správně.",
"loginFailed": "Přihlášení selhalo. Zkus to znovu."
}
},
"theme": {
"light": "Světlý režim",
"dark": "Tmavý režim",
"toggle": "Přepnout režim"
},
"locale": {
"sk": "Slovenština",
"cs": "Čeština",
"en": "Angličtina",
"es": "Španělština",
"de": "Němčina"
},
"nav": {
"today": "Dnes",
"meals": "Jídelníčky",
"ingredients": "Suroviny",
"stats": "Statistiky",
"settings": "Nastavení",
"more": "Více"
},
"pageTitles": {
"today": "Denní přehled",
"meals": "Jídelníčky",
"mealDetail": "Detail jídelníčku",
"ingredients": "Suroviny",
"stats": "Statistiky",
"settings": "Nastavení",
"default": "Nutrio"
},
"mealTypes": {
"breakfast": "Snídaně",
"lunch": "Oběd",
"dinner": "Večeře"
},
"common": {
"date": "Datum",
"all": "Všechny",
"none": "Žádné",
"save": "Uložit",
"saving": "Ukládám...",
"cancel": "Zrušit",
"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",
"fiber": "V"
},
"labels": {
"protein": "Bílkoviny",
"carbs": "Sacharidy",
"fat": "Tuky"
}
},
"today": {
"loadingDay": "Načítám den...",
"noMealPlan": "Bez jídelníčku",
"noItems": "Zatím bez položek.",
"dayTotals": "Součty dne",
"mealPickerPlaceholder": "Výběr jídelníčku bude doplněn."
},
"meals": {
"mealTypeFilter": "Typ jídla",
"namePlaceholder": "Název jídelníčku",
"createButton": "Vytvořit",
"libraryTitle": "Knihovna jídelníčků",
"empty": "Zatím nemáš žádné jídelníčky.",
"loadingMeal": "Načítám jídelníček...",
"nameLabel": "Název",
"mealTypeLabel": "Typ jídla",
"saveChanges": "Uložit změny",
"deleteMeal": "Smazat jídelníček",
"notFound": "Jídelníček neexistuje.",
"itemsTitle": "Položky jídelníčku",
"selectIngredient": "Vyber surovinu",
"addItem": "Přidat",
"noItems": "Tento jídelníček zatím nemá položky.",
"grams": "Gramáž"
},
"ingredients": {
"newTitle": "Nová surovina",
"editTitle": "Upravit surovinu",
"databaseTitle": "Databáze surovin",
"newButton": "Nová surovina",
"empty": "Zatím nemáš uložené suroviny.",
"name": "Název",
"protein100": "Bílkoviny / 100 g",
"carbs100": "Sacharidy / 100 g",
"sugar100": "Cukr / 100 g",
"fat100": "Tuky / 100 g",
"fiber100": "Vláknina / 100 g",
"kcal100Auto": "Kcal / 100 g (0 = auto)",
"submitDefault": "Uložit"
},
"stats": {
"title": "Statistiky",
"placeholder": "Základní přehled bude doplněn v další iteraci."
},
"settings": {
"accountTitle": "Nastavení účtu",
"loggedInAs": "Přihlášený uživatel",
"themeTitle": "Vzhled",
"languageTitle": "Jazyk",
"logout": "Odhlásit se"
}
}

View File

@ -1,43 +0,0 @@
const cs = {
app: {
name: 'Nutrio',
slogan: 'Planovani jidel, vyziva a denni prehled na jednom miste.',
},
auth: {
title: 'Prihlaseni',
subtitle: 'Pouzij email a heslo. Pokud ucet neexistuje, vytvori se automaticky.',
emailLabel: 'E-mail',
emailPlaceholder: "napr. jmeno{'@'}domena.cz",
passwordLabel: 'Heslo',
passwordPlaceholder: 'Zadej heslo',
submit: 'Prihlasit se',
submitting: 'Zpracovavam...',
helper: 'Po uspesnem prihlaseni bude token ulozen do prohlizece.',
tokenSaved: 'Token byl ulozen.',
successLoggedIn: 'Prihlaseni bylo uspesne.',
successAutoRegistered: 'Ucet neexistoval, byl vytvoren a uzivatel je prihlasen.',
themeToggle: 'Prepnout rezim',
languageLabel: 'Jazyk',
showPassword: 'Zobrazit heslo',
hidePassword: 'Skryt heslo',
errors: {
invalidEmail: 'Zadej platny email.',
passwordRequired: 'Zadej heslo.',
invalidCredentials: 'Email nebo heslo nejsou spravne.',
loginFailed: 'Prihlaseni selhalo. Zkus to znovu.',
},
},
theme: {
light: 'Svetly rezim',
dark: 'Tmavy rezim',
},
locale: {
sk: 'Slovenstina',
cs: 'Cestina',
en: 'Anglictina',
es: 'Spanelstina',
de: 'Nemcina',
},
}
export default cs

View File

@ -0,0 +1,173 @@
{
"app": {
"name": "Nutrio",
"slogan": "Mahlzeitenplanung, Ernährung und Tagesübersicht an einem Ort."
},
"auth": {
"title": "Anmelden",
"subtitle": "Nutze E-Mail und Passwort. Wenn das Konto nicht existiert, wird es automatisch erstellt.",
"emailLabel": "E-Mail",
"emailPlaceholder": "z. B. name{'@'}domain.de",
"passwordLabel": "Passwort",
"passwordPlaceholder": "Passwort eingeben",
"submit": "Anmelden",
"submitting": "Wird verarbeitet...",
"helper": "Nach erfolgreicher Anmeldung wird das Token im Browser gespeichert.",
"tokenSaved": "Token wurde gespeichert.",
"successLoggedIn": "Anmeldung war erfolgreich.",
"successAutoRegistered": "Konto war nicht vorhanden, wurde erstellt und der Benutzer ist nun angemeldet.",
"themeToggle": "Modus wechseln",
"languageLabel": "Sprache",
"showPassword": "Passwort anzeigen",
"hidePassword": "Passwort verbergen",
"errors": {
"invalidEmail": "Gib eine gültige E-Mail-Adresse ein.",
"passwordRequired": "Passwort eingeben.",
"invalidCredentials": "E-Mail oder Passwort ist falsch.",
"loginFailed": "Anmeldung fehlgeschlagen. Bitte versuche es erneut."
}
},
"theme": {
"light": "Heller Modus",
"dark": "Dunkler Modus",
"toggle": "Modus wechseln"
},
"locale": {
"sk": "Slowakisch",
"cs": "Tschechisch",
"en": "Englisch",
"es": "Spanisch",
"de": "Deutsch"
},
"nav": {
"today": "Heute",
"meals": "Mahlzeiten",
"ingredients": "Zutaten",
"stats": "Statistik",
"settings": "Einstellungen",
"more": "Mehr"
},
"pageTitles": {
"today": "Tagesübersicht",
"meals": "Mahlzeiten",
"mealDetail": "Mahlzeit-Detail",
"ingredients": "Zutaten",
"stats": "Statistik",
"settings": "Einstellungen",
"default": "Nutrio"
},
"mealTypes": {
"breakfast": "Frühstück",
"lunch": "Mittagessen",
"dinner": "Abendessen"
},
"common": {
"date": "Datum",
"all": "Alle",
"none": "Keine",
"save": "Speichern",
"saving": "Speichert...",
"cancel": "Abbrechen",
"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",
"fiber": "Ba"
},
"labels": {
"protein": "Eiweiß",
"carbs": "Kohlenhydrate",
"fat": "Fett"
}
},
"today": {
"loadingDay": "Tag wird geladen...",
"noMealPlan": "Kein Mahlzeitenplan",
"noItems": "Noch keine Einträge.",
"dayTotals": "Tagessummen",
"mealPickerPlaceholder": "Mahlzeiten-Auswahl wird später ergänzt."
},
"meals": {
"mealTypeFilter": "Mahlzeittyp",
"namePlaceholder": "Name des Mahlzeitenplans",
"createButton": "Erstellen",
"libraryTitle": "Mahlzeiten-Bibliothek",
"empty": "Du hast noch keine Mahlzeitenpläne.",
"loadingMeal": "Mahlzeitenplan wird geladen...",
"nameLabel": "Name",
"mealTypeLabel": "Mahlzeittyp",
"saveChanges": "Änderungen speichern",
"deleteMeal": "Mahlzeitenplan löschen",
"notFound": "Mahlzeitenplan existiert nicht.",
"itemsTitle": "Mahlzeiten-Elemente",
"selectIngredient": "Zutat auswählen",
"addItem": "Hinzufügen",
"noItems": "Dieser Mahlzeitenplan hat noch keine Einträge.",
"grams": "Gramm"
},
"ingredients": {
"newTitle": "Neue Zutat",
"editTitle": "Zutat bearbeiten",
"databaseTitle": "Zutaten-Datenbank",
"newButton": "Neue Zutat",
"empty": "Du hast noch keine gespeicherten Zutaten.",
"name": "Name",
"protein100": "Eiweiß / 100 g",
"carbs100": "Kohlenhydrate / 100 g",
"sugar100": "Zucker / 100 g",
"fat100": "Fett / 100 g",
"fiber100": "Ballaststoffe / 100 g",
"kcal100Auto": "Kcal / 100 g (0 = auto)",
"submitDefault": "Speichern"
},
"stats": {
"title": "Statistik",
"placeholder": "Grundübersicht wird in der nächsten Iteration ergänzt."
},
"settings": {
"accountTitle": "Kontoeinstellungen",
"loggedInAs": "Angemeldet als",
"themeTitle": "Darstellung",
"languageTitle": "Sprache",
"logout": "Abmelden"
}
}

View File

@ -1,43 +0,0 @@
const de = {
app: {
name: 'Nutrio',
slogan: 'Mahlzeitenplanung, Ernahrung und Tagesubersicht an einem Ort.',
},
auth: {
title: 'Anmelden',
subtitle: 'Nutze E-Mail und Passwort. Wenn das Konto nicht existiert, wird es automatisch erstellt.',
emailLabel: 'E-Mail',
emailPlaceholder: "z. B. name{'@'}domain.de",
passwordLabel: 'Passwort',
passwordPlaceholder: 'Passwort eingeben',
submit: 'Anmelden',
submitting: 'Wird verarbeitet...',
helper: 'Nach erfolgreicher Anmeldung wird das Token im Browser gespeichert.',
tokenSaved: 'Token wurde gespeichert.',
successLoggedIn: 'Anmeldung war erfolgreich.',
successAutoRegistered: 'Konto war nicht vorhanden, wurde erstellt und der Benutzer ist nun angemeldet.',
themeToggle: 'Modus wechseln',
languageLabel: 'Sprache',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
errors: {
invalidEmail: 'Gib eine gueltige E-Mail-Adresse ein.',
passwordRequired: 'Passwort eingeben.',
invalidCredentials: 'E-Mail oder Passwort ist falsch.',
loginFailed: 'Anmeldung fehlgeschlagen. Bitte versuche es erneut.',
},
},
theme: {
light: 'Heller Modus',
dark: 'Dunkler Modus',
},
locale: {
sk: 'Slowakisch',
cs: 'Tschechisch',
en: 'Englisch',
es: 'Spanisch',
de: 'Deutsch',
},
}
export default de

View File

@ -0,0 +1,173 @@
{
"app": {
"name": "Nutrio",
"slogan": "Meal planning, nutrition, and daily totals in one place."
},
"auth": {
"title": "Sign in",
"subtitle": "Use your email and password. If the account does not exist, it will be created automatically.",
"emailLabel": "Email",
"emailPlaceholder": "e.g. name{'@'}domain.com",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter password",
"submit": "Sign in",
"submitting": "Processing...",
"helper": "After successful sign-in, the token will be stored in your browser.",
"tokenSaved": "Token was saved.",
"successLoggedIn": "Sign-in was successful.",
"successAutoRegistered": "Account did not exist, it was created and the user is now signed in.",
"themeToggle": "Toggle mode",
"languageLabel": "Language",
"showPassword": "Show password",
"hidePassword": "Hide password",
"errors": {
"invalidEmail": "Enter a valid email address.",
"passwordRequired": "Enter password.",
"invalidCredentials": "Email or password is incorrect.",
"loginFailed": "Sign-in failed. Please try again."
}
},
"theme": {
"light": "Light mode",
"dark": "Dark mode",
"toggle": "Toggle mode"
},
"locale": {
"sk": "Slovak",
"cs": "Czech",
"en": "English",
"es": "Spanish",
"de": "German"
},
"nav": {
"today": "Today",
"meals": "Meals",
"ingredients": "Ingredients",
"stats": "Stats",
"settings": "Settings",
"more": "More"
},
"pageTitles": {
"today": "Daily Overview",
"meals": "Meals",
"mealDetail": "Meal Detail",
"ingredients": "Ingredients",
"stats": "Stats",
"settings": "Settings",
"default": "Nutrio"
},
"mealTypes": {
"breakfast": "Breakfast",
"lunch": "Lunch",
"dinner": "Dinner"
},
"common": {
"date": "Date",
"all": "All",
"none": "None",
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel",
"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",
"fiber": "Fi"
},
"labels": {
"protein": "Protein",
"carbs": "Carbs",
"fat": "Fat"
}
},
"today": {
"loadingDay": "Loading day...",
"noMealPlan": "No meal plan",
"noItems": "No items yet.",
"dayTotals": "Day totals",
"mealPickerPlaceholder": "Meal picker will be added later."
},
"meals": {
"mealTypeFilter": "Meal type",
"namePlaceholder": "Meal plan name",
"createButton": "Create",
"libraryTitle": "Meal library",
"empty": "You do not have any meal plans yet.",
"loadingMeal": "Loading meal plan...",
"nameLabel": "Name",
"mealTypeLabel": "Meal type",
"saveChanges": "Save changes",
"deleteMeal": "Delete meal plan",
"notFound": "Meal plan does not exist.",
"itemsTitle": "Meal items",
"selectIngredient": "Select ingredient",
"addItem": "Add",
"noItems": "This meal plan has no items yet.",
"grams": "Grams"
},
"ingredients": {
"newTitle": "New ingredient",
"editTitle": "Edit ingredient",
"databaseTitle": "Ingredient database",
"newButton": "New ingredient",
"empty": "You do not have any saved ingredients yet.",
"name": "Name",
"protein100": "Protein / 100 g",
"carbs100": "Carbs / 100 g",
"sugar100": "Sugar / 100 g",
"fat100": "Fat / 100 g",
"fiber100": "Fiber / 100 g",
"kcal100Auto": "Kcal / 100 g (0 = auto)",
"submitDefault": "Save"
},
"stats": {
"title": "Stats",
"placeholder": "Basic overview will be added in the next iteration."
},
"settings": {
"accountTitle": "Account settings",
"loggedInAs": "Logged in as",
"themeTitle": "Appearance",
"languageTitle": "Language",
"logout": "Log out"
}
}

View File

@ -1,43 +0,0 @@
const en = {
app: {
name: 'Nutrio',
slogan: 'Meal planning, nutrition, and daily totals in one place.',
},
auth: {
title: 'Sign in',
subtitle: 'Use your email and password. If the account does not exist, it will be created automatically.',
emailLabel: 'Email',
emailPlaceholder: "e.g. name{'@'}domain.com",
passwordLabel: 'Password',
passwordPlaceholder: 'Enter password',
submit: 'Sign in',
submitting: 'Processing...',
helper: 'After successful sign-in, the token will be stored in your browser.',
tokenSaved: 'Token was saved.',
successLoggedIn: 'Sign-in was successful.',
successAutoRegistered: 'Account did not exist, it was created and the user is now signed in.',
themeToggle: 'Toggle mode',
languageLabel: 'Language',
showPassword: 'Show password',
hidePassword: 'Hide password',
errors: {
invalidEmail: 'Enter a valid email address.',
passwordRequired: 'Enter password.',
invalidCredentials: 'Email or password is incorrect.',
loginFailed: 'Sign-in failed. Please try again.',
},
},
theme: {
light: 'Light mode',
dark: 'Dark mode',
},
locale: {
sk: 'Slovak',
cs: 'Czech',
en: 'English',
es: 'Spanish',
de: 'German',
},
}
export default en

View File

@ -0,0 +1,173 @@
{
"app": {
"name": "Nutrio",
"slogan": "Planificación de comidas, nutrición y resumen diario en un solo lugar."
},
"auth": {
"title": "Iniciar sesión",
"subtitle": "Usa correo y contraseña. Si la cuenta no existe, se creará automáticamente.",
"emailLabel": "Correo",
"emailPlaceholder": "p. ej. nombre{'@'}dominio.es",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Introduce la contraseña",
"submit": "Iniciar sesión",
"submitting": "Procesando...",
"helper": "Tras iniciar sesión correctamente, el token se guardará en el navegador.",
"tokenSaved": "El token se ha guardado.",
"successLoggedIn": "Inicio de sesión correcto.",
"successAutoRegistered": "La cuenta no existía, se creó y el usuario ha iniciado sesión.",
"themeToggle": "Cambiar modo",
"languageLabel": "Idioma",
"showPassword": "Mostrar contraseña",
"hidePassword": "Ocultar contraseña",
"errors": {
"invalidEmail": "Introduce un correo válido.",
"passwordRequired": "Introduce la contraseña.",
"invalidCredentials": "El correo o la contraseña son incorrectos.",
"loginFailed": "El inicio de sesión falló. Inténtalo de nuevo."
}
},
"theme": {
"light": "Modo claro",
"dark": "Modo oscuro",
"toggle": "Cambiar modo"
},
"locale": {
"sk": "Eslovaco",
"cs": "Checo",
"en": "Inglés",
"es": "Español",
"de": "Alemán"
},
"nav": {
"today": "Hoy",
"meals": "Menús",
"ingredients": "Ingredientes",
"stats": "Estadísticas",
"settings": "Ajustes",
"more": "Más"
},
"pageTitles": {
"today": "Resumen diario",
"meals": "Menús",
"mealDetail": "Detalle del menú",
"ingredients": "Ingredientes",
"stats": "Estadísticas",
"settings": "Ajustes",
"default": "Nutrio"
},
"mealTypes": {
"breakfast": "Desayuno",
"lunch": "Comida",
"dinner": "Cena"
},
"common": {
"date": "Fecha",
"all": "Todos",
"none": "Ninguno",
"save": "Guardar",
"saving": "Guardando...",
"cancel": "Cancelar",
"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",
"fiber": "Fi"
},
"labels": {
"protein": "Proteínas",
"carbs": "Carbohidratos",
"fat": "Grasas"
}
},
"today": {
"loadingDay": "Cargando día...",
"noMealPlan": "Sin menú",
"noItems": "Todavía sin elementos.",
"dayTotals": "Totales del día",
"mealPickerPlaceholder": "El selector de menú se añadirá más tarde."
},
"meals": {
"mealTypeFilter": "Tipo de comida",
"namePlaceholder": "Nombre del menú",
"createButton": "Crear",
"libraryTitle": "Biblioteca de menús",
"empty": "Aún no tienes menús.",
"loadingMeal": "Cargando menú...",
"nameLabel": "Nombre",
"mealTypeLabel": "Tipo de comida",
"saveChanges": "Guardar cambios",
"deleteMeal": "Eliminar menú",
"notFound": "El menú no existe.",
"itemsTitle": "Elementos del menú",
"selectIngredient": "Selecciona ingrediente",
"addItem": "Añadir",
"noItems": "Este menú aún no tiene elementos.",
"grams": "Gramos"
},
"ingredients": {
"newTitle": "Nuevo ingrediente",
"editTitle": "Editar ingrediente",
"databaseTitle": "Base de datos de ingredientes",
"newButton": "Nuevo ingrediente",
"empty": "Aún no tienes ingredientes guardados.",
"name": "Nombre",
"protein100": "Proteína / 100 g",
"carbs100": "Carbohidratos / 100 g",
"sugar100": "Azúcar / 100 g",
"fat100": "Grasas / 100 g",
"fiber100": "Fibra / 100 g",
"kcal100Auto": "Kcal / 100 g (0 = auto)",
"submitDefault": "Guardar"
},
"stats": {
"title": "Estadísticas",
"placeholder": "El resumen básico se añadirá en la próxima iteración."
},
"settings": {
"accountTitle": "Ajustes de cuenta",
"loggedInAs": "Sesión iniciada como",
"themeTitle": "Apariencia",
"languageTitle": "Idioma",
"logout": "Cerrar sesión"
}
}

View File

@ -1,43 +0,0 @@
const es = {
app: {
name: 'Nutrio',
slogan: 'Planificacion de comidas, nutricion y resumen diario en un solo lugar.',
},
auth: {
title: 'Iniciar sesion',
subtitle: 'Usa correo y contrasena. Si la cuenta no existe, se creara automaticamente.',
emailLabel: 'Correo',
emailPlaceholder: "p. ej. nombre{'@'}dominio.es",
passwordLabel: 'Contrasena',
passwordPlaceholder: 'Introduce la contrasena',
submit: 'Iniciar sesion',
submitting: 'Procesando...',
helper: 'Tras iniciar sesion correctamente, el token se guardara en el navegador.',
tokenSaved: 'El token se ha guardado.',
successLoggedIn: 'Inicio de sesion correcto.',
successAutoRegistered: 'La cuenta no existia, se creo y el usuario ha iniciado sesion.',
themeToggle: 'Cambiar modo',
languageLabel: 'Idioma',
showPassword: 'Mostrar contrasena',
hidePassword: 'Ocultar contrasena',
errors: {
invalidEmail: 'Introduce un correo valido.',
passwordRequired: 'Introduce la contrasena.',
invalidCredentials: 'El correo o la contrasena son incorrectos.',
loginFailed: 'El inicio de sesion fallo. Intentalo de nuevo.',
},
},
theme: {
light: 'Modo claro',
dark: 'Modo oscuro',
},
locale: {
sk: 'Eslovaco',
cs: 'Checo',
en: 'Ingles',
es: 'Espanol',
de: 'Aleman',
},
}
export default es

View File

@ -0,0 +1,173 @@
{
"app": {
"name": "Nutrio",
"slogan": "Plánovanie jedál, výživa a denný prehľad na jednom mieste."
},
"auth": {
"title": "Prihlásenie",
"subtitle": "Použi email a heslo. Ak účet neexistuje, vytvorí sa automaticky.",
"emailLabel": "Email",
"emailPlaceholder": "napr. meno{'@'}domena.sk",
"passwordLabel": "Heslo",
"passwordPlaceholder": "Zadaj heslo",
"submit": "Prihlásiť sa",
"submitting": "Spracúvam...",
"helper": "Po úspešnom prihlásení bude token uložený do prehliadača.",
"tokenSaved": "Token bol uložený.",
"successLoggedIn": "Prihlásenie bolo úspešné.",
"successAutoRegistered": "Účet neexistoval, bol vytvorený a používateľ je prihlásený.",
"themeToggle": "Prepnúť režim",
"languageLabel": "Jazyk",
"showPassword": "Zobraziť heslo",
"hidePassword": "Skryť heslo",
"errors": {
"invalidEmail": "Zadaj platný email.",
"passwordRequired": "Zadaj heslo.",
"invalidCredentials": "Email alebo heslo nie sú správne.",
"loginFailed": "Prihlásenie zlyhalo. Skús to znova."
}
},
"theme": {
"light": "Svetlý režim",
"dark": "Tmavý režim",
"toggle": "Prepnúť režim"
},
"locale": {
"sk": "Slovenčina",
"cs": "Čeština",
"en": "Angličtina",
"es": "Španielčina",
"de": "Nemčina"
},
"nav": {
"today": "Dnes",
"meals": "Jedálničky",
"ingredients": "Suroviny",
"stats": "Štatistiky",
"settings": "Nastavenia",
"more": "Viac"
},
"pageTitles": {
"today": "Denný prehľad",
"meals": "Jedálničky",
"mealDetail": "Detail jedálnička",
"ingredients": "Suroviny",
"stats": "Štatistiky",
"settings": "Nastavenia",
"default": "Nutrio"
},
"mealTypes": {
"breakfast": "Raňajky",
"lunch": "Obed",
"dinner": "Večera"
},
"common": {
"date": "Dátum",
"all": "Všetky",
"none": "Žiadne",
"save": "Uložiť",
"saving": "Ukladám...",
"cancel": "Zrušiť",
"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",
"fiber": "V"
},
"labels": {
"protein": "Bielkoviny",
"carbs": "Sacharidy",
"fat": "Tuky"
}
},
"today": {
"loadingDay": "Načítavam deň...",
"noMealPlan": "Bez jedálnička",
"noItems": "Zatiaľ bez položiek.",
"dayTotals": "Súčty dňa",
"mealPickerPlaceholder": "Výber jedálnička bude doplnený."
},
"meals": {
"mealTypeFilter": "Typ jedla",
"namePlaceholder": "Názov jedálnička",
"createButton": "Vytvoriť",
"libraryTitle": "Knižnica jedálničkov",
"empty": "Zatiaľ nemáš žiadne jedálničky.",
"loadingMeal": "Načítavam jedálniček...",
"nameLabel": "Názov",
"mealTypeLabel": "Typ jedla",
"saveChanges": "Uložiť zmeny",
"deleteMeal": "Zmazať jedálniček",
"notFound": "Jedálniček neexistuje.",
"itemsTitle": "Položky jedálnička",
"selectIngredient": "Vyber surovinu",
"addItem": "Pridať",
"noItems": "Tento jedálniček zatiaľ nemá položky.",
"grams": "Gramáž"
},
"ingredients": {
"newTitle": "Nová surovina",
"editTitle": "Upraviť surovinu",
"databaseTitle": "Databáza surovín",
"newButton": "Nová surovina",
"empty": "Zatiaľ nemáš uložené suroviny.",
"name": "Názov",
"protein100": "Bielkoviny / 100 g",
"carbs100": "Sacharidy / 100 g",
"sugar100": "Cukor / 100 g",
"fat100": "Tuky / 100 g",
"fiber100": "Vláknina / 100 g",
"kcal100Auto": "Kcal / 100 g (0 = auto)",
"submitDefault": "Uložiť"
},
"stats": {
"title": "Štatistiky",
"placeholder": "Základný prehľad bude doplnený v ďalšej iterácii."
},
"settings": {
"accountTitle": "Nastavenia účtu",
"loggedInAs": "Prihlásený používateľ",
"themeTitle": "Vzhľad",
"languageTitle": "Jazyk",
"logout": "Odhlásiť sa"
}
}

View File

@ -1,43 +0,0 @@
const sk = {
app: {
name: 'Nutrio',
slogan: 'Plánovanie jedál, výživa a denný prehľad na jednom mieste.',
},
auth: {
title: 'Prihlásenie',
subtitle: 'Použi email a heslo. Ak účet neexistuje, vytvorí sa automaticky.',
emailLabel: 'Email',
emailPlaceholder: "napr. meno{'@'}domena.sk",
passwordLabel: 'Heslo',
passwordPlaceholder: 'Zadaj heslo',
submit: 'Prihlásiť sa',
submitting: 'Spracúvam...',
helper: 'Po úspešnom prihlásení bude token uložený do prehliadača.',
tokenSaved: 'Token bol uložený.',
successLoggedIn: 'Prihlásenie bolo úspešné.',
successAutoRegistered: 'Účet neexistoval, bol vytvorený a používateľ je prihlásený.',
themeToggle: 'Prepnúť režim',
languageLabel: 'Jazyk',
showPassword: 'Zobraziť heslo',
hidePassword: 'Skryť heslo',
errors: {
invalidEmail: 'Zadaj platný email.',
passwordRequired: 'Zadaj heslo.',
invalidCredentials: 'Email alebo heslo nie sú správne.',
loginFailed: 'Prihlásenie zlyhalo. Skús to znova.',
},
},
theme: {
light: 'Svetlý režim',
dark: 'Tmavý režim',
},
locale: {
sk: 'Slovenčina',
cs: 'Čeština',
en: 'Angličtina',
es: 'Španielčina',
de: 'Nemčina',
},
}
export default sk

View File

@ -4,10 +4,40 @@ import './assets/css/style.css'
import App from './App.vue' import App from './App.vue'
import i18n from './i18n' import i18n from './i18n'
import router from './router' 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 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.component('font-awesome-icon', FontAwesomeIcon)
app.use(pinia)
app.use(router) app.use(router)
app.use(i18n) app.use(i18n)

View File

@ -1,15 +1,57 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import AuthView from '@/views/AuthView.vue' import AuthView from '@/views/AuthView.vue'
import AppLayout from '@/views/AppLayout.vue'
import TodayView from '@/views/TodayView.vue'
import MealsView from '@/views/MealsView.vue'
import MealDetailView from '@/views/MealDetailView.vue'
import IngredientsView from '@/views/IngredientsView.vue'
import StatsView from '@/views/StatsView.vue'
import SettingsView from '@/views/SettingsView.vue'
import { pinia } from '@/stores'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({ const routes: RouteRecordRaw[] = [
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ {
path: '/', path: '/',
name: 'auth', name: 'auth',
component: AuthView, component: AuthView,
meta: { guestOnly: true },
}, },
{
path: '/app',
component: AppLayout,
meta: { requiresAuth: true },
children: [
{ path: '', redirect: { name: 'today' } },
{ path: 'today/:date(\\d{4}-\\d{2}-\\d{2})?', name: 'today', component: TodayView },
{ path: 'meals', name: 'meals', component: MealsView },
{ path: 'meals/:mealId(\\d+)', name: 'meal-detail', component: MealDetailView, props: true },
{ path: 'ingredients', name: 'ingredients', component: IngredientsView },
{ path: 'stats', name: 'stats', component: StatsView },
{ path: 'settings', name: 'settings', component: SettingsView },
], ],
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
router.beforeEach((to) => {
const auth = useAuthStore(pinia)
const requiresAuth = to.matched.some((route) => route.meta.requiresAuth === true)
const guestOnly = to.matched.some((route) => route.meta.guestOnly === true)
if (requiresAuth && !auth.isAuthenticated) {
return { name: 'auth', query: { redirect: to.fullPath } }
}
if (guestOnly && auth.isAuthenticated) {
return { name: 'today' }
}
return true
}) })
export default router export default router

View File

@ -0,0 +1,73 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import BackendAPI from '@/BackendAPI.ts'
import { unwrapApiData } from '@/utils/api'
type LoginPayload = {
auto_registered?: boolean
user?: {
email?: string | null
token?: string | null
}
}
const TOKEN_KEY = 'token'
const USER_EMAIL_KEY = 'user_email'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
const userEmail = ref<string | null>(localStorage.getItem(USER_EMAIL_KEY))
const isAuthenticated = computed(() => typeof token.value === 'string' && token.value.length > 0)
const setSession = (nextToken: string, nextUserEmail: string) => {
token.value = nextToken
userEmail.value = nextUserEmail
localStorage.setItem(TOKEN_KEY, nextToken)
localStorage.setItem(USER_EMAIL_KEY, nextUserEmail)
}
const clearSession = () => {
token.value = null
userEmail.value = null
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_EMAIL_KEY)
}
const login = async (email: string, password: string): Promise<{ autoRegistered: boolean }> => {
const payload = unwrapApiData<LoginPayload>(await BackendAPI.userLogin(email, password))
const nextToken = payload.user?.token ?? null
const nextUserEmail = payload.user?.email ?? email
if (!nextToken) {
throw new Error('Token missing in login response')
}
setSession(nextToken, nextUserEmail)
return {
autoRegistered: payload.auto_registered === true,
}
}
const logout = async () => {
if (token.value) {
try {
await BackendAPI.userLogout(token.value)
} catch {
// Keep local logout flow even if API call fails.
}
}
clearSession()
}
return {
token,
userEmail,
isAuthenticated,
setSession,
clearSession,
login,
logout,
}
})

View File

@ -0,0 +1,127 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import BackendAPI from '@/BackendAPI.ts'
import { useAuthStore } from '@/stores/auth'
import { useMealsStore } from '@/stores/meals'
import type { DayMealsByType, DiaryDay, MealType } from '@/types/domain'
import { MEAL_TYPES } from '@/types/domain'
import { computeDayTotals, emptyTotals } from '@/utils/nutrition'
import { unwrapApiData } from '@/utils/api'
const createEmptyDay = (date: string): DiaryDay => ({
user_id: 0,
day_date: date,
diary_day_id: null,
entries: [],
totals: emptyTotals(),
})
export const useDiaryStore = defineStore('diary', () => {
const auth = useAuthStore()
const mealsStore = useMealsStore()
const currentDay = ref<DiaryDay | null>(null)
const loading = ref(false)
const requireToken = (): string => {
if (!auth.token) {
throw new Error('User is not authenticated')
}
return auth.token
}
const mealsByType = computed<DayMealsByType>(() => {
const ret: DayMealsByType = {
breakfast: null,
lunch: null,
dinner: null,
}
for (const entry of currentDay.value?.entries ?? []) {
const cachedMeal = mealsStore.getMealFromCache(entry.meal_id)
ret[entry.meal_type] = cachedMeal ?? entry.meal ?? null
}
return ret
})
const computedTotals = computed(() => computeDayTotals(mealsByType.value))
const hydrateDayMeals = async (day: DiaryDay) => {
const loadTasks: Promise<unknown>[] = []
for (const entry of day.entries) {
loadTasks.push(mealsStore.ensureMeal(entry.meal_id))
}
if (loadTasks.length > 0) {
await Promise.all(loadTasks)
}
}
const setCurrentDay = async (day: DiaryDay) => {
currentDay.value = day
await hydrateDayMeals(day)
}
const loadDay = async (date: string) => {
loading.value = true
try {
const token = requireToken()
const payload = unwrapApiData<DiaryDay>(await BackendAPI.diaryDayGet(token, date, true))
await setCurrentDay(payload)
} finally {
loading.value = false
}
}
const setMealForType = async (date: string, mealType: MealType, mealId: number) => {
const token = requireToken()
const payload = unwrapApiData<DiaryDay>(await BackendAPI.diaryDaySetMeal(token, date, mealType, mealId))
await setCurrentDay(payload)
}
const unsetMealForType = async (date: string, mealType: MealType) => {
const token = requireToken()
const payload = unwrapApiData<DiaryDay>(await BackendAPI.diaryDayUnsetMeal(token, date, mealType))
await setCurrentDay(payload)
}
const selectedMealId = (mealType: MealType): number | null => {
const entry = currentDay.value?.entries.find((item) => item.meal_type === mealType)
return entry?.meal_id ?? null
}
const ensureCurrentDay = (date: string) => {
if (!currentDay.value || currentDay.value.day_date !== date) {
currentDay.value = createEmptyDay(date)
}
}
const selectedMealOptions = computed(() => {
const map: Record<MealType, { value: number; label: string }[]> = {
breakfast: [],
lunch: [],
dinner: [],
}
for (const type of MEAL_TYPES) {
map[type] = mealsStore.sortedMeals
.filter((meal) => meal.meal_type === type)
.map((meal) => ({ value: meal.meal_id, label: meal.name }))
}
return map
})
return {
currentDay,
loading,
mealsByType,
computedTotals,
selectedMealOptions,
selectedMealId,
ensureCurrentDay,
loadDay,
setMealForType,
unsetMealForType,
}
})

View File

@ -0,0 +1,3 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()

View File

@ -0,0 +1,100 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import BackendAPI from '@/BackendAPI.ts'
import { useAuthStore } from '@/stores/auth'
import type { Ingredient } from '@/types/domain'
import { unwrapApiData } from '@/utils/api'
export type IngredientInput = {
name: string
protein_g_100: number
carbs_g_100: number
sugar_g_100: number
fat_g_100: number
fiber_g_100: number
kcal_100: number
}
export const useIngredientsStore = defineStore('ingredients', () => {
const auth = useAuthStore()
const items = ref<Ingredient[]>([])
const loading = ref(false)
const sortedItems = computed(() => {
return [...items.value].sort((a, b) => a.name.localeCompare(b.name, 'sk'))
})
const requireToken = (): string => {
if (!auth.token) {
throw new Error('User is not authenticated')
}
return auth.token
}
const loadIngredients = async (query = '', includeGlobal = true) => {
loading.value = true
try {
const token = requireToken()
const data = unwrapApiData<Ingredient[]>(
await BackendAPI.ingredientList(token, query, includeGlobal),
)
items.value = Array.isArray(data) ? data : []
} finally {
loading.value = false
}
}
const createIngredient = async (payload: IngredientInput) => {
const token = requireToken()
const created = unwrapApiData<Ingredient>(
await BackendAPI.ingredientCreate(
token,
payload.name,
payload.protein_g_100,
payload.carbs_g_100,
payload.sugar_g_100,
payload.fat_g_100,
payload.fiber_g_100,
payload.kcal_100,
),
)
items.value = [...items.value, created]
return created
}
const updateIngredient = async (ingredientId: number, payload: IngredientInput) => {
const token = requireToken()
const updated = unwrapApiData<Ingredient>(
await BackendAPI.ingredientUpdate(
token,
ingredientId,
payload.name,
payload.protein_g_100,
payload.carbs_g_100,
payload.sugar_g_100,
payload.fat_g_100,
payload.fiber_g_100,
payload.kcal_100,
),
)
items.value = items.value.map((item) => (item.ingredient_id === ingredientId ? updated : item))
return updated
}
const deleteIngredient = async (ingredientId: number) => {
const token = requireToken()
await BackendAPI.ingredientDelete(token, ingredientId)
items.value = items.value.filter((item) => item.ingredient_id !== ingredientId)
}
return {
items,
sortedItems,
loading,
loadIngredients,
createIngredient,
updateIngredient,
deleteIngredient,
}
})

View File

@ -0,0 +1,164 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import BackendAPI from '@/BackendAPI.ts'
import { useAuthStore } from '@/stores/auth'
import type { Meal, MealItem, MealType } from '@/types/domain'
import { withComputedMealTotals } from '@/utils/nutrition'
import { unwrapApiData } from '@/utils/api'
export type MealInput = {
name: string
meal_type: MealType
}
export type MealItemInput = {
ingredient_id: number
grams: number
position: number
}
type MealItemListResponse = {
meal_id: number
items: MealItem[]
}
export const useMealsStore = defineStore('meals', () => {
const auth = useAuthStore()
const list = ref<Meal[]>([])
const mealById = ref<Record<number, Meal>>({})
const loading = ref(false)
const sortedMeals = computed(() => {
return [...list.value].sort((a, b) => a.name.localeCompare(b.name, 'sk'))
})
const requireToken = (): string => {
if (!auth.token) {
throw new Error('User is not authenticated')
}
return auth.token
}
const upsertMeal = (meal: Meal) => {
const computedMeal = withComputedMealTotals(meal)
mealById.value[computedMeal.meal_id] = computedMeal
const index = list.value.findIndex((item) => item.meal_id === computedMeal.meal_id)
if (index >= 0) {
list.value.splice(index, 1, computedMeal)
} else {
list.value.push(computedMeal)
}
}
const loadMeals = async (mealType = '') => {
loading.value = true
try {
const token = requireToken()
const rows = unwrapApiData<Meal[]>(await BackendAPI.mealList(token, mealType, false, false))
const nextList = Array.isArray(rows)
? rows.map((meal) => withComputedMealTotals({ ...meal, items: meal.items ?? [] }))
: []
list.value = nextList
const nextById: Record<number, Meal> = {}
for (const meal of nextList) {
nextById[meal.meal_id] = meal
}
mealById.value = nextById
} finally {
loading.value = false
}
}
const loadMeal = async (mealId: number): Promise<Meal> => {
const token = requireToken()
const mealData = unwrapApiData<Meal>(await BackendAPI.mealGet(token, mealId, true, false))
const itemsData = unwrapApiData<MealItemListResponse>(await BackendAPI.mealItemList(token, mealId, false))
const mealWithItems = withComputedMealTotals({
...mealData,
items: itemsData.items ?? [],
})
upsertMeal(mealWithItems)
return mealWithItems
}
const getMealFromCache = (mealId: number): Meal | null => {
return mealById.value[mealId] ?? null
}
const ensureMeal = async (mealId: number): Promise<Meal> => {
const cached = getMealFromCache(mealId)
if (cached?.items?.length) {
return cached
}
return loadMeal(mealId)
}
const createMeal = async (payload: MealInput): Promise<Meal> => {
const token = requireToken()
const created = unwrapApiData<Meal>(await BackendAPI.mealCreate(token, payload.name, payload.meal_type))
const nextMeal = withComputedMealTotals({ ...created, items: [] })
upsertMeal(nextMeal)
return nextMeal
}
const updateMeal = async (mealId: number, payload: MealInput): Promise<Meal> => {
const token = requireToken()
const updated = unwrapApiData<Meal>(await BackendAPI.mealUpdate(token, mealId, payload.name, payload.meal_type))
const existingItems = mealById.value[mealId]?.items ?? []
const nextMeal = withComputedMealTotals({ ...updated, items: existingItems })
upsertMeal(nextMeal)
return nextMeal
}
const deleteMeal = async (mealId: number) => {
const token = requireToken()
await BackendAPI.mealDelete(token, mealId)
list.value = list.value.filter((meal) => meal.meal_id !== mealId)
const nextById = { ...mealById.value }
delete nextById[mealId]
mealById.value = nextById
}
const addMealItem = async (mealId: number, payload: MealItemInput): Promise<Meal> => {
const token = requireToken()
await BackendAPI.mealItemAdd(token, mealId, payload.ingredient_id, payload.grams, payload.position)
return loadMeal(mealId)
}
const updateMealItem = async (mealItemId: number, payload: MealItemInput, mealId: number): Promise<Meal> => {
const token = requireToken()
await BackendAPI.mealItemUpdate(
token,
mealItemId,
payload.ingredient_id,
payload.grams,
payload.position,
)
return loadMeal(mealId)
}
const deleteMealItem = async (mealId: number, mealItemId: number): Promise<Meal> => {
const token = requireToken()
await BackendAPI.mealItemDelete(token, mealItemId)
return loadMeal(mealId)
}
return {
list,
mealById,
sortedMeals,
loading,
loadMeals,
loadMeal,
getMealFromCache,
ensureMeal,
createMeal,
updateMeal,
deleteMeal,
addMealItem,
updateMealItem,
deleteMealItem,
}
})

View File

@ -0,0 +1,44 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export type ThemeMode = 'light' | 'dark'
const THEME_KEY = 'theme'
const isThemeMode = (value: unknown): value is ThemeMode => value === 'light' || value === 'dark'
export const useThemeStore = defineStore('theme', () => {
const mode = ref<ThemeMode>('light')
const initialized = ref(false)
const applyTheme = (nextMode: ThemeMode) => {
mode.value = nextMode
document.documentElement.setAttribute('data-theme', nextMode)
localStorage.setItem(THEME_KEY, nextMode)
}
const initialize = () => {
if (initialized.value) {
return
}
const storedTheme = localStorage.getItem(THEME_KEY)
applyTheme(isThemeMode(storedTheme) ? storedTheme : 'light')
initialized.value = true
}
const toggle = () => {
applyTheme(mode.value === 'dark' ? 'light' : 'dark')
}
const isDarkMode = computed(() => mode.value === 'dark')
const currentThemeLabelKey = computed(() => (isDarkMode.value ? 'theme.dark' : 'theme.light'))
return {
mode,
isDarkMode,
currentThemeLabelKey,
initialize,
applyTheme,
toggle,
}
})

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,71 @@
export type MealType = 'breakfast' | 'lunch' | 'dinner'
export interface Ingredient {
ingredient_id: number
user_id: number | null
name: string
protein_g_100: number
carbs_g_100: number
sugar_g_100: number
fat_g_100: number
fiber_g_100: number
kcal_100: number
}
export interface MealItemNutrition {
protein_g: number
carbs_g: number
sugar_g: number
fat_g: number
fiber_g: number
kcal: number
}
export interface MealItem {
meal_item_id: number
meal_id: number
ingredient_id: number
grams: number
position: number
ingredient?: Ingredient
nutrition?: MealItemNutrition
}
export interface NutritionTotals {
protein_g: number
carbs_g: number
sugar_g: number
fat_g: number
fiber_g: number
kcal: number
}
export interface Meal {
meal_id: number
user_id: number
name: string
meal_type: MealType
items?: MealItem[]
totals?: NutritionTotals
}
export interface DiaryEntry {
diary_entry_id: number
diary_day_id: number
meal_type: MealType
meal_id: number
meal?: Meal
meal_totals?: NutritionTotals
}
export interface DiaryDay {
user_id: number
day_date: string
diary_day_id: number | null
entries: DiaryEntry[]
totals?: NutritionTotals
}
export type DayMealsByType = Record<MealType, Meal | null>
export const MEAL_TYPES: MealType[] = ['breakfast', 'lunch', 'dinner']

17
frontend/src/utils/api.ts Normal file
View File

@ -0,0 +1,17 @@
export type ApiEnvelope<T> = {
status: 'OK'
data: T
}
export const unwrapApiData = <T>(payload: unknown): T => {
if (
typeof payload === 'object' &&
payload !== null &&
'status' in payload &&
'data' in payload
) {
return (payload as ApiEnvelope<T>).data
}
return payload as T
}

View File

@ -0,0 +1,8 @@
export const formatISODate = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export const todayISO = (): string => formatISODate(new Date())

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

@ -0,0 +1,83 @@
import type {
DayMealsByType,
Ingredient,
Meal,
MealItem,
NutritionTotals,
} from '@/types/domain'
const round2 = (value: number): number => Math.round(value * 100) / 100
export const emptyTotals = (): NutritionTotals => ({
protein_g: 0,
carbs_g: 0,
sugar_g: 0,
fat_g: 0,
fiber_g: 0,
kcal: 0,
})
export const computeItemNutrition = (ingredient: Ingredient, grams: number): NutritionTotals => {
const factor = grams / 100
const protein = round2(ingredient.protein_g_100 * factor)
const carbs = round2(ingredient.carbs_g_100 * factor)
const sugar = round2(ingredient.sugar_g_100 * factor)
const fat = round2(ingredient.fat_g_100 * factor)
const fiber = round2(ingredient.fiber_g_100 * factor)
const kcal = round2(protein * 4 + carbs * 4 + fat * 9)
return {
protein_g: protein,
carbs_g: carbs,
sugar_g: sugar,
fat_g: fat,
fiber_g: fiber,
kcal,
}
}
export const addTotals = (base: NutritionTotals, add: NutritionTotals): NutritionTotals => {
return {
protein_g: round2(base.protein_g + add.protein_g),
carbs_g: round2(base.carbs_g + add.carbs_g),
sugar_g: round2(base.sugar_g + add.sugar_g),
fat_g: round2(base.fat_g + add.fat_g),
fiber_g: round2(base.fiber_g + add.fiber_g),
kcal: round2(base.kcal + add.kcal),
}
}
export const computeMealTotals = (items: MealItem[]): NutritionTotals => {
let totals = emptyTotals()
for (const item of items) {
if (!item.ingredient || item.grams <= 0) {
continue
}
const itemTotals = computeItemNutrition(item.ingredient, item.grams)
totals = addTotals(totals, itemTotals)
}
return totals
}
export const computeDayTotals = (mealsByType: Partial<DayMealsByType>): NutritionTotals => {
let totals = emptyTotals()
for (const meal of Object.values(mealsByType)) {
if (!meal) {
continue
}
const mealTotals = computeMealTotals(meal.items ?? [])
totals = addTotals(totals, mealTotals)
}
return totals
}
export const withComputedMealTotals = (meal: Meal): Meal => {
return {
...meal,
totals: computeMealTotals(meal.items ?? []),
}
}

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import AppSidebar from '@/components/navigation/AppSidebar.vue'
import AppBottomTabs from '@/components/navigation/AppBottomTabs.vue'
import AppTopbar from '@/components/navigation/AppTopbar.vue'
</script>
<template>
<div class="app-shell">
<AppSidebar class="desktop-nav" />
<div class="app-main">
<AppTopbar />
<main class="app-content">
<RouterView />
</main>
</div>
<AppBottomTabs class="mobile-nav" />
</div>
</template>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { import {
faEnvelope, faEnvelope,
@ -7,26 +8,18 @@ import {
faEyeSlash, faEyeSlash,
faGlobe, faGlobe,
faLock, faLock,
faMoon,
faRightToBracket, faRightToBracket,
faSun,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import BackendAPI from '@/BackendAPI.ts' import ThemeToggle from '@/components/common/ThemeToggle.vue'
import i18n from '@/i18n' import i18n from '@/i18n'
import { SUPPORTED_LOCALES, type AppLocale } from '@/i18n' import { SUPPORTED_LOCALES, type AppLocale } from '@/i18n'
import { useAuthStore } from '@/stores/auth'
type ThemeMode = 'light' | 'dark'
type LoginResponse = {
auto_registered?: boolean
user?: {
email?: string | null
token?: string | null
}
}
const { t } = useI18n({ useScope: 'global' }) const { t } = useI18n({ useScope: 'global' })
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const email = ref('') const email = ref('')
const password = ref('') const password = ref('')
@ -61,31 +54,6 @@ watch(
{ immediate: true }, { immediate: true },
) )
const getInitialTheme = (): ThemeMode => {
const storedTheme = localStorage.getItem('theme')
return storedTheme === 'dark' ? 'dark' : 'light'
}
const theme = ref<ThemeMode>(getInitialTheme())
const applyTheme = (nextTheme: ThemeMode) => {
theme.value = nextTheme
document.documentElement.setAttribute('data-theme', nextTheme)
localStorage.setItem('theme', nextTheme)
}
applyTheme(theme.value)
const toggleTheme = () => {
applyTheme(theme.value === 'dark' ? 'light' : 'dark')
}
const isDarkMode = computed(() => theme.value === 'dark')
const themeLabel = computed(() => {
return isDarkMode.value ? t('theme.dark') : t('theme.light')
})
const submitLabel = computed(() => { const submitLabel = computed(() => {
return isLoading.value ? t('auth.submitting') : t('auth.submit') return isLoading.value ? t('auth.submitting') : t('auth.submit')
}) })
@ -124,24 +92,23 @@ const submitForm = async () => {
isLoading.value = true isLoading.value = true
try { try {
const response = (await BackendAPI.userLogin(normalizedEmail, normalizedPassword)) as LoginResponse const result = await authStore.login(
const token = response.user?.token ?? null normalizedEmail,
const userEmail = response.user?.email ?? normalizedEmail normalizedPassword,
)
if (token) { successMessage.value = result.autoRegistered
localStorage.setItem('token', token)
} else {
localStorage.removeItem('token')
}
localStorage.setItem('user_email', userEmail)
successMessage.value = response.auto_registered
? t('auth.successAutoRegistered') ? t('auth.successAutoRegistered')
: t('auth.successLoggedIn') : t('auth.successLoggedIn')
email.value = normalizedEmail email.value = normalizedEmail
password.value = '' password.value = ''
showPassword.value = false showPassword.value = false
const redirect =
typeof route.query.redirect === 'string' && route.query.redirect.length > 0
? route.query.redirect
: '/app/today'
await router.replace(redirect)
} catch (error) { } catch (error) {
errorMessage.value = mapApiError(error) errorMessage.value = mapApiError(error)
} finally { } finally {
@ -171,10 +138,7 @@ const submitForm = async () => {
</select> </select>
</label> </label>
<button type="button" class="theme-btn" :aria-label="t('auth.themeToggle')" @click="toggleTheme"> <ThemeToggle />
<font-awesome-icon :icon="isDarkMode ? faMoon : faSun" />
<span>{{ themeLabel }}</span>
</button>
</div> </div>
<h2>{{ t('auth.title') }}</h2> <h2>{{ t('auth.title') }}</h2>
@ -184,33 +148,18 @@ const submitForm = async () => {
<label for="email">{{ t('auth.emailLabel') }}</label> <label for="email">{{ t('auth.emailLabel') }}</label>
<div class="input-wrap"> <div class="input-wrap">
<font-awesome-icon :icon="faEnvelope" class="input-icon" /> <font-awesome-icon :icon="faEnvelope" class="input-icon" />
<input <input id="email" v-model="email" type="email" autocomplete="email"
id="email" :placeholder="t('auth.emailPlaceholder')" required />
v-model="email"
type="email"
autocomplete="email"
:placeholder="t('auth.emailPlaceholder')"
required
/>
</div> </div>
<label for="password">{{ t('auth.passwordLabel') }}</label> <label for="password">{{ t('auth.passwordLabel') }}</label>
<div class="input-wrap"> <div class="input-wrap">
<font-awesome-icon :icon="faLock" class="input-icon" /> <font-awesome-icon :icon="faLock" class="input-icon" />
<input <input id="password" v-model="password" :type="showPassword ? 'text' : 'password'"
id="password" autocomplete="current-password" :placeholder="t('auth.passwordPlaceholder')" required />
v-model="password" <button type="button" class="password-btn"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
:placeholder="t('auth.passwordPlaceholder')"
required
/>
<button
type="button"
class="password-btn"
:aria-label="showPassword ? t('auth.hidePassword') : t('auth.showPassword')" :aria-label="showPassword ? t('auth.hidePassword') : t('auth.showPassword')"
@click="showPassword = !showPassword" @click="showPassword = !showPassword">
>
<font-awesome-icon :icon="showPassword ? faEyeSlash : faEye" /> <font-awesome-icon :icon="showPassword ? faEyeSlash : faEye" />
</button> </button>
</div> </div>

View File

@ -0,0 +1,174 @@
<script setup lang="ts">
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>(() => {
if (editingId.value === null) {
return null
}
return ingredientsStore.items.find((item) => item.ingredient_id === editingId.value) ?? 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: {
name: string
protein_g_100: number
carbs_g_100: number
sugar_g_100: number
fat_g_100: number
fiber_g_100: number
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>
<template>
<section class="page">
<IngredientForm
v-if="formMode !== 'hidden'"
:initial="editingIngredient"
:submit-label="saving ? t('common.saving') : t('common.save')"
@save="saveIngredient"
@cancel="cancelEdit"
/>
<section class="card">
<div class="section-header">
<h3>{{ t('ingredients.databaseTitle') }}</h3>
<button class="btn" type="button" @click="startCreate">{{ t('ingredients.newButton') }}</button>
</div>
<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>
<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>
<button
class="btn btn-danger"
type="button"
@click="removeIngredient(ingredient.ingredient_id)"
>
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
<p v-else class="empty-state">{{ t('ingredients.empty') }}</p>
</section>
</section>
</template>

View File

@ -0,0 +1,226 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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))
const meal = computed(() => {
const id = mealId.value
if (!Number.isFinite(id)) {
return null
}
return mealsStore.mealById[id] ?? null
})
const syncForm = () => {
if (!meal.value) {
return
}
mealName.value = meal.value.name
mealType.value = meal.value.meal_type
}
watch(meal, syncForm, { immediate: true })
const loadData = async () => {
if (!Number.isFinite(mealId.value)) {
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
}
}
onMounted(loadData)
watch(mealId, loadData)
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
}
}
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: {
meal_item_id: number
ingredient_id: number
grams: number
position: number
}) => {
if (!meal.value) {
return
}
actionError.value = ''
try {
await mealsStore.updateMealItem(
payload.meal_item_id,
{
ingredient_id: payload.ingredient_id,
grams: payload.grams,
position: payload.position,
},
meal.value.meal_id,
)
} catch (error) {
actionError.value = toErrorMessage(error, t('ux.errors.updateMealItem'))
ui.error(actionError.value)
}
}
const removeItem = async (mealItemId: number) => {
if (!meal.value) {
return
}
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 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">
<div class="grid-two">
<label>
<span>{{ t('meals.nameLabel') }}</span>
<input v-model="mealName" class="input-text" type="text" />
</label>
<label>
<span>{{ t('meals.mealTypeLabel') }}</span>
<select v-model="mealType" class="input-select">
<option value="breakfast">{{ t('mealTypes.breakfast') }}</option>
<option value="lunch">{{ t('mealTypes.lunch') }}</option>
<option value="dinner">{{ t('mealTypes.dinner') }}</option>
</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') }}
</button>
<button class="btn btn-danger" type="button" @click="removeMeal">{{ t('meals.deleteMeal') }}</button>
</div>
</section>
<MealItemsEditor
:items="meal.items ?? []"
:ingredients="ingredientsStore.sortedItems"
@add-item="addItem"
@update-item="updateItem"
@remove-item="removeItem"
/>
</template>
<div v-else class="card card-state card-state--error">{{ t('meals.notFound') }}</div>
</section>
</template>

View File

@ -0,0 +1,130 @@
<script setup lang="ts">
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(() => {
if (!filterType.value) {
return mealsStore.sortedMeals
}
return mealsStore.sortedMeals.filter((meal) => meal.meal_type === filterType.value)
})
const createMeal = async () => {
const name = newMealName.value.trim()
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
}
}
</script>
<template>
<section class="page">
<div class="page-header page-header--split">
<MealTypeFilter v-model="filterType" />
</div>
<section class="card form-inline">
<input v-model="newMealName" class="input-text" type="text" :placeholder="t('meals.namePlaceholder')" />
<select v-model="newMealType" class="input-select">
<option value="breakfast">{{ t('mealTypes.breakfast') }}</option>
<option value="lunch">{{ t('mealTypes.lunch') }}</option>
<option value="dinner">{{ t('mealTypes.dinner') }}</option>
</select>
<button class="btn btn-primary" type="button" :disabled="creating" @click="createMeal">
{{ creating ? t('common.saving') : t('meals.createButton') }}
</button>
</section>
<section class="card">
<h3>{{ t('meals.libraryTitle') }}</h3>
<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"
:to="{ name: 'meal-detail', params: { mealId: meal.meal_id } }"
class="list-row"
>
<div>
<strong>{{ meal.name }}</strong>
<p>{{ t(`mealTypes.${meal.meal_type}`) }}</p>
</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>
</section>
</section>
</template>

View File

@ -0,0 +1,70 @@
<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({ 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()
await router.replace({ name: 'auth' })
}
</script>
<template>
<section class="page">
<section class="card settings-card">
<h3>{{ t('settings.accountTitle') }}</h3>
<p v-if="auth.userEmail">
{{ t('settings.loggedInAs') }}: <strong>{{ auth.userEmail }}</strong>
</p>
<div class="settings-card__theme">
<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>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<section class="page">
<section class="card">
<h3>{{ t('stats.title') }}</h3>
<p>{{ t('stats.placeholder') }}</p>
</section>
</section>
</template>

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
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 : ''
return dateFromRoute.length > 0 ? dateFromRoute : todayISO()
}
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
}
}
watch(
() => route.params.date,
async () => {
await reloadDay(resolveDate())
},
)
onMounted(async () => {
await reloadDay(resolveDate())
})
const onDateChange = async () => {
await router.replace({ name: 'today', params: { date: selectedDate.value } })
}
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)
const dayTotals = computed(() => diaryStore.computedTotals)
</script>
<template>
<section class="page">
<div class="page-header">
<label class="filter-control">
<span>{{ t('common.date') }}</span>
<input v-model="selectedDate" class="input-date" type="date" @change="onDateChange" />
</label>
</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">
<DayMealCard
v-for="mealType in MEAL_TYPES"
:key="mealType"
:meal-type="mealType"
:label="mealTypeLabel(mealType)"
:meal-options="diaryStore.selectedMealOptions[mealType]"
:selected-meal-id="diaryStore.selectedMealId(mealType)"
:meal="dayMeals[mealType]"
@select-meal="onSelectMeal"
/>
</div>
<DayTotalsCard :totals="dayTotals" />
</template>
</section>
</template>

View File

@ -9,6 +9,10 @@ import pkg from './package.json';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
server: {
host: "0.0.0.0", // sprístupní server na všetkých interfejsoch
port: 5173, // môžeš zmeniť port, ak potrebuješ
},
plugins: [ plugins: [
vue(), vue(),
vueDevTools(), vueDevTools(),