Files
dify/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx
Stephen Zhou 1873b22e96 refactor: update to tailwind v4 (#34415)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-04-02 07:06:11 +00:00

173 lines
6.6 KiB
TypeScript

'use client'
import type { Area } from 'react-easy-crop'
import type { OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput'
import type { AvatarProps } from '@/app/components/base/avatar'
import type { ImageFile } from '@/types/app'
import { RiDeleteBin5Line, RiPencilLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ImageInput from '@/app/components/base/app-icon-picker/ImageInput'
import getCroppedImg from '@/app/components/base/app-icon-picker/utils'
import { Avatar } from '@/app/components/base/avatar'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { toast } from '@/app/components/base/ui/toast'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
import { updateUserProfile } from '@/service/common'
type InputImageInfo = { file: File } | { tempUrl: string, croppedAreaPixels: Area, fileName: string }
type AvatarWithEditProps = AvatarProps & { onSave?: () => void }
const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
const { t } = useTranslation()
const [inputImageInfo, setInputImageInfo] = useState<InputImageInfo>()
const [isShowAvatarPicker, setIsShowAvatarPicker] = useState(false)
const [uploading, setUploading] = useState(false)
const [isShowDeleteConfirm, setIsShowDeleteConfirm] = useState(false)
const [hoverArea, setHoverArea] = useState<string>('left')
const [onAvatarError, setOnAvatarError] = 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 } })
setIsShowAvatarPicker(false)
onSave?.()
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
}
catch (e) {
toast.error((e as Error).message)
}
}, [onSave, t])
const handleDeleteAvatar = useCallback(async () => {
try {
await updateUserProfile({ url: 'account/avatar', body: { avatar: '' } })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
setIsShowDeleteConfirm(false)
onSave?.()
}
catch (e) {
toast.error((e as Error).message)
}
}, [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="group relative">
<Avatar {...props} onLoadingStatusChange={status => setOnAvatarError(status === 'error')} />
<div
className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => {
if (hoverArea === 'right' && !onAvatarError)
setIsShowDeleteConfirm(true)
else
setIsShowAvatarPicker(true)
}}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const isRight = x > rect.width / 2
setHoverArea(isRight ? 'right' : 'left')
}}
>
{hoverArea === 'right' && !onAvatarError
? (
<span className="text-xs text-white">
<RiDeleteBin5Line />
</span>
)
: (
<span className="text-xs text-white">
<RiPencilLine />
</span>
)}
</div>
</div>
</div>
<Dialog open={isShowAvatarPicker} onOpenChange={open => !open && setIsShowAvatarPicker(false)}>
<DialogContent className="w-[362px]! p-0!">
<ImageInput onImageInput={handleImageInput} cropShape="round" />
<Divider className="m-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button className="w-full" onClick={() => setIsShowAvatarPicker(false)}>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button variant="primary" className="w-full" disabled={uploading || !inputImageInfo} loading={uploading} onClick={handleSelect}>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={isShowDeleteConfirm} onOpenChange={open => !open && setIsShowDeleteConfirm(false)}>
<DialogContent className="w-[362px]! p-6!">
<div className="mb-3 text-text-primary title-2xl-semi-bold">{t('avatar.deleteTitle', { ns: 'common' })}</div>
<p className="mb-8 text-text-secondary">{t('avatar.deleteDescription', { ns: 'common' })}</p>
<div className="flex w-full items-center justify-center gap-2">
<Button className="w-full" onClick={() => setIsShowDeleteConfirm(false)}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button variant="warning" className="w-full" onClick={handleDeleteAvatar}>
{t('operation.delete', { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}
export default AvatarWithEdit