Public Access
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.
This commit is contained in:
1 parent
6da612bebd
commit
28ff6eae52
7 files changed
+424
-321
No files matched your search
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<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">
|
||||||
|
<AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
|
||||||
|
<CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<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>
|
||||||
|
{LANGUAGES.map((lang) => (
|
||||||
|
<SelectItem key={lang} value={lang}>
|
||||||
|
{t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="favoritePlatform">{t('SETTINGS.FAVORITE_PLATFORM')}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.FAVORITE_PLATFORM_HINT')}</p>
|
||||||
|
</div>
|
||||||
|
<Select value={profile?.settings?.favoritePlatform ?? NO_FAVORITE} onValueChange={handleFavoritePlatformChange}>
|
||||||
|
<SelectTrigger id="favoritePlatform" className="w-48">
|
||||||
|
<SelectValue/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_FAVORITE}>{t('SETTINGS.FAVORITE_PLATFORM_NONE')}</SelectItem>
|
||||||
|
{FAVORITE_PLATFORMS.map((platform) => (
|
||||||
|
<SelectItem key={platform.value} value={platform.value}>{platform.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<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={MAX_DISPLAY_NAME_LENGTH}
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{publicProfile && profileLink && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_TILES_HINT')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 border-t pt-6">
|
||||||
|
<ProfileStyleSettings style={normalizeProfileStyle(profile?.settings?.profileStyle)}
|
||||||
|
disabled={!publicProfile || savingProfileStyle}
|
||||||
|
onChange={handleProfileStyleChange}/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
+13
-321
@@ -1,42 +1,19 @@
|
|||||||
import {useEffect, useRef, useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
import {useSearchParams} from 'react-router-dom';
|
import {useSearchParams} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
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 {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 {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 BuildVersionCard from '../components/BuildVersionCard.jsx';
|
||||||
import DeleteAccountCard from '../components/DeleteAccountCard.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 SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||||
import {settingsSchema} from '../utils/validation.js';
|
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
|
||||||
import {useCurrentUserContext} from '../context/CurrentUserContext.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 {refreshSession} from '../utils/api.js';
|
||||||
import {markSession} from '../utils/auth.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.
|
// The `steam` query parameter Auth adds when "Sign in through Steam" sends the user back.
|
||||||
const STEAM_LINK_TOASTS = {
|
const STEAM_LINK_TOASTS = {
|
||||||
@@ -46,43 +23,17 @@ const STEAM_LINK_TOASTS = {
|
|||||||
failed: {show: toast.error, key: 'SETTINGS.STEAM_LINK.FAILED'},
|
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() {
|
export default function SettingsPage() {
|
||||||
const {t, i18n} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
|
|
||||||
const saveAppearance = useSaveAppearance();
|
|
||||||
const saveStyle = useSaveProfileStyle();
|
|
||||||
const runSave = useSaveResponse();
|
|
||||||
|
|
||||||
const {user: profile, updateUser: updateProfile, reload: reloadUser} = useCurrentUserContext();
|
const {user: profile, updateUser: updateProfile, reload: reloadUser} = useCurrentUserContext();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState(null);
|
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();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
// Read once: removing the parameter below must not load the profile again.
|
// Read once: removing the parameter below must not load the profile again.
|
||||||
const [steamOutcome] = useState(() => searchParams.get('steam'));
|
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.
|
// A language change gives a new `t` and runs the effect again; the toast shows once.
|
||||||
const steamToastShown = useRef(false);
|
const steamToastShown = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,89 +55,13 @@ export default function SettingsPage() {
|
|||||||
markSession();
|
markSession();
|
||||||
}
|
}
|
||||||
const {ok, status, data} = await reloadUser();
|
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');
|
else if (status !== 401) setLoadError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'INFO.ERROR.LOAD_PROFILE');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}, [reloadUser, reset, steamOutcome]);
|
}, [reloadUser, 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}}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -203,203 +78,20 @@ export default function SettingsPage() {
|
|||||||
return <div className="py-24 text-center text-destructive">{t(loadError)}</div>;
|
return <div className="py-24 text-center text-destructive">{t(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 (
|
return (
|
||||||
<div className="w-full max-w-2xl mx-auto p-4 md:p-8 space-y-6">
|
<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>
|
<h1 className="text-3xl font-bold tracking-tight">{t('SETTINGS.TITLE')}</h1>
|
||||||
|
|
||||||
<Card>
|
<AccountInfoCard/>
|
||||||
<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>
|
<PreferencesCard/>
|
||||||
<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>
|
|
||||||
{LANGUAGES.map((lang) => (
|
|
||||||
<SelectItem key={lang} value={lang}>
|
|
||||||
{t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="favoritePlatform">{t('SETTINGS.FAVORITE_PLATFORM')}</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">{t('SETTINGS.FAVORITE_PLATFORM_HINT')}</p>
|
|
||||||
</div>
|
|
||||||
<Select value={profile?.settings?.favoritePlatform ?? NO_FAVORITE} onValueChange={handleFavoritePlatformChange}>
|
|
||||||
<SelectTrigger id="favoritePlatform" className="w-48">
|
|
||||||
<SelectValue/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={NO_FAVORITE}>{t('SETTINGS.FAVORITE_PLATFORM_NONE')}</SelectItem>
|
|
||||||
{FAVORITE_PLATFORMS.map((platform) => (
|
|
||||||
<SelectItem key={platform.value} value={platform.value}>{platform.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<AppearanceCard/>
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
|
|
||||||
<CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified}
|
<SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified}
|
||||||
onUnlinked={() => updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
|
onUnlinked={() => updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
|
||||||
|
|
||||||
<Card>
|
<ProfileCard displayName={displayName}/>
|
||||||
<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={MAX_DISPLAY_NAME_LENGTH}
|
|
||||||
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>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{publicProfile && profileLink && (
|
|
||||||
<p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_TILES_HINT')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 border-t pt-6">
|
|
||||||
<ProfileStyleSettings style={normalizeProfileStyle(profile?.settings?.profileStyle)}
|
|
||||||
disabled={!publicProfile || savingProfileStyle}
|
|
||||||
onChange={handleProfileStyleChange}/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<BuildVersionCard/>
|
<BuildVersionCard/>
|
||||||
|
|
||||||
|
|||||||
@@ -265,3 +265,28 @@ describe('SettingsPage session', () => {
|
|||||||
expect(screen.queryByText('INFO.ERROR.LOAD_PROFILE')).toBeNull();
|
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(<SettingsPage/>);
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user