feat(profile): banner repositioning, and the editor rebuilt on the reference's shape
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

Three things, from the screenshots.

Banner repositioning, which was a real hole. A banner was always cropped dead
centre, and a GIF could not go through the still-image cropper at all — so
there was no way to frame one. The crop is now two percentages and a zoom
stored in the profile (`banner: {x, y, zoom}`), and the banner renders as an
<img> with object-position plus a scale rather than a CSS background: those two
compose into pan-and-zoom, where `background-size: cover` cannot be zoomed
without knowing the file's dimensions. The editor drags the live element, so a
GIF keeps animating while you frame it, and nothing is ever re-encoded.

The editor now has the reference's shape: a rail of cards on the left, each
showing what it currently holds, and the live profile beside it. A card opens a
dialog with the options on one side and a preview on the other. That is the
part both earlier attempts got wrong — the first put a 360px preview inside a
640px column, the second made you leave the preview to open a section. Here
they are on screen together at full size.

The dialog edits a copy: Cancel discards it, Apply commits to the draft, and
the draft is only persisted by Save. So backing out of a dialog cannot cost
unrelated work, and neither can the per-section reset in its header.

"Surprise me" randomises the look and nothing else. Fonts, effects, decoration,
theme and nameplate change; pronouns, tag, links, board and the banner crop are
the user's own words and framing, and rolling dice over those would be
vandalism. A test asserts exactly that over 40 rolls, and that every roll lands
on ids the normaliser accepts.

Five more nameplates in the y2k direction — holo, chrome, vapor, matrix, ember
— since the existing ten were all plain gradients.

Verified: shared build, web build, server typecheck, 523 web tests (5 new:
banner clamping and round-trip, randomiser bounds).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 10:02:29 -03:00
co-authored by Claude Opus 5
parent e6eba7a2b7
commit 033ba63762
13 changed files with 657 additions and 190 deletions
+36 -1
View File
@@ -37,7 +37,8 @@ export const PROFILE_EFFECTS = [
export type ProfileEffect = (typeof PROFILE_EFFECTS)[number]; export type ProfileEffect = (typeof PROFILE_EFFECTS)[number];
export const NAMEPLATES = [ export const NAMEPLATES = [
'none', 'sunset', 'ocean', 'neon', 'forest', 'royal', 'brasil', 'cyber', 'blossom', 'midnight', 'custom', 'none', 'sunset', 'ocean', 'neon', 'forest', 'royal', 'brasil', 'cyber', 'blossom', 'midnight',
'holo', 'chrome', 'vapor', 'matrix', 'ember', 'custom',
] as const; ] as const;
export type Nameplate = (typeof NAMEPLATES)[number]; export type Nameplate = (typeof NAMEPLATES)[number];
@@ -48,6 +49,24 @@ export interface NameStyle {
colors: [string, string]; colors: [string, string];
} }
/**
* Where the banner image sits inside its frame.
*
* A banner is cropped by the frame, and until now the crop was always dead
* centre — fine for a photo shot for it, wrong for anything else, and there was
* no way to fix it for a GIF, which cannot go through the still-image cropper.
* `x`/`y` are CSS object-position percentages; `zoom` scales the image inside
* the frame, so together they are a pan-and-zoom crop that costs nothing at
* render time and keeps the original file untouched.
*/
export interface BannerPosition {
/** 0–100. 50 is centred. */
x: number;
y: number;
/** 100–300 (%). 100 fills the frame. */
zoom: number;
}
export interface ProfileTheme { export interface ProfileTheme {
/** Two hex colours behind the card and the banner fallback. */ /** Two hex colours behind the card and the banner fallback. */
colors: [string, string]; colors: [string, string];
@@ -91,6 +110,7 @@ export interface ProfileWidgets {
export interface ProfileStyle { export interface ProfileStyle {
name: NameStyle; name: NameStyle;
theme: ProfileTheme | null; theme: ProfileTheme | null;
banner: BannerPosition;
decoration: AvatarDecoration; decoration: AvatarDecoration;
effect: ProfileEffect; effect: ProfileEffect;
nameplate: Nameplate; nameplate: Nameplate;
@@ -119,9 +139,12 @@ export const PROFILE_LIMITS = {
quoteAuthor: 40, quoteAuthor: 40,
} as const; } as const;
export const DEFAULT_BANNER_POSITION: BannerPosition = { x: 50, y: 50, zoom: 100 };
export const DEFAULT_PROFILE_STYLE: ProfileStyle = { export const DEFAULT_PROFILE_STYLE: ProfileStyle = {
name: { font: 'default', effect: 'solid', colors: ['#efefef', '#7c6cf6'] }, name: { font: 'default', effect: 'solid', colors: ['#efefef', '#7c6cf6'] },
theme: null, theme: null,
banner: DEFAULT_BANNER_POSITION,
decoration: 'none', decoration: 'none',
effect: 'none', effect: 'none',
nameplate: 'none', nameplate: 'none',
@@ -157,6 +180,12 @@ function pick<T extends string>(value: unknown, allowed: readonly T[], fallback:
function hex(value: unknown, fallback: string): string { function hex(value: unknown, fallback: string): string {
return typeof value === 'string' && HEX.test(value) ? value.toLowerCase() : fallback; return typeof value === 'string' && HEX.test(value) ? value.toLowerCase() : fallback;
} }
/** Clamps to a range, falling back when the value is not a finite number. */
function num(value: unknown, min: number, max: number, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value * 10) / 10));
}
function str(value: unknown, max: number): string { function str(value: unknown, max: number): string {
return typeof value === 'string' ? value.trim().slice(0, max) : ''; return typeof value === 'string' ? value.trim().slice(0, max) : '';
} }
@@ -212,6 +241,7 @@ export function normalizeProfileStyle(input: unknown): ProfileStyle {
const theme = (v.theme && typeof v.theme === 'object' ? v.theme : null) as Record<string, unknown> | null; const theme = (v.theme && typeof v.theme === 'object' ? v.theme : null) as Record<string, unknown> | null;
const themeColors = theme && Array.isArray(theme.colors) ? theme.colors : null; const themeColors = theme && Array.isArray(theme.colors) ? theme.colors : null;
const npColors = Array.isArray(v.nameplateColors) ? v.nameplateColors : []; const npColors = Array.isArray(v.nameplateColors) ? v.nameplateColors : [];
const banner = (v.banner && typeof v.banner === 'object' ? v.banner : {}) as Record<string, unknown>;
const tag = (v.tag && typeof v.tag === 'object' ? v.tag : null) as Record<string, unknown> | null; const tag = (v.tag && typeof v.tag === 'object' ? v.tag : null) as Record<string, unknown> | null;
const tagText = tag ? str(tag.text, PROFILE_LIMITS.tagMax).toUpperCase().replace(/[^A-Z0-9]/g, '') : ''; const tagText = tag ? str(tag.text, PROFILE_LIMITS.tagMax).toUpperCase().replace(/[^A-Z0-9]/g, '') : '';
const widgets = (v.widgets && typeof v.widgets === 'object' ? v.widgets : {}) as Record<string, unknown>; const widgets = (v.widgets && typeof v.widgets === 'object' ? v.widgets : {}) as Record<string, unknown>;
@@ -235,6 +265,11 @@ export function normalizeProfileStyle(input: unknown): ProfileStyle {
colors: [hex(nameColors[0], d.name.colors[0]), hex(nameColors[1], d.name.colors[1])], colors: [hex(nameColors[0], d.name.colors[0]), hex(nameColors[1], d.name.colors[1])],
}, },
theme: themeColors ? { colors: [hex(themeColors[0], '#7c6cf6'), hex(themeColors[1], '#0b0b10')] } : null, theme: themeColors ? { colors: [hex(themeColors[0], '#7c6cf6'), hex(themeColors[1], '#0b0b10')] } : null,
banner: {
x: num(banner.x, 0, 100, d.banner.x),
y: num(banner.y, 0, 100, d.banner.y),
zoom: num(banner.zoom, 100, 300, d.banner.zoom),
},
decoration: pick(v.decoration, AVATAR_DECORATIONS, d.decoration), decoration: pick(v.decoration, AVATAR_DECORATIONS, d.decoration),
effect: pick(v.effect, PROFILE_EFFECTS, d.effect), effect: pick(v.effect, PROFILE_EFFECTS, d.effect),
nameplate: pick(v.nameplate, NAMEPLATES, d.nameplate), nameplate: pick(v.nameplate, NAMEPLATES, d.nameplate),
@@ -18,7 +18,7 @@ import { parseFederatedUsername, isSelf, canonicalUserMatch } from '../../utils/
import { loadFederatedMutuals, type TaggedMutualFriend, type MutualSpace } from '../../utils/mutuals'; import { loadFederatedMutuals, type TaggedMutualFriend, type MutualSpace } from '../../utils/mutuals';
import { StyledName } from '../profile/StyledName'; import { StyledName } from '../profile/StyledName';
import { DecoratedAvatar } from '../profile/DecoratedAvatar'; import { DecoratedAvatar } from '../profile/DecoratedAvatar';
import { ProfileEffectLayer } from '../profile/ProfileEffectLayer'; import { ProfileBanner } from '../profile/ProfileBanner';
import { ProfileTagChip } from '../profile/ProfileTagChip'; import { ProfileTagChip } from '../profile/ProfileTagChip';
import { ProfileBadges } from '../profile/ProfileBadges'; import { ProfileBadges } from '../profile/ProfileBadges';
import { ProfileLinks } from '../profile/ProfileLinks'; import { ProfileLinks } from '../profile/ProfileLinks';
@@ -289,14 +289,7 @@ export function UserProfileModal() {
<div className="absolute inset-0 bg-black/50" onClick={closeModal} /> <div className="absolute inset-0 bg-black/50" onClick={closeModal} />
<div className="relative max-w-lg w-full mx-4 max-h-[calc(100vh-2rem)] flex flex-col glass-modal rounded-lg animate-slide-up overflow-hidden"> <div className="relative max-w-lg w-full mx-4 max-h-[calc(100vh-2rem)] flex flex-col glass-modal rounded-lg animate-slide-up overflow-hidden">
{/* Banner */} {/* Banner */}
<div <ProfileBanner src={bannerSrc} style={style} fallback={bannerFallback} className="h-[100px] flex-shrink-0" effectDensity={1.6}>
className="h-[100px] flex-shrink-0 relative"
style={bannerSrc
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { background: themeGradient(style) ?? bannerFallback }
}
>
<ProfileEffectLayer effect={style.effect} density={1.6} />
{/* Close button */} {/* Close button */}
<button <button
onClick={closeModal} onClick={closeModal}
@@ -306,7 +299,7 @@ export function UserProfileModal() {
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" /> <path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg> </svg>
</button> </button>
</div> </ProfileBanner>
{/* Header (avatar + name) */} {/* Header (avatar + name) */}
<div className="px-5 flex-shrink-0 relative"> <div className="px-5 flex-shrink-0 relative">
@@ -4,8 +4,10 @@ import { useUIStore } from '../../../stores/uiStore';
import { api } from '../../../api/client'; import { api } from '../../../api/client';
import { useT, type TranslationKey } from '../../../i18n'; import { useT, type TranslationKey } from '../../../i18n';
import { parseFederatedUsername } from '../../../utils/identity'; import { parseFederatedUsername } from '../../../utils/identity';
import { getProfileStyle, nameStyleProps, nameplateProps } from '../../../utils/profileStyle'; import { getProfileStyle, nameStyleProps, nameplateProps, surpriseProfileStyle } from '../../../utils/profileStyle';
import { ProfileCard } from '../../profile/ProfileCard'; import { ProfileEditDialog } from '../../profile/ProfileEditDialog';
import { ProfilePreviewStack } from '../../profile/ProfilePreviewStack';
import { BannerPositioner } from '../../profile/BannerPositioner';
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 { ProfileTagChip } from '../../profile/ProfileTagChip';
@@ -14,33 +16,36 @@ 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,
DEFAULT_PROFILE_STYLE, normalizeProfileStyle, steamCoverUrl, DEFAULT_PROFILE_STYLE, DEFAULT_BANNER_POSITION, normalizeProfileStyle, steamCoverUrl,
type ProfileStyle, type GameEntry, type ProfileStyle, type GameEntry,
} from '@backspace/shared/src/profile'; } from '@backspace/shared/src/profile';
/* /*
* Layout, after the first version was rightly called cramped: the settings * Layout follows the reference the owner pointed at: a rail of cards on the
* column is 640px wide and this editor had a 360px preview beside 6-column * left, each showing what it currently holds, and a live profile beside it.
* grids in it. Now it is three panels, the way Discord's profile editor is — * Clicking a card opens a dialog with the options on one side and a preview on
* * the other — so the thing being changed and its effect are on screen together,
* left : the list of sections, each showing its current value; picking one * which neither of the two earlier layouts managed.
* 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'; type SectionId = 'nameplate' | 'avatar' | 'name' | 'banner' | 'theme' | 'effect' | 'identity' | 'links' | 'board';
const SECTIONS: SectionId[] = ['nameplate', 'avatar', 'name', 'theme', 'effect', 'identity', 'links', 'board'];
/** Rail order, and which cards sit two to a row — mirrors the reference. */
const RAIL: { id: SectionId; half?: boolean }[] = [
{ id: 'nameplate' },
{ id: 'avatar', half: true }, { id: 'name', half: true },
{ id: 'banner', half: true }, { id: 'theme', half: true },
{ id: 'effect', half: true }, { id: 'identity', half: true },
{ id: 'links' },
{ id: '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'],
['#38bdf8', '#f472b6'], ['#e2e8f0', '#475569'], ['#fde047', '#b45309'], ['#c084fc', '#22d3ee'],
]; ];
// ─── Small controls ─────────────────────────────────────────────────────────
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">
@@ -59,9 +64,6 @@ function ColorPair({ value, onChange }: { value: [string, string]; onChange: (v:
); );
} }
const tileClass = (active: boolean) =>
`group/tile relative rounded-xl border-2 p-3 flex flex-col items-center gap-2 transition-all text-center ${active ? 'border-accent-primary bg-accent-primary/10 shadow-[0_0_0_3px_rgba(124,108,246,0.15)]' : 'border-white/[0.06] bg-white/[0.03] hover:bg-white/[0.07] hover:border-white/[0.14]'}`;
/** One glyph per section, so the list scans by shape rather than by reading. */ /** One glyph per section, so the list scans by shape rather than by reading. */
const SECTION_ICON: Record<string, string> = { const SECTION_ICON: Record<string, string> = {
nameplate: 'M3 6h18v4H3V6zm0 6h12v2H3v-2zm0 4h8v2H3v-2z', nameplate: 'M3 6h18v4H3V6zm0 6h12v2H3v-2zm0 4h8v2H3v-2z',
@@ -73,12 +75,6 @@ const SECTION_ICON: Record<string, string> = {
links: 'M3.9 12a4.1 4.1 0 0 1 4.1-4.1h4V6H8a6 6 0 0 0 0 12h4v-1.9H8A4.1 4.1 0 0 1 3.9 12zM8 13h8v-2H8v2zm8-7h-4v1.9h4a4.1 4.1 0 0 1 0 8.2h-4V18h4a6 6 0 0 0 0-12z', links: 'M3.9 12a4.1 4.1 0 0 1 4.1-4.1h4V6H8a6 6 0 0 0 0 12h4v-1.9H8A4.1 4.1 0 0 1 3.9 12zM8 13h8v-2H8v2zm8-7h-4v1.9h4a4.1 4.1 0 0 1 0 8.2h-4V18h4a6 6 0 0 0 0-12z',
board: 'M4 4h7v7H4V4zm9 0h7v4h-7V4zm0 6h7v10h-7V10zM4 13h7v7H4v-7z', board: 'M4 4h7v7H4V4zm9 0h7v4h-7V4zm0 6h7v10h-7V10zM4 13h7v7H4v-7z',
}; };
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 ────────────────────────────────────────────────────────────────── // ─── Games ──────────────────────────────────────────────────────────────────
@@ -142,6 +138,17 @@ function GameEditor({ game, onChange, onRemove, withNote }: { game: GameEntry; o
); );
} }
const tileClass = (active: boolean) =>
`group/tile relative rounded-xl border-2 p-3 flex flex-col items-center gap-2 transition-all text-center ${active ? 'border-accent-primary bg-accent-primary/10 shadow-[0_0_0_3px_rgba(124,108,246,0.15)]' : 'border-white/[0.06] bg-white/[0.03] hover:bg-white/[0.07] hover:border-white/[0.14]'}`;
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>;
}
// ─── Panel ────────────────────────────────────────────────────────────────── // ─── Panel ──────────────────────────────────────────────────────────────────
export function ProfilePanel() { export function ProfilePanel() {
@@ -151,8 +158,11 @@ export function ProfilePanel() {
const addToast = useUIStore((s) => s.addToast); const addToast = useUIStore((s) => s.addToast);
const saved = useMemo(() => getProfileStyle(user), [user]); const saved = useMemo(() => getProfileStyle(user), [user]);
/** Committed by Apply; what the rail and the big preview show. */
const [draft, setDraft] = useState<ProfileStyle>(saved); const [draft, setDraft] = useState<ProfileStyle>(saved);
const [section, setSection] = useState<SectionId | null>(null); /** The dialog's own copy — Cancel throws it away without touching `draft`. */
const [pending, setPending] = useState<ProfileStyle | null>(null);
const [open, setOpen] = 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]);
@@ -160,11 +170,15 @@ export function ProfilePanel() {
if (!user) return null; if (!user) return null;
const displayName = user.displayName ?? parseFederatedUsername(user.username).baseName; const displayName = user.displayName ?? parseFederatedUsername(user.username).baseName;
const bannerSrc = user.banner ? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : api.uploads.url(user.banner)) : null; const bannerSrc = user.banner ? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : api.uploads.url(user.banner)) : null;
const preview: typeof user = { ...user, profileStyle: draft };
const isDirty = JSON.stringify(normalizeProfileStyle(draft)) !== JSON.stringify(saved); const isDirty = JSON.stringify(normalizeProfileStyle(draft)) !== JSON.stringify(saved);
const set = <K extends keyof ProfileStyle>(k: K, v: ProfileStyle[K]) => setDraft((d) => ({ ...d, [k]: v })); const openSection = (id: SectionId) => { setPending(draft); setOpen(id); };
const setW = <K extends keyof ProfileStyle['widgets']>(k: K, v: ProfileStyle['widgets'][K]) => setDraft((d) => ({ ...d, widgets: { ...d.widgets, [k]: v } })); const closeDialog = () => { setOpen(null); setPending(null); };
const applyDialog = () => { if (pending) setDraft(pending); closeDialog(); };
const d = pending ?? draft; // what the dialogs read and write
const set = <K extends keyof ProfileStyle>(k: K, v: ProfileStyle[K]) => setPending((p) => ({ ...(p ?? draft), [k]: v }));
const setW = <K extends keyof ProfileStyle['widgets']>(k: K, v: ProfileStyle['widgets'][K]) =>
setPending((p) => { const base = p ?? draft; return { ...base, widgets: { ...base.widgets, [k]: v } }; });
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setError(''); setSaving(true); setError('');
@@ -176,56 +190,70 @@ export function ProfilePanel() {
}; };
const plate = nameplateProps(draft.nameplate, draft.nameplateColors); 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); 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 ── // ── What each rail card shows ──
const sectionPreview: Record<SectionId, React.ReactNode> = { const cardBody: Record<SectionId, React.ReactNode> = {
nameplate: ( nameplate: (
<div className={`h-9 rounded-md flex items-center gap-2 px-2 ${plate?.className ?? 'bg-white/[0.05]'}`} style={plate?.style}> <div className={`h-10 rounded-lg 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} /> <Avatar src={user.avatar} name={displayName} size={24} userId={user.homeUserId ?? user.id} user={user} />
<span className="text-[12px] text-txt-primary truncate">{displayName}</span> <span className="text-[12px] text-txt-primary truncate">{displayName}</span>
</div> </div>
), ),
avatar: ( avatar: (
<div className="flex items-center gap-4 py-1"> <div className="h-[76px] flex items-center justify-center">
<DecoratedAvatar decoration={draft.decoration} size={48}> <DecoratedAvatar decoration={draft.decoration} size={52}>
<Avatar src={user.avatar} name={displayName} size={48} userId={user.homeUserId ?? user.id} user={user} /> <Avatar src={user.avatar} name={displayName} size={52} userId={user.homeUserId ?? user.id} user={user} />
</DecoratedAvatar> </DecoratedAvatar>
<span className="text-xs text-txt-tertiary">{t(`profile.decoration.${draft.decoration}` as TranslationKey)}</span>
</div> </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>, name: (
<div className="h-[76px] flex items-center justify-center px-1">
<StyledName name={displayName} style={draft.name} className="text-[19px] font-bold truncate" />
</div>
),
banner: (
<div className="h-[76px] rounded-lg overflow-hidden bg-white/[0.05] flex items-center justify-center">
{bannerSrc
? <img src={bannerSrc} alt="" className="w-full h-full object-cover" style={{ objectPosition: `${draft.banner.x}% ${draft.banner.y}%`, transform: draft.banner.zoom === 100 ? undefined : `scale(${draft.banner.zoom / 100})` }} />
: <span className="text-[11px] text-txt-tertiary">{t('profile.editor.banner.none')}</span>}
</div>
),
theme: ( 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)' }} /> <div className="h-[76px] rounded-lg" style={{ background: draft.theme ? `linear-gradient(135deg, ${draft.theme.colors[0]}, ${draft.theme.colors[1]})` : 'rgba(255,255,255,0.05)' }} />
), ),
effect: ( effect: (
<div className="relative h-12 rounded-md overflow-hidden bg-gradient-to-br from-[#1e1b4b] to-[#0b0b10]"> <div className="relative h-[76px] rounded-lg overflow-hidden bg-gradient-to-br from-[#1e1b4b] to-[#0b0b10]">
<ProfileEffectLayer effect={draft.effect} density={0.6} /> <ProfileEffectLayer effect={draft.effect} density={0.7} />
<span className="absolute bottom-1 left-2 text-[11px] text-txt-secondary">{t(`profile.profileEffect.${draft.effect}` as TranslationKey)}</span> <span className="absolute bottom-1 left-2 text-[11px] text-txt-secondary">{t(`profile.profileEffect.${draft.effect}` as TranslationKey)}</span>
</div> </div>
), ),
identity: ( identity: (
<div className="flex items-center gap-2 text-[12px] text-txt-secondary min-h-[28px]"> <div className="h-[76px] flex flex-col items-start justify-center gap-1.5 px-1">
{draft.tag ? <ProfileTagChip tag={draft.tag} /> : <span className="text-txt-tertiary">—</span>} {draft.tag ? <ProfileTagChip tag={draft.tag} /> : <span className="text-[12px] text-txt-tertiary">{t('profile.editor.tag.empty')}</span>}
{draft.pronouns && <span>{draft.pronouns}</span>} <span className="text-[12px] text-txt-secondary truncate">{draft.pronouns || '—'}</span>
</div> </div>
), ),
links: <div className="text-[12px] text-txt-secondary">{draft.links.length} / {PROFILE_LIMITS.links}</div>, links: (
board: <div className="text-[12px] text-txt-secondary">{boardCount > 0 ? `${boardCount} ${t('profile.editor.items')}` : '—'}</div>, <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> = { // ── Dialog bodies ──
const dialogBody: Record<SectionId, React.ReactNode> = {
nameplate: ( nameplate: (
<> <>
<p className="text-xs text-txt-tertiary mb-3">{t('profile.editor.nameplate.hint')}</p> <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"> <div className="grid grid-cols-2 gap-2 mb-4">
{NAMEPLATES.map((n) => { {NAMEPLATES.map((n) => {
const p = nameplateProps(n, draft.nameplateColors); const p = nameplateProps(n, d.nameplateColors);
return ( return (
<button key={n} type="button" onClick={() => set('nameplate', n)} className={tileClass(draft.nameplate === n)}> <button key={n} type="button" onClick={() => set('nameplate', n)} className={tileClass(d.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}> <div className={`w-full h-10 rounded-lg 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} /> <Avatar src={user.avatar} name={displayName} size={22} userId={user.homeUserId ?? user.id} user={user} />
<span className="text-[11px] text-txt-primary truncate">{displayName}</span> <span className="text-[11px] text-txt-primary truncate">{displayName}</span>
</div> </div>
<span className="text-[11px] text-txt-secondary">{t(`profile.nameplate.${n}` as TranslationKey)}</span> <span className="text-[11px] text-txt-secondary">{t(`profile.nameplate.${n}` as TranslationKey)}</span>
@@ -233,225 +261,226 @@ export function ProfilePanel() {
); );
})} })}
</div> </div>
{draft.nameplate === 'custom' && <><Label>{t('profile.editor.colors')}</Label><ColorPair value={draft.nameplateColors} onChange={(c) => set('nameplateColors', c)} /></>} {d.nameplate === 'custom' && <><Label>{t('profile.editor.colors')}</Label><ColorPair value={d.nameplateColors} onChange={(c) => set('nameplateColors', c)} /></>}
</> </>
), ),
avatar: ( avatar: (
<div className="grid grid-cols-3 gap-2.5"> <div className="grid grid-cols-4 gap-2.5">
{AVATAR_DECORATIONS.map((d) => ( {AVATAR_DECORATIONS.map((dec) => (
<button key={d} type="button" onClick={() => set('decoration', d)} className={tileClass(draft.decoration === d)}> <button key={dec} type="button" onClick={() => set('decoration', dec)} className={tileClass(d.decoration === dec)}>
<DecoratedAvatar decoration={d} size={56}> <DecoratedAvatar decoration={dec} size={56}>
<Avatar src={user.avatar} name={displayName} size={56} userId={user.homeUserId ?? user.id} user={user} /> <Avatar src={user.avatar} name={displayName} size={56} userId={user.homeUserId ?? user.id} user={user} />
</DecoratedAvatar> </DecoratedAvatar>
<span className="text-[11px] text-txt-secondary">{t(`profile.decoration.${d}` as TranslationKey)}</span> <span className="text-[11px] text-txt-secondary">{t(`profile.decoration.${dec}` as TranslationKey)}</span>
</button> </button>
))} ))}
</div> </div>
), ),
name: ( name: (
<> <>
<Label>{t('profile.editor.font')}</Label> <Label>{t('profile.editor.font')}</Label>
<div className="grid grid-cols-3 gap-2 mb-5"> <div className="grid grid-cols-4 gap-2 mb-5">
{NAME_FONTS.map((f) => ( {NAME_FONTS.map((f) => (
<button key={f} type="button" onClick={() => set('name', { ...draft.name, font: f })} className={tileClass(draft.name.font === f)}> <button key={f} type="button" onClick={() => set('name', { ...d.name, font: f })} className={tileClass(d.name.font === f)}>
<span className={`name-font-${f} text-[24px] leading-none text-txt-primary`}>Ag</span> <span className={`name-font-${f} text-[22px] leading-none text-txt-primary`}>Ag</span>
<span className="text-[11px] text-txt-secondary">{t(`profile.font.${f}` as TranslationKey)}</span> <span className="text-[10px] text-txt-secondary truncate w-full">{t(`profile.font.${f}` as TranslationKey)}</span>
</button> </button>
))} ))}
</div> </div>
<Label>{t('profile.editor.effect')}</Label> <Label>{t('profile.editor.effect')}</Label>
<div className="grid grid-cols-2 gap-2 mb-5"> <div className="grid grid-cols-3 gap-2 mb-5">
{NAME_EFFECTS.map((e) => { {NAME_EFFECTS.map((e) => {
const p = nameStyleProps({ font: draft.name.font, effect: e, colors: draft.name.colors }); const p = nameStyleProps({ font: d.name.font, effect: e, colors: d.name.colors });
const label = t(`profile.effect.${e}` as TranslationKey);
return ( return (
<button key={e} type="button" onClick={() => set('name', { ...draft.name, effect: e })} className={tileClass(draft.name.effect === e)}> <button key={e} type="button" onClick={() => set('name', { ...d.name, effect: e })} className={tileClass(d.name.effect === e)}>
{/* The sample is the user's own name: an effect looks different {/* Sampled on the user's own name: an effect reads differently
on a short label than on the name it will actually carry. */} on a short label than on the name it will actually carry. */}
<span className={`${p.className} text-[17px] font-bold truncate max-w-full`} style={p.style} data-text={displayName}>{displayName}</span> <span className={`${p.className} text-[15px] font-bold truncate max-w-full`} style={p.style} data-text={displayName}>{displayName}</span>
<span className="text-[11px] text-txt-secondary">{label}</span> <span className="text-[10px] text-txt-secondary">{t(`profile.effect.${e}` as TranslationKey)}</span>
</button> </button>
); );
})} })}
</div> </div>
<Label>{t('profile.editor.colors')}</Label> <Label>{t('profile.editor.colors')}</Label>
<ColorPair value={draft.name.colors} onChange={(c) => set('name', { ...draft.name, colors: c })} /> <ColorPair value={d.name.colors} onChange={(c) => set('name', { ...d.name, colors: c })} />
</> </>
), ),
banner: bannerSrc ? (
<>
<p className="text-xs text-txt-tertiary mb-3">{t('profile.editor.banner.hint')}</p>
<BannerPositioner src={bannerSrc} value={d.banner} onChange={(v) => set('banner', v)} />
</>
) : (
<div className="text-sm text-txt-tertiary py-8 text-center">{t('profile.editor.banner.upload')}</div>
),
theme: ( theme: (
<> <>
<p className="text-xs text-txt-tertiary mb-3">{t('profile.editor.theme.hint')}</p> <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> <button type="button" onClick={() => set('theme', null)} className={`${chipClass(d.theme === null)} mb-4`}>{t('profile.editor.theme.none')}</button>
<ColorPair value={draft.theme?.colors ?? ['#7c6cf6', '#0b0b10']} onChange={(c) => set('theme', { colors: c })} /> <div className="grid grid-cols-4 gap-2 mb-4">
{PALETTES.map((pl) => (
<button key={pl.join()} type="button" onClick={() => set('theme', { colors: pl })}
className={`h-14 rounded-xl border-2 transition-transform hover:scale-[1.03] ${d.theme && d.theme.colors[0] === pl[0] && d.theme.colors[1] === pl[1] ? 'border-accent-primary' : 'border-transparent'}`}
style={{ background: `linear-gradient(135deg, ${pl[0]}, ${pl[1]})` }} />
))}
</div>
<Label>{t('profile.editor.colors')}</Label>
<ColorPair value={d.theme?.colors ?? ['#7c6cf6', '#0b0b10']} onChange={(c) => set('theme', { colors: c })} />
</> </>
), ),
effect: ( effect: (
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-3 gap-2.5">
{PROFILE_EFFECTS.map((e) => ( {PROFILE_EFFECTS.map((e) => (
<button key={e} type="button" onClick={() => set('effect', e)} className={tileClass(draft.effect === e)}> <button key={e} type="button" onClick={() => set('effect', e)} className={tileClass(d.effect === e)}>
<div className="relative w-full h-16 rounded-md overflow-hidden bg-gradient-to-br from-[#1e1b4b] to-[#0b0b10]"> <div className="relative w-full h-16 rounded-lg overflow-hidden bg-gradient-to-br from-[#1e1b4b] to-[#0b0b10]">
<ProfileEffectLayer effect={e} density={0.7} /> <ProfileEffectLayer effect={e} density={0.8} />
</div> </div>
<span className="text-[11px] text-txt-secondary">{t(`profile.profileEffect.${e}` as TranslationKey)}</span> <span className="text-[11px] text-txt-secondary">{t(`profile.profileEffect.${e}` as TranslationKey)}</span>
</button> </button>
))} ))}
</div> </div>
), ),
identity: ( identity: (
<div className="space-y-5"> <div className="space-y-5">
<label className="block"> <label className="block">
<Label>{t('profile.editor.pronouns')}</Label> <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" /> <input type="text" value={d.pronouns} maxLength={PROFILE_LIMITS.pronouns} onChange={(e) => set('pronouns', e.target.value)} placeholder={t('profile.editor.pronouns.placeholder')} className="input-standard w-full" />
</label> </label>
<div> <div>
<Label>{t('profile.editor.tag')}</Label> <Label>{t('profile.editor.tag')}</Label>
<div className="flex items-center gap-2"> <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={d.tag?.emoji ?? ''} onChange={(e) => set('tag', { text: d.tag?.text ?? '', emoji: e.target.value, color: d.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" /> <input type="text" value={d.tag?.text ?? ''} maxLength={PROFILE_LIMITS.tagMax} onChange={(e) => set('tag', { text: e.target.value.toUpperCase(), emoji: d.tag?.emoji ?? '', color: d.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' }}> <label className="w-10 h-10 rounded-lg overflow-hidden border border-white/20 cursor-pointer flex-shrink-0" style={{ background: d.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" /> <input type="color" value={d.tag?.color ?? '#7c6cf6'} onChange={(e) => set('tag', { text: d.tag?.text ?? '', emoji: d.tag?.emoji ?? '', color: e.target.value })} className="opacity-0 w-full h-full cursor-pointer" />
</label> </label>
</div> </div>
<div className="flex items-center justify-between mt-2"> <div className="flex items-center justify-between mt-2">
<p className="text-[11px] text-txt-tertiary">{t('profile.editor.tag.hint')}</p> <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>} {d.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> </div>
</div> </div>
), ),
links: ( links: (
<> <>
<p className="text-xs text-txt-tertiary mb-3">{t('profile.editor.links.hint')}</p> <p className="text-xs text-txt-tertiary mb-3">{t('profile.editor.links.hint')}</p>
<div className="space-y-2"> <div className="space-y-2">
{draft.links.map((l, i) => ( {d.links.map((l, i) => (
<div key={i} className="rounded-xl bg-white/[0.04] p-2.5 space-y-2"> <div key={i} className="rounded-xl bg-white/[0.04] p-2.5 space-y-2">
<div className="flex items-center gap-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" /> <input type="text" value={l.label} maxLength={PROFILE_LIMITS.linkLabel} onChange={(e) => set('links', d.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> <button type="button" onClick={() => set('links', d.links.filter((_, j) => j !== i))} className="text-txt-tertiary hover:text-accent-rose text-xl leading-none px-1">×</button>
</div> </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" /> <input type="url" value={l.url} maxLength={PROFILE_LIMITS.linkUrl} onChange={(e) => set('links', d.links.map((x, j) => j === i ? { ...x, url: e.target.value } : x))} placeholder="https://" className="input-standard w-full text-sm" />
</div> </div>
))} ))}
{draft.links.length < PROFILE_LIMITS.links && ( {d.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> <button type="button" onClick={() => set('links', [...d.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> </div>
</> </>
), ),
board: ( board: (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<Label>{t('profile.widgets.favoriteGame')}</Label> <Label>{t('profile.widgets.favoriteGame')}</Label>
{draft.widgets.favoriteGame {d.widgets.favoriteGame
? <GameEditor game={draft.widgets.favoriteGame} withNote onChange={(g) => setW('favoriteGame', g)} onRemove={() => setW('favoriteGame', null)} /> ? <GameEditor game={d.widgets.favoriteGame} withNote onChange={(g) => setW('favoriteGame', g)} onRemove={() => setW('favoriteGame', null)} />
: <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('favoriteGame', g)} />} : <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('favoriteGame', g)} />}
</div> </div>
<div> <div>
<Label>{t('profile.widgets.rotation')} · {draft.widgets.rotation.length}/{PROFILE_LIMITS.rotation}</Label> <Label>{t('profile.widgets.rotation')} · {d.widgets.rotation.length}/{PROFILE_LIMITS.rotation}</Label>
<div className="space-y-2"> <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))} />)} {d.widgets.rotation.map((g, i) => <GameEditor key={i} game={g} onChange={(n) => setW('rotation', d.widgets.rotation.map((x, j) => j === i ? n : x))} onRemove={() => setW('rotation', d.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])} />} {d.widgets.rotation.length < PROFILE_LIMITS.rotation && <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('rotation', [...d.widgets.rotation, g])} />}
</div> </div>
</div> </div>
<div> <div>
<Label>{t('profile.widgets.wishlist')} · {draft.widgets.wishlist.length}/{PROFILE_LIMITS.wishlist}</Label> <Label>{t('profile.widgets.wishlist')} · {d.widgets.wishlist.length}/{PROFILE_LIMITS.wishlist}</Label>
<div className="flex flex-wrap gap-2 mb-2"> <div className="flex flex-wrap gap-2 mb-2">
{draft.widgets.wishlist.map((g, i) => ( {d.widgets.wishlist.map((g, i) => (
<div key={i} className="relative w-[56px]" title={g.name}> <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>} {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> <button type="button" onClick={() => setW('wishlist', d.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>
))} ))}
</div> </div>
{draft.widgets.wishlist.length < PROFILE_LIMITS.wishlist && <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('wishlist', [...draft.widgets.wishlist, g])} />} {d.widgets.wishlist.length < PROFILE_LIMITS.wishlist && <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('wishlist', [...d.widgets.wishlist, g])} />}
</div> </div>
<div> <div>
<Label>{t('profile.editor.quote')}</Label> <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" /> <textarea value={d.widgets.quote?.text ?? ''} maxLength={PROFILE_LIMITS.quoteText} rows={3} onChange={(e) => setW('quote', e.target.value ? { text: e.target.value, author: d.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" />} {d.widgets.quote && <input type="text" value={d.widgets.quote.author} maxLength={PROFILE_LIMITS.quoteAuthor} onChange={(e) => setW('quote', { text: d.widgets.quote!.text, author: e.target.value })} placeholder={t('profile.editor.quote.author')} className="input-standard w-full text-sm mt-2" />}
</div> </div>
</div> </div>
), ),
}; };
/** Restores just this section, so a reset never costs unrelated work. */
const resetSection = (id: SectionId) => {
const z = DEFAULT_PROFILE_STYLE;
const patch: Record<SectionId, Partial<ProfileStyle>> = {
nameplate: { nameplate: z.nameplate, nameplateColors: z.nameplateColors },
avatar: { decoration: z.decoration },
name: { name: z.name },
banner: { banner: DEFAULT_BANNER_POSITION },
theme: { theme: z.theme },
effect: { effect: z.effect },
identity: { pronouns: z.pronouns, tag: z.tag },
links: { links: z.links },
board: { widgets: z.widgets },
};
setPending((p) => ({ ...(p ?? draft), ...patch[id] }));
};
return ( return (
<div className="flex flex-col h-full min-h-0"> <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"> <div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-6">
{/* Left: sections / editor */} {/* Rail */}
<div className="min-h-0 overflow-y-auto scrollbar-thin pr-1"> <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> <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> <p className="text-xs text-txt-tertiary mt-1 mb-4">{t('profile.editor.subtitle')}</p>
<div className="space-y-2"> <div className="grid grid-cols-2 gap-2.5">
{SECTIONS.map((id) => ( {RAIL.map(({ id, half }) => (
<button key={id} type="button" onClick={() => setSection(id)} <button key={id} type="button" onClick={() => openSection(id)}
className="w-full text-left rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/[0.05] hover:border-white/[0.12] p-3 transition-colors group"> className={`${half ? '' : 'col-span-2'} text-left rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/[0.05] hover:border-white/[0.12] p-3 transition-colors group`}>
<div className="flex items-center gap-2 mb-2.5"> <div className="flex items-center gap-2 mb-2.5">
<span className="w-6 h-6 rounded-md bg-white/[0.06] flex items-center justify-center flex-shrink-0"> <span className="w-6 h-6 rounded-md bg-white/[0.06] flex items-center justify-center flex-shrink-0">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" className="text-txt-secondary"><path d={SECTION_ICON[id]} /></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" className="text-txt-secondary"><path d={SECTION_ICON[id]} /></svg>
</span> </span>
<span className="text-[13px] font-semibold text-txt-primary flex-1">{t(`profile.section.${id}` as TranslationKey)}</span> <span className="text-[12.5px] font-semibold text-txt-primary flex-1 truncate">{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 group-hover:translate-x-0.5 transition-transform"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" /></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary group-hover:text-txt-secondary group-hover:translate-x-0.5 transition-transform flex-shrink-0"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" /></svg>
</div> </div>
{sectionPreview[id]} {cardBody[id]}
</button> </button>
))} ))}
</div> </div>
</> <button type="button" onClick={() => setDraft(surpriseProfileStyle(draft))}
) : ( className="w-full mt-3 py-2.5 rounded-xl border border-dashed border-white/15 text-sm text-txt-secondary hover:text-txt-primary hover:bg-white/[0.04] transition-colors">
<> 🎲 {t('profile.editor.surprise')}
<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> </button>
<h2 className="text-lg font-semibold text-txt-primary mb-4">{t(`profile.section.${section}` as TranslationKey)}</h2>
{sectionEditor[section]}
</>
)}
</div> </div>
{/* Centre: live card + in-context samples */} {/* Live profile */}
<div className="min-h-0 overflow-y-auto scrollbar-thin"> <div className="min-h-0 overflow-y-auto scrollbar-thin">
<Label>{t('profile.editor.preview')}</Label> <div className="grid grid-cols-1 xl:grid-cols-[minmax(320px,380px)_1fr] gap-6">
<div className="rounded-[12px] overflow-hidden glass-modal select-none"> <ProfilePreviewStack user={user} style={draft} displayName={displayName} bannerSrc={bannerSrc} />
<ProfileCard user={preview} style={draft} displayName={displayName} bannerSrc={bannerSrc} />
</div>
<div className="mt-5 space-y-3">
<div> <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> <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} /> <ProfileBoard widgets={draft.widgets} />
</div> </div>
</div> </div>
</div>
</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>} {error && <div className="mt-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>}
@@ -460,12 +489,27 @@ export function ProfilePanel() {
<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">
<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(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={() => 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-5 py-1.5 bg-accent-primary hover:bg-accent-primary/85 text-white text-sm font-semibold 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>
)} )}
{open && pending && (
<ProfileEditDialog
title={t(`profile.section.${open}` as TranslationKey)}
onClose={closeDialog}
onApply={applyDialog}
onReset={() => resetSection(open)}
onSurprise={open === 'name' || open === 'avatar' || open === 'effect' || open === 'nameplate' || open === 'theme'
? () => setPending(surpriseProfileStyle(pending))
: undefined}
preview={<ProfilePreviewStack user={user} style={pending} displayName={displayName} bannerSrc={bannerSrc} compact />}
>
{dialogBody[open]}
</ProfileEditDialog>
)}
</div> </div>
); );
} }
@@ -0,0 +1,81 @@
import React, { useCallback, useRef } from 'react';
import type { BannerPosition } from '@backspace/shared/src/profile';
import { DEFAULT_BANNER_POSITION } from '@backspace/shared/src/profile';
import { bannerImageStyle } from '../../utils/profileStyle';
import { useT } from '../../i18n';
interface BannerPositionerProps {
src: string;
value: BannerPosition;
onChange: (v: BannerPosition) => void;
/** Aspect of the frame being previewed, width / height. */
aspect?: number;
}
/**
* Drag-to-reposition for the banner, with a zoom slider.
*
* Works on the live element rather than a canvas crop, so an animated GIF keeps
* animating while you frame it — the case the still-image cropper could never
* handle. Nothing is re-encoded: the result is two percentages and a scale.
*/
export function BannerPositioner({ src, value, onChange, aspect = 4 }: BannerPositionerProps) {
const t = useT();
const frameRef = useRef<HTMLDivElement>(null);
const drag = useRef<{ x: number; y: number; from: BannerPosition } | null>(null);
const onPointerDown = useCallback((e: React.PointerEvent) => {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
drag.current = { x: e.clientX, y: e.clientY, from: value };
}, [value]);
const onPointerMove = useCallback((e: React.PointerEvent) => {
const d = drag.current;
const frame = frameRef.current;
if (!d || !frame) return;
const rect = frame.getBoundingClientRect();
// Dragging the picture right reveals more of its left side, so the
// object-position percentage goes down — hence the subtraction.
const nx = d.from.x - ((e.clientX - d.x) / rect.width) * 100;
const ny = d.from.y - ((e.clientY - d.y) / rect.height) * 100;
onChange({ ...value, x: Math.min(100, Math.max(0, Math.round(nx))), y: Math.min(100, Math.max(0, Math.round(ny))) });
}, [onChange, value]);
const endDrag = useCallback(() => { drag.current = null; }, []);
return (
<div>
<div
ref={frameRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className="relative w-full overflow-hidden rounded-lg cursor-grab active:cursor-grabbing touch-none bg-black/40 select-none"
style={{ aspectRatio: String(aspect) }}
>
<img src={src} alt="" draggable={false} className="absolute inset-0 w-full h-full object-cover select-none" style={bannerImageStyle(value)} />
{/* Grid overlay: without a reference the drag has no felt anchor. */}
<div className="absolute inset-0 pointer-events-none opacity-40"
style={{ backgroundImage: 'linear-gradient(rgba(255,255,255,.25) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.25) 1px, transparent 1px)', backgroundSize: '33.33% 33.33%' }} />
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-1.5 pointer-events-none">
<span className="px-2 py-0.5 rounded-full bg-black/60 text-[11px] text-white/80">{t('profile.editor.banner.drag')}</span>
</div>
</div>
<div className="flex items-center gap-3 mt-3">
<span className="text-[11px] text-txt-tertiary w-10">{t('profile.editor.banner.zoom')}</span>
<input
type="range" min={100} max={300} step={1} value={value.zoom}
onChange={(e) => onChange({ ...value, zoom: Number(e.target.value) })}
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer bg-surface-base [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
style={{ background: `linear-gradient(to right, rgb(var(--accent-primary)) 0%, rgb(var(--accent-primary)) ${(value.zoom - 100) / 2}%, rgb(var(--interactive-muted)) ${(value.zoom - 100) / 2}%, rgb(var(--interactive-muted)) 100%)` }}
/>
<span className="text-[11px] text-txt-tertiary tabular-nums w-9 text-right">{value.zoom}%</span>
<button type="button" onClick={() => onChange(DEFAULT_BANNER_POSITION)} className="text-[11px] text-txt-tertiary hover:text-txt-secondary">
{t('profile.editor.banner.center')}
</button>
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import React from 'react';
import type { ProfileStyle } from '@backspace/shared/src/profile';
import { bannerImageStyle, themeGradient } from '../../utils/profileStyle';
import { ProfileEffectLayer } from './ProfileEffectLayer';
interface ProfileBannerProps {
src: string | null;
style: ProfileStyle;
/** Gradient used when there is no image and no theme. */
fallback: string;
className?: string;
effectDensity?: number;
children?: React.ReactNode;
}
/**
* The banner strip: image (framed by the user's crop), theme gradient, or the
* identity gradient — in that order — with the profile effect over it.
*/
export function ProfileBanner({ src, style, fallback, className = '', effectDensity = 1, children }: ProfileBannerProps) {
const theme = themeGradient(style);
return (
<div className={`relative overflow-hidden ${className}`} style={src ? undefined : { background: theme ?? fallback }}>
{src && (
<img
src={src}
alt=""
draggable={false}
className="absolute inset-0 w-full h-full object-cover select-none"
style={bannerImageStyle(style.banner)}
/>
)}
<ProfileEffectLayer effect={style.effect} density={effectDensity} />
{children}
</div>
);
}
@@ -6,7 +6,7 @@ import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username'; import { Username } from '../ui/Username';
import { StyledName } from './StyledName'; import { StyledName } from './StyledName';
import { DecoratedAvatar } from './DecoratedAvatar'; import { DecoratedAvatar } from './DecoratedAvatar';
import { ProfileEffectLayer } from './ProfileEffectLayer'; import { ProfileBanner } from './ProfileBanner';
import { ProfileTagChip } from './ProfileTagChip'; import { ProfileTagChip } from './ProfileTagChip';
import { ProfileBadges } from './ProfileBadges'; import { ProfileBadges } from './ProfileBadges';
import { ProfileLinks } from './ProfileLinks'; import { ProfileLinks } from './ProfileLinks';
@@ -46,15 +46,7 @@ export function ProfileCard({ user, style, displayName, bannerSrc, space, member
{/* Theme wash behind the whole card */} {/* Theme wash behind the whole card */}
{theme && <div className="absolute inset-0 opacity-[0.22]" style={{ background: theme }} aria-hidden="true" />} {theme && <div className="absolute inset-0 opacity-[0.22]" style={{ background: theme }} aria-hidden="true" />}
{/* Banner */} <ProfileBanner src={bannerSrc} style={style} fallback={bannerFallback} className="h-[88px]" />
<div
className="h-[88px] relative"
style={bannerSrc
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { background: bannerFallback }}
>
<ProfileEffectLayer effect={style.effect} />
</div>
<div className="px-4 pb-4 relative"> <div className="px-4 pb-4 relative">
<div className="flex items-end justify-between mt-[-44px] mb-2.5"> <div className="flex items-end justify-between mt-[-44px] mb-2.5">
@@ -0,0 +1,80 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useT } from '../../i18n';
interface ProfileEditDialogProps {
title: string;
onClose: () => void;
onApply: () => void;
/** Restores this section to its defaults, leaving the rest of the draft. */
onReset?: () => void;
onSurprise?: () => void;
/** The controls. */
children: React.ReactNode;
/** Live preview, shown beside the controls. */
preview: React.ReactNode;
}
/**
* The dialog every section is edited in: controls on the left, a live preview
* on the right, Cancel/Apply at the bottom.
*
* This replaces a drill-down inside the settings column. The point is that the
* preview and the controls are visible at the same time and at full size — the
* earlier layouts made you choose between the two.
*/
export function ProfileEditDialog({ title, onClose, onApply, onReset, onSurprise, children, preview }: ProfileEditDialogProps) {
const t = useT();
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
return createPortal(
<div className="fixed inset-0 z-[260] flex items-center justify-center animate-fade-in p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative w-full max-w-4xl max-h-[calc(100vh-4rem)] flex flex-col glass-modal rounded-2xl animate-slide-up overflow-hidden">
<header className="flex items-center justify-between px-5 py-4 flex-shrink-0">
<h2 className="text-[17px] font-bold text-txt-primary">{title}</h2>
<div className="flex items-center gap-1">
{onReset && (
<button type="button" onClick={onReset} title={t('profile.editor.resetSection')}
className="w-8 h-8 rounded-lg flex items-center justify-center text-txt-tertiary hover:text-txt-primary hover:bg-white/[0.06] transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 5V2L8 6l4 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z" /></svg>
</button>
)}
<button type="button" onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-txt-tertiary hover:text-txt-primary hover:bg-white/[0.06] transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" /></svg>
</button>
</div>
</header>
<div className="flex-1 min-h-0 grid grid-cols-1 md:grid-cols-[1fr_320px] gap-5 px-5 pb-4 overflow-hidden">
<div className="min-h-0 overflow-y-auto scrollbar-thin pr-1">{children}</div>
<aside className="min-h-0 overflow-y-auto scrollbar-thin hidden md:block">{preview}</aside>
</div>
<footer className="flex items-center justify-between gap-3 px-5 py-3.5 border-t border-white/[0.06] flex-shrink-0">
{onSurprise ? (
<button type="button" onClick={onSurprise}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm text-txt-secondary hover:text-txt-primary bg-white/[0.05] hover:bg-white/[0.09] transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zM8.5 9A1.5 1.5 0 1 1 7 7.5 1.5 1.5 0 0 1 8.5 9zm7 7A1.5 1.5 0 1 1 14 14.5 1.5 1.5 0 0 1 15.5 16zm0-7A1.5 1.5 0 1 1 14 7.5 1.5 1.5 0 0 1 15.5 9zm-7 7A1.5 1.5 0 1 1 7 14.5 1.5 1.5 0 0 1 8.5 16zm3.5-3.5A1.5 1.5 0 1 1 10.5 11 1.5 1.5 0 0 1 12 12.5z" /></svg>
{t('profile.editor.surprise')}
</button>
) : <span />}
<div className="flex items-center gap-2">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg text-sm font-medium text-txt-secondary hover:text-txt-primary bg-white/[0.05] hover:bg-white/[0.09] transition-colors">
{t('profile.editor.cancel')}
</button>
<button type="button" onClick={onApply} className="px-5 py-2 rounded-lg text-sm font-semibold bg-accent-primary hover:bg-accent-primary/85 text-white transition-colors">
{t('profile.editor.apply')}
</button>
</div>
</footer>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,66 @@
import React from 'react';
import type { User } from '@backspace/shared';
import type { ProfileStyle } from '@backspace/shared/src/profile';
import { Avatar } from '../ui/Avatar';
import { ProfileCard } from './ProfileCard';
import { ProfileTagChip } from './ProfileTagChip';
import { nameStyleProps, nameplateProps } from '../../utils/profileStyle';
import { useT } from '../../i18n';
interface ProfilePreviewStackProps {
user: User;
style: ProfileStyle;
displayName: string;
bannerSrc: string | null;
/** Card only — used inside the edit dialogs, where space is tight. */
compact?: boolean;
}
/**
* The three places a profile is actually seen: the card, a chat line, and the
* member-list row. Shared by the editor's main preview and by every edit
* dialog, so "what you are changing" is always shown in context rather than as
* an isolated swatch.
*/
export function ProfilePreviewStack({ user, style, displayName, bannerSrc, compact = false }: ProfilePreviewStackProps) {
const t = useT();
const nameP = nameStyleProps(style.name);
const plate = nameplateProps(style.nameplate, style.nameplateColors);
const preview: User = { ...user, profileStyle: style };
return (
<div className="space-y-4">
<div className="rounded-[12px] overflow-hidden glass-modal select-none">
<ProfileCard user={preview} style={style} displayName={displayName} bannerSrc={bannerSrc} />
</div>
{!compact && (
<>
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('profile.editor.inChat')}</div>
<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} data-text={displayName}>{displayName}</span>
{style.tag && <ProfileTagChip tag={style.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>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('profile.editor.inMembers')}</div>
<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>
{style.tag && <ProfileTagChip tag={style.tag} className="scale-90 origin-left" />}
</div>
</div>
</>
)}
</div>
);
}
+17
View File
@@ -1169,6 +1169,23 @@ export const en = {
'profile.section.identity': 'Pronouns & Tag', 'profile.section.identity': 'Pronouns & Tag',
'profile.section.links': 'Links', 'profile.section.links': 'Links',
'profile.section.board': 'Board', 'profile.section.board': 'Board',
'profile.section.banner': 'Banner Position',
'profile.editor.banner.hint': 'Drag the image to frame it, and zoom in if you want a tighter crop. Works on GIFs too — nothing is re-encoded.',
'profile.editor.banner.drag': 'Drag to reposition',
'profile.editor.banner.zoom': 'Zoom',
'profile.editor.banner.center': 'Centre',
'profile.editor.banner.none': 'No banner',
'profile.editor.banner.upload': 'Upload a banner under Account first, then frame it here.',
'profile.editor.cancel': 'Cancel',
'profile.editor.apply': 'Apply',
'profile.editor.surprise': 'Surprise me',
'profile.editor.resetSection': 'Reset this section',
'profile.editor.tag.empty': 'No tag',
'profile.nameplate.holo': 'Holo',
'profile.nameplate.chrome': 'Chrome',
'profile.nameplate.vapor': 'Vapor',
'profile.nameplate.matrix': 'Matrix',
'profile.nameplate.ember': 'Ember',
'profile.editor.back': 'Back', 'profile.editor.back': 'Back',
'profile.editor.items': 'items', 'profile.editor.items': 'items',
'profile.editor.inChat': 'In chat', 'profile.editor.inChat': 'In chat',
+17
View File
@@ -1168,6 +1168,23 @@ export const ptBR: Partial<Dictionary> = {
'profile.section.identity': 'Pronomes e tag', 'profile.section.identity': 'Pronomes e tag',
'profile.section.links': 'Links', 'profile.section.links': 'Links',
'profile.section.board': 'Quadro', 'profile.section.board': 'Quadro',
'profile.section.banner': 'Posição do banner',
'profile.editor.banner.hint': 'Arraste a imagem para enquadrar, e dê zoom se quiser um corte mais fechado. Funciona com GIF também — nada é recodificado.',
'profile.editor.banner.drag': 'Arraste para reposicionar',
'profile.editor.banner.zoom': 'Zoom',
'profile.editor.banner.center': 'Centralizar',
'profile.editor.banner.none': 'Sem banner',
'profile.editor.banner.upload': 'Envie um banner em Conta primeiro, depois enquadre aqui.',
'profile.editor.cancel': 'Cancelar',
'profile.editor.apply': 'Aplicar',
'profile.editor.surprise': 'Surpreenda-me',
'profile.editor.resetSection': 'Zerar esta seção',
'profile.editor.tag.empty': 'Sem tag',
'profile.nameplate.holo': 'Holo',
'profile.nameplate.chrome': 'Cromado',
'profile.nameplate.vapor': 'Vapor',
'profile.nameplate.matrix': 'Matrix',
'profile.nameplate.ember': 'Brasa',
'profile.editor.back': 'Voltar', 'profile.editor.back': 'Voltar',
'profile.editor.items': 'itens', 'profile.editor.items': 'itens',
'profile.editor.inChat': 'No chat', 'profile.editor.inChat': 'No chat',
+5
View File
@@ -878,3 +878,8 @@
.nameplate-cyber { background: repeating-linear-gradient(135deg, rgba(34,211,238,0.35) 0 6px, rgba(217,70,239,0.3) 6px 12px), linear-gradient(90deg, rgba(0,0,0,0.3), transparent); } .nameplate-cyber { background: repeating-linear-gradient(135deg, rgba(34,211,238,0.35) 0 6px, rgba(217,70,239,0.3) 6px 12px), linear-gradient(90deg, rgba(0,0,0,0.3), transparent); }
.nameplate-blossom { --np1: #fb7185; --np2: #f9a8d4; } .nameplate-blossom { --np1: #fb7185; --np2: #f9a8d4; }
.nameplate-midnight { --np1: #1e1b4b; --np2: #312e81; } .nameplate-midnight { --np1: #1e1b4b; --np2: #312e81; }
.nameplate-holo { background: linear-gradient(90deg, rgba(34,211,238,.55), rgba(167,139,250,.5) 30%, rgba(244,114,182,.5) 55%, rgba(251,191,36,.4) 75%, transparent); background-size: 200% 100%; animation: name-slide 7s linear infinite; }
.nameplate-chrome { background: linear-gradient(90deg, rgba(226,232,240,.55), rgba(100,116,139,.5) 22%, rgba(248,250,252,.6) 38%, rgba(71,85,105,.45) 58%, transparent); }
.nameplate-vapor { background: linear-gradient(90deg, rgba(244,114,182,.55), rgba(34,211,238,.45) 55%, transparent), repeating-linear-gradient(0deg, rgba(255,255,255,.07) 0 2px, transparent 2px 5px); }
.nameplate-matrix { background: linear-gradient(90deg, rgba(22,163,74,.5), rgba(4,120,87,.35) 55%, transparent), repeating-linear-gradient(90deg, rgba(74,222,128,.14) 0 1px, transparent 1px 5px); }
.nameplate-ember { background: linear-gradient(90deg, rgba(220,38,38,.55), rgba(249,115,22,.5) 35%, rgba(251,191,36,.35) 60%, transparent); }
+49 -1
View File
@@ -3,7 +3,7 @@ import {
normalizeProfileStyle, DEFAULT_PROFILE_STYLE, PROFILE_LIMITS, normalizeProfileStyle, DEFAULT_PROFILE_STYLE, PROFILE_LIMITS,
NAME_FONTS, NAME_EFFECTS, AVATAR_DECORATIONS, PROFILE_EFFECTS, NAMEPLATES, NAME_FONTS, NAME_EFFECTS, AVATAR_DECORATIONS, PROFILE_EFFECTS, NAMEPLATES,
} from '@backspace/shared/src/profile'; } from '@backspace/shared/src/profile';
import { computeBadges, detectLinkIcon } from './profileStyle'; import { computeBadges, detectLinkIcon, surpriseProfileStyle } from './profileStyle';
import type { User } from '@backspace/shared'; import type { User } from '@backspace/shared';
describe('normalizeProfileStyle', () => { describe('normalizeProfileStyle', () => {
@@ -117,3 +117,51 @@ describe('computeBadges', () => {
expect(computeBadges({ ...base, isAdmin: true, createdAt: now }, { now })).toEqual(['admin']); expect(computeBadges({ ...base, isAdmin: true, createdAt: now }, { now })).toEqual(['admin']);
}); });
}); });
describe('banner framing', () => {
it('defaults to centred at 100%', () => {
expect(normalizeProfileStyle({}).banner).toEqual({ x: 50, y: 50, zoom: 100 });
});
it('clamps the crop into range instead of rejecting it', () => {
expect(normalizeProfileStyle({ banner: { x: -40, y: 900, zoom: 5000 } }).banner).toEqual({ x: 0, y: 100, zoom: 300 });
expect(normalizeProfileStyle({ banner: { x: 'left', y: null, zoom: NaN } }).banner).toEqual({ x: 50, y: 50, zoom: 100 });
});
it('keeps a real crop through a round trip', () => {
const framed = { x: 12.5, y: 80, zoom: 175 };
expect(normalizeProfileStyle(normalizeProfileStyle({ banner: framed })).banner).toEqual(framed);
});
});
describe('surpriseProfileStyle', () => {
const base = normalizeProfileStyle({
pronouns: 'ele/dele',
tag: { text: 'STRT', emoji: '🔥', color: '#f97316' },
links: [{ label: 'site', url: 'https://streethosting.com.br/' }],
banner: { x: 10, y: 90, zoom: 220 },
widgets: { quote: { text: 'comendo piranhas', author: '' } },
});
it('never touches what the user wrote or framed', () => {
// Rolling dice over someone's own words would be vandalism, so the
// randomiser is restricted to the purely visual fields.
for (let i = 0; i < 40; i++) {
const s = surpriseProfileStyle(base);
expect(s.pronouns).toBe(base.pronouns);
expect(s.tag).toEqual(base.tag);
expect(s.links).toEqual(base.links);
expect(s.banner).toEqual(base.banner);
expect(s.widgets).toEqual(base.widgets);
}
});
it('only ever produces ids the normaliser accepts', () => {
for (let i = 0; i < 40; i++) {
const s = surpriseProfileStyle(base);
expect(normalizeProfileStyle(s)).toEqual(s);
expect(s.decoration).not.toBe('none');
expect(s.nameplate).not.toBe('custom');
}
});
});
+52
View File
@@ -3,6 +3,11 @@ import type { User, MemberWithUser, Space } from '@backspace/shared';
import { import {
DEFAULT_PROFILE_STYLE, DEFAULT_PROFILE_STYLE,
normalizeProfileStyle, normalizeProfileStyle,
NAME_FONTS,
NAME_EFFECTS,
AVATAR_DECORATIONS,
PROFILE_EFFECTS,
NAMEPLATES,
type ProfileStyle, type ProfileStyle,
type NameStyle, type NameStyle,
type Nameplate, type Nameplate,
@@ -92,3 +97,50 @@ export function computeBadges(user: User, ctx: BadgeContext = {}): BadgeId[] {
if (s.name.effect !== 'solid' || s.decoration !== 'none' || s.effect !== 'none') out.push('stylist'); if (s.name.effect !== 'solid' || s.decoration !== 'none' || s.effect !== 'none') out.push('stylist');
return out; return out;
} }
// ─── Banner framing ───────────────────────────────────────────────────────────
/**
* Props for the banner <img>. An image element rather than a CSS background:
* `object-position` and a `transform: scale` compose cleanly into pan-and-zoom,
* where `background-size: cover` cannot be zoomed without knowing the file's
* dimensions. It also plays animated GIFs, which is the case that had no crop
* path at all.
*/
export function bannerImageStyle(pos: ProfileStyle['banner']): CSSProperties {
return {
objectPosition: `${pos.x}% ${pos.y}%`,
transform: pos.zoom === 100 ? undefined : `scale(${pos.zoom / 100})`,
transformOrigin: `${pos.x}% ${pos.y}%`,
};
}
// ─── Randomiser ───────────────────────────────────────────────────────────────
/** Two-colour pairs that look deliberate together, for "Surprise me". */
const SURPRISE_PALETTES: [string, string][] = [
['#efefef', '#7c6cf6'], ['#f97316', '#fde047'], ['#22d3ee', '#a855f7'],
['#4ade80', '#22d3ee'], ['#f472b6', '#fbbf24'], ['#fb7185', '#7c3aed'],
['#a3e635', '#16a34a'], ['#ffffff', '#94a3b8'], ['#38bdf8', '#f472b6'],
];
function sample<T>(items: readonly T[]): T {
return items[Math.floor(Math.random() * items.length)]!;
}
/**
* Randomises the look, never the content: fonts, effects, decoration, theme and
* nameplate change; pronouns, tag, links, board and the banner crop are the
* user's own words and framing, and rolling dice over them would be vandalism.
*/
export function surpriseProfileStyle(current: ProfileStyle): ProfileStyle {
const colors = sample(SURPRISE_PALETTES);
return {
...current,
name: { font: sample(NAME_FONTS), effect: sample(NAME_EFFECTS), colors: [...colors] as [string, string] },
decoration: sample(AVATAR_DECORATIONS.filter((d) => d !== 'none')),
effect: sample(PROFILE_EFFECTS),
nameplate: sample(NAMEPLATES.filter((n) => n !== 'custom')),
theme: Math.random() < 0.6 ? { colors: [...sample(SURPRISE_PALETTES)] as [string, string] } : null,
};
}