Components.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
  2. import type { State, Uppy } from '@uppy/core/lib/Uppy'
  3. import type { FileProcessingInfo } from '@uppy/utils/lib/FileProgress'
  4. import type { I18n } from '@uppy/utils/lib/Translator'
  5. import { h } from 'preact'
  6. import classNames from 'classnames'
  7. import prettierBytes from '@transloadit/prettier-bytes'
  8. import prettyETA from '@uppy/utils/lib/prettyETA'
  9. import statusBarStates from './StatusBarStates.ts'
  10. const DOT = `\u00B7`
  11. const renderDot = (): string => ` ${DOT} `
  12. interface UploadBtnProps<M extends Meta, B extends Body> {
  13. newFiles: number
  14. isUploadStarted: boolean
  15. recoveredState: State<M, B>['recoveredState']
  16. i18n: I18n
  17. uploadState: string
  18. isSomeGhost: boolean
  19. startUpload: () => void
  20. }
  21. function UploadBtn<M extends Meta, B extends Body>(
  22. props: UploadBtnProps<M, B>,
  23. ): JSX.Element {
  24. const {
  25. newFiles,
  26. isUploadStarted,
  27. recoveredState,
  28. i18n,
  29. uploadState,
  30. isSomeGhost,
  31. startUpload,
  32. } = props
  33. const uploadBtnClassNames = classNames(
  34. 'uppy-u-reset',
  35. 'uppy-c-btn',
  36. 'uppy-StatusBar-actionBtn',
  37. 'uppy-StatusBar-actionBtn--upload',
  38. {
  39. 'uppy-c-btn-primary': uploadState === statusBarStates.STATE_WAITING,
  40. },
  41. { 'uppy-StatusBar-actionBtn--disabled': isSomeGhost },
  42. )
  43. const uploadBtnText =
  44. newFiles && isUploadStarted && !recoveredState ?
  45. i18n('uploadXNewFiles', { smart_count: newFiles })
  46. : i18n('uploadXFiles', { smart_count: newFiles })
  47. return (
  48. <button
  49. type="button"
  50. className={uploadBtnClassNames}
  51. aria-label={i18n('uploadXFiles', { smart_count: newFiles })}
  52. onClick={startUpload}
  53. disabled={isSomeGhost}
  54. data-uppy-super-focusable
  55. >
  56. {uploadBtnText}
  57. </button>
  58. )
  59. }
  60. interface RetryBtnProps<M extends Meta, B extends Body> {
  61. i18n: I18n
  62. uppy: Uppy<M, B>
  63. }
  64. function RetryBtn<M extends Meta, B extends Body>(
  65. props: RetryBtnProps<M, B>,
  66. ): JSX.Element {
  67. const { i18n, uppy } = props
  68. return (
  69. <button
  70. type="button"
  71. className="uppy-u-reset uppy-c-btn uppy-StatusBar-actionBtn uppy-StatusBar-actionBtn--retry"
  72. aria-label={i18n('retryUpload')}
  73. onClick={() =>
  74. uppy.retryAll().catch(() => {
  75. /* Error reported and handled via an event */
  76. })
  77. }
  78. data-uppy-super-focusable
  79. data-cy="retry"
  80. >
  81. <svg
  82. aria-hidden="true"
  83. focusable="false"
  84. className="uppy-c-icon"
  85. width="8"
  86. height="10"
  87. viewBox="0 0 8 10"
  88. >
  89. <path d="M4 2.408a2.75 2.75 0 1 0 2.75 2.75.626.626 0 0 1 1.25.018v.023a4 4 0 1 1-4-4.041V.25a.25.25 0 0 1 .389-.208l2.299 1.533a.25.25 0 0 1 0 .416l-2.3 1.533A.25.25 0 0 1 4 3.316v-.908z" />
  90. </svg>
  91. {i18n('retry')}
  92. </button>
  93. )
  94. }
  95. interface CancelBtnProps<M extends Meta, B extends Body> {
  96. i18n: I18n
  97. uppy: Uppy<M, B>
  98. }
  99. function CancelBtn<M extends Meta, B extends Body>(
  100. props: CancelBtnProps<M, B>,
  101. ): JSX.Element {
  102. const { i18n, uppy } = props
  103. return (
  104. <button
  105. type="button"
  106. className="uppy-u-reset uppy-StatusBar-actionCircleBtn"
  107. title={i18n('cancel')}
  108. aria-label={i18n('cancel')}
  109. onClick={(): void => uppy.cancelAll()}
  110. data-cy="cancel"
  111. data-uppy-super-focusable
  112. >
  113. <svg
  114. aria-hidden="true"
  115. focusable="false"
  116. className="uppy-c-icon"
  117. width="16"
  118. height="16"
  119. viewBox="0 0 16 16"
  120. >
  121. <g fill="none" fillRule="evenodd">
  122. <circle fill="#888" cx="8" cy="8" r="8" />
  123. <path
  124. fill="#FFF"
  125. d="M9.283 8l2.567 2.567-1.283 1.283L8 9.283 5.433 11.85 4.15 10.567 6.717 8 4.15 5.433 5.433 4.15 8 6.717l2.567-2.567 1.283 1.283z"
  126. />
  127. </g>
  128. </svg>
  129. </button>
  130. )
  131. }
  132. interface PauseResumeButtonProps<M extends Meta, B extends Body> {
  133. i18n: I18n
  134. uppy: Uppy<M, B>
  135. isAllPaused: boolean
  136. isAllComplete: boolean
  137. resumableUploads: boolean
  138. }
  139. function PauseResumeButton<M extends Meta, B extends Body>(
  140. props: PauseResumeButtonProps<M, B>,
  141. ): JSX.Element {
  142. const { isAllPaused, i18n, isAllComplete, resumableUploads, uppy } = props
  143. const title = isAllPaused ? i18n('resume') : i18n('pause')
  144. function togglePauseResume(): void {
  145. if (isAllComplete) return
  146. if (!resumableUploads) {
  147. uppy.cancelAll()
  148. return
  149. }
  150. if (isAllPaused) {
  151. uppy.resumeAll()
  152. return
  153. }
  154. uppy.pauseAll()
  155. }
  156. return (
  157. <button
  158. title={title}
  159. aria-label={title}
  160. className="uppy-u-reset uppy-StatusBar-actionCircleBtn"
  161. type="button"
  162. onClick={togglePauseResume}
  163. data-cy="togglePauseResume"
  164. data-uppy-super-focusable
  165. >
  166. <svg
  167. aria-hidden="true"
  168. focusable="false"
  169. className="uppy-c-icon"
  170. width="16"
  171. height="16"
  172. viewBox="0 0 16 16"
  173. >
  174. <g fill="none" fillRule="evenodd">
  175. <circle fill="#888" cx="8" cy="8" r="8" />
  176. <path
  177. fill="#FFF"
  178. d={
  179. isAllPaused ?
  180. 'M6 4.25L11.5 8 6 11.75z'
  181. : 'M5 4.5h2v7H5v-7zm4 0h2v7H9v-7z'
  182. }
  183. />
  184. </g>
  185. </svg>
  186. </button>
  187. )
  188. }
  189. interface DoneBtnProps {
  190. i18n: I18n
  191. doneButtonHandler: (() => void) | undefined
  192. }
  193. function DoneBtn(props: DoneBtnProps): JSX.Element {
  194. const { i18n, doneButtonHandler } = props
  195. return (
  196. <button
  197. type="button"
  198. className="uppy-u-reset uppy-c-btn uppy-StatusBar-actionBtn uppy-StatusBar-actionBtn--done"
  199. onClick={doneButtonHandler}
  200. data-uppy-super-focusable
  201. >
  202. {i18n('done')}
  203. </button>
  204. )
  205. }
  206. function LoadingSpinner(): JSX.Element {
  207. return (
  208. <svg
  209. className="uppy-StatusBar-spinner"
  210. aria-hidden="true"
  211. focusable="false"
  212. width="14"
  213. height="14"
  214. >
  215. <path
  216. d="M13.983 6.547c-.12-2.509-1.64-4.893-3.939-5.936-2.48-1.127-5.488-.656-7.556 1.094C.524 3.367-.398 6.048.162 8.562c.556 2.495 2.46 4.52 4.94 5.183 2.932.784 5.61-.602 7.256-3.015-1.493 1.993-3.745 3.309-6.298 2.868-2.514-.434-4.578-2.349-5.153-4.84a6.226 6.226 0 0 1 2.98-6.778C6.34.586 9.74 1.1 11.373 3.493c.407.596.693 1.282.842 1.988.127.598.073 1.197.161 1.794.078.525.543 1.257 1.15.864.525-.341.49-1.05.456-1.592-.007-.15.02.3 0 0"
  217. fillRule="evenodd"
  218. />
  219. </svg>
  220. )
  221. }
  222. interface ProgressBarProcessingProps {
  223. progress: FileProcessingInfo
  224. }
  225. function ProgressBarProcessing(props: ProgressBarProcessingProps): JSX.Element {
  226. const { progress } = props
  227. const { value, mode, message } = progress
  228. const dot = `\u00B7`
  229. return (
  230. <div className="uppy-StatusBar-content">
  231. <LoadingSpinner />
  232. {mode === 'determinate' ? `${Math.round(value * 100)}% ${dot} ` : ''}
  233. {message}
  234. </div>
  235. )
  236. }
  237. interface ProgressDetailsProps {
  238. i18n: I18n
  239. numUploads: number
  240. complete: number
  241. totalUploadedSize: number
  242. totalSize: number
  243. totalETA: number
  244. }
  245. function ProgressDetails(props: ProgressDetailsProps): JSX.Element {
  246. const { numUploads, complete, totalUploadedSize, totalSize, totalETA, i18n } =
  247. props
  248. const ifShowFilesUploadedOfTotal = numUploads > 1
  249. return (
  250. <div className="uppy-StatusBar-statusSecondary">
  251. {ifShowFilesUploadedOfTotal &&
  252. i18n('filesUploadedOfTotal', {
  253. complete,
  254. smart_count: numUploads,
  255. })}
  256. <span className="uppy-StatusBar-additionalInfo">
  257. {/* When should we render this dot?
  258. 1. .-additionalInfo is shown (happens only on desktops)
  259. 2. AND 'filesUploadedOfTotal' was shown
  260. */}
  261. {ifShowFilesUploadedOfTotal && renderDot()}
  262. {i18n('dataUploadedOfTotal', {
  263. complete: prettierBytes(totalUploadedSize),
  264. total: prettierBytes(totalSize),
  265. })}
  266. {renderDot()}
  267. {i18n('xTimeLeft', {
  268. time: prettyETA(totalETA),
  269. })}
  270. </span>
  271. </div>
  272. )
  273. }
  274. interface FileUploadCountProps {
  275. i18n: I18n
  276. complete: number
  277. numUploads: number
  278. }
  279. function FileUploadCount(props: FileUploadCountProps): JSX.Element {
  280. const { i18n, complete, numUploads } = props
  281. return (
  282. <div className="uppy-StatusBar-statusSecondary">
  283. {i18n('filesUploadedOfTotal', { complete, smart_count: numUploads })}
  284. </div>
  285. )
  286. }
  287. interface UploadNewlyAddedFilesProps {
  288. i18n: I18n
  289. newFiles: number
  290. startUpload: () => void
  291. }
  292. function UploadNewlyAddedFiles(props: UploadNewlyAddedFilesProps): JSX.Element {
  293. const { i18n, newFiles, startUpload } = props
  294. const uploadBtnClassNames = classNames(
  295. 'uppy-u-reset',
  296. 'uppy-c-btn',
  297. 'uppy-StatusBar-actionBtn',
  298. 'uppy-StatusBar-actionBtn--uploadNewlyAdded',
  299. )
  300. return (
  301. <div className="uppy-StatusBar-statusSecondary">
  302. <div className="uppy-StatusBar-statusSecondaryHint">
  303. {i18n('xMoreFilesAdded', { smart_count: newFiles })}
  304. </div>
  305. <button
  306. type="button"
  307. className={uploadBtnClassNames}
  308. aria-label={i18n('uploadXFiles', { smart_count: newFiles })}
  309. onClick={startUpload}
  310. >
  311. {i18n('upload')}
  312. </button>
  313. </div>
  314. )
  315. }
  316. interface ProgressBarUploadingProps {
  317. i18n: I18n
  318. supportsUploadProgress: boolean
  319. totalProgress: number
  320. showProgressDetails: boolean | undefined
  321. isUploadStarted: boolean
  322. isAllComplete: boolean
  323. isAllPaused: boolean
  324. newFiles: number
  325. numUploads: number
  326. complete: number
  327. totalUploadedSize: number
  328. totalSize: number
  329. totalETA: number
  330. startUpload: () => void
  331. }
  332. function ProgressBarUploading(
  333. props: ProgressBarUploadingProps,
  334. ): JSX.Element | null {
  335. const {
  336. i18n,
  337. supportsUploadProgress,
  338. totalProgress,
  339. showProgressDetails,
  340. isUploadStarted,
  341. isAllComplete,
  342. isAllPaused,
  343. newFiles,
  344. numUploads,
  345. complete,
  346. totalUploadedSize,
  347. totalSize,
  348. totalETA,
  349. startUpload,
  350. } = props
  351. const showUploadNewlyAddedFiles = newFiles && isUploadStarted
  352. if (!isUploadStarted || isAllComplete) {
  353. return null
  354. }
  355. const title = isAllPaused ? i18n('paused') : i18n('uploading')
  356. function renderProgressDetails(): JSX.Element | null {
  357. if (!isAllPaused && !showUploadNewlyAddedFiles && showProgressDetails) {
  358. if (supportsUploadProgress) {
  359. return (
  360. <ProgressDetails
  361. numUploads={numUploads}
  362. complete={complete}
  363. totalUploadedSize={totalUploadedSize}
  364. totalSize={totalSize}
  365. totalETA={totalETA}
  366. i18n={i18n}
  367. />
  368. )
  369. }
  370. return (
  371. <FileUploadCount
  372. i18n={i18n}
  373. complete={complete}
  374. numUploads={numUploads}
  375. />
  376. )
  377. }
  378. return null
  379. }
  380. return (
  381. <div className="uppy-StatusBar-content" aria-label={title} title={title}>
  382. {!isAllPaused ?
  383. <LoadingSpinner />
  384. : null}
  385. <div className="uppy-StatusBar-status">
  386. <div className="uppy-StatusBar-statusPrimary">
  387. {supportsUploadProgress ? `${title}: ${totalProgress}%` : title}
  388. </div>
  389. {renderProgressDetails()}
  390. {showUploadNewlyAddedFiles ?
  391. <UploadNewlyAddedFiles
  392. i18n={i18n}
  393. newFiles={newFiles}
  394. startUpload={startUpload}
  395. />
  396. : null}
  397. </div>
  398. </div>
  399. )
  400. }
  401. interface ProgressBarCompleteProps {
  402. i18n: I18n
  403. }
  404. function ProgressBarComplete(props: ProgressBarCompleteProps): JSX.Element {
  405. const { i18n } = props
  406. return (
  407. <div
  408. className="uppy-StatusBar-content"
  409. role="status"
  410. title={i18n('complete')}
  411. >
  412. <div className="uppy-StatusBar-status">
  413. <div className="uppy-StatusBar-statusPrimary">
  414. <svg
  415. aria-hidden="true"
  416. focusable="false"
  417. className="uppy-StatusBar-statusIndicator uppy-c-icon"
  418. width="15"
  419. height="11"
  420. viewBox="0 0 15 11"
  421. >
  422. <path d="M.414 5.843L1.627 4.63l3.472 3.472L13.202 0l1.212 1.213L5.1 10.528z" />
  423. </svg>
  424. {i18n('complete')}
  425. </div>
  426. </div>
  427. </div>
  428. )
  429. }
  430. interface ProgressBarErrorProps {
  431. i18n: I18n
  432. error: any
  433. complete: number
  434. numUploads: number
  435. }
  436. function ProgressBarError(props: ProgressBarErrorProps): JSX.Element {
  437. const { error, i18n, complete, numUploads } = props
  438. function displayErrorAlert(): void {
  439. const errorMessage = `${i18n('uploadFailed')} \n\n ${error}`
  440. // eslint-disable-next-line no-alert
  441. alert(errorMessage) // TODO: move to custom alert implementation
  442. }
  443. return (
  444. <div className="uppy-StatusBar-content" title={i18n('uploadFailed')}>
  445. <svg
  446. aria-hidden="true"
  447. focusable="false"
  448. className="uppy-StatusBar-statusIndicator uppy-c-icon"
  449. width="11"
  450. height="11"
  451. viewBox="0 0 11 11"
  452. >
  453. <path d="M4.278 5.5L0 1.222 1.222 0 5.5 4.278 9.778 0 11 1.222 6.722 5.5 11 9.778 9.778 11 5.5 6.722 1.222 11 0 9.778z" />
  454. </svg>
  455. <div className="uppy-StatusBar-status">
  456. <div className="uppy-StatusBar-statusPrimary">
  457. {i18n('uploadFailed')}
  458. <button
  459. className="uppy-u-reset uppy-StatusBar-details"
  460. aria-label={i18n('showErrorDetails')}
  461. data-microtip-position="top-right"
  462. data-microtip-size="medium"
  463. onClick={displayErrorAlert}
  464. type="button"
  465. >
  466. ?
  467. </button>
  468. </div>
  469. <FileUploadCount
  470. i18n={i18n}
  471. complete={complete}
  472. numUploads={numUploads}
  473. />
  474. </div>
  475. </div>
  476. )
  477. }
  478. export {
  479. UploadBtn,
  480. RetryBtn,
  481. CancelBtn,
  482. PauseResumeButton,
  483. DoneBtn,
  484. LoadingSpinner,
  485. ProgressDetails,
  486. ProgressBarProcessing,
  487. ProgressBarError,
  488. ProgressBarUploading,
  489. ProgressBarComplete,
  490. }