Skip to content

Commit f582d4a

Browse files
authored
feat: Add ability to change profile avatar (#12642)
1 parent 2f41bd4 commit f582d4a

File tree

13 files changed

+158
-19
lines changed

13 files changed

+158
-19
lines changed

api/fields/member_fields.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from flask_restful import fields # type: ignore
22

3-
from libs.helper import TimestampField
3+
from libs.helper import AvatarUrlField, TimestampField
44

55
simple_account_fields = {"id": fields.String, "name": fields.String, "email": fields.String}
66

77
account_fields = {
88
"id": fields.String,
99
"name": fields.String,
1010
"avatar": fields.String,
11+
"avatar_url": AvatarUrlField,
1112
"email": fields.String,
1213
"is_password_set": fields.Boolean,
1314
"interface_language": fields.String,
@@ -22,6 +23,7 @@
2223
"id": fields.String,
2324
"name": fields.String,
2425
"avatar": fields.String,
26+
"avatar_url": AvatarUrlField,
2527
"email": fields.String,
2628
"last_login_at": TimestampField,
2729
"last_active_at": TimestampField,

api/libs/helper.py

+12
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ def output(self, key, obj):
4141
return None
4242

4343

44+
class AvatarUrlField(fields.Raw):
45+
def output(self, key, obj):
46+
if obj is None:
47+
return None
48+
49+
from models.account import Account
50+
51+
if isinstance(obj, Account) and obj.avatar is not None:
52+
return file_helpers.get_signed_file_url(obj.avatar)
53+
return None
54+
55+
4456
class TimestampField(fields.Raw):
4557
def format(self, value) -> int:
4658
return int(value.timestamp())
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
'use client'
2+
3+
import type { Area } from 'react-easy-crop'
4+
import React, { useCallback, useState } from 'react'
5+
import { useTranslation } from 'react-i18next'
6+
import { useContext } from 'use-context-selector'
7+
import { RiPencilLine } from '@remixicon/react'
8+
import { updateUserProfile } from '@/service/common'
9+
import { ToastContext } from '@/app/components/base/toast'
10+
import ImageInput, { type OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput'
11+
import Modal from '@/app/components/base/modal'
12+
import Divider from '@/app/components/base/divider'
13+
import Button from '@/app/components/base/button'
14+
import Avatar, { type AvatarProps } from '@/app/components/base/avatar'
15+
import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks'
16+
import type { ImageFile } from '@/types/app'
17+
import getCroppedImg from '@/app/components/base/app-icon-picker/utils'
18+
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
19+
20+
type InputImageInfo = { file: File } | { tempUrl: string; croppedAreaPixels: Area; fileName: string }
21+
type AvatarWithEditProps = AvatarProps & { onSave?: () => void }
22+
23+
const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
24+
const { t } = useTranslation()
25+
const { notify } = useContext(ToastContext)
26+
27+
const [inputImageInfo, setInputImageInfo] = useState<InputImageInfo>()
28+
const [isShowAvatarPicker, setIsShowAvatarPicker] = useState(false)
29+
const [uploading, setUploading] = useState(false)
30+
31+
const handleImageInput: OnImageInput = useCallback(async (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => {
32+
setInputImageInfo(
33+
isCropped
34+
? { tempUrl: fileOrTempUrl as string, croppedAreaPixels: croppedAreaPixels!, fileName: fileName! }
35+
: { file: fileOrTempUrl as File },
36+
)
37+
}, [setInputImageInfo])
38+
39+
const handleSaveAvatar = useCallback(async (uploadedFileId: string) => {
40+
try {
41+
await updateUserProfile({ url: 'account/avatar', body: { avatar: uploadedFileId } })
42+
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
43+
setIsShowAvatarPicker(false)
44+
onSave?.()
45+
}
46+
catch (e) {
47+
notify({ type: 'error', message: (e as Error).message })
48+
}
49+
}, [notify, onSave, t])
50+
51+
const { handleLocalFileUpload } = useLocalFileUploader({
52+
limit: 3,
53+
disabled: false,
54+
onUpload: (imageFile: ImageFile) => {
55+
if (imageFile.progress === 100) {
56+
setUploading(false)
57+
setInputImageInfo(undefined)
58+
handleSaveAvatar(imageFile.fileId)
59+
}
60+
61+
// Error
62+
if (imageFile.progress === -1)
63+
setUploading(false)
64+
},
65+
})
66+
67+
const handleSelect = useCallback(async () => {
68+
if (!inputImageInfo)
69+
return
70+
setUploading(true)
71+
if ('file' in inputImageInfo) {
72+
handleLocalFileUpload(inputImageInfo.file)
73+
return
74+
}
75+
const blob = await getCroppedImg(inputImageInfo.tempUrl, inputImageInfo.croppedAreaPixels, inputImageInfo.fileName)
76+
const file = new File([blob], inputImageInfo.fileName, { type: blob.type })
77+
handleLocalFileUpload(file)
78+
}, [handleLocalFileUpload, inputImageInfo])
79+
80+
if (DISABLE_UPLOAD_IMAGE_AS_ICON)
81+
return <Avatar {...props} />
82+
83+
return (
84+
<>
85+
<div>
86+
<div className="relative group">
87+
<Avatar {...props} />
88+
<div
89+
onClick={() => { setIsShowAvatarPicker(true) }}
90+
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"
91+
>
92+
<span className="text-white text-xs">
93+
<RiPencilLine />
94+
</span>
95+
</div>
96+
</div>
97+
</div>
98+
99+
<Modal
100+
closable
101+
className="!w-[362px] !p-0"
102+
isShow={isShowAvatarPicker}
103+
onClose={() => setIsShowAvatarPicker(false)}
104+
>
105+
<ImageInput onImageInput={handleImageInput} cropShape='round' />
106+
<Divider className='m-0' />
107+
108+
<div className='w-full flex items-center justify-center p-3 gap-2'>
109+
<Button className='w-full' onClick={() => setIsShowAvatarPicker(false)}>
110+
{t('app.iconPicker.cancel')}
111+
</Button>
112+
113+
<Button variant="primary" className='w-full' disabled={uploading || !inputImageInfo} loading={uploading} onClick={handleSelect}>
114+
{t('app.iconPicker.ok')}
115+
</Button>
116+
</div>
117+
</Modal>
118+
</>
119+
)
120+
}
121+
122+
export default AvatarWithEdit

web/app/account/account-page/index.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
55
import { useContext } from 'use-context-selector'
66
import DeleteAccount from '../delete-account'
77
import s from './index.module.css'
8+
import AvatarWithEdit from './AvatarWithEdit'
89
import Collapse from '@/app/components/header/account-setting/collapse'
910
import type { IItem } from '@/app/components/header/account-setting/collapse'
1011
import Modal from '@/app/components/base/modal'
@@ -13,7 +14,6 @@ import { updateUserProfile } from '@/service/common'
1314
import { useAppContext } from '@/context/app-context'
1415
import { ToastContext } from '@/app/components/base/toast'
1516
import AppIcon from '@/app/components/base/app-icon'
16-
import Avatar from '@/app/components/base/avatar'
1717
import { IS_CE_EDITION } from '@/config'
1818
import Input from '@/app/components/base/input'
1919

@@ -133,7 +133,7 @@ export default function AccountPage() {
133133
<h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4>
134134
</div>
135135
<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'>
136-
<Avatar name={userProfile.name} size={64} />
136+
<AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={ mutateUserProfile } size={64} />
137137
<div className='ml-4'>
138138
<p className='system-xl-semibold text-text-primary'>{userProfile.name}</p>
139139
<p className='system-xs-regular text-text-tertiary'>{userProfile.email}</p>

web/app/account/avatar.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export default function AppSelector() {
4545
${open && 'bg-components-panel-bg-blur'}
4646
`}
4747
>
48-
<Avatar name={userProfile.name} size={32} />
48+
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />
4949
</Menu.Button>
5050
</div>
5151
<Transition
@@ -71,7 +71,7 @@ export default function AppSelector() {
7171
<div className='system-md-medium text-text-primary break-all'>{userProfile.name}</div>
7272
<div className='system-xs-regular text-text-tertiary break-all'>{userProfile.email}</div>
7373
</div>
74-
<Avatar name={userProfile.name} size={32} />
74+
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />
7575
</div>
7676
</div>
7777
</Menu.Item>

web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ const ChatItem: FC<ChatItemProps> = ({
149149
suggestedQuestions={suggestedQuestions}
150150
onSend={doSend}
151151
showPromptLog
152-
questionIcon={<Avatar name={userProfile.name} size={40} />}
152+
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />}
153153
allToolIcons={allToolIcons}
154154
hideLogModal
155155
noSpacing

web/app/components/app/configuration/debug/debug-with-single-model/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ const DebugWithSingleModel = forwardRef<DebugWithSingleModelRefType, DebugWithSi
175175
onRegenerate={doRegenerate}
176176
onStopResponding={handleStop}
177177
showPromptLog
178-
questionIcon={<Avatar name={userProfile.name} size={40} />}
178+
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />}
179179
allToolIcons={allToolIcons}
180180
onAnnotationEdited={handleAnnotationEdited}
181181
onAnnotationAdded={handleAnnotationAdded}

web/app/components/base/app-icon-picker/ImageInput.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
import type { ChangeEvent, FC } from 'react'
44
import { createRef, useEffect, useState } from 'react'
5-
import type { Area } from 'react-easy-crop'
6-
import Cropper from 'react-easy-crop'
5+
import Cropper, { type Area, type CropperProps } from 'react-easy-crop'
76
import classNames from 'classnames'
87

98
import { ImagePlus } from '../icons/src/vender/line/images'
@@ -18,11 +17,13 @@ export type OnImageInput = {
1817

1918
type UploaderProps = {
2019
className?: string
20+
cropShape?: CropperProps['cropShape']
2121
onImageInput?: OnImageInput
2222
}
2323

2424
const ImageInput: FC<UploaderProps> = ({
2525
className,
26+
cropShape,
2627
onImageInput,
2728
}) => {
2829
const [inputImage, setInputImage] = useState<{ file: File; url: string }>()
@@ -78,6 +79,7 @@ const ImageInput: FC<UploaderProps> = ({
7879
crop={crop}
7980
zoom={zoom}
8081
aspect={1}
82+
cropShape={cropShape}
8183
onCropChange={setCrop}
8284
onCropComplete={onCropComplete}
8385
onZoomChange={setZoom}

web/app/components/base/avatar/index.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
import { useState } from 'react'
33
import cn from '@/utils/classnames'
44

5-
type AvatarProps = {
5+
export type AvatarProps = {
66
name: string
7-
avatar?: string
7+
avatar: string | null
88
size?: number
99
className?: string
1010
textClassName?: string

web/app/components/datasets/settings/permission-selector/index.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange,
7474
>
7575
{permission === 'only_me' && (
7676
<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')}>
77-
<Avatar name={userProfile.name} className='shrink-0 mr-2' size={24} />
77+
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0 mr-2' size={24} />
7878
<div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsOnlyMe')}</div>
7979
{!disabled && <RiArrowDownSLine className='shrink-0 w-4 h-4 text-gray-700' />}
8080
</div>
@@ -106,7 +106,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange,
106106
setOpen(false)
107107
}}>
108108
<div className='flex items-center gap-2'>
109-
<Avatar name={userProfile.name} className='shrink-0 mr-2' size={24} />
109+
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0 mr-2' size={24} />
110110
<div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsOnlyMe')}</div>
111111
{permission === 'only_me' && <Check className='w-4 h-4 text-primary-600' />}
112112
</div>
@@ -149,7 +149,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange,
149149
</div>
150150
{showMe && (
151151
<div className='pl-3 pr-[10px] py-1 flex gap-2 items-center rounded-lg'>
152-
<Avatar name={userProfile.name} className='shrink-0' size={24} />
152+
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0' size={24} />
153153
<div className='grow'>
154154
<div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'>
155155
{userProfile.name}
@@ -162,7 +162,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange,
162162
)}
163163
{filteredMemberList.map(member => (
164164
<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)}>
165-
<Avatar name={member.name} className='shrink-0' size={24} />
165+
<Avatar avatar={userProfile.avatar_url} name={member.name} className='shrink-0' size={24} />
166166
<div className='grow'>
167167
<div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'>{member.name}</div>
168168
<div className='text-xs text-gray-500 leading-[18px] truncate'>{member.email}</div>

web/app/components/header/account-dropdown/index.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
6868
${open && 'bg-gray-200'}
6969
`}
7070
>
71-
<Avatar name={userProfile.name} className='sm:mr-2 mr-0' size={32} />
71+
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='sm:mr-2 mr-0' size={32} />
7272
{!isMobile && <>
7373
{userProfile.name}
7474
<RiArrowDownSLine className="w-3 h-3 ml-1 text-gray-700" />
@@ -92,7 +92,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
9292
>
9393
<Menu.Item disabled>
9494
<div className='flex flex-nowrap items-center px-4 py-[13px]'>
95-
<Avatar name={userProfile.name} size={36} className='mr-3' />
95+
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} className='mr-3' />
9696
<div className='grow'>
9797
<div className='system-md-medium text-text-primary break-all'>{userProfile.name}</div>
9898
<div className='system-xs-regular text-text-tertiary break-all'>{userProfile.email}</div>

web/app/components/header/account-setting/members-page/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ const MembersPage = () => {
9595
accounts.map(account => (
9696
<div key={account.id} className='flex border-b border-divider-subtle'>
9797
<div className='grow flex items-center py-2 px-3'>
98-
<Avatar size={24} className='mr-2' name={account.name} />
98+
<Avatar avatar={account.avatar_url} size={24} className='mr-2' name={account.name} />
9999
<div className=''>
100100
<div className='text-text-secondary system-sm-medium'>
101101
{account.name}

web/models/common.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type UserProfileResponse = {
2222
name: string
2323
email: string
2424
avatar: string
25+
avatar_url: string | null
2526
is_password_set: boolean
2627
interface_language?: string
2728
interface_theme?: string
@@ -62,7 +63,7 @@ export type TenantInfoResponse = {
6263
trial_end_reason: null | 'trial_exceeded' | 'using_custom'
6364
}
6465

65-
export type Member = Pick<UserProfileResponse, 'id' | 'name' | 'email' | 'last_login_at' | 'last_active_at' | 'created_at'> & {
66+
export type Member = Pick<UserProfileResponse, 'id' | 'name' | 'email' | 'last_login_at' | 'last_active_at' | 'created_at' | 'avatar_url'> & {
6667
avatar: string
6768
status: 'pending' | 'active' | 'banned' | 'closed'
6869
role: 'owner' | 'admin' | 'editor' | 'normal' | 'dataset_operator'

0 commit comments

Comments
 (0)