You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

AvatarWithEdit.tsx 6.4KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  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 { RiDeleteBin5Line, 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 [isShowDeleteConfirm, setIsShowDeleteConfirm] = useState(false)
  27. const [hoverArea, setHoverArea] = useState<string>('left')
  28. const [onAvatarError, setOnAvatarError] = useState(false)
  29. const handleImageInput: OnImageInput = useCallback(async (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => {
  30. setInputImageInfo(
  31. isCropped
  32. ? { tempUrl: fileOrTempUrl as string, croppedAreaPixels: croppedAreaPixels!, fileName: fileName! }
  33. : { file: fileOrTempUrl as File },
  34. )
  35. }, [setInputImageInfo])
  36. const handleSaveAvatar = useCallback(async (uploadedFileId: string) => {
  37. try {
  38. await updateUserProfile({ url: 'account/avatar', body: { avatar: uploadedFileId } })
  39. notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
  40. setIsShowAvatarPicker(false)
  41. onSave?.()
  42. }
  43. catch (e) {
  44. notify({ type: 'error', message: (e as Error).message })
  45. }
  46. }, [notify, onSave, t])
  47. const handleDeleteAvatar = useCallback(async () => {
  48. try {
  49. await updateUserProfile({ url: 'account/avatar', body: { avatar: '' } })
  50. notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
  51. setIsShowDeleteConfirm(false)
  52. onSave?.()
  53. }
  54. catch (e) {
  55. notify({ type: 'error', message: (e as Error).message })
  56. }
  57. }, [notify, onSave, t])
  58. const { handleLocalFileUpload } = useLocalFileUploader({
  59. limit: 3,
  60. disabled: false,
  61. onUpload: (imageFile: ImageFile) => {
  62. if (imageFile.progress === 100) {
  63. setUploading(false)
  64. setInputImageInfo(undefined)
  65. handleSaveAvatar(imageFile.fileId)
  66. }
  67. // Error
  68. if (imageFile.progress === -1)
  69. setUploading(false)
  70. },
  71. })
  72. const handleSelect = useCallback(async () => {
  73. if (!inputImageInfo)
  74. return
  75. setUploading(true)
  76. if ('file' in inputImageInfo) {
  77. handleLocalFileUpload(inputImageInfo.file)
  78. return
  79. }
  80. const blob = await getCroppedImg(inputImageInfo.tempUrl, inputImageInfo.croppedAreaPixels, inputImageInfo.fileName)
  81. const file = new File([blob], inputImageInfo.fileName, { type: blob.type })
  82. handleLocalFileUpload(file)
  83. }, [handleLocalFileUpload, inputImageInfo])
  84. if (DISABLE_UPLOAD_IMAGE_AS_ICON)
  85. return <Avatar {...props} />
  86. return (
  87. <>
  88. <div>
  89. <div className="group relative">
  90. <Avatar {...props} onError={(x: boolean) => setOnAvatarError(x)} />
  91. <div
  92. className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
  93. onClick={() => {
  94. if (hoverArea === 'right' && !onAvatarError)
  95. setIsShowDeleteConfirm(true)
  96. else
  97. setIsShowAvatarPicker(true)
  98. }}
  99. onMouseMove={(e) => {
  100. const rect = e.currentTarget.getBoundingClientRect()
  101. const x = e.clientX - rect.left
  102. const isRight = x > rect.width / 2
  103. setHoverArea(isRight ? 'right' : 'left')
  104. }}
  105. >
  106. {hoverArea === 'right' && !onAvatarError ? (
  107. <span className="text-xs text-white">
  108. <RiDeleteBin5Line />
  109. </span>
  110. ) : (
  111. <span className="text-xs text-white">
  112. <RiPencilLine />
  113. </span>
  114. )}
  115. </div>
  116. </div>
  117. </div>
  118. <Modal
  119. closable
  120. className="!w-[362px] !p-0"
  121. isShow={isShowAvatarPicker}
  122. onClose={() => setIsShowAvatarPicker(false)}
  123. >
  124. <ImageInput onImageInput={handleImageInput} cropShape='round' />
  125. <Divider className='m-0' />
  126. <div className='flex w-full items-center justify-center gap-2 p-3'>
  127. <Button className='w-full' onClick={() => setIsShowAvatarPicker(false)}>
  128. {t('app.iconPicker.cancel')}
  129. </Button>
  130. <Button variant="primary" className='w-full' disabled={uploading || !inputImageInfo} loading={uploading} onClick={handleSelect}>
  131. {t('app.iconPicker.ok')}
  132. </Button>
  133. </div>
  134. </Modal>
  135. <Modal
  136. closable
  137. className="!w-[362px] !p-6"
  138. isShow={isShowDeleteConfirm}
  139. onClose={() => setIsShowDeleteConfirm(false)}
  140. >
  141. <div className="title-2xl-semi-bold mb-3 text-text-primary">{t('common.avatar.deleteTitle')}</div>
  142. <p className="mb-8 text-text-secondary">{t('common.avatar.deleteDescription')}</p>
  143. <div className="flex w-full items-center justify-center gap-2">
  144. <Button className="w-full" onClick={() => setIsShowDeleteConfirm(false)}>
  145. {t('common.operation.cancel')}
  146. </Button>
  147. <Button variant="warning" className="w-full" onClick={handleDeleteAvatar}>
  148. {t('common.operation.delete')}
  149. </Button>
  150. </div>
  151. </Modal>
  152. </>
  153. )
  154. }
  155. export default AvatarWithEdit