index.tsx 8.6 KB

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