Public Access
feat(profile): let users design their public profile
This commit is contained in:
1 parent
a5458b3f86
commit
b55fcfec92
20 files changed
+674
-138
No files matched your search
@@ -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')}/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user