diff --git a/AGENTS.md b/AGENTS.md index 21936d8..742a4b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,14 +15,27 @@ It describes what the project is, what is already implemented, and what still ne - `backend/` - `frontend/` -## Current State (as of 2026-02-12) +## Current State (as of 2026-02-13) - `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 is still template-level: - - `frontend/src/App.vue` has placeholder content. - - `frontend/src/router/index.ts` has empty `routes: []`. +- Frontend auth page is now implemented: + - `frontend/src/App.vue` renders router view. + - `frontend/src/router/index.ts` maps `/` to `frontend/src/views/AuthView.vue`. + - `AuthView` serves as login + registration entry (single form, email + password). + - Successful login stores `token` and `user_email` in `localStorage`. +- Frontend i18n is wired: + - setup in `frontend/src/i18n/index.ts` + - locale files in `frontend/src/locales/{sk,cs,en,es,de}.ts` + - language switcher updates locale dynamically. +- Frontend theme system is implemented: + - light/dark mode toggle in auth page + - design tokens in `frontend/src/assets/css/style.css` (`:root` variables). +- App logo is served from `frontend/public/Nutrio.png` (copied from `doc/Nutrio.png`). +- Font Awesome is installed and registered globally in `frontend/src/main.ts`. +- `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`. - `backend/data.json` contains sample meal data (not currently wired into DB/API flow). ## Backend Architecture @@ -111,6 +124,11 @@ All actions are invoked through `backend/public/API.php` with `?action=database()` migration flow +- when backend API signature changes, regenerate frontend API client: + - `php scripts/buildTypeScript.php` Frontend: @@ -143,7 +165,7 @@ Frontend: ## Product Behavior Target (what to build next) - Harden auth (token transport/header strategy, token revoke strategy, brute-force/rate-limits). -- Build frontend screens for ingredients, meals, meal item editor, diary day, diary range. +- Build remaining frontend screens for ingredients, meals, meal item editor, diary day, diary range. - Connect frontend to implemented backend actions. - Add API tests for validation, ownership checks, and totals calculation consistency. - Add pagination/filter strategy where list endpoints grow. diff --git a/backend/scripts/buildTypeScript.php b/backend/scripts/buildTypeScript.php index 621f0d1..6dbc168 100644 --- a/backend/scripts/buildTypeScript.php +++ b/backend/scripts/buildTypeScript.php @@ -4,12 +4,12 @@ require __DIR__ . '/../vendor/autoload.php'; ob_start(); -$backend_api = new TPsoft\BugreportBackend\API('typescript', 'import.meta.env.VITE_BACKENDAPI_URL', 'backend'); +$backend_api = new TPsoft\Nutrio\API('typescript', 'import.meta.env.VITE_BACKENDAPI_URL', 'BackendAPI'); $output = ob_get_contents(); ob_end_clean(); -$ts_path = realpath(__DIR__ . '/../../frontend/src').'/backend.js'; +$ts_path = realpath(__DIR__ . '/../../frontend/src').'/BackendAPI.js'; $suc = file_put_contents($ts_path, $output); if ($suc === false) { echo "✗ TypeScript store into file failed\n"; diff --git a/backend/src/API.php b/backend/src/API.php index 3ddcba5..e60d2ea 100644 --- a/backend/src/API.php +++ b/backend/src/API.php @@ -72,8 +72,21 @@ class API extends APIlite { { $email = $this->normalizeEmail($email); $password = $this->normalizePassword($password); + $autoRegistered = false; if (!$this->users()->verifyUser($email, $password)) { - throw new \Exception('Invalid email or password'); + $existing = $this->users()->userBy('email', $email); + if (is_array($existing)) { + throw new \Exception('Invalid email or password'); + } + $userId = $this->users()->user(null, array( + 'email' => $email, + 'password_hash' => $this->users()->hashString($password), + 'created_at' => '`NOW`' + )); + if ($userId === false) { + throw new \Exception('Failed to auto-register user'); + } + $autoRegistered = true; } $user = $this->users()->userBy('email', $email); if (!is_array($user) || !isset($user['user_id'])) { @@ -87,6 +100,7 @@ class API extends APIlite { } return array( 'logged_in' => true, + 'auto_registered' => $autoRegistered, 'user' => $this->mapAuthUser($user) ); } diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..7004666 --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1 @@ +VITE_BACKENDAPI_URL="https://192.168.0.101/Nutrio/backend/public/API.php" \ No newline at end of file diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..5bcdf37 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1 @@ +VITE_BACKENDAPI_URL="/API.php" \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1abde61..665f4d8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,11 @@ "name": "nutrio", "version": "0.0.0", "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.2.0", + "@fortawesome/free-solid-svg-icons": "^7.2.0", + "@fortawesome/vue-fontawesome": "^3.1.3", "vue": "^3.5.27", + "vue-i18n": "^11.2.8", "vue-router": "^5.0.1" }, "devDependencies": { @@ -1139,6 +1143,49 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.2.0.tgz", + "integrity": "sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.2.0.tgz", + "integrity": "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.2.0.tgz", + "integrity": "sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/vue-fontawesome": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.1.3.tgz", + "integrity": "sha512-OHHUTLPEzdwP8kcYIzhioUdUOjZ4zzmi+midwa4bqscza4OJCOvTKJEHkXNz8PgZ23kWci1HkKVX0bm8f9t9gQ==", + "license": "MIT", + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7", + "vue": ">= 3.0.0 < 4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1191,6 +1238,50 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@intlify/core-base": { + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz", + "integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "11.2.8", + "@intlify/shared": "11.2.8" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz", + "integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "11.2.8", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz", + "integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -5073,6 +5164,32 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/vue-i18n": { + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz", + "integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.2.8", + "@intlify/shared": "11.2.8", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "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", diff --git a/frontend/package.json b/frontend/package.json index 5f36b63..5de74f6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,11 @@ "format": "prettier --write --experimental-cli src/" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.2.0", + "@fortawesome/free-solid-svg-icons": "^7.2.0", + "@fortawesome/vue-fontawesome": "^3.1.3", "vue": "^3.5.27", + "vue-i18n": "^11.2.8", "vue-router": "^5.0.1" }, "devDependencies": { diff --git a/frontend/public/Nutrio.png b/frontend/public/Nutrio.png new file mode 100644 index 0000000..a305925 Binary files /dev/null and b/frontend/public/Nutrio.png differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index abfd315..a78fb6f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,11 +1,7 @@ - + - - diff --git a/frontend/src/BackendAPI.js b/frontend/src/BackendAPI.js new file mode 100644 index 0000000..860f154 --- /dev/null +++ b/frontend/src/BackendAPI.js @@ -0,0 +1,161 @@ +/** + * 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(); diff --git a/frontend/src/assets/css/style.css b/frontend/src/assets/css/style.css new file mode 100644 index 0000000..fea548e --- /dev/null +++ b/frontend/src/assets/css/style.css @@ -0,0 +1,327 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Grotesk:wght@500;700&display=swap'); + +:root { + --color-white: #ffffff; + --color-green: #64b010; + --color-gray: #454c53; + --color-bg: #f4f7f1; + --color-surface: #ffffff; + --color-text: #222a30; + --color-muted: #5f6974; + --color-border: #d8dde1; + --color-shadow: rgba(69, 76, 83, 0.2); + --color-success-bg: #e7f4d8; + --color-error-bg: #f6dde0; + --color-error: #7d2430; + --radius-md: 0.875rem; + --radius-lg: 1.25rem; + --space-xs: 0.5rem; + --space-sm: 0.75rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 2rem; + --space-2xl: 2.75rem; + --fs-sm: 0.875rem; + --fs-md: 1rem; + --fs-lg: 1.25rem; + --fs-xl: 1.875rem; + --shadow-soft: 0 20px 40px -30px var(--color-shadow); + --transition-fast: 160ms ease; +} + +:root[data-theme='dark'] { + --color-bg: #1f252a; + --color-surface: #2b333a; + --color-text: #f2f4f2; + --color-muted: #bac3ca; + --color-border: #4d5862; + --color-shadow: rgba(0, 0, 0, 0.35); + --color-success-bg: #35561b; + --color-error-bg: #5a2b33; + --color-error: #ffdce2; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#app { + min-height: 100%; +} + +body { + margin: 0; + font-family: 'DM Sans', 'Segoe UI', sans-serif; + font-size: var(--fs-md); + color: var(--color-text); + background: + radial-gradient(circle at 12% 10%, rgba(100, 176, 16, 0.22) 0%, transparent 38%), + radial-gradient(circle at 88% 88%, rgba(69, 76, 83, 0.2) 0%, transparent 44%), + var(--color-bg); + transition: + background-color var(--transition-fast), + color var(--transition-fast); +} + +button, +input, +select { + font: inherit; +} + +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-xl) var(--space-md); +} + +.auth-shell { + width: min(980px, 100%); + display: grid; + grid-template-columns: 1fr 1.15fr; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-soft); + overflow: hidden; +} + +.auth-brand { + padding: var(--space-2xl) var(--space-xl); + background: + linear-gradient(155deg, rgba(100, 176, 16, 0.88) 0%, rgba(69, 76, 83, 0.92) 100%); + color: var(--color-white); + display: flex; + flex-direction: column; + justify-content: center; + gap: var(--space-md); +} + +.auth-brand h1 { + margin: 0; + font-family: 'Space Grotesk', 'Segoe UI', sans-serif; + font-size: clamp(2rem, 5vw, 2.75rem); + letter-spacing: 0.04em; +} + +.auth-brand p { + margin: 0; + font-size: var(--fs-lg); + line-height: 1.5; +} + +.auth-logo { + width: clamp(96px, 18vw, 150px); + height: auto; + filter: drop-shadow(0 12px 20px rgba(0, 0, 0, 0.28)); +} + +.auth-card { + padding: var(--space-xl); + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.auth-toolbar { + display: flex; + justify-content: space-between; + gap: var(--space-sm); + align-items: center; +} + +.locale-control { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + color: var(--color-muted); + font-size: var(--fs-sm); +} + +.locale-control select { + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 0.42rem 0.55rem; +} + +.theme-btn { + display: inline-flex; + align-items: center; + gap: 0.45rem; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + border-radius: var(--radius-md); + padding: 0.45rem 0.7rem; + cursor: pointer; + transition: + border-color var(--transition-fast), + color var(--transition-fast), + transform var(--transition-fast); +} + +.theme-btn:hover { + border-color: var(--color-green); + color: var(--color-green); + transform: translateY(-1px); +} + +.auth-card h2 { + margin: var(--space-sm) 0 0; + font-family: 'Space Grotesk', 'Segoe UI', sans-serif; + font-size: var(--fs-xl); +} + +.auth-subtitle { + margin: 0; + color: var(--color-muted); + line-height: 1.45; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.auth-form label { + font-size: var(--fs-sm); + font-weight: 600; +} + +.input-wrap { + width: 100%; + display: flex; + align-items: center; + gap: var(--space-xs); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + padding: 0 0.75rem; + transition: border-color var(--transition-fast); +} + +.input-wrap:focus-within { + border-color: var(--color-green); +} + +.input-icon { + color: var(--color-muted); + width: 1rem; +} + +.input-wrap input { + width: 100%; + border: none; + outline: none; + background: transparent; + color: var(--color-text); + padding: 0.74rem 0; +} + +.input-wrap input::placeholder { + color: var(--color-muted); +} + +.password-btn { + border: none; + background: transparent; + color: var(--color-muted); + cursor: pointer; + padding: 0.3rem; +} + +.submit-btn { + margin-top: var(--space-sm); + border: none; + background: var(--color-green); + color: var(--color-white); + border-radius: var(--radius-md); + padding: 0.76rem 1rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + font-weight: 700; + cursor: pointer; + transition: + transform var(--transition-fast), + filter var(--transition-fast); +} + +.submit-btn:hover:not(:disabled) { + transform: translateY(-1px); + filter: brightness(1.05); +} + +.submit-btn:disabled { + opacity: 0.72; + cursor: wait; +} + +.auth-helper { + margin: 0; + color: var(--color-muted); + font-size: var(--fs-sm); +} + +.feedback { + margin: 0; + border-radius: var(--radius-md); + padding: 0.65rem 0.75rem; + font-size: var(--fs-sm); +} + +.feedback-error { + background: var(--color-error-bg); + color: var(--color-error); +} + +.feedback-success { + background: var(--color-success-bg); + color: var(--color-text); +} + +@media (max-width: 920px) { + .auth-shell { + grid-template-columns: 1fr; + } + + .auth-brand { + text-align: center; + align-items: center; + padding: var(--space-xl); + } + + .auth-brand p { + max-width: 40ch; + } +} + +@media (max-width: 620px) { + .auth-page { + padding: var(--space-sm); + } + + .auth-card { + padding: var(--space-md); + } + + .auth-toolbar { + flex-direction: column; + align-items: stretch; + } + + .theme-btn, + .locale-control { + justify-content: center; + } +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..75c4a02 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,44 @@ +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' + +export const SUPPORTED_LOCALES = ['sk', 'cs', 'en', 'es', 'de'] as const +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) +} + +const browserLocale = navigator.language.slice(0, 2).toLowerCase() +const storedLocale = localStorage.getItem('locale') + +const locale: AppLocale = isSupportedLocale(storedLocale) + ? storedLocale + : isSupportedLocale(browserLocale) + ? browserLocale + : fallbackLocale + +if (!isSupportedLocale(storedLocale) || storedLocale !== locale) { + localStorage.setItem('locale', locale) +} + +const i18n = createI18n({ + legacy: false, + locale, + fallbackLocale, + messages: { + sk, + cs, + en, + es, + de, + }, +}) + +export default i18n diff --git a/frontend/src/locales/cs.ts b/frontend/src/locales/cs.ts new file mode 100644 index 0000000..06be5ba --- /dev/null +++ b/frontend/src/locales/cs.ts @@ -0,0 +1,43 @@ +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 diff --git a/frontend/src/locales/de.ts b/frontend/src/locales/de.ts new file mode 100644 index 0000000..96bc9fb --- /dev/null +++ b/frontend/src/locales/de.ts @@ -0,0 +1,43 @@ +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 diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts new file mode 100644 index 0000000..9988cce --- /dev/null +++ b/frontend/src/locales/en.ts @@ -0,0 +1,43 @@ +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 diff --git a/frontend/src/locales/es.ts b/frontend/src/locales/es.ts new file mode 100644 index 0000000..203300f --- /dev/null +++ b/frontend/src/locales/es.ts @@ -0,0 +1,43 @@ +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 diff --git a/frontend/src/locales/sk.ts b/frontend/src/locales/sk.ts new file mode 100644 index 0000000..e0f3917 --- /dev/null +++ b/frontend/src/locales/sk.ts @@ -0,0 +1,43 @@ +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 diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c8e37b0..d0d27d6 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,9 +1,14 @@ import { createApp } from 'vue' +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' +import './assets/css/style.css' import App from './App.vue' +import i18n from './i18n' import router from './router' const app = createApp(App) +app.component('font-awesome-icon', FontAwesomeIcon) app.use(router) +app.use(i18n) app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e1eab52..70c45be 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,8 +1,15 @@ import { createRouter, createWebHistory } from 'vue-router' +import AuthView from '@/views/AuthView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), - routes: [], + routes: [ + { + path: '/', + name: 'auth', + component: AuthView, + }, + ], }) export default router diff --git a/frontend/src/views/AuthView.vue b/frontend/src/views/AuthView.vue new file mode 100644 index 0000000..1127152 --- /dev/null +++ b/frontend/src/views/AuthView.vue @@ -0,0 +1,234 @@ + + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4217010..233f4b6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,9 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import vueDevTools from 'vite-plugin-vue-devtools' +import pkg from './package.json'; + + // https://vite.dev/config/ export default defineConfig({ plugins: [