Browse Source

feat: Add ability to change profile avatar (#12642)

tags/0.15.2
Shun Miyazawa 9 months ago
parent
commit
f582d4a13e
No account linked to committer's email address

+ 3
- 1
api/fields/member_fields.py View File

from flask_restful import fields # type: ignore 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} simple_account_fields = {"id": fields.String, "name": fields.String, "email": fields.String}


"id": fields.String, "id": fields.String,
"name": fields.String, "name": fields.String,
"avatar": fields.String, "avatar": fields.String,
"avatar_url": AvatarUrlField,
"email": fields.String, "email": fields.String,
"is_password_set": fields.Boolean, "is_password_set": fields.Boolean,
"interface_language": fields.String, "interface_language": fields.String,
"id": fields.String, "id": fields.String,
"name": fields.String, "name": fields.String,
"avatar": fields.String, "avatar": fields.String,
"avatar_url": AvatarUrlField,
"email": fields.String, "email": fields.String,
"last_login_at": TimestampField, "last_login_at": TimestampField,
"last_active_at": TimestampField, "last_active_at": TimestampField,

+ 12
- 0
api/libs/helper.py View File

return None 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): class TimestampField(fields.Raw):
def format(self, value) -> int: def format(self, value) -> int:
return int(value.timestamp()) return int(value.timestamp())

+ 122
- 0
web/app/account/account-page/AvatarWithEdit.tsx View File

'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

+ 2
- 2
web/app/account/account-page/index.tsx View File

import { useContext } from 'use-context-selector' import { useContext } from 'use-context-selector'
import DeleteAccount from '../delete-account' import DeleteAccount from '../delete-account'
import s from './index.module.css' import s from './index.module.css'
import AvatarWithEdit from './AvatarWithEdit'
import Collapse from '@/app/components/header/account-setting/collapse' import Collapse from '@/app/components/header/account-setting/collapse'
import type { IItem } from '@/app/components/header/account-setting/collapse' import type { IItem } from '@/app/components/header/account-setting/collapse'
import Modal from '@/app/components/base/modal' import Modal from '@/app/components/base/modal'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
import Avatar from '@/app/components/base/avatar'
import { IS_CE_EDITION } from '@/config' import { IS_CE_EDITION } from '@/config'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'


<h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4> <h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4>
</div> </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'> <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'> <div className='ml-4'>
<p className='system-xl-semibold text-text-primary'>{userProfile.name}</p> <p className='system-xl-semibold text-text-primary'>{userProfile.name}</p>
<p className='system-xs-regular text-text-tertiary'>{userProfile.email}</p> <p className='system-xs-regular text-text-tertiary'>{userProfile.email}</p>

+ 2
- 2
web/app/account/avatar.tsx View File

${open && 'bg-components-panel-bg-blur'} ${open && 'bg-components-panel-bg-blur'}
`} `}
> >
<Avatar name={userProfile.name} size={32} />
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />
</Menu.Button> </Menu.Button>
</div> </div>
<Transition <Transition
<div className='system-md-medium text-text-primary break-all'>{userProfile.name}</div> <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 className='system-xs-regular text-text-tertiary break-all'>{userProfile.email}</div>
</div> </div>
<Avatar name={userProfile.name} size={32} />
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />
</div> </div>
</div> </div>
</Menu.Item> </Menu.Item>

+ 1
- 1
web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx View File

suggestedQuestions={suggestedQuestions} suggestedQuestions={suggestedQuestions}
onSend={doSend} onSend={doSend}
showPromptLog showPromptLog
questionIcon={<Avatar name={userProfile.name} size={40} />}
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />}
allToolIcons={allToolIcons} allToolIcons={allToolIcons}
hideLogModal hideLogModal
noSpacing noSpacing

+ 1
- 1
web/app/components/app/configuration/debug/debug-with-single-model/index.tsx View File

onRegenerate={doRegenerate} onRegenerate={doRegenerate}
onStopResponding={handleStop} onStopResponding={handleStop}
showPromptLog showPromptLog
questionIcon={<Avatar name={userProfile.name} size={40} />}
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />}
allToolIcons={allToolIcons} allToolIcons={allToolIcons}
onAnnotationEdited={handleAnnotationEdited} onAnnotationEdited={handleAnnotationEdited}
onAnnotationAdded={handleAnnotationAdded} onAnnotationAdded={handleAnnotationAdded}

+ 4
- 2
web/app/components/base/app-icon-picker/ImageInput.tsx View File



import type { ChangeEvent, FC } from 'react' import type { ChangeEvent, FC } from 'react'
import { createRef, useEffect, useState } 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 classNames from 'classnames'


import { ImagePlus } from '../icons/src/vender/line/images' import { ImagePlus } from '../icons/src/vender/line/images'


type UploaderProps = { type UploaderProps = {
className?: string className?: string
cropShape?: CropperProps['cropShape']
onImageInput?: OnImageInput onImageInput?: OnImageInput
} }


const ImageInput: FC<UploaderProps> = ({ const ImageInput: FC<UploaderProps> = ({
className, className,
cropShape,
onImageInput, onImageInput,
}) => { }) => {
const [inputImage, setInputImage] = useState<{ file: File; url: string }>() const [inputImage, setInputImage] = useState<{ file: File; url: string }>()
crop={crop} crop={crop}
zoom={zoom} zoom={zoom}
aspect={1} aspect={1}
cropShape={cropShape}
onCropChange={setCrop} onCropChange={setCrop}
onCropComplete={onCropComplete} onCropComplete={onCropComplete}
onZoomChange={setZoom} onZoomChange={setZoom}

+ 2
- 2
web/app/components/base/avatar/index.tsx View File

import { useState } from 'react' import { useState } from 'react'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'


type AvatarProps = {
export type AvatarProps = {
name: string name: string
avatar?: string
avatar: string | null
size?: number size?: number
className?: string className?: string
textClassName?: string textClassName?: string

+ 4
- 4
web/app/components/datasets/settings/permission-selector/index.tsx View File

> >
{permission === 'only_me' && ( {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')}> <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> <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' />} {!disabled && <RiArrowDownSLine className='shrink-0 w-4 h-4 text-gray-700' />}
</div> </div>
setOpen(false) setOpen(false)
}}> }}>
<div className='flex items-center gap-2'> <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> <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' />} {permission === 'only_me' && <Check className='w-4 h-4 text-primary-600' />}
</div> </div>
</div> </div>
{showMe && ( {showMe && (
<div className='pl-3 pr-[10px] py-1 flex gap-2 items-center rounded-lg'> <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='grow'>
<div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'> <div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'>
{userProfile.name} {userProfile.name}
)} )}
{filteredMemberList.map(member => ( {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)}> <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='grow'>
<div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'>{member.name}</div> <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> <div className='text-xs text-gray-500 leading-[18px] truncate'>{member.email}</div>

+ 2
- 2
web/app/components/header/account-dropdown/index.tsx View File

${open && 'bg-gray-200'} ${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 && <> {!isMobile && <>
{userProfile.name} {userProfile.name}
<RiArrowDownSLine className="w-3 h-3 ml-1 text-gray-700" /> <RiArrowDownSLine className="w-3 h-3 ml-1 text-gray-700" />
> >
<Menu.Item disabled> <Menu.Item disabled>
<div className='flex flex-nowrap items-center px-4 py-[13px]'> <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='grow'>
<div className='system-md-medium text-text-primary break-all'>{userProfile.name}</div> <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 className='system-xs-regular text-text-tertiary break-all'>{userProfile.email}</div>

+ 1
- 1
web/app/components/header/account-setting/members-page/index.tsx View File

accounts.map(account => ( accounts.map(account => (
<div key={account.id} className='flex border-b border-divider-subtle'> <div key={account.id} className='flex border-b border-divider-subtle'>
<div className='grow flex items-center py-2 px-3'> <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=''>
<div className='text-text-secondary system-sm-medium'> <div className='text-text-secondary system-sm-medium'>
{account.name} {account.name}

+ 2
- 1
web/models/common.ts View File

name: string name: string
email: string email: string
avatar: string avatar: string
avatar_url: string | null
is_password_set: boolean is_password_set: boolean
interface_language?: string interface_language?: string
interface_theme?: string interface_theme?: string
trial_end_reason: null | 'trial_exceeded' | 'using_custom' 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 avatar: string
status: 'pending' | 'active' | 'banned' | 'closed' status: 'pending' | 'active' | 'banned' | 'closed'
role: 'owner' | 'admin' | 'editor' | 'normal' | 'dataset_operator' role: 'owner' | 'admin' | 'editor' | 'normal' | 'dataset_operator'

Loading…
Cancel
Save