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:
@ -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
161
frontend/src/BackendAPI.js
Normal 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();
|
||||
327
frontend/src/assets/css/style.css
Normal file
327
frontend/src/assets/css/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
44
frontend/src/i18n/index.ts
Normal file
44
frontend/src/i18n/index.ts
Normal 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
|
||||
43
frontend/src/locales/cs.ts
Normal file
43
frontend/src/locales/cs.ts
Normal 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
|
||||
43
frontend/src/locales/de.ts
Normal file
43
frontend/src/locales/de.ts
Normal 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
|
||||
43
frontend/src/locales/en.ts
Normal file
43
frontend/src/locales/en.ts
Normal 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
|
||||
43
frontend/src/locales/es.ts
Normal file
43
frontend/src/locales/es.ts
Normal 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
|
||||
43
frontend/src/locales/sk.ts
Normal file
43
frontend/src/locales/sk.ts
Normal 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
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
234
frontend/src/views/AuthView.vue
Normal file
234
frontend/src/views/AuthView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user