add auto-signup login and localized auth UI

- backend: userLogin auto-registers missing users and returns auto_registered
- frontend: add responsive auth page (login + signup flow via userLogin) with light/dark mode and logo
- i18n: wire language switching, add translations for cs/en/es/de
- ui/tooling: add Font Awesome integration
This commit is contained in:
2026-02-13 07:55:37 +01:00
parent 2d96baa389
commit 64a8ac047f
21 changed files with 1168 additions and 17 deletions

View File

@ -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=<method_n
- ingredients can be user-owned or global (`user_id = null`) for read/select
- Registration/login generate and store user token in DB.
- `userLogout(token)` invalidates session by setting `token` and `token_expires` to `NULL`.
- `userLogin(email, password)` now auto-registers user when:
- email is valid
- user does not exist
- then proceeds with normal login/token generation flow
- `userLogin` response now includes `auto_registered` flag (`true|false`).
## Known Pitfalls and Notes
@ -122,6 +140,8 @@ All actions are invoked through `backend/public/API.php` with `?action=<method_n
- 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.
- In vue-i18n locale strings, `@` must be escaped as `{'@'}` to avoid "Invalid linked format" errors.
## Local Runbook
@ -132,6 +152,8 @@ Backend:
- configure DB via `backend/config/*.php` override
- serve `backend/public` through web server/PHP runtime
- first API hit triggers `Maintenance->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.

View File

@ -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";

View File

@ -72,9 +72,22 @@ class API extends APIlite {
{
$email = $this->normalizeEmail($email);
$password = $this->normalizePassword($password);
$autoRegistered = false;
if (!$this->users()->verifyUser($email, $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'])) {
throw new \Exception('User not found');
@ -87,6 +100,7 @@ class API extends APIlite {
}
return array(
'logged_in' => true,
'auto_registered' => $autoRegistered,
'user' => $this->mapAuthUser($user)
);
}

View File

@ -0,0 +1 @@
VITE_BACKENDAPI_URL="https://192.168.0.101/Nutrio/backend/public/API.php"

1
frontend/.env.production Normal file
View File

@ -0,0 +1 @@
VITE_BACKENDAPI_URL="/API.php"

View File

@ -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",

View File

@ -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": {

BIN
frontend/public/Nutrio.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@ -1,11 +1,7 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<h1>You did it!</h1>
<p>
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
documentation
</p>
<RouterView />
</template>
<style scoped></style>

161
frontend/src/BackendAPI.js Normal file
View File

@ -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();

View File

@ -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;
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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

View File

@ -0,0 +1,234 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
faEnvelope,
faEye,
faEyeSlash,
faGlobe,
faLock,
faMoon,
faRightToBracket,
faSun,
} from '@fortawesome/free-solid-svg-icons'
import BackendAPI from '@/BackendAPI.js'
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
}
}
const { t } = useI18n({ useScope: 'global' })
const email = ref('')
const password = ref('')
const showPassword = ref(false)
const isLoading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
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 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')
})
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')
}
const submitForm = async () => {
errorMessage.value = ''
successMessage.value = ''
const normalizedEmail = email.value.trim().toLowerCase()
const normalizedPassword = password.value
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(normalizedEmail)) {
errorMessage.value = t('auth.errors.invalidEmail')
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
if (token) {
localStorage.setItem('token', token)
} else {
localStorage.removeItem('token')
}
localStorage.setItem('user_email', userEmail)
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
}
}
</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>
<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>
<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>
<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>
<p class="auth-helper">{{ t('auth.helper') }}</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>
</template>

View File

@ -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: [