feat(profile): let users design their public profile

This commit is contained in:
lobo committed 2026-09-28 14:20:14 +02:00
1 parent a5458b3f86
commit b55fcfec92
20 files changed
+674 -138

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
import {cn} from '@/lib/utils';
import HeroArt from './HeroArt.jsx';
/**
* The art of a game behind a card's content, or the accent gradient without one. Put it first in a card with
* `relative overflow-hidden`; the gradient keeps the text readable.
*/
export default function HeroBanner({appid}) {
return (
<>
<HeroArt key={appid} appid={appid} className="pointer-events-none absolute inset-0 size-full object-cover opacity-60"/>
<div className={cn('pointer-events-none absolute inset-0', appid
? 'bg-gradient-to-r from-card via-card/85 to-card/60'
: 'bg-gradient-to-br from-primary/15 via-transparent to-transparent')}/>
</>
);
}
+47
View File
@@ -0,0 +1,47 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {UserRound} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
/**
* Uses the art of this Steam game as banner or background of the public profile; a checked item removes it again.
*
* @param onSaved gets the whole saved style
*/
export default function ProfileArtMenu({appid, style, onSaved}) {
const {t} = useTranslation();
const save = useSaveProfileStyle();
const [saving, setSaving] = useState(false);
const toggle = async (field) => {
setSaving(true);
const saved = await save((stored) => ({[field]: stored[field] === appid ? null : appid}), style);
if (saved) onSaved(saved);
setSaving(false);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={saving}><UserRound/>{t('GAME_DETAIL.PROFILE_ART')}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuCheckboxItem checked={style.bannerAppid === appid} onCheckedChange={() => toggle('bannerAppid')}>
{t('GAME_DETAIL.PROFILE_BANNER')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={style.backgroundAppid === appid}
onCheckedChange={() => toggle('backgroundAppid')}>
{t('GAME_DETAIL.PROFILE_BACKGROUND')}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+97
View File
@@ -0,0 +1,97 @@
import {useTranslation} from 'react-i18next';
import {ArrowDown, ArrowUp, ImageOff} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Label} from '@/components/ui/label';
import {Switch} from '@/components/ui/switch';
import {AccentPicker, CornersPicker} from './StylePickers.jsx';
import {PROFILE_SECTIONS} from '../utils/profileStyle.js';
import {coverUrl} from '../utils/games.js';
function ArtRow({id, label, appid, onRemove, disabled}) {
const {t} = useTranslation();
return (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label id={`${id}-label`}>{label}</Label>
{!appid && <p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_ART_HINT')}</p>}
</div>
{appid && (
<div className="flex items-center gap-2">
<img src={coverUrl(appid)} 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={`${id}-label`}
disabled={disabled} onClick={onRemove}>
<ImageOff/>{t('SETTINGS.PROFILE_ART_REMOVE')}
</Button>
</div>
)}
</div>
);
}
/**
* The look of the public profile: accent, corners, banner, background and the sections in their order.
*
* @param disabled the profile is private, or a save runs
* @param onChange gets the changed fields only
*/
export default function ProfileStyleSettings({style, disabled, onChange}) {
const {t} = useTranslation();
const shown = style.sections;
const rows = [...shown, ...PROFILE_SECTIONS.filter((s) => !shown.includes(s))];
const move = (index, by) => {
const next = [...shown];
[next[index], next[index + by]] = [next[index + by], next[index]];
onChange({sections: next});
};
const toggle = (section) => onChange({
sections: shown.includes(section) ? shown.filter((s) => s !== section) : [...shown, section],
});
return (
<div className="space-y-6">
<div className="space-y-1">
<span className="text-sm font-medium">{t('SETTINGS.PROFILE_STYLE')}</span>
<p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_STYLE_HINT')}</p>
</div>
<AccentPicker id="profile-accent" value={style.accent} disabled={disabled}
onChange={(accent) => onChange({accent})}/>
<CornersPicker id="profile-corners" value={style.corners} disabled={disabled}
onChange={(corners) => onChange({corners})}/>
<ArtRow id="profile-banner" label={t('SETTINGS.PROFILE_BANNER')} appid={style.bannerAppid}
disabled={disabled} onRemove={() => onChange({bannerAppid: null})}/>
<ArtRow id="profile-background" label={t('SETTINGS.PROFILE_BACKGROUND')} appid={style.backgroundAppid}
disabled={disabled} onRemove={() => onChange({backgroundAppid: null})}/>
<div className="space-y-3">
<span id="profile-sections-label" className="text-sm font-medium">{t('SETTINGS.PROFILE_SECTIONS')}</span>
<ul aria-labelledby="profile-sections-label" className="space-y-2">
{rows.map((section) => {
const index = shown.indexOf(section);
const name = t(`SETTINGS.PROFILE_SECTION.${section}`);
return (
<li key={section} className="flex items-center justify-between gap-4">
<Label htmlFor={`profileSection-${section}`}>{name}</Label>
<div className="flex items-center gap-1">
<Button type="button" variant="ghost" size="icon" className="size-7"
aria-label={t('SETTINGS.PROFILE_SECTION_UP', {name})}
disabled={disabled || index <= 0} onClick={() => move(index, -1)}>
<ArrowUp/>
</Button>
<Button type="button" variant="ghost" size="icon" className="size-7"
aria-label={t('SETTINGS.PROFILE_SECTION_DOWN', {name})}
disabled={disabled || index < 0 || index === shown.length - 1}
onClick={() => move(index, 1)}>
<ArrowDown/>
</Button>
<Switch id={`profileSection-${section}`} checked={index >= 0} disabled={disabled}
className="ml-2" onCheckedChange={() => toggle(section)}/>
</div>
</li>
);
})}
</ul>
</div>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import {useTranslation} from 'react-i18next';
import {Button} from '@/components/ui/button';
import {Label} from '@/components/ui/label';
import {cn} from '@/lib/utils';
import {ACCENTS, CORNERS} from '../utils/appearance.js';
/**
* One round button per accent preset; each button carries its own preset, so it shows its own color.
*
* @param id prefix for the label id, unique on the page
*/
export function AccentPicker({id, value, onChange, disabled}) {
const {t} = useTranslation();
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Label id={`${id}-label`}>{t('SETTINGS.ACCENT')}</Label>
<div role="group" aria-labelledby={`${id}-label`} className="flex flex-wrap gap-2">
{ACCENTS.map((accent) => (
<button key={accent} type="button" data-accent={accent} disabled={disabled}
aria-label={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
title={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
aria-pressed={value === accent}
onClick={() => onChange(accent)}
className={cn('size-8 rounded-full bg-primary ring-offset-2 ring-offset-background transition hover:scale-110 disabled:opacity-50 disabled:hover:scale-100',
value === accent && 'ring-2 ring-foreground')}/>
))}
</div>
</div>
);
}
/**
* @param id prefix for the label id, unique on the page
*/
export function CornersPicker({id, value, onChange, disabled}) {
const {t} = useTranslation();
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Label id={`${id}-label`}>{t('SETTINGS.CORNERS')}</Label>
<div role="group" aria-labelledby={`${id}-label`} className="flex gap-2">
{CORNERS.map((corners) => (
<Button key={corners} type="button" size="sm" data-corners={corners} disabled={disabled}
variant={value === corners ? 'default' : 'outline'}
aria-pressed={value === corners}
onClick={() => onChange(corners)}>
{t(`SETTINGS.CORNER_OPTIONS.${corners.toUpperCase()}`)}
</Button>
))}
</div>
</div>
);
}