AvatarWithEdit.tsx 4.4 KB

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