mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
186
autogpt_platform/frontend/src/components/atoms/Avatar/Avatar.tsx
Normal file
186
autogpt_platform/frontend/src/components/atoms/Avatar/Avatar.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user