"use client"; import * as React from "react"; import { motion } from "framer-motion"; import { cn } from "../lib/utils"; import { transitions } from "../lib/motion"; const SIZES = { xs: "size-5 text-[9px]", sm: "size-6 text-[10px]", md: "size-8 text-xs", lg: "size-10 text-sm", } as const; /** Stable, soft background per name, so the same person keeps one color. */ function hueFor(seed: string): number { let hash = 0; for (let index = 0; index < seed.length; index += 1) hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; return hash % 360; } function initials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "?"; return ((parts[0]?.[0] ?? "") + (parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : "")).toUpperCase(); } export interface AvatarProps { name: string; src?: string | null; size?: keyof typeof SIZES; className?: string; } export function Avatar({ name, src, size = "md", className }: AvatarProps) { const [failed, setFailed] = React.useState(false); const hue = hueFor(name); return ( {src && !failed ? ( // eslint-disable-next-line @next/next/no-img-element -- framework-free package setFailed(true)} /> ) : ( initials(name) )} ); } /** Overlapping avatars that spread apart on hover. */ export function AvatarGroup({ people, max = 4, size = "sm", className, }: { people: Array<{ name: string; src?: string | null }>; max?: number; size?: keyof typeof SIZES; className?: string; }) { const [hovered, setHovered] = React.useState(false); const visible = people.slice(0, max); const overflow = people.length - visible.length; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} aria-label={people.map((person) => person.name).join(", ")} > {visible.map((person, index) => ( ))} {overflow > 0 ? ( +{overflow} ) : null}
); }