diff --git a/packages/shared/src/profile.ts b/packages/shared/src/profile.ts index ad91436b..06a38e9d 100644 --- a/packages/shared/src/profile.ts +++ b/packages/shared/src/profile.ts @@ -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(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 | 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; const tag = (v.tag && typeof v.tag === 'object' ? v.tag : null) as Record | 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; @@ -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), diff --git a/packages/web/src/components/modals/UserProfileModal.tsx b/packages/web/src/components/modals/UserProfileModal.tsx index dc8f4ad9..25e92c03 100644 --- a/packages/web/src/components/modals/UserProfileModal.tsx +++ b/packages/web/src/components/modals/UserProfileModal.tsx @@ -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() {
{/* Banner */} -
- + {/* Close button */} -
+ {/* Header (avatar + name) */}
diff --git a/packages/web/src/components/modals/settingsPanels/ProfilePanel.tsx b/packages/web/src/components/modals/settingsPanels/ProfilePanel.tsx index fe8c4df9..71f1a37f 100644 --- a/packages/web/src/components/modals/settingsPanels/ProfilePanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/ProfilePanel.tsx @@ -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 (
@@ -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 = { nameplate: 'M3 6h18v4H3V6zm0 6h12v2H3v-2zm0 4h8v2H3v-2z', @@ -73,12 +75,6 @@ const SECTION_ICON: Record = { 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
{children}
; -} // ─── 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
{children}
; +} + + // ─── 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(saved); - const [section, setSection] = useState(null); + /** The dialog's own copy — Cancel throws it away without touching `draft`. */ + const [pending, setPending] = useState(null); + const [open, setOpen] = useState(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: K, v: ProfileStyle[K]) => setDraft((d) => ({ ...d, [k]: v })); - const setW = (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: K, v: ProfileStyle[K]) => setPending((p) => ({ ...(p ?? draft), [k]: v })); + const setW = (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 = { + // ── What each rail card shows ── + const cardBody: Record = { nameplate: ( -
- +
+ {displayName}
), avatar: ( -
- - +
+ + - {t(`profile.decoration.${draft.decoration}` as TranslationKey)}
), - name:
, + name: ( +
+ +
+ ), + banner: ( +
+ {bannerSrc + ? + : {t('profile.editor.banner.none')}} +
+ ), theme: ( -
+
), effect: ( -
- +
+ {t(`profile.profileEffect.${draft.effect}` as TranslationKey)}
), identity: ( -
- {draft.tag ? : —} - {draft.pronouns && {draft.pronouns}} +
+ {draft.tag ? : {t('profile.editor.tag.empty')}} + {draft.pronouns || '—'}
), - links:
{draft.links.length} / {PROFILE_LIMITS.links}
, - board:
{boardCount > 0 ? `${boardCount} ${t('profile.editor.items')}` : '—'}
, + links: ( +
{draft.links.length} / {PROFILE_LIMITS.links}
+ ), + board: ( +
{boardCount > 0 ? `${boardCount} ${t('profile.editor.items')}` : '—'}
+ ), }; - const sectionEditor: Record = { + // ── Dialog bodies ── + const dialogBody: Record = { nameplate: ( <>

{t('profile.editor.nameplate.hint')}

{NAMEPLATES.map((n) => { - const p = nameplateProps(n, draft.nameplateColors); + const p = nameplateProps(n, d.nameplateColors); return ( -
- {draft.nameplate === 'custom' && <> set('nameplateColors', c)} />} + {d.nameplate === 'custom' && <> set('nameplateColors', c)} />} ), + avatar: ( -
- {AVATAR_DECORATIONS.map((d) => ( - ))}
), + name: ( <> -
+
{NAME_FONTS.map((f) => ( - ))}
-
+
{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 ( - ); })}
- set('name', { ...draft.name, colors: c })} /> + set('name', { ...d.name, colors: c })} /> ), + + banner: bannerSrc ? ( + <> +

{t('profile.editor.banner.hint')}

+ set('banner', v)} /> + + ) : ( +
{t('profile.editor.banner.upload')}
+ ), + theme: ( <>

{t('profile.editor.theme.hint')}

- - set('theme', { colors: c })} /> + +
+ {PALETTES.map((pl) => ( +
+ + set('theme', { colors: c })} /> ), + effect: ( -
+
{PROFILE_EFFECTS.map((e) => ( - ))}
), + identity: (
- set('tag', { text: draft.tag?.text ?? '', emoji: e.target.value, color: draft.tag?.color ?? '#7c6cf6' })} placeholder="🔥" className="input-standard w-14 text-center" /> - 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" /> -

{t('profile.editor.tag.hint')}

- {draft.tag && } + {d.tag && }
), + links: ( <>

{t('profile.editor.links.hint')}

- {draft.links.map((l, i) => ( + {d.links.map((l, i) => (
- 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" /> - + 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" /> +
- set('links', draft.links.map((x, j) => j === i ? { ...x, url: e.target.value } : x))} placeholder="https://" className="input-standard w-full text-sm" /> + set('links', d.links.map((x, j) => j === i ? { ...x, url: e.target.value } : x))} placeholder="https://" className="input-standard w-full text-sm" />
))} - {draft.links.length < PROFILE_LIMITS.links && ( - + {d.links.length < PROFILE_LIMITS.links && ( + )}
), + board: (
- {draft.widgets.favoriteGame - ? setW('favoriteGame', g)} onRemove={() => setW('favoriteGame', null)} /> + {d.widgets.favoriteGame + ? setW('favoriteGame', g)} onRemove={() => setW('favoriteGame', null)} /> : setW('favoriteGame', g)} />}
- +
- {draft.widgets.rotation.map((g, i) => 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 && setW('rotation', [...draft.widgets.rotation, g])} />} + {d.widgets.rotation.map((g, i) => 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 && setW('rotation', [...d.widgets.rotation, g])} />}
- +
- {draft.widgets.wishlist.map((g, i) => ( + {d.widgets.wishlist.map((g, i) => (
{g.cover ? :
{g.name}
} - +
))}
- {draft.widgets.wishlist.length < PROFILE_LIMITS.wishlist && setW('wishlist', [...draft.widgets.wishlist, g])} />} + {d.widgets.wishlist.length < PROFILE_LIMITS.wishlist && setW('wishlist', [...d.widgets.wishlist, g])} />}
-