| 1 | "use client"; |
| 2 | |
| 3 | import { useState } from "react"; |
| 4 | import { clsx } from "@/lib/cx"; |
| 5 | |
| 6 | |
| 7 | export type AvatarSize = "sm" | "md" | "lg"; |
| 8 | |
| 9 | const SIZE_CLASS: Record<AvatarSize, string> = { |
| 10 | sm: "h-8 w-8 text-[11px]", |
| 11 | md: "h-11 w-11 text-[12px]", |
| 12 | lg: "h-20 w-20 text-xl", |
| 13 | }; |
| 14 | |
| 15 | export function initialsOf(name: string): string { |
| 16 | const cleaned = name.replace(/\(.*?\)/g, "").trim(); |
| 17 | const parts = cleaned.split(/\s+/).filter(Boolean); |
| 18 | if (parts.length === 0) return "?"; |
| 19 | if (parts.length === 1) { |
| 20 | return parts[0].slice(0, 2).toUpperCase(); |
| 21 | } |
| 22 | const last = parts[parts.length - 1]; |
| 23 | return (parts[0][0] + (last[0] ?? "")).toUpperCase(); |
| 24 | } |
| 25 | |
| 26 | export function MemberAvatar({ |
| 27 | name, |
| 28 | photoUrl, |
| 29 | size = "sm", |
| 30 | className, |
| 31 | }: { |
| 32 | name: string; |
| 33 | photoUrl?: string | null; |
| 34 | size?: AvatarSize; |
| 35 | className?: string; |
| 36 | }) { |
| 37 | const [broken, setBroken] = useState(false); |
| 38 | const showImg = !!photoUrl && !broken; |
| 39 | |
| 40 | return ( |
| 41 | <span |
| 42 | aria-hidden="true" |
| 43 | className={clsx( |
| 44 | "relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full border border-line bg-graphite font-mono font-semibold uppercase tracking-tight text-ash", |
| 45 | SIZE_CLASS[size], |
| 46 | className |
| 47 | )} |
| 48 | > |
| 49 | <span className="leading-none">{initialsOf(name)}</span> |
| 50 | {showImg ? ( |
| 51 | <img |
| 52 | src={photoUrl || undefined} |
| 53 | alt="" |
| 54 | loading="lazy" |
| 55 | decoding="async" |
| 56 | referrerPolicy="no-referrer" |
| 57 | onError={() => setBroken(true)} |
| 58 | className="absolute inset-0 h-full w-full object-cover" |
| 59 | /> |
| 60 | ) : null} |
| 61 | </span> |
| 62 | ); |
| 63 | } |