diff --git a/src/components/AccountInfoCard.jsx b/src/components/AccountInfoCard.jsx
new file mode 100644
index 0000000..4747ed8
--- /dev/null
+++ b/src/components/AccountInfoCard.jsx
@@ -0,0 +1,43 @@
+import {useTranslation} from 'react-i18next';
+import {UserRound} from 'lucide-react';
+
+import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
+import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
+
+const formatDate = (value, locale) => {
+ if (!value) return '—';
+ try {
+ return new Date(value).toLocaleString(locale);
+ } catch {
+ return value;
+ }
+};
+
+export default function AccountInfoCard() {
+ const {t, i18n} = useTranslation();
+ const {user: profile} = useCurrentUserContext();
+ 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 (
+
+
+ {t('SETTINGS.ACCOUNT')}
+
+
+
+ {accountRows.map(([label, value]) => (
+
+
- {label}
+ - {value || '—'}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/AppearanceCard.jsx b/src/components/AppearanceCard.jsx
new file mode 100644
index 0000000..238bae3
--- /dev/null
+++ b/src/components/AppearanceCard.jsx
@@ -0,0 +1,81 @@
+import {useTranslation} from 'react-i18next';
+import {ImageOff, Palette, RotateCcw} from 'lucide-react';
+
+import {Button} from '@/components/ui/button';
+import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
+import {Label} from '@/components/ui/label';
+import {Switch} from '@/components/ui/switch';
+import {AccentPicker, CornersPicker} from './StylePickers.jsx';
+import {usePreferences} from '../context/PreferencesContext.jsx';
+import useSaveAppearance from '../hooks/useSaveAppearance.js';
+import {coverUrl} from '../utils/games.js';
+
+export default function AppearanceCard() {
+ const {t} = useTranslation();
+ const {appearance} = usePreferences();
+ const saveAppearance = useSaveAppearance();
+
+ return (
+
+
+ {t('SETTINGS.APPEARANCE')}
+
+
+ saveAppearance({accent})}/>
+ saveAppearance({corners})}/>
+
+
+
+
{t('SETTINGS.WIDE_LAYOUT_HINT')}
+
+
saveAppearance({wideLayout})}/>
+
+
+
+
+ {!appearance.backgroundAppid && (
+
{t('SETTINGS.BACKGROUND_HINT')}
+ )}
+
+ {appearance.backgroundAppid && (
+
+
})
{ event.currentTarget.hidden = true; }}
+ className="h-10 w-20 rounded-md object-cover"/>
+
+
+ )}
+
+
+
+
+
+ {appearance.showBanner && !appearance.bannerAppid && (
+
{t('SETTINGS.BANNER_HINT')}
+ )}
+
+
saveAppearance({showBanner})}/>
+
+ {appearance.showBanner && appearance.bannerAppid && (
+
+
})
{ event.currentTarget.hidden = true; }}
+ className="h-10 w-20 rounded-md object-cover"/>
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/PreferencesCard.jsx b/src/components/PreferencesCard.jsx
new file mode 100644
index 0000000..e2dfb06
--- /dev/null
+++ b/src/components/PreferencesCard.jsx
@@ -0,0 +1,89 @@
+import {useTranslation} from 'react-i18next';
+import {SlidersHorizontal} from 'lucide-react';
+import {toast} from 'sonner';
+
+import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
+import {Label} from '@/components/ui/label';
+import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
+import {Switch} from '@/components/ui/switch';
+import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
+import {usePreferences} from '../context/PreferencesContext.jsx';
+import usePersistSettings from '../hooks/usePersistSettings.js';
+import useSaveResponse from '../hooks/useSaveResponse.js';
+import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
+import {LANGUAGES, saveSettings} from '../utils/settings.js';
+
+// Radix Select has no empty value.
+const NO_FAVORITE = 'NONE';
+
+export default function PreferencesCard() {
+ const {t, i18n} = useTranslation();
+ const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
+ const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
+ const persistSettings = usePersistSettings();
+ const runSave = useSaveResponse();
+
+ const handleDarkThemeToggle = async () => {
+ const next = !isDarkMode;
+ setDarkMode(next); // apply on the fly
+ await persistSettings(() => saveSettings({useDarkTheme: next}));
+ };
+
+ const handleLanguageChange = async (next) => {
+ await persistSettings(() => setLanguage(next));
+ };
+
+ const handleFavoritePlatformChange = async (value) => {
+ const platform = value === NO_FAVORITE ? null : value;
+ if (await runSave(() => saveFavoritePlatform(platform))) {
+ updateProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}}));
+ toast.success(t('SETTINGS.SAVED'));
+ }
+ };
+
+ return (
+
+
+ {t('SETTINGS.PREFERENCES')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{t('SETTINGS.FAVORITE_PLATFORM_HINT')}
+
+
+
+
+
+ );
+}
diff --git a/src/components/ProfileCard.jsx b/src/components/ProfileCard.jsx
new file mode 100644
index 0000000..0fa5f36
--- /dev/null
+++ b/src/components/ProfileCard.jsx
@@ -0,0 +1,139 @@
+import {useState} from 'react';
+import {useTranslation} from 'react-i18next';
+import {useForm} from 'react-hook-form';
+import {zodResolver} from '@hookform/resolvers/zod';
+import {Copy, Gamepad2, Loader2, Save} 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 {Switch} from '@/components/ui/switch';
+import FormField from './FormField.jsx';
+import ProfileStyleSettings from './ProfileStyleSettings.jsx';
+import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
+import usePersistSettings from '../hooks/usePersistSettings.js';
+import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
+import {normalizeProfileStyle} from '../utils/profileStyle.js';
+import {saveSettings} from '../utils/settings.js';
+import {settingsSchema} from '../utils/validation.js';
+import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
+
+/** `displayName` fills the name field once, at mount. */
+export default function ProfileCard({displayName}) {
+ const {t} = useTranslation();
+ const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
+ const persistSettings = usePersistSettings();
+ const saveStyle = useSaveProfileStyle();
+ const [savingProfileStyle, setSavingProfileStyle] = useState(false);
+
+ const {
+ register,
+ handleSubmit,
+ formState: {errors, isSubmitting},
+ } = useForm({
+ resolver: zodResolver(settingsSchema),
+ defaultValues: {displayName},
+ });
+
+ const handlePublicProfileToggle = async () => {
+ const next = !profile?.settings?.publicProfile;
+ const result = await persistSettings(() => saveSettings({publicProfile: next}));
+ if (result.ok) {
+ updateProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
+ }
+ };
+
+ // Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other.
+ const handleProfileStyleChange = async (changes) => {
+ const previous = normalizeProfileStyle(profile?.settings?.profileStyle);
+ const showProfileStyle = (next) => updateProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}}));
+ showProfileStyle({...previous, ...changes});
+ setSavingProfileStyle(true);
+ showProfileStyle(await saveStyle(changes, previous) ?? previous);
+ setSavingProfileStyle(false);
+ };
+
+ 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 = !!profile?.settings?.publicProfile && !!values.displayName;
+ const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile}));
+ if (result.ok) {
+ updateProfile((p) => ({...p, displayName: values.displayName, settings: {...p.settings, publicProfile}}));
+ }
+ };
+
+ const publicProfile = !!profile?.settings?.publicProfile;
+ const profileLink = profile?.displayName
+ ? `${window.location.origin}/u/${encodeURIComponent(profile.displayName)}`
+ : null;
+
+ return (
+
+
+ {t('SETTINGS.PROFILE')}
+
+
+
+
+
+
+
+
+
+ {profileLink ? t('SETTINGS.PUBLIC_PROFILE_HINT') : t('SETTINGS.PUBLIC_PROFILE_NEEDS_NAME')}
+
+
+
+
+ {publicProfile && profileLink && (
+
+ )}
+ {publicProfile && profileLink && (
+
{t('SETTINGS.PROFILE_TILES_HINT')}
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/hooks/usePersistSettings.js b/src/hooks/usePersistSettings.js
new file mode 100644
index 0000000..35b4275
--- /dev/null
+++ b/src/hooks/usePersistSettings.js
@@ -0,0 +1,34 @@
+import {useCallback} from 'react';
+import {useTranslation} from 'react-i18next';
+import {toast} from 'sonner';
+
+const BACKEND_ERROR_KEY = {
+ DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
+};
+
+/** Like `useSaveResponse`, but maps backend error codes, shows "saved" and resolves to `{ok}`. */
+export default function usePersistSettings() {
+ const {t} = useTranslation();
+
+ return useCallback(async (send) => {
+ try {
+ const response = await send();
+
+ if (response.status === 401) return {ok: false};
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ const mappedKey = BACKEND_ERROR_KEY[data.code];
+ toast.error(t(mappedKey ?? 'SETTINGS.ERROR_SAVE'));
+ return {ok: false};
+ }
+
+ 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};
+ }
+ }, [t]);
+}
diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx
index 5df750d..376da74 100644
--- a/src/pages/SettingsPage.jsx
+++ b/src/pages/SettingsPage.jsx
@@ -1,42 +1,19 @@
import {useEffect, useRef, useState} from 'react';
import {useSearchParams} 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 AccountInfoCard from '../components/AccountInfoCard.jsx';
+import AppearanceCard from '../components/AppearanceCard.jsx';
import BuildVersionCard from '../components/BuildVersionCard.jsx';
import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
-import FormField from '../components/FormField.jsx';
+import PreferencesCard from '../components/PreferencesCard.jsx';
+import ProfileCard from '../components/ProfileCard.jsx';
import SteamAccountCard from '../components/SteamAccountCard.jsx';
-import {settingsSchema} from '../utils/validation.js';
-import {usePreferences} from '../context/PreferencesContext.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
-import useSaveAppearance from '../hooks/useSaveAppearance.js';
-import {AccentPicker, CornersPicker} from '../components/StylePickers.jsx';
-import ProfileStyleSettings from '../components/ProfileStyleSettings.jsx';
-import {normalizeProfileStyle} from '../utils/profileStyle.js';
-import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
-import useSaveResponse from '../hooks/useSaveResponse.js';
-import {coverUrl} from '../utils/games.js';
import {refreshSession} from '../utils/api.js';
import {markSession} from '../utils/auth.js';
-import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
-import {LANGUAGES, saveSettings} from '../utils/settings.js';
-import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
-
-const BACKEND_ERROR_KEY = {
- DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
-};
// The `steam` query parameter Auth adds when "Sign in through Steam" sends the user back.
const STEAM_LINK_TOASTS = {
@@ -46,43 +23,17 @@ const STEAM_LINK_TOASTS = {
failed: {show: toast.error, key: 'SETTINGS.STEAM_LINK.FAILED'},
};
-// Radix Select has no empty value.
-const NO_FAVORITE = 'NONE';
-
-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 {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
- const saveAppearance = useSaveAppearance();
- const saveStyle = useSaveProfileStyle();
- const runSave = useSaveResponse();
-
+ const {t} = useTranslation();
const {user: profile, updateUser: updateProfile, reload: reloadUser} = useCurrentUserContext();
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
- const [savingProfileStyle, setSavingProfileStyle] = useState(false);
+ // From this page's own load: the context user can still be empty when the form mounts.
+ const [displayName, setDisplayName] = useState('');
const [searchParams, setSearchParams] = useSearchParams();
// Read once: removing the parameter below must not load the profile again.
const [steamOutcome] = useState(() => searchParams.get('steam'));
- const {
- register,
- handleSubmit,
- reset,
- formState: {errors, isSubmitting},
- } = useForm({
- resolver: zodResolver(settingsSchema),
- defaultValues: {displayName: ''},
- });
-
// A language change gives a new `t` and runs the effect again; the toast shows once.
const steamToastShown = useRef(false);
useEffect(() => {
@@ -104,89 +55,13 @@ export default function SettingsPage() {
markSession();
}
const {ok, status, data} = await reloadUser();
- if (ok) reset({displayName: data.displayName ?? ''});
+ if (ok) setDisplayName(data.displayName ?? '');
else if (status !== 401) setLoadError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'INFO.ERROR.LOAD_PROFILE');
setIsLoading(false);
};
load();
- }, [reloadUser, reset, steamOutcome]);
-
- const persistSettings = async (send) => {
- try {
- const response = await send();
-
- if (response.status === 401) return {ok: false};
-
- if (!response.ok) {
- const data = await response.json().catch(() => ({}));
- const mappedKey = BACKEND_ERROR_KEY[data.code];
- toast.error(t(mappedKey ?? 'SETTINGS.ERROR_SAVE'));
- return {ok: false};
- }
-
- 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};
- }
- };
-
- const handleDarkThemeToggle = async () => {
- const next = !isDarkMode;
- setDarkMode(next); // apply on the fly
- await persistSettings(() => saveSettings({useDarkTheme: next}));
- };
-
- const handleLanguageChange = async (next) => {
- await persistSettings(() => setLanguage(next));
- };
-
- const handleFavoritePlatformChange = async (value) => {
- const platform = value === NO_FAVORITE ? null : value;
- if (await runSave(() => saveFavoritePlatform(platform))) {
- updateProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}}));
- toast.success(t('SETTINGS.SAVED'));
- }
- };
-
- const handlePublicProfileToggle = async () => {
- const next = !profile?.settings?.publicProfile;
- const result = await persistSettings(() => saveSettings({publicProfile: next}));
- if (result.ok) {
- updateProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
- }
- };
-
- // Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other.
- const handleProfileStyleChange = async (changes) => {
- const previous = normalizeProfileStyle(profile?.settings?.profileStyle);
- const showProfileStyle = (next) => updateProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}}));
- showProfileStyle({...previous, ...changes});
- setSavingProfileStyle(true);
- showProfileStyle(await saveStyle(changes, previous) ?? previous);
- setSavingProfileStyle(false);
- };
-
- 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 = !!profile?.settings?.publicProfile && !!values.displayName;
- const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile}));
- if (result.ok) {
- updateProfile((p) => ({...p, displayName: values.displayName, settings: {...p.settings, publicProfile}}));
- }
- };
+ }, [reloadUser, steamOutcome]);
if (isLoading) {
return (
@@ -203,203 +78,20 @@ export default function SettingsPage() {
return {t(loadError)}
;
}
- 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 (
{t('SETTINGS.TITLE')}
-
-
- {t('SETTINGS.ACCOUNT')}
-
-
-
- {accountRows.map(([label, value]) => (
-
-
- {label}
- - {value || '—'}
-
- ))}
-
-
-
+
-
-
- {t('SETTINGS.PREFERENCES')}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{t('SETTINGS.FAVORITE_PLATFORM_HINT')}
-
-
-
-
-
+
-
-
- {t('SETTINGS.APPEARANCE')}
-
-
- saveAppearance({accent})}/>
- saveAppearance({corners})}/>
-
-
-
-
{t('SETTINGS.WIDE_LAYOUT_HINT')}
-
-
saveAppearance({wideLayout})}/>
-
-
-
-
- {!appearance.backgroundAppid && (
-
{t('SETTINGS.BACKGROUND_HINT')}
- )}
-
- {appearance.backgroundAppid && (
-
-
})
{ event.currentTarget.hidden = true; }}
- className="h-10 w-20 rounded-md object-cover"/>
-
-
- )}
-
-
-
-
-
- {appearance.showBanner && !appearance.bannerAppid && (
-
{t('SETTINGS.BANNER_HINT')}
- )}
-
-
saveAppearance({showBanner})}/>
-
- {appearance.showBanner && appearance.bannerAppid && (
-
-
})
{ event.currentTarget.hidden = true; }}
- className="h-10 w-20 rounded-md object-cover"/>
-
-
- )}
-
-
-
+
updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
-
-
- {t('SETTINGS.PROFILE')}
-
-
-
-
-
-
-
-
-
- {profileLink ? t('SETTINGS.PUBLIC_PROFILE_HINT') : t('SETTINGS.PUBLIC_PROFILE_NEEDS_NAME')}
-
-
-
-
- {publicProfile && profileLink && (
-
- )}
- {publicProfile && profileLink && (
-
{t('SETTINGS.PROFILE_TILES_HINT')}
- )}
-
-
-
-
-
+
diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx
index 057f9e9..ff0b10f 100644
--- a/src/pages/SettingsPage.test.jsx
+++ b/src/pages/SettingsPage.test.jsx
@@ -265,3 +265,28 @@ describe('SettingsPage session', () => {
expect(screen.queryByText('INFO.ERROR.LOAD_PROFILE')).toBeNull();
});
});
+
+describe('SettingsPage load order', () => {
+ beforeEach(() => localStorage.setItem('tbp_session', '1'));
+
+ afterEach(() => {
+ cleanup();
+ localStorage.clear();
+ });
+
+ it('shows the display name from its own load when the two /me answers arrive one after the other', async () => {
+ const open = [];
+ fetchRoutes({
+ ...authRoutes(() => profile()),
+ 'GET /api/users/me': () => new Promise((resolve) => open.push(() => resolve(json(200, profile())))),
+ });
+ renderWithProviders();
+
+ await waitFor(() => expect(open).toHaveLength(2));
+ await act(async () => open.shift()());
+ await act(async () => open.shift()());
+
+ expect(await screen.findByText('a@b.de')).toBeTruthy();
+ expect(screen.getByLabelText('SETTINGS.DISPLAY_NAME').value).toBe('Lobo');
+ });
+});