Public Access
Added registration and settings page
This commit is contained in:
1 parent
b34234caa1
commit
2b5f77f0b9
12 files changed
+905
-142
No files matched your search
+16
-19
@@ -6,10 +6,13 @@ import LandingPage from './pages/LandingPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import ImprintPage from './pages/ImprintPage';
|
||||
import Dashboard from "./pages/Dashboard.jsx";
|
||||
import RegistrationPage from './pages/RegistrationPage.jsx';
|
||||
import SettingsPage from './pages/SettingsPage.jsx';
|
||||
import {usePreferences} from './context/PreferencesContext.jsx';
|
||||
|
||||
function App() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(true);
|
||||
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// Hooks for routing
|
||||
@@ -21,21 +24,8 @@ function App() {
|
||||
setIsAuthenticated(!!token); // Converts the token string (or null) to a boolean
|
||||
}, [location]);
|
||||
|
||||
// Sync React state with the HTML class required by Tailwind CSS
|
||||
useEffect(() => {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
const toggleLanguage = () => {
|
||||
if (i18n.language === 'de') {
|
||||
i18n.changeLanguage('en').then(_ => console.log("Changed language to 'en'"));
|
||||
} else {
|
||||
i18n.changeLanguage('de').then(_ => console.log("Changed language to 'de'"));
|
||||
}
|
||||
setLanguage(i18n.language === 'de' ? 'en' : 'de');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -56,14 +46,19 @@ function App() {
|
||||
{i18n.language.toUpperCase()}
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
setIsDarkMode(!isDarkMode);
|
||||
setDarkMode(!isDarkMode);
|
||||
}} className="text-xl text-slate-600 dark:text-slate-300 hover:text-blue-500 transition-colors">
|
||||
<i className={isDarkMode ? "mdi mdi-weather-sunny" : "mdi mdi-weather-night"}></i>
|
||||
</button>
|
||||
{isAuthenticated ? (
|
||||
<button onClick={handleLogout} className="bg-red-600 hover:bg-red-500 text-white px-5 py-2 rounded-lg font-bold transition-all shadow-md shadow-red-500/20">
|
||||
{t('MAIN.LOGOUT')}
|
||||
</button>
|
||||
<>
|
||||
<Link to="/settings" className="text-sm font-bold text-slate-600 dark:text-slate-300 hover:text-blue-500 transition-colors">
|
||||
<i className="mdi mdi-cog text-xl"></i>
|
||||
</Link>
|
||||
<button onClick={handleLogout} className="bg-red-600 hover:bg-red-500 text-white px-5 py-2 rounded-lg font-bold transition-all shadow-md shadow-red-500/20">
|
||||
{t('MAIN.LOGOUT')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="bg-blue-600 hover:bg-blue-500 text-white px-5 py-2 rounded-lg font-bold transition-all shadow-md shadow-blue-500/20">
|
||||
{t('MAIN.LOGIN')}
|
||||
@@ -76,7 +71,9 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage/>}/>
|
||||
<Route path="/login" element={<LoginPage/>}/>
|
||||
<Route path="/register" element={<RegistrationPage/>}/>
|
||||
<Route path="/dashboard" element={<Dashboard/>}/>
|
||||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {createContext, useContext, useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
||||
|
||||
const PreferencesContext = createContext(null);
|
||||
|
||||
export function PreferencesProvider({children}) {
|
||||
const {i18n} = useTranslation();
|
||||
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
const stored = localStorage.getItem(DARK_MODE_STORAGE_KEY);
|
||||
return stored === null ? true : stored === 'true';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode));
|
||||
}, [isDarkMode]);
|
||||
|
||||
const setLanguage = (lang) => i18n.changeLanguage(lang);
|
||||
|
||||
return (
|
||||
<PreferencesContext.Provider
|
||||
value={{
|
||||
isDarkMode,
|
||||
setDarkMode: setIsDarkMode,
|
||||
language: i18n.language,
|
||||
setLanguage,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PreferencesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePreferences() {
|
||||
const ctx = useContext(PreferencesContext);
|
||||
if (!ctx) {
|
||||
throw new Error('usePreferences must be used inside <PreferencesProvider>');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
+4
-1
@@ -2,6 +2,7 @@ import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {BrowserRouter} from 'react-router-dom';
|
||||
import App from './App.jsx';
|
||||
import {PreferencesProvider} from './context/PreferencesContext.jsx';
|
||||
import './i18n';
|
||||
import './style.css';
|
||||
import '@mdi/font/css/materialdesignicons.css';
|
||||
@@ -9,7 +10,9 @@ import '@mdi/font/css/materialdesignicons.css';
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App/>
|
||||
<PreferencesProvider>
|
||||
<App/>
|
||||
</PreferencesProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
+10
-4
@@ -1,5 +1,5 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
const Dashboard = () => {
|
||||
@@ -83,9 +83,15 @@ const Dashboard = () => {
|
||||
<div className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center">
|
||||
<span className="text-slate-400 dark:text-slate-500">Tile 2 (Coming soon)</span>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center">
|
||||
<span className="text-slate-400 dark:text-slate-500">Settings (Coming soon)</span>
|
||||
</div>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex flex-col items-center justify-center hover:border-blue-500 dark:hover:border-blue-500 transition-colors"
|
||||
>
|
||||
<i className="mdi mdi-cog text-4xl text-slate-500 dark:text-slate-400 mb-2"></i>
|
||||
<span className="text-slate-700 dark:text-slate-300 font-semibold">
|
||||
{t('SETTINGS.TITLE')}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
|
||||
export default function LoginPage() {
|
||||
const {t} = useTranslation();
|
||||
@@ -131,6 +131,13 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-sm text-center text-slate-600 dark:text-slate-400">
|
||||
{t('LOGIN.NO_ACCOUNT')}{' '}
|
||||
<Link to="/register" className="text-blue-600 hover:text-blue-500 font-semibold">
|
||||
{t('REGISTRATION.TITLE')}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import {useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
|
||||
import {registrationSchema} from '../utils/validation.js';
|
||||
|
||||
const BACKEND_ERROR_KEY = {
|
||||
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE',
|
||||
INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE',
|
||||
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
'w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:outline-none';
|
||||
const inputErrorClass =
|
||||
'w-full px-4 py-2 border border-red-500 dark:border-red-500 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:outline-none';
|
||||
|
||||
export default function RegistrationPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [feedback, setFeedback] = useState({message: '', isError: false});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: {errors, isSubmitting},
|
||||
} = useForm({
|
||||
resolver: zodResolver(registrationSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
inviteCode: '',
|
||||
steamId: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
setFeedback({message: '', isError: false});
|
||||
try {
|
||||
const apiUrl = `${import.meta.env.VITE_API_URL}/api/registration/new`;
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
inviteCode: values.inviteCode,
|
||||
steamId: values.steamId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setFeedback({message: t('REGISTRATION.SUCCESS'), isError: false});
|
||||
setTimeout(() => navigate('/login'), 800);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const mappedKey = BACKEND_ERROR_KEY[data.code];
|
||||
setFeedback({
|
||||
message: mappedKey ? t(mappedKey) : data.message || t('REGISTRATION.ERROR_GENERIC'),
|
||||
isError: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Registration API error:', err);
|
||||
setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
|
||||
}
|
||||
};
|
||||
|
||||
const renderError = (field) =>
|
||||
errors[field] && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{t(errors[field].message)}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900 px-4 py-8">
|
||||
<div className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8">
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-6 text-center">
|
||||
{t('REGISTRATION.TITLE')}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
{t('REGISTRATION.EMAIL')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={errors.email ? inputErrorClass : inputClass}
|
||||
{...register('email')}
|
||||
/>
|
||||
{renderError('email')}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
{t('REGISTRATION.PASSWORD')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={errors.password ? inputErrorClass : inputClass}
|
||||
{...register('password')}
|
||||
/>
|
||||
{renderError('password')}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="passwordConfirmation" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
{t('REGISTRATION.PASSWORD_CONFIRMATION')}
|
||||
</label>
|
||||
<input
|
||||
id="passwordConfirmation"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={errors.passwordConfirmation ? inputErrorClass : inputClass}
|
||||
{...register('passwordConfirmation')}
|
||||
/>
|
||||
{renderError('passwordConfirmation')}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="inviteCode" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
{t('REGISTRATION.INVITE_CODE')}
|
||||
</label>
|
||||
<input
|
||||
id="inviteCode"
|
||||
type="text"
|
||||
className={errors.inviteCode ? inputErrorClass : inputClass}
|
||||
{...register('inviteCode')}
|
||||
/>
|
||||
{renderError('inviteCode')}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="steamId" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
{t('REGISTRATION.STEAM_ID')}
|
||||
</label>
|
||||
<input
|
||||
id="steamId"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={errors.steamId ? inputErrorClass : inputClass}
|
||||
{...register('steamId')}
|
||||
/>
|
||||
{renderError('steamId')}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold rounded-lg transition-colors duration-200"
|
||||
>
|
||||
{isSubmitting ? '...' : t('REGISTRATION.SUBMIT')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{feedback.message && (
|
||||
<div
|
||||
className={`mt-6 p-4 rounded-lg text-sm text-center ${
|
||||
feedback.isError
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
}`}
|
||||
>
|
||||
{feedback.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-sm text-center text-slate-600 dark:text-slate-400">
|
||||
{t('REGISTRATION.HAVE_ACCOUNT')}{' '}
|
||||
<Link to="/login" className="text-blue-600 hover:text-blue-500 font-semibold">
|
||||
{t('MAIN.LOGIN')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
|
||||
import {settingsSchema} from '../utils/validation.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
|
||||
const BACKEND_ERROR_KEY = {
|
||||
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
|
||||
};
|
||||
|
||||
const SUPPORTED_LANGUAGES = ['en', 'de'];
|
||||
|
||||
const inputClass =
|
||||
'w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:outline-none';
|
||||
const inputErrorClass =
|
||||
'w-full px-4 py-2 border border-red-500 dark:border-red-500 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:outline-none';
|
||||
const readOnlyClass =
|
||||
'w-full px-4 py-2 border border-slate-200 dark:border-slate-700 rounded-lg bg-slate-100 dark:bg-slate-900 text-slate-500 dark:text-slate-400 cursor-not-allowed';
|
||||
|
||||
const formatDate = (value, locale) => {
|
||||
if (!value) return '—';
|
||||
try {
|
||||
return new Date(value).toLocaleString(locale);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
|
||||
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [feedback, setFeedback] = useState({message: '', isError: false});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: {errors, isSubmitting},
|
||||
} = useForm({
|
||||
resolver: zodResolver(settingsSchema),
|
||||
defaultValues: {useDarkTheme: true, language: 'en', steamId: ''},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('tbp_jwt');
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('tbp_jwt');
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setLoadError(t('INFO.ERROR.LOAD_PROFILE'));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setProfile(data);
|
||||
reset({
|
||||
useDarkTheme: !!data.settings?.useDarkTheme,
|
||||
language: data.settings?.language || 'en',
|
||||
steamId: data.steamId ? String(data.steamId) : '',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Settings load error:', err);
|
||||
setLoadError(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
}, [navigate, reset, t]);
|
||||
|
||||
const persistSettings = async (payload) => {
|
||||
const token = localStorage.getItem('tbp_jwt');
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return {ok: false};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('tbp_jwt');
|
||||
navigate('/login');
|
||||
return {ok: false};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const mappedKey = BACKEND_ERROR_KEY[data.code];
|
||||
setFeedback({
|
||||
message: mappedKey ? t(mappedKey) : data.message || t('SETTINGS.ERROR_SAVE'),
|
||||
isError: true,
|
||||
});
|
||||
return {ok: false};
|
||||
}
|
||||
|
||||
setFeedback({message: t('SETTINGS.SAVED'), isError: false});
|
||||
return {ok: true};
|
||||
} catch (err) {
|
||||
console.error('Settings save error:', err);
|
||||
setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
|
||||
return {ok: false};
|
||||
}
|
||||
};
|
||||
|
||||
const handleDarkThemeToggle = async () => {
|
||||
const next = !isDarkMode;
|
||||
setDarkMode(next); // apply on the fly
|
||||
await persistSettings({
|
||||
useDarkTheme: next,
|
||||
language: i18n.language,
|
||||
steamId: profile?.steamId ?? 0,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLanguageChange = async (event) => {
|
||||
const next = event.target.value;
|
||||
setLanguage(next); // apply on the fly
|
||||
await persistSettings({
|
||||
useDarkTheme: isDarkMode,
|
||||
language: next,
|
||||
steamId: profile?.steamId ?? 0,
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmitSteamId = async (values) => {
|
||||
const result = await persistSettings({
|
||||
useDarkTheme: isDarkMode,
|
||||
language: i18n.language,
|
||||
steamId: values.steamId,
|
||||
});
|
||||
if (result.ok) {
|
||||
setProfile((p) => (p ? {...p, steamId: values.steamId} : p));
|
||||
}
|
||||
};
|
||||
|
||||
const renderError = (field) =>
|
||||
errors[field] && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{t(errors[field].message)}</p>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-white">
|
||||
{t('SETTINGS.LOADING')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900 text-red-500">
|
||||
{loadError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-8">
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 border border-slate-200 dark:border-slate-700">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-6">
|
||||
{t('SETTINGS.TITLE')}
|
||||
</h1>
|
||||
|
||||
{/* Read-only account info */}
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-lg font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('SETTINGS.ACCOUNT')}
|
||||
</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
{t('SETTINGS.EMAIL')}
|
||||
</label>
|
||||
<input type="text" value={profile?.email ?? ''} readOnly className={readOnlyClass}/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
{t('SETTINGS.ROLE')}
|
||||
</label>
|
||||
<input type="text" value={profile?.role ?? ''} readOnly className={readOnlyClass}/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
{t('SETTINGS.CREATED_DATE')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formatDate(profile?.createdDate, i18n.language)}
|
||||
readOnly
|
||||
className={readOnlyClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
{t('SETTINGS.LAST_LOGIN')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formatDate(profile?.lastLogin, i18n.language)}
|
||||
readOnly
|
||||
className={readOnlyClass}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Preferences — live */}
|
||||
<section className="space-y-4 mb-8">
|
||||
<h2 className="text-lg font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('SETTINGS.PREFERENCES')}
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="useDarkTheme" className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{t('SETTINGS.DARK_THEME')}
|
||||
</label>
|
||||
<button
|
||||
id="useDarkTheme"
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isDarkMode}
|
||||
onClick={handleDarkThemeToggle}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
isDarkMode ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
isDarkMode ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="language" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
{t('SETTINGS.LANGUAGE')}
|
||||
</label>
|
||||
<select
|
||||
id="language"
|
||||
value={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
className={inputClass}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Steam ID — explicit save */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-slate-700 dark:text-slate-200 mb-4">
|
||||
{t('SETTINGS.STEAM')}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit(onSubmitSteamId)} className="space-y-4" noValidate>
|
||||
<div>
|
||||
<label htmlFor="steamId" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
{t('SETTINGS.STEAM_ID')}
|
||||
</label>
|
||||
<input
|
||||
id="steamId"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={errors.steamId ? inputErrorClass : inputClass}
|
||||
{...register('steamId')}
|
||||
/>
|
||||
{renderError('steamId')}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold rounded-lg transition-colors duration-200"
|
||||
>
|
||||
{isSubmitting ? '...' : t('SETTINGS.SAVE_STEAM_ID')}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{feedback.message && (
|
||||
<div
|
||||
className={`mt-6 p-4 rounded-lg text-sm text-center ${
|
||||
feedback.isError
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
}`}
|
||||
>
|
||||
{feedback.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
// Error messages are i18n keys — translate them in components via t(error.message)
|
||||
|
||||
const emailField = z
|
||||
.string()
|
||||
.min(1, 'VALIDATION.EMAIL_REQUIRED')
|
||||
.email('VALIDATION.EMAIL_INVALID');
|
||||
|
||||
const passwordField = z
|
||||
.string()
|
||||
.min(8, 'VALIDATION.PASSWORD_TOO_SHORT');
|
||||
|
||||
const inviteCodeField = z
|
||||
.string()
|
||||
.min(1, 'VALIDATION.INVITE_CODE_REQUIRED');
|
||||
|
||||
// Backend stores steamId as a primitive long; Steam IDs are positive 64-bit integers.
|
||||
const steamIdField = z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((v) => (typeof v === 'string' ? v.trim() : String(v)))
|
||||
.refine((v) => v.length > 0, {message: 'VALIDATION.STEAM_ID_REQUIRED'})
|
||||
.refine((v) => /^\d+$/.test(v), {message: 'VALIDATION.STEAM_ID_INVALID'})
|
||||
.transform((v) => Number(v))
|
||||
.refine((n) => Number.isFinite(n) && n > 0, {message: 'VALIDATION.STEAM_ID_INVALID'});
|
||||
|
||||
export const registrationSchema = z
|
||||
.object({
|
||||
email: emailField,
|
||||
password: passwordField,
|
||||
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
|
||||
inviteCode: inviteCodeField,
|
||||
steamId: steamIdField,
|
||||
})
|
||||
.refine((d) => d.password === d.passwordConfirmation, {
|
||||
path: ['passwordConfirmation'],
|
||||
message: 'VALIDATION.PASSWORD_MISMATCH',
|
||||
});
|
||||
|
||||
export const settingsSchema = z.object({
|
||||
useDarkTheme: z.boolean(),
|
||||
language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}),
|
||||
steamId: steamIdField,
|
||||
});
|
||||
Reference in new issue
Block a user