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
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:
@@ -37,7 +37,8 @@ export const PROFILE_EFFECTS = [
|
||||
export type ProfileEffect = (typeof PROFILE_EFFECTS)[number];
|
||||
|
||||
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;
|
||||
export type Nameplate = (typeof NAMEPLATES)[number];
|
||||
|
||||
@@ -48,6 +49,24 @@ export interface NameStyle {
|
||||
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 {
|
||||
/** Two hex colours behind the card and the banner fallback. */
|
||||
colors: [string, string];
|
||||
@@ -91,6 +110,7 @@ export interface ProfileWidgets {
|
||||
export interface ProfileStyle {
|
||||
name: NameStyle;
|
||||
theme: ProfileTheme | null;
|
||||
banner: BannerPosition;
|
||||
decoration: AvatarDecoration;
|
||||
effect: ProfileEffect;
|
||||
nameplate: Nameplate;
|
||||
@@ -119,9 +139,12 @@ export const PROFILE_LIMITS = {
|
||||
quoteAuthor: 40,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_BANNER_POSITION: BannerPosition = { x: 50, y: 50, zoom: 100 };
|
||||
|
||||
export const DEFAULT_PROFILE_STYLE: ProfileStyle = {
|
||||
name: { font: 'default', effect: 'solid', colors: ['#efefef', '#7c6cf6'] },
|
||||
theme: null,
|
||||
banner: DEFAULT_BANNER_POSITION,
|
||||
decoration: 'none',
|
||||
effect: '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 {
|
||||
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 {
|
||||
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 themeColors = theme && Array.isArray(theme.colors) ? theme.colors : null;
|
||||
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 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>;
|
||||
@@ -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])],
|
||||
},
|
||||
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),
|
||||
effect: pick(v.effect, PROFILE_EFFECTS, d.effect),
|
||||
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 { StyledName } from '../profile/StyledName';
|
||||
import { DecoratedAvatar } from '../profile/DecoratedAvatar';
|
||||
import { ProfileEffectLayer } from '../profile/ProfileEffectLayer';
|
||||
import { ProfileBanner } from '../profile/ProfileBanner';
|
||||
import { ProfileTagChip } from '../profile/ProfileTagChip';
|
||||
import { ProfileBadges } from '../profile/ProfileBadges';
|
||||
import { ProfileLinks } from '../profile/ProfileLinks';
|
||||
@@ -289,14 +289,7 @@ export function UserProfileModal() {
|
||||
<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">
|
||||
{/* Banner */}
|
||||
<div
|
||||
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} />
|
||||
<ProfileBanner src={bannerSrc} style={style} fallback={bannerFallback} className="h-[100px] flex-shrink-0" effectDensity={1.6}>
|
||||
{/* Close button */}
|
||||
<button
|
||||
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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</ProfileBanner>
|
||||
|
||||
{/* Header (avatar + name) */}
|
||||
<div className="px-5 flex-shrink-0 relative">
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useUIStore } from '../../../stores/uiStore';
|
||||
import { api } from '../../../api/client';
|
||||
import { useT, type TranslationKey } from '../../../i18n';
|
||||
import { parseFederatedUsername } from '../../../utils/identity';
|
||||
import { getProfileStyle, nameStyleProps, nameplateProps } from '../../../utils/profileStyle';
|
||||
import { ProfileCard } from '../../profile/ProfileCard';
|
||||
import { getProfileStyle, nameStyleProps, nameplateProps, surpriseProfileStyle } from '../../../utils/profileStyle';
|
||||
import { ProfileEditDialog } from '../../profile/ProfileEditDialog';
|
||||
import { ProfilePreviewStack } from '../../profile/ProfilePreviewStack';
|
||||
import { BannerPositioner } from '../../profile/BannerPositioner';
|
||||
import { DecoratedAvatar } from '../../profile/DecoratedAvatar';
|
||||
import { ProfileEffectLayer } from '../../profile/ProfileEffectLayer';
|
||||
import { ProfileTagChip } from '../../profile/ProfileTagChip';
|
||||
@@ -14,33 +16,36 @@ import { StyledName } from '../../profile/StyledName';
|
||||
import { Avatar } from '../../ui/Avatar';
|
||||
import {
|
||||
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,
|
||||
} from '@backspace/shared/src/profile';
|
||||
|
||||
/*
|
||||
* 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).
|
||||
* Layout follows the reference the owner pointed at: a rail of cards on the
|
||||
* left, each showing what it currently holds, and a live profile beside it.
|
||||
* 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,
|
||||
* which neither of the two earlier layouts managed.
|
||||
*/
|
||||
|
||||
type SectionId = 'nameplate' | 'avatar' | 'name' | 'theme' | 'effect' | 'identity' | 'links' | 'board';
|
||||
const SECTIONS: SectionId[] = ['nameplate', 'avatar', 'name', 'theme', 'effect', 'identity', 'links', 'board'];
|
||||
type SectionId = 'nameplate' | 'avatar' | 'name' | 'banner' | '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][] = [
|
||||
['#efefef', '#7c6cf6'], ['#f97316', '#fde047'], ['#22d3ee', '#a855f7'], ['#4ade80', '#22d3ee'],
|
||||
['#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 }) {
|
||||
return (
|
||||
<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. */
|
||||
const SECTION_ICON: Record<string, string> = {
|
||||
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',
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProfilePanel() {
|
||||
@@ -151,8 +158,11 @@ export function ProfilePanel() {
|
||||
const addToast = useUIStore((s) => s.addToast);
|
||||
|
||||
const saved = useMemo(() => getProfileStyle(user), [user]);
|
||||
/** Committed by Apply; what the rail and the big preview show. */
|
||||
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 [error, setError] = useState('');
|
||||
useEffect(() => { setDraft(saved); }, [saved]);
|
||||
@@ -160,11 +170,15 @@ export function ProfilePanel() {
|
||||
if (!user) return null;
|
||||
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 preview: typeof user = { ...user, profileStyle: draft };
|
||||
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 setW = <K extends keyof ProfileStyle['widgets']>(k: K, v: ProfileStyle['widgets'][K]) => setDraft((d) => ({ ...d, widgets: { ...d.widgets, [k]: v } }));
|
||||
const openSection = (id: SectionId) => { setPending(draft); setOpen(id); };
|
||||
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 () => {
|
||||
setSaving(true); setError('');
|
||||
@@ -176,56 +190,70 @@ export function ProfilePanel() {
|
||||
};
|
||||
|
||||
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> = {
|
||||
// ── What each rail card shows ──
|
||||
const cardBody: 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} />
|
||||
<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={24} 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} size={48}>
|
||||
<Avatar src={user.avatar} name={displayName} size={48} userId={user.homeUserId ?? user.id} user={user} />
|
||||
<div className="h-[76px] flex items-center justify-center">
|
||||
<DecoratedAvatar decoration={draft.decoration} size={52}>
|
||||
<Avatar src={user.avatar} name={displayName} size={52} 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>,
|
||||
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: (
|
||||
<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: (
|
||||
<div className="relative h-12 rounded-md overflow-hidden bg-gradient-to-br from-[#1e1b4b] to-[#0b0b10]">
|
||||
<ProfileEffectLayer effect={draft.effect} density={0.6} />
|
||||
<div className="relative h-[76px] rounded-lg overflow-hidden bg-gradient-to-br from-[#1e1b4b] to-[#0b0b10]">
|
||||
<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>
|
||||
</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 className="h-[76px] flex flex-col items-start justify-center gap-1.5 px-1">
|
||||
{draft.tag ? <ProfileTagChip tag={draft.tag} /> : <span className="text-[12px] text-txt-tertiary">{t('profile.editor.tag.empty')}</span>}
|
||||
<span className="text-[12px] text-txt-secondary truncate">{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>,
|
||||
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> = {
|
||||
// ── Dialog bodies ──
|
||||
const dialogBody: 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);
|
||||
const p = nameplateProps(n, d.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} />
|
||||
<button key={n} type="button" onClick={() => set('nameplate', n)} className={tileClass(d.nameplate === n)}>
|
||||
<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={22} 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>
|
||||
@@ -233,223 +261,224 @@ export function ProfilePanel() {
|
||||
);
|
||||
})}
|
||||
</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: (
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{AVATAR_DECORATIONS.map((d) => (
|
||||
<button key={d} type="button" onClick={() => set('decoration', d)} className={tileClass(draft.decoration === d)}>
|
||||
<DecoratedAvatar decoration={d} size={56}>
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
{AVATAR_DECORATIONS.map((dec) => (
|
||||
<button key={dec} type="button" onClick={() => set('decoration', dec)} className={tileClass(d.decoration === dec)}>
|
||||
<DecoratedAvatar decoration={dec} size={56}>
|
||||
<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>
|
||||
<span className="text-[11px] text-txt-secondary">{t(`profile.decoration.${dec}` as TranslationKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
|
||||
name: (
|
||||
<>
|
||||
<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) => (
|
||||
<button key={f} type="button" onClick={() => set('name', { ...draft.name, font: f })} className={tileClass(draft.name.font === f)}>
|
||||
<span className={`name-font-${f} text-[24px] leading-none text-txt-primary`}>Ag</span>
|
||||
<span className="text-[11px] text-txt-secondary">{t(`profile.font.${f}` as TranslationKey)}</span>
|
||||
<button key={f} type="button" onClick={() => set('name', { ...d.name, font: f })} className={tileClass(d.name.font === f)}>
|
||||
<span className={`name-font-${f} text-[22px] leading-none text-txt-primary`}>Ag</span>
|
||||
<span className="text-[10px] text-txt-secondary truncate w-full">{t(`profile.font.${f}` as TranslationKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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) => {
|
||||
const p = nameStyleProps({ font: draft.name.font, effect: e, colors: draft.name.colors });
|
||||
const label = t(`profile.effect.${e}` as TranslationKey);
|
||||
const p = nameStyleProps({ font: d.name.font, effect: e, colors: d.name.colors });
|
||||
return (
|
||||
<button key={e} type="button" onClick={() => set('name', { ...draft.name, effect: e })} className={tileClass(draft.name.effect === e)}>
|
||||
{/* The sample is the user's own name: an effect looks different
|
||||
<button key={e} type="button" onClick={() => set('name', { ...d.name, effect: e })} className={tileClass(d.name.effect === e)}>
|
||||
{/* Sampled on the user's own name: an effect reads differently
|
||||
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="text-[11px] text-txt-secondary">{label}</span>
|
||||
<span className={`${p.className} text-[15px] font-bold truncate max-w-full`} style={p.style} data-text={displayName}>{displayName}</span>
|
||||
<span className="text-[10px] text-txt-secondary">{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 })} />
|
||||
<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: (
|
||||
<>
|
||||
<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 })} />
|
||||
<button type="button" onClick={() => set('theme', null)} className={`${chipClass(d.theme === null)} mb-4`}>{t('profile.editor.theme.none')}</button>
|
||||
<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: (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{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} />
|
||||
<button key={e} type="button" onClick={() => set('effect', e)} className={tileClass(d.effect === e)}>
|
||||
<div className="relative w-full h-16 rounded-lg overflow-hidden bg-gradient-to-br from-[#1e1b4b] to-[#0b0b10]">
|
||||
<ProfileEffectLayer effect={e} density={0.8} />
|
||||
</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" />
|
||||
<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>
|
||||
<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" />
|
||||
<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={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: d.tag?.color ?? '#7c6cf6' }}>
|
||||
<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>
|
||||
</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>}
|
||||
{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>
|
||||
),
|
||||
|
||||
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) => (
|
||||
{d.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>
|
||||
<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', d.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" />
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
{d.links.length < PROFILE_LIMITS.links && (
|
||||
<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>
|
||||
</>
|
||||
),
|
||||
|
||||
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)} />
|
||||
{d.widgets.favoriteGame
|
||||
? <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)} />}
|
||||
</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">
|
||||
{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])} />}
|
||||
{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))} />)}
|
||||
{d.widgets.rotation.length < PROFILE_LIMITS.rotation && <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('rotation', [...d.widgets.rotation, g])} />}
|
||||
</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">
|
||||
{draft.widgets.wishlist.map((g, i) => (
|
||||
{d.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>
|
||||
<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>
|
||||
{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>
|
||||
<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" />}
|
||||
<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" />
|
||||
{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>
|
||||
),
|
||||
};
|
||||
|
||||
/** 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 (
|
||||
<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="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-6">
|
||||
{/* Rail */}
|
||||
<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] hover:border-white/[0.12] p-3 transition-colors group">
|
||||
<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">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" className="text-txt-secondary"><path d={SECTION_ICON[id]} /></svg>
|
||||
</span>
|
||||
<span className="text-[13px] font-semibold text-txt-primary flex-1">{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>
|
||||
</div>
|
||||
{sectionPreview[id]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
<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="grid grid-cols-2 gap-2.5">
|
||||
{RAIL.map(({ id, half }) => (
|
||||
<button key={id} type="button" onClick={() => openSection(id)}
|
||||
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">
|
||||
<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>
|
||||
</span>
|
||||
<span className="text-[12.5px] font-semibold text-txt-primary flex-1 truncate">{t(`profile.section.${id}` as TranslationKey)}</span>
|
||||
<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>
|
||||
</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>
|
||||
{cardBody[id]}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Right: the board as others see it */}
|
||||
{/* Live profile */}
|
||||
<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 className="grid grid-cols-1 xl:grid-cols-[minmax(320px,380px)_1fr] gap-6">
|
||||
<ProfilePreviewStack user={user} style={draft} displayName={displayName} bannerSrc={bannerSrc} />
|
||||
<div>
|
||||
<Label>{t('profile.tab.board')}</Label>
|
||||
<ProfileBoard widgets={draft.widgets} />
|
||||
</div>
|
||||
</div>
|
||||
<ProfileBoard widgets={draft.widgets} />
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
<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')}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { StyledName } from './StyledName';
|
||||
import { DecoratedAvatar } from './DecoratedAvatar';
|
||||
import { ProfileEffectLayer } from './ProfileEffectLayer';
|
||||
import { ProfileBanner } from './ProfileBanner';
|
||||
import { ProfileTagChip } from './ProfileTagChip';
|
||||
import { ProfileBadges } from './ProfileBadges';
|
||||
import { ProfileLinks } from './ProfileLinks';
|
||||
@@ -46,15 +46,7 @@ export function ProfileCard({ user, style, displayName, bannerSrc, space, member
|
||||
{/* Theme wash behind the whole card */}
|
||||
{theme && <div className="absolute inset-0 opacity-[0.22]" style={{ background: theme }} aria-hidden="true" />}
|
||||
|
||||
{/* Banner */}
|
||||
<div
|
||||
className="h-[88px] relative"
|
||||
style={bannerSrc
|
||||
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: { background: bannerFallback }}
|
||||
>
|
||||
<ProfileEffectLayer effect={style.effect} />
|
||||
</div>
|
||||
<ProfileBanner src={bannerSrc} style={style} fallback={bannerFallback} className="h-[88px]" />
|
||||
|
||||
<div className="px-4 pb-4 relative">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1169,6 +1169,23 @@ export const en = {
|
||||
'profile.section.identity': 'Pronouns & Tag',
|
||||
'profile.section.links': 'Links',
|
||||
'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.items': 'items',
|
||||
'profile.editor.inChat': 'In chat',
|
||||
|
||||
@@ -1168,6 +1168,23 @@ export const ptBR: Partial<Dictionary> = {
|
||||
'profile.section.identity': 'Pronomes e tag',
|
||||
'profile.section.links': 'Links',
|
||||
'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.items': 'itens',
|
||||
'profile.editor.inChat': 'No chat',
|
||||
|
||||
@@ -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-blossom { --np1: #fb7185; --np2: #f9a8d4; }
|
||||
.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); }
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
normalizeProfileStyle, DEFAULT_PROFILE_STYLE, PROFILE_LIMITS,
|
||||
NAME_FONTS, NAME_EFFECTS, AVATAR_DECORATIONS, PROFILE_EFFECTS, NAMEPLATES,
|
||||
} from '@backspace/shared/src/profile';
|
||||
import { computeBadges, detectLinkIcon } from './profileStyle';
|
||||
import { computeBadges, detectLinkIcon, surpriseProfileStyle } from './profileStyle';
|
||||
import type { User } from '@backspace/shared';
|
||||
|
||||
describe('normalizeProfileStyle', () => {
|
||||
@@ -117,3 +117,51 @@ describe('computeBadges', () => {
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { User, MemberWithUser, Space } from '@backspace/shared';
|
||||
import {
|
||||
DEFAULT_PROFILE_STYLE,
|
||||
normalizeProfileStyle,
|
||||
NAME_FONTS,
|
||||
NAME_EFFECTS,
|
||||
AVATAR_DECORATIONS,
|
||||
PROFILE_EFFECTS,
|
||||
NAMEPLATES,
|
||||
type ProfileStyle,
|
||||
type NameStyle,
|
||||
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');
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user