102 lines
2.9 KiB
TypeScript
102 lines
2.9 KiB
TypeScript
import Image from "next/image";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* UserAvatar — the one avatar component: shows the user's image, or their
|
|
* initials on a tinted disc when there's no image. Used everywhere a user
|
|
* thumbnail appears (notification feed, comments, follower lists, and the
|
|
* profile header's large zoomable hero).
|
|
*
|
|
* Sizing is class-driven (`className`) rather than a fixed px, so callers get
|
|
* Tailwind's responsive sizes — e.g. `size-10` for a list row or
|
|
* `size-24 sm:size-28` for the profile header. `textClassName` scales the
|
|
* initials to match. When `onClick` is set, an image avatar becomes a
|
|
* focusable button (used by the profile header to open its zoom preview);
|
|
* `interactive` adds a hover-ring affordance.
|
|
*/
|
|
export function UserAvatar({
|
|
src,
|
|
name,
|
|
className,
|
|
textClassName = "text-sm",
|
|
onClick,
|
|
ariaLabel,
|
|
interactive = false,
|
|
}: {
|
|
src?: string | null;
|
|
name: string;
|
|
/** Box size + extras. Defaults to a 40px (size-10) circle. */
|
|
className?: string;
|
|
/** Initials font size — match it to the box. Defaults to text-sm. */
|
|
textClassName?: string;
|
|
/** When set, an image avatar renders as a focusable button (e.g. to open a
|
|
* zoom preview or navigate). Initials stay non-interactive. */
|
|
onClick?: () => void;
|
|
ariaLabel?: string;
|
|
/** Add a hover-ring affordance to an image avatar. */
|
|
interactive?: boolean;
|
|
}) {
|
|
const size = className ?? "size-10";
|
|
|
|
if (src) {
|
|
const imgRing = cn(
|
|
"rounded-full object-cover ring-1 ring-foreground/10",
|
|
interactive && "hover:ring-foreground/30 transition",
|
|
);
|
|
// width/height satisfy next/image; the rendered size comes from the CSS
|
|
// size class (which overrides the intrinsic attributes).
|
|
if (onClick) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label={ariaLabel ?? `View ${name}'s picture`}
|
|
className={cn(
|
|
"shrink-0 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
size,
|
|
)}
|
|
>
|
|
<Image
|
|
src={src}
|
|
alt={name}
|
|
width={112}
|
|
height={112}
|
|
className={cn("size-full", imgRing)}
|
|
unoptimized
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
return (
|
|
<Image
|
|
src={src}
|
|
alt={name}
|
|
width={112}
|
|
height={112}
|
|
className={cn("shrink-0", size, imgRing)}
|
|
unoptimized
|
|
/>
|
|
);
|
|
}
|
|
|
|
const initials =
|
|
name
|
|
.replace(/^@/, "")
|
|
.split(/\s+/)
|
|
.map((p) => p[0])
|
|
.slice(0, 2)
|
|
.join("")
|
|
.toUpperCase() || "?";
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"shrink-0 rounded-full bg-primary/15 text-primary flex items-center justify-center font-semibold ring-1 ring-foreground/10",
|
|
size,
|
|
textClassName,
|
|
)}
|
|
>
|
|
{initials}
|
|
</div>
|
|
);
|
|
}
|