Compare commits
19 Commits
64a8ac047f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 02e9aa1ac4 | |||
| b3be74e652 | |||
| af437963bb | |||
| 6ff28dae13 | |||
| 69e5f4e320 | |||
| b0b5d0972a | |||
| 2468b31462 | |||
| be3c355b37 | |||
| ee144847bd | |||
| 2b237d3d71 | |||
| 1d5b730e11 | |||
| 072f44c213 | |||
| 276cc21c5a | |||
| 9b2f2c4e91 | |||
| 3010a66d59 | |||
| 92086055dc | |||
| 210ab43a0b | |||
| ae7f05786d | |||
| d13d861113 |
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
||||
/backend/config/Config.cust.php
|
||||
/frontend/node_modules
|
||||
/frontend/dist
|
||||
/dist
|
||||
|
||||
25
.vscode/settings.json
vendored
Normal 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": {
|
||||
|
||||
}
|
||||
}
|
||||
78
AGENTS.md
@ -15,27 +15,71 @@ It describes what the project is, what is already implemented, and what still ne
|
||||
- `backend/`
|
||||
- `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.
|
||||
- Backend DB migrations exist in `backend/src/Maintenance.php` up to version `7`.
|
||||
- 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/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).
|
||||
- Successful login stores `token` and `user_email` in `localStorage`.
|
||||
- Frontend i18n is wired:
|
||||
- Login now uses `frontend/src/stores/auth.ts` (`Pinia`) and redirects to authenticated app routes.
|
||||
- `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`
|
||||
- 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.
|
||||
- app UI keys include (`nav`, `pageTitles`, `mealTypes`, `common`, `nutrition`, `today`, `meals`, `ingredients`, `stats`, `settings`, `ux`)
|
||||
- `nutrition.short.fiber` exists in all locales and is used in macro badges
|
||||
- `ux.toast.sessionExpired` exists in all locales for auto-logout flow
|
||||
- Frontend theme system is implemented:
|
||||
- light/dark mode toggle in auth page
|
||||
- centralized theme store: `frontend/src/stores/theme.ts`
|
||||
- shared toggle component: `frontend/src/components/common/ThemeToggle.vue`
|
||||
- toggle available in auth, app topbar, and settings
|
||||
- design tokens in `frontend/src/assets/css/style.css` (`:root` variables).
|
||||
- Frontend macro-badge visual system is implemented:
|
||||
- shared component: `frontend/src/components/common/MacroBadge.vue`
|
||||
- shared colors/tokens/styles in `frontend/src/assets/css/style.css`
|
||||
- used in `IngredientsView`, `MealsView`, `DayTotalsCard`, and `DayMealCard`
|
||||
- `Today` day totals card layout: kcal block on the left, macros on the right in one row
|
||||
- macro colors:
|
||||
- protein `#3B82F6`
|
||||
- carbs `#F59E0B`
|
||||
- fat `#EF4444`
|
||||
- fiber `#10B981`
|
||||
- style uses subtle tinted background + full-color text, pill shape (no saturated full backgrounds)
|
||||
- Frontend typography is local (no remote font CDN):
|
||||
- `frontend/src/assets/css/style.css` uses local `@font-face` for `DM Sans` and `Space Grotesk`
|
||||
- font files are stored in `frontend/src/assets/fonts/*.woff2`
|
||||
- App logo is served from `frontend/public/Nutrio.png` (copied from `doc/Nutrio.png`).
|
||||
- Font Awesome is installed and registered globally in `frontend/src/main.ts`.
|
||||
- `frontend/src/BackendAPI.js` is generated via `backend/scripts/buildTypeScript.php` and should not be edited manually.
|
||||
- `frontend/src/BackendAPI.d.ts` provides TS declarations for generated `BackendAPI.js`.
|
||||
- Pinia is installed and configured in `frontend/src/main.ts` via `frontend/src/stores/index.ts`.
|
||||
- Frontend domain/store structure exists:
|
||||
- `frontend/src/types/domain.ts`
|
||||
- `frontend/src/stores/{auth,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 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.
|
||||
- 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.
|
||||
- APIlite wraps responses with a nested `data` object. Keep this in mind on frontend parsing.
|
||||
- `frontend/src/BackendAPI.js` is generated output; regenerate when backend API changes, do not patch manually.
|
||||
- APIlite response handling detail:
|
||||
- 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.
|
||||
|
||||
## Local Runbook
|
||||
@ -165,8 +214,9 @@ Frontend:
|
||||
## Product Behavior Target (what to build next)
|
||||
|
||||
- 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.
|
||||
- Connect frontend to implemented backend actions.
|
||||
- Polish authenticated frontend UX further (more granular field validation, retry actions, richer empty states).
|
||||
- Add diary range screen/workflow on frontend (backend endpoint already exists).
|
||||
- Add i18n coverage for any future UI additions and keep keys stable.
|
||||
- Add API tests for validation, ownership checks, and totals calculation consistency.
|
||||
- Add pagination/filter strategy where list endpoints grow.
|
||||
|
||||
@ -176,3 +226,5 @@ Frontend:
|
||||
- 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.
|
||||
- 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).
|
||||
|
||||
|
||||
7
backend/composer.lock
generated
@ -8,11 +8,11 @@
|
||||
"packages": [
|
||||
{
|
||||
"name": "tpsoft/apilite",
|
||||
"version": "v1.1.0",
|
||||
"version": "v1.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.tpsoft.org/TPsoft.org/APIlite.git",
|
||||
"reference": "fab8efd780ede046ced076f237351cdba5a8a51f"
|
||||
"reference": "951fe36da3184bf29398a067f2218af768e5d280"
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2"
|
||||
@ -41,6 +41,7 @@
|
||||
"description": "A set of tools to simplify the work of creating backend APIs for your frontend projects.",
|
||||
"keywords": [
|
||||
"api",
|
||||
"javascript",
|
||||
"json",
|
||||
"php",
|
||||
"rest",
|
||||
@ -52,7 +53,7 @@
|
||||
"type": "other"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-09T06:33:17+00:00"
|
||||
"time": "2026-02-14T05:28:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tpsoft/dbmodel",
|
||||
|
||||
@ -9,7 +9,7 @@ $backend_api = new TPsoft\Nutrio\API('typescript', 'import.meta.env.VITE_BACKEND
|
||||
$output = ob_get_contents();
|
||||
ob_end_clean();
|
||||
|
||||
$ts_path = realpath(__DIR__ . '/../../frontend/src').'/BackendAPI.js';
|
||||
$ts_path = realpath(__DIR__ . '/../../frontend/src').'/BackendAPI.ts';
|
||||
$suc = file_put_contents($ts_path, $output);
|
||||
if ($suc === false) {
|
||||
echo "✗ TypeScript store into file failed\n";
|
||||
|
||||
@ -46,7 +46,7 @@ class API extends APIlite {
|
||||
if (is_array($existing)) {
|
||||
throw new \Exception('User with this email already exists');
|
||||
}
|
||||
$userId = $this->users()->userSave(array(
|
||||
$userId = $this->users()->user(null, array(
|
||||
'email' => $email,
|
||||
'password_hash' => $this->users()->hashString($password),
|
||||
'token' => null,
|
||||
@ -192,7 +192,7 @@ class API extends APIlite {
|
||||
if ($kcal_100 == 0) {
|
||||
$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,
|
||||
'name' => $name,
|
||||
'protein_g_100' => $this->round2($protein_g_100),
|
||||
@ -326,7 +326,7 @@ class API extends APIlite {
|
||||
$user_id = $this->requireUserIDbyToken($token);
|
||||
$name = $this->normalizeName($name);
|
||||
$this->assertMealType($meal_type);
|
||||
$mealId = $this->meals()->mealSave(array(
|
||||
$mealId = $this->meals()->meal(null, array(
|
||||
'user_id' => $user_id,
|
||||
'name' => $name,
|
||||
'meal_type' => $meal_type,
|
||||
@ -387,7 +387,7 @@ class API extends APIlite {
|
||||
throw new \Exception('position must be >= 1');
|
||||
}
|
||||
$this->getIngredientAccessible($user_id, $ingredient_id);
|
||||
$mealItemId = $this->mealItems()->mealItemSave(array(
|
||||
$mealItemId = $this->mealItems()->mealItem(null, array(
|
||||
'meal_id' => $meal_id,
|
||||
'ingredient_id' => $ingredient_id,
|
||||
'grams' => $this->round2($grams),
|
||||
@ -508,7 +508,7 @@ class API extends APIlite {
|
||||
throw new \Exception('Failed to update diary entry');
|
||||
}
|
||||
} else {
|
||||
$inserted = $this->diaryEntries()->diaryEntrySave(array(
|
||||
$inserted = $this->diaryEntries()->diaryEntry(null, array(
|
||||
'diary_day_id' => (int) $day['diary_day_id'],
|
||||
'meal_type' => $meal_type,
|
||||
'meal_id' => $meal_id,
|
||||
@ -890,7 +890,7 @@ class API extends APIlite {
|
||||
if (is_array($day)) {
|
||||
return $this->mapDiaryDay($day);
|
||||
}
|
||||
$dayId = $this->diaryDays()->diaryDaySave(array(
|
||||
$dayId = $this->diaryDays()->diaryDay(null, array(
|
||||
'user_id' => $user_id,
|
||||
'day_date' => $day_date,
|
||||
'created_at' => '`NOW`'
|
||||
|
||||
@ -105,7 +105,7 @@ class Users extends \TPsoft\DBmodel\DBmodel {
|
||||
return password_verify($password, $password_hash);
|
||||
}
|
||||
|
||||
public function generateToken(int $user_id, int $ttl_seconds = 3600): string {
|
||||
public function generateToken(int $user_id, int $ttl_seconds = 604800): string {
|
||||
if ($user_id <= 0) {
|
||||
throw new \Exception('Invalid user_id');
|
||||
}
|
||||
@ -152,7 +152,7 @@ class Users extends \TPsoft\DBmodel\DBmodel {
|
||||
if (!hash_equals($stored_token, $token)) {
|
||||
return false;
|
||||
}
|
||||
$refresh_expires = date('Y-m-d H:i:s', time() + 3600);
|
||||
$refresh_expires = date('Y-m-d H:i:s', time() + 604800); // 7 days
|
||||
$updated = $this->user($user_id, array(
|
||||
'token_expires' => $refresh_expires
|
||||
));
|
||||
|
||||
94
build.bat
Normal file
@ -0,0 +1,94 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
|
||||
set "ROOT=%~dp0"
|
||||
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
|
||||
|
||||
set "DIST_DIR=%ROOT%\dist"
|
||||
set "DIST_PUBLIC=%DIST_DIR%\public"
|
||||
set "DIST_APP=%DIST_PUBLIC%"
|
||||
set "FRONTEND_DIR=%ROOT%\frontend"
|
||||
set "BACKEND_DIR=%ROOT%\backend"
|
||||
|
||||
echo [1/6] Cleaning dist...
|
||||
if exist "%DIST_DIR%" (
|
||||
rmdir /S /Q "%DIST_DIR%"
|
||||
if exist "%DIST_DIR%" (
|
||||
echo ERROR: Failed to remove "%DIST_DIR%".
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
mkdir "%DIST_DIR%" >nul 2>&1 || (
|
||||
echo ERROR: Failed to create "%DIST_DIR%".
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [2/6] Building frontend...
|
||||
pushd "%FRONTEND_DIR%" >nul 2>&1 || (
|
||||
echo ERROR: Frontend directory not found: "%FRONTEND_DIR%".
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo - npm install
|
||||
call npm install
|
||||
if errorlevel 1 (
|
||||
popd >nul
|
||||
echo ERROR: npm install failed.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo - npm run build
|
||||
call npm run build
|
||||
if errorlevel 1 (
|
||||
popd >nul
|
||||
echo ERROR: npm run build failed.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%FRONTEND_DIR%\dist" (
|
||||
popd >nul
|
||||
echo ERROR: Frontend build output not found at "%FRONTEND_DIR%\dist".
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
popd >nul
|
||||
|
||||
echo [3/6] Copy backend root to dist/...
|
||||
call :RunRobocopy "%BACKEND_DIR%" "%DIST_DIR%" /E /R:2 /W:1 /NFL /NDL /NJH /NJS /NP ^
|
||||
/XD "%BACKEND_DIR%\.git" "%BACKEND_DIR%\.vscode" "%BACKEND_DIR%\tests" "%BACKEND_DIR%\node_modules" "%BACKEND_DIR%\frontend" "%BACKEND_DIR%\dist" ^
|
||||
/XF ".env" ".env.*"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo [4/6] Copy frontend/dist to dist/public...
|
||||
call :RunRobocopy "%FRONTEND_DIR%\dist" "%DIST_APP%" /E /R:2 /W:1 /NFL /NDL /NJH /NJS /NP
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo [5/6] Applying .env.production (if present)...
|
||||
set "ENV_NOTE=No backend/.env.production found"
|
||||
if exist "%BACKEND_DIR%\.env.production" (
|
||||
copy /Y "%BACKEND_DIR%\.env.production" "%DIST_DIR%\.env" >nul
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to copy backend/.env.production to dist/.env.
|
||||
exit /b 1
|
||||
)
|
||||
set "ENV_NOTE=Copied backend/.env.production to dist/.env"
|
||||
)
|
||||
|
||||
echo [6/6] Summary
|
||||
echo - Frontend build: OK
|
||||
echo - Backend root copied to: "%DIST_DIR%" (excluding excluded dirs)
|
||||
echo - Frontend assets copied to: "%DIST_APP%"
|
||||
echo - Env: %ENV_NOTE%
|
||||
echo - DocumentRoot should be: "%DIST_PUBLIC%"
|
||||
echo - Frontend app is served from: "%DIST_APP%"
|
||||
echo DONE "%DIST_DIR%"
|
||||
exit /b 0
|
||||
|
||||
:RunRobocopy
|
||||
robocopy %*
|
||||
if errorlevel 8 (
|
||||
echo ERROR: robocopy failed with exit code %ERRORLEVEL%.
|
||||
exit /b 1
|
||||
)
|
||||
exit /b 0
|
||||
9
deploy.bat
Normal file
@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
|
||||
call php d:\www\sftpsync\src\sftpsync.php --host nutrio.tpsoft.org --user igor ^
|
||||
--delete-dir /storage/tpsoft.org/nutrio/public ^
|
||||
--sync d:/www/Nutrio/dist /storage/tpsoft.org/nutrio ^
|
||||
--skip .git ^
|
||||
--print-relative
|
||||
|
||||
echo ✔️ Done.
|
||||
238
doc/prompt.txt
Normal 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. 10–15% opacity farby)
|
||||
- text farba plná farba
|
||||
- formát: B 12g / S 24g / T 5g / V 6g
|
||||
|
||||
2) Today page (route: /app/today)
|
||||
- V dennom súčte pridaj vlákninu.
|
||||
- Makrá zobraz rovnakým farebným štýlom ako v ingredients.
|
||||
- Kalórie nech ostanú neutrálne (tmavá šedá), vizuálne dominantné veľkosťou písma, nie farbou.
|
||||
|
||||
Layout:
|
||||
- hore veľké číslo kcal
|
||||
- pod tým horizontálne makrá ako farebné štítky
|
||||
|
||||
3) Meals list (route: /app/meals)
|
||||
- Aktuálne sa zobrazuje len počet kalórií.
|
||||
- Pridaj aj súhrn makier (protein, carbs, fat, fiber).
|
||||
- Zobraz ich rovnakým badge systémom pre konzistentnosť.
|
||||
- Kcal nech je oddelené (napr. nad makrami alebo výraznejšie písmo).
|
||||
|
||||
4) Konzistentnosť:
|
||||
- Použi jeden spoločný komponent napr. <MacroBadge />
|
||||
- Nepoužívaj sýte plné farebné pozadia.
|
||||
- Použi minimalistický SaaS štýl.
|
||||
- Farby makier musia byť identické naprieč celou aplikáciou.
|
||||
- Nepridávaj nové knižnice.
|
||||
|
||||
Výsledok:
|
||||
Čistý, konzistentný, moderný vzhľad bez prehnaných farieb.
|
||||
|
||||
|
||||
----- 2026-02-14 13:35:31 -----------------------------------------------------
|
||||
V koreňovom adresári projektu vytvor Windows batch skript build.bat.
|
||||
|
||||
Štruktúra projektu:
|
||||
- frontend/ (Vue 3 + Vite) -> build do frontend/dist
|
||||
- backend/ (PHP) -> web root je backend/public a PHP súbory v public používajú relatívne cesty typu ../src/Init.php
|
||||
=> Musíme zachovať štruktúru, aby v dist platilo: dist/public/.. = dist/
|
||||
|
||||
Cieľ:
|
||||
- v koreňi vytvoriť root/dist tak, aby som na serveri nastavil DocumentRoot na dist/public
|
||||
- relatívne cesty v PHP z dist/public na dist/src musia fungovať (napr. dist/public/index.php -> ../src/Init.php)
|
||||
- frontend (Vite) nasadiť ako statické súbory do dist/public/app (aby nekolidoval s backend/public/index.php)
|
||||
|
||||
Výsledná štruktúra dist:
|
||||
- dist/public/ (WEB ROOT) = kópia backend/public
|
||||
- dist/src/ = kópia backend/src
|
||||
- dist/vendor/ = kópia backend/vendor (ak existuje)
|
||||
- dist/config/ = kópia backend/config (ak existuje)
|
||||
- dist/… = ostatné potrebné backend súbory/adresáre z backend/ (mimo public)
|
||||
- dist/public/app/ = Vite build (obsah frontend/dist)
|
||||
|
||||
Požiadavky na build.bat:
|
||||
|
||||
1) Vyčistenie:
|
||||
- ak existuje root/dist, zmaž ho celý
|
||||
- vytvor root/dist
|
||||
|
||||
2) Frontend build (nepoužívaj npm ci):
|
||||
- cd frontend
|
||||
- spusti "npm install"
|
||||
- spusti "npm run build"
|
||||
- ak build zlyhá, ukonči skript s exit code 1
|
||||
- vráť sa do root
|
||||
|
||||
3) Kopírovanie backend do dist:
|
||||
- skopíruj backend/public -> dist/public
|
||||
- skopíruj všetko potrebné z backend/ do dist/ TAK, aby platili relatívne cesty z dist/public na dist/src atď.
|
||||
Konkrétne:
|
||||
- kopíruj backend/src -> dist/src (ak existuje)
|
||||
- kopíruj backend/vendor -> dist/vendor (ak existuje)
|
||||
- kopíruj ďalšie bežné backend adresáre (napr. config, templates, storage…) do dist/ na rovnakú úroveň ako public
|
||||
- NEkopíruj backend/public druhýkrát mimo dist/public
|
||||
- NEkopíruj: .git, .vscode, tests, node_modules, frontend, dist
|
||||
|
||||
4) Skopírovanie Vite buildu do dist/public:
|
||||
- vytvor dist/public
|
||||
- skopíruj obsah frontend/dist -> dist/public
|
||||
|
||||
5) .env:
|
||||
- ak existuje backend/.env.production, skopíruj ho do dist/.env
|
||||
- inak nič
|
||||
|
||||
6) Robustnosť:
|
||||
- použi robocopy na kopírovanie (rýchle a s exclude)
|
||||
- kontroluj ERRORLEVEL po každom kritickom kroku a pri chybe ukonči skript s exit code 1
|
||||
- používaj echo logy (kroky + DONE)
|
||||
|
||||
7) Výstup:
|
||||
- na konci vypíš, že DocumentRoot má byť nastavený na dist/public a frontend je v dist/public
|
||||
|
||||
Dodaj kompletný obsah súboru build.bat.
|
||||
|
||||
----- 2026-02-15 17:05:23 -----------------------------------------------------
|
||||
Úloha:
|
||||
V projekte vytvor plne funkčnú konfiguráciu PWA.
|
||||
|
||||
1️⃣ Manifest
|
||||
Vytvor súbor: frontend/public/manifest.json
|
||||
Obsah musí:
|
||||
- byť validný JSON
|
||||
obsahovať:
|
||||
- name
|
||||
- short_name
|
||||
- start_url
|
||||
- display = "standalone"
|
||||
- background_color
|
||||
- theme_color
|
||||
- icons pole
|
||||
Použi tieto hodnoty:
|
||||
{
|
||||
"name": "Nutrio",
|
||||
"short_name": "Nutrio",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#22c55e"
|
||||
}
|
||||
2️⃣ Ikony
|
||||
Zdrojový obrázok:
|
||||
frontend/public/Nutrio.600.png
|
||||
Z neho vytvor PNG ikony do adresára:
|
||||
frontend/public/icons/
|
||||
Vygeneruj tieto veľkosti:
|
||||
16x16
|
||||
32x32
|
||||
48x48
|
||||
180x180 (apple touch icon)
|
||||
192x192 (PWA required)
|
||||
512x512 (PWA required)
|
||||
Požiadavky:
|
||||
zachovať pomer strán
|
||||
zachovať priehľadnosť
|
||||
nepoužívať žiadne orezávanie
|
||||
výstupné názvy:
|
||||
icon-16.png
|
||||
icon-32.png
|
||||
icon-48.png
|
||||
icon-180.png
|
||||
icon-192.png
|
||||
icon-512.png
|
||||
3️⃣ Manifest icons sekcia
|
||||
Pole icons musí obsahovať iba:
|
||||
[
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
}
|
||||
]
|
||||
4️⃣ Úprava index.html
|
||||
Do frontend/index.html pridaj do <head>:
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/icon-16.png">
|
||||
<link rel="apple-touch-icon" href="/icons/icon-180.png">
|
||||
<meta name="theme-color" content="#22c55e">
|
||||
5️⃣ Výstup
|
||||
nevysvetľuj
|
||||
nevypisuj komentáre
|
||||
vytvor alebo uprav iba potrebné súbory
|
||||
ak adresár icons neexistuje, vytvor ho
|
||||
9
frontend/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
@ -2,9 +2,13 @@
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/icon-16.png">
|
||||
<link rel="apple-touch-icon" href="/icons/icon-180.png">
|
||||
<meta name="theme-color" content="#22c55e">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
<title>Nutrio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
113
frontend/package-lock.json
generated
@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "nutrio",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nutrio",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/vue-fontawesome": "^3.1.3",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.27",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-router": "^5.0.1"
|
||||
@ -2292,13 +2293,10 @@
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
"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/devtools-core": {
|
||||
"version": "8.0.6",
|
||||
@ -4254,6 +4252,66 @@
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
@ -5128,16 +5186,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-eslint-parser": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz",
|
||||
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",
|
||||
"integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"eslint-scope": "^8.2.0",
|
||||
"eslint-visitor-keys": "^4.2.0",
|
||||
"espree": "^10.3.0",
|
||||
"eslint-scope": "^8.2.0 || ^9.0.0",
|
||||
"eslint-visitor-keys": "^4.2.0 || ^5.0.0",
|
||||
"espree": "^10.3.0 || ^11.0.0",
|
||||
"esquery": "^1.6.0",
|
||||
"semver": "^7.6.3"
|
||||
},
|
||||
@ -5148,17 +5206,17 @@
|
||||
"url": "https://github.com/sponsors/mysticatea"
|
||||
},
|
||||
"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": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz",
|
||||
"integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
@ -5184,12 +5242,6 @@
|
||||
"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": {
|
||||
"version": "5.0.2",
|
||||
"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": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nutrio",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@ -18,6 +18,7 @@
|
||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/vue-fontawesome": "^3.1.3",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.27",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-router": "^5.0.1"
|
||||
|
||||
BIN
frontend/public/Nutrio.600.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 66 KiB |
BIN
frontend/public/icons/icon-16.png
Normal file
|
After Width: | Height: | Size: 874 B |
BIN
frontend/public/icons/icon-180.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/icons/icon-32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/icons/icon-48.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
20
frontend/public/manifest.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Nutrio",
|
||||
"short_name": "Nutrio",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#22c55e",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import ToastHost from '@/components/common/ToastHost.vue'
|
||||
import ConfirmModalHost from '@/components/common/ConfirmModalHost.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<RouterView />
|
||||
<ToastHost />
|
||||
<ConfirmModalHost />
|
||||
</template>
|
||||
|
||||
@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Generated by APIlite
|
||||
* https://gitea.tpsoft.org/TPsoft.org/APIlite
|
||||
*
|
||||
* 2026-02-13 06:55:45 */
|
||||
|
||||
class BackendAPI {
|
||||
endpoint = import.meta.env.VITE_BACKENDAPI_URL;
|
||||
|
||||
/* ----------------------------------------------------
|
||||
* General API call
|
||||
*/
|
||||
call(method, data, callback) {
|
||||
var xhttp = new XMLHttpRequest();
|
||||
xhttp.withCredentials = true;
|
||||
xhttp.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
if (this.status == 200) {
|
||||
if (callback != null) callback(JSON.parse(this.responseText));
|
||||
} else {
|
||||
if (callback != null) callback({'status': 'ERROR', 'message': 'HTTP STATUS ' + this.status});
|
||||
}
|
||||
}
|
||||
}
|
||||
var form_data = new FormData();
|
||||
Object.keys(data).forEach(key => {
|
||||
let val = data[key];
|
||||
if (typeof val == 'object') val = JSON.stringify(val);
|
||||
form_data.append(key, val);
|
||||
});
|
||||
xhttp.open('POST', this.endpoint + '?action=' + method);
|
||||
xhttp.send(form_data);
|
||||
}
|
||||
|
||||
callPromise(method, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.call(method, data, function(response) {
|
||||
if (method == '__HELP__') {
|
||||
resolve(response);
|
||||
return;
|
||||
}
|
||||
if (response.status == 'OK') {
|
||||
resolve(response.data);
|
||||
} else {
|
||||
reject(response.msg);
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------
|
||||
* API actions
|
||||
*/
|
||||
help() {
|
||||
return this.callPromise('__HELP__', {});
|
||||
}
|
||||
|
||||
health() {
|
||||
return this.callPromise('health', {});
|
||||
}
|
||||
|
||||
userRegistration(email, password) {
|
||||
return this.callPromise('userRegistration', {email: email, password: password});
|
||||
}
|
||||
|
||||
userLogin(email, password) {
|
||||
return this.callPromise('userLogin', {email: email, password: password});
|
||||
}
|
||||
|
||||
userDelete(email, password) {
|
||||
return this.callPromise('userDelete', {email: email, password: password});
|
||||
}
|
||||
|
||||
userLogout(token) {
|
||||
return this.callPromise('userLogout', {token: token});
|
||||
}
|
||||
|
||||
ingredientList(token, query, include_global) {
|
||||
return this.callPromise('ingredientList', {token: token, query: query, include_global: include_global});
|
||||
}
|
||||
|
||||
ingredientGet(token, ingredient_id) {
|
||||
return this.callPromise('ingredientGet', {token: token, ingredient_id: ingredient_id});
|
||||
}
|
||||
|
||||
ingredientCreate(token, name, protein_g_100, carbs_g_100, sugar_g_100, fat_g_100, fiber_g_100, kcal_100) {
|
||||
return this.callPromise('ingredientCreate', {token: token, name: name, protein_g_100: protein_g_100, carbs_g_100: carbs_g_100, sugar_g_100: sugar_g_100, fat_g_100: fat_g_100, fiber_g_100: fiber_g_100, kcal_100: kcal_100});
|
||||
}
|
||||
|
||||
ingredientUpdate(token, ingredient_id, name, protein_g_100, carbs_g_100, sugar_g_100, fat_g_100, fiber_g_100, kcal_100) {
|
||||
return this.callPromise('ingredientUpdate', {token: token, ingredient_id: ingredient_id, name: name, protein_g_100: protein_g_100, carbs_g_100: carbs_g_100, sugar_g_100: sugar_g_100, fat_g_100: fat_g_100, fiber_g_100: fiber_g_100, kcal_100: kcal_100});
|
||||
}
|
||||
|
||||
ingredientDelete(token, ingredient_id) {
|
||||
return this.callPromise('ingredientDelete', {token: token, ingredient_id: ingredient_id});
|
||||
}
|
||||
|
||||
mealList(token, meal_type, with_items, with_totals) {
|
||||
return this.callPromise('mealList', {token: token, meal_type: meal_type, with_items: with_items, with_totals: with_totals});
|
||||
}
|
||||
|
||||
mealGet(token, meal_id, with_items, with_totals) {
|
||||
return this.callPromise('mealGet', {token: token, meal_id: meal_id, with_items: with_items, with_totals: with_totals});
|
||||
}
|
||||
|
||||
mealCreate(token, name, meal_type) {
|
||||
return this.callPromise('mealCreate', {token: token, name: name, meal_type: meal_type});
|
||||
}
|
||||
|
||||
mealUpdate(token, meal_id, name, meal_type) {
|
||||
return this.callPromise('mealUpdate', {token: token, meal_id: meal_id, name: name, meal_type: meal_type});
|
||||
}
|
||||
|
||||
mealDelete(token, meal_id) {
|
||||
return this.callPromise('mealDelete', {token: token, meal_id: meal_id});
|
||||
}
|
||||
|
||||
mealItemList(token, meal_id, with_calculated) {
|
||||
return this.callPromise('mealItemList', {token: token, meal_id: meal_id, with_calculated: with_calculated});
|
||||
}
|
||||
|
||||
mealItemAdd(token, meal_id, ingredient_id, grams, position) {
|
||||
return this.callPromise('mealItemAdd', {token: token, meal_id: meal_id, ingredient_id: ingredient_id, grams: grams, position: position});
|
||||
}
|
||||
|
||||
mealItemUpdate(token, meal_item_id, ingredient_id, grams, position) {
|
||||
return this.callPromise('mealItemUpdate', {token: token, meal_item_id: meal_item_id, ingredient_id: ingredient_id, grams: grams, position: position});
|
||||
}
|
||||
|
||||
mealItemDelete(token, meal_item_id) {
|
||||
return this.callPromise('mealItemDelete', {token: token, meal_item_id: meal_item_id});
|
||||
}
|
||||
|
||||
mealItemReorder(token, meal_id, ordered_item_ids) {
|
||||
return this.callPromise('mealItemReorder', {token: token, meal_id: meal_id, ordered_item_ids: ordered_item_ids});
|
||||
}
|
||||
|
||||
mealTotals(token, meal_id) {
|
||||
return this.callPromise('mealTotals', {token: token, meal_id: meal_id});
|
||||
}
|
||||
|
||||
diaryDayGet(token, day_date, with_totals) {
|
||||
return this.callPromise('diaryDayGet', {token: token, day_date: day_date, with_totals: with_totals});
|
||||
}
|
||||
|
||||
diaryDaySetMeal(token, day_date, meal_type, meal_id) {
|
||||
return this.callPromise('diaryDaySetMeal', {token: token, day_date: day_date, meal_type: meal_type, meal_id: meal_id});
|
||||
}
|
||||
|
||||
diaryDayUnsetMeal(token, day_date, meal_type) {
|
||||
return this.callPromise('diaryDayUnsetMeal', {token: token, day_date: day_date, meal_type: meal_type});
|
||||
}
|
||||
|
||||
diaryRange(token, date_from, date_to) {
|
||||
return this.callPromise('diaryRange', {token: token, date_from: date_from, date_to: date_to});
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
export default new BackendAPI();
|
||||
209
frontend/src/BackendAPI.ts
Normal file
@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Generated by APIlite
|
||||
* https://gitea.tpsoft.org/TPsoft.org/APIlite
|
||||
*
|
||||
* 2026-02-14 06:30:12 */
|
||||
|
||||
export interface APIliteActionResponse<T> {
|
||||
status: 'OK';
|
||||
data: T;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
export interface APIliteErrorResponse {
|
||||
status: 'ERROR';
|
||||
msg: string;
|
||||
}
|
||||
|
||||
export interface APIliteMethodParam {
|
||||
name: string;
|
||||
type: string | null;
|
||||
optional: boolean;
|
||||
default: unknown;
|
||||
doc: string | null;
|
||||
}
|
||||
|
||||
export interface APIliteMethodDoc {
|
||||
name: string;
|
||||
doc: string | null;
|
||||
description: string | null;
|
||||
params: APIliteMethodParam[];
|
||||
return: string | string[] | null;
|
||||
}
|
||||
|
||||
export interface APIliteHelpResponse {
|
||||
name: string;
|
||||
html_version: string;
|
||||
javascript_version: string;
|
||||
typescript_version: string;
|
||||
actions: APIliteMethodDoc[];
|
||||
status: string;
|
||||
data: string;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
class BackendAPI {
|
||||
endpoint: string = import.meta.env.VITE_BACKENDAPI_URL;
|
||||
|
||||
private call(
|
||||
method: string,
|
||||
data: Record<string, unknown>,
|
||||
callback: (response: APIliteHelpResponse | APIliteActionResponse<unknown> | APIliteErrorResponse) => void
|
||||
): void {
|
||||
const xhttp = new XMLHttpRequest();
|
||||
xhttp.withCredentials = true;
|
||||
xhttp.onreadystatechange = function() {
|
||||
if (this.readyState === 4) {
|
||||
if (this.status === 200) {
|
||||
const response = JSON.parse(this.responseText) as APIliteHelpResponse | APIliteActionResponse<unknown> | APIliteErrorResponse;
|
||||
callback(response);
|
||||
} else {
|
||||
callback({ status: 'ERROR', msg: 'HTTP STATUS ' + this.status });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
Object.keys(data).forEach((key) => {
|
||||
const rawValue = data[key];
|
||||
if (typeof rawValue === 'undefined') {
|
||||
return;
|
||||
}
|
||||
let value: string | Blob;
|
||||
if (rawValue instanceof Blob) {
|
||||
value = rawValue;
|
||||
} else if (typeof rawValue === 'object' && rawValue !== null) {
|
||||
value = JSON.stringify(rawValue);
|
||||
} else {
|
||||
value = String(rawValue);
|
||||
}
|
||||
formData.append(key, value);
|
||||
});
|
||||
|
||||
xhttp.open('POST', this.endpoint + '?action=' + method);
|
||||
xhttp.send(formData);
|
||||
}
|
||||
|
||||
private callPromise<T>(method: string, data: Record<string, unknown>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.call(method, data, (response) => {
|
||||
if (method === '__HELP__') {
|
||||
resolve(response as T);
|
||||
return;
|
||||
}
|
||||
if (response.status === 'OK') {
|
||||
resolve(response.data as T);
|
||||
return;
|
||||
}
|
||||
reject(response.msg);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
help(): Promise<APIliteHelpResponse> {
|
||||
return this.callPromise<APIliteHelpResponse>('__HELP__', {});
|
||||
}
|
||||
|
||||
health(): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('health', { });
|
||||
}
|
||||
|
||||
userRegistration(email: string, password: string): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('userRegistration', { email, password });
|
||||
}
|
||||
|
||||
userLogin(email: string, password: string): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('userLogin', { email, password });
|
||||
}
|
||||
|
||||
userDelete(email: string, password: string): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('userDelete', { email, password });
|
||||
}
|
||||
|
||||
userLogout(token: string): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('userLogout', { token });
|
||||
}
|
||||
|
||||
ingredientList(token: string, query?: string, include_global?: boolean): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('ingredientList', { token, query, include_global });
|
||||
}
|
||||
|
||||
ingredientGet(token: string, ingredient_id: number): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('ingredientGet', { token, ingredient_id });
|
||||
}
|
||||
|
||||
ingredientCreate(token: string, 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): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('ingredientCreate', { token, name, protein_g_100, carbs_g_100, sugar_g_100, fat_g_100, fiber_g_100, kcal_100 });
|
||||
}
|
||||
|
||||
ingredientUpdate(token: string, ingredient_id: number, 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): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('ingredientUpdate', { token, ingredient_id, name, protein_g_100, carbs_g_100, sugar_g_100, fat_g_100, fiber_g_100, kcal_100 });
|
||||
}
|
||||
|
||||
ingredientDelete(token: string, ingredient_id: number): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('ingredientDelete', { token, ingredient_id });
|
||||
}
|
||||
|
||||
mealList(token: string, meal_type?: string, with_items?: boolean, with_totals?: boolean): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('mealList', { token, meal_type, with_items, with_totals });
|
||||
}
|
||||
|
||||
mealGet(token: string, meal_id: number, with_items?: boolean, with_totals?: boolean): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('mealGet', { token, meal_id, with_items, with_totals });
|
||||
}
|
||||
|
||||
mealCreate(token: string, name: string, meal_type: string): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('mealCreate', { token, name, meal_type });
|
||||
}
|
||||
|
||||
mealUpdate(token: string, meal_id: number, name: string, meal_type: string): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('mealUpdate', { token, meal_id, name, meal_type });
|
||||
}
|
||||
|
||||
mealDelete(token: string, meal_id: number): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('mealDelete', { token, meal_id });
|
||||
}
|
||||
|
||||
mealItemList(token: string, meal_id: number, with_calculated?: boolean): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('mealItemList', { token, meal_id, with_calculated });
|
||||
}
|
||||
|
||||
mealItemAdd(token: string, meal_id: number, ingredient_id: number, grams: number, position?: number): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('mealItemAdd', { token, meal_id, ingredient_id, grams, position });
|
||||
}
|
||||
|
||||
mealItemUpdate(token: string, meal_item_id: number, ingredient_id: number, grams: number, position: number): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('mealItemUpdate', { token, meal_item_id, ingredient_id, grams, position });
|
||||
}
|
||||
|
||||
mealItemDelete(token: string, meal_item_id: number): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('mealItemDelete', { token, meal_item_id });
|
||||
}
|
||||
|
||||
mealItemReorder(token: string, meal_id: number, ordered_item_ids: unknown[]): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('mealItemReorder', { token, meal_id, ordered_item_ids });
|
||||
}
|
||||
|
||||
mealTotals(token: string, meal_id: number): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('mealTotals', { token, meal_id });
|
||||
}
|
||||
|
||||
diaryDayGet(token: string, day_date: string, with_totals?: boolean): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('diaryDayGet', { token, day_date, with_totals });
|
||||
}
|
||||
|
||||
diaryDaySetMeal(token: string, day_date: string, meal_type: string, meal_id: number): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('diaryDaySetMeal', { token, day_date, meal_type, meal_id });
|
||||
}
|
||||
|
||||
diaryDayUnsetMeal(token: string, day_date: string, meal_type: string): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('diaryDayUnsetMeal', { token, day_date, meal_type });
|
||||
}
|
||||
|
||||
diaryRange(token: string, date_from: string, date_to: string): Promise<APIliteActionResponse<unknown[]>> {
|
||||
return this.callPromise<APIliteActionResponse<unknown[]>>('diaryRange', { token, date_from, date_to });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new BackendAPI();
|
||||
@ -1,4 +1,92 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Grotesk:wght@500;700&display=swap');
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/dm-sans-latin-ext-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/dm-sans-latin-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/dm-sans-latin-ext-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/dm-sans-latin-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/dm-sans-latin-ext-700-normal.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/dm-sans-latin-700-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/space-grotesk-latin-ext-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/space-grotesk-latin-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/space-grotesk-latin-ext-700-normal.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/space-grotesk-latin-700-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-white: #ffffff;
|
||||
@ -13,6 +101,10 @@
|
||||
--color-success-bg: #e7f4d8;
|
||||
--color-error-bg: #f6dde0;
|
||||
--color-error: #7d2430;
|
||||
--macro-protein: #3B82F6;
|
||||
--macro-carbs: #F59E0B;
|
||||
--macro-fat: #EF4444;
|
||||
--macro-fiber: #10B981;
|
||||
--radius-md: 0.875rem;
|
||||
--radius-lg: 1.25rem;
|
||||
--space-xs: 0.5rem;
|
||||
@ -27,6 +119,8 @@
|
||||
--fs-xl: 1.875rem;
|
||||
--shadow-soft: 0 20px 40px -30px var(--color-shadow);
|
||||
--transition-fast: 160ms ease;
|
||||
--font-body: 'DM Sans', 'Segoe UI', Tahoma, sans-serif;
|
||||
--font-display: 'Space Grotesk', 'Segoe UI Semibold', 'Trebuchet MS', sans-serif;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
@ -55,7 +149,7 @@ body,
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'DM Sans', 'Segoe UI', sans-serif;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--fs-md);
|
||||
color: var(--color-text);
|
||||
background:
|
||||
@ -105,7 +199,7 @@ select {
|
||||
|
||||
.auth-brand h1 {
|
||||
margin: 0;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@ -176,7 +270,7 @@ select {
|
||||
|
||||
.auth-card h2 {
|
||||
margin: var(--space-sm) 0 0;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--fs-xl);
|
||||
}
|
||||
|
||||
@ -325,3 +419,578 @@ select {
|
||||
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%;
|
||||
}
|
||||
}
|
||||
|
||||
BIN
frontend/src/assets/fonts/dm-sans-latin-400-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/dm-sans-latin-500-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/dm-sans-latin-700-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/dm-sans-latin-ext-400-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/dm-sans-latin-ext-500-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/dm-sans-latin-ext-700-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/space-grotesk-latin-500-normal.woff2
Normal file
BIN
frontend/src/assets/fonts/space-grotesk-latin-700-normal.woff2
Normal file
27
frontend/src/components/common/ConfirmModalHost.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
|
||||
const ui = useUIStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="ui.confirmState.open"
|
||||
class="confirm-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="confirm-modal card">
|
||||
<h3>{{ ui.confirmState.title }}</h3>
|
||||
<p>{{ ui.confirmState.message }}</p>
|
||||
<div class="confirm-modal__actions">
|
||||
<button type="button" class="btn" @click="ui.resolveConfirm(false)">
|
||||
{{ ui.confirmState.cancelLabel }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" @click="ui.resolveConfirm(true)">
|
||||
{{ ui.confirmState.confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
29
frontend/src/components/common/MacroBadge.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export type MacroType = 'protein' | 'carbs' | 'fat' | 'fiber'
|
||||
|
||||
const props = defineProps<{
|
||||
macro: MacroType
|
||||
label: string
|
||||
grams: number
|
||||
}>()
|
||||
|
||||
const formattedGrams = computed(() => {
|
||||
if (!Number.isFinite(props.grams)) {
|
||||
return '0'
|
||||
}
|
||||
|
||||
return props.grams.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['macro-badge', `macro-badge--${macro}`]">
|
||||
<span>{{ label }}</span>
|
||||
<strong class="macro-badge__value">{{ formattedGrams }}g</strong>
|
||||
</span>
|
||||
</template>
|
||||
32
frontend/src/components/common/ThemeToggle.vue
Normal 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>
|
||||
28
frontend/src/components/common/ToastHost.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
|
||||
const ui = useUIStore()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-host" aria-live="polite" aria-atomic="true">
|
||||
<div
|
||||
v-for="toast in ui.toasts"
|
||||
:key="toast.id"
|
||||
class="toast-item"
|
||||
:class="`toast-item--${toast.type}`"
|
||||
>
|
||||
<span>{{ toast.message }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="toast-item__close"
|
||||
@click="ui.removeToast(toast.id)"
|
||||
:aria-label="t('common.close')"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
103
frontend/src/components/ingredients/IngredientForm.vue
Normal 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>
|
||||
111
frontend/src/components/meals/MealItemsEditor.vue
Normal 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>
|
||||
30
frontend/src/components/meals/MealTypeFilter.vue
Normal 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>
|
||||
27
frontend/src/components/navigation/AppBottomTabs.vue
Normal 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>
|
||||
33
frontend/src/components/navigation/AppSidebar.vue
Normal 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>
|
||||
44
frontend/src/components/navigation/AppTopbar.vue
Normal 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>
|
||||
57
frontend/src/components/today/DayMealCard.vue
Normal 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>
|
||||
29
frontend/src/components/today/DayTotalsCard.vue
Normal 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>
|
||||
11
frontend/src/components/today/MealPickerModal.vue
Normal 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>
|
||||
@ -1,10 +1,10 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import cs from '@/locales/cs'
|
||||
import de from '@/locales/de'
|
||||
import en from '@/locales/en'
|
||||
import es from '@/locales/es'
|
||||
import sk from '@/locales/sk'
|
||||
import cs from '@/locales/cs.json'
|
||||
import de from '@/locales/de.json'
|
||||
import en from '@/locales/en.json'
|
||||
import es from '@/locales/es.json'
|
||||
import sk from '@/locales/sk.json'
|
||||
|
||||
export const SUPPORTED_LOCALES = ['sk', 'cs', 'en', 'es', 'de'] as const
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number]
|
||||
@ -12,33 +12,33 @@ export type AppLocale = (typeof SUPPORTED_LOCALES)[number]
|
||||
const fallbackLocale: AppLocale = 'sk'
|
||||
|
||||
const isSupportedLocale = (value: string | null): value is AppLocale => {
|
||||
return value !== null && SUPPORTED_LOCALES.includes(value as AppLocale)
|
||||
return value !== null && SUPPORTED_LOCALES.includes(value as AppLocale)
|
||||
}
|
||||
|
||||
const browserLocale = navigator.language.slice(0, 2).toLowerCase()
|
||||
const storedLocale = localStorage.getItem('locale')
|
||||
|
||||
const locale: AppLocale = isSupportedLocale(storedLocale)
|
||||
? storedLocale
|
||||
: isSupportedLocale(browserLocale)
|
||||
? browserLocale
|
||||
: fallbackLocale
|
||||
? storedLocale
|
||||
: isSupportedLocale(browserLocale)
|
||||
? browserLocale
|
||||
: fallbackLocale
|
||||
|
||||
if (!isSupportedLocale(storedLocale) || storedLocale !== locale) {
|
||||
localStorage.setItem('locale', locale)
|
||||
localStorage.setItem('locale', locale)
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale,
|
||||
fallbackLocale,
|
||||
messages: {
|
||||
sk,
|
||||
cs,
|
||||
en,
|
||||
es,
|
||||
de,
|
||||
},
|
||||
legacy: false,
|
||||
locale,
|
||||
fallbackLocale,
|
||||
messages: {
|
||||
sk,
|
||||
cs,
|
||||
en,
|
||||
es,
|
||||
de,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
|
||||
173
frontend/src/locales/cs.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
173
frontend/src/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
173
frontend/src/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
173
frontend/src/locales/es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
173
frontend/src/locales/sk.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -4,10 +4,40 @@ import './assets/css/style.css'
|
||||
import App from './App.vue'
|
||||
import i18n from './i18n'
|
||||
import router from './router'
|
||||
import { pinia } from './stores'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useThemeStore } from './stores/theme'
|
||||
import { useUIStore } from './stores/ui'
|
||||
import { SESSION_EXPIRED_EVENT } from './utils/error'
|
||||
|
||||
const app = createApp(App)
|
||||
const themeStore = useThemeStore(pinia)
|
||||
const authStore = useAuthStore(pinia)
|
||||
const uiStore = useUIStore(pinia)
|
||||
themeStore.initialize()
|
||||
|
||||
const handleSessionExpired = async () => {
|
||||
const wasAuthenticated = authStore.isAuthenticated
|
||||
authStore.clearSession()
|
||||
|
||||
if (wasAuthenticated) {
|
||||
uiStore.info(String(i18n.global.t('ux.toast.sessionExpired')))
|
||||
}
|
||||
|
||||
await router.isReady()
|
||||
if (router.currentRoute.value.name !== 'auth') {
|
||||
await router.replace({ name: 'auth' })
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, () => {
|
||||
void handleSessionExpired()
|
||||
})
|
||||
}
|
||||
|
||||
app.component('font-awesome-icon', FontAwesomeIcon)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
|
||||
@ -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 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 routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'auth',
|
||||
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: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'auth',
|
||||
component: AuthView,
|
||||
},
|
||||
],
|
||||
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
|
||||
|
||||
73
frontend/src/stores/auth.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
127
frontend/src/stores/diary.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
3
frontend/src/stores/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export const pinia = createPinia()
|
||||
100
frontend/src/stores/ingredients.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
164
frontend/src/stores/meals.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
44
frontend/src/stores/theme.ts
Normal 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
@ -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,
|
||||
}
|
||||
})
|
||||
71
frontend/src/types/domain.ts
Normal 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
@ -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
|
||||
}
|
||||
8
frontend/src/utils/date.ts
Normal 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())
|
||||
53
frontend/src/utils/error.ts
Normal 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
|
||||
}
|
||||
83
frontend/src/utils/nutrition.ts
Normal 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 ?? []),
|
||||
}
|
||||
}
|
||||
19
frontend/src/views/AppLayout.vue
Normal 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>
|
||||
@ -1,32 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
faEnvelope,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faGlobe,
|
||||
faLock,
|
||||
faMoon,
|
||||
faRightToBracket,
|
||||
faSun,
|
||||
faEnvelope,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faGlobe,
|
||||
faLock,
|
||||
faRightToBracket,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import BackendAPI from '@/BackendAPI.js'
|
||||
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||
import i18n from '@/i18n'
|
||||
import { SUPPORTED_LOCALES, type AppLocale } from '@/i18n'
|
||||
|
||||
type ThemeMode = 'light' | 'dark'
|
||||
|
||||
type LoginResponse = {
|
||||
auto_registered?: boolean
|
||||
user?: {
|
||||
email?: string | null
|
||||
token?: string | null
|
||||
}
|
||||
}
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
@ -36,199 +29,155 @@ const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const isSupportedLocale = (value: string): value is AppLocale => {
|
||||
return SUPPORTED_LOCALES.includes(value as 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)
|
||||
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),
|
||||
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 },
|
||||
() => i18n.global.locale.value,
|
||||
(nextLocale) => {
|
||||
document.documentElement.setAttribute('lang', nextLocale)
|
||||
},
|
||||
{ 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(() => {
|
||||
return isLoading.value ? t('auth.submitting') : t('auth.submit')
|
||||
return isLoading.value ? t('auth.submitting') : t('auth.submit')
|
||||
})
|
||||
|
||||
const mapApiError = (error: unknown): string => {
|
||||
if (typeof error !== 'string') {
|
||||
return t('auth.errors.loginFailed')
|
||||
}
|
||||
if (error === 'Invalid email or password') {
|
||||
return t('auth.errors.invalidCredentials')
|
||||
}
|
||||
if (error === 'Invalid email format') {
|
||||
return t('auth.errors.invalidEmail')
|
||||
}
|
||||
return t('auth.errors.loginFailed')
|
||||
if (typeof error !== 'string') {
|
||||
return t('auth.errors.loginFailed')
|
||||
}
|
||||
if (error === 'Invalid email or password') {
|
||||
return t('auth.errors.invalidCredentials')
|
||||
}
|
||||
if (error === 'Invalid email format') {
|
||||
return t('auth.errors.invalidEmail')
|
||||
}
|
||||
return t('auth.errors.loginFailed')
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
const normalizedEmail = email.value.trim().toLowerCase()
|
||||
const normalizedPassword = password.value
|
||||
const normalizedEmail = email.value.trim().toLowerCase()
|
||||
const normalizedPassword = password.value
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
if (!emailRegex.test(normalizedEmail)) {
|
||||
errorMessage.value = t('auth.errors.invalidEmail')
|
||||
return
|
||||
}
|
||||
if (!emailRegex.test(normalizedEmail)) {
|
||||
errorMessage.value = t('auth.errors.invalidEmail')
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedPassword.length <= 0) {
|
||||
errorMessage.value = t('auth.errors.passwordRequired')
|
||||
return
|
||||
}
|
||||
if (normalizedPassword.length <= 0) {
|
||||
errorMessage.value = t('auth.errors.passwordRequired')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = (await BackendAPI.userLogin(normalizedEmail, normalizedPassword)) as LoginResponse
|
||||
const token = response.user?.token ?? null
|
||||
const userEmail = response.user?.email ?? normalizedEmail
|
||||
isLoading.value = true
|
||||
try {
|
||||
const result = await authStore.login(
|
||||
normalizedEmail,
|
||||
normalizedPassword,
|
||||
)
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem('token', token)
|
||||
} else {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
localStorage.setItem('user_email', userEmail)
|
||||
successMessage.value = result.autoRegistered
|
||||
? t('auth.successAutoRegistered')
|
||||
: t('auth.successLoggedIn')
|
||||
|
||||
successMessage.value = response.auto_registered
|
||||
? t('auth.successAutoRegistered')
|
||||
: t('auth.successLoggedIn')
|
||||
|
||||
email.value = normalizedEmail
|
||||
password.value = ''
|
||||
showPassword.value = false
|
||||
} catch (error) {
|
||||
errorMessage.value = mapApiError(error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
email.value = normalizedEmail
|
||||
password.value = ''
|
||||
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) {
|
||||
errorMessage.value = mapApiError(error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auth-page">
|
||||
<div class="auth-shell">
|
||||
<section class="auth-brand">
|
||||
<img src="/Nutrio.png" :alt="t('app.name')" class="auth-logo" />
|
||||
<h1>{{ t('app.name') }}</h1>
|
||||
<p>{{ t('app.slogan') }}</p>
|
||||
</section>
|
||||
<main class="auth-page">
|
||||
<div class="auth-shell">
|
||||
<section class="auth-brand">
|
||||
<img src="/Nutrio.png" :alt="t('app.name')" class="auth-logo" />
|
||||
<h1>{{ t('app.name') }}</h1>
|
||||
<p>{{ t('app.slogan') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="auth-card">
|
||||
<div class="auth-toolbar">
|
||||
<label class="locale-control" :aria-label="t('auth.languageLabel')">
|
||||
<font-awesome-icon :icon="faGlobe" />
|
||||
<span>{{ t('auth.languageLabel') }}</span>
|
||||
<select v-model="localeValue">
|
||||
<option v-for="lang in SUPPORTED_LOCALES" :key="lang" :value="lang">
|
||||
{{ t(`locale.${lang}`) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<section class="auth-card">
|
||||
<div class="auth-toolbar">
|
||||
<label class="locale-control" :aria-label="t('auth.languageLabel')">
|
||||
<font-awesome-icon :icon="faGlobe" />
|
||||
<span>{{ t('auth.languageLabel') }}</span>
|
||||
<select v-model="localeValue">
|
||||
<option v-for="lang in SUPPORTED_LOCALES" :key="lang" :value="lang">
|
||||
{{ t(`locale.${lang}`) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="button" class="theme-btn" :aria-label="t('auth.themeToggle')" @click="toggleTheme">
|
||||
<font-awesome-icon :icon="isDarkMode ? faMoon : faSun" />
|
||||
<span>{{ themeLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<h2>{{ t('auth.title') }}</h2>
|
||||
<p class="auth-subtitle">{{ t('auth.subtitle') }}</p>
|
||||
<h2>{{ t('auth.title') }}</h2>
|
||||
<p class="auth-subtitle">{{ t('auth.subtitle') }}</p>
|
||||
|
||||
<form class="auth-form" @submit.prevent="submitForm">
|
||||
<label for="email">{{ t('auth.emailLabel') }}</label>
|
||||
<div class="input-wrap">
|
||||
<font-awesome-icon :icon="faEnvelope" class="input-icon" />
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<form class="auth-form" @submit.prevent="submitForm">
|
||||
<label for="email">{{ t('auth.emailLabel') }}</label>
|
||||
<div class="input-wrap">
|
||||
<font-awesome-icon :icon="faEnvelope" class="input-icon" />
|
||||
<input id="email" v-model="email" type="email" autocomplete="email"
|
||||
:placeholder="t('auth.emailPlaceholder')" required />
|
||||
</div>
|
||||
|
||||
<label for="password">{{ t('auth.passwordLabel') }}</label>
|
||||
<div class="input-wrap">
|
||||
<font-awesome-icon :icon="faLock" class="input-icon" />
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
: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')"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<font-awesome-icon :icon="showPassword ? faEyeSlash : faEye" />
|
||||
</button>
|
||||
</div>
|
||||
<label for="password">{{ t('auth.passwordLabel') }}</label>
|
||||
<div class="input-wrap">
|
||||
<font-awesome-icon :icon="faLock" class="input-icon" />
|
||||
<input id="password" v-model="password" :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')"
|
||||
@click="showPassword = !showPassword">
|
||||
<font-awesome-icon :icon="showPassword ? faEyeSlash : faEye" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="submit-btn" type="submit" :disabled="isLoading">
|
||||
<font-awesome-icon :icon="faRightToBracket" />
|
||||
<span>{{ submitLabel }}</span>
|
||||
</button>
|
||||
</form>
|
||||
<button class="submit-btn" type="submit" :disabled="isLoading">
|
||||
<font-awesome-icon :icon="faRightToBracket" />
|
||||
<span>{{ submitLabel }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-helper">{{ t('auth.helper') }}</p>
|
||||
<p class="auth-helper">{{ t('auth.helper') }}</p>
|
||||
|
||||
<p v-if="errorMessage.length > 0" class="feedback feedback-error">{{ errorMessage }}</p>
|
||||
<p v-if="errorMessage.length > 0" class="feedback feedback-error">{{ errorMessage }}</p>
|
||||
|
||||
<p v-if="successMessage.length > 0" class="feedback feedback-success">
|
||||
{{ successMessage }} {{ t('auth.tokenSaved') }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<p v-if="successMessage.length > 0" class="feedback feedback-success">
|
||||
{{ successMessage }} {{ t('auth.tokenSaved') }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
174
frontend/src/views/IngredientsView.vue
Normal 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>
|
||||
226
frontend/src/views/MealDetailView.vue
Normal 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>
|
||||
130
frontend/src/views/MealsView.vue
Normal 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>
|
||||
70
frontend/src/views/SettingsView.vue
Normal 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>
|
||||
14
frontend/src/views/StatsView.vue
Normal 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>
|
||||
114
frontend/src/views/TodayView.vue
Normal 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>
|
||||
@ -9,13 +9,17 @@ import pkg from './package.json';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0", // sprístupní server na všetkých interfejsoch
|
||||
port: 5173, // môžeš zmeniť port, ak potrebuješ
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||