Public Access
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:
1 parent
aa5124ea1e
commit
fd343da4be
7 files changed
+418
-18
No files matched your search
+64
-16
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user