feat(profile): public profile page and the switch in settings

/u/<name> shows a public profile without login. Settings get the switch and
the link; every settings save now sends the other saved values, and removing
the display name also makes the profile private.
This commit is contained in:
lobo committed 2026-09-24 14:18:32 +02:00
1 parent aa5124ea1e
commit fd343da4be
7 files changed
+418 -18

No files matched your search

+64 -16
View File
@@ -4,7 +4,7 @@ import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {Gamepad2, Loader2, Save, SlidersHorizontal, UserRound} from 'lucide-react';
import {Copy, Gamepad2, Loader2, Save, SlidersHorizontal, UserRound} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
@@ -141,36 +141,56 @@ export default function SettingsPage() {
}
};
// 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({
useDarkTheme: next,
language: i18n.language,
steamId: profile?.steamId ?? null,
displayName: profile?.displayName ?? null,
});
await persistSettings({...savedSettings(), useDarkTheme: next});
};
const handleLanguageChange = async (next) => {
setLanguage(next); // apply on the fly
await persistSettings({
useDarkTheme: isDarkMode,
language: next,
steamId: profile?.steamId ?? null,
displayName: profile?.displayName ?? null,
});
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({
useDarkTheme: isDarkMode,
language: i18n.language,
...savedSettings(),
steamId: values.steamId,
displayName: values.displayName,
publicProfile,
});
if (result.ok) {
setProfile((p) => (p ? {...p, steamId: values.steamId, displayName: values.displayName} : p));
setProfile((p) => (p ? {
...p, steamId: values.steamId, displayName: values.displayName, settings: {...p.settings, publicProfile},
} : p));
}
};
@@ -189,6 +209,11 @@ export default function SettingsPage() {
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],
@@ -275,6 +300,29 @@ export default function SettingsPage() {
{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>