diff --git a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx index 76db83c1ba..3fc677d8d8 100644 --- a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx +++ b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx @@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import ImageInput from '@/app/components/base/app-icon-picker/ImageInput' import getCroppedImg from '@/app/components/base/app-icon-picker/utils' -import Avatar from '@/app/components/base/avatar' +import { Avatar } from '@/app/components/base/avatar' import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks' @@ -103,7 +103,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => { <>
- setOnAvatarError(x)} /> + setOnAvatarError(status === 'error')} />
{ diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx index 835663c721..9a104619da 100644 --- a/web/app/account/(commonLayout)/account-page/index.tsx +++ b/web/app/account/(commonLayout)/account-page/index.tsx @@ -4,6 +4,7 @@ import type { App } from '@/types/app' import { RiGraduationCapFill, } from '@remixicon/react' +import { useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' @@ -15,11 +16,11 @@ import PremiumBadge from '@/app/components/base/premium-badge' import { ToastContext } from '@/app/components/base/toast/context' import Collapse from '@/app/components/header/account-setting/collapse' import { IS_CE_EDITION, validPassword } from '@/config' -import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' import { updateUserProfile } from '@/service/common' import { useAppList } from '@/service/use-apps' +import { commonQueryKeys, useUserProfile } from '@/service/use-common' import DeleteAccount from '../delete-account' import AvatarWithEdit from './AvatarWithEdit' @@ -37,7 +38,10 @@ export default function AccountPage() { const { systemFeatures } = useGlobalPublicStore() const { data: appList } = useAppList({ page: 1, limit: 100, name: '' }) const apps = appList?.data || [] - const { mutateUserProfile, userProfile } = useAppContext() + const queryClient = useQueryClient() + const { data: userProfileResp } = useUserProfile() + const userProfile = userProfileResp?.profile + const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: commonQueryKeys.userProfile }) const { isEducationAccount } = useProviderContext() const { notify } = useContext(ToastContext) const [editNameModalVisible, setEditNameModalVisible] = useState(false) @@ -53,6 +57,9 @@ export default function AccountPage() { const [showConfirmPassword, setShowConfirmPassword] = useState(false) const [showUpdateEmail, setShowUpdateEmail] = useState(false) + if (!userProfile) + return null + const handleEditName = () => { setEditNameModalVisible(true) setEditName(userProfile.name) @@ -149,7 +156,7 @@ export default function AccountPage() {

{t('account.myAccount', { ns: 'common' })}

- +

{userProfile.name} diff --git a/web/app/account/(commonLayout)/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx index 8ea29e8e45..07b685b8c5 100644 --- a/web/app/account/(commonLayout)/avatar.tsx +++ b/web/app/account/(commonLayout)/avatar.tsx @@ -7,12 +7,11 @@ import { useRouter } from 'next/navigation' import { Fragment } from 'react' import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' -import Avatar from '@/app/components/base/avatar' +import { Avatar } from '@/app/components/base/avatar' import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general' import PremiumBadge from '@/app/components/base/premium-badge' -import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' -import { useLogout } from '@/service/use-common' +import { useLogout, useUserProfile } from '@/service/use-common' export type IAppSelector = { isMobile: boolean @@ -21,10 +20,15 @@ export type IAppSelector = { export default function AppSelector() { const router = useRouter() const { t } = useTranslation() - const { userProfile } = useAppContext() + const { data: userProfileResp } = useUserProfile() + const userProfile = userProfileResp?.profile const { isEducationAccount } = useProviderContext() const { mutateAsync: logout } = useLogout() + + if (!userProfile) + return null + const handleLogout = async () => { await logout() @@ -50,7 +54,7 @@ export default function AppSelector() { ${open && 'bg-components-panel-bg-blur'} `} > - +

{userProfile.email}
- +
diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index d718e0941d..835a1e702e 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -11,14 +11,13 @@ import { useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import Avatar from '@/app/components/base/avatar' +import { Avatar } from '@/app/components/base/avatar' import Button from '@/app/components/base/button' import Loading from '@/app/components/base/loading' import Toast from '@/app/components/base/toast' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect' -import { useAppContext } from '@/context/app-context' -import { useIsLogin } from '@/service/use-common' +import { useIsLogin, useUserProfile } from '@/service/use-common' import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth' function buildReturnUrl(pathname: string, search: string) { @@ -62,7 +61,8 @@ export default function OAuthAuthorize() { const searchParams = useSearchParams() const client_id = decodeURIComponent(searchParams.get('client_id') || '') const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '') - const { userProfile } = useAppContext() + const { data: userProfileResp } = useUserProfile() + const userProfile = userProfileResp?.profile const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri) const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp() const hasNotifiedRef = useRef(false) @@ -138,7 +138,7 @@ export default function OAuthAuthorize() { {isLoggedIn && userProfile && (
- +
{userProfile.name}
{userProfile.email}
diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx index 5c803a91f0..90cbac13a4 100644 --- a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx +++ b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx @@ -10,7 +10,7 @@ import { SubjectType } from '@/models/access-control' import { useSearchForWhiteListCandidates } from '@/service/access-control' import { cn } from '@/utils/classnames' import useAccessControlStore from '../../../../context/access-control-store' -import Avatar from '../../base/avatar' +import { Avatar } from '../../base/avatar' import Button from '../../base/button' import Checkbox from '../../base/checkbox' import Input from '../../base/input' @@ -203,7 +203,7 @@ function MemberItem({ member }: MemberItemProps) {
- +

{member.name}

diff --git a/web/app/components/app/app-access-control/specific-groups-or-members.tsx b/web/app/components/app/app-access-control/specific-groups-or-members.tsx index 8ca817c872..2c0e4b2694 100644 --- a/web/app/components/app/app-access-control/specific-groups-or-members.tsx +++ b/web/app/components/app/app-access-control/specific-groups-or-members.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next' import { AccessMode } from '@/models/access-control' import { useAppWhiteListSubjects } from '@/service/access-control' import useAccessControlStore from '../../../../context/access-control-store' -import Avatar from '../../base/avatar' +import { Avatar } from '../../base/avatar' import Loading from '../../base/loading' import Tooltip from '../../base/tooltip' import AddMemberOrGroupDialog from './add-member-or-group-pop' @@ -106,7 +106,7 @@ function MemberItem({ member }: MemberItemProps) { }, [member, setSpecificMembers, specificMembers]) return ( } + icon={} onRemove={handleRemoveMember} >

{member.name}

diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.spec.tsx index d621bb3941..350ede8c96 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.spec.tsx @@ -91,7 +91,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({ })) vi.mock('@/app/components/base/avatar', () => ({ - default: ({ name }: { name: string }) =>
{name}
, + Avatar: ({ name }: { name: string }) =>
{name}
, })) const createModelAndParameter = (overrides: Partial = {}): ModelAndParameter => ({ diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx index b7a7e90fca..e957fc24c4 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx @@ -7,7 +7,7 @@ import { useCallback, useMemo, } from 'react' -import Avatar from '@/app/components/base/avatar' +import { Avatar } from '@/app/components/base/avatar' import Chat from '@/app/components/base/chat/chat' import { useChat } from '@/app/components/base/chat/chat/hooks' import { getLastAnswer } from '@/app/components/base/chat/utils' @@ -149,7 +149,7 @@ const ChatItem: FC = ({ suggestedQuestions={suggestedQuestions} onSend={doSend} showPromptLog - questionIcon={} + questionIcon={} allToolIcons={allToolIcons} hideLogModal noSpacing diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx index addeb92297..84ff8b5ede 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx @@ -3,7 +3,7 @@ import type { ChatConfig, ChatItem, OnSend } from '@/app/components/base/chat/ty import type { FileEntity } from '@/app/components/base/file-uploader/types' import { memo, useCallback, useImperativeHandle, useMemo } from 'react' import { useStore as useAppStore } from '@/app/components/app/store' -import Avatar from '@/app/components/base/avatar' +import { Avatar } from '@/app/components/base/avatar' import Chat from '@/app/components/base/chat/chat' import { useChat } from '@/app/components/base/chat/chat/hooks' import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils' @@ -168,7 +168,7 @@ const DebugWithSingleModel = ( switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)} onStopResponding={handleStop} showPromptLog - questionIcon={} + questionIcon={} allToolIcons={allToolIcons} onAnnotationEdited={handleAnnotationEdited} onAnnotationAdded={handleAnnotationAdded} diff --git a/web/app/components/base/avatar/__tests__/index.spec.tsx b/web/app/components/base/avatar/__tests__/index.spec.tsx index 5fad1d0a90..f7fae7105d 100644 --- a/web/app/components/base/avatar/__tests__/index.spec.tsx +++ b/web/app/components/base/avatar/__tests__/index.spec.tsx @@ -1,308 +1,114 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import Avatar from '../index' +import { render, screen } from '@testing-library/react' +import { Avatar } from '../index' describe('Avatar', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // Rendering tests - verify component renders correctly in different states describe('Rendering', () => { - it('should render img element with correct alt and src when avatar URL is provided', () => { - const avatarUrl = 'https://example.com/avatar.jpg' - const props = { name: 'John Doe', avatar: avatarUrl } - - render() + it('should render img element when avatar URL is provided', () => { + render() const img = screen.getByRole('img', { name: 'John Doe' }) expect(img).toBeInTheDocument() - expect(img).toHaveAttribute('src', avatarUrl) + expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg') }) - it('should render fallback div with uppercase initial when avatar is null', () => { - const props = { name: 'alice', avatar: null } - - render() + it('should render fallback with uppercase initial when avatar is null', () => { + render() expect(screen.queryByRole('img')).not.toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() }) - }) - // Props tests - verify all props are applied correctly - describe('Props', () => { - describe('size prop', () => { - it.each([ - { size: undefined, expected: '30px', label: 'default (30px)' }, - { size: 50, expected: '50px', label: 'custom (50px)' }, - ])('should apply $label size to img element', ({ size, expected }) => { - const props = { name: 'Test', avatar: 'https://example.com/avatar.jpg', size } + it('should render both image and fallback when avatar is provided', () => { + render() - render() - - expect(screen.getByRole('img')).toHaveStyle({ - width: expected, - height: expected, - fontSize: expected, - lineHeight: expected, - }) - }) - - it('should apply size to fallback div when avatar is null', () => { - const props = { name: 'Test', avatar: null, size: 40 } - - render() - - const textElement = screen.getByText('T') - const outerDiv = textElement.parentElement as HTMLElement - expect(outerDiv).toHaveStyle({ width: '40px', height: '40px' }) - }) - }) - - describe('className prop', () => { - it('should merge className with default avatar classes on img', () => { - const props = { - name: 'Test', - avatar: 'https://example.com/avatar.jpg', - className: 'custom-class', - } - - render() - - const img = screen.getByRole('img') - expect(img).toHaveClass('custom-class') - expect(img).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600') - }) - - it('should merge className with default avatar classes on fallback div', () => { - const props = { - name: 'Test', - avatar: null, - className: 'my-custom-class', - } - - render() - - const textElement = screen.getByText('T') - const outerDiv = textElement.parentElement as HTMLElement - expect(outerDiv).toHaveClass('my-custom-class') - expect(outerDiv).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600') - }) - }) - - describe('textClassName prop', () => { - it('should apply textClassName to the initial text element', () => { - const props = { - name: 'Test', - avatar: null, - textClassName: 'custom-text-class', - } - - render() - - const textElement = screen.getByText('T') - expect(textElement).toHaveClass('custom-text-class') - expect(textElement).toHaveClass('scale-[0.4]', 'text-center', 'text-white') - }) - }) - }) - - // State Management tests - verify useState and useEffect behavior - describe('State Management', () => { - it('should switch to fallback when image fails to load', async () => { - const props = { name: 'John', avatar: 'https://example.com/broken.jpg' } - render() - const img = screen.getByRole('img') - - fireEvent.error(img) - - await waitFor(() => { - expect(screen.queryByRole('img')).not.toBeInTheDocument() - }) - expect(screen.getByText('J')).toBeInTheDocument() - }) - - it('should reset error state when avatar URL changes', async () => { - const initialProps = { name: 'John', avatar: 'https://example.com/broken.jpg' } - const { rerender } = render() - const img = screen.getByRole('img') - - // First, trigger error - fireEvent.error(img) - await waitFor(() => { - expect(screen.queryByRole('img')).not.toBeInTheDocument() - }) - expect(screen.getByText('J')).toBeInTheDocument() - - rerender() - - await waitFor(() => { - expect(screen.getByRole('img')).toBeInTheDocument() - }) - expect(screen.queryByText('J')).not.toBeInTheDocument() - }) - - it('should not reset error state if avatar becomes null', async () => { - const initialProps = { name: 'John', avatar: 'https://example.com/broken.jpg' } - const { rerender } = render() - - // Trigger error - fireEvent.error(screen.getByRole('img')) - await waitFor(() => { - expect(screen.getByText('J')).toBeInTheDocument() - }) - - rerender() - - await waitFor(() => { - expect(screen.queryByRole('img')).not.toBeInTheDocument() - }) + expect(screen.getByRole('img')).toBeInTheDocument() expect(screen.getByText('J')).toBeInTheDocument() }) }) - // Event Handlers tests - verify onError callback behavior - describe('Event Handlers', () => { - it('should call onError with true when image fails to load', () => { - const onErrorMock = vi.fn() - const props = { - name: 'John', - avatar: 'https://example.com/broken.jpg', - onError: onErrorMock, - } - render() + describe('Size variants', () => { + it.each([ + { size: 'xxs' as const, expectedClass: 'size-4' }, + { size: 'xs' as const, expectedClass: 'size-5' }, + { size: 'sm' as const, expectedClass: 'size-6' }, + { size: 'md' as const, expectedClass: 'size-8' }, + { size: 'lg' as const, expectedClass: 'size-9' }, + { size: 'xl' as const, expectedClass: 'size-10' }, + { size: '2xl' as const, expectedClass: 'size-12' }, + { size: '3xl' as const, expectedClass: 'size-16' }, + ])('should apply $expectedClass for size="$size"', ({ size, expectedClass }) => { + const { container } = render() - fireEvent.error(screen.getByRole('img')) - - expect(onErrorMock).toHaveBeenCalledTimes(1) - expect(onErrorMock).toHaveBeenCalledWith(true) + const root = container.firstElementChild as HTMLElement + expect(root).toHaveClass(expectedClass) }) - it('should call onError with false when image loads successfully', () => { - const onErrorMock = vi.fn() - const props = { - name: 'John', - avatar: 'https://example.com/avatar.jpg', - onError: onErrorMock, - } - render() + it('should default to md size when size is not specified', () => { + const { container } = render() - fireEvent.load(screen.getByRole('img')) - - expect(onErrorMock).toHaveBeenCalledTimes(1) - expect(onErrorMock).toHaveBeenCalledWith(false) - }) - - it('should not throw when onError is not provided', async () => { - const props = { name: 'John', avatar: 'https://example.com/broken.jpg' } - render() - - expect(() => fireEvent.error(screen.getByRole('img'))).not.toThrow() - await waitFor(() => { - expect(screen.getByText('J')).toBeInTheDocument() - }) + const root = container.firstElementChild as HTMLElement + expect(root).toHaveClass('size-8') + }) + }) + + describe('className prop', () => { + it('should merge className with avatar variant classes on root', () => { + const { container } = render( + , + ) + + const root = container.firstElementChild as HTMLElement + expect(root).toHaveClass('custom-class') + expect(root).toHaveClass('rounded-full', 'bg-primary-600') }) }) - // Edge Cases tests - verify handling of unusual inputs describe('Edge Cases', () => { it('should handle empty string name gracefully', () => { - const props = { name: '', avatar: null } + const { container } = render() - const { container } = render() - - // Note: Using querySelector here because empty name produces no visible text, - // making semantic queries (getByRole, getByText) impossible - const textElement = container.querySelector('.text-white') as HTMLElement - expect(textElement).toBeInTheDocument() - expect(textElement.textContent).toBe('') + const fallback = container.querySelector('.text-white') as HTMLElement + expect(fallback).toBeInTheDocument() + expect(fallback.textContent).toBe('') }) it.each([ { name: '中文名', expected: '中', label: 'Chinese characters' }, { name: '123User', expected: '1', label: 'number' }, ])('should display first character when name starts with $label', ({ name, expected }) => { - const props = { name, avatar: null } - - render() + render() expect(screen.getByText(expected)).toBeInTheDocument() }) it('should handle empty string avatar as falsy value', () => { - const props = { name: 'Test', avatar: '' as string | null } - - render() + render() expect(screen.queryByRole('img')).not.toBeInTheDocument() expect(screen.getByText('T')).toBeInTheDocument() }) - - it('should handle undefined className and textClassName', () => { - const props = { name: 'Test', avatar: null } - - render() - - const textElement = screen.getByText('T') - const outerDiv = textElement.parentElement as HTMLElement - expect(outerDiv).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600') - }) - - it.each([ - { size: 0, expected: '0px', label: 'zero' }, - { size: 1000, expected: '1000px', label: 'very large' }, - ])('should handle $label size value', ({ size, expected }) => { - const props = { name: 'Test', avatar: null, size } - - render() - - const textElement = screen.getByText('T') - const outerDiv = textElement.parentElement as HTMLElement - expect(outerDiv).toHaveStyle({ width: expected, height: expected }) - }) }) - // Combined props tests - verify props work together correctly - describe('Combined Props', () => { - it('should apply all props correctly when used together', () => { - const onErrorMock = vi.fn() - const props = { - name: 'Test User', - avatar: 'https://example.com/avatar.jpg', - size: 64, - className: 'custom-avatar', - onError: onErrorMock, - } + describe('onLoadingStatusChange', () => { + it('should render image when avatar and onLoadingStatusChange are provided', () => { + render( + , + ) - render() - - const img = screen.getByRole('img') - expect(img).toHaveAttribute('alt', 'Test User') - expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg') - expect(img).toHaveStyle({ width: '64px', height: '64px' }) - expect(img).toHaveClass('custom-avatar') - - // Trigger load to verify onError callback - fireEvent.load(img) - expect(onErrorMock).toHaveBeenCalledWith(false) + expect(screen.getByRole('img')).toBeInTheDocument() }) - it('should apply all fallback props correctly when used together', () => { - const props = { - name: 'Fallback User', - avatar: null, - size: 48, - className: 'fallback-custom', - textClassName: 'custom-text-style', - } + it('should not render image when avatar is null even with onLoadingStatusChange', () => { + const onStatusChange = vi.fn() + render( + , + ) - render() - - const textElement = screen.getByText('F') - const outerDiv = textElement.parentElement as HTMLElement - expect(outerDiv).toHaveClass('fallback-custom') - expect(outerDiv).toHaveStyle({ width: '48px', height: '48px' }) - expect(textElement).toHaveClass('custom-text-style') + expect(screen.queryByRole('img')).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/base/avatar/index.stories.tsx b/web/app/components/base/avatar/index.stories.tsx index 5e392640ca..bf4da697db 100644 --- a/web/app/components/base/avatar/index.stories.tsx +++ b/web/app/components/base/avatar/index.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import Avatar from '.' +import { Avatar } from '.' const meta = { title: 'Base/Data Display/Avatar', @@ -7,12 +7,12 @@ const meta = { parameters: { docs: { description: { - component: 'Initials or image-based avatar used across contacts and member lists. Falls back to the first letter when the image fails to load.', + component: 'Initials or image-based avatar built on Base UI. Falls back to the first letter when the image fails to load.', }, source: { language: 'tsx', code: ` - + `.trim(), }, }, @@ -20,8 +20,8 @@ const meta = { tags: ['autodocs'], args: { name: 'Alex Doe', - avatar: 'https://cloud.dify.ai/logo/logo.svg', - size: 40, + avatar: 'https://i.pravatar.cc/96?u=avatar-default', + size: 'xl', }, } satisfies Meta @@ -40,23 +40,20 @@ export const WithFallback: Story = { source: { language: 'tsx', code: ` - + `.trim(), }, }, }, } -export const CustomSizes: Story = { +export const AllSizes: Story = { render: args => (
- {[24, 32, 48, 64].map(size => ( + {(['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const).map(size => (
- - {size} - px - + {size}
))}
@@ -66,7 +63,7 @@ export const CustomSizes: Story = { source: { language: 'tsx', code: ` -{[24, 32, 48, 64].map(size => ( +{(['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const).map(size => ( ))} `.trim(), @@ -74,3 +71,16 @@ export const CustomSizes: Story = { }, }, } + +export const AllFallbackSizes: Story = { + render: args => ( +
+ {(['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const).map(size => ( +
+ + {size} +
+ ))} +
+ ), +} diff --git a/web/app/components/base/avatar/index.tsx b/web/app/components/base/avatar/index.tsx index bf7fa060ef..2d55ec2720 100644 --- a/web/app/components/base/avatar/index.tsx +++ b/web/app/components/base/avatar/index.tsx @@ -1,64 +1,52 @@ -'use client' -import { useEffect, useState } from 'react' +import type { ImageLoadingStatus } from '@base-ui/react/avatar' +import { Avatar as BaseAvatar } from '@base-ui/react/avatar' import { cn } from '@/utils/classnames' +const SIZES = { + 'xxs': { root: 'size-4', text: 'text-[7px]' }, + 'xs': { root: 'size-5', text: 'text-[8px]' }, + 'sm': { root: 'size-6', text: 'text-[10px]' }, + 'md': { root: 'size-8', text: 'text-xs' }, + 'lg': { root: 'size-9', text: 'text-sm' }, + 'xl': { root: 'size-10', text: 'text-base' }, + '2xl': { root: 'size-12', text: 'text-xl' }, + '3xl': { root: 'size-16', text: 'text-2xl' }, +} as const + +export type AvatarSize = keyof typeof SIZES + export type AvatarProps = { name: string avatar: string | null - size?: number + size?: AvatarSize className?: string - textClassName?: string - onError?: (x: boolean) => void + onLoadingStatusChange?: (status: ImageLoadingStatus) => void } -const Avatar = ({ + +const BASE_CLASS = 'relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full bg-primary-600' + +export const Avatar = ({ name, avatar, - size = 30, + size = 'md', className, - textClassName, - onError, + onLoadingStatusChange, }: AvatarProps) => { - const avatarClassName = 'shrink-0 flex items-center rounded-full bg-primary-600' - const style = { width: `${size}px`, height: `${size}px`, fontSize: `${size}px`, lineHeight: `${size}px` } - const [imgError, setImgError] = useState(false) - - const handleError = () => { - setImgError(true) - onError?.(true) - } - - // after uploaded, api would first return error imgs url: '.../files//file-preview/...'. Then return the right url, Which caused not show the avatar - useEffect(() => { - if (avatar && imgError) - setImgError(false) - }, [avatar]) - - if (avatar && !imgError) { - return ( - {name} onError?.(false)} - /> - ) - } + const sizeConfig = SIZES[size] return ( -
-
- {name && name[0].toLocaleUpperCase()} -
-
+ + {avatar && ( + + )} + + {name?.[0]?.toLocaleUpperCase()} + + ) } - -export default Avatar diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 304425b9a7..e56cd194db 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -23,7 +23,7 @@ import { submitHumanInputForm as submitHumanInputFormService } from '@/service/w import { TransferMethod } from '@/types/app' import { cn } from '@/utils/classnames' import { formatBooleanInputs } from '@/utils/model-config' -import Avatar from '../../avatar' +import { Avatar } from '../../avatar' import Chat from '../chat' import { useChat } from '../chat/hooks' import { getLastAnswer, isValidGeneratedAnswer } from '../utils' @@ -351,7 +351,7 @@ const ChatWrapper = () => { ) : undefined diff --git a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx index 2e8f15d636..2f8d4fddb7 100644 --- a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx +++ b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx @@ -23,7 +23,7 @@ import { import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' import { TransferMethod } from '@/types/app' import { cn } from '@/utils/classnames' -import Avatar from '../../avatar' +import { Avatar } from '../../avatar' import Chat from '../chat' import { useChat } from '../chat/hooks' import { getLastAnswer, isValidGeneratedAnswer } from '../utils' @@ -337,7 +337,7 @@ const ChatWrapper = () => { ) : undefined diff --git a/web/app/components/datasets/settings/permission-selector/index.tsx b/web/app/components/datasets/settings/permission-selector/index.tsx index d28deb5b35..8becd7936c 100644 --- a/web/app/components/datasets/settings/permission-selector/index.tsx +++ b/web/app/components/datasets/settings/permission-selector/index.tsx @@ -4,7 +4,7 @@ import { useDebounceFn } from 'ahooks' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import Avatar from '@/app/components/base/avatar' +import { Avatar } from '@/app/components/base/avatar' import Input from '@/app/components/base/input' import { PortalToFollowElem, @@ -106,7 +106,7 @@ const PermissionSelector = ({ isOnlyMe && ( <>
- +
{t('form.permissionsOnlyMe', { ns: 'datasetSettings' })} @@ -135,7 +135,7 @@ const PermissionSelector = ({ ) } @@ -146,13 +146,13 @@ const PermissionSelector = ({ avatar={selectedMembers[0].avatar_url} name={selectedMembers[0].name} className="absolute left-0 top-0 z-0" - size={16} + size="xxs" /> ) @@ -182,7 +182,7 @@ const PermissionSelector = ({ {/* Only me */} + } text={t('form.permissionsOnlyMe', { ns: 'datasetSettings' })} onClick={onSelectOnlyMe} @@ -226,7 +226,7 @@ const PermissionSelector = ({ {showMe && ( + } name={userProfile.name} email={userProfile.email} @@ -237,7 +237,7 @@ const PermissionSelector = ({ {filteredMemberList.map(member => ( + } name={member.name} email={member.email} diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 87b286f319..0a5779839e 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' -import Avatar from '@/app/components/base/avatar' +import { Avatar } from '@/app/components/base/avatar' import PremiumBadge from '@/app/components/base/premium-badge' import ThemeSwitcher from '@/app/components/base/theme-switcher' import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu' @@ -140,7 +140,7 @@ export default function AppSelector() { aria-label={t('account.account', { ns: 'common' })} className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', isAccountMenuOpen && 'bg-background-default-dodge')} > - +
{userProfile.email}
- +
{ accounts.map(account => (
- +
{account.name} diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx index d2b1150c9c..a302b37f80 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import Avatar from '@/app/components/base/avatar' +import { Avatar } from '@/app/components/base/avatar' import Input from '@/app/components/base/input' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' import { useMembers } from '@/service/use-common' @@ -69,7 +69,7 @@ const MemberSelector: FC = ({ )} {currentValue && ( <> - +
{currentValue.name}
{currentValue.email}
@@ -98,7 +98,7 @@ const MemberSelector: FC = ({ setOpen(false) }} > - +
{account.name}
{account.email}
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx index be26c9bece..a655ccd6bb 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx @@ -3,7 +3,7 @@ import type { Member } from '@/models/common' import { RiCloseCircleFill, RiErrorWarningFill } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Avatar from '@/app/components/base/avatar' +import { Avatar } from '@/app/components/base/avatar' import { cn } from '@/utils/classnames' type Props = { @@ -34,7 +34,7 @@ const EmailItem = ({ {isError && ( )} - {!isError && } + {!isError && }
{email === data.email ? data.name : data.email} {email === data.email && {t('members.you', { ns: 'common' })}} diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx index eca07fd6ce..90f451474a 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx @@ -4,7 +4,7 @@ import type { Recipient } from '@/app/components/workflow/nodes/human-input/type import type { Member } from '@/models/common' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import Avatar from '@/app/components/base/avatar' +import { Avatar } from '@/app/components/base/avatar' import Input from '@/app/components/base/input' import { cn } from '@/utils/classnames' @@ -65,7 +65,7 @@ const MemberList: FC = ({ searchValue, list, value, onSearchChange, onSel onSelect(account.id) }} > - item.user_id === account.id) && 'opacity-50')} avatar={account.avatar_url} size={24} name={account.name} /> + item.user_id === account.id) && 'opacity-50')} avatar={account.avatar_url} size="sm" name={account.name} />
item.user_id === account.id) && 'opacity-50')}>
{account.name} diff --git a/web/app/education-apply/user-info.tsx b/web/app/education-apply/user-info.tsx index cc7f0bb63e..6481194870 100644 --- a/web/app/education-apply/user-info.tsx +++ b/web/app/education-apply/user-info.tsx @@ -1,6 +1,6 @@ import { useRouter } from 'next/navigation' import { useTranslation } from 'react-i18next' -import Avatar from '@/app/components/base/avatar' +import { Avatar } from '@/app/components/base/avatar' import Button from '@/app/components/base/button' import { Triangle } from '@/app/components/base/icons/src/public/education' import { useAppContext } from '@/context/app-context' @@ -34,7 +34,7 @@ const UserInfo = () => { className="mr-4" avatar={userProfile.avatar_url} name={userProfile.name} - size={48} + size="2xl" />
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index ca33a1fa2a..f6ebd5723c 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1436,11 +1436,6 @@ "count": 1 } }, - "app/components/base/avatar/index.tsx": { - "react-hooks-extra/no-direct-set-state-in-use-effect": { - "count": 1 - } - }, "app/components/base/block-input/index.stories.tsx": { "no-console": { "count": 2 diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index bfdf284b03..2f06800696 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -151,7 +151,7 @@ export default antfu( }, { name: 'dify/base-ui-primitives', - files: ['app/components/base/ui/**/*.tsx'], + files: ['app/components/base/ui/**/*.tsx', 'app/components/base/avatar/**/*.tsx'], rules: { 'react-refresh/only-export-components': 'off', },