Public Access
feat(ui): modern shadcn/ui theme with violet accent across all pages
Add shadcn/ui (Radix + Tailwind 4) components, zinc/violet theme tokens and self-hosted Inter. New header with user menu, Steam cover art, skeletons and toasts. Replace @mdi/font with lucide-react. Fix: top-owned list on the landing page only showed with top-played entries; numbers use the app language.
This commit is contained in:
1 parent
76a9ac3e6f
commit
e480369a98
43 files changed
+3804
-979
No files matched your search
+90
-160
@@ -4,6 +4,17 @@ import {useTranslation} from 'react-i18next';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
|
||||
import {Gamepad2, Loader2, Save, SlidersHorizontal, UserRound} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import FormField from '../components/FormField.jsx';
|
||||
import {settingsSchema} from '../utils/validation.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
|
||||
@@ -13,13 +24,6 @@ const BACKEND_ERROR_KEY = {
|
||||
|
||||
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 {
|
||||
@@ -37,7 +41,6 @@ export default function SettingsPage() {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [feedback, setFeedback] = useState({message: '', isError: false});
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -121,20 +124,17 @@ export default function SettingsPage() {
|
||||
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,
|
||||
});
|
||||
toast.error(mappedKey ? t(mappedKey) : data.message || t('SETTINGS.ERROR_SAVE'));
|
||||
return {ok: false};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
localStorage.setItem('tbp_jwt', data.token); // Core reads the steamId from the token
|
||||
setFeedback({message: t('SETTINGS.SAVED'), isError: false});
|
||||
toast.success(t('SETTINGS.SAVED'));
|
||||
return {ok: true};
|
||||
} catch (err) {
|
||||
console.error('Settings save error:', err);
|
||||
setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
return {ok: false};
|
||||
}
|
||||
};
|
||||
@@ -149,8 +149,7 @@ export default function SettingsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleLanguageChange = async (event) => {
|
||||
const next = event.target.value;
|
||||
const handleLanguageChange = async (next) => {
|
||||
setLanguage(next); // apply on the fly
|
||||
await persistSettings({
|
||||
useDarkTheme: isDarkMode,
|
||||
@@ -170,167 +169,98 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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 className="w-full max-w-2xl mx-auto p-4 md:p-8 space-y-6">
|
||||
<Skeleton className="h-9 w-48"/>
|
||||
<Skeleton className="h-56 rounded-xl"/>
|
||||
<Skeleton className="h-40 rounded-xl"/>
|
||||
<Skeleton className="h-44 rounded-xl"/>
|
||||
</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="py-24 text-center text-destructive">{loadError}</div>;
|
||||
}
|
||||
|
||||
const accountRows = [
|
||||
[t('SETTINGS.EMAIL'), profile?.email],
|
||||
[t('SETTINGS.ROLE'), profile?.role],
|
||||
[t('SETTINGS.CREATED_DATE'), formatDate(profile?.createdDate, i18n.language)],
|
||||
[t('SETTINGS.LAST_LOGIN'), formatDate(profile?.lastLogin, i18n.language)],
|
||||
];
|
||||
|
||||
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>
|
||||
<div className="w-full max-w-2xl mx-auto p-4 md:p-8 space-y-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{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}/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><UserRound className="size-4 text-primary"/>{t('SETTINGS.ACCOUNT')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="divide-y">
|
||||
{accountRows.map(([label, value]) => (
|
||||
<div key={label} className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<dt className="text-sm text-muted-foreground">{label}</dt>
|
||||
<dd className="text-sm font-medium break-all">{value || '—'}</dd>
|
||||
</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>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><SlidersHorizontal className="size-4 text-primary"/>{t('SETTINGS.PREFERENCES')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="useDarkTheme">{t('SETTINGS.DARK_THEME')}</Label>
|
||||
<Switch id="useDarkTheme" checked={isDarkMode} onCheckedChange={handleDarkThemeToggle}/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="language">{t('SETTINGS.LANGUAGE')}</Label>
|
||||
<Select value={i18n.language} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger id="language" className="w-40">
|
||||
<SelectValue/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
<SelectItem key={lang} value={lang}>
|
||||
{t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.STEAM')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmitSteamId)} className="space-y-4" noValidate>
|
||||
<FormField id="steamId" label={t('SETTINGS.STEAM_ID')} error={errors.steamId} t={t}>
|
||||
<Input
|
||||
id="steamId"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
aria-invalid={!!errors.steamId}
|
||||
{...register('steamId')}
|
||||
/>
|
||||
</FormField>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="animate-spin"/> : <Save/>}
|
||||
{t('SETTINGS.SAVE_STEAM_ID')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user