Skip to content

feat: Add ability to change profile avatar #12642

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2ede0b7
Modified to return avatar_url
miya Jan 11, 2025
3351016
added the ability to upload avater
miya Jan 11, 2025
6d119a3
add onSelect prop to AvatarWithEdit for user profile mutation
miya Jan 11, 2025
c88c319
enhance AvatarWithEdit to show overlay on hover for avatar selection
miya Jan 11, 2025
941c8c4
add error handling and success notifications to avatar selection process
miya Jan 11, 2025
d607bd2
refactor AvatarWithEdit to extend AvatarProps and simplify prop defin…
miya Jan 11, 2025
6d473a6
add image upload functionality and modal for avatar selection
miya Jan 11, 2025
1932699
add cropShape prop to ImageInput for customizable cropping shape
miya Jan 11, 2025
af1f91d
clean code
miya Jan 11, 2025
c983b8a
onSelect -> onSave
miya Jan 11, 2025
ce75e06
add avatar_url field to account_with_role_fields for enhanced avatar …
miya Jan 11, 2025
cd245e5
add avatar props
miya Jan 11, 2025
24a0823
add conditional rendering to AvatarWithEdit for disabling image upload
miya Jan 11, 2025
5d9f1fb
Use icons instead of text
miya Jan 11, 2025
d61e375
Return null if avater is null
miya Jan 11, 2025
5510a75
fix indent
miya Jan 11, 2025
63e91e9
fix lint error
miya Jan 11, 2025
2fb79ff
isShowAvatarIconPicker -> isShowAvatarPicker
miya Jan 13, 2025
909f381
fix typo
miya Jan 13, 2025
b27e9a8
Merge branch 'main' into feat/add-profile-avatar-change
miya Jan 13, 2025
ac205bd
Initialize InputImageInfo when file upload is successfully completed
miya Jan 13, 2025
53bb55d
added error handling when file upload fails
miya Jan 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion api/fields/member_fields.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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}

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,
Expand All @@ -22,6 +23,7 @@
"id": fields.String,
"name": fields.String,
"avatar": fields.String,
"avatar_url": AvatarUrlField,
"email": fields.String,
"last_login_at": TimestampField,
"last_active_at": TimestampField,
Expand Down
12 changes: 12 additions & 0 deletions api/libs/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ def output(self, key, obj):
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())
Expand Down
122 changes: 122 additions & 0 deletions web/app/account/account-page/AvatarWithEdit.tsx
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions web/app/account/account-page/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -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>
Expand Down
4 changes: 2 additions & 2 deletions web/app/account/avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
6 changes: 4 additions & 2 deletions web/app/components/base/app-icon-picker/ImageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 }>()
Expand Down Expand Up @@ -78,6 +79,7 @@ const ImageInput: FC<UploaderProps> = ({
crop={crop}
zoom={zoom}
aspect={1}
cropShape={cropShape}
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
Expand Down
4 changes: 2 additions & 2 deletions web/app/components/base/avatar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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}
Expand All @@ -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>
Expand Down
4 changes: 2 additions & 2 deletions web/app/components/header/account-dropdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
3 changes: 2 additions & 1 deletion web/models/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
Loading