| @@ -1,6 +1,6 @@ | |||
| from flask_restful import fields # type: ignore | |||
| from libs.helper import TimestampField | |||
| from libs.helper import AvatarUrlField, TimestampField | |||
| simple_account_fields = {"id": fields.String, "name": fields.String, "email": fields.String} | |||
| @@ -8,6 +8,7 @@ account_fields = { | |||
| "id": fields.String, | |||
| "name": fields.String, | |||
| "avatar": fields.String, | |||
| "avatar_url": AvatarUrlField, | |||
| "email": fields.String, | |||
| "is_password_set": fields.Boolean, | |||
| "interface_language": fields.String, | |||
| @@ -22,6 +23,7 @@ account_with_role_fields = { | |||
| "id": fields.String, | |||
| "name": fields.String, | |||
| "avatar": fields.String, | |||
| "avatar_url": AvatarUrlField, | |||
| "email": fields.String, | |||
| "last_login_at": TimestampField, | |||
| "last_active_at": TimestampField, | |||
| @@ -41,6 +41,18 @@ class AppIconUrlField(fields.Raw): | |||
| return None | |||
| class AvatarUrlField(fields.Raw): | |||
| def output(self, key, obj): | |||
| if obj is None: | |||
| return None | |||
| from models.account import Account | |||
| if isinstance(obj, Account) and obj.avatar is not None: | |||
| return file_helpers.get_signed_file_url(obj.avatar) | |||
| return None | |||
| class TimestampField(fields.Raw): | |||
| def format(self, value) -> int: | |||
| return int(value.timestamp()) | |||
| @@ -0,0 +1,122 @@ | |||
| 'use client' | |||
| import type { Area } from 'react-easy-crop' | |||
| import React, { useCallback, useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useContext } from 'use-context-selector' | |||
| import { RiPencilLine } from '@remixicon/react' | |||
| import { updateUserProfile } from '@/service/common' | |||
| import { ToastContext } from '@/app/components/base/toast' | |||
| import ImageInput, { type OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput' | |||
| import Modal from '@/app/components/base/modal' | |||
| import Divider from '@/app/components/base/divider' | |||
| import Button from '@/app/components/base/button' | |||
| import Avatar, { type AvatarProps } from '@/app/components/base/avatar' | |||
| import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks' | |||
| import type { ImageFile } from '@/types/app' | |||
| import getCroppedImg from '@/app/components/base/app-icon-picker/utils' | |||
| import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config' | |||
| type InputImageInfo = { file: File } | { tempUrl: string; croppedAreaPixels: Area; fileName: string } | |||
| type AvatarWithEditProps = AvatarProps & { onSave?: () => void } | |||
| const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => { | |||
| const { t } = useTranslation() | |||
| const { notify } = useContext(ToastContext) | |||
| const [inputImageInfo, setInputImageInfo] = useState<InputImageInfo>() | |||
| const [isShowAvatarPicker, setIsShowAvatarPicker] = useState(false) | |||
| const [uploading, setUploading] = useState(false) | |||
| const handleImageInput: OnImageInput = useCallback(async (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => { | |||
| setInputImageInfo( | |||
| isCropped | |||
| ? { tempUrl: fileOrTempUrl as string, croppedAreaPixels: croppedAreaPixels!, fileName: fileName! } | |||
| : { file: fileOrTempUrl as File }, | |||
| ) | |||
| }, [setInputImageInfo]) | |||
| const handleSaveAvatar = useCallback(async (uploadedFileId: string) => { | |||
| try { | |||
| await updateUserProfile({ url: 'account/avatar', body: { avatar: uploadedFileId } }) | |||
| notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') }) | |||
| setIsShowAvatarPicker(false) | |||
| onSave?.() | |||
| } | |||
| catch (e) { | |||
| notify({ type: 'error', message: (e as Error).message }) | |||
| } | |||
| }, [notify, onSave, t]) | |||
| const { handleLocalFileUpload } = useLocalFileUploader({ | |||
| limit: 3, | |||
| disabled: false, | |||
| onUpload: (imageFile: ImageFile) => { | |||
| if (imageFile.progress === 100) { | |||
| setUploading(false) | |||
| setInputImageInfo(undefined) | |||
| handleSaveAvatar(imageFile.fileId) | |||
| } | |||
| // Error | |||
| if (imageFile.progress === -1) | |||
| setUploading(false) | |||
| }, | |||
| }) | |||
| const handleSelect = useCallback(async () => { | |||
| if (!inputImageInfo) | |||
| return | |||
| setUploading(true) | |||
| if ('file' in inputImageInfo) { | |||
| handleLocalFileUpload(inputImageInfo.file) | |||
| return | |||
| } | |||
| const blob = await getCroppedImg(inputImageInfo.tempUrl, inputImageInfo.croppedAreaPixels, inputImageInfo.fileName) | |||
| const file = new File([blob], inputImageInfo.fileName, { type: blob.type }) | |||
| handleLocalFileUpload(file) | |||
| }, [handleLocalFileUpload, inputImageInfo]) | |||
| if (DISABLE_UPLOAD_IMAGE_AS_ICON) | |||
| return <Avatar {...props} /> | |||
| return ( | |||
| <> | |||
| <div> | |||
| <div className="relative group"> | |||
| <Avatar {...props} /> | |||
| <div | |||
| onClick={() => { setIsShowAvatarPicker(true) }} | |||
| className="absolute inset-0 bg-black bg-opacity-50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer flex items-center justify-center" | |||
| > | |||
| <span className="text-white text-xs"> | |||
| <RiPencilLine /> | |||
| </span> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <Modal | |||
| closable | |||
| className="!w-[362px] !p-0" | |||
| isShow={isShowAvatarPicker} | |||
| onClose={() => setIsShowAvatarPicker(false)} | |||
| > | |||
| <ImageInput onImageInput={handleImageInput} cropShape='round' /> | |||
| <Divider className='m-0' /> | |||
| <div className='w-full flex items-center justify-center p-3 gap-2'> | |||
| <Button className='w-full' onClick={() => setIsShowAvatarPicker(false)}> | |||
| {t('app.iconPicker.cancel')} | |||
| </Button> | |||
| <Button variant="primary" className='w-full' disabled={uploading || !inputImageInfo} loading={uploading} onClick={handleSelect}> | |||
| {t('app.iconPicker.ok')} | |||
| </Button> | |||
| </div> | |||
| </Modal> | |||
| </> | |||
| ) | |||
| } | |||
| export default AvatarWithEdit | |||
| @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' | |||
| import { useContext } from 'use-context-selector' | |||
| import DeleteAccount from '../delete-account' | |||
| import s from './index.module.css' | |||
| import AvatarWithEdit from './AvatarWithEdit' | |||
| import Collapse from '@/app/components/header/account-setting/collapse' | |||
| import type { IItem } from '@/app/components/header/account-setting/collapse' | |||
| import Modal from '@/app/components/base/modal' | |||
| @@ -13,7 +14,6 @@ import { updateUserProfile } from '@/service/common' | |||
| import { useAppContext } from '@/context/app-context' | |||
| import { ToastContext } from '@/app/components/base/toast' | |||
| import AppIcon from '@/app/components/base/app-icon' | |||
| import Avatar from '@/app/components/base/avatar' | |||
| import { IS_CE_EDITION } from '@/config' | |||
| import Input from '@/app/components/base/input' | |||
| @@ -133,7 +133,7 @@ export default function AccountPage() { | |||
| <h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4> | |||
| </div> | |||
| <div className='mb-8 p-6 rounded-xl flex items-center bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1'> | |||
| <Avatar name={userProfile.name} size={64} /> | |||
| <AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={ mutateUserProfile } size={64} /> | |||
| <div className='ml-4'> | |||
| <p className='system-xl-semibold text-text-primary'>{userProfile.name}</p> | |||
| <p className='system-xs-regular text-text-tertiary'>{userProfile.email}</p> | |||
| @@ -45,7 +45,7 @@ export default function AppSelector() { | |||
| ${open && 'bg-components-panel-bg-blur'} | |||
| `} | |||
| > | |||
| <Avatar name={userProfile.name} size={32} /> | |||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} /> | |||
| </Menu.Button> | |||
| </div> | |||
| <Transition | |||
| @@ -71,7 +71,7 @@ export default function AppSelector() { | |||
| <div className='system-md-medium text-text-primary break-all'>{userProfile.name}</div> | |||
| <div className='system-xs-regular text-text-tertiary break-all'>{userProfile.email}</div> | |||
| </div> | |||
| <Avatar name={userProfile.name} size={32} /> | |||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} /> | |||
| </div> | |||
| </div> | |||
| </Menu.Item> | |||
| @@ -149,7 +149,7 @@ const ChatItem: FC<ChatItemProps> = ({ | |||
| suggestedQuestions={suggestedQuestions} | |||
| onSend={doSend} | |||
| showPromptLog | |||
| questionIcon={<Avatar name={userProfile.name} size={40} />} | |||
| questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />} | |||
| allToolIcons={allToolIcons} | |||
| hideLogModal | |||
| noSpacing | |||
| @@ -175,7 +175,7 @@ const DebugWithSingleModel = forwardRef<DebugWithSingleModelRefType, DebugWithSi | |||
| onRegenerate={doRegenerate} | |||
| onStopResponding={handleStop} | |||
| showPromptLog | |||
| questionIcon={<Avatar name={userProfile.name} size={40} />} | |||
| questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />} | |||
| allToolIcons={allToolIcons} | |||
| onAnnotationEdited={handleAnnotationEdited} | |||
| onAnnotationAdded={handleAnnotationAdded} | |||
| @@ -2,8 +2,7 @@ | |||
| import type { ChangeEvent, FC } from 'react' | |||
| import { createRef, useEffect, useState } from 'react' | |||
| import type { Area } from 'react-easy-crop' | |||
| import Cropper from 'react-easy-crop' | |||
| import Cropper, { type Area, type CropperProps } from 'react-easy-crop' | |||
| import classNames from 'classnames' | |||
| import { ImagePlus } from '../icons/src/vender/line/images' | |||
| @@ -18,11 +17,13 @@ export type OnImageInput = { | |||
| type UploaderProps = { | |||
| className?: string | |||
| cropShape?: CropperProps['cropShape'] | |||
| onImageInput?: OnImageInput | |||
| } | |||
| const ImageInput: FC<UploaderProps> = ({ | |||
| className, | |||
| cropShape, | |||
| onImageInput, | |||
| }) => { | |||
| const [inputImage, setInputImage] = useState<{ file: File; url: string }>() | |||
| @@ -78,6 +79,7 @@ const ImageInput: FC<UploaderProps> = ({ | |||
| crop={crop} | |||
| zoom={zoom} | |||
| aspect={1} | |||
| cropShape={cropShape} | |||
| onCropChange={setCrop} | |||
| onCropComplete={onCropComplete} | |||
| onZoomChange={setZoom} | |||
| @@ -2,9 +2,9 @@ | |||
| import { useState } from 'react' | |||
| import cn from '@/utils/classnames' | |||
| type AvatarProps = { | |||
| export type AvatarProps = { | |||
| name: string | |||
| avatar?: string | |||
| avatar: string | null | |||
| size?: number | |||
| className?: string | |||
| textClassName?: string | |||
| @@ -74,7 +74,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange, | |||
| > | |||
| {permission === 'only_me' && ( | |||
| <div className={cn('flex items-center px-3 py-[6px] rounded-lg bg-gray-100 cursor-pointer hover:bg-gray-200', open && 'bg-gray-200', disabled && 'hover:!bg-gray-100 !cursor-default')}> | |||
| <Avatar name={userProfile.name} className='shrink-0 mr-2' size={24} /> | |||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0 mr-2' size={24} /> | |||
| <div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsOnlyMe')}</div> | |||
| {!disabled && <RiArrowDownSLine className='shrink-0 w-4 h-4 text-gray-700' />} | |||
| </div> | |||
| @@ -106,7 +106,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange, | |||
| setOpen(false) | |||
| }}> | |||
| <div className='flex items-center gap-2'> | |||
| <Avatar name={userProfile.name} className='shrink-0 mr-2' size={24} /> | |||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0 mr-2' size={24} /> | |||
| <div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsOnlyMe')}</div> | |||
| {permission === 'only_me' && <Check className='w-4 h-4 text-primary-600' />} | |||
| </div> | |||
| @@ -149,7 +149,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange, | |||
| </div> | |||
| {showMe && ( | |||
| <div className='pl-3 pr-[10px] py-1 flex gap-2 items-center rounded-lg'> | |||
| <Avatar name={userProfile.name} className='shrink-0' size={24} /> | |||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0' size={24} /> | |||
| <div className='grow'> | |||
| <div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'> | |||
| {userProfile.name} | |||
| @@ -162,7 +162,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange, | |||
| )} | |||
| {filteredMemberList.map(member => ( | |||
| <div key={member.id} className='pl-3 pr-[10px] py-1 flex gap-2 items-center rounded-lg hover:bg-gray-100 cursor-pointer' onClick={() => selectMember(member)}> | |||
| <Avatar name={member.name} className='shrink-0' size={24} /> | |||
| <Avatar avatar={userProfile.avatar_url} name={member.name} className='shrink-0' size={24} /> | |||
| <div className='grow'> | |||
| <div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'>{member.name}</div> | |||
| <div className='text-xs text-gray-500 leading-[18px] truncate'>{member.email}</div> | |||
| @@ -68,7 +68,7 @@ export default function AppSelector({ isMobile }: IAppSelector) { | |||
| ${open && 'bg-gray-200'} | |||
| `} | |||
| > | |||
| <Avatar name={userProfile.name} className='sm:mr-2 mr-0' size={32} /> | |||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='sm:mr-2 mr-0' size={32} /> | |||
| {!isMobile && <> | |||
| {userProfile.name} | |||
| <RiArrowDownSLine className="w-3 h-3 ml-1 text-gray-700" /> | |||
| @@ -92,7 +92,7 @@ export default function AppSelector({ isMobile }: IAppSelector) { | |||
| > | |||
| <Menu.Item disabled> | |||
| <div className='flex flex-nowrap items-center px-4 py-[13px]'> | |||
| <Avatar name={userProfile.name} size={36} className='mr-3' /> | |||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} className='mr-3' /> | |||
| <div className='grow'> | |||
| <div className='system-md-medium text-text-primary break-all'>{userProfile.name}</div> | |||
| <div className='system-xs-regular text-text-tertiary break-all'>{userProfile.email}</div> | |||
| @@ -95,7 +95,7 @@ const MembersPage = () => { | |||
| accounts.map(account => ( | |||
| <div key={account.id} className='flex border-b border-divider-subtle'> | |||
| <div className='grow flex items-center py-2 px-3'> | |||
| <Avatar size={24} className='mr-2' name={account.name} /> | |||
| <Avatar avatar={account.avatar_url} size={24} className='mr-2' name={account.name} /> | |||
| <div className=''> | |||
| <div className='text-text-secondary system-sm-medium'> | |||
| {account.name} | |||
| @@ -22,6 +22,7 @@ export type UserProfileResponse = { | |||
| name: string | |||
| email: string | |||
| avatar: string | |||
| avatar_url: string | null | |||
| is_password_set: boolean | |||
| interface_language?: string | |||
| interface_theme?: string | |||
| @@ -62,7 +63,7 @@ export type TenantInfoResponse = { | |||
| trial_end_reason: null | 'trial_exceeded' | 'using_custom' | |||
| } | |||
| export type Member = Pick<UserProfileResponse, 'id' | 'name' | 'email' | 'last_login_at' | 'last_active_at' | 'created_at'> & { | |||
| export type Member = Pick<UserProfileResponse, 'id' | 'name' | 'email' | 'last_login_at' | 'last_active_at' | 'created_at' | 'avatar_url'> & { | |||
| avatar: string | |||
| status: 'pending' | 'active' | 'banned' | 'closed' | |||
| role: 'owner' | 'admin' | 'editor' | 'normal' | 'dataset_operator' | |||