Files
backspace/packages/web/src/components/profile/DecoratedAvatar.tsx
T
devsyncwrldandClaude Fable 5.1 ef9c73501c
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
feat(profile): a full identity system — name styles, decorations, effects, nameplates, tag, links, board
The owner asked for profile customisation that is "completo e FODA", pointing
at Discord's profile editor. This is the whole thing, end to end, with a live
preview.

What a user can now set, and where it shows:

  - Display name style: 10 fonts × 9 effects (solid, gradient, neon, toon,
    pop, gummy, prism, glitch, fire) × two colours. Popout and full profile.
  - Avatar decoration: 10 (ring, neon, flames, stars, halo, vines, pixel,
    hearts, frost, crown). Inline SVG — no image assets, scales to any size.
  - Profile effect over the banner: 9 (sparkles, rain, snow, matrix,
    confetti, aurora, embers, bubbles, hearts). CSS particles with seeded
    positions so re-renders do not change the weather.
  - Nameplate behind the member-list row: 10 presets + custom two-colour.
  - Theme: two colours washed behind the card, and the banner fallback.
  - Pronouns, and a clan-style tag (emoji + 2–4 letters) beside the name,
    also on the member row.
  - Links, up to 5, with the brand icon picked from the host.
  - Board: favourite game, up to 5 in rotation, up to 10 wanted, a quote.
    Games come from Steam search through a small server proxy (cached,
    10 min) or are typed by hand; covers only load from Steam's CDN.
  - Badges, derived rather than stored: instance admin, server owner,
    founder (joined in the space's first month), veteran (a year), OG (six
    months), stylist. Nothing to grant, revoke or migrate.

Storage is one JSON column, users.profile_style (migration 0019), and one
normaliser in shared/profile.ts that both the server (on write and on read)
and the client (on render) run. It never throws: unknown ids fall back,
strings are cut to their limits, colours must be #rrggbb, link URLs must be
https, cover URLs must be a Steam CDN host. Kept out of the federation
durable-field set on purpose — peers on older builds do not know the field,
and it is cosmetic.

The editor is a new Settings > Profile tab. Its preview is the same
ProfileCard component the popout renders, with the draft style swapped in —
what you see while editing is, by construction, what others see.

Fonts are OFL builds self-hosted from public/fonts alongside DM Sans, latin
subsets only, ~120 KB in total.

Verified: web build, 517 web tests (11 new: normaliser bounds and fallbacks,
link icon detection, badge derivation), server typecheck, 804 server tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:36:23 -03:00

133 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import type { AvatarDecoration } from '@backspace/shared/src/profile';
interface DecoratedAvatarProps {
decoration: AvatarDecoration;
/** Avatar element to decorate; the overlay sizes itself to this box. */
children: React.ReactNode;
className?: string;
}
/**
* Wraps an avatar with a decorative layer. Every decoration is inline SVG on a
* 100×100 box around the picture — no image assets, so a new one is a few
* lines of markup and scales to any avatar size.
*/
export function DecoratedAvatar({ decoration, children, className = '' }: DecoratedAvatarProps) {
return (
<div className={`relative inline-block ${className}`}>
{children}
{decoration !== 'none' && (
<div className="avatar-deco" aria-hidden="true">
<Decoration id={decoration} />
</div>
)}
</div>
);
}
function Decoration({ id }: { id: AvatarDecoration }) {
switch (id) {
case 'ring':
return (
<svg viewBox="0 0 100 100" className="deco-spin-slow">
<defs>
<linearGradient id="deco-ring-g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#a78bfa" /><stop offset="0.5" stopColor="#f472b6" /><stop offset="1" stopColor="#fbbf24" />
</linearGradient>
</defs>
<circle cx="50" cy="50" r="46" fill="none" stroke="url(#deco-ring-g)" strokeWidth="4" />
</svg>
);
case 'neon':
return (
<svg viewBox="0 0 100 100" className="deco-pulse">
<circle cx="50" cy="50" r="46" fill="none" stroke="#22d3ee" strokeWidth="3" style={{ filter: 'drop-shadow(0 0 4px #22d3ee) drop-shadow(0 0 10px #a855f7)' }} />
</svg>
);
case 'flames':
return (
<svg viewBox="0 0 100 100" className="deco-flicker" style={{ transformOrigin: '50% 100%' }}>
<defs>
<linearGradient id="deco-fire-g" x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stopColor="#ef4444" /><stop offset="0.6" stopColor="#f97316" /><stop offset="1" stopColor="#fde047" />
</linearGradient>
</defs>
<path fill="url(#deco-fire-g)" opacity="0.95"
d="M50 2 C56 12 62 14 60 24 C68 20 72 12 74 6 C78 18 86 22 86 34 C92 30 94 24 94 20 C98 34 98 48 92 60 C98 62 100 56 100 52 C98 74 84 92 64 98 C50 100 36 98 26 90 C12 82 2 66 4 48 C6 44 10 46 12 50 C10 38 14 30 20 24 C22 30 26 34 30 34 C28 22 34 12 42 6 C44 12 46 14 50 14 Z" />
<circle cx="50" cy="52" r="42" fill="#0b0b10" />
</svg>
);
case 'stars':
return (
<svg viewBox="0 0 100 100" className="deco-spin">
{[0, 72, 144, 216, 288].map((a) => (
<g key={a} transform={`rotate(${a} 50 50)`}>
<path transform="translate(50 6) scale(0.55)" fill="#fde68a" stroke="#f59e0b" strokeWidth="1"
d="M0 -10 L2.9 -3.1 L10 -3.1 L4.2 1.2 L6.2 8.1 L0 4 L-6.2 8.1 L-4.2 1.2 L-10 -3.1 L-2.9 -3.1 Z" />
</g>
))}
</svg>
);
case 'halo':
return (
<svg viewBox="0 0 100 100" className="deco-float">
<ellipse cx="50" cy="8" rx="30" ry="7" fill="none" stroke="#fde68a" strokeWidth="4" style={{ filter: 'drop-shadow(0 0 6px #fbbf24)' }} />
</svg>
);
case 'vines':
return (
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="46" fill="none" stroke="#166534" strokeWidth="3.5" />
{[20, 70, 130, 200, 260, 320].map((a) => (
<g key={a} transform={`rotate(${a} 50 50) translate(50 4)`}>
<path d="M0 0 C-6 -4 -8 4 -3 8 C2 4 4 -2 0 0 Z" fill="#22c55e" />
<path d="M0 0 C6 -4 8 4 3 8 C-2 4 -4 -2 0 0 Z" fill="#4ade80" />
</g>
))}
</svg>
);
case 'pixel':
return (
<svg viewBox="0 0 100 100" shapeRendering="crispEdges">
{Array.from({ length: 28 }).map((_, i) => {
const a = (i / 28) * Math.PI * 2;
const x = 50 + Math.cos(a) * 45; const y = 50 + Math.sin(a) * 45;
return <rect key={i} x={Math.round(x / 5) * 5 - 2.5} y={Math.round(y / 5) * 5 - 2.5} width="5" height="5" fill={i % 2 ? '#a855f7' : '#22d3ee'} />;
})}
</svg>
);
case 'hearts':
return (
<svg viewBox="0 0 100 100" className="deco-float">
{[[8, 20, 1], [88, 14, 0.8], [6, 74, 0.7], [90, 78, 0.9]].map(([x, y, s], i) => (
<path key={i} transform={`translate(${x} ${y}) scale(${s})`} fill="#fb7185"
d="M0 6 C-8 -2 -8 -10 -2 -10 C1 -10 0 -8 0 -6 C0 -8 -1 -10 2 -10 C8 -10 8 -2 0 6 Z" />
))}
</svg>
);
case 'frost':
return (
<svg viewBox="0 0 100 100" className="deco-spin-slow">
<circle cx="50" cy="50" r="46" fill="none" stroke="#bae6fd" strokeWidth="2" strokeDasharray="3 5" opacity="0.9" />
{[0, 60, 120, 180, 240, 300].map((a) => (
<g key={a} transform={`rotate(${a} 50 50) translate(50 3)`} stroke="#e0f2fe" strokeWidth="1.6" strokeLinecap="round" style={{ filter: 'drop-shadow(0 0 3px #7dd3fc)' }}>
<line x1="0" y1="0" x2="0" y2="10" /><line x1="-4" y1="3" x2="4" y2="7" /><line x1="4" y1="3" x2="-4" y2="7" />
</g>
))}
</svg>
);
case 'crown':
return (
<svg viewBox="0 0 100 100" className="deco-float">
<path transform="translate(50 6)" fill="#fbbf24" stroke="#b45309" strokeWidth="1.5" strokeLinejoin="round"
d="M-22 6 L-28 -12 L-14 -2 L0 -16 L14 -2 L28 -12 L22 6 Z" />
<rect x="28" y="12" width="44" height="5" rx="1.5" fill="#f59e0b" stroke="#b45309" strokeWidth="1" />
<circle cx="36" cy="-4" r="2.4" fill="#f87171" /><circle cx="50" cy="-8" r="2.4" fill="#60a5fa" /><circle cx="64" cy="-4" r="2.4" fill="#4ade80" />
</svg>
);
default:
return null;
}
}