index.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. 'use client'
  2. import React, { useCallback, useEffect, useRef, useState } from 'react'
  3. import { useTranslation } from 'react-i18next'
  4. import { useContext } from 'use-context-selector'
  5. import cn from 'classnames'
  6. import s from './index.module.css'
  7. import type { File as FileEntity } from '@/models/datasets'
  8. import { ToastContext } from '@/app/components/base/toast'
  9. import Button from '@/app/components/base/button'
  10. import { upload } from '@/service/base'
  11. type IFileUploaderProps = {
  12. file?: FileEntity
  13. onFileUpdate: (file?: FileEntity) => void
  14. }
  15. const ACCEPTS = [
  16. '.pdf',
  17. '.html',
  18. '.htm',
  19. '.md',
  20. '.markdown',
  21. '.txt',
  22. '.xls',
  23. '.xlsx',
  24. '.csv',
  25. ]
  26. const MAX_SIZE = 15 * 1024 * 1024
  27. const FileUploader = ({ file, onFileUpdate }: IFileUploaderProps) => {
  28. const { t } = useTranslation()
  29. const { notify } = useContext(ToastContext)
  30. const [dragging, setDragging] = useState(false)
  31. const dropRef = useRef<HTMLDivElement>(null)
  32. const dragRef = useRef<HTMLDivElement>(null)
  33. const fileUploader = useRef<HTMLInputElement>(null)
  34. const uploadPromise = useRef<any>(null)
  35. const [currentFile, setCurrentFile] = useState<File>()
  36. const [uploading, setUploading] = useState(false)
  37. const [percent, setPercent] = useState(0)
  38. // utils
  39. const getFileType = (currentFile: File) => {
  40. if (!currentFile)
  41. return ''
  42. const arr = currentFile.name.split('.')
  43. return arr[arr.length - 1]
  44. }
  45. const getFileName = (name: string) => {
  46. const arr = name.split('.')
  47. return arr.slice(0, -1).join()
  48. }
  49. const getFileSize = (size: number) => {
  50. if (size / 1024 < 10)
  51. return `${(size / 1024).toFixed(2)}KB`
  52. return `${(size / 1024 / 1024).toFixed(2)}MB`
  53. }
  54. const isValid = (file: File) => {
  55. const { size } = file
  56. const ext = `.${getFileType(file)}`
  57. const isValidType = ACCEPTS.includes(ext)
  58. if (!isValidType)
  59. notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.typeError') })
  60. const isValidSize = size <= MAX_SIZE
  61. if (!isValidSize)
  62. notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.size') })
  63. return isValidType && isValidSize
  64. }
  65. const onProgress = useCallback((e: ProgressEvent) => {
  66. if (e.lengthComputable) {
  67. const percent = Math.floor(e.loaded / e.total * 100)
  68. setPercent(percent)
  69. }
  70. }, [setPercent])
  71. const abort = () => {
  72. const currentXHR = uploadPromise.current
  73. currentXHR.abort()
  74. }
  75. const fileUpload = async (file?: File) => {
  76. if (!file)
  77. return
  78. if (!isValid(file))
  79. return
  80. setCurrentFile(file)
  81. setUploading(true)
  82. const formData = new FormData()
  83. formData.append('file', file)
  84. // store for abort
  85. const currentXHR = new XMLHttpRequest()
  86. uploadPromise.current = currentXHR
  87. try {
  88. const result = await upload({
  89. xhr: currentXHR,
  90. data: formData,
  91. onprogress: onProgress,
  92. }) as FileEntity
  93. onFileUpdate(result)
  94. setUploading(false)
  95. }
  96. catch (xhr: any) {
  97. setUploading(false)
  98. // abort handle
  99. if (xhr.readyState === 0 && xhr.status === 0) {
  100. if (fileUploader.current)
  101. fileUploader.current.value = ''
  102. setCurrentFile(undefined)
  103. return
  104. }
  105. notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.failed') })
  106. }
  107. }
  108. const handleDragEnter = (e: DragEvent) => {
  109. e.preventDefault()
  110. e.stopPropagation()
  111. e.target !== dragRef.current && setDragging(true)
  112. }
  113. const handleDragOver = (e: DragEvent) => {
  114. e.preventDefault()
  115. e.stopPropagation()
  116. }
  117. const handleDragLeave = (e: DragEvent) => {
  118. e.preventDefault()
  119. e.stopPropagation()
  120. e.target === dragRef.current && setDragging(false)
  121. }
  122. const handleDrop = (e: DragEvent) => {
  123. e.preventDefault()
  124. e.stopPropagation()
  125. setDragging(false)
  126. if (!e.dataTransfer)
  127. return
  128. const files = [...e.dataTransfer.files]
  129. if (files.length > 1) {
  130. notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.count') })
  131. return
  132. }
  133. onFileUpdate()
  134. fileUpload(files[0])
  135. }
  136. const selectHandle = () => {
  137. if (fileUploader.current)
  138. fileUploader.current.click()
  139. }
  140. const removeFile = () => {
  141. if (fileUploader.current)
  142. fileUploader.current.value = ''
  143. setCurrentFile(undefined)
  144. onFileUpdate()
  145. }
  146. const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => {
  147. const currentFile = e.target.files?.[0]
  148. onFileUpdate()
  149. fileUpload(currentFile)
  150. }
  151. useEffect(() => {
  152. dropRef.current?.addEventListener('dragenter', handleDragEnter)
  153. dropRef.current?.addEventListener('dragover', handleDragOver)
  154. dropRef.current?.addEventListener('dragleave', handleDragLeave)
  155. dropRef.current?.addEventListener('drop', handleDrop)
  156. return () => {
  157. dropRef.current?.removeEventListener('dragenter', handleDragEnter)
  158. dropRef.current?.removeEventListener('dragover', handleDragOver)
  159. dropRef.current?.removeEventListener('dragleave', handleDragLeave)
  160. dropRef.current?.removeEventListener('drop', handleDrop)
  161. }
  162. }, [])
  163. return (
  164. <div className={s.fileUploader}>
  165. <input
  166. ref={fileUploader}
  167. style={{ display: 'none' }}
  168. type="file"
  169. id="fileUploader"
  170. accept={ACCEPTS.join(',')}
  171. onChange={fileChangeHandle}
  172. />
  173. <div className={s.title}>{t('datasetCreation.stepOne.uploader.title')}</div>
  174. <div ref={dropRef}>
  175. {!currentFile && !file && (
  176. <div className={cn(s.uploader, dragging && s.dragging)}>
  177. <span>{t('datasetCreation.stepOne.uploader.button')}</span>
  178. <label className={s.browse} onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.browse')}</label>
  179. {dragging && <div ref={dragRef} className={s.draggingCover}/>}
  180. </div>
  181. )}
  182. </div>
  183. {currentFile && (
  184. <div className={cn(s.file, uploading && s.uploading)}>
  185. {uploading && (
  186. <div className={s.progressbar} style={{ width: `${percent}%` }}/>
  187. )}
  188. <div className={cn(s.fileIcon, s[getFileType(currentFile)])}/>
  189. <div className={s.fileInfo}>
  190. <div className={s.filename}>
  191. <span className={s.name}>{getFileName(currentFile.name)}</span>
  192. <span className={s.extension}>{`.${getFileType(currentFile)}`}</span>
  193. </div>
  194. <div className={s.fileExtraInfo}>
  195. <span className={s.size}>{getFileSize(currentFile.size)}</span>
  196. <span className={s.error}></span>
  197. </div>
  198. </div>
  199. <div className={s.actionWrapper}>
  200. {uploading && (
  201. <>
  202. <div className={s.percent}>{`${percent}%`}</div>
  203. <div className={s.divider}/>
  204. <div className={s.buttonWrapper}>
  205. <Button className={cn(s.button, 'ml-2 !h-8 bg-white')} onClick={abort}>{t('datasetCreation.stepOne.uploader.cancel')}</Button>
  206. </div>
  207. </>
  208. )}
  209. {!uploading && (
  210. <>
  211. <div className={s.buttonWrapper}>
  212. <Button className={cn(s.button, 'ml-2 !h-8 bg-white')} onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.change')}</Button>
  213. <div className={s.divider}/>
  214. <div className={s.remove} onClick={removeFile}/>
  215. </div>
  216. </>
  217. )}
  218. </div>
  219. </div>
  220. )}
  221. {!currentFile && file && (
  222. <div className={cn(s.file)}>
  223. <div className={cn(s.fileIcon, s[file.extension])}/>
  224. <div className={s.fileInfo}>
  225. <div className={s.filename}>
  226. <span className={s.name}>{getFileName(file.name)}</span>
  227. <span className={s.extension}>{`.${file.extension}`}</span>
  228. </div>
  229. <div className={s.fileExtraInfo}>
  230. <span className={s.size}>{getFileSize(file.size)}</span>
  231. <span className={s.error}></span>
  232. </div>
  233. </div>
  234. <div className={s.actionWrapper}>
  235. <div className={s.buttonWrapper}>
  236. <Button className={cn(s.button, 'ml-2 !h-8 bg-white')} onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.change')}</Button>
  237. <div className={s.divider}/>
  238. <div className={s.remove} onClick={removeFile}/>
  239. </div>
  240. </div>
  241. </div>
  242. )}
  243. <div className={s.tip}>{t('datasetCreation.stepOne.uploader.tip')}</div>
  244. </div>
  245. )
  246. }
  247. export default FileUploader