fix(profile): give the editor room — three panels instead of a cramped column
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s

The owner's verdict on the first editor was "imprensado", and it was: the
settings shell caps its content at 640px, and the editor put a 360px preview
next to six-column tile grids inside that. Everything was legible and nothing
was usable.

Now it follows the shape of the reference (Discord's profile editor):

  - left, 320px: the list of sections, each showing its current value as a
    mini preview (the nameplate strip, the decorated avatar, the styled name,
    the theme swatch, the effect thumbnail). Picking one replaces the list
    with that section's editor, with a back arrow — a drill-down instead of
    sub-modals, one level deep.
  - centre: the live card, plus two in-context samples — how the name and tag
    read on a chat line, and the row in the member list with its nameplate.
  - right: the Board as others see it, with an Edit shortcut.

Tiles went from 5–6 per row to 2–3, with real size (Gg samples at 26px,
decorations on a 56px avatar, effect thumbnails 64px tall). The settings
shell drops its width cap and lets the panel fill the height for this tab
only; every other panel keeps the single reading column. Stacks to one
column below the lg breakpoint.

Verified: web build, 517 web tests. Not verified: pixels — there is no
browser here, so the proportions are from the reference and the numbers, and
a screenshot decides.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 09:27:38 -03:00
co-authored by Claude Fable 5.1
parent e7094014b7
commit 723522b90d
4 changed files with 348 additions and 199 deletions
@@ -238,7 +238,10 @@ export function UserSettingsModal() {
{/* Content area (desktop always, mobile only when viewing content) */} {/* Content area (desktop always, mobile only when viewing content) */}
{(!isMobile || mobileView === 'content') && ( {(!isMobile || mobileView === 'content') && (
<SettingsScrollContainer> <SettingsScrollContainer>
<div className="px-6 max-w-[640px] mx-auto"> {/* The profile editor is a three-panel layout and needs the whole
width and height of the content area; every other panel is a
single reading column. */}
<div className={tab === 'profile' ? 'px-6 h-full flex flex-col min-h-0' : 'px-6 max-w-[640px] mx-auto'}>
{/* Mobile back button */} {/* Mobile back button */}
{isMobile && ( {isMobile && (
<button <button
@@ -8,6 +8,9 @@ import { getProfileStyle, nameStyleProps, nameplateProps } from '../../../utils/
import { ProfileCard } from '../../profile/ProfileCard'; import { ProfileCard } from '../../profile/ProfileCard';
import { DecoratedAvatar } from '../../profile/DecoratedAvatar'; import { DecoratedAvatar } from '../../profile/DecoratedAvatar';
import { ProfileEffectLayer } from '../../profile/ProfileEffectLayer'; import { ProfileEffectLayer } from '../../profile/ProfileEffectLayer';
import { ProfileTagChip } from '../../profile/ProfileTagChip';
import { ProfileBoard } from '../../profile/ProfileWidgets';
import { StyledName } from '../../profile/StyledName';
import { Avatar } from '../../ui/Avatar'; import { Avatar } from '../../ui/Avatar';
import { import {
NAME_FONTS, NAME_EFFECTS, AVATAR_DECORATIONS, PROFILE_EFFECTS, NAMEPLATES, PROFILE_LIMITS, NAME_FONTS, NAME_EFFECTS, AVATAR_DECORATIONS, PROFILE_EFFECTS, NAMEPLATES, PROFILE_LIMITS,
@@ -15,33 +18,40 @@ import {
type ProfileStyle, type GameEntry, type ProfileStyle, type GameEntry,
} from '@backspace/shared/src/profile'; } from '@backspace/shared/src/profile';
/** Colour pairs offered as one-click presets for name and theme. */ /*
* Layout, after the first version was rightly called cramped: the settings
* column is 640px wide and this editor had a 360px preview beside 6-column
* grids in it. Now it is three panels, the way Discord's profile editor is —
*
* left : the list of sections, each showing its current value; picking one
* replaces the list with that section's editor (back arrow to return)
* centre : the live card, plus how the name reads in chat and in the member list
* right : the Board as others see it
*
* — and the settings shell drops its width cap for this tab (see UserSettings).
*/
type SectionId = 'nameplate' | 'avatar' | 'name' | 'theme' | 'effect' | 'identity' | 'links' | 'board';
const SECTIONS: SectionId[] = ['nameplate', 'avatar', 'name', 'theme', 'effect', 'identity', 'links', 'board'];
const PALETTES: [string, string][] = [ const PALETTES: [string, string][] = [
['#efefef', '#7c6cf6'], ['#f97316', '#fde047'], ['#22d3ee', '#a855f7'], ['#4ade80', '#22d3ee'], ['#efefef', '#7c6cf6'], ['#f97316', '#fde047'], ['#22d3ee', '#a855f7'], ['#4ade80', '#22d3ee'],
['#f472b6', '#fbbf24'], ['#fb7185', '#7c3aed'], ['#a3e635', '#16a34a'], ['#ffffff', '#94a3b8'], ['#f472b6', '#fbbf24'], ['#fb7185', '#7c3aed'], ['#a3e635', '#16a34a'], ['#ffffff', '#94a3b8'],
]; ];
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) { // ─── Small controls ─────────────────────────────────────────────────────────
return (
<section>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{title}</div>
{hint && <p className="text-xs text-txt-tertiary mb-2">{hint}</p>}
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">{children}</div>
</section>
);
}
function ColorPair({ value, onChange }: { value: [string, string]; onChange: (v: [string, string]) => void }) { function ColorPair({ value, onChange }: { value: [string, string]; onChange: (v: [string, string]) => void }) {
return ( return (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{PALETTES.map((p) => ( {PALETTES.map((p) => (
<button key={p.join()} type="button" onClick={() => onChange(p)} <button key={p.join()} type="button" onClick={() => onChange(p)}
className={`w-7 h-7 rounded-full border-2 transition-transform hover:scale-110 ${p[0] === value[0] && p[1] === value[1] ? 'border-white' : 'border-transparent'}`} className={`w-8 h-8 rounded-full border-2 transition-transform hover:scale-110 ${p[0] === value[0] && p[1] === value[1] ? 'border-white' : 'border-transparent'}`}
style={{ background: `linear-gradient(135deg, ${p[0]}, ${p[1]})` }} /> style={{ background: `linear-gradient(135deg, ${p[0]}, ${p[1]})` }} />
))} ))}
<span className="w-px h-6 bg-white/10 mx-1" /> <span className="w-px h-6 bg-white/10 mx-1" />
{[0, 1].map((i) => ( {[0, 1].map((i) => (
<label key={i} className="w-7 h-7 rounded-full overflow-hidden border-2 border-white/20 cursor-pointer" style={{ background: value[i] }}> <label key={i} className="w-8 h-8 rounded-full overflow-hidden border-2 border-white/20 cursor-pointer" style={{ background: value[i] }} title={`#${i + 1}`}>
<input type="color" value={value[i]} onChange={(e) => { const n: [string, string] = [value[0], value[1]]; n[i] = e.target.value; onChange(n); }} className="opacity-0 w-full h-full cursor-pointer" /> <input type="color" value={value[i]} onChange={(e) => { const n: [string, string] = [value[0], value[1]]; n[i] = e.target.value; onChange(n); }} className="opacity-0 w-full h-full cursor-pointer" />
</label> </label>
))} ))}
@@ -49,6 +59,17 @@ function ColorPair({ value, onChange }: { value: [string, string]; onChange: (v:
); );
} }
const tileClass = (active: boolean) =>
`relative rounded-xl border-2 p-3 flex flex-col items-center gap-2 transition-colors text-center ${active ? 'border-accent-primary bg-accent-primary/10' : 'border-white/[0.06] bg-white/[0.03] hover:bg-white/[0.06]'}`;
const chipClass = (active: boolean) =>
`px-3 py-2 rounded-lg text-sm border transition-colors ${active ? 'border-accent-primary bg-accent-primary/15 text-txt-primary' : 'border-white/[0.06] bg-white/[0.03] text-txt-secondary hover:bg-white/[0.06]'}`;
function Label({ children }: { children: React.ReactNode }) {
return <div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">{children}</div>;
}
// ─── Games ──────────────────────────────────────────────────────────────────
function GameSearch({ onPick, placeholder }: { onPick: (g: GameEntry) => void; placeholder: string }) { function GameSearch({ onPick, placeholder }: { onPick: (g: GameEntry) => void; placeholder: string }) {
const t = useT(); const t = useT();
const [q, setQ] = useState(''); const [q, setQ] = useState('');
@@ -70,17 +91,17 @@ function GameSearch({ onPick, placeholder }: { onPick: (g: GameEntry) => void; p
<input type="text" value={q} onChange={(e) => setQ(e.target.value)} placeholder={placeholder} className="input-standard w-full text-sm" <input type="text" value={q} onChange={(e) => setQ(e.target.value)} placeholder={placeholder} className="input-standard w-full text-sm"
onKeyDown={(e) => { if (e.key === 'Enter' && q.trim()) { e.preventDefault(); pick({ appId: null, name: q.trim(), cover: null, note: '', tags: [] }); } }} /> onKeyDown={(e) => { if (e.key === 'Enter' && q.trim()) { e.preventDefault(); pick({ appId: null, name: q.trim(), cover: null, note: '', tags: [] }); } }} />
{(hits.length > 0 || busy) && ( {(hits.length > 0 || busy) && (
<div className="absolute z-20 left-0 right-0 mt-1 rounded-lg glass-modal overflow-hidden max-h-64 overflow-y-auto scrollbar-thin"> <div className="absolute z-20 left-0 right-0 mt-1 rounded-lg glass-modal overflow-hidden max-h-72 overflow-y-auto scrollbar-thin">
{busy && hits.length === 0 && <div className="px-3 py-2 text-xs text-txt-tertiary">{t('profile.editor.searching')}</div>} {busy && hits.length === 0 && <div className="px-3 py-2 text-xs text-txt-tertiary">{t('profile.editor.searching')}</div>}
{hits.map((h) => ( {hits.map((h) => (
<button key={h.appId} type="button" onClick={() => pick({ appId: h.appId, name: h.name, cover: steamCoverUrl(h.appId), note: '', tags: [] })} <button key={h.appId} type="button" onClick={() => pick({ appId: h.appId, name: h.name, cover: steamCoverUrl(h.appId), note: '', tags: [] })}
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-interactive-hover text-left"> className="w-full flex items-center gap-2.5 px-2.5 py-2 hover:bg-interactive-hover text-left">
<img src={h.cover} alt="" className="w-6 h-9 rounded object-cover bg-black/40" loading="lazy" /> <img src={h.cover} alt="" className="w-7 h-[42px] rounded object-cover bg-black/40" loading="lazy" />
<span className="text-sm text-txt-primary truncate">{h.name}</span> <span className="text-sm text-txt-primary truncate">{h.name}</span>
</button> </button>
))} ))}
{q.trim() && !busy && ( {q.trim() && !busy && (
<button type="button" onClick={() => pick({ appId: null, name: q.trim(), cover: null, note: '', tags: [] })} className="w-full px-3 py-1.5 text-xs text-txt-tertiary hover:bg-interactive-hover text-left"> <button type="button" onClick={() => pick({ appId: null, name: q.trim(), cover: null, note: '', tags: [] })} className="w-full px-3 py-2 text-xs text-txt-tertiary hover:bg-interactive-hover text-left">
{t('profile.editor.useTyped', { name: q.trim() })} {t('profile.editor.useTyped', { name: q.trim() })}
</button> </button>
)} )}
@@ -93,22 +114,24 @@ function GameSearch({ onPick, placeholder }: { onPick: (g: GameEntry) => void; p
function GameEditor({ game, onChange, onRemove, withNote }: { game: GameEntry; onChange: (g: GameEntry) => void; onRemove: () => void; withNote?: boolean }) { function GameEditor({ game, onChange, onRemove, withNote }: { game: GameEntry; onChange: (g: GameEntry) => void; onRemove: () => void; withNote?: boolean }) {
const t = useT(); const t = useT();
return ( return (
<div className="flex items-start gap-2.5 rounded-lg bg-white/[0.04] p-2"> <div className="flex items-start gap-3 rounded-xl bg-white/[0.04] p-3">
{game.cover ? <img src={game.cover} alt="" className="w-10 h-[60px] rounded object-cover bg-black/40" /> : <div className="w-10 h-[60px] rounded bg-white/[0.06]" />} {game.cover ? <img src={game.cover} alt="" className="w-12 h-[72px] rounded-md object-cover bg-black/40" /> : <div className="w-12 h-[72px] rounded-md bg-white/[0.06]" />}
<div className="flex-1 min-w-0 space-y-1.5"> <div className="flex-1 min-w-0 space-y-2">
<div className="text-sm font-medium text-txt-primary truncate">{game.name}</div> <div className="text-sm font-semibold text-txt-primary truncate">{game.name}</div>
{withNote && ( {withNote && (
<input type="text" value={game.note} maxLength={PROFILE_LIMITS.gameNote} onChange={(e) => onChange({ ...game, note: e.target.value })} <input type="text" value={game.note} maxLength={PROFILE_LIMITS.gameNote} onChange={(e) => onChange({ ...game, note: e.target.value })}
placeholder={t('profile.editor.gameNote')} className="input-standard w-full text-xs py-1" /> placeholder={t('profile.editor.gameNote')} className="input-standard w-full text-xs py-1.5" />
)} )}
<input type="text" value={game.tags.join(', ')} onChange={(e) => onChange({ ...game, tags: e.target.value.split(',').map((x) => x.trim()).filter(Boolean).slice(0, PROFILE_LIMITS.gameTags) })} <input type="text" value={game.tags.join(', ')} onChange={(e) => onChange({ ...game, tags: e.target.value.split(',').map((x) => x.trim()).filter(Boolean).slice(0, PROFILE_LIMITS.gameTags) })}
placeholder={t('profile.editor.gameTags')} className="input-standard w-full text-xs py-1" /> placeholder={t('profile.editor.gameTags')} className="input-standard w-full text-xs py-1.5" />
</div> </div>
<button type="button" onClick={onRemove} className="text-txt-tertiary hover:text-accent-rose text-lg leading-none px-1" aria-label={t('profile.editor.remove')}>×</button> <button type="button" onClick={onRemove} className="text-txt-tertiary hover:text-accent-rose text-xl leading-none px-1" aria-label={t('profile.editor.remove')}>×</button>
</div> </div>
); );
} }
// ─── Panel ──────────────────────────────────────────────────────────────────
export function ProfilePanel() { export function ProfilePanel() {
const t = useT(); const t = useT();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@@ -117,6 +140,7 @@ export function ProfilePanel() {
const saved = useMemo(() => getProfileStyle(user), [user]); const saved = useMemo(() => getProfileStyle(user), [user]);
const [draft, setDraft] = useState<ProfileStyle>(saved); const [draft, setDraft] = useState<ProfileStyle>(saved);
const [section, setSection] = useState<SectionId | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { setDraft(saved); }, [saved]); useEffect(() => { setDraft(saved); }, [saved]);
@@ -138,196 +162,288 @@ export function ProfilePanel() {
} catch (e) { setError(e instanceof Error ? e.message : t('profile.editor.saveFailed')); } } catch (e) { setError(e instanceof Error ? e.message : t('profile.editor.saveFailed')); }
finally { setSaving(false); } finally { setSaving(false); }
}; };
const handleReset = () => setDraft(DEFAULT_PROFILE_STYLE);
const chip = (active: boolean) => `px-2.5 py-1.5 rounded-md text-sm border transition-colors ${active ? 'border-accent-primary bg-accent-primary/15 text-txt-primary' : 'border-white/[0.06] bg-white/[0.03] text-txt-secondary hover:bg-white/[0.06]'}`; const plate = nameplateProps(draft.nameplate, draft.nameplateColors);
const tile = (active: boolean) => `relative rounded-lg border-2 p-2 flex flex-col items-center gap-1 transition-colors ${active ? 'border-accent-primary bg-accent-primary/10' : 'border-white/[0.06] bg-white/[0.03] hover:bg-white/[0.06]'}`; const nameP = nameStyleProps(draft.name);
const boardCount = (draft.widgets.favoriteGame ? 1 : 0) + draft.widgets.rotation.length + draft.widgets.wishlist.length + (draft.widgets.quote ? 1 : 0);
// ── Left column: section list, or the open section's editor ──
const sectionPreview: Record<SectionId, React.ReactNode> = {
nameplate: (
<div className={`h-9 rounded-md flex items-center gap-2 px-2 ${plate?.className ?? 'bg-white/[0.05]'}`} style={plate?.style}>
<Avatar src={user.avatar} name={displayName} size={22} userId={user.homeUserId ?? user.id} user={user} />
<span className="text-[12px] text-txt-primary truncate">{displayName}</span>
</div>
),
avatar: (
<div className="flex items-center gap-4 py-1">
<DecoratedAvatar decoration={draft.decoration}>
<Avatar src={user.avatar} name={displayName} size={48} userId={user.homeUserId ?? user.id} user={user} />
</DecoratedAvatar>
<span className="text-xs text-txt-tertiary">{t(`profile.decoration.${draft.decoration}` as TranslationKey)}</span>
</div>
),
name: <div className="h-10 flex items-center px-2 rounded-md bg-white/[0.05]"><StyledName name={displayName} style={draft.name} className="text-[18px] font-bold" /></div>,
theme: (
<div className="h-9 rounded-md" style={{ background: draft.theme ? `linear-gradient(135deg, ${draft.theme.colors[0]}, ${draft.theme.colors[1]})` : 'rgba(255,255,255,0.05)' }} />
),
effect: (
<div className="relative h-12 rounded-md overflow-hidden bg-gradient-to-br from-[#1e1b4b] to-[#0b0b10]">
<ProfileEffectLayer effect={draft.effect} density={0.6} />
<span className="absolute bottom-1 left-2 text-[11px] text-txt-secondary">{t(`profile.profileEffect.${draft.effect}` as TranslationKey)}</span>
</div>
),
identity: (
<div className="flex items-center gap-2 text-[12px] text-txt-secondary min-h-[28px]">
{draft.tag ? <ProfileTagChip tag={draft.tag} /> : <span className="text-txt-tertiary">—</span>}
{draft.pronouns && <span>{draft.pronouns}</span>}
</div>
),
links: <div className="text-[12px] text-txt-secondary">{draft.links.length} / {PROFILE_LIMITS.links}</div>,
board: <div className="text-[12px] text-txt-secondary">{boardCount > 0 ? `${boardCount} ${t('profile.editor.items')}` : '—'}</div>,
};
const sectionEditor: Record<SectionId, React.ReactNode> = {
nameplate: (
<>
<p className="text-xs text-txt-tertiary mb-3">{t('profile.editor.nameplate.hint')}</p>
<div className="grid grid-cols-2 gap-2 mb-4">
{NAMEPLATES.map((n) => {
const p = nameplateProps(n, draft.nameplateColors);
return (
<button key={n} type="button" onClick={() => set('nameplate', n)} className={tileClass(draft.nameplate === n)}>
<div className={`w-full h-9 rounded-md flex items-center gap-2 px-2 ${p?.className ?? 'bg-white/[0.05]'}`} style={p?.style}>
<Avatar src={user.avatar} name={displayName} size={20} userId={user.homeUserId ?? user.id} user={user} />
<span className="text-[11px] text-txt-primary truncate">{displayName}</span>
</div>
<span className="text-[11px] text-txt-secondary">{t(`profile.nameplate.${n}` as TranslationKey)}</span>
</button>
);
})}
</div>
{draft.nameplate === 'custom' && <><Label>{t('profile.editor.colors')}</Label><ColorPair value={draft.nameplateColors} onChange={(c) => set('nameplateColors', c)} /></>}
</>
),
avatar: (
<div className="grid grid-cols-3 gap-2">
{AVATAR_DECORATIONS.map((d) => (
<button key={d} type="button" onClick={() => set('decoration', d)} className={tileClass(draft.decoration === d)}>
<DecoratedAvatar decoration={d}>
<Avatar src={user.avatar} name={displayName} size={56} userId={user.homeUserId ?? user.id} user={user} />
</DecoratedAvatar>
<span className="text-[11px] text-txt-secondary">{t(`profile.decoration.${d}` as TranslationKey)}</span>
</button>
))}
</div>
),
name: (
<>
<Label>{t('profile.editor.font')}</Label>
<div className="grid grid-cols-3 gap-2 mb-5">
{NAME_FONTS.map((f) => (
<button key={f} type="button" onClick={() => set('name', { ...draft.name, font: f })} className={tileClass(draft.name.font === f)}>
<span className={`name-font-${f} text-[26px] leading-none text-txt-primary`}>Gg</span>
<span className="text-[11px] text-txt-secondary">{t(`profile.font.${f}` as TranslationKey)}</span>
</button>
))}
</div>
<Label>{t('profile.editor.effect')}</Label>
<div className="grid grid-cols-3 gap-2 mb-5">
{NAME_EFFECTS.map((e) => {
const p = nameStyleProps({ font: draft.name.font, effect: e, colors: draft.name.colors });
return (
<button key={e} type="button" onClick={() => set('name', { ...draft.name, effect: e })} className={`${chipClass(draft.name.effect === e)} text-center`}>
<span className={`${p.className} text-[15px] font-bold`} style={p.style}>{t(`profile.effect.${e}` as TranslationKey)}</span>
</button>
);
})}
</div>
<Label>{t('profile.editor.colors')}</Label>
<ColorPair value={draft.name.colors} onChange={(c) => set('name', { ...draft.name, colors: c })} />
</>
),
theme: (
<>
<p className="text-xs text-txt-tertiary mb-3">{t('profile.editor.theme.hint')}</p>
<button type="button" onClick={() => set('theme', null)} className={`${chipClass(draft.theme === null)} mb-4`}>{t('profile.editor.theme.none')}</button>
<ColorPair value={draft.theme?.colors ?? ['#7c6cf6', '#0b0b10']} onChange={(c) => set('theme', { colors: c })} />
</>
),
effect: (
<div className="grid grid-cols-2 gap-2">
{PROFILE_EFFECTS.map((e) => (
<button key={e} type="button" onClick={() => set('effect', e)} className={tileClass(draft.effect === e)}>
<div className="relative w-full h-16 rounded-md overflow-hidden bg-gradient-to-br from-[#1e1b4b] to-[#0b0b10]">
<ProfileEffectLayer effect={e} density={0.7} />
</div>
<span className="text-[11px] text-txt-secondary">{t(`profile.profileEffect.${e}` as TranslationKey)}</span>
</button>
))}
</div>
),
identity: (
<div className="space-y-5">
<label className="block">
<Label>{t('profile.editor.pronouns')}</Label>
<input type="text" value={draft.pronouns} maxLength={PROFILE_LIMITS.pronouns} onChange={(e) => set('pronouns', e.target.value)} placeholder={t('profile.editor.pronouns.placeholder')} className="input-standard w-full" />
</label>
<div>
<Label>{t('profile.editor.tag')}</Label>
<div className="flex items-center gap-2">
<input type="text" value={draft.tag?.emoji ?? ''} onChange={(e) => set('tag', { text: draft.tag?.text ?? '', emoji: e.target.value, color: draft.tag?.color ?? '#7c6cf6' })} placeholder="🔥" className="input-standard w-14 text-center" />
<input type="text" value={draft.tag?.text ?? ''} maxLength={PROFILE_LIMITS.tagMax} onChange={(e) => set('tag', { text: e.target.value.toUpperCase(), emoji: draft.tag?.emoji ?? '', color: draft.tag?.color ?? '#7c6cf6' })} placeholder="STRT" className="input-standard flex-1 uppercase tracking-wider" />
<label className="w-10 h-10 rounded-lg overflow-hidden border border-white/20 cursor-pointer flex-shrink-0" style={{ background: draft.tag?.color ?? '#7c6cf6' }}>
<input type="color" value={draft.tag?.color ?? '#7c6cf6'} onChange={(e) => set('tag', { text: draft.tag?.text ?? '', emoji: draft.tag?.emoji ?? '', color: e.target.value })} className="opacity-0 w-full h-full cursor-pointer" />
</label>
</div>
<div className="flex items-center justify-between mt-2">
<p className="text-[11px] text-txt-tertiary">{t('profile.editor.tag.hint')}</p>
{draft.tag && <button type="button" onClick={() => set('tag', null)} className="text-xs text-txt-tertiary hover:text-accent-rose">{t('profile.editor.remove')}</button>}
</div>
</div>
</div>
),
links: (
<>
<p className="text-xs text-txt-tertiary mb-3">{t('profile.editor.links.hint')}</p>
<div className="space-y-2">
{draft.links.map((l, i) => (
<div key={i} className="rounded-xl bg-white/[0.04] p-2.5 space-y-2">
<div className="flex items-center gap-2">
<input type="text" value={l.label} maxLength={PROFILE_LIMITS.linkLabel} onChange={(e) => set('links', draft.links.map((x, j) => j === i ? { ...x, label: e.target.value } : x))} placeholder={t('profile.editor.linkLabel')} className="input-standard flex-1 text-sm" />
<button type="button" onClick={() => set('links', draft.links.filter((_, j) => j !== i))} className="text-txt-tertiary hover:text-accent-rose text-xl leading-none px-1">×</button>
</div>
<input type="url" value={l.url} maxLength={PROFILE_LIMITS.linkUrl} onChange={(e) => set('links', draft.links.map((x, j) => j === i ? { ...x, url: e.target.value } : x))} placeholder="https://" className="input-standard w-full text-sm" />
</div>
))}
{draft.links.length < PROFILE_LIMITS.links && (
<button type="button" onClick={() => set('links', [...draft.links, { label: '', url: '' }])} className="w-full py-2 rounded-xl border border-dashed border-white/15 text-sm text-txt-secondary hover:text-txt-primary hover:bg-white/[0.04]">+ {t('profile.editor.addLink')}</button>
)}
</div>
</>
),
board: (
<div className="space-y-6">
<div>
<Label>{t('profile.widgets.favoriteGame')}</Label>
{draft.widgets.favoriteGame
? <GameEditor game={draft.widgets.favoriteGame} withNote onChange={(g) => setW('favoriteGame', g)} onRemove={() => setW('favoriteGame', null)} />
: <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('favoriteGame', g)} />}
</div>
<div>
<Label>{t('profile.widgets.rotation')} · {draft.widgets.rotation.length}/{PROFILE_LIMITS.rotation}</Label>
<div className="space-y-2">
{draft.widgets.rotation.map((g, i) => <GameEditor key={i} game={g} onChange={(n) => setW('rotation', draft.widgets.rotation.map((x, j) => j === i ? n : x))} onRemove={() => setW('rotation', draft.widgets.rotation.filter((_, j) => j !== i))} />)}
{draft.widgets.rotation.length < PROFILE_LIMITS.rotation && <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('rotation', [...draft.widgets.rotation, g])} />}
</div>
</div>
<div>
<Label>{t('profile.widgets.wishlist')} · {draft.widgets.wishlist.length}/{PROFILE_LIMITS.wishlist}</Label>
<div className="flex flex-wrap gap-2 mb-2">
{draft.widgets.wishlist.map((g, i) => (
<div key={i} className="relative w-[56px]" title={g.name}>
{g.cover ? <img src={g.cover} alt="" className="w-[56px] h-[84px] rounded-md object-cover bg-black/40" /> : <div className="w-[56px] h-[84px] rounded-md bg-white/[0.06] flex items-center justify-center text-txt-tertiary text-[10px] px-1 text-center">{g.name}</div>}
<button type="button" onClick={() => setW('wishlist', draft.widgets.wishlist.filter((_, j) => j !== i))} className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-black/80 text-white text-xs">×</button>
</div>
))}
</div>
{draft.widgets.wishlist.length < PROFILE_LIMITS.wishlist && <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('wishlist', [...draft.widgets.wishlist, g])} />}
</div>
<div>
<Label>{t('profile.editor.quote')}</Label>
<textarea value={draft.widgets.quote?.text ?? ''} maxLength={PROFILE_LIMITS.quoteText} rows={3} onChange={(e) => setW('quote', e.target.value ? { text: e.target.value, author: draft.widgets.quote?.author ?? '' } : null)} placeholder={t('profile.editor.quote.placeholder')} className="input-standard w-full text-sm resize-none" />
{draft.widgets.quote && <input type="text" value={draft.widgets.quote.author} maxLength={PROFILE_LIMITS.quoteAuthor} onChange={(e) => setW('quote', { text: draft.widgets.quote!.text, author: e.target.value })} placeholder={t('profile.editor.quote.author')} className="input-standard w-full text-sm mt-2" />}
</div>
</div>
),
};
return ( return (
<div className="space-y-5"> <div className="flex flex-col h-full min-h-0">
<h2 className="text-lg font-semibold text-txt-primary mb-2">{t('profile.editor.title')}</h2> <div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-[320px_minmax(340px,380px)_1fr] gap-6">
<p className="text-xs text-txt-tertiary -mt-3">{t('profile.editor.subtitle')}</p> {/* Left: sections / editor */}
<div className="min-h-0 overflow-y-auto scrollbar-thin pr-1">
<div className="grid grid-cols-1 xl:grid-cols-[1fr_360px] gap-5 items-start"> {section === null ? (
<div className="space-y-5 min-w-0"> <>
{/* Name style */} <h2 className="text-lg font-semibold text-txt-primary">{t('profile.editor.title')}</h2>
<Section title={t('profile.editor.name')} hint={t('profile.editor.name.hint')}> <p className="text-xs text-txt-tertiary mt-1 mb-4">{t('profile.editor.subtitle')}</p>
<div className="text-xs text-txt-tertiary mb-1.5">{t('profile.editor.font')}</div> <div className="space-y-2">
<div className="grid grid-cols-5 gap-1.5 mb-3"> {SECTIONS.map((id) => (
{NAME_FONTS.map((f) => ( <button key={id} type="button" onClick={() => setSection(id)}
<button key={f} type="button" onClick={() => set('name', { ...draft.name, font: f })} className={tile(draft.name.font === f)} title={t(`profile.font.${f}` as TranslationKey)}> className="w-full text-left rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/[0.05] p-3 transition-colors group">
<span className={`name-font-${f} text-[20px] leading-none text-txt-primary`}>Gg</span> <div className="flex items-center justify-between mb-2">
<span className="text-[10px] text-txt-tertiary truncate w-full text-center">{t(`profile.font.${f}` as TranslationKey)}</span> <span className="text-[13px] font-semibold text-txt-primary">{t(`profile.section.${id}` as TranslationKey)}</span>
</button> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary group-hover:text-txt-secondary"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" /></svg>
))}
</div>
<div className="text-xs text-txt-tertiary mb-1.5">{t('profile.editor.effect')}</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{NAME_EFFECTS.map((e) => {
const p = nameStyleProps({ font: 'default', effect: e, colors: draft.name.colors });
return (
<button key={e} type="button" onClick={() => set('name', { ...draft.name, effect: e })} className={chip(draft.name.effect === e)}>
<span className={p.className} style={p.style}>{t(`profile.effect.${e}` as TranslationKey)}</span>
</button>
);
})}
</div>
<div className="text-xs text-txt-tertiary mb-1.5">{t('profile.editor.colors')}</div>
<ColorPair value={draft.name.colors} onChange={(c) => set('name', { ...draft.name, colors: c })} />
</Section>
{/* Decoration */}
<Section title={t('profile.editor.decoration')}>
<div className="grid grid-cols-6 gap-2">
{AVATAR_DECORATIONS.map((d) => (
<button key={d} type="button" onClick={() => set('decoration', d)} className={tile(draft.decoration === d)} title={t(`profile.decoration.${d}` as TranslationKey)}>
<DecoratedAvatar decoration={d}>
<Avatar src={user.avatar} name={displayName} size={40} userId={user.homeUserId ?? user.id} user={user} />
</DecoratedAvatar>
<span className="text-[10px] text-txt-tertiary truncate w-full text-center">{t(`profile.decoration.${d}` as TranslationKey)}</span>
</button>
))}
</div>
</Section>
{/* Profile effect */}
<Section title={t('profile.editor.profileEffect')}>
<div className="grid grid-cols-5 gap-2">
{PROFILE_EFFECTS.map((e) => (
<button key={e} type="button" onClick={() => set('effect', e)} className={tile(draft.effect === e)}>
<div className="relative w-full h-12 rounded-md overflow-hidden bg-gradient-to-br from-[#1e1b4b] to-[#0b0b10]">
<ProfileEffectLayer effect={e} density={0.6} />
</div>
<span className="text-[10px] text-txt-tertiary">{t(`profile.profileEffect.${e}` as TranslationKey)}</span>
</button>
))}
</div>
</Section>
{/* Nameplate */}
<Section title={t('profile.editor.nameplate')} hint={t('profile.editor.nameplate.hint')}>
<div className="grid grid-cols-4 gap-2 mb-3">
{NAMEPLATES.map((n) => {
const p = nameplateProps(n, draft.nameplateColors);
return (
<button key={n} type="button" onClick={() => set('nameplate', n)} className={tile(draft.nameplate === n)}>
<div className={`w-full h-8 rounded-md flex items-center gap-2 px-2 ${p?.className ?? 'bg-white/[0.04]'}`} style={p?.style}>
<Avatar src={user.avatar} name={displayName} size={18} userId={user.homeUserId ?? user.id} user={user} />
<span className="text-[11px] text-txt-primary truncate">{displayName}</span>
</div> </div>
<span className="text-[10px] text-txt-tertiary">{t(`profile.nameplate.${n}` as TranslationKey)}</span> {sectionPreview[id]}
</button> </button>
); ))}
})}
</div>
{draft.nameplate === 'custom' && <ColorPair value={draft.nameplateColors} onChange={(c) => set('nameplateColors', c)} />}
</Section>
{/* Theme */}
<Section title={t('profile.editor.theme')} hint={t('profile.editor.theme.hint')}>
<div className="flex items-center gap-3 flex-wrap">
<button type="button" onClick={() => set('theme', null)} className={chip(draft.theme === null)}>{t('profile.editor.theme.none')}</button>
<ColorPair value={draft.theme?.colors ?? ['#7c6cf6', '#0b0b10']} onChange={(c) => set('theme', { colors: c })} />
</div>
</Section>
{/* Identity: pronouns + tag */}
<Section title={t('profile.editor.identity')}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<label className="block">
<span className="block text-xs text-txt-secondary mb-1.5">{t('profile.editor.pronouns')}</span>
<input type="text" value={draft.pronouns} maxLength={PROFILE_LIMITS.pronouns} onChange={(e) => set('pronouns', e.target.value)} placeholder={t('profile.editor.pronouns.placeholder')} className="input-standard w-full" />
</label>
<div>
<span className="block text-xs text-txt-secondary mb-1.5">{t('profile.editor.tag')}</span>
<div className="flex items-center gap-2">
<input type="text" value={draft.tag?.emoji ?? ''} onChange={(e) => set('tag', { text: draft.tag?.text ?? '', emoji: e.target.value, color: draft.tag?.color ?? '#7c6cf6' })} placeholder="🔥" className="input-standard w-12 text-center" />
<input type="text" value={draft.tag?.text ?? ''} maxLength={PROFILE_LIMITS.tagMax} onChange={(e) => set('tag', { text: e.target.value.toUpperCase(), emoji: draft.tag?.emoji ?? '', color: draft.tag?.color ?? '#7c6cf6' })} placeholder="STRT" className="input-standard w-24 uppercase tracking-wider" />
<label className="w-8 h-8 rounded-md overflow-hidden border border-white/20 cursor-pointer flex-shrink-0" style={{ background: draft.tag?.color ?? '#7c6cf6' }}>
<input type="color" value={draft.tag?.color ?? '#7c6cf6'} onChange={(e) => set('tag', { text: draft.tag?.text ?? '', emoji: draft.tag?.emoji ?? '', color: e.target.value })} className="opacity-0 w-full h-full cursor-pointer" />
</label>
{draft.tag && <button type="button" onClick={() => set('tag', null)} className="text-xs text-txt-tertiary hover:text-txt-secondary">{t('profile.editor.remove')}</button>}
</div>
<p className="text-[11px] text-txt-tertiary mt-1">{t('profile.editor.tag.hint')}</p>
</div> </div>
</div> </>
</Section> ) : (
<>
{/* Links */} <button type="button" onClick={() => setSection(null)} className="flex items-center gap-1.5 text-sm text-txt-secondary hover:text-txt-primary mb-3">
<Section title={t('profile.editor.links')} hint={t('profile.editor.links.hint')}> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z" /></svg>
<div className="space-y-2"> {t('profile.editor.back')}
{draft.links.map((l, i) => ( </button>
<div key={i} className="flex items-center gap-2"> <h2 className="text-lg font-semibold text-txt-primary mb-4">{t(`profile.section.${section}` as TranslationKey)}</h2>
<input type="text" value={l.label} maxLength={PROFILE_LIMITS.linkLabel} onChange={(e) => set('links', draft.links.map((x, j) => j === i ? { ...x, label: e.target.value } : x))} placeholder={t('profile.editor.linkLabel')} className="input-standard w-36 text-sm" /> {sectionEditor[section]}
<input type="url" value={l.url} maxLength={PROFILE_LIMITS.linkUrl} onChange={(e) => set('links', draft.links.map((x, j) => j === i ? { ...x, url: e.target.value } : x))} placeholder="https://" className="input-standard flex-1 text-sm" /> </>
<button type="button" onClick={() => set('links', draft.links.filter((_, j) => j !== i))} className="text-txt-tertiary hover:text-accent-rose text-lg leading-none px-1">×</button> )}
</div>
))}
{draft.links.length < PROFILE_LIMITS.links && (
<button type="button" onClick={() => set('links', [...draft.links, { label: '', url: '' }])} className="text-sm text-accent-primary hover:underline">+ {t('profile.editor.addLink')}</button>
)}
</div>
</Section>
{/* Board */}
<Section title={t('profile.editor.board')} hint={t('profile.editor.board.hint')}>
<div className="space-y-4">
<div>
<div className="text-xs text-txt-secondary mb-1.5">{t('profile.widgets.favoriteGame')}</div>
{draft.widgets.favoriteGame
? <GameEditor game={draft.widgets.favoriteGame} withNote onChange={(g) => setW('favoriteGame', g)} onRemove={() => setW('favoriteGame', null)} />
: <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('favoriteGame', g)} />}
</div>
<div>
<div className="text-xs text-txt-secondary mb-1.5">{t('profile.widgets.rotation')} · {draft.widgets.rotation.length}/{PROFILE_LIMITS.rotation}</div>
<div className="space-y-2">
{draft.widgets.rotation.map((g, i) => <GameEditor key={i} game={g} onChange={(n) => setW('rotation', draft.widgets.rotation.map((x, j) => j === i ? n : x))} onRemove={() => setW('rotation', draft.widgets.rotation.filter((_, j) => j !== i))} />)}
{draft.widgets.rotation.length < PROFILE_LIMITS.rotation && <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('rotation', [...draft.widgets.rotation, g])} />}
</div>
</div>
<div>
<div className="text-xs text-txt-secondary mb-1.5">{t('profile.widgets.wishlist')} · {draft.widgets.wishlist.length}/{PROFILE_LIMITS.wishlist}</div>
<div className="flex flex-wrap gap-2 mb-2">
{draft.widgets.wishlist.map((g, i) => (
<div key={i} className="relative w-[52px]" title={g.name}>
{g.cover ? <img src={g.cover} alt="" className="w-[52px] h-[78px] rounded object-cover bg-black/40" /> : <div className="w-[52px] h-[78px] rounded bg-white/[0.06] flex items-center justify-center text-txt-tertiary text-xs px-1 text-center">{g.name}</div>}
<button type="button" onClick={() => setW('wishlist', draft.widgets.wishlist.filter((_, j) => j !== i))} className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-black/80 text-white text-xs">×</button>
</div>
))}
</div>
{draft.widgets.wishlist.length < PROFILE_LIMITS.wishlist && <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('wishlist', [...draft.widgets.wishlist, g])} />}
</div>
<div>
<div className="text-xs text-txt-secondary mb-1.5">{t('profile.editor.quote')}</div>
<textarea value={draft.widgets.quote?.text ?? ''} maxLength={PROFILE_LIMITS.quoteText} rows={2} onChange={(e) => setW('quote', e.target.value ? { text: e.target.value, author: draft.widgets.quote?.author ?? '' } : null)} placeholder={t('profile.editor.quote.placeholder')} className="input-standard w-full text-sm resize-none" />
{draft.widgets.quote && <input type="text" value={draft.widgets.quote.author} maxLength={PROFILE_LIMITS.quoteAuthor} onChange={(e) => setW('quote', { text: draft.widgets.quote!.text, author: e.target.value })} placeholder={t('profile.editor.quote.author')} className="input-standard w-full text-sm mt-1.5" />}
</div>
</div>
</Section>
</div> </div>
{/* Live preview */} {/* Centre: live card + in-context samples */}
<aside className="xl:sticky xl:top-0"> <div className="min-h-0 overflow-y-auto scrollbar-thin">
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('profile.editor.preview')}</div> <Label>{t('profile.editor.preview')}</Label>
<div className="rounded-[12px] overflow-hidden glass-modal select-none"> <div className="rounded-[12px] overflow-hidden glass-modal select-none">
<ProfileCard user={preview} style={draft} displayName={displayName} bannerSrc={bannerSrc} /> <ProfileCard user={preview} style={draft} displayName={displayName} bannerSrc={bannerSrc} />
</div> </div>
<p className="text-[11px] text-txt-tertiary mt-2">{t('profile.editor.preview.hint')}</p> <div className="mt-5 space-y-3">
</aside> <div>
<Label>{t('profile.editor.inChat')}</Label>
<div className="flex items-start gap-3 rounded-xl bg-white/[0.03] p-3">
<Avatar src={user.avatar} name={displayName} size={36} userId={user.homeUserId ?? user.id} user={user} />
<div className="min-w-0">
<div className="flex items-center gap-2 leading-tight">
<span className={`${nameP.className} text-[15px] font-semibold`} style={nameP.style}>{displayName}</span>
{draft.tag && <ProfileTagChip tag={draft.tag} className="scale-90 origin-left" />}
<span className="text-[11px] text-txt-tertiary">21:19</span>
</div>
<div className="text-[14px] text-txt-secondary">{t('profile.editor.inChat.sample')}</div>
</div>
</div>
</div>
<div>
<Label>{t('profile.editor.inMembers')}</Label>
<div className={`flex items-center gap-2.5 px-2 py-1.5 rounded-[4px] ${plate?.className ?? 'bg-white/[0.03]'}`} style={plate?.style}>
<Avatar src={user.avatar} name={displayName} size={32} status="online" userId={user.homeUserId ?? user.id} user={user} />
<span className="text-[13.5px] font-medium text-txt-primary truncate">{displayName}</span>
{draft.tag && <ProfileTagChip tag={draft.tag} className="scale-90 origin-left" />}
</div>
</div>
</div>
</div>
{/* Right: the board as others see it */}
<div className="min-h-0 overflow-y-auto scrollbar-thin">
<div className="flex items-center justify-between mb-2">
<Label>{t('profile.tab.board')}</Label>
<button type="button" onClick={() => setSection('board')} className="text-xs text-accent-primary hover:underline mb-2">{t('profile.editor.editBoard')}</button>
</div>
<ProfileBoard widgets={draft.widgets} />
</div>
</div> </div>
{error && <div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>} {error && <div className="mt-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>}
{(isDirty || saving) && ( {(isDirty || saving) && (
<div className="sticky bottom-0 z-10 pointer-events-none"> <div className="flex-shrink-0 pt-3 flex justify-center">
<div className="flex justify-center pt-3 pb-1"> <div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up">
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto"> <button onClick={() => setDraft(DEFAULT_PROFILE_STYLE)} className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors">{t('profile.editor.resetAll')}</button>
<button onClick={handleReset} className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors">{t('profile.editor.resetAll')}</button> <button onClick={() => setDraft(saved)} className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors">{t('profile.editor.discard')}</button>
<button onClick={() => setDraft(saved)} className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors">{t('profile.editor.discard')}</button> <button onClick={handleSave} disabled={saving} className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50">
<button onClick={handleSave} disabled={saving} className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"> {saving ? t('profile.editor.saving') : t('profile.editor.save')}
{saving ? t('profile.editor.saving') : t('profile.editor.save')} </button>
</button>
</div>
</div> </div>
</div> </div>
)} )}
+15
View File
@@ -1141,6 +1141,21 @@ export const en = {
'profile.widgets.rotation': 'Games in Rotation', 'profile.widgets.rotation': 'Games in Rotation',
'profile.widgets.wishlist': 'Want to Play', 'profile.widgets.wishlist': 'Want to Play',
'profile.widgets.empty': 'Nothing on the board yet.', 'profile.widgets.empty': 'Nothing on the board yet.',
// Profile editor — three-panel layout
'profile.section.nameplate': 'Nameplate',
'profile.section.avatar': 'Avatar & Decoration',
'profile.section.name': 'Display Name Style',
'profile.section.theme': 'Theme',
'profile.section.effect': 'Profile Effect',
'profile.section.identity': 'Pronouns & Tag',
'profile.section.links': 'Links',
'profile.section.board': 'Board',
'profile.editor.back': 'Back',
'profile.editor.items': 'items',
'profile.editor.inChat': 'In chat',
'profile.editor.inChat.sample': 'does anyone read this?',
'profile.editor.inMembers': 'In the member list',
'profile.editor.editBoard': 'Edit',
} as const; } as const;
export type TranslationKey = keyof typeof en; export type TranslationKey = keyof typeof en;
+15
View File
@@ -1140,4 +1140,19 @@ export const ptBR: Partial<Dictionary> = {
'profile.widgets.rotation': 'Na rotação', 'profile.widgets.rotation': 'Na rotação',
'profile.widgets.wishlist': 'Quero jogar', 'profile.widgets.wishlist': 'Quero jogar',
'profile.widgets.empty': 'Nada no quadro ainda.', 'profile.widgets.empty': 'Nada no quadro ainda.',
// Editor de perfil — três painéis
'profile.section.nameplate': 'Nameplate',
'profile.section.avatar': 'Avatar e decoração',
'profile.section.name': 'Estilo do nome',
'profile.section.theme': 'Tema',
'profile.section.effect': 'Efeito do perfil',
'profile.section.identity': 'Pronomes e tag',
'profile.section.links': 'Links',
'profile.section.board': 'Quadro',
'profile.editor.back': 'Voltar',
'profile.editor.items': 'itens',
'profile.editor.inChat': 'No chat',
'profile.editor.inChat.sample': 'alguém lê isso?',
'profile.editor.inMembers': 'Na lista de membros',
'profile.editor.editBoard': 'Editar',
}; };