index.tsx 8.6 KB

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