From 28ff6eae529748e4c717df8be3fac2a105e4dad2 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 30 Sep 2026 08:46:51 +0200 Subject: [PATCH] refactor(settings): one card per section The name field takes its value from the page's own load, not from the user context: the context can still be empty when the form mounts. --- src/components/AccountInfoCard.jsx | 43 ++++ src/components/AppearanceCard.jsx | 81 +++++++ src/components/PreferencesCard.jsx | 89 ++++++++ src/components/ProfileCard.jsx | 139 ++++++++++++ src/hooks/usePersistSettings.js | 34 +++ src/pages/SettingsPage.jsx | 334 ++--------------------------- src/pages/SettingsPage.test.jsx | 25 +++ 7 files changed, 424 insertions(+), 321 deletions(-) create mode 100644 src/components/AccountInfoCard.jsx create mode 100644 src/components/AppearanceCard.jsx create mode 100644 src/components/PreferencesCard.jsx create mode 100644 src/components/ProfileCard.jsx create mode 100644 src/hooks/usePersistSettings.js 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')} + + +
+ + +

{t('SETTINGS.DISPLAY_NAME_HINT')}

+
+ +
+ +
+
+
+ +

+ {profileLink ? t('SETTINGS.PUBLIC_PROFILE_HINT') : t('SETTINGS.PUBLIC_PROFILE_NEEDS_NAME')} +

+
+ +
+ {publicProfile && profileLink && ( +
+ {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')} - - -
- - -

{t('SETTINGS.DISPLAY_NAME_HINT')}

-
- -
- -
-
-
- -

- {profileLink ? t('SETTINGS.PUBLIC_PROFILE_HINT') : t('SETTINGS.PUBLIC_PROFILE_NEEDS_NAME')} -

-
- -
- {publicProfile && profileLink && ( -
- {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'); + }); +});