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
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:
@@ -238,7 +238,10 @@ export function UserSettingsModal() {
|
||||
{/* Content area (desktop always, mobile only when viewing content) */}
|
||||
{(!isMobile || mobileView === 'content') && (
|
||||
<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 */}
|
||||
{isMobile && (
|
||||
<button
|
||||
|
||||
@@ -8,6 +8,9 @@ import { getProfileStyle, nameStyleProps, nameplateProps } from '../../../utils/
|
||||
import { ProfileCard } from '../../profile/ProfileCard';
|
||||
import { DecoratedAvatar } from '../../profile/DecoratedAvatar';
|
||||
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 {
|
||||
NAME_FONTS, NAME_EFFECTS, AVATAR_DECORATIONS, PROFILE_EFFECTS, NAMEPLATES, PROFILE_LIMITS,
|
||||
@@ -15,33 +18,40 @@ import {
|
||||
type ProfileStyle, type GameEntry,
|
||||
} 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][] = [
|
||||
['#efefef', '#7c6cf6'], ['#f97316', '#fde047'], ['#22d3ee', '#a855f7'], ['#4ade80', '#22d3ee'],
|
||||
['#f472b6', '#fbbf24'], ['#fb7185', '#7c3aed'], ['#a3e635', '#16a34a'], ['#ffffff', '#94a3b8'],
|
||||
];
|
||||
|
||||
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
// ─── Small controls ─────────────────────────────────────────────────────────
|
||||
|
||||
function ColorPair({ value, onChange }: { value: [string, string]; onChange: (v: [string, string]) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{PALETTES.map((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]})` }} />
|
||||
))}
|
||||
<span className="w-px h-6 bg-white/10 mx-1" />
|
||||
{[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" />
|
||||
</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 }) {
|
||||
const t = useT();
|
||||
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"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && q.trim()) { e.preventDefault(); pick({ appId: null, name: q.trim(), cover: null, note: '', tags: [] }); } }} />
|
||||
{(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>}
|
||||
{hits.map((h) => (
|
||||
<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">
|
||||
<img src={h.cover} alt="" className="w-6 h-9 rounded object-cover bg-black/40" loading="lazy" />
|
||||
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-7 h-[42px] rounded object-cover bg-black/40" loading="lazy" />
|
||||
<span className="text-sm text-txt-primary truncate">{h.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{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() })}
|
||||
</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 }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 rounded-lg bg-white/[0.04] p-2">
|
||||
{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]" />}
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<div className="text-sm font-medium text-txt-primary truncate">{game.name}</div>
|
||||
<div className="flex items-start gap-3 rounded-xl bg-white/[0.04] p-3">
|
||||
{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-2">
|
||||
<div className="text-sm font-semibold text-txt-primary truncate">{game.name}</div>
|
||||
{withNote && (
|
||||
<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) })}
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Panel ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProfilePanel() {
|
||||
const t = useT();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -117,6 +140,7 @@ export function ProfilePanel() {
|
||||
|
||||
const saved = useMemo(() => getProfileStyle(user), [user]);
|
||||
const [draft, setDraft] = useState<ProfileStyle>(saved);
|
||||
const [section, setSection] = useState<SectionId | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
useEffect(() => { setDraft(saved); }, [saved]);
|
||||
@@ -138,196 +162,288 @@ export function ProfilePanel() {
|
||||
} catch (e) { setError(e instanceof Error ? e.message : t('profile.editor.saveFailed')); }
|
||||
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 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 plate = nameplateProps(draft.nameplate, draft.nameplateColors);
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-2">{t('profile.editor.title')}</h2>
|
||||
<p className="text-xs text-txt-tertiary -mt-3">{t('profile.editor.subtitle')}</p>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_360px] gap-5 items-start">
|
||||
<div className="space-y-5 min-w-0">
|
||||
{/* Name style */}
|
||||
<Section title={t('profile.editor.name')} hint={t('profile.editor.name.hint')}>
|
||||
<div className="text-xs text-txt-tertiary mb-1.5">{t('profile.editor.font')}</div>
|
||||
<div className="grid grid-cols-5 gap-1.5 mb-3">
|
||||
{NAME_FONTS.map((f) => (
|
||||
<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)}>
|
||||
<span className={`name-font-${f} text-[20px] leading-none text-txt-primary`}>Gg</span>
|
||||
<span className="text-[10px] text-txt-tertiary truncate w-full text-center">{t(`profile.font.${f}` as TranslationKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</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 className="flex flex-col h-full min-h-0">
|
||||
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-[320px_minmax(340px,380px)_1fr] gap-6">
|
||||
{/* Left: sections / editor */}
|
||||
<div className="min-h-0 overflow-y-auto scrollbar-thin pr-1">
|
||||
{section === null ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-txt-primary">{t('profile.editor.title')}</h2>
|
||||
<p className="text-xs text-txt-tertiary mt-1 mb-4">{t('profile.editor.subtitle')}</p>
|
||||
<div className="space-y-2">
|
||||
{SECTIONS.map((id) => (
|
||||
<button key={id} type="button" onClick={() => setSection(id)}
|
||||
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">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[13px] font-semibold text-txt-primary">{t(`profile.section.${id}` as TranslationKey)}</span>
|
||||
<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>
|
||||
<span className="text-[10px] text-txt-tertiary">{t(`profile.nameplate.${n}` as TranslationKey)}</span>
|
||||
{sectionPreview[id]}
|
||||
</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>
|
||||
</Section>
|
||||
|
||||
{/* Links */}
|
||||
<Section title={t('profile.editor.links')} hint={t('profile.editor.links.hint')}>
|
||||
<div className="space-y-2">
|
||||
{draft.links.map((l, i) => (
|
||||
<div key={i} 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 w-36 text-sm" />
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={() => setSection(null)} className="flex items-center gap-1.5 text-sm text-txt-secondary hover:text-txt-primary mb-3">
|
||||
<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>
|
||||
{t('profile.editor.back')}
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-4">{t(`profile.section.${section}` as TranslationKey)}</h2>
|
||||
{sectionEditor[section]}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live preview */}
|
||||
<aside className="xl:sticky xl:top-0">
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('profile.editor.preview')}</div>
|
||||
{/* Centre: live card + in-context samples */}
|
||||
<div className="min-h-0 overflow-y-auto scrollbar-thin">
|
||||
<Label>{t('profile.editor.preview')}</Label>
|
||||
<div className="rounded-[12px] overflow-hidden glass-modal select-none">
|
||||
<ProfileCard user={preview} style={draft} displayName={displayName} bannerSrc={bannerSrc} />
|
||||
</div>
|
||||
<p className="text-[11px] text-txt-tertiary mt-2">{t('profile.editor.preview.hint')}</p>
|
||||
</aside>
|
||||
<div className="mt-5 space-y-3">
|
||||
<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>
|
||||
|
||||
{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) && (
|
||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||
<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 pointer-events-auto">
|
||||
<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={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')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-shrink-0 pt-3 flex justify-center">
|
||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up">
|
||||
<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={() => 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">
|
||||
{saving ? t('profile.editor.saving') : t('profile.editor.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1141,6 +1141,21 @@ export const en = {
|
||||
'profile.widgets.rotation': 'Games in Rotation',
|
||||
'profile.widgets.wishlist': 'Want to Play',
|
||||
'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;
|
||||
|
||||
export type TranslationKey = keyof typeof en;
|
||||
|
||||
@@ -1140,4 +1140,19 @@ export const ptBR: Partial<Dictionary> = {
|
||||
'profile.widgets.rotation': 'Na rotação',
|
||||
'profile.widgets.wishlist': 'Quero jogar',
|
||||
'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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user