feat(frontend): new <Avatar /> component using next/image (#10897)

## Changes 🏗️

<img width="800" height="648" alt="Screenshot 2025-09-10 at 22 00 01"
src="https://github.com/user-attachments/assets/eb396d62-01f2-45e5-9150-4e01dfcb71d0"
/><br />

Adds a new `<Avatar />` component and uses that across the app. Is a
copy of
[shadcn/avatar](https://duckduckgo.com/?q=shadcn+avatar&t=brave&ia=web)
with the following modifications:
- renders images with
[`next/image`](https://duckduckgo.com/?q=next+image&t=brave&ia=web) by
default
- this ensures avatars rendered on the app are optimised and resized ✔️
- it will work as long as all the domains are white-listed in
`nextjs.config.mjs`
- allows to bypass and use a normal `<img />` tag via an `as` prop if
needed
  - sometimes we might need to render images from a dynamic cdn 🤷🏽‍♂️   

## Checklist 📋

### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] ...

### For configuration changes:

None
This commit is contained in:
Ubbe
2025-09-10 22:25:09 +09:00
committed by GitHub
parent 3bbce71678
commit e70c970ab6
10 changed files with 268 additions and 90 deletions

View File

@@ -1,8 +1,11 @@
import Link from "next/link";
import Image from "next/image";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
interface LibraryAgentCardProps {
agent: LibraryAgent;

View File

@@ -1,4 +1,7 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
import { StarRatingIcons } from "@/components/ui/icons";
interface CreatorInfoCardProps {

View File

@@ -1,6 +1,9 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import Image from "next/image";
import { StarRatingIcons } from "@/components/ui/icons";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
interface StoreCardProps {
agentName: string;

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { StarRatingIcons } from "@/components/ui/icons";
import Avatar, { AvatarFallback, AvatarImage } from "../atoms/Avatar/Avatar";
interface CreatorInfoCardProps {
username: string;

View File

@@ -1,7 +1,7 @@
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import Image from "next/image";
import { StarRatingIcons } from "@/components/ui/icons";
import Avatar, { AvatarFallback, AvatarImage } from "../atoms/Avatar/Avatar";
interface StoreCardProps {
agentName: string;

View File

@@ -0,0 +1,60 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import React from "react";
import {
Avatar,
AvatarImage,
AvatarFallback,
} from "@/components/atoms/Avatar/Avatar";
const meta: Meta<typeof Avatar> = {
title: "Atoms/Avatar",
component: Avatar,
};
export default meta;
type Story = StoryObj<typeof Avatar>;
export const WithNextImage: Story = {
render: () => (
<div style={{ display: "flex", gap: 16 }}>
<Avatar className="h-16 w-16">
<AvatarImage
as="NextImage"
src="https://avatars.githubusercontent.com/u/9919?v=4"
alt="GitHub Avatar"
/>
<AvatarFallback>G</AvatarFallback>
</Avatar>
<Avatar className="h-10 w-10">
<AvatarImage
as="NextImage"
src="https://avatars.githubusercontent.com/u/583231?v=4"
alt="Octocat"
/>
<AvatarFallback>O</AvatarFallback>
</Avatar>
</div>
),
};
export const WithImgTag: Story = {
render: () => (
<div style={{ display: "flex", gap: 16 }}>
<Avatar className="h-16 w-16">
<AvatarImage
as="img"
src="https://avatars.githubusercontent.com/u/139426?v=4"
alt="User"
/>
<AvatarFallback>U</AvatarFallback>
</Avatar>
<Avatar className="h-10 w-10">
<AvatarImage as="img" src="" alt="No Image" />
<AvatarFallback>N</AvatarFallback>
</Avatar>
</div>
),
};

View File

@@ -0,0 +1,186 @@
"use client";
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import Image, { ImageProps } from "next/image";
type AvatarContextValue = {
isLoaded: boolean;
setIsLoaded: (v: boolean) => void;
hasImage: boolean;
setHasImage: (v: boolean) => void;
};
const AvatarContext = createContext<AvatarContextValue | null>(null);
function useAvatarContext(): AvatarContextValue {
const ctx = useContext(AvatarContext);
if (!ctx) throw new Error("Avatar components must be used within <Avatar />");
return ctx;
}
export type AvatarProps = React.HTMLAttributes<HTMLDivElement>;
export function Avatar({
className,
children,
...props
}: AvatarProps): JSX.Element {
const [isLoaded, setIsLoaded] = useState<boolean>(false);
const [hasImage, setHasImage] = useState<boolean>(false);
const value = useMemo(
() => ({ isLoaded, setIsLoaded, hasImage, setHasImage }),
[isLoaded, hasImage],
);
return (
<AvatarContext.Provider value={value}>
<div
className={[
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className || "",
].join(" ")}
{...props}
>
{children}
</div>
</AvatarContext.Provider>
);
}
export interface AvatarImageProps
extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, "width" | "height"> {
as?: "NextImage" | "img";
width?: number;
height?: number;
fill?: boolean;
sizes?: string;
priority?: boolean;
unoptimized?: boolean;
}
function getAvatarSizeFromClassName(className?: string): number | null {
if (!className) return null;
// Try to parse classes like h-16 w-16 first
const hMatch = className.match(/\bh-(\d+)\b/);
if (hMatch) return parseInt(hMatch[1], 10) * 4; // Tailwind spacing scale default: 1 => 0.25rem
const wMatch = className.match(/\bw-(\d+)\b/);
if (wMatch) return parseInt(wMatch[1], 10) * 4;
// Fallback fixed size
return null;
}
export function AvatarImage({
as = "NextImage",
src,
alt,
className,
onLoad,
onError,
width,
height,
fill,
sizes,
priority,
unoptimized,
...rest
}: AvatarImageProps): JSX.Element | null {
const { setIsLoaded, setHasImage } = useAvatarContext();
useEffect(
function setHasImageOnSrcChange() {
setHasImage(Boolean(src));
},
[src, setHasImage],
);
if (!src) return null;
const sizeFromClass = getAvatarSizeFromClassName(className);
const computedWidth = width || sizeFromClass || 40;
const computedHeight = height || sizeFromClass || 40;
if (as === "img") {
function handleLoad(e: React.SyntheticEvent<HTMLImageElement, Event>) {
setIsLoaded(true);
if (onLoad) onLoad(e);
}
function handleError(e: React.SyntheticEvent<HTMLImageElement, Event>) {
setIsLoaded(false);
setHasImage(false);
if (onError) onError(e);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt || "Avatar image"}
className={["h-full w-full object-cover", className || ""].join(" ")}
width={computedWidth}
height={computedHeight}
onLoad={handleLoad}
onError={handleError}
{...rest}
/>
);
}
function handleLoadingComplete(): void {
setIsLoaded(true);
}
function handleErrorNext(): void {
setIsLoaded(false);
setHasImage(false);
}
return (
<Image
src={src}
alt={alt || "Avatar image"}
className={["h-full w-full object-cover", className || ""].join(" ")}
width={fill ? undefined : computedWidth}
height={fill ? undefined : computedHeight}
fill={Boolean(fill)}
sizes={sizes}
priority={priority}
unoptimized={unoptimized}
onLoadingComplete={handleLoadingComplete}
onError={handleErrorNext as ImageProps["onError"]}
/>
);
}
export type AvatarFallbackProps = React.HTMLAttributes<HTMLSpanElement> & {
size?: number;
};
export function AvatarFallback({
className,
children,
size: _size, // accepted for API compatibility; currently not used
...props
}: AvatarFallbackProps): JSX.Element | null {
const { isLoaded, hasImage } = useAvatarContext();
const show = !isLoaded || !hasImage;
if (!show) return null;
return (
<span
className={[
"flex h-full w-full items-center justify-center rounded-full bg-neutral-200 text-neutral-600",
className || "",
].join(" ")}
{...props}
>
{children}
</span>
);
}
export default Avatar;

View File

@@ -1,4 +1,3 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Popover,
PopoverContent,
@@ -9,6 +8,10 @@ import * as React from "react";
import { getAccountMenuOptionIcon, MenuItemGroup } from "../../helpers";
import { AccountLogoutOption } from "./components/AccountLogoutOption";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
interface Props {
userName?: string;

View File

@@ -1,6 +1,5 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Popover,
PopoverContent,
@@ -15,6 +14,10 @@ import { MenuItemGroup } from "../../helpers";
import { MobileNavbarMenuItem } from "./components/MobileNavbarMenuItem";
import { Button } from "@/components/atoms/Button/Button";
import { CaretUpIcon, ListIcon } from "@phosphor-icons/react";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
interface MobileNavBarProps {
userName?: string;

View File

@@ -1,83 +0,0 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import BoringAvatar from "./BoringAvatarWrapper";
import tailwindConfig from "../../../tailwind.config";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
/**
* Hack to match the avatar size based on Tailwind classes.
* This function attempts to extract the size from a 'h-' class in the className string,
* and maps it to the corresponding size in the Tailwind config.
* If no matching class is found, it defaults to 40.
* @param className - The className string to parse
* @returns The size of the avatar in pixels
*/
const getAvatarSize = (className: string | undefined): number => {
if (className?.includes("h-")) {
const match = parseInt(className.match(/h-(\d+)/)?.[1] || "16");
if (match) {
const size =
tailwindConfig.theme.extend.spacing[
match as keyof typeof tailwindConfig.theme.extend.spacing
];
return size ? parseInt(size.replace("rem", "")) * 16 : 40;
}
}
return 40;
};
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> & {
size?: number;
}
>(({ className, size, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full",
className,
)}
{...props}
>
<BoringAvatar
size={size || getAvatarSize(className)}
name={props.children?.toString() || "User"}
variant="marble"
colors={["#92A1C6", "#146A7C", "#F0AB3D", "#C271B4", "#C20D90"]}
/>
</AvatarPrimitive.Fallback>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar as Avatar, AvatarImage, AvatarFallback };