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

This commit is contained in:
Shun Miyazawa 2025-01-22 11:11:31 +09:00 • committed by GitHub
commit f582d4a13e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 158 additions and 19 deletions

View file

@ -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

View file

@ -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>

View file

@ -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>