Public Access
The token is no longer stored; a tbp_session flag remembers the login. apiFetch renews the session once on a 401 and sends the request again. Parallel requests and other tabs share one refresh, because Auth revokes the session when a refresh token is used twice. Only a 401 from the refresh ends the session; a 502 or no network shows the normal error.
421 lines
20 KiB
React
421 lines
20 KiB
React
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 {Copy, Gamepad2, ImageOff, Loader2, Palette, RotateCcw, 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';
|
|
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
|
import {ACCENTS, CORNERS} from '../utils/appearance.js';
|
|
import {coverUrl} from '../utils/games.js';
|
|
import {cn} from '@/lib/utils';
|
|
import {apiFetch} from '../utils/api.js';
|
|
import {hasSession, markSession} from '../utils/auth.js';
|
|
|
|
const BACKEND_ERROR_KEY = {
|
|
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
|
|
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
|
|
};
|
|
|
|
const SUPPORTED_LANGUAGES = ['en', 'de'];
|
|
|
|
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 expireSession = useSessionExpired();
|
|
const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
|
|
const saveAppearance = useSaveAppearance();
|
|
|
|
const [profile, setProfile] = useState(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState(null);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
reset,
|
|
formState: {errors, isSubmitting},
|
|
} = useForm({
|
|
resolver: zodResolver(settingsSchema),
|
|
defaultValues: {useDarkTheme: true, language: 'en', steamId: '', displayName: ''},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!hasSession()) {
|
|
navigate('/login');
|
|
return;
|
|
}
|
|
|
|
const load = async () => {
|
|
try {
|
|
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
expireSession();
|
|
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 ?? '',
|
|
displayName: data.displayName ?? '',
|
|
});
|
|
} catch (err) {
|
|
console.error('Settings load error:', err);
|
|
setLoadError(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
load();
|
|
}, [navigate, expireSession, reset, t]);
|
|
|
|
const persistSettings = async (payload) => {
|
|
if (!hasSession()) {
|
|
navigate('/login');
|
|
return {ok: false};
|
|
}
|
|
|
|
try {
|
|
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
expireSession();
|
|
return {ok: false};
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
const mappedKey = BACKEND_ERROR_KEY[data.code];
|
|
toast.error(mappedKey ? t(mappedKey) : data.message || t('SETTINGS.ERROR_SAVE'));
|
|
return {ok: false};
|
|
}
|
|
|
|
// Auth set a new access cookie for the new Steam ID; the new stamp makes the header load it again.
|
|
markSession();
|
|
toast.success(t('SETTINGS.SAVED'));
|
|
return {ok: true};
|
|
} catch (err) {
|
|
console.error('Settings save error:', err);
|
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
return {ok: false};
|
|
}
|
|
};
|
|
|
|
// The backend replaces all settings, so every save sends the saved values of the fields it does not change.
|
|
const savedSettings = () => ({
|
|
useDarkTheme: isDarkMode,
|
|
language: i18n.language,
|
|
steamId: profile?.steamId ?? null,
|
|
displayName: profile?.displayName ?? null,
|
|
publicProfile: !!profile?.settings?.publicProfile,
|
|
});
|
|
|
|
const handleDarkThemeToggle = async () => {
|
|
const next = !isDarkMode;
|
|
setDarkMode(next); // apply on the fly
|
|
await persistSettings({...savedSettings(), useDarkTheme: next});
|
|
};
|
|
|
|
const handleLanguageChange = async (next) => {
|
|
setLanguage(next); // apply on the fly
|
|
await persistSettings({...savedSettings(), language: next});
|
|
};
|
|
|
|
const handlePublicProfileToggle = async () => {
|
|
const next = !profile?.settings?.publicProfile;
|
|
const result = await persistSettings({...savedSettings(), publicProfile: next});
|
|
if (result.ok) {
|
|
setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
|
|
}
|
|
};
|
|
|
|
const copyProfileLink = async (link) => {
|
|
try {
|
|
await navigator.clipboard.writeText(link);
|
|
toast.success(t('SETTINGS.PUBLIC_PROFILE_COPIED'));
|
|
} catch {
|
|
toast.error(link);
|
|
}
|
|
};
|
|
|
|
const onSubmitProfile = async (values) => {
|
|
// Without a name the profile has no link, so removing the name also makes it private.
|
|
const publicProfile = savedSettings().publicProfile && !!values.displayName;
|
|
const result = await persistSettings({
|
|
...savedSettings(),
|
|
steamId: values.steamId,
|
|
displayName: values.displayName,
|
|
publicProfile,
|
|
});
|
|
if (result.ok) {
|
|
setProfile((p) => (p ? {
|
|
...p, steamId: values.steamId, displayName: values.displayName, settings: {...p.settings, publicProfile},
|
|
} : p));
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<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="py-24 text-center text-destructive">{loadError}</div>;
|
|
}
|
|
|
|
const publicProfile = !!profile?.settings?.publicProfile;
|
|
const profileLink = profile?.displayName
|
|
? `${window.location.origin}/u/${encodeURIComponent(profile.displayName)}`
|
|
: null;
|
|
|
|
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="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>
|
|
|
|
<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>
|
|
))}
|
|
</dl>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<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) => (
|
|
<SelectItem key={lang} value={lang}>
|
|
{t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<Label id="accent-label">{t('SETTINGS.ACCENT')}</Label>
|
|
<div role="group" aria-labelledby="accent-label" className="flex flex-wrap gap-2">
|
|
{ACCENTS.map((accent) => (
|
|
<button key={accent} type="button" data-accent={accent}
|
|
aria-label={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
|
|
title={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
|
|
aria-pressed={appearance.accent === accent}
|
|
onClick={() => saveAppearance({accent})}
|
|
className={cn('size-8 rounded-full bg-primary ring-offset-2 ring-offset-background transition hover:scale-110',
|
|
appearance.accent === accent && 'ring-2 ring-foreground')}/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<Label id="corners-label">{t('SETTINGS.CORNERS')}</Label>
|
|
<div role="group" aria-labelledby="corners-label" className="flex gap-2">
|
|
{CORNERS.map((corners) => (
|
|
<Button key={corners} type="button" size="sm" data-corners={corners}
|
|
variant={appearance.corners === corners ? 'default' : 'outline'}
|
|
aria-pressed={appearance.corners === corners}
|
|
onClick={() => saveAppearance({corners})}>
|
|
{t(`SETTINGS.CORNER_OPTIONS.${corners.toUpperCase()}`)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="wideLayout">{t('SETTINGS.WIDE_LAYOUT')}</Label>
|
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.WIDE_LAYOUT_HINT')}</p>
|
|
</div>
|
|
<Switch id="wideLayout" checked={appearance.wideLayout}
|
|
onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
|
|
{!appearance.backgroundAppid && (
|
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.BACKGROUND_HINT')}</p>
|
|
)}
|
|
</div>
|
|
{appearance.backgroundAppid && (
|
|
<div className="flex items-center gap-2">
|
|
<img src={coverUrl(appearance.backgroundAppid)} alt=""
|
|
onError={(event) => { event.currentTarget.hidden = true; }}
|
|
className="h-10 w-20 rounded-md object-cover"/>
|
|
<Button type="button" variant="outline" size="sm"
|
|
aria-describedby="background-label"
|
|
onClick={() => saveAppearance({backgroundAppid: null})}>
|
|
<ImageOff/>{t('SETTINGS.BACKGROUND_REMOVE')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<Label id="banner-label" htmlFor="showBanner">{t('SETTINGS.BANNER')}</Label>
|
|
{appearance.showBanner && !appearance.bannerAppid && (
|
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.BANNER_HINT')}</p>
|
|
)}
|
|
</div>
|
|
<Switch id="showBanner" checked={appearance.showBanner}
|
|
onCheckedChange={(showBanner) => saveAppearance({showBanner})}/>
|
|
</div>
|
|
{appearance.showBanner && appearance.bannerAppid && (
|
|
<div className="flex items-center justify-end gap-2">
|
|
<img src={coverUrl(appearance.bannerAppid)} alt=""
|
|
onError={(event) => { event.currentTarget.hidden = true; }}
|
|
className="h-10 w-20 rounded-md object-cover"/>
|
|
<Button type="button" variant="outline" size="sm"
|
|
aria-describedby="banner-label"
|
|
onClick={() => saveAppearance({bannerAppid: null})}>
|
|
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.PROFILE')}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit(onSubmitProfile)} className="space-y-4" noValidate>
|
|
<FormField id="displayName" label={t('SETTINGS.DISPLAY_NAME')} error={errors.displayName} t={t}>
|
|
<Input
|
|
id="displayName"
|
|
type="text"
|
|
maxLength={24}
|
|
placeholder={t('SETTINGS.DISPLAY_NAME_PLACEHOLDER')}
|
|
aria-invalid={!!errors.displayName}
|
|
{...register('displayName')}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.DISPLAY_NAME_HINT')}</p>
|
|
</FormField>
|
|
<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_PROFILE')}
|
|
</Button>
|
|
</form>
|
|
|
|
<div className="mt-6 space-y-3 border-t pt-6">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="publicProfile">{t('SETTINGS.PUBLIC_PROFILE')}</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{profileLink ? t('SETTINGS.PUBLIC_PROFILE_HINT') : t('SETTINGS.PUBLIC_PROFILE_NEEDS_NAME')}
|
|
</p>
|
|
</div>
|
|
<Switch id="publicProfile" checked={publicProfile} disabled={!profileLink}
|
|
onCheckedChange={handlePublicProfileToggle}/>
|
|
</div>
|
|
{publicProfile && profileLink && (
|
|
<div className="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
|
|
<a href={profileLink} target="_blank" rel="noopener noreferrer"
|
|
className="min-w-0 flex-1 truncate font-mono text-primary hover:underline">{profileLink}</a>
|
|
<Button type="button" variant="ghost" size="icon" className="size-7"
|
|
aria-label={t('SETTINGS.PUBLIC_PROFILE_COPY')} onClick={() => copyProfileLink(profileLink)}>
|
|
<Copy/>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|