瀏覽代碼

Merge branch 'master' into patch-1

Artur Paikin 7 年之前
父節點
當前提交
ff650a34f7
共有 100 個文件被更改,包括 2656 次插入1774 次删除
  1. 1 1
      .travis.yml
  2. 120 27
      CHANGELOG.md
  3. 56 10
      README.md
  4. 2 1
      bin/build-css.js
  5. 1 1
      bin/upload-to-cdn.sh
  6. 1 0
      env.example.sh
  7. 1 1
      examples/aws-presigned-url/index.html
  8. 0 2
      examples/aws-presigned-url/main.js
  9. 1 1
      examples/aws-presigned-url/package.json
  10. 1 1
      examples/aws-uppy-server/index.html
  11. 0 2
      examples/aws-uppy-server/main.js
  12. 0 1
      examples/bundled-example/main.js
  13. 2 3
      examples/cdn-example/index.html
  14. 4 0
      examples/custom-provider/.gitignore
  15. 108 0
      examples/custom-provider/client/MyCustomProvider.js
  16. 7 0
      examples/custom-provider/client/aliasify.js
  17. 26 0
      examples/custom-provider/client/main.js
  18. 12 0
      examples/custom-provider/index.html
  19. 0 0
      examples/custom-provider/output/.empty
  20. 21 0
      examples/custom-provider/package.json
  21. 13 0
      examples/custom-provider/readme.md
  22. 42 0
      examples/custom-provider/server/customprovider.js
  23. 二進制
      examples/custom-provider/server/fixtures/image.jpg
  24. 89 0
      examples/custom-provider/server/index.js
  25. 0 2
      examples/digitalocean-spaces/main.js
  26. 1 1
      examples/digitalocean-spaces/package.json
  27. 2 4
      examples/multiple-instances/main.js
  28. 4 2
      examples/react-example/App.js
  29. 1 2
      examples/redux/main.js
  30. 2 3
      examples/uppy-with-server/client/index.html
  31. 2 1
      examples/uppy-with-server/package.json
  32. 3 3
      examples/xhr-bundle/main.js
  33. 1 1
      examples/xhr-bundle/package.json
  34. 239 239
      package-lock.json
  35. 11 4
      package.json
  36. 168 123
      src/core/Core.js
  37. 2 1
      src/core/Core.test.js
  38. 28 4
      src/core/Plugin.js
  39. 41 0
      src/core/PromiseWaiter.js
  40. 51 27
      src/core/Translator.js
  41. 19 3
      src/core/Translator.test.js
  42. 0 3
      src/core/UppySocket.js
  43. 12 142
      src/core/Utils.js
  44. 15 33
      src/core/Utils.test.js
  45. 6 10
      src/core/__snapshots__/Core.test.js.snap
  46. 36 0
      src/core/mime-types.js
  47. 424 0
      src/plugins/AwsS3/Multipart.js
  48. 284 0
      src/plugins/AwsS3/MultipartUploader.js
  49. 1 1
      src/plugins/AwsS3/index.js
  50. 16 10
      src/plugins/Dashboard/ActionBrowseTagline.js
  51. 9 9
      src/plugins/Dashboard/Dashboard.js
  52. 16 10
      src/plugins/Dashboard/FileCard.js
  53. 12 10
      src/plugins/Dashboard/FileItem.js
  54. 3 1
      src/plugins/Dashboard/FileItemProgress.js
  55. 29 24
      src/plugins/Dashboard/FileList.js
  56. 11 9
      src/plugins/Dashboard/Tabs.js
  57. 25 16
      src/plugins/Dashboard/index.js
  58. 14 0
      src/plugins/Dashboard/index.test.js
  59. 34 23
      src/plugins/DragDrop/index.js
  60. 6 6
      src/plugins/Dropbox/icons.js
  61. 4 0
      src/plugins/Dropbox/index.js
  62. 1 3
      src/plugins/Dummy.js
  63. 11 6
      src/plugins/FileInput.js
  64. 3 1
      src/plugins/Form.js
  65. 12 0
      src/plugins/GoogleDrive/index.js
  66. 6 1
      src/plugins/Instagram/index.js
  67. 9 9
      src/plugins/StatusBar/StatusBar.js
  68. 7 1
      src/plugins/StatusBar/index.js
  69. 55 19
      src/plugins/Transloadit/index.js
  70. 76 30
      src/plugins/Transloadit/index.test.js
  71. 58 25
      src/plugins/Tus.js
  72. 62 0
      src/plugins/Url/index.js
  73. 1 3
      src/plugins/Webcam/index.js
  74. 13 11
      src/plugins/XHRUpload.js
  75. 4 15
      src/react/Dashboard.js
  76. 13 13
      src/react/DashboardModal.js
  77. 4 5
      src/react/DragDrop.js
  78. 5 4
      src/react/ProgressBar.js
  79. 5 3
      src/react/StatusBar.js
  80. 2 2
      src/react/Wrapper.js
  81. 47 0
      src/react/propTypes.js
  82. 7 10
      src/scss/_common.scss
  83. 24 20
      src/scss/_dashboard.scss
  84. 2 12
      src/scss/_dragdrop.scss
  85. 1 1
      src/scss/_informer.scss
  86. 80 44
      src/scss/_microtip.scss
  87. 4 9
      src/scss/_progressbar.scss
  88. 14 6
      src/scss/_provider.scss
  89. 13 5
      src/scss/_statusbar.scss
  90. 7 1
      src/scss/_webcam.scss
  91. 15 0
      src/server/RequestClient.js
  92. 0 559
      src/vendor/file-type/index.js
  93. 0 21
      src/vendor/file-type/license
  94. 0 109
      src/vendor/file-type/package.json
  95. 27 14
      src/views/ProviderView/AuthView.js
  96. 3 1
      src/views/ProviderView/Browser.js
  97. 6 3
      src/views/ProviderView/Item.js
  98. 3 11
      src/views/ProviderView/ItemList.js
  99. 15 14
      src/views/ProviderView/index.js
  100. 1 5
      test/endtoend/src/main.js

+ 1 - 1
.travis.yml

@@ -1,6 +1,6 @@
 language: node_js
 language: node_js
 node_js:
 node_js:
-- 6.11.3
+- 8.11.1
 addons:
 addons:
   apt:
   apt:
     sources:
     sources:

+ 120 - 27
CHANGELOG.md

@@ -16,9 +16,10 @@ last Friday of every new month.
 
 
 ## Backlog
 ## Backlog
 
 
-Ideas that will be planned and find their way into a release at one point
+Ideas that will be planned and find their way into a release at one point.
+PRs are welcome! Please do open an issue to discuss first if it's a big feature, priorities may have changed after something was added here.
 
 
-- [ ] core: Decouple rendering from Plugin ?
+- [ ] core: Decouple rendering from the Plugin base class?
 - [ ] core: Make sure Uppy works well in VR
 - [ ] core: Make sure Uppy works well in VR
 - [ ] test: Human should check http://www.webpagetest.org and https://developers.google.com/web/tools/lighthouse/, use it sometimes to test website and Uppy. Will show response/loading times and other issues
 - [ ] test: Human should check http://www.webpagetest.org and https://developers.google.com/web/tools/lighthouse/, use it sometimes to test website and Uppy. Will show response/loading times and other issues
 - [ ] test: Human should test with real screen reader to identify accessibility problems
 - [ ] test: Human should test with real screen reader to identify accessibility problems
@@ -31,7 +32,7 @@ Ideas that will be planned and find their way into a release at one point
 - [ ] maybe restrict system file picking dialog too https://github.com/transloadit/uppy/issues/253
 - [ ] maybe restrict system file picking dialog too https://github.com/transloadit/uppy/issues/253
 - [ ] uppy-server: what happens if access token expires amid an upload/download process.
 - [ ] uppy-server: what happens if access token expires amid an upload/download process.
 - [ ] good way to change plugin options at runtime—maybe `this.state.options`?
 - [ ] good way to change plugin options at runtime—maybe `this.state.options`?
-- [ ] s3: multipart/"resumable" uploads for large files (@goto-bus-stop)
+- [x] s3: multipart/"resumable" uploads for large files (@goto-bus-stop)
 - [ ] DnD Bar: drag and drop + statusbar or progressbar ? (@arturi)
 - [ ] DnD Bar: drag and drop + statusbar or progressbar ? (@arturi)
 - [ ] possibility to work on already uploaded / in progress files #112, #113
 - [ ] possibility to work on already uploaded / in progress files #112, #113
 - [ ] possibility to edit/delete more than one file at once #118, #97
 - [ ] possibility to edit/delete more than one file at once #118, #97
@@ -45,7 +46,6 @@ Ideas that will be planned and find their way into a release at one point
 - [ ] Prepare for (piwik-) tracking of usage of uppy ? see #83
 - [ ] Prepare for (piwik-) tracking of usage of uppy ? see #83
 - [ ] screenshot+screencast support similar to Webcam #148
 - [ ] screenshot+screencast support similar to Webcam #148
 - [ ] Webcam modes #198
 - [ ] Webcam modes #198
-- [ ] feature: improved UI for Provider, Google Drive and Instagram, grid/list views
 - [ ] feature: React Native support
 - [ ] feature: React Native support
 - [ ] consider iframe / more security for Transloadit/Uppy integration widget and Uppy itself. Page can’t get files from Google Drive if its an iframe; possibility for folder restriction for provider plugins
 - [ ] consider iframe / more security for Transloadit/Uppy integration widget and Uppy itself. Page can’t get files from Google Drive if its an iframe; possibility for folder restriction for provider plugins
 - [ ] It would be nice in the long run to have a dynamic package builder here right on the website where you can select the plugins you need/want and it builds and downloads a minified version of them?
 - [ ] It would be nice in the long run to have a dynamic package builder here right on the website where you can select the plugins you need/want and it builds and downloads a minified version of them?
@@ -62,6 +62,12 @@ Sort of like jQuery UI: https://jqueryui.com/download/
 - [ ] provider: Add Facebook
 - [ ] provider: Add Facebook
 - [ ] provider: Add OneDrive
 - [ ] provider: Add OneDrive
 - [ ] provider: Add Box
 - [ ] provider: Add Box
+- [ ] provider: change ProviderViews signature to receive Provider instance in second param. ref https://github.com/transloadit/uppy/pull/743#discussion_r180106070
+- [ ] core: css-in-js, while keeping non-random classnames (ideally prefixed) and useful preprocessor features. also see simple https://github.com/codemirror/CodeMirror/blob/master/lib/codemirror.css (@arturi, @goto-bus-stop)
+- [ ] webcam: Stop recording when file size is exceeded, should be possible given how the MediaRecorder API works
+- [ ] dashboard: add option to disable uploading from local disk #657
+- [ ] dashboard: display data like image resolution on file cards #783
+- [ ] server: pass metadata to S3 `getKey` option, see https://github.com/transloadit/uppy/issues/689
 
 
 ## 1.0 Goals
 ## 1.0 Goals
 
 
@@ -69,14 +75,15 @@ What we need to do to release Uppy 1.0
 
 
 - [ ] QA: test how everything works together: user experience from `npm install` to production build with Webpack, using in React/Redux environment (npm pack)
 - [ ] QA: test how everything works together: user experience from `npm install` to production build with Webpack, using in React/Redux environment (npm pack)
 - [ ] QA: test in multiple browsers and mobile devices again
 - [ ] QA: test in multiple browsers and mobile devices again
-- [ ] QA: test uppy server. benchmarks / stress test. multiple connections, different setups, large files (10 GB)
+- [x] QA: test uppy server. benchmarks / stress test. multiple connections, different setups, large files (10 GB)
 - [ ] QA: tests for some plugins
 - [ ] QA: tests for some plugins
 - [x] docs: on using plugins, all options, list of plugins, i18n
 - [x] docs: on using plugins, all options, list of plugins, i18n
 - [ ] feature: preset for Transloadit that mimics jQuery SDK, check https://github.com/transloadit/jquery-sdk docs
 - [ ] feature: preset for Transloadit that mimics jQuery SDK, check https://github.com/transloadit/jquery-sdk docs
-- [ ] refactoring: possibly add CSS-in-JS
+- [ ] refactoring: possibly add CSS-in-JS, style encapsulation
 - [x] refactoring: possibly switch from Yo-Yo to Preact, because it’s more stable, solves a few issues we are struggling with (onload being weird/hard/modern-browsers-only with bel; no way to pass refs to elements; extra network requests with base64 urls) and mature, “new standard”, larger community
 - [x] refactoring: possibly switch from Yo-Yo to Preact, because it’s more stable, solves a few issues we are struggling with (onload being weird/hard/modern-browsers-only with bel; no way to pass refs to elements; extra network requests with base64 urls) and mature, “new standard”, larger community
 - [ ] refactoring: split uppy into small packages, lerna repo?
 - [ ] refactoring: split uppy into small packages, lerna repo?
 - [x] QA: tests for core and utils
 - [x] QA: tests for core and utils
+- [ ] feature: basic Reacte Native support
 - [x] feature: Redux and ReduxDevTools support (currently mirrors Uppy state to Redux)
 - [x] feature: Redux and ReduxDevTools support (currently mirrors Uppy state to Redux)
 - [x] feature: beta file recovering after closed tab / browser crash
 - [x] feature: beta file recovering after closed tab / browser crash
 - [x] feature: easy integration with React (UppyReact components)
 - [x] feature: easy integration with React (UppyReact components)
@@ -88,40 +95,126 @@ What we need to do to release Uppy 1.0
 - [x] uppy-server: security audit
 - [x] uppy-server: security audit
 - [x] uppy-server: storing tokens in user’s browser only (d040281cc9a63060e2f2685c16de0091aee5c7b4)
 - [x] uppy-server: storing tokens in user’s browser only (d040281cc9a63060e2f2685c16de0091aee5c7b4)
 
 
+# 0.26.0
+
+- [ ] dashboard: allow minimizing the Dashboard during upload (Uppy then becomes just a tiny progress indicator) (@arturi)
+
 # next
 # next
 
 
-## 0.24.0
+## 0.25.0
 
 
-To be released: 2018-03-29.
+To Be Released: 2018-05-31.
 
 
-- [ ] dashboard: allow minimizing the Dashboard during upload (Uppy then becomes just a tiny progress indicator) (@arturi)
-- [ ] dashboard: cancel button for any kind of uploads? currently resume/pause only for tus, and cancel for XHR (@arturi, @goto-bus-stop)
 - [ ] dashboard: cancel button for transloadit assemblies (@arturi, @goto-bus-stop)
 - [ ] dashboard: cancel button for transloadit assemblies (@arturi, @goto-bus-stop)
-- [ ] dashboard: disallow removing files if `bundle: true` in XHRUpload (@arturi) 
-- [ ] dashboard: optional alert `onbeforeunload` while upload is in progress, safeguarding from accidentaly navigating away from a page with an ongoing upload
-- [ ] dashboard: add image cropping, study https://github.com/MattKetmo/darkroomjs/, https://github.com/fengyuanchen/cropperjs #151
-- [ ] core: css-in-js, while keeping non-random classnames (ideally prefixed) and useful preprocessor features. also see simple https://github.com/codemirror/CodeMirror/blob/master/lib/codemirror.css (@arturi, @goto-bus-stop)
-- [ ] core: all: reset or !important styles to be immune to any environment/page, look at screenshots in #446. Maybe `postcss-safe-important`, http://cleanslatecss.com/ or https://github.com/maximkoretskiy/postcss-autoreset or increase specificity (with .uppy prefix) (@arturi)
-- [ ] url: refactor things into Provider, see comments in  https://github.com/transloadit/uppy/pull/588 (@ifedapoolarewaju, @arturi)
-- [ ] dashboard: option for Boolean metadata #454 (@arturi)
-- [ ] look into text-based file type icons to save space, or more icons for file types? (@nqst, @arturi)
 - [ ] core: figure out per-plugin locales and i18n strings packs #491
 - [ ] core: figure out per-plugin locales and i18n strings packs #491
 - [ ] goldenretriever: confirmation before restore #443
 - [ ] goldenretriever: confirmation before restore #443
 - [ ] goldenretriever: add “ghost” files (@arturi)
 - [ ] goldenretriever: add “ghost” files (@arturi)
-- [ ] core: i18n all strings + document them
-- [ ] core: update file-type
-- [x] goldenretriever: warn, not error, when files cannot be saved by goldenretriever (#641 / @goto-bus-stop)
+- [ ] test: add typescript with JSDoc (@arturi)
+- [ ] dragdrop: allow customizing arrow icon https://github.com/transloadit/uppy/pull/374#issuecomment-334116208 (@arturi)
+- [ ] dashboard: disallow removing files if `bundle: true` in XHRUpload (@arturi)
+- [ ] dashboard: optional alert `onbeforeunload` while upload is in progress, safeguarding from accidentaly navigating away from a page with an ongoing upload
+- [ ] dashboard: add image cropping, study https://github.com/MattKetmo/darkroomjs/, https://github.com/fengyuanchen/cropperjs #151
 - [ ] docs: quick start guide: https://community.transloadit.com/t/quick-start-guide-would-be-really-helpful/14605 (@arturi)
 - [ ] docs: quick start guide: https://community.transloadit.com/t/quick-start-guide-would-be-really-helpful/14605 (@arturi)
-- [ ] docs: all useful events (@arturi)
-- [ ] s3: rename `AWS S3` to something more general if it works with Google Cloud Storage too? See #460
-- [ ] dashboard: try adding optional whitelabel “powered by uppy.io”, maybe muted small uppy logo that gains color on hover (@nqst, @arturi)
 - [ ] transloadit: add error reporting (@goto-bus-stop)
 - [ ] transloadit: add error reporting (@goto-bus-stop)
 - [ ] uppy-server: benchmarks / stress test, large file, uppy-server / tus / S3 (10 GB)
 - [ ] uppy-server: benchmarks / stress test, large file, uppy-server / tus / S3 (10 GB)
 - [ ] uppy-server: document docker image setup for uppy-server (@ifedapoolarewaju)
 - [ ] uppy-server: document docker image setup for uppy-server (@ifedapoolarewaju)
 - [ ] xhrupload: emit a final `upload-progress` event in the XHRUpload plugin just before firing `upload-complete` (tus-js-client already handles this internally) (@arturi)
 - [ ] xhrupload: emit a final `upload-progress` event in the XHRUpload plugin just before firing `upload-complete` (tus-js-client already handles this internally) (@arturi)
-- [x] s3: fix xhr response handlers (#625, @goto-bus-stop)
-- [ ] test: add typescript with JSDoc (@arturi)
-- [ ] dragdrop: allow customizing arrow icon https://github.com/transloadit/uppy/pull/374#issuecomment-334116208 (@arturi)
+- [ ] core: add more mime-to-extension mappings from https://github.com/micnic/mime.json/blob/master/index.json (which ones?) # (@arturi, @goto-bus-stop)
+- [ ] providers: select files only after “select” is pressed, don’t add them right away when they are checked (keep a list of fileIds in state?); better UI + solves issue with autoProceed uploading in background, which is weird; re-read https://github.com/transloadit/uppy/pull/419#issuecomment-345210519 (@arturi, @goto-bus-stop)
+- [x] tus: add `filename` and `filetype`, so that tus servers knows what headers to set  https://github.com/tus/tus-js-client/commit/ebc5189eac35956c9f975ead26de90c896dbe360 (#844 / @vith)
+- [ ] core: look into utilizing https://github.com/que-etc/resize-observer-polyfill for responsive components. See also https://github.com/transloadit/uppy/issues/750
+- [x] core: removed .run() (to solve issues like #756), update ddocs (#793 / goto-bus-stop)
+- [ ] core: use Browserslist config to share between PostCSS, Autoprefixer and Babel https://github.com/browserslist/browserslist, https://github.com/amilajack/eslint-plugin-compat (@arturi)
+- [ ] core: utilize https://github.com/jonathantneal/postcss-preset-env, maybe https://github.com/jonathantneal/postcss-normalize (@arturi)
+- [ ] docs: improve on React docs https://uppy.io/docs/react/, add small example for each component maybe? Dashboard, DragDrop, ProgressBar? No need to make separate pages for all of them, just headings on the same page. Right now docs are confusing, because they focus on DashboardModal. Also problems with syntax highlight on https://uppy.io/docs/react/dashboard-modal/ (@goto-bus-stop)
+- [ ] core: customizing metadata fields, boolean metadata; see #809, #454 and related (@arturi)
+- [x] providers: Add user/account names to Uppy provider views (61bf0a7 / @ifedapoolarewaju)
+- [x] s3: implement multipart uploads (#726 / @goto-bus-stop)
+
+## 0.24.4
+
+Released: 2018-05-14.
+
+- core: Pass `allowedFileTypes` and `maxNumberOfFiles` to input[type=file] in UI components: Dashboard, DragDrop, FileInput (#814 / @arturi)
+- transloadit: Update Transloadit plugin's Uppy Server handling (#804 / @goto-bus-stop)
+- tus: respect `limit` option for upload parameter requests (#817 / @ap--)
+- docs: Explain name `metadata` vs. `$_FILES[]["name"]` (#1c1bf2e / @goto-bus-stop)
+- dashboard: improve “powered by” icon (#0284c8e / @arturi)
+- statusbar: add default string for cancel button (#822 / @mrbatista)
+
+## 0.24.3
+
+Released: 2018-05-10.
+
+- core: add `uppy.getFiles()` method (@goto-bus-stop / #770)
+- core: merge meta data when add file (#810 / @mrbatista)
+- dashboard: fix duplicate plugin IDs, see #702 (@goto-bus-stop)
+- dashboard/statusbar: fix some unicode characters showing up as gibberish (#787 / @goto-bus-stop)
+- dashboard: Fix grid item height in remote providers with few files (#791 / @goto-bus-stop)
+- dashboard: Add `rel="noopener noreferrer"` to links containing `target="_blank"` (#767 / @kvz)
+- instagram: add extensions to instagram files (@ifedapoolarewaju)
+- transloadit: More robust failure handling for Transloadit, closes #708 (#805 / @goto-bus-stop)
+- docs: Document "headers" upload parameter in AwsS3 plugin (#780 / @janko-m)
+- docs: Update some `uppy.state` docs to align with the Stores feature (#792 / @goto-bus-stop)
+- dragdrop: Add `inputName` option like FileInput has, set empty value="", closes #729 (#778 / @goto-bus-stop, @arturi)
+- docs: Google Cloud Storage setup for the AwsS3 plugin (#777 / goto-bus-stop)
+- react: Update React component PropTypes (#776 / @arturi)
+- statusbar: add some spacing between text elements (#760 / @goto-bus-stop)
+
+## 0.24.2
+
+Released: 2018-04-17.
+
+- dashboard: Fix showLinkToFileUploadResult option (@arturi / #763)
+- docs: Consistent shape for the getResponseData (responseText, response) (@arturi / #765)
+
+## 0.24.1
+
+Released: 2018-04-16.
+
+- dashboard: ⚠️ **breaking** `maxWidth`, `maxHeight` --> `width` and `height`; update docs and React props too; regardless of what we call those internally, this makes more sense, I think (@arturi)
+- core: Avoid important for those styles that need to be overriden by inline-styles + microtip (@arturi)
+- tus & xhrupload: Retain uppy-server error messages, fixes #707 (@goto-bus-stop / #759)
+- dragdrop: Link `<label>` and `<input>`, fixes #749 (@goto-bus-stop / #757)
+
+## 0.24.0
+
+Released: 2018-04-12.
+
+- core: ⚠️ **breaking** !important styles to be immune to any environment/page, look at screenshots in #446. Use `postcss-safe-important` (look into http://cleanslatecss.com/ or https://github.com/maximkoretskiy/postcss-autoreset or increasing specificity with .uppy prefix) (#744 / @arturi)
+- core: ⚠️ **breaking** `onBeforeFileAdded()`, `onBeforeUpload()` and `addFile()` are now synchronous. You can no longer return a Promise from the `onBefore*()` functions. (#294, #746, @goto-bus-stop, @arturi)
+- statusbar: ⚠️ **breaking** Move progress details to second line and make them optional (#682 / @arturi)
+- core: Add uppy-Root to a DOM el that gets mounted in mount (#682 / @arturi)
+- core: Fix all file state was included in progress accidentally (#682 / @arturi)
+- dashboard: Options to disable showLinkToFileUploadResult and meta editing if metaFields is not provided (#682 / @arturi)
+- dashboard: Remove dashed file icon for now (#682 / @arturi)
+- dashboard: Add optional whitelabel “powered by uppy.io” (@nqst, @arturi)
+- dashboard: Huge UI redesign, update provider views, StatusBar, Webcam, FileCard (@arturi, @nqst)
+- docs: Update uppy-server docs to point to Kubernetes (#706 / @kiloreux)
+- docs: Talk about success_action_status for POST uploads (#728 / @goto-bus-stop)
+- docs: Add custom provider example (#743 / @ifedapoolarewaju)
+- docs: Addmore useful events, i18n strings, typos, fixes and improvements following Tim’s feedback (#704 / @arturi)
+- goldenretriever: Regenerate thumbnails after restore (#723 / @goto-bus-stop)
+- goldenretriever: Warn, not error, when files cannot be saved by goldenretriever (#641 / @goto-bus-stop)
+- instagram: Use date&time as file name for instagram files (#682 / @arturi)
+- providers: Fix logging out of providers (#742 / @goto-bus-stop)
+- providers: Refactor Provider views: Filter, add showFilter and showBreadcrumbs (#682 / @arturi)
+- react: Allow overriding `<DashboardModal />` `target` prop (#740, @goto-bus-stop)
+- s3: Support fake XHR from remote uploads (#711, @goto-bus-stop)
+- s3: Document Digital Ocean Spaces
+- s3: Fix xhr response handlers (#625, @goto-bus-stop)
+- statusbar: Cancel button for any kind of uploads (@arturi, @goto-bus-stop)
+- url: Add checks for protocols, assume `http` when no protocol is used (#682 / @arturi)
+- url: Refactor things into Provider, see comments in  https://github.com/transloadit/uppy/pull/588; exposing the Provider module and the ProviderView to the public API (#727 / @ifedapoolarewaju, @arturi)
+- webcam: Styles updates: adapt for mobile, better camera icon, move buttons to the bottom bar (#682 / @arturi)
+- server: Fixed security vulnerability in transient dependency [#70](https://github.com/transloadit/uppy-server/issues/70) (@ifedapoolarewaju)
+- server: Auto-generate tmp download file name to avoid Path traversal (@ifedapoolarewaju)
+- server: Namespace redis key storage/lookup to avoid collisions (@ifedapoolarewaju)
+- server: Validate callback redirect url after completing OAuth (@ifedapoolarewaju)
+- server: Reduce the permission level required by Google Drive (@ifedapoolarewaju)
+- server: Auto-generate Server secret if none is provided on startup (@ifedapoolarewaju)
+- server: We implemented a more standard logger for Uppy Server (@ifedapoolarewaju)
+- server: Added an example project to run Uppy Server on Serverless (@ifedapoolarewaju)
 
 
 ## 0.23.3
 ## 0.23.3
 
 

+ 56 - 10
README.md

@@ -35,7 +35,6 @@ const uppy = Uppy({ autoProceed: false })
   .use(Instagram, { target: Dashboard, host: 'https://server.uppy.io' })
   .use(Instagram, { target: Dashboard, host: 'https://server.uppy.io' })
   .use(Webcam, { target: Dashboard })
   .use(Webcam, { target: Dashboard })
   .use(Tus, { endpoint: 'https://master.tus.io/files/' })
   .use(Tus, { endpoint: 'https://master.tus.io/files/' })
-  .run()
   .on('complete', (result) => {
   .on('complete', (result) => {
     console.log('Upload result:', result)
     console.log('Upload result:', result)
   })
   })
@@ -64,7 +63,7 @@ $ npm install uppy --save
 
 
 We recommend installing from npm and then using a module bundler such as [Webpack](http://webpack.github.io/), [Browserify](http://browserify.org/) or [Rollup.js](http://rollupjs.org/).
 We recommend installing from npm and then using a module bundler such as [Webpack](http://webpack.github.io/), [Browserify](http://browserify.org/) or [Rollup.js](http://rollupjs.org/).
 
 
-Add CSS [uppy.min.css](https://transloadit.edgly.net/releases/uppy/v0.23.3/dist/uppy.min.css), either to `<head>` of your HTML page or include in JS, if your bundler of choice supports it — transforms and plugins are available for Browserify and Webpack.
+Add CSS [uppy.min.css](https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.css), either to `<head>` of your HTML page or include in JS, if your bundler of choice supports it — transforms and plugins are available for Browserify and Webpack.
 
 
 Alternatively, you can also use a pre-built bundle from Transloadit's CDN: Edgly. In that case `Uppy` will attach itself to the global `window.Uppy` object.
 Alternatively, you can also use a pre-built bundle from Transloadit's CDN: Edgly. In that case `Uppy` will attach itself to the global `window.Uppy` object.
 
 
@@ -73,12 +72,12 @@ Alternatively, you can also use a pre-built bundle from Transloadit's CDN: Edgly
 1\. Add a script to the bottom of `<body>`:
 1\. Add a script to the bottom of `<body>`:
 
 
 ``` html
 ``` html
-<script src="https://transloadit.edgly.net/releases/uppy/v0.23.3/dist/uppy.min.js"></script>
+<script src="https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.js"></script>
 ```
 ```
 
 
 2\. Add CSS to `<head>`:
 2\. Add CSS to `<head>`:
 ``` html
 ``` html
-<link href="https://transloadit.edgly.net/releases/uppy/v0.23.3/dist/uppy.min.css" rel="stylesheet">
+<link href="https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.css" rel="stylesheet">
 ```
 ```
 
 
 3\. Initialize:
 3\. Initialize:
@@ -89,7 +88,6 @@ Alternatively, you can also use a pre-built bundle from Transloadit's CDN: Edgly
   var uppy = Uppy.Core()
   var uppy = Uppy.Core()
   uppy.use(Uppy.DragDrop, { target: '.UppyDragDrop' })
   uppy.use(Uppy.DragDrop, { target: '.UppyDragDrop' })
   uppy.use(Uppy.Tus, { endpoint: '//master.tus.io/files/' })
   uppy.use(Uppy.Tus, { endpoint: '//master.tus.io/files/' })
-  uppy.run()
 </script>
 </script>
 ```
 ```
 
 
@@ -99,7 +97,7 @@ Alternatively, you can also use a pre-built bundle from Transloadit's CDN: Edgly
 - [Plugins](https://uppy.io/docs/plugins/) — list of Uppy plugins and their options.
 - [Plugins](https://uppy.io/docs/plugins/) — list of Uppy plugins and their options.
 - [Server](https://uppy.io/docs/server/) — setting up and running an Uppy Server instance, which adds support for Instagram, Dropbox, Google Drive and other remote sources.
 - [Server](https://uppy.io/docs/server/) — setting up and running an Uppy Server instance, which adds support for Instagram, Dropbox, Google Drive and other remote sources.
 - [React](https://uppy.io/docs/react/) — components to integrate Uppy UI plugins with React apps.
 - [React](https://uppy.io/docs/react/) — components to integrate Uppy UI plugins with React apps.
-- Architecture & Making a Plugin — how to write a plugin for Uppy [documentation in progress].
+- [Architecture & Making a Plugin](https://uppy.io/docs/writing-plugins/) — how to write a plugin for Uppy [documentation in progress].
 
 
 ## Plugins
 ## Plugins
 
 
@@ -135,8 +133,8 @@ We aim to support IE10+ and recent versions of Safari, Edge, Chrome, Firefox, an
 Having no JavaScript beats having a lot of it, so that’s a fair question! Running an uploading & encoding business for ten years though we found that in cases, the file input leaves some to be desired:
 Having no JavaScript beats having a lot of it, so that’s a fair question! Running an uploading & encoding business for ten years though we found that in cases, the file input leaves some to be desired:
 
 
 - We received complaints about broken uploads and found that resumable uploads are important, especially for big files and to be inclusive towards people on poorer connections (we also launched [tus.io](https://tus.io) to attack that problem). Uppy uploads can survive network outages and browser crashes or accidental navigate-aways.
 - We received complaints about broken uploads and found that resumable uploads are important, especially for big files and to be inclusive towards people on poorer connections (we also launched [tus.io](https://tus.io) to attack that problem). Uppy uploads can survive network outages and browser crashes or accidental navigate-aways.
-- Uppy supports editing meta information before uploading (and e.g. cropping is planned). 
-- There’s the situation where people are using their mobile devices and want to upload on the go, but they have their picture on Instagram, files in Dropbox, or just a plain file url from anywhere on the open web. Uppy allows to pick files from those and push it to the destination without downloading it to your mobile device first. 
+- Uppy supports editing meta information before uploading (and e.g. cropping is planned).
+- There’s the situation where people are using their mobile devices and want to upload on the go, but they have their picture on Instagram, files in Dropbox, or just a plain file url from anywhere on the open web. Uppy allows to pick files from those and push it to the destination without downloading it to your mobile device first.
 - Accurate upload progress reporting is an issue on many platforms.
 - Accurate upload progress reporting is an issue on many platforms.
 - Some file validation — size, type, number of files — can be done on the client with Uppy.
 - Some file validation — size, type, number of files — can be done on the client with Uppy.
 - Uppy integrates webcam support, in case your users want to upload a picture/video/audio that does not exist yet :)
 - Uppy integrates webcam support, in case your users want to upload a picture/video/audio that does not exist yet :)
@@ -148,7 +146,7 @@ Not all apps need all of these features. A `<input type="file">` is fine in many
 
 
 ### Why is all this goodness free?
 ### Why is all this goodness free?
 
 
-Transloadit’s team is small and we have a shared ambition to make a living from open source. By giving away projects like [tus.io](https://tus.io) and [Uppy](https://uppy.io),we’re hoping to advance the state of the art, make life a tiny little bit better for everyone, and in doing so have rewarding jobs and get some eyes on our commercial service: [a content ingestion & processing platform](https://transloadit.com). 
+Transloadit’s team is small and we have a shared ambition to make a living from open source. By giving away projects like [tus.io](https://tus.io) and [Uppy](https://uppy.io),we’re hoping to advance the state of the art, make life a tiny little bit better for everyone, and in doing so have rewarding jobs and get some eyes on our commercial service: [a content ingestion & processing platform](https://transloadit.com).
 
 
 Our thinking is that if just a fraction of our open source userbase can see the appeal of hosted versions straight from the source, that could already be enough to sustain our work. So far this is working out! We’re able to dedicate 80% of our time to open source and haven’t gone bankrupt just yet :D
 Our thinking is that if just a fraction of our open source userbase can see the appeal of hosted versions straight from the source, that could already be enough to sustain our work. So far this is working out! We’re able to dedicate 80% of our time to open source and haven’t gone bankrupt just yet :D
 
 
@@ -162,7 +160,7 @@ Yes, there is an S3 plugin, please check out the [docs](https://uppy.io/docs/aws
 
 
 ### Do I need to install special service/server for Uppy? Can I use it with Rails/Node/Go/PHP?
 ### Do I need to install special service/server for Uppy? Can I use it with Rails/Node/Go/PHP?
 
 
-Yes, whatever you want on the backend will work with `XHRUpload` plugin, since it just does a `POST` or `PUT` request. Here’s a [PHP backend example](https://uppy.io/docs/xhrupload/#Uploading-to-a-PHP-Server). 
+Yes, whatever you want on the backend will work with `XHRUpload` plugin, since it just does a `POST` or `PUT` request. Here’s a [PHP backend example](https://uppy.io/docs/xhrupload/#Uploading-to-a-PHP-Server).
 
 
 If you want resumability with the Tus plugin, use [one of the tus server implementations](https://tus.io/implementations.html) 👌🏼
 If you want resumability with the Tus plugin, use [one of the tus server implementations](https://tus.io/implementations.html) 👌🏼
 
 
@@ -173,6 +171,54 @@ And you’ll need [`uppy-server`](https://github.com/transloadit/uppy-server) if
  - Contributor’s guide in [`website/src/docs/contributing.md`](website/src/docs/contributing.md)
  - Contributor’s guide in [`website/src/docs/contributing.md`](website/src/docs/contributing.md)
  - Changelog to track our release progress (we aim to roll out a release every month): [`CHANGELOG.md`](CHANGELOG.md)
  - Changelog to track our release progress (we aim to roll out a release every month): [`CHANGELOG.md`](CHANGELOG.md)
 
 
+<!--contributors-->
+## Contributors
+
+[<img alt="arturi" src="https://avatars2.githubusercontent.com/u/1199054?v=4&s=117" width="117">](https://github.com/arturi) |[<img alt="goto-bus-stop" src="https://avatars1.githubusercontent.com/u/1006268?v=4&s=117" width="117">](https://github.com/goto-bus-stop) |[<img alt="kvz" src="https://avatars2.githubusercontent.com/u/26752?v=4&s=117" width="117">](https://github.com/kvz) |[<img alt="hedgerh" src="https://avatars2.githubusercontent.com/u/2524280?v=4&s=117" width="117">](https://github.com/hedgerh) |[<img alt="ifedapoolarewaju" src="https://avatars1.githubusercontent.com/u/8383781?v=4&s=117" width="117">](https://github.com/ifedapoolarewaju) |[<img alt="sadovnychyi" src="https://avatars3.githubusercontent.com/u/193864?v=4&s=117" width="117">](https://github.com/sadovnychyi) |
+:---: |:---: |:---: |:---: |:---: |:---: |
+[arturi](https://github.com/arturi) |[goto-bus-stop](https://github.com/goto-bus-stop) |[kvz](https://github.com/kvz) |[hedgerh](https://github.com/hedgerh) |[ifedapoolarewaju](https://github.com/ifedapoolarewaju) |[sadovnychyi](https://github.com/sadovnychyi) |
+
+[<img alt="richardwillars" src="https://avatars3.githubusercontent.com/u/291004?v=4&s=117" width="117">](https://github.com/richardwillars) |[<img alt="AJvanLoon" src="https://avatars0.githubusercontent.com/u/15716628?v=4&s=117" width="117">](https://github.com/AJvanLoon) |[<img alt="wilkoklak" src="https://avatars1.githubusercontent.com/u/17553085?v=4&s=117" width="117">](https://github.com/wilkoklak) |[<img alt="oliverpool" src="https://avatars0.githubusercontent.com/u/3864879?v=4&s=117" width="117">](https://github.com/oliverpool) |[<img alt="nqst" src="https://avatars0.githubusercontent.com/u/375537?v=4&s=117" width="117">](https://github.com/nqst) |[<img alt="janko-m" src="https://avatars2.githubusercontent.com/u/795488?v=4&s=117" width="117">](https://github.com/janko-m) |
+:---: |:---: |:---: |:---: |:---: |:---: |
+[richardwillars](https://github.com/richardwillars) |[AJvanLoon](https://github.com/AJvanLoon) |[wilkoklak](https://github.com/wilkoklak) |[oliverpool](https://github.com/oliverpool) |[nqst](https://github.com/nqst) |[janko-m](https://github.com/janko-m) |
+
+[<img alt="gavboulton" src="https://avatars0.githubusercontent.com/u/3900826?v=4&s=117" width="117">](https://github.com/gavboulton) |[<img alt="bertho-zero" src="https://avatars0.githubusercontent.com/u/8525267?v=4&s=117" width="117">](https://github.com/bertho-zero) |[<img alt="johnunclesam" src="https://avatars3.githubusercontent.com/u/21275217?v=4&s=117" width="117">](https://github.com/johnunclesam) |[<img alt="ogtfaber" src="https://avatars2.githubusercontent.com/u/320955?v=4&s=117" width="117">](https://github.com/ogtfaber) |[<img alt="sunil-shrestha" src="https://avatars3.githubusercontent.com/u/2129058?v=4&s=117" width="117">](https://github.com/sunil-shrestha) |[<img alt="tim-kos" src="https://avatars1.githubusercontent.com/u/15005?v=4&s=117" width="117">](https://github.com/tim-kos) |
+:---: |:---: |:---: |:---: |:---: |:---: |
+[gavboulton](https://github.com/gavboulton) |[bertho-zero](https://github.com/bertho-zero) |[johnunclesam](https://github.com/johnunclesam) |[ogtfaber](https://github.com/ogtfaber) |[sunil-shrestha](https://github.com/sunil-shrestha) |[tim-kos](https://github.com/tim-kos) |
+
+[<img alt="phitranphitranphitran" src="https://avatars2.githubusercontent.com/u/14257077?v=4&s=117" width="117">](https://github.com/phitranphitranphitran) |[<img alt="btrice" src="https://avatars2.githubusercontent.com/u/4358225?v=4&s=117" width="117">](https://github.com/btrice) |[<img alt="Martin005" src="https://avatars0.githubusercontent.com/u/10096404?v=4&s=117" width="117">](https://github.com/Martin005) |[<img alt="martiuslim" src="https://avatars2.githubusercontent.com/u/17944339?v=4&s=117" width="117">](https://github.com/martiuslim) |[<img alt="richmeij" src="https://avatars0.githubusercontent.com/u/9741858?v=4&s=117" width="117">](https://github.com/richmeij) |[<img alt="Burkes" src="https://avatars2.githubusercontent.com/u/9220052?v=4&s=117" width="117">](https://github.com/Burkes) |
+:---: |:---: |:---: |:---: |:---: |:---: |
+[phitranphitranphitran](https://github.com/phitranphitranphitran) |[btrice](https://github.com/btrice) |[Martin005](https://github.com/Martin005) |[martiuslim](https://github.com/martiuslim) |[richmeij](https://github.com/richmeij) |[Burkes](https://github.com/Burkes) |
+
+[<img alt="ThomasG77" src="https://avatars2.githubusercontent.com/u/642120?v=4&s=117" width="117">](https://github.com/ThomasG77) |[<img alt="zhuangya" src="https://avatars2.githubusercontent.com/u/499038?v=4&s=117" width="117">](https://github.com/zhuangya) |[<img alt="fortrieb" src="https://avatars0.githubusercontent.com/u/4126707?v=4&s=117" width="117">](https://github.com/fortrieb) |[<img alt="muhammadInam" src="https://avatars1.githubusercontent.com/u/7801708?v=4&s=117" width="117">](https://github.com/muhammadInam) |[<img alt="rosenfeld" src="https://avatars1.githubusercontent.com/u/32246?v=4&s=117" width="117">](https://github.com/rosenfeld) |[<img alt="ajschmidt8" src="https://avatars0.githubusercontent.com/u/7400326?v=4&s=117" width="117">](https://github.com/ajschmidt8) |
+:---: |:---: |:---: |:---: |:---: |:---: |
+[ThomasG77](https://github.com/ThomasG77) |[zhuangya](https://github.com/zhuangya) |[fortrieb](https://github.com/fortrieb) |[muhammadInam](https://github.com/muhammadInam) |[rosenfeld](https://github.com/rosenfeld) |[ajschmidt8](https://github.com/ajschmidt8) |
+
+[<img alt="rhymes" src="https://avatars3.githubusercontent.com/u/146201?v=4&s=117" width="117">](https://github.com/rhymes) |[<img alt="functino" src="https://avatars0.githubusercontent.com/u/415498?v=4&s=117" width="117">](https://github.com/functino) |[<img alt="radarhere" src="https://avatars2.githubusercontent.com/u/3112309?v=4&s=117" width="117">](https://github.com/radarhere) |[<img alt="azeemba" src="https://avatars0.githubusercontent.com/u/2160795?v=4&s=117" width="117">](https://github.com/azeemba) |[<img alt="bducharme" src="https://avatars2.githubusercontent.com/u/4173569?v=4&s=117" width="117">](https://github.com/bducharme) |[<img alt="chao" src="https://avatars2.githubusercontent.com/u/55872?v=4&s=117" width="117">](https://github.com/chao) |
+:---: |:---: |:---: |:---: |:---: |:---: |
+[rhymes](https://github.com/rhymes) |[functino](https://github.com/functino) |[radarhere](https://github.com/radarhere) |[azeemba](https://github.com/azeemba) |[bducharme](https://github.com/bducharme) |[chao](https://github.com/chao) |
+
+[<img alt="csprance" src="https://avatars0.githubusercontent.com/u/7902617?v=4&s=117" width="117">](https://github.com/csprance) |[<img alt="danmichaelo" src="https://avatars1.githubusercontent.com/u/434495?v=4&s=117" width="117">](https://github.com/danmichaelo) |[<img alt="mrboomer" src="https://avatars0.githubusercontent.com/u/5942912?v=4&s=117" width="117">](https://github.com/mrboomer) |[<img alt="lowsprofile" src="https://avatars1.githubusercontent.com/u/11029687?v=4&s=117" width="117">](https://github.com/lowsprofile) |[<img alt="gjungb" src="https://avatars0.githubusercontent.com/u/3391068?v=4&s=117" width="117">](https://github.com/gjungb) |[<img alt="Cloud887" src="https://avatars1.githubusercontent.com/u/27247160?v=4&s=117" width="117">](https://github.com/Cloud887) |
+:---: |:---: |:---: |:---: |:---: |:---: |
+[csprance](https://github.com/csprance) |[danmichaelo](https://github.com/danmichaelo) |[mrboomer](https://github.com/mrboomer) |[lowsprofile](https://github.com/lowsprofile) |[gjungb](https://github.com/gjungb) |[Cloud887](https://github.com/Cloud887) |
+
+[<img alt="jagoPG" src="https://avatars3.githubusercontent.com/u/16286114?v=4&s=117" width="117">](https://github.com/jagoPG) |[<img alt="jcjmcclean" src="https://avatars3.githubusercontent.com/u/1822574?v=4&s=117" width="117">](https://github.com/jcjmcclean) |[<img alt="jessica-coursera" src="https://avatars1.githubusercontent.com/u/35155465?v=4&s=117" width="117">](https://github.com/jessica-coursera) |[<img alt="lucaperret" src="https://avatars1.githubusercontent.com/u/1887122?v=4&s=117" width="117">](https://github.com/lucaperret) |[<img alt="mperrando" src="https://avatars2.githubusercontent.com/u/525572?v=4&s=117" width="117">](https://github.com/mperrando) |[<img alt="mnafees" src="https://avatars1.githubusercontent.com/u/1763885?v=4&s=117" width="117">](https://github.com/mnafees) |
+:---: |:---: |:---: |:---: |:---: |:---: |
+[jagoPG](https://github.com/jagoPG) |[jcjmcclean](https://github.com/jcjmcclean) |[jessica-coursera](https://github.com/jessica-coursera) |[lucaperret](https://github.com/lucaperret) |[mperrando](https://github.com/mperrando) |[mnafees](https://github.com/mnafees) |
+
+[<img alt="pauln" src="https://avatars3.githubusercontent.com/u/574359?v=4&s=117" width="117">](https://github.com/pauln) |[<img alt="phillipalexander" src="https://avatars0.githubusercontent.com/u/1577682?v=4&s=117" width="117">](https://github.com/phillipalexander) |[<img alt="luarmr" src="https://avatars3.githubusercontent.com/u/817416?v=4&s=117" width="117">](https://github.com/luarmr) |[<img alt="sergei-zelinsky" src="https://avatars2.githubusercontent.com/u/19428086?v=4&s=117" width="117">](https://github.com/sergei-zelinsky) |[<img alt="tomsaleeba" src="https://avatars0.githubusercontent.com/u/1773838?v=4&s=117" width="117">](https://github.com/tomsaleeba) |[<img alt="eltercero" src="https://avatars0.githubusercontent.com/u/545235?v=4&s=117" width="117">](https://github.com/eltercero) |
+:---: |:---: |:---: |:---: |:---: |:---: |
+[pauln](https://github.com/pauln) |[phillipalexander](https://github.com/phillipalexander) |[luarmr](https://github.com/luarmr) |[sergei-zelinsky](https://github.com/sergei-zelinsky) |[tomsaleeba](https://github.com/tomsaleeba) |[eltercero](https://github.com/eltercero) |
+
+[<img alt="xhocquet" src="https://avatars2.githubusercontent.com/u/8116516?v=4&s=117" width="117">](https://github.com/xhocquet) |[<img alt="avalla" src="https://avatars1.githubusercontent.com/u/986614?v=4&s=117" width="117">](https://github.com/avalla) |[<img alt="c0b41" src="https://avatars1.githubusercontent.com/u/2834954?v=4&s=117" width="117">](https://github.com/c0b41) |[<img alt="franckl" src="https://avatars0.githubusercontent.com/u/3875803?v=4&s=117" width="117">](https://github.com/franckl) |[<img alt="kiloreux" src="https://avatars0.githubusercontent.com/u/6282557?v=4&s=117" width="117">](https://github.com/kiloreux) |[<img alt="raineluntta" src="https://avatars0.githubusercontent.com/u/14221637?v=4&s=117" width="117">](https://github.com/raineluntta) |
+:---: |:---: |:---: |:---: |:---: |:---: |
+[xhocquet](https://github.com/xhocquet) |[avalla](https://github.com/avalla) |[c0b41](https://github.com/c0b41) |[franckl](https://github.com/franckl) |[kiloreux](https://github.com/kiloreux) |[raineluntta](https://github.com/raineluntta) |
+
+[<img alt="amitport" src="https://avatars1.githubusercontent.com/u/1131991?v=4&s=117" width="117">](https://github.com/amitport) |
+:---: |
+[amitport](https://github.com/amitport) |
+<!--/contributors-->
+
 ## License
 ## License
 
 
 [The MIT License](LICENSE).
 [The MIT License](LICENSE).

+ 2 - 1
bin/build-css.js

@@ -2,6 +2,7 @@ var sass = require('node-sass')
 var postcss = require('postcss')
 var postcss = require('postcss')
 var autoprefixer = require('autoprefixer')
 var autoprefixer = require('autoprefixer')
 var cssnano = require('cssnano')
 var cssnano = require('cssnano')
+var safeImportant = require('postcss-safe-important')
 var chalk = require('chalk')
 var chalk = require('chalk')
 var fs = require('fs')
 var fs = require('fs')
 var path = require('path')
 var path = require('path')
@@ -38,7 +39,7 @@ function compileCSS () {
   return new Promise(function (resolve) {
   return new Promise(function (resolve) {
     sass.render({file: './src/scss/uppy.scss'}, function (err, sassResult) {
     sass.render({file: './src/scss/uppy.scss'}, function (err, sassResult) {
       if (err) handleErr(err)
       if (err) handleErr(err)
-      postcss([ autoprefixer ])
+      postcss([ autoprefixer, safeImportant ])
         .process(sassResult.css, { from: path.join(__dirname, '../src/scss/uppy.scss') })
         .process(sassResult.css, { from: path.join(__dirname, '../src/scss/uppy.scss') })
         .then(function (postCSSResult) {
         .then(function (postCSSResult) {
           postCSSResult.warnings().forEach(function (warn) {
           postCSSResult.warnings().forEach(function (warn) {

+ 1 - 1
bin/upload-to-cdn.sh

@@ -8,7 +8,7 @@
 #  - Checks if a tag is being built (on Travis - otherwise opts to continue execution regardless)
 #  - Checks if a tag is being built (on Travis - otherwise opts to continue execution regardless)
 #  - Installs AWS CLI if needed
 #  - Installs AWS CLI if needed
 #  - Assumed a fully built uppy is in root dir (unless a specific tag was specified, then it's fetched from npm)
 #  - Assumed a fully built uppy is in root dir (unless a specific tag was specified, then it's fetched from npm)
-#  - Runs npm pack, and stores files to e.g. https://transloadit.edgly.net/releases/uppy/v0.23.3/dist/uppy.css
+#  - Runs npm pack, and stores files to e.g. https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.css
 #  - Uses local package by default, if [version] argument was specified, takes package from npm
 #  - Uses local package by default, if [version] argument was specified, takes package from npm
 #
 #
 # Run as:
 # Run as:

+ 1 - 0
env.example.sh

@@ -10,6 +10,7 @@ export UPPYSERVER_INSTAGRAM_KEY="***"
 export UPPYSERVER_INSTAGRAM_SECRET="***"
 export UPPYSERVER_INSTAGRAM_SECRET="***"
 export EDGLY_KEY="***"
 export EDGLY_KEY="***"
 export EDGLY_SECRET="***"
 export EDGLY_SECRET="***"
+export GITHUB_TOKEN="***"
 
 
 # Let's not set this by default, because that will make acceptance tests Always run on Saucelabs
 # Let's not set this by default, because that will make acceptance tests Always run on Saucelabs
 ## export SAUCE_ACCESS_KEY="***"
 ## export SAUCE_ACCESS_KEY="***"

+ 1 - 1
examples/aws-presigned-url/index.html

@@ -3,7 +3,7 @@
   <head>
   <head>
     <meta charset="utf-8">
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <meta name="viewport" content="width=device-width, initial-scale=1">
-    <title>Uppy AWS Example</title>
+    <title>Uppy AWS Presigned URL Example</title>
     <link href="uppy.min.css" rel="stylesheet">
     <link href="uppy.min.css" rel="stylesheet">
   </head>
   </head>
   <body>
   <body>

+ 0 - 2
examples/aws-presigned-url/main.js

@@ -38,5 +38,3 @@ uppy.use(AwsS3, {
     })
     })
   }
   }
 })
 })
-
-uppy.run()

+ 1 - 1
examples/aws-presigned-url/package.json

@@ -1,6 +1,6 @@
 {
 {
   "private": true,
   "private": true,
-  "name": "uppy-aws-php-example",
+  "name": "uppy-aws-presigned-url-example",
   "scripts": {
   "scripts": {
     "start": "node ./serve.js"
     "start": "node ./serve.js"
   },
   },

+ 1 - 1
examples/aws-uppy-server/index.html

@@ -3,7 +3,7 @@
   <head>
   <head>
     <meta charset="utf-8">
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <meta name="viewport" content="width=device-width, initial-scale=1">
-    <title>Uppy AWS Example</title>
+    <title>Uppy Server + AWS Example</title>
     <link href="uppy.min.css" rel="stylesheet">
     <link href="uppy.min.css" rel="stylesheet">
   </head>
   </head>
   <body>
   <body>

+ 0 - 2
examples/aws-uppy-server/main.js

@@ -21,5 +21,3 @@ uppy.use(Dashboard, {
 uppy.use(AwsS3, {
 uppy.use(AwsS3, {
   host: 'http://localhost:3020'
   host: 'http://localhost:3020'
 })
 })
-
-uppy.run()

+ 0 - 1
examples/bundled-example/main.js

@@ -34,7 +34,6 @@ const uppy = Uppy({
   .use(Tus, { endpoint: TUS_ENDPOINT })
   .use(Tus, { endpoint: TUS_ENDPOINT })
   .use(Form, { target: '#upload-form' })
   .use(Form, { target: '#upload-form' })
   // .use(GoldenRetriever, {serviceWorker: true})
   // .use(GoldenRetriever, {serviceWorker: true})
-  .run()
 
 
 uppy.on('complete', (result) => {
 uppy.on('complete', (result) => {
   if (result.failed.length === 0) {
   if (result.failed.length === 0) {

+ 2 - 3
examples/cdn-example/index.html

@@ -4,17 +4,16 @@
     <title></title>
     <title></title>
     <meta charset="UTF-8">
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <meta name="viewport" content="width=device-width, initial-scale=1">
-    <link href="https://transloadit.edgly.net/releases/uppy/v0.23.3/dist/uppy.min.css" rel="stylesheet">
+    <link href="https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.css" rel="stylesheet">
   </head>
   </head>
   <body>
   <body>
     <button id="uppyModalOpener">Open Modal</button>
     <button id="uppyModalOpener">Open Modal</button>
-    <script src="https://transloadit.edgly.net/releases/uppy/v0.23.3/dist/uppy.min.js"></script>
+    <script src="https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.js"></script>
     <script>
     <script>
       const uppy = Uppy.Core({debug: true, autoProceed: false})
       const uppy = Uppy.Core({debug: true, autoProceed: false})
         .use(Uppy.Dashboard, { trigger: '#uppyModalOpener' })
         .use(Uppy.Dashboard, { trigger: '#uppyModalOpener' })
         .use(Uppy.Webcam, {target: Uppy.Dashboard})
         .use(Uppy.Webcam, {target: Uppy.Dashboard})
         .use(Uppy.Tus, { endpoint: 'https://master.tus.io/files/' })
         .use(Uppy.Tus, { endpoint: 'https://master.tus.io/files/' })
-        .run()
 
 
       uppy.on('success', (fileCount) => {
       uppy.on('success', (fileCount) => {
         console.log(`${fileCount} files uploaded`)
         console.log(`${fileCount} files uploaded`)

+ 4 - 0
examples/custom-provider/.gitignore

@@ -0,0 +1,4 @@
+uppy.min.css
+node_modules
+output/*
+!output/.empty

+ 108 - 0
examples/custom-provider/client/MyCustomProvider.js

@@ -0,0 +1,108 @@
+const Plugin = require('uppy/lib/core/Plugin')
+const { Provider } = require('uppy/lib/server')
+const { ProviderView } = require('uppy/lib/views')
+const { h } = require('preact')
+
+module.exports = class MyCustomProvider extends Plugin {
+  constructor (uppy, opts) {
+    super(uppy, opts)
+    this.type = 'acquirer'
+    this.id = this.opts.id || 'MyCustomProvider'
+    this.title = 'MyCustomProvider'
+    this.icon = () => (
+      <img src="https://uppy.io/images/logos/uppy-dog-head-arrow.svg" width="23" />
+    )
+
+    // writing out the key explicitly for readability the key used to store
+    // the provider instance must be equal to this.id.
+    this[this.id] = new Provider(uppy, {
+      host: this.opts.host,
+      provider: 'mycustomprovider'
+    })
+
+    this.files = []
+    this.onAuth = this.onAuth.bind(this)
+    this.render = this.render.bind(this)
+
+    // merge default options with the ones set by user
+    this.opts = Object.assign({}, opts)
+  }
+
+  install () {
+    this.view = new ProviderView(this)
+    // Set default state
+    this.setPluginState({
+      authenticated: false,
+      files: [],
+      folders: [],
+      directories: [],
+      activeRow: -1,
+      filterInput: '',
+      isSearchVisible: false
+    })
+
+    const target = this.opts.target
+    if (target) {
+      this.mount(target, this)
+    }
+  }
+
+  uninstall () {
+    this.view.tearDown()
+    this.unmount()
+  }
+
+  onAuth (authenticated) {
+    this.setPluginState({ authenticated })
+    if (authenticated) {
+      this.view.getFolder()
+    }
+  }
+
+  isFolder (item) {
+    return false
+  }
+
+  getItemData (item) {
+    return item
+  }
+
+  getItemIcon (item) {
+    return () => (
+      <img src="https://uppy.io/images/logos/uppy-dog-head-arrow.svg" />
+    )
+  }
+
+  getItemSubList (item) {
+    return item.entries
+  }
+
+  getItemName (item) {
+    return item.name
+  }
+
+  getMimeType (item) {
+    // mime types aren't supported.
+    return null
+  }
+
+  getItemId (item) {
+    return item.name
+  }
+
+  getItemRequestPath (item) {
+    return encodeURIComponent(item.name)
+  }
+
+  getItemModifiedDate (item) {
+    return Date.now()
+  }
+
+  getItemThumbnailUrl (item) {
+    return 'https://uppy.io/images/logos/uppy-dog-head-arrow.svg'
+  }
+
+  render (state) {
+    return this.view.render(state)
+  }
+}

+ 7 - 0
examples/custom-provider/client/aliasify.js

@@ -0,0 +1,7 @@
+const path = require('path')
+
+module.exports = {
+  replacements: {
+    '^uppy/lib/(.*?)$': path.join(__dirname, '../../../src/$1')
+  }
+}

+ 26 - 0
examples/custom-provider/client/main.js

@@ -0,0 +1,26 @@
+const Uppy = require('uppy/lib/core')
+const GoogleDrive = require('uppy/lib/plugins/GoogleDrive')
+const Tus = require('uppy/lib/plugins/Tus')
+const MyCustomProvider = require('./MyCustomProvider')
+const Dashboard = require('uppy/lib/plugins/Dashboard')
+
+const uppy = Uppy({
+  debug: true,
+  autoProceed: false
+})
+
+uppy.use(GoogleDrive, {
+  host: 'http://localhost:3020'
+})
+
+uppy.use(MyCustomProvider, {
+  host: 'http://localhost:3020'
+})
+
+uppy.use(Dashboard, {
+  inline: true,
+  target: 'body',
+  plugins: ['GoogleDrive', 'MyCustomProvider']
+})
+
+uppy.use(Tus, {endpoint: 'https://master.tus.io/files/'})

+ 12 - 0
examples/custom-provider/index.html

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Uppy Custom provider Example</title>
+    <link href="uppy.min.css" rel="stylesheet">
+  </head>
+  <body>
+    <script src="bundle.js"></script>
+  </body>
+</html>

+ 0 - 0
examples/custom-provider/output/.empty


+ 21 - 0
examples/custom-provider/package.json

@@ -0,0 +1,21 @@
+{
+  "private": true,
+  "name": "custom-provider-example",
+  "scripts": {
+    "copy": "cp ../../dist/uppy.min.css .",
+    "start:client": "budo client/main.js:bundle.js -- -t babelify -g aliasify",
+    "start:server": "node server/index.js",
+    "start": "npm-run-all --serial copy --parallel start:*"
+  },
+  "aliasify": "client/aliasify.js",
+  "dependencies": {
+    "aliasify": "^2.1.0",
+    "babelify": "^7.3.0",
+    "body-parser": "^1.18.2",
+    "budo": "^10.0.4",
+    "express": "^4.16.2",
+    "express-session": "^1.15.6",
+    "npm-run-all": "^4.1.2",
+    "uppy-server": "^0.11.0"
+  }
+}

+ 13 - 0
examples/custom-provider/readme.md

@@ -0,0 +1,13 @@
+# Uppy + Server + Custom Provider  Example
+
+This example uses uppy-server with a dummy custom provider.
+This serves as an illustration on how integrating custom providers would work
+
+## Run it
+
+Move into this directory, then:
+
+```bash
+npm install
+npm start
+```

+ 42 - 0
examples/custom-provider/server/customprovider.js

@@ -0,0 +1,42 @@
+const fs = require('fs')
+const path = require('path')
+const DUMM_FILE = path.join(__dirname, 'fixtures/image.jpg')
+
+/**
+ * an example of a custom provider module. It implements uppy-server's Provider interface
+ */
+class MyCustomProvider {
+  constructor (options) {
+    this.authProvider = MyCustomProvider.authProvider
+  }
+
+  static get authProvider () {
+    return 'mycustomprovider'
+  }
+
+  list (options, done) {
+    const response = {
+      body: {
+        entries: [
+          { name: 'file1.jpg' },
+          { name: 'file2.jpg' },
+          {name: 'file3.jpg'}
+        ]
+      }
+    }
+    return done(null, response, response.body)
+  }
+
+  download ({ id, token }, onData) {
+    return fs.readFile(DUMM_FILE, (err, data) => {
+      if (err) console.error(err)
+      onData(data)
+    })
+  }
+
+  size ({ id, token }, done) {
+    return done(fs.statSync(DUMM_FILE).size)
+  }
+}
+
+module.exports = MyCustomProvider

二進制
examples/custom-provider/server/fixtures/image.jpg


+ 89 - 0
examples/custom-provider/server/index.js

@@ -0,0 +1,89 @@
+const express = require('express')
+const uppy = require('uppy-server')
+const bodyParser = require('body-parser')
+const session = require('express-session')
+
+const app = express()
+
+app.use(bodyParser.json())
+app.use(session({
+  secret: 'some-secret',
+  resave: true,
+  saveUninitialized: true
+}))
+
+app.use((req, res, next) => {
+  res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*')
+  res.setHeader(
+    'Access-Control-Allow-Methods',
+    'GET, POST, OPTIONS, PUT, PATCH, DELETE'
+  )
+  res.setHeader(
+    'Access-Control-Allow-Headers',
+    'Authorization, Origin, Content-Type, Accept'
+  )
+  res.setHeader('Access-Control-Allow-Credentials', 'true')
+  next()
+})
+
+// Routes
+app.get('/', (req, res) => {
+  res.setHeader('Content-Type', 'text/plain')
+  res.send('Welcome to my uppy server')
+})
+
+// initialize uppy
+const uppyOptions = {
+  providerOptions: {
+    google: {
+      key: 'your google key',
+      secret: 'your google secret'
+    }
+  },
+  customProviders: {
+    mycustomprovider: {
+      config: {
+        // your oauth handlers
+        authorize_url: 'http://localhost:3020/oauth/authorize',
+        access_url: 'http://localhost:3020/oauth/token',
+        oauth: 2,
+        key: '***',
+        secret: '**',
+        scope: ['read', 'write']
+      },
+      // you provider module
+      module: require('./customprovider')
+    }
+  },
+  server: {
+    host: 'localhost:3020',
+    protocol: 'http'
+  },
+  filePath: './output',
+  secret: 'some-secret',
+  debug: true
+}
+
+app.get('/oauth/authorize', (req, res) => {
+  // skips the default oauth process.
+  // ideally this endpoint should handle the actual oauth process
+  res.redirect(`http://localhost:3020/mycustomprovider/callback?state=${req.query.state}&access_token=randombytes`)
+})
+
+app.use(uppy.app(uppyOptions))
+
+// handle 404
+app.use((req, res, next) => {
+  return res.status(404).json({ message: 'Not Found' })
+})
+
+// handle server errors
+app.use((err, req, res, next) => {
+  console.error('\x1b[31m', err.stack, '\x1b[0m')
+  res.status(err.status || 500).json({ message: err.message, error: err })
+})
+
+uppy.socket(app.listen(3020), uppyOptions)
+
+console.log('Welcome to Uppy Server!')
+console.log(`Listening on http://0.0.0.0:${3020}`)

+ 0 - 2
examples/digitalocean-spaces/main.js

@@ -14,5 +14,3 @@ uppy.use(Dashboard, {
 
 
 // No client side changes needed!
 // No client side changes needed!
 uppy.use(AwsS3, { host: '/uppy-server' })
 uppy.use(AwsS3, { host: '/uppy-server' })
-
-uppy.run()

+ 1 - 1
examples/digitalocean-spaces/package.json

@@ -1,6 +1,6 @@
 {
 {
   "private": true,
   "private": true,
-  "name": "uppy-digitalocean-spaces",
+  "name": "uppy-digitalocean-spaces-example",
   "scripts": {
   "scripts": {
     "start": "node ./server.js"
     "start": "node ./server.js"
   },
   },

+ 2 - 4
examples/multiple-instances/main.js

@@ -11,10 +11,9 @@ const a = Uppy({
   .use(Dashboard, {
   .use(Dashboard, {
     target: '#a',
     target: '#a',
     inline: true,
     inline: true,
-    maxWidth: 400
+    width: 400
   })
   })
   .use(GoldenRetriever, { serviceWorker: false })
   .use(GoldenRetriever, { serviceWorker: false })
-  .run()
 
 
 const b = Uppy({
 const b = Uppy({
   id: 'b',
   id: 'b',
@@ -23,10 +22,9 @@ const b = Uppy({
   .use(Dashboard, {
   .use(Dashboard, {
     target: '#b',
     target: '#b',
     inline: true,
     inline: true,
-    maxWidth: 400
+    width: 400
   })
   })
   .use(GoldenRetriever, { serviceWorker: false })
   .use(GoldenRetriever, { serviceWorker: false })
-  .run()
 
 
 window.a = a
 window.a = a
 window.b = b
 window.b = b

+ 4 - 2
examples/react-example/App.js

@@ -21,11 +21,9 @@ module.exports = class App extends React.Component {
     this.uppy = new Uppy({ autoProceed: false })
     this.uppy = new Uppy({ autoProceed: false })
       .use(Tus, { endpoint: 'https://master.tus.io/files/' })
       .use(Tus, { endpoint: 'https://master.tus.io/files/' })
       .use(GoogleDrive, { host: 'https://server.uppy.io' })
       .use(GoogleDrive, { host: 'https://server.uppy.io' })
-      .run()
 
 
     this.uppy2 = new Uppy({ autoProceed: false })
     this.uppy2 = new Uppy({ autoProceed: false })
       .use(Tus, { endpoint: 'https://master.tus.io/files/' })
       .use(Tus, { endpoint: 'https://master.tus.io/files/' })
-      .run()
   }
   }
 
 
   componentWillUnmount () {
   componentWillUnmount () {
@@ -62,6 +60,9 @@ module.exports = class App extends React.Component {
           <Dashboard
           <Dashboard
             uppy={this.uppy}
             uppy={this.uppy}
             plugins={['GoogleDrive']}
             plugins={['GoogleDrive']}
+            metaFields={[
+              { id: 'name', name: 'Name', placeholder: 'File name' }
+            ]}
           />
           />
         )}
         )}
 
 
@@ -73,6 +74,7 @@ module.exports = class App extends React.Component {
           <DashboardModal
           <DashboardModal
             uppy={this.uppy2}
             uppy={this.uppy2}
             open={this.state.open}
             open={this.state.open}
+            target={document.body}
             onRequestClose={() => this.setState({ open: false })}
             onRequestClose={() => this.setState({ open: false })}
           />
           />
         </div>
         </div>

+ 1 - 2
examples/redux/main.js

@@ -76,9 +76,8 @@ const uppy = Uppy({
 uppy.use(Dashboard, {
 uppy.use(Dashboard, {
   target: '#app',
   target: '#app',
   inline: true,
   inline: true,
-  maxWidth: 400
+  width: 400
 })
 })
 uppy.use(Tus, { endpoint: 'https://master.tus.io/' })
 uppy.use(Tus, { endpoint: 'https://master.tus.io/' })
-uppy.run()
 
 
 window.uppy = uppy
 window.uppy = uppy

+ 2 - 3
examples/uppy-with-server/client/index.html

@@ -4,18 +4,17 @@
     <title></title>
     <title></title>
     <meta charset="UTF-8">
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <meta name="viewport" content="width=device-width, initial-scale=1">
-    <link href="https://transloadit.edgly.net/releases/uppy/v0.23.3/dist/uppy.min.css" rel="stylesheet">
+    <link href="https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.css" rel="stylesheet">
   </head>
   </head>
   <body>
   <body>
     <button id="uppyModalOpener">Open Modal</button>
     <button id="uppyModalOpener">Open Modal</button>
-    <script src="https://transloadit.edgly.net/releases/uppy/v0.23.3/dist/uppy.min.js"></script>
+    <script src="https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.js"></script>
     <script>
     <script>
       const uppy = Uppy.Core({debug: true, autoProceed: false})
       const uppy = Uppy.Core({debug: true, autoProceed: false})
         .use(Uppy.Dashboard, { trigger: '#uppyModalOpener' })
         .use(Uppy.Dashboard, { trigger: '#uppyModalOpener' })
         .use(Uppy.Instagram, { target: Uppy.Dashboard, host: 'http://localhost:3020' })
         .use(Uppy.Instagram, { target: Uppy.Dashboard, host: 'http://localhost:3020' })
         .use(Uppy.GoogleDrive, { target: Uppy.Dashboard, host: 'http://localhost:3020' })
         .use(Uppy.GoogleDrive, { target: Uppy.Dashboard, host: 'http://localhost:3020' })
         .use(Uppy.Tus, { endpoint: 'https://master.tus.io/files/' })
         .use(Uppy.Tus, { endpoint: 'https://master.tus.io/files/' })
-        .run()
 
 
       uppy.on('success', (fileCount) => {
       uppy.on('success', (fileCount) => {
         console.log(`${fileCount} files uploaded`)
         console.log(`${fileCount} files uploaded`)

+ 2 - 1
examples/uppy-with-server/package.json

@@ -1,4 +1,5 @@
 {
 {
+  "private": true,
   "name": "uppy-with-server-example",
   "name": "uppy-with-server-example",
   "version": "1.0.0",
   "version": "1.0.0",
   "description": "",
   "description": "",
@@ -18,6 +19,6 @@
     "light-server": "^2.4.0",
     "light-server": "^2.4.0",
     "upload-server": "^1.1.6",
     "upload-server": "^1.1.6",
     "uppy": "^0.22.2",
     "uppy": "^0.22.2",
-    "uppy-server": "^0.10.0"
+    "uppy-server": "^0.12.1"
   }
   }
 }
 }

+ 3 - 3
examples/xhr-bundle/main.js

@@ -9,7 +9,9 @@ const uppy = Uppy({
 
 
 uppy.use(Dashboard, {
 uppy.use(Dashboard, {
   target: '#app',
   target: '#app',
-  inline: true
+  inline: true,
+  hideRetryButton: true,
+  hideCancelButton: true
 })
 })
 
 
 uppy.use(XHRUpload, {
 uppy.use(XHRUpload, {
@@ -17,5 +19,3 @@ uppy.use(XHRUpload, {
   endpoint: 'http://localhost:9967/upload',
   endpoint: 'http://localhost:9967/upload',
   fieldName: 'files'
   fieldName: 'files'
 })
 })
-
-uppy.run()

+ 1 - 1
examples/xhr-bundle/package.json

@@ -1,6 +1,6 @@
 {
 {
   "private": true,
   "private": true,
-  "name": "uppy-multiple-instances-example",
+  "name": "uppy-xhr-bundle-example",
   "scripts": {
   "scripts": {
     "css": "cp ../../dist/uppy.min.css .",
     "css": "cp ../../dist/uppy.min.css .",
     "start:client": "npm run css && budo main.js:bundle.js -- -t babelify -g aliasify",
     "start:client": "npm run css && budo main.js:bundle.js -- -t babelify -g aliasify",

File diff suppressed because it is too large
+ 239 - 239
package-lock.json


+ 11 - 4
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "uppy",
   "name": "uppy",
-  "version": "0.23.3",
+  "version": "0.24.4",
   "description": "Extensible JavaScript file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Instagram, Dropbox, Google Drive, S3 and more :dog:",
   "description": "Extensible JavaScript file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Instagram, Dropbox, Google Drive, S3 and more :dog:",
   "main": "lib/index.js",
   "main": "lib/index.js",
   "jsnext:main": "src/index.js",
   "jsnext:main": "src/index.js",
@@ -73,6 +73,7 @@
     "eslint-plugin-standard": "^3.0.1",
     "eslint-plugin-standard": "^3.0.1",
     "exorcist": "^1.0.0",
     "exorcist": "^1.0.0",
     "fakefile": "0.0.9",
     "fakefile": "0.0.9",
+    "github-contributors-list": "1.2.3",
     "glob": "^7.1.2",
     "glob": "^7.1.2",
     "isomorphic-fetch": "2.2.1",
     "isomorphic-fetch": "2.2.1",
     "jest": "^22.0.6",
     "jest": "^22.0.6",
@@ -82,16 +83,18 @@
     "mkdirp": "0.5.1",
     "mkdirp": "0.5.1",
     "multi-glob": "1.0.1",
     "multi-glob": "1.0.1",
     "next-update": "^3.6.0",
     "next-update": "^3.6.0",
-    "node-sass": "^4.7.2",
+    "node-sass": "^4.9.0",
     "npm-run-all": "^4.1.2",
     "npm-run-all": "^4.1.2",
     "onchange": "^3.3.0",
     "onchange": "^3.3.0",
     "postcss": "^6.0.16",
     "postcss": "^6.0.16",
+    "postcss-safe-important": "^1.1.0",
     "pre-commit": "^1.2.2",
     "pre-commit": "^1.2.2",
     "redux": "^3.7.2",
     "redux": "^3.7.2",
     "replace-x": "^1.5.0",
     "replace-x": "^1.5.0",
     "sass": "0.5.0",
     "sass": "0.5.0",
     "temp-write": "^3.4.0",
     "temp-write": "^3.4.0",
     "tinyify": "^2.4.0",
     "tinyify": "^2.4.0",
+    "typescript": "^2.8.1",
     "uppy-server": "^0.11.2",
     "uppy-server": "^0.11.2",
     "watchify": "^3.9.0",
     "watchify": "^3.9.0",
     "wdio-mocha-framework": "^0.5.12",
     "wdio-mocha-framework": "^0.5.12",
@@ -133,12 +136,14 @@
     "release:minor": "env SEMANTIC=minor npm run release",
     "release:minor": "env SEMANTIC=minor npm run release",
     "release:patch": "env SEMANTIC=patch npm run release",
     "release:patch": "env SEMANTIC=patch npm run release",
     "replace:versions": "replace-x -r 'uppy/v\\d+\\.\\d+\\.\\d+/dist' \"uppy/v$npm_package_version/dist\" ./examples/ README.md bin/upload-to-cdn.sh website/src/examples/ website/src/docs/ website/themes/uppy/layout/ --exclude=node_modules",
     "replace:versions": "replace-x -r 'uppy/v\\d+\\.\\d+\\.\\d+/dist' \"uppy/v$npm_package_version/dist\" ./examples/ README.md bin/upload-to-cdn.sh website/src/examples/ website/src/docs/ website/themes/uppy/layout/ --exclude=node_modules",
-    "release": "npm version ${SEMANTIC:-patch} -m \"Release %s\" && npm run replace:versions && git commit -m \"Change Uppy version references to v$npm_package_version\" ./examples/ README.md bin/upload-to-cdn.sh website/src/examples/ website/src/docs/ website/themes/uppy/layout/ && git push && git push --tags && npm publish",
+    "replace:versions:commit": "git commit -m \"Change Uppy version references to v$npm_package_version\" ./examples/ README.md bin/upload-to-cdn.sh website/src/examples/ website/src/docs/ website/themes/uppy/layout/",
+    "release": "npm version ${SEMANTIC:-patch} -m \"Release %s\" && npm run replace:versions && npm run replace:versions:commit && git push && git push --tags && npm publish",
     "start:server": "node bin/start-server",
     "start:server": "node bin/start-server",
     "start": "npm-run-all --parallel watch start:server web:preview",
     "start": "npm-run-all --parallel watch start:server web:preview",
     "test:acceptance": "./bin/endtoend-build && wdio test/endtoend/wdio.remote.conf.js",
     "test:acceptance": "./bin/endtoend-build && wdio test/endtoend/wdio.remote.conf.js",
     "test:acceptance:local": "./bin/endtoend-build && wdio test/endtoend/wdio.local.conf.js",
     "test:acceptance:local": "./bin/endtoend-build && wdio test/endtoend/wdio.local.conf.js",
     "test:unit": "jest --testPathPattern=./src --coverage",
     "test:unit": "jest --testPathPattern=./src --coverage",
+    "test:type": "tsc -p .",
     "test": "npm run lint && npm run test:unit",
     "test": "npm run lint && npm run test:unit",
     "test:watch": "jest --watch --testPathPattern=src",
     "test:watch": "jest --watch --testPathPattern=src",
     "travis:deletecache": "travis cache --delete",
     "travis:deletecache": "travis cache --delete",
@@ -163,6 +168,8 @@
     "web:update:frontpage:code:sample": "cd website && ./node_modules/.bin/hexo generate && cp -f public/frontpage-code-sample.html ./themes/uppy/layout/partials/frontpage-code-sample.html",
     "web:update:frontpage:code:sample": "cd website && ./node_modules/.bin/hexo generate && cp -f public/frontpage-code-sample.html ./themes/uppy/layout/partials/frontpage-code-sample.html",
     "web": "npm-run-all web:clean web:build",
     "web": "npm-run-all web:clean web:build",
     "uploadcdn": "bin/upload-to-cdn.sh",
     "uploadcdn": "bin/upload-to-cdn.sh",
-    "prepublishOnly": "npm-run-all clean build"
+    "prepublishOnly": "npm-run-all clean build",
+    "contributors": "githubcontrib --owner transloadit --repo uppy --cols 6 $([ \"${GITHUB_TOKEN:-}\" == \"\" ] && echo \"\" || echo \"--authToken ${GITHUB_TOKEN}\") --showlogin true --sortOrder desc",
+    "contributors:save": "replace-x -m '<!--contributors-->[\\s\\S]+<!--/contributors-->' \"<!--contributors-->\n## Contributors\n\n$(npm run --silent contributors)\n<!--/contributors-->\" README.md"
   }
   }
 }
 }

+ 168 - 123
src/core/Core.js

@@ -11,10 +11,12 @@ const DefaultStore = require('../store/DefaultStore')
  * Uppy Core module.
  * Uppy Core module.
  * Manages plugins, state updates, acts as an event bus,
  * Manages plugins, state updates, acts as an event bus,
  * adds/removes files and metadata.
  * adds/removes files and metadata.
- *
- * @param {object} opts — Uppy options
  */
  */
 class Uppy {
 class Uppy {
+  /**
+  * Instantiate Uppy
+  * @param {object} opts — Uppy options
+  */
   constructor (opts) {
   constructor (opts) {
     const defaultLocale = {
     const defaultLocale = {
       strings: {
       strings: {
@@ -29,9 +31,10 @@ class Uppy {
         exceedsSize: 'This file exceeds maximum allowed size of',
         exceedsSize: 'This file exceeds maximum allowed size of',
         youCanOnlyUploadFileTypes: 'You can only upload:',
         youCanOnlyUploadFileTypes: 'You can only upload:',
         uppyServerError: 'Connection with Uppy Server failed',
         uppyServerError: 'Connection with Uppy Server failed',
-        failedToUpload: 'Failed to upload',
+        failedToUpload: 'Failed to upload %{file}',
         noInternetConnection: 'No Internet connection',
         noInternetConnection: 'No Internet connection',
-        connectedToInternet: 'Connected to the Intenet!'
+        connectedToInternet: 'Connected to the Internet',
+        noFilesFound: 'You have no files or folders here'
       }
       }
     }
     }
 
 
@@ -41,16 +44,16 @@ class Uppy {
       autoProceed: true,
       autoProceed: true,
       debug: false,
       debug: false,
       restrictions: {
       restrictions: {
-        maxFileSize: false,
-        maxNumberOfFiles: false,
-        minNumberOfFiles: false,
-        allowedFileTypes: false
+        maxFileSize: null,
+        maxNumberOfFiles: null,
+        minNumberOfFiles: null,
+        allowedFileTypes: null
       },
       },
       meta: {},
       meta: {},
-      onBeforeFileAdded: (currentFile, files) => Promise.resolve(),
-      onBeforeUpload: (files) => Promise.resolve(),
+      onBeforeFileAdded: (currentFile, files) => currentFile,
+      onBeforeUpload: (files) => files,
       locale: defaultLocale,
       locale: defaultLocale,
-      store: new DefaultStore()
+      store: DefaultStore()
     }
     }
 
 
     // Merge default options with the ones set by user
     // Merge default options with the ones set by user
@@ -122,10 +125,12 @@ class Uppy {
 
 
     // for debugging and testing
     // for debugging and testing
     // this.updateNum = 0
     // this.updateNum = 0
-    if (this.opts.debug) {
-      global.uppyLog = ''
-      global[this.opts.id] = this
+    if (this.opts.debug && typeof window !== 'undefined') {
+      window['uppyLog'] = ''
+      window[this.opts.id] = this
     }
     }
+
+    this._addListeners()
   }
   }
 
 
   on (event, callback) {
   on (event, callback) {
@@ -150,9 +155,9 @@ class Uppy {
   }
   }
 
 
   /**
   /**
-   * Updates state
+   * Updates state with a patch
    *
    *
-   * @param {patch} object
+   * @param {object} patch {foo: 'bar'}
    */
    */
   setState (patch) {
   setState (patch) {
     this.store.setState(patch)
     this.store.setState(patch)
@@ -160,6 +165,7 @@ class Uppy {
 
 
   /**
   /**
    * Returns current state.
    * Returns current state.
+   * @return {object}
    */
    */
   getState () {
   getState () {
     return this.store.getState()
     return this.store.getState()
@@ -281,15 +287,22 @@ class Uppy {
     return this.getState().files[fileID]
     return this.getState().files[fileID]
   }
   }
 
 
+  /**
+   * Get all files in an array.
+   */
+  getFiles () {
+    const { files } = this.getState()
+    return Object.keys(files).map((fileID) => files[fileID])
+  }
+
   /**
   /**
   * Check if minNumberOfFiles restriction is reached before uploading.
   * Check if minNumberOfFiles restriction is reached before uploading.
   *
   *
-  * @return {boolean}
   * @private
   * @private
   */
   */
-  _checkMinNumberOfFiles () {
+  _checkMinNumberOfFiles (files) {
     const {minNumberOfFiles} = this.opts.restrictions
     const {minNumberOfFiles} = this.opts.restrictions
-    if (Object.keys(this.getState().files).length < minNumberOfFiles) {
+    if (Object.keys(files).length < minNumberOfFiles) {
       throw new Error(`${this.i18n('youHaveToAtLeastSelectX', { smart_count: minNumberOfFiles })}`)
       throw new Error(`${this.i18n('youHaveToAtLeastSelectX', { smart_count: minNumberOfFiles })}`)
     }
     }
   }
   }
@@ -312,8 +325,20 @@ class Uppy {
 
 
     if (allowedFileTypes) {
     if (allowedFileTypes) {
       const isCorrectFileType = allowedFileTypes.filter((type) => {
       const isCorrectFileType = allowedFileTypes.filter((type) => {
-        if (!file.type) return false
-        return match(file.type, type)
+        // if (!file.type) return false
+
+        // is this is a mime-type
+        if (type.indexOf('/') > -1) {
+          if (!file.type) return false
+          return match(file.type, type)
+        }
+
+        // otherwise this is likely an extension
+        if (type[0] === '.') {
+          if (file.extension === type.substr(1)) {
+            return file.extension
+          }
+        }
       }).length > 0
       }).length > 0
 
 
       if (!isCorrectFileType) {
       if (!isCorrectFileType) {
@@ -337,73 +362,92 @@ class Uppy {
   * @param {object} file object to add
   * @param {object} file object to add
   */
   */
   addFile (file) {
   addFile (file) {
-    return Promise.resolve()
-      // Wrap this in a Promise `.then()` handler so errors will reject the Promise
-      // instead of throwing.
-      .then(() => this.opts.onBeforeFileAdded(file, this.getState().files))
-      .then(() => Utils.getFileType(file))
-      .then((fileType) => {
-        const updatedFiles = Object.assign({}, this.getState().files)
-        let fileName
-        if (file.name) {
-          fileName = file.name
-        } else if (fileType.split('/')[0] === 'image') {
-          fileName = fileType.split('/')[0] + '.' + fileType.split('/')[1]
-        } else {
-          fileName = 'noname'
-        }
-        const fileExtension = Utils.getFileNameAndExtension(fileName).extension
-        const isRemote = file.isRemote || false
-
-        const fileID = Utils.generateFileID(file)
-
-        const newFile = {
-          source: file.source || '',
-          id: fileID,
-          name: fileName,
-          extension: fileExtension || '',
-          meta: Object.assign({}, this.getState().meta, {
-            name: fileName,
-            type: fileType
-          }),
-          type: fileType,
-          data: file.data,
-          progress: {
-            percentage: 0,
-            bytesUploaded: 0,
-            bytesTotal: file.data.size || 0,
-            uploadComplete: false,
-            uploadStarted: false
-          },
-          size: file.data.size || 0,
-          isRemote: isRemote,
-          remote: file.remote || '',
-          preview: file.preview
-        }
+    const { files } = this.getState()
 
 
-        this._checkRestrictions(newFile)
+    const onError = (msg) => {
+      const err = typeof msg === 'object' ? msg : new Error(msg)
+      this.log(err.message)
+      this.info(err.message, 'error', 5000)
+      throw err
+    }
 
 
-        updatedFiles[fileID] = newFile
-        this.setState({files: updatedFiles})
+    const onBeforeFileAddedResult = this.opts.onBeforeFileAdded(file, files)
 
 
-        this.emit('file-added', newFile)
-        this.log(`Added file: ${fileName}, ${fileID}, mime type: ${fileType}`)
+    if (onBeforeFileAddedResult === false) {
+      this.log('Not adding file because onBeforeFileAdded returned false')
+      return
+    }
 
 
-        if (this.opts.autoProceed && !this.scheduledAutoProceed) {
-          this.scheduledAutoProceed = setTimeout(() => {
-            this.scheduledAutoProceed = null
-            this.upload().catch((err) => {
-              console.error(err.stack || err.message || err)
-            })
-          }, 4)
-        }
-      })
-      .catch((err) => {
-        const message = typeof err === 'object' ? err.message : err
-        this.log(message)
-        this.info(message, 'error', 5000)
-        return Promise.reject(typeof err === 'object' ? err : new Error(err))
+    if (typeof onBeforeFileAddedResult === 'object' && onBeforeFileAddedResult) {
+      // warning after the change in 0.24
+      if (onBeforeFileAddedResult.then) {
+        throw new TypeError('onBeforeFileAdded() returned a Promise, but this is no longer supported. It must be synchronous.')
+      }
+      file = onBeforeFileAddedResult
+    }
+
+    const fileType = Utils.getFileType(file)
+    let fileName
+    if (file.name) {
+      fileName = file.name
+    } else if (fileType.split('/')[0] === 'image') {
+      fileName = fileType.split('/')[0] + '.' + fileType.split('/')[1]
+    } else {
+      fileName = 'noname'
+    }
+    const fileExtension = Utils.getFileNameAndExtension(fileName).extension
+    const isRemote = file.isRemote || false
+
+    const fileID = Utils.generateFileID(file)
+
+    const meta = file.meta || {}
+    meta.name = fileName
+    meta.type = fileType
+
+    const newFile = {
+      source: file.source || '',
+      id: fileID,
+      name: fileName,
+      extension: fileExtension || '',
+      meta: Object.assign({}, this.getState().meta, meta),
+      type: fileType,
+      data: file.data,
+      progress: {
+        percentage: 0,
+        bytesUploaded: 0,
+        bytesTotal: file.data.size || 0,
+        uploadComplete: false,
+        uploadStarted: false
+      },
+      size: file.data.size || 0,
+      isRemote: isRemote,
+      remote: file.remote || '',
+      preview: file.preview
+    }
+
+    try {
+      this._checkRestrictions(newFile)
+    } catch (err) {
+      onError(err)
+    }
+
+    this.setState({
+      files: Object.assign({}, files, {
+        [fileID]: newFile
       })
       })
+    })
+
+    this.emit('file-added', newFile)
+    this.log(`Added file: ${fileName}, ${fileID}, mime type: ${fileType}`)
+
+    if (this.opts.autoProceed && !this.scheduledAutoProceed) {
+      this.scheduledAutoProceed = setTimeout(() => {
+        this.scheduledAutoProceed = null
+        this.upload().catch((err) => {
+          console.error(err.stack || err.message || err)
+        })
+      }, 4)
+    }
   }
   }
 
 
   removeFile (fileID) {
   removeFile (fileID) {
@@ -603,20 +647,8 @@ class Uppy {
   /**
   /**
    * Registers listeners for all global actions, like:
    * Registers listeners for all global actions, like:
    * `error`, `file-removed`, `upload-progress`
    * `error`, `file-removed`, `upload-progress`
-   *
    */
    */
-  actions () {
-    // const log = this.log
-    // this.on('*', function (payload) {
-    //   log(`[Core] Event: ${this.event}`)
-    //   log(payload)
-    // })
-
-    // stress-test re-rendering
-    // setInterval(() => {
-    //   this.setState({bla: 'bla'})
-    // }, 20)
-
+  _addListeners () {
     this.on('error', (error) => {
     this.on('error', (error) => {
       this.setState({ error: error.message })
       this.setState({ error: error.message })
     })
     })
@@ -625,7 +657,7 @@ class Uppy {
       this.setFileState(file.id, { error: error.message })
       this.setFileState(file.id, { error: error.message })
       this.setState({ error: error.message })
       this.setState({ error: error.message })
 
 
-      let message = `${this.i18n('failedToUpload')} ${file.name}`
+      let message = this.i18n('failedToUpload', { file: file.name })
       if (typeof error === 'object' && error.message) {
       if (typeof error === 'object' && error.message) {
         message = { message: message, details: error.message }
         message = { message: message, details: error.message }
       }
       }
@@ -767,8 +799,8 @@ class Uppy {
   /**
   /**
    * Registers a plugin with Core.
    * Registers a plugin with Core.
    *
    *
-   * @param {Class} Plugin object
-   * @param {Object} options object that will be passed to Plugin later
+   * @param {object} Plugin object
+   * @param {object} [opts] object with options to be passed to Plugin
    * @return {Object} self for chaining
    * @return {Object} self for chaining
    */
    */
   use (Plugin, opts) {
   use (Plugin, opts) {
@@ -793,12 +825,9 @@ class Uppy {
 
 
     let existsPluginAlready = this.getPlugin(pluginId)
     let existsPluginAlready = this.getPlugin(pluginId)
     if (existsPluginAlready) {
     if (existsPluginAlready) {
-      let msg = `Already found a plugin named '${existsPluginAlready.id}'.
-        Tried to use: '${pluginId}'.
-        Uppy is currently limited to running one of every plugin.
-        Share your use case with us over at
-        https://github.com/transloadit/uppy/issues/
-        if you want us to reconsider.`
+      let msg = `Already found a plugin named '${existsPluginAlready.id}'. ` +
+        `Tried to use: '${pluginId}'.\n` +
+        `Uppy plugins must have unique 'id' options. See https://uppy.io/docs/plugins/#id.`
       throw new Error(msg)
       throw new Error(msg)
     }
     }
 
 
@@ -811,10 +840,11 @@ class Uppy {
   /**
   /**
    * Find one Plugin by name.
    * Find one Plugin by name.
    *
    *
-   * @param string name description
+   * @param {string} name description
+   * @return {object | boolean}
    */
    */
   getPlugin (name) {
   getPlugin (name) {
-    let foundPlugin = false
+    let foundPlugin = null
     this.iteratePlugins((plugin) => {
     this.iteratePlugins((plugin) => {
       const pluginName = plugin.id
       const pluginName = plugin.id
       if (pluginName === name) {
       if (pluginName === name) {
@@ -828,7 +858,7 @@ class Uppy {
   /**
   /**
    * Iterate through all `use`d plugins.
    * Iterate through all `use`d plugins.
    *
    *
-   * @param function method description
+   * @param {function} method that will be run on each plugin
    */
    */
   iteratePlugins (method) {
   iteratePlugins (method) {
     Object.keys(this.plugins).forEach(pluginType => {
     Object.keys(this.plugins).forEach(pluginType => {
@@ -839,7 +869,7 @@ class Uppy {
   /**
   /**
    * Uninstall and remove a plugin.
    * Uninstall and remove a plugin.
    *
    *
-   * @param {Plugin} instance The plugin instance to remove.
+   * @param {object} instance The plugin instance to remove.
    */
    */
   removePlugin (instance) {
   removePlugin (instance) {
     const list = this.plugins[instance.type]
     const list = this.plugins[instance.type]
@@ -871,7 +901,9 @@ class Uppy {
   * Set info message in `state.info`, so that UI plugins like `Informer`
   * Set info message in `state.info`, so that UI plugins like `Informer`
   * can display the message.
   * can display the message.
   *
   *
-  * @param {string} msg Message to be displayed by the informer
+  * @param {string | object} message Message to be displayed by the informer
+  * @param {string} [type]
+  * @param {number} [duration]
   */
   */
 
 
   info (message, type = 'info', duration = 3000) {
   info (message, type = 'info', duration = 3000) {
@@ -888,7 +920,7 @@ class Uppy {
 
 
     this.emit('info-visible')
     this.emit('info-visible')
 
 
-    window.clearTimeout(this.infoTimeoutID)
+    clearTimeout(this.infoTimeoutID)
     if (duration === 0) {
     if (duration === 0) {
       this.infoTimeoutID = undefined
       this.infoTimeoutID = undefined
       return
       return
@@ -912,7 +944,7 @@ class Uppy {
    * Logs stuff to console, only if `debug` is set to true. Silent in production.
    * Logs stuff to console, only if `debug` is set to true. Silent in production.
    *
    *
    * @param {String|Object} msg to log
    * @param {String|Object} msg to log
-   * @param {String} type optional `error` or `warning`
+   * @param {String} [type] optional `error` or `warning`
    */
    */
   log (msg, type) {
   log (msg, type) {
     if (!this.opts.debug) {
     if (!this.opts.debug) {
@@ -921,7 +953,7 @@ class Uppy {
 
 
     let message = `[Uppy] [${Utils.getTimeStamp()}] ${msg}`
     let message = `[Uppy] [${Utils.getTimeStamp()}] ${msg}`
 
 
-    global.uppyLog = global.uppyLog + '\n' + 'DEBUG LOG: ' + msg
+    window['uppyLog'] = window['uppyLog'] + '\n' + 'DEBUG LOG: ' + msg
 
 
     if (type === 'error') {
     if (type === 'error') {
       console.error(message)
       console.error(message)
@@ -943,13 +975,10 @@ class Uppy {
   }
   }
 
 
   /**
   /**
-   * Initializes actions.
-   *
+   * Obsolete, event listeners are now added in the constructor.
    */
    */
   run () {
   run () {
-    this.log('Core is run, initializing actions...')
-    this.actions()
-
+    this.log('Calling run() is no longer necessary.', 'warning')
     return this
     return this
   }
   }
 
 
@@ -1077,7 +1106,7 @@ class Uppy {
     // Not returning the `catch`ed promise, because we still want to return a rejected
     // Not returning the `catch`ed promise, because we still want to return a rejected
     // promise from this method if the upload failed.
     // promise from this method if the upload failed.
     lastStep.catch((err) => {
     lastStep.catch((err) => {
-      this.emit('error', err)
+      this.emit('error', err, uploadID)
 
 
       this._removeUpload(uploadID)
       this._removeUpload(uploadID)
     })
     })
@@ -1103,7 +1132,7 @@ class Uppy {
     })
     })
   }
   }
 
 
-    /**
+  /**
    * Start an upload for all the files that are not currently being uploaded.
    * Start an upload for all the files that are not currently being uploaded.
    *
    *
    * @return {Promise}
    * @return {Promise}
@@ -1113,16 +1142,31 @@ class Uppy {
       this.log('No uploader type plugins are used', 'warning')
       this.log('No uploader type plugins are used', 'warning')
     }
     }
 
 
+    let files = this.getState().files
+    const onBeforeUploadResult = this.opts.onBeforeUpload(files)
+
+    if (onBeforeUploadResult === false) {
+      return Promise.reject(new Error('Not starting the upload because onBeforeUpload returned false'))
+    }
+
+    if (onBeforeUploadResult && typeof onBeforeUploadResult === 'object') {
+      // warning after the change in 0.24
+      if (onBeforeUploadResult.then) {
+        throw new TypeError('onBeforeUpload() returned a Promise, but this is no longer supported. It must be synchronous.')
+      }
+
+      files = onBeforeUploadResult
+    }
+
     return Promise.resolve()
     return Promise.resolve()
-      .then(() => this.opts.onBeforeUpload(this.getState().files))
-      .then(() => this._checkMinNumberOfFiles())
+      .then(() => this._checkMinNumberOfFiles(files))
       .then(() => {
       .then(() => {
         const { currentUploads } = this.getState()
         const { currentUploads } = this.getState()
         // get a list of files that are currently assigned to uploads
         // get a list of files that are currently assigned to uploads
         const currentlyUploadingFiles = Object.keys(currentUploads).reduce((prev, curr) => prev.concat(currentUploads[curr].fileIDs), [])
         const currentlyUploadingFiles = Object.keys(currentUploads).reduce((prev, curr) => prev.concat(currentUploads[curr].fileIDs), [])
 
 
         const waitingFileIDs = []
         const waitingFileIDs = []
-        Object.keys(this.getState().files).forEach((fileID) => {
+        Object.keys(files).forEach((fileID) => {
           const file = this.getFile(fileID)
           const file = this.getFile(fileID)
           // if the file hasn't started uploading and hasn't already been assigned to an upload..
           // if the file hasn't started uploading and hasn't already been assigned to an upload..
           if ((!file.progress.uploadStarted) && (currentlyUploadingFiles.indexOf(fileID) === -1)) {
           if ((!file.progress.uploadStarted) && (currentlyUploadingFiles.indexOf(fileID) === -1)) {
@@ -1145,5 +1189,6 @@ class Uppy {
 module.exports = function (opts) {
 module.exports = function (opts) {
   return new Uppy(opts)
   return new Uppy(opts)
 }
 }
+
 // Expose class constructor.
 // Expose class constructor.
 module.exports.Uppy = Uppy
 module.exports.Uppy = Uppy

File diff suppressed because it is too large
+ 2 - 1
src/core/Core.test.js


+ 28 - 4
src/core/Plugin.js

@@ -1,6 +1,28 @@
 const preact = require('preact')
 const preact = require('preact')
 const { findDOMElement } = require('../core/Utils')
 const { findDOMElement } = require('../core/Utils')
 
 
+/**
+ * Defer a frequent call to the microtask queue.
+ */
+function debounce (fn) {
+  let calling = null
+  let latestArgs = null
+  return (...args) => {
+    latestArgs = args
+    if (!calling) {
+      calling = Promise.resolve().then(() => {
+        calling = null
+        // At this point `args` may be different from the most
+        // recent state, if multiple calls happened since this task
+        // was queued. So we use the `latestArgs`, which definitely
+        // is the most recent call.
+        return fn(...latestArgs)
+      })
+    }
+    return calling
+  }
+}
+
 /**
 /**
  * Boilerplate that all Plugins share - and should not be used
  * Boilerplate that all Plugins share - and should not be used
  * directly. It also shows which methods final plugins should implement/override,
  * directly. It also shows which methods final plugins should implement/override,
@@ -39,8 +61,8 @@ module.exports = class Plugin {
       return
       return
     }
     }
 
 
-    if (this.updateUI) {
-      this.updateUI(state)
+    if (this._updateUI) {
+      this._updateUI(state)
     }
     }
   }
   }
 
 
@@ -60,9 +82,11 @@ module.exports = class Plugin {
     if (targetElement) {
     if (targetElement) {
       this.isTargetDOMEl = true
       this.isTargetDOMEl = true
 
 
-      this.updateUI = (state) => {
+      // API for plugins that require a synchronous rerender.
+      this.rerender = (state) => {
         this.el = preact.render(this.render(state), targetElement, this.el)
         this.el = preact.render(this.render(state), targetElement, this.el)
       }
       }
+      this._updateUI = debounce(this.rerender)
 
 
       this.uppy.log(`Installing ${callerPluginName} to a DOM element`)
       this.uppy.log(`Installing ${callerPluginName} to a DOM element`)
 
 
@@ -71,7 +95,7 @@ module.exports = class Plugin {
         targetElement.innerHTML = ''
         targetElement.innerHTML = ''
       }
       }
 
 
-      this.el = preact.render(this.render(this.uppy.state), targetElement)
+      this.el = preact.render(this.render(this.uppy.getState()), targetElement)
 
 
       return this.el
       return this.el
     }
     }

+ 41 - 0
src/core/PromiseWaiter.js

@@ -0,0 +1,41 @@
+/**
+ * Wait for multiple Promises to resolve.
+ */
+module.exports = class PromiseWaiter {
+  constructor () {
+    this.promises = []
+  }
+
+  add (promise) {
+    this.promises.push(promise)
+
+    const remove = () => {
+      this.remove(promise)
+    }
+    promise.then(remove, remove)
+  }
+
+  remove (promise) {
+    const index = this.promises.indexOf(promise)
+    if (index !== -1) {
+      this.promises.splice(index, 1)
+    }
+  }
+
+  wait () {
+    const promises = this.promises
+    this.promises = []
+
+    function noop () {
+      // No result value
+    }
+
+    // Just wait for a Promise to conclude in some way, whether it's resolution
+    // or rejection. We don't care about the contents.
+    function concluded (promise) {
+      return promise.then(noop, noop)
+    }
+
+    return Promise.all(promises.map(concluded)).then(noop)
+  }
+}

+ 51 - 27
src/core/Translator.js

@@ -27,28 +27,24 @@ module.exports = class Translator {
 
 
     this.opts = Object.assign({}, defaultOptions, opts)
     this.opts = Object.assign({}, defaultOptions, opts)
     this.locale = Object.assign({}, defaultOptions.locale, opts.locale)
     this.locale = Object.assign({}, defaultOptions.locale, opts.locale)
-
-    // console.log(this.opts.locale)
-
-    // this.locale.pluralize = this.locale ? this.locale.pluralize : defaultPluralize
-    // this.locale.strings = Object.assign({}, en_US.strings, this.opts.locale.strings)
   }
   }
 
 
-/**
- * Takes a string with placeholder variables like `%{smart_count} file selected`
- * and replaces it with values from options `{smart_count: 5}`
- *
- * @license https://github.com/airbnb/polyglot.js/blob/master/LICENSE
- * taken from https://github.com/airbnb/polyglot.js/blob/master/lib/polyglot.js#L299
- *
- * @param {string} phrase that needs interpolation, with placeholders
- * @param {object} options with values that will be used to replace placeholders
- * @return {string} interpolated
- */
+  /**
+   * Takes a string with placeholder variables like `%{smart_count} file selected`
+   * and replaces it with values from options `{smart_count: 5}`
+   *
+   * @license https://github.com/airbnb/polyglot.js/blob/master/LICENSE
+   * taken from https://github.com/airbnb/polyglot.js/blob/master/lib/polyglot.js#L299
+   *
+   * @param {string} phrase that needs interpolation, with placeholders
+   * @param {object} options with values that will be used to replace placeholders
+   * @return {string} interpolated
+   */
   interpolate (phrase, options) {
   interpolate (phrase, options) {
-    const replace = String.prototype.replace
+    const { split, replace } = String.prototype
     const dollarRegex = /\$/g
     const dollarRegex = /\$/g
     const dollarBillsYall = '$$$$'
     const dollarBillsYall = '$$$$'
+    let interpolated = [phrase]
 
 
     for (let arg in options) {
     for (let arg in options) {
       if (arg !== '_' && options.hasOwnProperty(arg)) {
       if (arg !== '_' && options.hasOwnProperty(arg)) {
@@ -62,21 +58,49 @@ module.exports = class Translator {
         // We create a new `RegExp` each time instead of using a more-efficient
         // We create a new `RegExp` each time instead of using a more-efficient
         // string replace so that the same argument can be replaced multiple times
         // string replace so that the same argument can be replaced multiple times
         // in the same phrase.
         // in the same phrase.
-        phrase = replace.call(phrase, new RegExp('%\\{' + arg + '\\}', 'g'), replacement)
+        interpolated = insertReplacement(interpolated, new RegExp('%\\{' + arg + '\\}', 'g'), replacement)
       }
       }
     }
     }
-    return phrase
+
+    return interpolated
+
+    function insertReplacement (source, rx, replacement) {
+      const newParts = []
+      source.forEach((chunk) => {
+        split.call(chunk, rx).forEach((raw, i, list) => {
+          if (raw !== '') {
+            newParts.push(raw)
+          }
+
+          // Interlace with the `replacement` value
+          if (i < list.length - 1) {
+            newParts.push(replacement)
+          }
+        })
+      })
+      return newParts
+    }
   }
   }
 
 
-/**
- * Public translate method
- *
- * @param {string} key
- * @param {object} options with values that will be used later to replace placeholders in string
- * @return {string} translated (and interpolated)
- */
+  /**
+   * Public translate method
+   *
+   * @param {string} key
+   * @param {object} options with values that will be used later to replace placeholders in string
+   * @return {string} translated (and interpolated)
+   */
   translate (key, options) {
   translate (key, options) {
-    if (options && options.smart_count) {
+    return this.translateArray(key, options).join('')
+  }
+
+  /**
+   * Get a translation and return the translated and interpolated parts as an array.
+   * @param {string} key
+   * @param {object} options with values that will be used to replace placeholders
+   * @return {Array} The translated and interpolated parts, in order.
+   */
+  translateArray (key, options) {
+    if (options && typeof options.smart_count !== 'undefined') {
       var plural = this.locale.pluralize(options.smart_count)
       var plural = this.locale.pluralize(options.smart_count)
       return this.interpolate(this.opts.locale.strings[key][plural], options)
       return this.interpolate(this.opts.locale.strings[key][plural], options)
     }
     }

+ 19 - 3
src/core/Translator.test.js

@@ -8,6 +8,22 @@ describe('core/translator', () => {
       const core = new Core({ locale: russian })
       const core = new Core({ locale: russian })
       expect(core.translator.translate('chooseFile')).toEqual('Выберите файл')
       expect(core.translator.translate('chooseFile')).toEqual('Выберите файл')
     })
     })
+
+    it('should translate a string with non-string elements', () => {
+      const core = new Core({
+        locale: {
+          strings: {
+            test: 'Hello %{who}!',
+            test2: 'Hello %{who}'
+          }
+        }
+      })
+
+      const who = Symbol('who')
+      expect(core.translator.translateArray('test', { who: who })).toEqual(['Hello ', who, '!'])
+      // No empty string at the end.
+      expect(core.translator.translateArray('test2', { who: who })).toEqual(['Hello ', who])
+    })
   })
   })
 
 
   describe('interpolation', () => {
   describe('interpolation', () => {
@@ -23,15 +39,15 @@ describe('core/translator', () => {
     it('should translate a string', () => {
     it('should translate a string', () => {
       const core = new Core({ locale: russian })
       const core = new Core({ locale: russian })
       expect(
       expect(
-        core.translator.translate('filesChosen', { smart_count: '18' })
+        core.translator.translate('filesChosen', { smart_count: 18 })
       ).toEqual('Выбрано 18 файлов')
       ).toEqual('Выбрано 18 файлов')
 
 
       expect(
       expect(
-        core.translator.translate('filesChosen', { smart_count: '1' })
+        core.translator.translate('filesChosen', { smart_count: 1 })
       ).toEqual('Выбран 1 файл')
       ).toEqual('Выбран 1 файл')
 
 
       expect(
       expect(
-        core.translator.translate('filesChosen', { smart_count: '0' })
+        core.translator.translate('filesChosen', { smart_count: 0 })
       ).toEqual('Выбрано 0 файлов')
       ).toEqual('Выбрано 0 файлов')
     })
     })
   })
   })

+ 0 - 3
src/core/UppySocket.js

@@ -51,12 +51,10 @@ module.exports = class UppySocket {
   }
   }
 
 
   on (action, handler) {
   on (action, handler) {
-    console.log(action)
     this.emitter.on(action, handler)
     this.emitter.on(action, handler)
   }
   }
 
 
   emit (action, payload) {
   emit (action, payload) {
-    console.log(action)
     this.emitter.emit(action, payload)
     this.emitter.emit(action, payload)
   }
   }
 
 
@@ -67,7 +65,6 @@ module.exports = class UppySocket {
   _handleMessage (e) {
   _handleMessage (e) {
     try {
     try {
       const message = JSON.parse(e.data)
       const message = JSON.parse(e.data)
-      console.log(message)
       this.emit(message.action, message.payload)
       this.emit(message.action, message.payload)
     } catch (err) {
     } catch (err) {
       console.log(err)
       console.log(err)

+ 12 - 142
src/core/Utils.js

@@ -1,8 +1,5 @@
 const throttle = require('lodash.throttle')
 const throttle = require('lodash.throttle')
-// we inline file-type module, as opposed to using the NPM version,
-// because of this https://github.com/sindresorhus/file-type/issues/78
-// and https://github.com/sindresorhus/copy-text-to-clipboard/issues/5
-const fileType = require('../vendor/file-type')
+const mimeTypes = require('./mime-types.js')
 
 
 /**
 /**
  * A collection of small utility functions that help with dom manipulation, adding listeners,
  * A collection of small utility functions that help with dom manipulation, adding listeners,
@@ -116,53 +113,25 @@ function getArrayBuffer (chunk) {
 }
 }
 
 
 function getFileType (file) {
 function getFileType (file) {
-  const extensionsToMime = {
-    'md': 'text/markdown',
-    'markdown': 'text/markdown',
-    'mp4': 'video/mp4',
-    'mp3': 'audio/mp3',
-    'svg': 'image/svg+xml',
-    'jpg': 'image/jpeg',
-    'png': 'image/png',
-    'gif': 'image/gif',
-    'yaml': 'text/yaml',
-    'yml': 'text/yaml'
-  }
-
   const fileExtension = file.name ? getFileNameAndExtension(file.name).extension : null
   const fileExtension = file.name ? getFileNameAndExtension(file.name).extension : null
 
 
   if (file.isRemote) {
   if (file.isRemote) {
     // some remote providers do not support file types
     // some remote providers do not support file types
-    const mime = file.type ? file.type : extensionsToMime[fileExtension]
-    return Promise.resolve(mime)
+    return file.type ? file.type : mimeTypes[fileExtension]
   }
   }
 
 
-  // 1. try to determine file type from magic bytes with file-type module
-  // this should be the most trustworthy way
-  const chunk = file.data.slice(0, 4100)
-  return getArrayBuffer(chunk)
-    .then((buffer) => {
-      const type = fileType(buffer)
-      if (type && type.mime) {
-        return type.mime
-      }
-
-      // 2. if that’s no good, check if mime type is set in the file object
-      if (file.type) {
-        return file.type
-      }
+  // check if mime type is set in the file object
+  if (file.type) {
+    return file.type
+  }
 
 
-      // 3. if that’s no good, see if we can map extension to a mime type
-      if (fileExtension && extensionsToMime[fileExtension]) {
-        return extensionsToMime[fileExtension]
-      }
+  // see if we can map extension to a mime type
+  if (fileExtension && mimeTypes[fileExtension]) {
+    return mimeTypes[fileExtension]
+  }
 
 
-      // if all fails, well, return empty
-      return null
-    })
-    .catch(() => {
-      return null
-    })
+  // if all fails, well, return empty
+  return null
 }
 }
 
 
 // TODO Check which types are actually supported in browsers. Chrome likes webm
 // TODO Check which types are actually supported in browsers. Chrome likes webm
@@ -208,104 +177,6 @@ function isObjectURL (url) {
   return url.indexOf('blob:') === 0
   return url.indexOf('blob:') === 0
 }
 }
 
 
-function getProportionalHeight (img, width) {
-  const aspect = img.width / img.height
-  return Math.round(width / aspect)
-}
-
-/**
- * Create a thumbnail for the given Uppy file object.
- *
- * @param {{data: Blob}} file
- * @param {number} width
- * @return {Promise}
- */
-function createThumbnail (file, targetWidth) {
-  const originalUrl = URL.createObjectURL(file.data)
-  const onload = new Promise((resolve, reject) => {
-    const image = new Image()
-    image.src = originalUrl
-    image.onload = () => {
-      URL.revokeObjectURL(originalUrl)
-      resolve(image)
-    }
-    image.onerror = () => {
-      // The onerror event is totally useless unfortunately, as far as I know
-      URL.revokeObjectURL(originalUrl)
-      reject(new Error('Could not create thumbnail'))
-    }
-  })
-
-  return onload.then((image) => {
-    const targetHeight = getProportionalHeight(image, targetWidth)
-    const canvas = resizeImage(image, targetWidth, targetHeight)
-    return canvasToBlob(canvas, 'image/png')
-  }).then((blob) => {
-    return URL.createObjectURL(blob)
-  })
-}
-
-/**
- * Resize an image to the target `width` and `height`.
- *
- * Returns a Canvas with the resized image on it.
- */
-function resizeImage (image, targetWidth, targetHeight) {
-  let sourceWidth = image.width
-  let sourceHeight = image.height
-
-  if (targetHeight < image.height / 2) {
-    const steps = Math.floor(Math.log(image.width / targetWidth) / Math.log(2))
-    const stepScaled = downScaleInSteps(image, steps)
-    image = stepScaled.image
-    sourceWidth = stepScaled.sourceWidth
-    sourceHeight = stepScaled.sourceHeight
-  }
-
-  const canvas = document.createElement('canvas')
-  canvas.width = targetWidth
-  canvas.height = targetHeight
-
-  const context = canvas.getContext('2d')
-  context.drawImage(image,
-    0, 0, sourceWidth, sourceHeight,
-    0, 0, targetWidth, targetHeight)
-
-  return canvas
-}
-
-/**
- * Downscale an image by 50% `steps` times.
- */
-function downScaleInSteps (image, steps) {
-  let source = image
-  let currentWidth = source.width
-  let currentHeight = source.height
-
-  for (let i = 0; i < steps; i += 1) {
-    const canvas = document.createElement('canvas')
-    const context = canvas.getContext('2d')
-    canvas.width = currentWidth / 2
-    canvas.height = currentHeight / 2
-    context.drawImage(source,
-      // The entire source image. We pass width and height here,
-      // because we reuse this canvas, and should only scale down
-      // the part of the canvas that contains the previous scale step.
-      0, 0, currentWidth, currentHeight,
-      // Draw to 50% size
-      0, 0, currentWidth / 2, currentHeight / 2)
-    currentWidth /= 2
-    currentHeight /= 2
-    source = canvas
-  }
-
-  return {
-    image: source,
-    sourceWidth: currentWidth,
-    sourceHeight: currentHeight
-  }
-}
-
 /**
 /**
  * Save a <canvas> element's content to a Blob object.
  * Save a <canvas> element's content to a Blob object.
  *
  *
@@ -579,7 +450,6 @@ module.exports = {
   getArrayBuffer,
   getArrayBuffer,
   isPreviewSupported,
   isPreviewSupported,
   isObjectURL,
   isObjectURL,
-  createThumbnail,
   secondsToTime,
   secondsToTime,
   dataURItoBlob,
   dataURItoBlob,
   dataURItoFile,
   dataURItoFile,

+ 15 - 33
src/core/Utils.test.js

@@ -149,35 +149,13 @@ describe('core/utils', () => {
   })
   })
 
 
   describe('getFileType', () => {
   describe('getFileType', () => {
-    beforeEach(() => {
-      global.FileReader = class FileReader {
-        addEventListener (e, cb) {
-          if (e === 'load') {
-            this.loadCb = cb
-          }
-          if (e === 'error') {
-            this.errorCb = cb
-          }
-        }
-        readAsArrayBuffer (chunk) {
-          this.loadCb({ target: { result: new ArrayBuffer(8) } })
-        }
-      }
-    })
-
-    afterEach(() => {
-      global.FileReader = undefined
-    })
-
     it('should trust the filetype if the file comes from a remote source', () => {
     it('should trust the filetype if the file comes from a remote source', () => {
       const file = {
       const file = {
         isRemote: true,
         isRemote: true,
         type: 'audio/webm',
         type: 'audio/webm',
         name: 'foo.webm'
         name: 'foo.webm'
       }
       }
-      return utils.getFileType(file).then(r => {
-        expect(r).toEqual('audio/webm')
-      })
+      expect(utils.getFileType(file)).toEqual('audio/webm')
     })
     })
 
 
     it('should determine the filetype from the mimetype', () => {
     it('should determine the filetype from the mimetype', () => {
@@ -186,19 +164,25 @@ describe('core/utils', () => {
         name: 'foo.webm',
         name: 'foo.webm',
         data: 'sdfsdfhq9efbicw'
         data: 'sdfsdfhq9efbicw'
       }
       }
-      return utils.getFileType(file).then(r => {
-        expect(r).toEqual('audio/webm')
-      })
+      expect(utils.getFileType(file)).toEqual('audio/webm')
     })
     })
 
 
     it('should determine the filetype from the extension', () => {
     it('should determine the filetype from the extension', () => {
-      const file = {
+      const fileMP3 = {
         name: 'foo.mp3',
         name: 'foo.mp3',
         data: 'sdfsfhfh329fhwihs'
         data: 'sdfsfhfh329fhwihs'
       }
       }
-      return utils.getFileType(file).then(r => {
-        expect(r).toEqual('audio/mp3')
-      })
+      const fileYAML = {
+        name: 'bar.yaml',
+        data: 'sdfsfhfh329fhwihs'
+      }
+      const fileMKV = {
+        name: 'bar.mkv',
+        data: 'sdfsfhfh329fhwihs'
+      }
+      expect(utils.getFileType(fileMP3)).toEqual('audio/mp3')
+      expect(utils.getFileType(fileYAML)).toEqual('text/yaml')
+      expect(utils.getFileType(fileMKV)).toEqual('video/x-matroska')
     })
     })
 
 
     it('should fail gracefully if unable to detect', () => {
     it('should fail gracefully if unable to detect', () => {
@@ -206,9 +190,7 @@ describe('core/utils', () => {
         name: 'foobar',
         name: 'foobar',
         data: 'sdfsfhfh329fhwihs'
         data: 'sdfsfhfh329fhwihs'
       }
       }
-      return utils.getFileType(file).then(r => {
-        expect(r).toEqual(null)
-      })
+      expect(utils.getFileType(file)).toEqual(null)
     })
     })
   })
   })
 
 

+ 6 - 10
src/core/__snapshots__/Core.test.js.snap

@@ -7,12 +7,8 @@ exports[`src/Core plugins should not be able to add a plugin that has no type 1`
 exports[`src/Core plugins should not be able to add an invalid plugin 1`] = `"Expected a plugin class, but got object. Please verify that the plugin was imported and spelled correctly."`;
 exports[`src/Core plugins should not be able to add an invalid plugin 1`] = `"Expected a plugin class, but got object. Please verify that the plugin was imported and spelled correctly."`;
 
 
 exports[`src/Core plugins should prevent the same plugin from being added more than once 1`] = `
 exports[`src/Core plugins should prevent the same plugin from being added more than once 1`] = `
-"Already found a plugin named 'TestSelector1'.
-        Tried to use: 'TestSelector1'.
-        Uppy is currently limited to running one of every plugin.
-        Share your use case with us over at
-        https://github.com/transloadit/uppy/issues/
-        if you want us to reconsider."
+"Already found a plugin named 'TestSelector1'. Tried to use: 'TestSelector1'.
+Uppy plugins must have unique 'id' options. See https://uppy.io/docs/plugins/#id."
 `;
 `;
 
 
 exports[`src/Core uploading a file should only upload files that are not already assigned to another upload id 1`] = `
 exports[`src/Core uploading a file should only upload files that are not already assigned to another upload id 1`] = `
@@ -26,7 +22,7 @@ Object {
       "isRemote": false,
       "isRemote": false,
       "meta": Object {
       "meta": Object {
         "name": "foo.jpg",
         "name": "foo.jpg",
-        "type": null,
+        "type": "image/jpeg",
       },
       },
       "name": "foo.jpg",
       "name": "foo.jpg",
       "preview": undefined,
       "preview": undefined,
@@ -40,7 +36,7 @@ Object {
       "remote": "",
       "remote": "",
       "size": 0,
       "size": 0,
       "source": "jest",
       "source": "jest",
-      "type": null,
+      "type": "image/jpeg",
     },
     },
     Object {
     Object {
       "data": Uint8Array [],
       "data": Uint8Array [],
@@ -49,7 +45,7 @@ Object {
       "isRemote": false,
       "isRemote": false,
       "meta": Object {
       "meta": Object {
         "name": "bar.jpg",
         "name": "bar.jpg",
-        "type": null,
+        "type": "image/jpeg",
       },
       },
       "name": "bar.jpg",
       "name": "bar.jpg",
       "preview": undefined,
       "preview": undefined,
@@ -63,7 +59,7 @@ Object {
       "remote": "",
       "remote": "",
       "size": 0,
       "size": 0,
       "source": "jest",
       "source": "jest",
-      "type": null,
+      "type": "image/jpeg",
     },
     },
   ],
   ],
   "uploadID": "cjd09qwxb000dlql4tp4doz8h",
   "uploadID": "cjd09qwxb000dlql4tp4doz8h",

+ 36 - 0
src/core/mime-types.js

@@ -0,0 +1,36 @@
+module.exports = {
+  'md': 'text/markdown',
+  'markdown': 'text/markdown',
+  'mp4': 'video/mp4',
+  'mp3': 'audio/mp3',
+  'svg': 'image/svg+xml',
+  'jpg': 'image/jpeg',
+  'png': 'image/png',
+  'gif': 'image/gif',
+  'yaml': 'text/yaml',
+  'yml': 'text/yaml',
+  'csv': 'text/csv',
+  'avi': 'video/x-msvideo',
+  'mks': 'video/x-matroska',
+  'mkv': 'video/x-matroska',
+  'mov': 'video/quicktime',
+  'doc': 'application/msword',
+  'docm': 'application/vnd.ms-word.document.macroenabled.12',
+  'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+  'dot': 'application/msword',
+  'dotm': 'application/vnd.ms-word.template.macroenabled.12',
+  'dotx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
+  'xla': 'application/vnd.ms-excel',
+  'xlam': 'application/vnd.ms-excel.addin.macroenabled.12',
+  'xlc': 'application/vnd.ms-excel',
+  'xlf': 'application/x-xliff+xml',
+  'xlm': 'application/vnd.ms-excel',
+  'xls': 'application/vnd.ms-excel',
+  'xlsb': 'application/vnd.ms-excel.sheet.binary.macroenabled.12',
+  'xlsm': 'application/vnd.ms-excel.sheet.macroenabled.12',
+  'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+  'xlt': 'application/vnd.ms-excel',
+  'xltm': 'application/vnd.ms-excel.template.macroenabled.12',
+  'xltx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
+  'xlw': 'application/vnd.ms-excel'
+}

+ 424 - 0
src/plugins/AwsS3/Multipart.js

@@ -0,0 +1,424 @@
+const Plugin = require('../../core/Plugin')
+const RequestClient = require('../../server/RequestClient')
+const UppySocket = require('../../core/UppySocket')
+const {
+  emitSocketProgress,
+  getSocketHost,
+  limitPromises
+} = require('../../core/Utils')
+const Uploader = require('./MultipartUploader')
+
+/**
+ * Create a wrapper around an event emitter with a `remove` method to remove
+ * all events that were added using the wrapped emitter.
+ */
+function createEventTracker (emitter) {
+  const events = []
+  return {
+    on (event, fn) {
+      events.push([ event, fn ])
+      return emitter.on(event, fn)
+    },
+    remove () {
+      events.forEach(([ event, fn ]) => {
+        emitter.off(event, fn)
+      })
+    }
+  }
+}
+
+function assertServerError (res) {
+  if (res && res.error) {
+    const error = new Error(res.message)
+    Object.assign(error, res.error)
+    throw error
+  }
+  return res
+}
+
+module.exports = class AwsS3Multipart extends Plugin {
+  constructor (uppy, opts) {
+    super(uppy, opts)
+    this.type = 'uploader'
+    this.id = 'AwsS3Multipart'
+    this.title = 'AWS S3 Multipart'
+    this.server = new RequestClient(uppy, opts)
+
+    const defaultOptions = {
+      timeout: 30 * 1000,
+      limit: 0,
+      createMultipartUpload: this.createMultipartUpload.bind(this),
+      listParts: this.listParts.bind(this),
+      prepareUploadPart: this.prepareUploadPart.bind(this),
+      abortMultipartUpload: this.abortMultipartUpload.bind(this),
+      completeMultipartUpload: this.completeMultipartUpload.bind(this)
+    }
+
+    this.opts = Object.assign({}, defaultOptions, opts)
+
+    this.upload = this.upload.bind(this)
+
+    if (typeof this.opts.limit === 'number' && this.opts.limit !== 0) {
+      this.limitRequests = limitPromises(this.opts.limit)
+    } else {
+      this.limitRequests = (fn) => fn
+    }
+
+    this.uploaders = Object.create(null)
+    this.uploaderEvents = Object.create(null)
+    this.uploaderSockets = Object.create(null)
+  }
+
+  /**
+   * Clean up all references for a file's upload: the MultipartUploader instance,
+   * any events related to the file, and the uppy-server WebSocket connection.
+   */
+  resetUploaderReferences (fileID) {
+    if (this.uploaders[fileID]) {
+      this.uploaders[fileID].abort()
+      this.uploaders[fileID] = null
+    }
+    if (this.uploaderEvents[fileID]) {
+      this.uploaderEvents[fileID].remove()
+      this.uploaderEvents[fileID] = null
+    }
+    if (this.uploaderSockets[fileID]) {
+      this.uploaderSockets[fileID].close()
+      this.uploaderSockets[fileID] = null
+    }
+  }
+
+  assertHost () {
+    if (!this.opts.host) {
+      throw new Error('Expected a `host` option containing an uppy-server address.')
+    }
+  }
+
+  createMultipartUpload (file) {
+    this.assertHost()
+
+    return this.server.post('s3/multipart', {
+      filename: file.name,
+      type: file.type
+    }).then(assertServerError)
+  }
+
+  listParts (file, { key, uploadId }) {
+    this.assertHost()
+
+    const filename = encodeURIComponent(key)
+    return this.server.get(`s3/multipart/${uploadId}?key=${filename}`)
+      .then(assertServerError)
+  }
+
+  prepareUploadPart (file, { key, uploadId, number }) {
+    this.assertHost()
+
+    const filename = encodeURIComponent(key)
+    return this.server.get(`s3/multipart/${uploadId}/${number}?key=${filename}`)
+      .then(assertServerError)
+  }
+
+  completeMultipartUpload (file, { key, uploadId, parts }) {
+    this.assertHost()
+
+    const filename = encodeURIComponent(key)
+    const uploadIdEnc = encodeURIComponent(uploadId)
+    return this.server.post(`s3/multipart/${uploadIdEnc}/complete?key=${filename}`, { parts })
+      .then(assertServerError)
+  }
+
+  abortMultipartUpload (file, { key, uploadId }) {
+    this.assertHost()
+
+    const filename = encodeURIComponent(key)
+    const uploadIdEnc = encodeURIComponent(uploadId)
+    return this.server.delete(`s3/multipart/${uploadIdEnc}?key=${filename}`)
+      .then(assertServerError)
+  }
+
+  uploadFile (file) {
+    return new Promise((resolve, reject) => {
+      const upload = new Uploader(file.data, Object.assign({
+        // .bind to pass the file object to each handler.
+        createMultipartUpload: this.limitRequests(this.opts.createMultipartUpload.bind(this, file)),
+        listParts: this.limitRequests(this.opts.listParts.bind(this, file)),
+        prepareUploadPart: this.opts.prepareUploadPart.bind(this, file),
+        completeMultipartUpload: this.limitRequests(this.opts.completeMultipartUpload.bind(this, file)),
+        abortMultipartUpload: this.limitRequests(this.opts.abortMultipartUpload.bind(this, file)),
+
+        limit: this.opts.limit || 5,
+        onStart: (data) => {
+          const cFile = this.uppy.getFile(file.id)
+          this.uppy.setFileState(file.id, {
+            s3Multipart: Object.assign({}, cFile.s3Multipart, {
+              key: data.key,
+              uploadId: data.uploadId,
+              parts: []
+            })
+          })
+        },
+        onProgress: (bytesUploaded, bytesTotal) => {
+          this.uppy.emit('upload-progress', file, {
+            uploader: this,
+            bytesUploaded: bytesUploaded,
+            bytesTotal: bytesTotal
+          })
+        },
+        onError: (err) => {
+          this.uppy.log(err)
+          this.uppy.emit('upload-error', file, err)
+          err.message = `Failed because: ${err.message}`
+
+          this.resetUploaderReferences(file.id)
+          reject(err)
+        },
+        onSuccess: (result) => {
+          this.uppy.emit('upload-success', file, upload, result.location)
+
+          if (result.location) {
+            this.uppy.log('Download ' + upload.file.name + ' from ' + result.location)
+          }
+
+          this.resetUploaderReferences(file.id)
+          resolve(upload)
+        },
+        onPartComplete: (part) => {
+          // Store completed parts in state.
+          const cFile = this.uppy.getFile(file.id)
+          this.uppy.setFileState(file.id, {
+            s3Multipart: Object.assign({}, cFile.s3Multipart, {
+              parts: [
+                ...cFile.s3Multipart.parts,
+                part
+              ]
+            })
+          })
+
+          this.uppy.emit('s3-multipart:part-uploaded', cFile, part)
+        }
+      }, file.s3Multipart))
+
+      this.uploaders[file.id] = upload
+      this.uploaderEvents[file.id] = createEventTracker(this.uppy)
+
+      this.onFileRemove(file.id, (removed) => {
+        this.resetUploaderReferences(file.id)
+        resolve(`upload ${removed.id} was removed`)
+      })
+
+      this.onFilePause(file.id, (isPaused) => {
+        if (isPaused) {
+          upload.pause()
+        } else {
+          upload.start()
+        }
+      })
+
+      this.onPauseAll(file.id, () => {
+        upload.pause()
+      })
+
+      this.onCancelAll(file.id, () => {
+        upload.abort({ really: true })
+      })
+
+      this.onResumeAll(file.id, () => {
+        upload.start()
+      })
+
+      if (!file.isPaused) {
+        upload.start()
+      }
+
+      if (!file.isRestored) {
+        this.uppy.emit('upload-started', file, upload)
+      }
+    })
+  }
+
+  uploadRemote (file) {
+    this.resetUploaderReferences(file.id)
+
+    return new Promise((resolve, reject) => {
+      if (file.serverToken) {
+        return this.connectToServerSocket(file)
+          .then(() => resolve())
+          .catch(reject)
+      }
+
+      this.uppy.emit('upload-started', file)
+
+      fetch(file.remote.url, {
+        method: 'post',
+        credentials: 'include',
+        headers: {
+          'Accept': 'application/json',
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify(Object.assign({}, file.remote.body, {
+          protocol: 's3-multipart',
+          size: file.data.size,
+          metadata: file.meta
+        }))
+      })
+      .then((res) => {
+        if (res.status < 200 || res.status > 300) {
+          return reject(res.statusText)
+        }
+
+        return res.json().then((data) => {
+          this.uppy.setFileState(file.id, { serverToken: data.token })
+          return this.uppy.getFile(file.id)
+        })
+      })
+      .then((file) => {
+        return this.connectToServerSocket(file)
+      })
+      .then(() => {
+        resolve()
+      })
+      .catch((err) => {
+        reject(new Error(err))
+      })
+    })
+  }
+
+  connectToServerSocket (file) {
+    return new Promise((resolve, reject) => {
+      const token = file.serverToken
+      const host = getSocketHost(file.remote.host)
+      const socket = new UppySocket({ target: `${host}/api/${token}` })
+      this.uploaderSockets[socket] = socket
+      this.uploaderEvents[file.id] = createEventTracker(this.uppy)
+
+      this.onFileRemove(file.id, (removed) => {
+        socket.send('pause', {})
+        resolve(`upload ${file.id} was removed`)
+      })
+
+      this.onFilePause(file.id, (isPaused) => {
+        socket.send(isPaused ? 'pause' : 'resume', {})
+      })
+
+      this.onPauseAll(file.id, () => socket.send('pause', {}))
+
+      this.onCancelAll(file.id, () => socket.send('pause', {}))
+
+      this.onResumeAll(file.id, () => {
+        if (file.error) {
+          socket.send('pause', {})
+        }
+        socket.send('resume', {})
+      })
+
+      this.onRetry(file.id, () => {
+        socket.send('pause', {})
+        socket.send('resume', {})
+      })
+
+      this.onRetryAll(file.id, () => {
+        socket.send('pause', {})
+        socket.send('resume', {})
+      })
+
+      if (file.isPaused) {
+        socket.send('pause', {})
+      }
+
+      socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file))
+
+      socket.on('error', (errData) => {
+        this.uppy.emit('upload-error', file, new Error(errData.error))
+        reject(new Error(errData.error))
+      })
+
+      socket.on('success', (data) => {
+        this.uppy.emit('upload-success', file, data, data.url)
+        resolve()
+      })
+    })
+  }
+
+  upload (fileIDs) {
+    if (fileIDs.length === 0) return Promise.resolve()
+
+    const promises = fileIDs.map((id) => {
+      const file = this.uppy.getFile(id)
+      if (file.isRemote) {
+        return this.uploadRemote(file)
+      }
+      return this.uploadFile(file)
+    })
+
+    return Promise.all(promises)
+  }
+
+  addResumableUploadsCapabilityFlag () {
+    this.uppy.setState({
+      capabilities: Object.assign({}, this.uppy.getState().capabilities, {
+        resumableUploads: true
+      })
+    })
+  }
+
+  onFileRemove (fileID, cb) {
+    this.uploaderEvents[fileID].on('file-removed', (file) => {
+      if (fileID === file.id) cb(file.id)
+    })
+  }
+
+  onFilePause (fileID, cb) {
+    this.uploaderEvents[fileID].on('upload-pause', (targetFileID, isPaused) => {
+      if (fileID === targetFileID) {
+        // const isPaused = this.uppy.pauseResume(fileID)
+        cb(isPaused)
+      }
+    })
+  }
+
+  onRetry (fileID, cb) {
+    this.uploaderEvents[fileID].on('upload-retry', (targetFileID) => {
+      if (fileID === targetFileID) {
+        cb()
+      }
+    })
+  }
+
+  onRetryAll (fileID, cb) {
+    this.uploaderEvents[fileID].on('retry-all', (filesToRetry) => {
+      if (!this.uppy.getFile(fileID)) return
+      cb()
+    })
+  }
+
+  onPauseAll (fileID, cb) {
+    this.uploaderEvents[fileID].on('pause-all', () => {
+      if (!this.uppy.getFile(fileID)) return
+      cb()
+    })
+  }
+
+  onCancelAll (fileID, cb) {
+    this.uploaderEvents[fileID].on('cancel-all', () => {
+      if (!this.uppy.getFile(fileID)) return
+      cb()
+    })
+  }
+
+  onResumeAll (fileID, cb) {
+    this.uploaderEvents[fileID].on('resume-all', () => {
+      if (!this.uppy.getFile(fileID)) return
+      cb()
+    })
+  }
+
+  install () {
+    this.addResumableUploadsCapabilityFlag()
+    this.uppy.addUploader(this.upload)
+  }
+
+  uninstall () {
+    this.uppy.removeUploader(this.upload)
+  }
+}

+ 284 - 0
src/plugins/AwsS3/MultipartUploader.js

@@ -0,0 +1,284 @@
+const MB = 1024 * 1024
+
+const defaultOptions = {
+  limit: 1,
+  onStart () {},
+  onProgress () {},
+  onPartComplete () {},
+  onSuccess () {},
+  onError (err) {
+    throw err
+  }
+}
+
+function remove (arr, el) {
+  const i = arr.indexOf(el)
+  if (i !== -1) arr.splice(i, 1)
+}
+
+class MultipartUploader {
+  constructor (file, options) {
+    this.options = Object.assign({}, defaultOptions, options)
+    this.file = file
+
+    this.key = this.options.key || null
+    this.uploadId = this.options.uploadId || null
+    this.parts = this.options.parts || []
+
+    this.isPaused = false
+    this.chunks = null
+    this.chunkState = null
+    this.uploading = []
+
+    this._initChunks()
+  }
+
+  _initChunks () {
+    const chunks = []
+    const chunkSize = Math.max(Math.ceil(this.file.size / 10000), 5 * MB)
+
+    for (let i = 0; i < this.file.size; i += chunkSize) {
+      const end = Math.min(this.file.size, i + chunkSize)
+      chunks.push(this.file.slice(i, end))
+    }
+
+    this.chunks = chunks
+    this.chunkState = chunks.map(() => ({
+      uploaded: 0,
+      busy: false,
+      done: false
+    }))
+  }
+
+  _createUpload () {
+    return Promise.resolve().then(() =>
+      this.options.createMultipartUpload()
+    ).then((result) => {
+      const valid = typeof result === 'object' && result &&
+        typeof result.uploadId === 'string' &&
+        typeof result.key === 'string'
+      if (!valid) {
+        throw new TypeError(`AwsS3/Multipart: Got incorrect result from 'createMultipartUpload()', expected an object '{ uploadId, key }'.`)
+      }
+      return result
+    }).then((result) => {
+      this.key = result.key
+      this.uploadId = result.uploadId
+
+      this.options.onStart(result)
+    }).then(() => {
+      this._uploadParts()
+    }).catch((err) => {
+      this._onError(err)
+    })
+  }
+
+  _resumeUpload () {
+    return Promise.resolve().then(() =>
+      this.options.listParts({
+        uploadId: this.uploadId,
+        key: this.key
+      })
+    ).then((parts) => {
+      parts.forEach((part) => {
+        const i = part.PartNumber - 1
+        this.chunkState[i] = {
+          uploaded: part.Size,
+          etag: part.ETag,
+          done: true
+        }
+
+        // Only add if we did not yet know about this part.
+        if (!this.parts.some((p) => p.PartNumber === part.PartNumber)) {
+          this.parts.push({
+            PartNumber: part.PartNumber,
+            ETag: part.ETag
+          })
+        }
+      })
+      this._uploadParts()
+    }).catch((err) => {
+      this._onError(err)
+    })
+  }
+
+  _uploadParts () {
+    if (this.isPaused) return
+
+    const need = this.options.limit - this.uploading.length
+    if (need === 0) return
+
+    // All parts are uploaded.
+    if (this.chunkState.every((state) => state.done)) {
+      this._completeUpload()
+      return
+    }
+
+    const candidates = []
+    for (let i = 0; i < this.chunkState.length; i++) {
+      const state = this.chunkState[i]
+      if (state.done || state.busy) continue
+
+      candidates.push(i)
+      if (candidates.length >= need) {
+        break
+      }
+    }
+
+    candidates.forEach((index) => {
+      this._uploadPart(index)
+    })
+  }
+
+  _uploadPart (index) {
+    const body = this.chunks[index]
+    this.chunkState[index].busy = true
+
+    return Promise.resolve().then(() =>
+      this.options.prepareUploadPart({
+        key: this.key,
+        uploadId: this.uploadId,
+        body,
+        number: index + 1
+      })
+    ).then((result) => {
+      const valid = typeof result === 'object' && result &&
+        typeof result.url === 'string'
+      if (!valid) {
+        throw new TypeError(`AwsS3/Multipart: Got incorrect result from 'prepareUploadPart()', expected an object '{ url }'.`)
+      }
+      return result
+    }).then(({ url }) => {
+      this._uploadPartBytes(index, url)
+    })
+  }
+
+  _onPartProgress (index, sent, total) {
+    this.chunkState[index].uploaded = sent
+
+    const totalUploaded = this.chunkState.reduce((n, c) => n + c.uploaded, 0)
+    this.options.onProgress(totalUploaded, this.file.size)
+  }
+
+  _onPartComplete (index, etag) {
+    this.chunkState[index].etag = etag
+    this.chunkState[index].done = true
+
+    const part = {
+      PartNumber: index + 1,
+      ETag: etag
+    }
+    this.parts.push(part)
+
+    this.options.onPartComplete(part)
+
+    this._uploadParts()
+  }
+
+  _uploadPartBytes (index, url) {
+    const body = this.chunks[index]
+    const xhr = new XMLHttpRequest()
+    xhr.open('PUT', url, true)
+    xhr.responseType = 'text'
+
+    this.uploading.push(xhr)
+
+    xhr.upload.addEventListener('progress', (ev) => {
+      if (!ev.lengthComputable) return
+
+      this._onPartProgress(index, ev.loaded, ev.total)
+    })
+
+    xhr.addEventListener('abort', (ev) => {
+      remove(this.uploading, ev.target)
+      this.chunkState[index].busy = false
+    })
+
+    xhr.addEventListener('load', (ev) => {
+      remove(this.uploading, ev.target)
+      this.chunkState[index].busy = false
+
+      if (ev.target.status < 200 || ev.target.status >= 300) {
+        this._onError(new Error('Non 2xx'))
+        return
+      }
+
+      this._onPartProgress(index, body.size, body.size)
+
+      // NOTE This must be allowed by CORS.
+      const etag = ev.target.getResponseHeader('ETag')
+      if (etag === null) {
+        this._onError(new Error('AwsS3/Multipart: Could not read the ETag header. This likely means CORS is not configured correctly on the S3 Bucket. Seee https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.'))
+        return
+      }
+
+      this._onPartComplete(index, etag)
+    })
+
+    xhr.addEventListener('error', (ev) => {
+      remove(this.uploading, ev.target)
+      this.chunkState[index].busy = false
+
+      const error = new Error('Unknown error')
+      error.source = ev.target
+      this._onError(error)
+    })
+
+    xhr.send(body)
+  }
+
+  _completeUpload () {
+    // Parts may not have completed uploading in sorted order, if limit > 1.
+    this.parts.sort((a, b) => a.PartNumber - b.PartNumber)
+
+    return Promise.resolve().then(() =>
+      this.options.completeMultipartUpload({
+        key: this.key,
+        uploadId: this.uploadId,
+        parts: this.parts
+      })
+    ).then((result) => {
+      this.options.onSuccess(result)
+    }, (err) => {
+      this._onError(err)
+    })
+  }
+
+  _abortUpload () {
+    this.options.abortMultipartUpload({
+      key: this.key,
+      uploadId: this.uploadId
+    })
+  }
+
+  _onError (err) {
+    this.options.onError(err)
+  }
+
+  start () {
+    this.isPaused = false
+    if (this.uploadId) {
+      this._resumeUpload()
+    } else {
+      this._createUpload()
+    }
+  }
+
+  pause () {
+    const inProgress = this.uploading.slice()
+    inProgress.forEach((xhr) => {
+      xhr.abort()
+    })
+    this.isPaused = true
+  }
+
+  abort (opts = {}) {
+    const really = opts.really || false
+
+    if (!really) return this.pause()
+
+    this._abortUpload()
+  }
+}
+
+module.exports = MultipartUploader

+ 1 - 1
src/plugins/AwsS3/index.js

@@ -65,7 +65,7 @@ module.exports = class AwsS3 extends Plugin {
       (params.method == null || /^(put|post)$/i.test(params.method))
       (params.method == null || /^(put|post)$/i.test(params.method))
 
 
     if (!valid) {
     if (!valid) {
-      const err = new TypeError(`AwsS3: got incorrect result from 'getUploadParameters()' for file '${file.name}', expected an object '{ url, method, fields }'.\nSee https://uppy.io/docs/aws-s3/#getUploadParameters-file for more on the expected format.`)
+      const err = new TypeError(`AwsS3: got incorrect result from 'getUploadParameters()' for file '${file.name}', expected an object '{ url, method, fields, headers }'.\nSee https://uppy.io/docs/aws-s3/#getUploadParameters-file for more on the expected format.`)
       console.error(err)
       console.error(err)
       throw err
       throw err
     }
     }

+ 16 - 10
src/plugins/Dashboard/ActionBrowseTagline.js

@@ -11,24 +11,30 @@ class ActionBrowseTagline extends Component {
   }
   }
 
 
   render () {
   render () {
-    // empty value=""  on file input, so we can select same file
-    // after removing it from Uppy — otherwise OS thinks it’s selected
+    const browse = (
+      <button type="button" class="uppy-Dashboard-browse" onclick={this.handleClick}>
+        {this.props.i18n('browse')}
+      </button>
+    )
+
+    // empty value="" on file input, so that the input is cleared after a file is selected,
+    // because Uppy will be handling the upload and so we can select same file
+    // after removing — otherwise browser thinks it’s already selected
     return (
     return (
       <span>
       <span>
         {this.props.acquirers.length === 0
         {this.props.acquirers.length === 0
-          ? this.props.i18n('dropPaste')
-          : this.props.i18n('dropPasteImport')
-        } <button type="button" class="uppy-Dashboard-browse" onclick={this.handleClick}>
-          {this.props.i18n('browse')}
-        </button>
+          ? this.props.i18nArray('dropPaste', { browse })
+          : this.props.i18nArray('dropPasteImport', { browse })
+        }
         <input class="uppy-Dashboard-input"
         <input class="uppy-Dashboard-input"
-          hidden="true"
+          hidden
           aria-hidden="true"
           aria-hidden="true"
-          tabindex="-1"
+          tabindex={-1}
           type="file"
           type="file"
           name="files[]"
           name="files[]"
-          multiple="true"
+          multiple={this.props.maxNumberOfFiles !== 1}
           onchange={this.props.handleInputChange}
           onchange={this.props.handleInputChange}
+          accept={this.props.allowedFileTypes}
           value=""
           value=""
           ref={(input) => {
           ref={(input) => {
             this.input = input
             this.input = input

+ 9 - 9
src/plugins/Dashboard/Dashboard.js

@@ -8,11 +8,11 @@ const { h } = require('preact')
 // http://dev.edenspiekermann.com/2016/02/11/introducing-accessible-modal-dialog
 // http://dev.edenspiekermann.com/2016/02/11/introducing-accessible-modal-dialog
 // https://github.com/ghosh/micromodal
 // https://github.com/ghosh/micromodal
 
 
-const renderInnerPanel = (props) => {
+const PanelContent = (props) => {
   return <div style={{ width: '100%', height: '100%' }}>
   return <div style={{ width: '100%', height: '100%' }}>
     <div class="uppy-DashboardContent-bar">
     <div class="uppy-DashboardContent-bar">
       <div class="uppy-DashboardContent-title">
       <div class="uppy-DashboardContent-title">
-        {props.i18n('importFrom')} {props.activePanel ? props.activePanel.name : null}
+        {props.i18n('importFrom', { name: props.activePanel.name })}
       </div>
       </div>
       <button class="uppy-DashboardContent-back"
       <button class="uppy-DashboardContent-back"
         type="button"
         type="button"
@@ -23,8 +23,8 @@ const renderInnerPanel = (props) => {
 }
 }
 
 
 const poweredByUppy = (props) => {
 const poweredByUppy = (props) => {
-  return <a href="https://uppy.io" target="_blank" class="uppy-Dashboard-poweredBy">Powered by <svg aria-hidden="true" class="uppy-Dashboard-poweredByIcon" width="12" height="12" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
-    <path fill-rule="nonzero" d="M8.57 7.554v4.149H3.424V7.554H0L6 0l6 7.554H8.57z" />
+  return <a href="https://uppy.io" rel="noreferrer noopener" target="_blank" class="uppy-Dashboard-poweredBy">Powered by <svg aria-hidden="true" class="UppyIcon uppy-Dashboard-poweredByIcon" width="11" height="11" viewBox="0 0 11 11" xmlns="http://www.w3.org/2000/svg">
+    <path d="M7.365 10.5l-.01-4.045h2.612L5.5.806l-4.467 5.65h2.604l.01 4.044h3.718z" fill-rule="evenodd" />
   </svg><span class="uppy-Dashboard-poweredByUppy">Uppy</span></a>
   </svg><span class="uppy-Dashboard-poweredByUppy">Uppy</span></a>
 }
 }
 
 
@@ -43,21 +43,21 @@ module.exports = function Dashboard (props) {
       aria-label={!props.inline ? props.i18n('dashboardWindowTitle') : props.i18n('dashboardTitle')}
       aria-label={!props.inline ? props.i18n('dashboardWindowTitle') : props.i18n('dashboardTitle')}
       onpaste={props.handlePaste}>
       onpaste={props.handlePaste}>
 
 
-      <div class="uppy-Dashboard-overlay" tabindex="-1" onclick={props.handleClickOutside} />
+      <div class="uppy-Dashboard-overlay" tabindex={-1} onclick={props.handleClickOutside} />
 
 
       <div class="uppy-Dashboard-inner"
       <div class="uppy-Dashboard-inner"
         aria-modal={!props.inline && 'true'}
         aria-modal={!props.inline && 'true'}
         role={!props.inline && 'dialog'}
         role={!props.inline && 'dialog'}
         style={{
         style={{
-          maxWidth: props.inline && props.maxWidth ? props.maxWidth : '',
-          maxHeight: props.inline && props.maxHeight ? props.maxHeight : ''
+          width: props.inline && props.width ? props.width : '',
+          height: props.inline && props.height ? props.height : ''
         }}>
         }}>
         <button class="uppy-Dashboard-close"
         <button class="uppy-Dashboard-close"
           type="button"
           type="button"
           aria-label={props.i18n('closeModal')}
           aria-label={props.i18n('closeModal')}
           title={props.i18n('closeModal')}
           title={props.i18n('closeModal')}
           onclick={props.closeModal}>
           onclick={props.closeModal}>
-          <span aria-hidden="true">×</span>
+          <span aria-hidden="true">&times;</span>
         </button>
         </button>
 
 
         <div class="uppy-Dashboard-innerWrap">
         <div class="uppy-Dashboard-innerWrap">
@@ -73,7 +73,7 @@ module.exports = function Dashboard (props) {
             role="tabpanel"
             role="tabpanel"
             id={props.activePanel && `uppy-DashboardContent-panel--${props.activePanel.id}`}
             id={props.activePanel && `uppy-DashboardContent-panel--${props.activePanel.id}`}
             aria-hidden={props.activePanel ? 'false' : 'true'}>
             aria-hidden={props.activePanel ? 'false' : 'true'}>
-            {props.activePanel && renderInnerPanel(props)}
+            {props.activePanel && <PanelContent {...props} />}
           </div>
           </div>
 
 
           <div class="uppy-Dashboard-progressindicators">
           <div class="uppy-Dashboard-progressindicators">

+ 16 - 10
src/plugins/Dashboard/FileCard.js

@@ -56,13 +56,21 @@ module.exports = class FileCard extends Component {
   }
   }
 
 
   render () {
   render () {
+    if (!this.props.fileCardFor) {
+      return <div class="uppy-DashboardFileCard" aria-hidden />
+    }
+
     const file = this.props.files[this.props.fileCardFor]
     const file = this.props.files[this.props.fileCardFor]
 
 
-    return <div class="uppy-DashboardFileCard" aria-hidden={!this.props.fileCardFor}>
-      {this.props.fileCardFor &&
-        <div style="width: 100%; height: 100%;">
+    return (
+      <div class="uppy-DashboardFileCard" aria-hidden={!this.props.fileCardFor}>
+        <div style={{ width: '100%', height: '100%' }}>
           <div class="uppy-DashboardContent-bar">
           <div class="uppy-DashboardContent-bar">
-            <h2 class="uppy-DashboardContent-title">{this.props.i18n('editing')} <span class="uppy-DashboardContent-titleFile">{file.meta ? file.meta.name : file.name}</span></h2>
+            <h2 class="uppy-DashboardContent-title">
+              {this.props.i18nArray('editing', {
+                file: <span class="uppy-DashboardContent-titleFile">{file.meta ? file.meta.name : file.name}</span>
+              })}
+            </h2>
             <button class="uppy-DashboardContent-back" type="button" title={this.props.i18n('finishEditingFile')}
             <button class="uppy-DashboardContent-back" type="button" title={this.props.i18n('finishEditingFile')}
               onclick={this.handleSave}>{this.props.i18n('done')}</button>
               onclick={this.handleSave}>{this.props.i18n('done')}</button>
           </div>
           </div>
@@ -79,16 +87,14 @@ module.exports = class FileCard extends Component {
             <div class="uppy-Dashboard-actions">
             <div class="uppy-Dashboard-actions">
               <button class="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Dashboard-actionsBtn"
               <button class="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Dashboard-actionsBtn"
                 type="button"
                 type="button"
-                title={this.props.i18n('finishEditingFiles')}
-                onclick={this.handleSave}>Save changes</button>
+                onclick={this.handleSave}>{this.props.i18n('saveChanges')}</button>
               <button class="uppy-u-reset uppy-c-btn uppy-c-btn-link uppy-Dashboard-actionsBtn"
               <button class="uppy-u-reset uppy-c-btn uppy-c-btn-link uppy-Dashboard-actionsBtn"
                 type="button"
                 type="button"
-                title={this.props.i18n('finishEditingFiles')}
-                onclick={this.handleCancel}>Cancel</button>
+                onclick={this.handleCancel}>{this.props.i18n('cancel')}</button>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
-      }
-    </div>
+      </div>
+    )
   }
   }
 }
 }

+ 12 - 10
src/plugins/Dashboard/FileItem.js

@@ -25,13 +25,13 @@ module.exports = function fileItem (props) {
 
 
   const onPauseResumeCancelRetry = (ev) => {
   const onPauseResumeCancelRetry = (ev) => {
     if (isUploaded) return
     if (isUploaded) return
-    if (error) {
+    if (error && !props.hideRetryButton) {
       props.retryUpload(file.id)
       props.retryUpload(file.id)
       return
       return
     }
     }
     if (props.resumableUploads) {
     if (props.resumableUploads) {
       props.pauseUpload(file.id)
       props.pauseUpload(file.id)
-    } else {
+    } else if (!props.hideCancelButton) {
       props.cancelUpload(file.id)
       props.cancelUpload(file.id)
     }
     }
   }
   }
@@ -60,7 +60,7 @@ module.exports = function fileItem (props) {
     <div class="uppy-DashboardItem-preview">
     <div class="uppy-DashboardItem-preview">
       <div class="uppy-DashboardItem-previewInnerWrap" style={{ backgroundColor: getFileTypeIcon(file.type).color }}>
       <div class="uppy-DashboardItem-previewInnerWrap" style={{ backgroundColor: getFileTypeIcon(file.type).color }}>
         {props.showLinkToFileUploadResult && file.uploadURL
         {props.showLinkToFileUploadResult && file.uploadURL
-          ? <a class="uppy-DashboardItem-previewLink" href={file.uploadURL} target="_blank" />
+          ? <a class="uppy-DashboardItem-previewLink" href={file.uploadURL} rel="noreferrer noopener" target="_blank" />
           : null
           : null
         }
         }
         <FilePreview file={file} />
         <FilePreview file={file} />
@@ -79,10 +79,11 @@ module.exports = function fileItem (props) {
             title={progressIndicatorTitle}
             title={progressIndicatorTitle}
             onclick={onPauseResumeCancelRetry}>
             onclick={onPauseResumeCancelRetry}>
             {error
             {error
-              ? iconRetry()
+              ? props.hideCancelButton ? null : iconRetry()
               : FileItemProgress({
               : FileItemProgress({
                 progress: file.progress.percentage,
                 progress: file.progress.percentage,
-                fileID: file.id
+                fileID: file.id,
+                hideCancelButton: props.hideCancelButton
               })
               })
             }
             }
           </button>
           </button>
@@ -91,8 +92,8 @@ module.exports = function fileItem (props) {
     </div>
     </div>
     <div class="uppy-DashboardItem-info">
     <div class="uppy-DashboardItem-info">
       <h4 class="uppy-DashboardItem-name" title={fileName}>
       <h4 class="uppy-DashboardItem-name" title={fileName}>
-        {file.uploadURL
-          ? <a href={file.uploadURL} target="_blank">
+        {props.showLinkToFileUploadResult && file.uploadURL
+          ? <a href={file.uploadURL} rel="noreferrer noopener" target="_blank">
             {file.extension ? truncatedFileName + '.' + file.extension : truncatedFileName}
             {file.extension ? truncatedFileName + '.' + file.extension : truncatedFileName}
           </a>
           </a>
           : file.extension ? truncatedFileName + '.' + file.extension : truncatedFileName
           : file.extension ? truncatedFileName + '.' + file.extension : truncatedFileName
@@ -103,7 +104,7 @@ module.exports = function fileItem (props) {
         {file.source && <div class="uppy-DashboardItem-sourceIcon">
         {file.source && <div class="uppy-DashboardItem-sourceIcon">
             {acquirers.map(acquirer => {
             {acquirers.map(acquirer => {
               if (acquirer.id === file.source) {
               if (acquirer.id === file.source) {
-                return <span title={`${props.i18n('fileSource')}: ${acquirer.name}`}>
+                return <span title={props.i18n('fileSource', { name: acquirer.name })}>
                   {acquirer.icon()}
                   {acquirer.icon()}
                 </span>
                 </span>
               }
               }
@@ -121,8 +122,8 @@ module.exports = function fileItem (props) {
         </button>
         </button>
         : null
         : null
       }
       }
-      {file.uploadURL &&
-        <button class="uppy-DashboardItem-copyLink"
+      {props.showLinkToFileUploadResult && file.uploadURL
+        ? <button class="uppy-DashboardItem-copyLink"
           type="button"
           type="button"
           aria-label={props.i18n('copyLink')}
           aria-label={props.i18n('copyLink')}
           title={props.i18n('copyLink')}
           title={props.i18n('copyLink')}
@@ -134,6 +135,7 @@ module.exports = function fileItem (props) {
               })
               })
               .catch(props.log)
               .catch(props.log)
           }}>{iconCopy()}</button>
           }}>{iconCopy()}</button>
+        : ''
       }
       }
     </div>
     </div>
     <div class="uppy-DashboardItem-action">
     <div class="uppy-DashboardItem-action">

+ 3 - 1
src/plugins/Dashboard/FileItemProgress.js

@@ -25,7 +25,9 @@ module.exports = (props) => {
         <rect x="5" y="0" width="2" height="10" rx="0" />
         <rect x="5" y="0" width="2" height="10" rx="0" />
       </g>
       </g>
       <polygon transform="translate(2, 3)" points="14 22.5 7 15.2457065 8.99985857 13.1732815 14 18.3547104 22.9729883 9 25 11.1005634" class="check" />
       <polygon transform="translate(2, 3)" points="14 22.5 7 15.2457065 8.99985857 13.1732815 14 18.3547104 22.9729883 9 25 11.1005634" class="check" />
-      <polygon class="cancel" transform="translate(2, 2)" points="19.8856516 11.0625 16 14.9481516 12.1019737 11.0625 11.0625 12.1143484 14.9481516 16 11.0625 19.8980263 12.1019737 20.9375 16 17.0518484 19.8856516 20.9375 20.9375 19.8980263 17.0518484 16 20.9375 12" />
+      {props.hideCancelButton ? null
+        : <polygon class="cancel" transform="translate(2, 2)" points="19.8856516 11.0625 16 14.9481516 12.1019737 11.0625 11.0625 12.1143484 14.9481516 16 11.0625 19.8980263 12.1019737 20.9375 16 17.0518484 19.8856516 20.9375 20.9375 19.8980263 17.0518484 16 20.9375 12" />
+      }
     </svg>
     </svg>
   )
   )
 }
 }

+ 29 - 24
src/plugins/Dashboard/FileList.js

@@ -15,33 +15,38 @@ module.exports = (props) => {
     {noFiles &&
     {noFiles &&
       <div class="uppy-Dashboard-bgIcon">
       <div class="uppy-Dashboard-bgIcon">
         <div class="uppy-Dashboard-dropFilesTitle">
         <div class="uppy-Dashboard-dropFilesTitle">
-          {h(ActionBrowseTagline, {
-            acquirers: props.acquirers,
-            handleInputChange: props.handleInputChange,
-            i18n: props.i18n
-          })}
+          <ActionBrowseTagline
+            acquirers={props.acquirers}
+            handleInputChange={props.handleInputChange}
+            i18n={props.i18n}
+            i18nArray={props.i18nArray}
+            allowedFileTypes={props.allowedFileTypes}
+            maxNumberOfFiles={props.maxNumberOfFiles}
+          />
         </div>
         </div>
         { props.note && <div class="uppy-Dashboard-note">{props.note}</div> }
         { props.note && <div class="uppy-Dashboard-note">{props.note}</div> }
       </div>
       </div>
     }
     }
-    {Object.keys(props.files).map((fileID) => {
-      return FileItem({
-        acquirers: props.acquirers,
-        file: props.files[fileID],
-        toggleFileCard: props.toggleFileCard,
-        showProgressDetails: props.showProgressDetails,
-        info: props.info,
-        log: props.log,
-        i18n: props.i18n,
-        removeFile: props.removeFile,
-        pauseUpload: props.pauseUpload,
-        cancelUpload: props.cancelUpload,
-        retryUpload: props.retryUpload,
-        resumableUploads: props.resumableUploads,
-        isWide: props.isWide,
-        showLinkToFileUploadResult: props.showLinkToFileUploadResult,
-        metaFields: props.metaFields
-      })
-    })}
+    {Object.keys(props.files).map((fileID) => (
+      <FileItem
+        acquirers={props.acquirers}
+        file={props.files[fileID]}
+        toggleFileCard={props.toggleFileCard}
+        showProgressDetails={props.showProgressDetails}
+        info={props.info}
+        log={props.log}
+        i18n={props.i18n}
+        removeFile={props.removeFile}
+        pauseUpload={props.pauseUpload}
+        cancelUpload={props.cancelUpload}
+        retryUpload={props.retryUpload}
+        hideCancelButton={props.hideCancelButton}
+        hideRetryButton={props.hideRetryButton}
+        resumableUploads={props.resumableUploads}
+        isWide={props.isWide}
+        showLinkToFileUploadResult={props.showLinkToFileUploadResult}
+        metaFields={props.metaFields}
+      />
+    ))}
   </ul>
   </ul>
 }
 }

+ 11 - 9
src/plugins/Dashboard/Tabs.js

@@ -23,33 +23,35 @@ class Tabs extends Component {
             <ActionBrowseTagline
             <ActionBrowseTagline
               acquirers={this.props.acquirers}
               acquirers={this.props.acquirers}
               handleInputChange={this.props.handleInputChange}
               handleInputChange={this.props.handleInputChange}
-              i18n={this.props.i18n} />
+              i18n={this.props.i18n}
+              i18nArray={this.props.i18nArray} />
           </div>
           </div>
         </div>
         </div>
       )
       )
     }
     }
 
 
-    // empty value=""  on file input, so we can select same file
-    // after removing it from Uppy — otherwise OS thinks it’s selected
-
+    // empty value="" on file input, so that the input is cleared after a file is selected,
+    // because Uppy will be handling the upload and so we can select same file
+    // after removing — otherwise browser thinks it’s already selected
     return <div class="uppy-DashboardTabs">
     return <div class="uppy-DashboardTabs">
       <ul class="uppy-DashboardTabs-list" role="tablist">
       <ul class="uppy-DashboardTabs-list" role="tablist">
         <li class="uppy-DashboardTab" role="presentation">
         <li class="uppy-DashboardTab" role="presentation">
           <button type="button"
           <button type="button"
             class="uppy-DashboardTab-btn"
             class="uppy-DashboardTab-btn"
             role="tab"
             role="tab"
-            tabindex="0"
+            tabindex={0}
             onclick={this.handleClick}>
             onclick={this.handleClick}>
             {localIcon()}
             {localIcon()}
             <div class="uppy-DashboardTab-name">{this.props.i18n('myDevice')}</div>
             <div class="uppy-DashboardTab-name">{this.props.i18n('myDevice')}</div>
           </button>
           </button>
           <input class="uppy-Dashboard-input"
           <input class="uppy-Dashboard-input"
-            hidden="true"
+            hidden
             aria-hidden="true"
             aria-hidden="true"
-            tabindex="-1"
+            tabindex={-1}
             type="file"
             type="file"
             name="files[]"
             name="files[]"
-            multiple="true"
+            multiple={this.props.maxNumberOfFiles !== 1}
+            accept={this.props.allowedFileTypes}
             onchange={this.props.handleInputChange}
             onchange={this.props.handleInputChange}
             value=""
             value=""
             ref={(input) => { this.input = input }} />
             ref={(input) => { this.input = input }} />
@@ -59,7 +61,7 @@ class Tabs extends Component {
             <button class="uppy-DashboardTab-btn"
             <button class="uppy-DashboardTab-btn"
               type="button"
               type="button"
               role="tab"
               role="tab"
-              tabindex="0"
+              tabindex={0}
               aria-controls={`uppy-DashboardContent-panel--${target.id}`}
               aria-controls={`uppy-DashboardContent-panel--${target.id}`}
               aria-selected={this.props.activePanel.id === target.id}
               aria-selected={this.props.activePanel.id === target.id}
               onclick={() => this.props.showPanel(target.id)}>
               onclick={() => this.props.showPanel(target.id)}>

+ 25 - 16
src/plugins/Dashboard/index.js

@@ -42,23 +42,25 @@ module.exports = class Dashboard extends Plugin {
         selectToUpload: 'Select files to upload',
         selectToUpload: 'Select files to upload',
         closeModal: 'Close Modal',
         closeModal: 'Close Modal',
         upload: 'Upload',
         upload: 'Upload',
-        importFrom: 'Import from',
+        importFrom: 'Import from %{name}',
         dashboardWindowTitle: 'Uppy Dashboard Window (Press escape to close)',
         dashboardWindowTitle: 'Uppy Dashboard Window (Press escape to close)',
         dashboardTitle: 'Uppy Dashboard',
         dashboardTitle: 'Uppy Dashboard',
-        copyLinkToClipboardSuccess: 'Link copied to clipboard.',
+        copyLinkToClipboardSuccess: 'Link copied to clipboard',
         copyLinkToClipboardFallback: 'Copy the URL below',
         copyLinkToClipboardFallback: 'Copy the URL below',
         copyLink: 'Copy link',
         copyLink: 'Copy link',
-        fileSource: 'File source',
+        fileSource: 'File source: %{name}',
         done: 'Done',
         done: 'Done',
         name: 'Name',
         name: 'Name',
         removeFile: 'Remove file',
         removeFile: 'Remove file',
         editFile: 'Edit file',
         editFile: 'Edit file',
-        editing: 'Editing',
+        editing: 'Editing %{file}',
         finishEditingFile: 'Finish editing file',
         finishEditingFile: 'Finish editing file',
+        saveChanges: 'Save changes',
+        cancel: 'Cancel',
         localDisk: 'Local Disk',
         localDisk: 'Local Disk',
         myDevice: 'My Device',
         myDevice: 'My Device',
-        dropPasteImport: 'Drop files here, paste, import from one of the locations above or',
-        dropPaste: 'Drop files here, paste or',
+        dropPasteImport: 'Drop files here, paste, import from one of the locations above or %{browse}',
+        dropPaste: 'Drop files here, paste or %{browse}',
         browse: 'browse',
         browse: 'browse',
         fileProgress: 'File progress: upload speed and ETA',
         fileProgress: 'File progress: upload speed and ETA',
         numberOfSelectedFiles: 'Number of selected files',
         numberOfSelectedFiles: 'Number of selected files',
@@ -96,6 +98,8 @@ module.exports = class Dashboard extends Plugin {
       showLinkToFileUploadResult: true,
       showLinkToFileUploadResult: true,
       showProgressDetails: false,
       showProgressDetails: false,
       hideUploadButton: false,
       hideUploadButton: false,
+      hideRetryButton: false,
+      hideCancelButton: false,
       hideProgressAfterFinish: false,
       hideProgressAfterFinish: false,
       note: null,
       note: null,
       closeModalOnClickOutside: false,
       closeModalOnClickOutside: false,
@@ -117,6 +121,7 @@ module.exports = class Dashboard extends Plugin {
 
 
     this.translator = new Translator({locale: this.locale})
     this.translator = new Translator({locale: this.locale})
     this.i18n = this.translator.translate.bind(this.translator)
     this.i18n = this.translator.translate.bind(this.translator)
+    this.i18nArray = this.translator.translateArray.bind(this.translator)
 
 
     this.openModal = this.openModal.bind(this)
     this.openModal = this.openModal.bind(this)
     this.closeModal = this.closeModal.bind(this)
     this.closeModal = this.closeModal.bind(this)
@@ -272,8 +277,8 @@ module.exports = class Dashboard extends Plugin {
       this.updateBrowserHistory()
       this.updateBrowserHistory()
     }
     }
 
 
+    this.rerender(this.uppy.getState())
     this.updateDashboardElWidth()
     this.updateDashboardElWidth()
-    // this.setFocusToFirstNode()
     this.setFocusToBrowse()
     this.setFocusToBrowse()
   }
   }
 
 
@@ -335,8 +340,6 @@ module.exports = class Dashboard extends Plugin {
         name: file.name,
         name: file.name,
         type: file.type,
         type: file.type,
         data: blob
         data: blob
-      }).catch(() => {
-        // Ignore
       })
       })
     })
     })
   }
   }
@@ -351,8 +354,6 @@ module.exports = class Dashboard extends Plugin {
         name: file.name,
         name: file.name,
         type: file.type,
         type: file.type,
         data: file
         data: file
-      }).catch(() => {
-        // Ignore
       })
       })
     })
     })
   }
   }
@@ -420,8 +421,6 @@ module.exports = class Dashboard extends Plugin {
         name: file.name,
         name: file.name,
         type: file.type,
         type: file.type,
         data: file
         data: file
-      }).catch(() => {
-        // Ignore
       })
       })
     })
     })
   }
   }
@@ -508,6 +507,8 @@ module.exports = class Dashboard extends Plugin {
       progressindicators: progressindicators,
       progressindicators: progressindicators,
       autoProceed: this.uppy.opts.autoProceed,
       autoProceed: this.uppy.opts.autoProceed,
       hideUploadButton: this.opts.hideUploadButton,
       hideUploadButton: this.opts.hideUploadButton,
+      hideRetryButton: this.opts.hideRetryButton,
+      hideCancelButton: this.opts.hideCancelButton,
       id: this.id,
       id: this.id,
       closeModal: this.requestCloseModal,
       closeModal: this.requestCloseModal,
       handleClickOutside: this.handleClickOutside,
       handleClickOutside: this.handleClickOutside,
@@ -518,6 +519,7 @@ module.exports = class Dashboard extends Plugin {
       hideAllPanels: this.hideAllPanels,
       hideAllPanels: this.hideAllPanels,
       log: this.uppy.log,
       log: this.uppy.log,
       i18n: this.i18n,
       i18n: this.i18n,
+      i18nArray: this.i18nArray,
       addFile: this.uppy.addFile,
       addFile: this.uppy.addFile,
       removeFile: this.uppy.removeFile,
       removeFile: this.uppy.removeFile,
       info: this.uppy.info,
       info: this.uppy.info,
@@ -532,13 +534,15 @@ module.exports = class Dashboard extends Plugin {
       toggleFileCard: this.toggleFileCard,
       toggleFileCard: this.toggleFileCard,
       saveFileCard: saveFileCard,
       saveFileCard: saveFileCard,
       updateDashboardElWidth: this.updateDashboardElWidth,
       updateDashboardElWidth: this.updateDashboardElWidth,
-      maxWidth: this.opts.maxWidth,
-      maxHeight: this.opts.maxHeight,
+      width: this.opts.width,
+      height: this.opts.height,
       showLinkToFileUploadResult: this.opts.showLinkToFileUploadResult,
       showLinkToFileUploadResult: this.opts.showLinkToFileUploadResult,
       proudlyDisplayPoweredByUppy: this.opts.proudlyDisplayPoweredByUppy,
       proudlyDisplayPoweredByUppy: this.opts.proudlyDisplayPoweredByUppy,
       currentWidth: pluginState.containerWidth,
       currentWidth: pluginState.containerWidth,
       isWide: pluginState.containerWidth > 400,
       isWide: pluginState.containerWidth > 400,
-      isTargetDOMEl: this.isTargetDOMEl
+      isTargetDOMEl: this.isTargetDOMEl,
+      allowedFileTypes: this.uppy.opts.restrictions.allowedFileTypes,
+      maxNumberOfFiles: this.uppy.opts.restrictions.maxNumberOfFiles
     })
     })
   }
   }
 
 
@@ -573,8 +577,11 @@ module.exports = class Dashboard extends Plugin {
 
 
     if (!this.opts.disableStatusBar) {
     if (!this.opts.disableStatusBar) {
       this.uppy.use(StatusBar, {
       this.uppy.use(StatusBar, {
+        id: `${this.id}:StatusBar`,
         target: this,
         target: this,
         hideUploadButton: this.opts.hideUploadButton,
         hideUploadButton: this.opts.hideUploadButton,
+        hideRetryButton: this.opts.hideRetryButton,
+        hideCancelButton: this.opts.hideCancelButton,
         showProgressDetails: this.opts.showProgressDetails,
         showProgressDetails: this.opts.showProgressDetails,
         hideAfterFinish: this.opts.hideProgressAfterFinish,
         hideAfterFinish: this.opts.hideProgressAfterFinish,
         locale: this.opts.locale
         locale: this.opts.locale
@@ -583,12 +590,14 @@ module.exports = class Dashboard extends Plugin {
 
 
     if (!this.opts.disableInformer) {
     if (!this.opts.disableInformer) {
       this.uppy.use(Informer, {
       this.uppy.use(Informer, {
+        id: `${this.id}:Informer`,
         target: this
         target: this
       })
       })
     }
     }
 
 
     if (!this.opts.disableThumbnailGenerator) {
     if (!this.opts.disableThumbnailGenerator) {
       this.uppy.use(ThumbnailGenerator, {
       this.uppy.use(ThumbnailGenerator, {
+        id: `${this.id}:ThumbnailGenerator`,
         thumbnailWidth: this.opts.thumbnailWidth
         thumbnailWidth: this.opts.thumbnailWidth
       })
       })
     }
     }

+ 14 - 0
src/plugins/Dashboard/index.test.js

@@ -0,0 +1,14 @@
+const Core = require('../../core')
+const DashboardPlugin = require('./index')
+const StatusBarPlugin = require('../StatusBar')
+
+describe('Dashboard', () => {
+  it('can safely be added together with the StatusBar without id conflicts', () => {
+    const core = new Core()
+    core.use(StatusBarPlugin)
+
+    expect(() => {
+      core.use(DashboardPlugin, { inline: false })
+    }).not.toThrow()
+  })
+})

+ 34 - 23
src/plugins/DragDrop/index.js

@@ -17,7 +17,7 @@ module.exports = class DragDrop extends Plugin {
 
 
     const defaultLocale = {
     const defaultLocale = {
       strings: {
       strings: {
-        dropHereOr: 'Drop files here or',
+        dropHereOr: 'Drop files here or %{browse}',
         browse: 'browse'
         browse: 'browse'
       }
       }
     }
     }
@@ -25,9 +25,10 @@ module.exports = class DragDrop extends Plugin {
     // Default options
     // Default options
     const defaultOpts = {
     const defaultOpts = {
       target: null,
       target: null,
+      inputName: 'files[]',
       width: '100%',
       width: '100%',
       height: '100%',
       height: '100%',
-      note: '',
+      note: null,
       locale: defaultLocale
       locale: defaultLocale
     }
     }
 
 
@@ -43,10 +44,10 @@ module.exports = class DragDrop extends Plugin {
     // i18n
     // i18n
     this.translator = new Translator({locale: this.locale})
     this.translator = new Translator({locale: this.locale})
     this.i18n = this.translator.translate.bind(this.translator)
     this.i18n = this.translator.translate.bind(this.translator)
+    this.i18nArray = this.translator.translateArray.bind(this.translator)
 
 
     // Bind `this` to class methods
     // Bind `this` to class methods
     this.handleDrop = this.handleDrop.bind(this)
     this.handleDrop = this.handleDrop.bind(this)
-    this.handleBrowseClick = this.handleBrowseClick.bind(this)
     this.handleInputChange = this.handleInputChange.bind(this)
     this.handleInputChange = this.handleInputChange.bind(this)
     this.checkDragDropSupport = this.checkDragDropSupport.bind(this)
     this.checkDragDropSupport = this.checkDragDropSupport.bind(this)
     this.render = this.render.bind(this)
     this.render = this.render.bind(this)
@@ -83,8 +84,6 @@ module.exports = class DragDrop extends Plugin {
         name: file.name,
         name: file.name,
         type: file.type,
         type: file.type,
         data: file
         data: file
-      }).catch(() => {
-        // Ignore
       })
       })
     })
     })
   }
   }
@@ -100,39 +99,51 @@ module.exports = class DragDrop extends Plugin {
         name: file.name,
         name: file.name,
         type: file.type,
         type: file.type,
         data: file
         data: file
-      }).catch(() => {
-        // Ignore
       })
       })
     })
     })
   }
   }
 
 
-  handleBrowseClick (ev) {
-    ev.stopPropagation()
-    this.input.click()
-  }
-
   render (state) {
   render (state) {
-    const DragDropClass = `uppy uppy-DragDrop-container ${this.isDragDropSupported ? 'is-dragdrop-supported' : ''}`
+    /* http://tympanus.net/codrops/2015/09/15/styling-customizing-file-inputs-smart-way/ */
+    const hiddenInputStyle = {
+      width: '0.1px',
+      height: '0.1px',
+      opacity: 0,
+      overflow: 'hidden',
+      position: 'absolute',
+      zIndex: -1
+    }
+    const DragDropClass = `uppy-Root uppy-DragDrop-container ${this.isDragDropSupported ? 'uppy-DragDrop--is-dragdrop-supported' : ''}`
     const DragDropStyle = {
     const DragDropStyle = {
       width: this.opts.width,
       width: this.opts.width,
       height: this.opts.height
       height: this.opts.height
     }
     }
+    const restrictions = this.uppy.opts.restrictions
+
+    // empty value="" on file input, so that the input is cleared after a file is selected,
+    // because Uppy will be handling the upload and so we can select same file
+    // after removing — otherwise browser thinks it’s already selected
     return (
     return (
       <div class={DragDropClass} style={DragDropStyle}>
       <div class={DragDropClass} style={DragDropStyle}>
         <div class="uppy-DragDrop-inner">
         <div class="uppy-DragDrop-inner">
           <svg aria-hidden="true" class="UppyIcon uppy-DragDrop-arrow" width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
           <svg aria-hidden="true" class="UppyIcon uppy-DragDrop-arrow" width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
             <path d="M11 10V0H5v10H2l6 6 6-6h-3zm0 0" fill-rule="evenodd" />
             <path d="M11 10V0H5v10H2l6 6 6-6h-3zm0 0" fill-rule="evenodd" />
           </svg>
           </svg>
-          <input class="uppy-DragDrop-input"
-            type="file"
-            name="files[]"
-            multiple="true"
-            ref={(input) => {
-              this.input = input
-            }}
-            onchange={this.handleInputChange} />
-          <label class="uppy-DragDrop-label" onclick={this.handleBrowseClick}>
-            {this.i18n('dropHereOr')} <span class="uppy-DragDrop-dragText">{this.i18n('browse')}</span>
+          <label class="uppy-DragDrop-label">
+            <input style={hiddenInputStyle}
+              class="uppy-DragDrop-input"
+              type="file"
+              name={this.opts.inputName}
+              multiple={restrictions.maxNumberOfFiles !== 1}
+              accept={restrictions.allowedFileTypes}
+              ref={(input) => {
+                this.input = input
+              }}
+              onchange={this.handleInputChange}
+              value="" />
+            {this.i18nArray('dropHereOr', {
+              browse: <span class="uppy-DragDrop-dragText">{this.i18n('browse')}</span>
+            })}
           </label>
           </label>
           <span class="uppy-DragDrop-note">{this.opts.note}</span>
           <span class="uppy-DragDrop-note">{this.opts.note}</span>
         </div>
         </div>

+ 6 - 6
src/plugins/Dropbox/icons.js

@@ -1,14 +1,14 @@
 const { h } = require('preact')
 const { h } = require('preact')
 
 
 module.exports = {
 module.exports = {
-  folder: () => {
-    return <svg aria-hidden="true" class="UppyIcon" style="width:16px;margin-right:3px" viewBox="0 0 276.157 276.157">
+  folder: () => (
+    <svg aria-hidden="true" class="UppyIcon" style={{ width: 16, marginRight: 3 }} viewBox="0 0 276.157 276.157">
       <path d="M273.08 101.378c-3.3-4.65-8.86-7.32-15.254-7.32h-24.34V67.59c0-10.2-8.3-18.5-18.5-18.5h-85.322c-3.63 0-9.295-2.875-11.436-5.805l-6.386-8.735c-4.982-6.814-15.104-11.954-23.546-11.954H58.73c-9.292 0-18.638 6.608-21.737 15.372l-2.033 5.752c-.958 2.71-4.72 5.37-7.596 5.37H18.5C8.3 49.09 0 57.39 0 67.59v167.07c0 .886.16 1.73.443 2.52.152 3.306 1.18 6.424 3.053 9.064 3.3 4.652 8.86 7.32 15.255 7.32h188.487c11.395 0 23.27-8.425 27.035-19.18l40.677-116.188c2.11-6.035 1.43-12.164-1.87-16.816zM18.5 64.088h8.864c9.295 0 18.64-6.607 21.738-15.37l2.032-5.75c.96-2.712 4.722-5.373 7.597-5.373h29.565c3.63 0 9.295 2.876 11.437 5.806l6.386 8.735c4.982 6.815 15.104 11.954 23.546 11.954h85.322c1.898 0 3.5 1.602 3.5 3.5v26.47H69.34c-11.395 0-23.27 8.423-27.035 19.178L15 191.23V67.59c0-1.898 1.603-3.5 3.5-3.5zm242.29 49.15l-40.676 116.188c-1.674 4.78-7.812 9.135-12.877 9.135H18.75c-1.447 0-2.576-.372-3.02-.997-.442-.625-.422-1.814.057-3.18l40.677-116.19c1.674-4.78 7.812-9.134 12.877-9.134h188.487c1.448 0 2.577.372 3.02.997.443.625.423 1.814-.056 3.18z" />
       <path d="M273.08 101.378c-3.3-4.65-8.86-7.32-15.254-7.32h-24.34V67.59c0-10.2-8.3-18.5-18.5-18.5h-85.322c-3.63 0-9.295-2.875-11.436-5.805l-6.386-8.735c-4.982-6.814-15.104-11.954-23.546-11.954H58.73c-9.292 0-18.638 6.608-21.737 15.372l-2.033 5.752c-.958 2.71-4.72 5.37-7.596 5.37H18.5C8.3 49.09 0 57.39 0 67.59v167.07c0 .886.16 1.73.443 2.52.152 3.306 1.18 6.424 3.053 9.064 3.3 4.652 8.86 7.32 15.255 7.32h188.487c11.395 0 23.27-8.425 27.035-19.18l40.677-116.188c2.11-6.035 1.43-12.164-1.87-16.816zM18.5 64.088h8.864c9.295 0 18.64-6.607 21.738-15.37l2.032-5.75c.96-2.712 4.722-5.373 7.597-5.373h29.565c3.63 0 9.295 2.876 11.437 5.806l6.386 8.735c4.982 6.815 15.104 11.954 23.546 11.954h85.322c1.898 0 3.5 1.602 3.5 3.5v26.47H69.34c-11.395 0-23.27 8.423-27.035 19.178L15 191.23V67.59c0-1.898 1.603-3.5 3.5-3.5zm242.29 49.15l-40.676 116.188c-1.674 4.78-7.812 9.135-12.877 9.135H18.75c-1.447 0-2.576-.372-3.02-.997-.442-.625-.422-1.814.057-3.18l40.677-116.19c1.674-4.78 7.812-9.134 12.877-9.134h188.487c1.448 0 2.577.372 3.02.997.443.625.423 1.814-.056 3.18z" />
     </svg>
     </svg>
-  },
-  file: () => {
-    return <svg aria-hidden="true" class="UppyIcon" width="11" height="14.5" viewBox="0 0 44 58">
+  ),
+  file: () => (
+    <svg aria-hidden="true" class="UppyIcon" width={11} height={14.5} viewBox="0 0 44 58">
       <path d="M27.437.517a1 1 0 0 0-.094.03H4.25C2.037.548.217 2.368.217 4.58v48.405c0 2.212 1.82 4.03 4.03 4.03H39.03c2.21 0 4.03-1.818 4.03-4.03V15.61a1 1 0 0 0-.03-.28 1 1 0 0 0 0-.093 1 1 0 0 0-.03-.032 1 1 0 0 0 0-.03 1 1 0 0 0-.032-.063 1 1 0 0 0-.03-.063 1 1 0 0 0-.032 0 1 1 0 0 0-.03-.063 1 1 0 0 0-.032-.03 1 1 0 0 0-.03-.063 1 1 0 0 0-.063-.062l-14.593-14a1 1 0 0 0-.062-.062A1 1 0 0 0 28 .708a1 1 0 0 0-.374-.157 1 1 0 0 0-.156 0 1 1 0 0 0-.03-.03l-.003-.003zM4.25 2.547h22.218v9.97c0 2.21 1.82 4.03 4.03 4.03h10.564v36.438a2.02 2.02 0 0 1-2.032 2.032H4.25c-1.13 0-2.032-.9-2.032-2.032V4.58c0-1.13.902-2.032 2.03-2.032zm24.218 1.345l10.375 9.937.75.718H30.5c-1.13 0-2.032-.9-2.032-2.03V3.89z" />
       <path d="M27.437.517a1 1 0 0 0-.094.03H4.25C2.037.548.217 2.368.217 4.58v48.405c0 2.212 1.82 4.03 4.03 4.03H39.03c2.21 0 4.03-1.818 4.03-4.03V15.61a1 1 0 0 0-.03-.28 1 1 0 0 0 0-.093 1 1 0 0 0-.03-.032 1 1 0 0 0 0-.03 1 1 0 0 0-.032-.063 1 1 0 0 0-.03-.063 1 1 0 0 0-.032 0 1 1 0 0 0-.03-.063 1 1 0 0 0-.032-.03 1 1 0 0 0-.03-.063 1 1 0 0 0-.063-.062l-14.593-14a1 1 0 0 0-.062-.062A1 1 0 0 0 28 .708a1 1 0 0 0-.374-.157 1 1 0 0 0-.156 0 1 1 0 0 0-.03-.03l-.003-.003zM4.25 2.547h22.218v9.97c0 2.21 1.82 4.03 4.03 4.03h10.564v36.438a2.02 2.02 0 0 1-2.032 2.032H4.25c-1.13 0-2.032-.9-2.032-2.032V4.58c0-1.13.902-2.032 2.03-2.032zm24.218 1.345l10.375 9.937.75.718H30.5c-1.13 0-2.032-.9-2.032-2.03V3.89z" />
     </svg>
     </svg>
-  }
+  )
 }
 }

+ 4 - 0
src/plugins/Dropbox/index.js

@@ -68,6 +68,10 @@ module.exports = class Dropbox extends Plugin {
     }
     }
   }
   }
 
 
+  getUsername (data) {
+    return data.user_email
+  }
+
   isFolder (item) {
   isFolder (item) {
     return item['.tag'] === 'folder'
     return item['.tag'] === 'folder'
   }
   }

+ 1 - 3
src/plugins/Dummy.js

@@ -35,9 +35,7 @@ module.exports = class Dummy extends Plugin {
       data: blob
       data: blob
     }
     }
     this.props.log('Adding fake file blob')
     this.props.log('Adding fake file blob')
-    this.props.addFile(file).catch(() => {
-      // Ignore
-    })
+    this.props.addFile(file)
   }
   }
 
 
   render (state) {
   render (state) {

+ 11 - 6
src/plugins/FileInput.js

@@ -19,7 +19,6 @@ module.exports = class FileInput extends Plugin {
     // Default options
     // Default options
     const defaultOptions = {
     const defaultOptions = {
       target: null,
       target: null,
-      allowMultipleFiles: true,
       pretty: true,
       pretty: true,
       inputName: 'files[]',
       inputName: 'files[]',
       locale: defaultLocale
       locale: defaultLocale
@@ -51,8 +50,6 @@ module.exports = class FileInput extends Plugin {
         name: file.name,
         name: file.name,
         type: file.type,
         type: file.type,
         data: file
         data: file
-      }).catch(() => {
-        // Ignore
       })
       })
     })
     })
   }
   }
@@ -62,6 +59,7 @@ module.exports = class FileInput extends Plugin {
   }
   }
 
 
   render (state) {
   render (state) {
+    /* http://tympanus.net/codrops/2015/09/15/styling-customizing-file-inputs-smart-way/ */
     const hiddenInputStyle = {
     const hiddenInputStyle = {
       width: '0.1px',
       width: '0.1px',
       height: '0.1px',
       height: '0.1px',
@@ -71,14 +69,21 @@ module.exports = class FileInput extends Plugin {
       zIndex: -1
       zIndex: -1
     }
     }
 
 
-    return <div class="uppy uppy-FileInput-container">
+    const restrictions = this.uppy.opts.restrictions
+
+    // empty value="" on file input, so that the input is cleared after a file is selected,
+    // because Uppy will be handling the upload and so we can select same file
+    // after removing — otherwise browser thinks it’s already selected
+    return <div class="uppy-Root uppy-FileInput-container">
       <input class="uppy-FileInput-input"
       <input class="uppy-FileInput-input"
         style={this.opts.pretty && hiddenInputStyle}
         style={this.opts.pretty && hiddenInputStyle}
         type="file"
         type="file"
         name={this.opts.inputName}
         name={this.opts.inputName}
         onchange={this.handleInputChange}
         onchange={this.handleInputChange}
-        multiple={this.opts.allowMultipleFiles}
-        ref={(input) => { this.input = input }} />
+        multiple={restrictions.maxNumberOfFiles !== 1}
+        accept={restrictions.allowedFileTypes}
+        ref={(input) => { this.input = input }}
+        value="" />
       {this.opts.pretty &&
       {this.opts.pretty &&
         <button class="uppy-FileInput-btn" type="button" onclick={this.handleClick}>
         <button class="uppy-FileInput-btn" type="button" onclick={this.handleClick}>
           {this.i18n('chooseFiles')}
           {this.i18n('chooseFiles')}

+ 3 - 1
src/plugins/Form.js

@@ -53,7 +53,9 @@ module.exports = class Form extends Plugin {
   handleFormSubmit (ev) {
   handleFormSubmit (ev) {
     if (this.opts.triggerUploadOnSubmit) {
     if (this.opts.triggerUploadOnSubmit) {
       ev.preventDefault()
       ev.preventDefault()
-      this.uppy.upload()
+      this.uppy.upload().catch((err) => {
+        this.uppy.log(err.stack || err.message || err)
+      })
     }
     }
   }
   }
 
 

+ 12 - 0
src/plugins/GoogleDrive/index.js

@@ -63,6 +63,18 @@ module.exports = class GoogleDrive extends Plugin {
     }
     }
   }
   }
 
 
+  getUsername (data) {
+    for (const item of data.items) {
+      if (item.userPermission.role === 'owner') {
+        for (const owner of item.owners) {
+          if (owner.isAuthenticatedUser) {
+            return owner.emailAddress
+          }
+        }
+      }
+    }
+  }
+
   isFolder (item) {
   isFolder (item) {
     return item.mimeType === 'application/vnd.google-apps.folder'
     return item.mimeType === 'application/vnd.google-apps.folder'
   }
   }

+ 6 - 1
src/plugins/Instagram/index.js

@@ -71,6 +71,10 @@ module.exports = class Instagram extends Plugin {
     }
     }
   }
   }
 
 
+  getUsername (data) {
+    return data.data[0].user.username
+  }
+
   isFolder (item) {
   isFolder (item) {
     return false
     return false
   }
   }
@@ -102,6 +106,7 @@ module.exports = class Instagram extends Plugin {
 
 
   getItemName (item) {
   getItemName (item) {
     if (item && item['created_time']) {
     if (item && item['created_time']) {
+      const ext = item.type === 'video' ? 'mp4' : 'jpeg'
       let date = new Date(item['created_time'] * 1000)
       let date = new Date(item['created_time'] * 1000)
       date = date.toLocaleDateString([], {
       date = date.toLocaleDateString([], {
         year: 'numeric',
         year: 'numeric',
@@ -111,7 +116,7 @@ module.exports = class Instagram extends Plugin {
         minute: 'numeric'
         minute: 'numeric'
       })
       })
       // adding both date and carousel_id, so the name is unique
       // adding both date and carousel_id, so the name is unique
-      return `Instagram ${date} ${item.carousel_id || ''}`
+      return `Instagram ${date} ${item.carousel_id || ''}.${ext}`
     }
     }
     return ''
     return ''
   }
   }

+ 9 - 9
src/plugins/StatusBar/StatusBar.js

@@ -99,8 +99,8 @@ module.exports = (props) => {
       {progressBarContent}
       {progressBarContent}
       <div class="uppy-StatusBar-actions">
       <div class="uppy-StatusBar-actions">
         { props.newFiles && !props.hideUploadButton ? <UploadBtn {...props} uploadState={uploadState} /> : null }
         { props.newFiles && !props.hideUploadButton ? <UploadBtn {...props} uploadState={uploadState} /> : null }
-        { props.error ? <RetryBtn {...props} /> : null }
-        { uploadState !== statusBarStates.STATE_WAITING && uploadState !== statusBarStates.STATE_COMPLETE
+        { props.error && !props.hideRetryButton ? <RetryBtn {...props} /> : null }
+        { !props.hideCancelButton && uploadState !== statusBarStates.STATE_WAITING && uploadState !== statusBarStates.STATE_COMPLETE
           ? <CancelBtn {...props} />
           ? <CancelBtn {...props} />
           : null
           : null
         }
         }
@@ -141,7 +141,7 @@ const CancelBtn = (props) => {
   return <button type="button"
   return <button type="button"
     class="uppy-u-reset uppy-c-btn uppy-StatusBar-actionBtn uppy-StatusBar-actionBtn--cancel"
     class="uppy-u-reset uppy-c-btn uppy-StatusBar-actionBtn uppy-StatusBar-actionBtn--cancel"
     aria-label={props.i18n('cancel')}
     aria-label={props.i18n('cancel')}
-    onclick={props.cancelAll}>Cancel{props.i18n('cancel')}</button>
+    onclick={props.cancelAll}>{props.i18n('cancel')}</button>
 }
 }
 
 
 const PauseResumeButtons = (props) => {
 const PauseResumeButtons = (props) => {
@@ -161,7 +161,7 @@ const PauseResumeButtons = (props) => {
         : <svg aria-hidden="true" class="UppyIcon" width="16" height="17" viewBox="0 0 12 13">
         : <svg aria-hidden="true" class="UppyIcon" width="16" height="17" viewBox="0 0 12 13">
           <path d="M4.888.81v11.38c0 .446-.324.81-.722.81H2.722C2.324 13 2 12.636 2 12.19V.81c0-.446.324-.81.722-.81h1.444c.398 0 .722.364.722.81zM9.888.81v11.38c0 .446-.324.81-.722.81H7.722C7.324 13 7 12.636 7 12.19V.81c0-.446.324-.81.722-.81h1.444c.398 0 .722.364.722.81z" />
           <path d="M4.888.81v11.38c0 .446-.324.81-.722.81H2.722C2.324 13 2 12.636 2 12.19V.81c0-.446.324-.81.722-.81h1.444c.398 0 .722.364.722.81zM9.888.81v11.38c0 .446-.324.81-.722.81H7.722C7.324 13 7 12.636 7 12.19V.81c0-.446.324-.81.722-.81h1.444c.398 0 .722.364.722.81z" />
         </svg>
         </svg>
-      : <svg aria-hidden="true" class="UppyIcon" width="16px" height="16px" viewBox="0 0 19 19">
+      : props.hideCancelButton ? null : <svg aria-hidden="true" class="UppyIcon" width="16px" height="16px" viewBox="0 0 19 19">
         <path d="M17.318 17.232L9.94 9.854 9.586 9.5l-.354.354-7.378 7.378h.707l-.62-.62v.706L9.318 9.94l.354-.354-.354-.354L1.94 1.854v.707l.62-.62h-.706l7.378 7.378.354.354.354-.354 7.378-7.378h-.707l.622.62v-.706L9.854 9.232l-.354.354.354.354 7.378 7.378.708-.707-7.38-7.378v.708l7.38-7.38.353-.353-.353-.353-.622-.622-.353-.353-.354.352-7.378 7.38h.708L2.56 1.23 2.208.88l-.353.353-.622.62-.353.355.352.353 7.38 7.38v-.708l-7.38 7.38-.353.353.352.353.622.622.353.353.354-.353 7.38-7.38h-.708l7.38 7.38z" />
         <path d="M17.318 17.232L9.94 9.854 9.586 9.5l-.354.354-7.378 7.378h.707l-.62-.62v.706L9.318 9.94l.354-.354-.354-.354L1.94 1.854v.707l.62-.62h-.706l7.378 7.378.354.354.354-.354 7.378-7.378h-.707l.622.62v-.706L9.854 9.232l-.354.354.354.354 7.378 7.378.708-.707-7.38-7.378v.708l7.38-7.38.353-.353-.353-.353-.622-.622-.353-.353-.354.352-7.378 7.38h.708L2.56 1.23 2.208.88l-.353.353-.622.62-.353.355.352.353 7.38 7.38v-.708l-7.38 7.38-.353.353.352.353.622.622.353.353.354-.353 7.38-7.38h-.708l7.38 7.38z" />
       </svg>
       </svg>
     }
     }
@@ -172,15 +172,15 @@ const ProgressBarProcessing = (props) => {
   const value = Math.round(props.value * 100)
   const value = Math.round(props.value * 100)
 
 
   return <div class="uppy-StatusBar-content">
   return <div class="uppy-StatusBar-content">
-    {props.mode === 'determinate' ? `${value}%` : ''}
+    {props.mode === 'determinate' ? `${value}% \u00B7 ` : ''}
     {props.message}
     {props.message}
   </div>
   </div>
 }
 }
 
 
 const progressDetails = (props) => {
 const progressDetails = (props) => {
   return <span class="uppy-StatusBar-statusSecondary">
   return <span class="uppy-StatusBar-statusSecondary">
-    { props.inProgress > 1 && props.i18n('filesUploadedOfTotal', { complete: props.complete, smart_count: props.inProgress }) + '' }
-    { props.i18n('dataUploadedOfTotal', { complete: props.totalUploadedSize, total: props.totalSize }) }
+    { props.inProgress > 1 && props.i18n('filesUploadedOfTotal', { complete: props.complete, smart_count: props.inProgress }) + ' \u00B7 ' }
+    { props.i18n('dataUploadedOfTotal', { complete: props.totalUploadedSize, total: props.totalSize }) + ' \u00B7 ' }
     { props.i18n('xTimeLeft', { time: props.totalETA }) }
     { props.i18n('xTimeLeft', { time: props.totalETA }) }
   </span>
   </span>
 }
 }
@@ -218,8 +218,8 @@ const ProgressBarComplete = ({ totalProgress, i18n }) => {
 const ProgressBarError = ({ error, retryAll, i18n }) => {
 const ProgressBarError = ({ error, retryAll, i18n }) => {
   return (
   return (
     <div class="uppy-StatusBar-content" role="alert">
     <div class="uppy-StatusBar-content" role="alert">
-      <strong>{i18n('uploadFailed')}.</strong>
-      <span>{i18n('pleasePressRetry')}</span>
+      <strong class="uppy-StatusBar-contentPadding">{i18n('uploadFailed')}.</strong>
+      <span class="uppy-StatusBar-contentPadding">{i18n('pleasePressRetry')}</span>
       <span class="uppy-StatusBar-details"
       <span class="uppy-StatusBar-details"
         aria-label={error}
         aria-label={error}
         data-microtip-position="top"
         data-microtip-position="top"

+ 7 - 1
src/plugins/StatusBar/index.js

@@ -27,6 +27,7 @@ module.exports = class StatusBar extends Plugin {
         paused: 'Paused',
         paused: 'Paused',
         error: 'Error',
         error: 'Error',
         retry: 'Retry',
         retry: 'Retry',
+        cancel: 'Cancel',
         pressToRetry: 'Press to retry',
         pressToRetry: 'Press to retry',
         retryUpload: 'Retry upload',
         retryUpload: 'Retry upload',
         resumeUpload: 'Resume upload',
         resumeUpload: 'Resume upload',
@@ -53,6 +54,8 @@ module.exports = class StatusBar extends Plugin {
     const defaultOptions = {
     const defaultOptions = {
       target: 'body',
       target: 'body',
       hideUploadButton: false,
       hideUploadButton: false,
+      hideRetryButton: false,
+      hideCancelButton: false,
       showProgressDetails: false,
       showProgressDetails: false,
       locale: defaultLocale,
       locale: defaultLocale,
       hideAfterFinish: true
       hideAfterFinish: true
@@ -94,7 +97,8 @@ module.exports = class StatusBar extends Plugin {
   }
   }
 
 
   startUpload () {
   startUpload () {
-    return this.uppy.upload().catch(() => {
+    return this.uppy.upload().catch((err) => {
+      this.uppy.log(err.stack || err.message || err)
       // Ignore
       // Ignore
     })
     })
   }
   }
@@ -215,6 +219,8 @@ module.exports = class StatusBar extends Plugin {
       resumableUploads: resumableUploads,
       resumableUploads: resumableUploads,
       showProgressDetails: this.opts.showProgressDetails,
       showProgressDetails: this.opts.showProgressDetails,
       hideUploadButton: this.opts.hideUploadButton,
       hideUploadButton: this.opts.hideUploadButton,
+      hideRetryButton: this.opts.hideRetryButton,
+      hideCancelButton: this.opts.hideCancelButton,
       hideAfterFinish: this.opts.hideAfterFinish
       hideAfterFinish: this.opts.hideAfterFinish
     })
     })
   }
   }

+ 55 - 19
src/plugins/Transloadit/index.js

@@ -12,6 +12,10 @@ function defaultGetAssemblyOptions (file, options) {
   }
   }
 }
 }
 
 
+const UPPY_SERVER = 'https://api2.transloadit.com/uppy-server'
+// Regex used to check if an uppy-server address is run by Transloadit.
+const TL_UPPY_SERVER = /https?:\/\/api2(?:-\w+)?\.transloadit\.com\/uppy-server/
+
 /**
 /**
  * Upload files to Transloadit using Tus.
  * Upload files to Transloadit using Tus.
  */
  */
@@ -53,6 +57,7 @@ module.exports = class Transloadit extends Plugin {
 
 
     this.prepareUpload = this.prepareUpload.bind(this)
     this.prepareUpload = this.prepareUpload.bind(this)
     this.afterUpload = this.afterUpload.bind(this)
     this.afterUpload = this.afterUpload.bind(this)
+    this.handleError = this.handleError.bind(this)
     this.onFileUploadURLAvailable = this.onFileUploadURLAvailable.bind(this)
     this.onFileUploadURLAvailable = this.onFileUploadURLAvailable.bind(this)
     this.onRestored = this.onRestored.bind(this)
     this.onRestored = this.onRestored.bind(this)
     this.getPersistentData = this.getPersistentData.bind(this)
     this.getPersistentData = this.getPersistentData.bind(this)
@@ -180,23 +185,19 @@ module.exports = class Transloadit extends Plugin {
           endpoint: assembly.tus_url
           endpoint: assembly.tus_url
         })
         })
 
 
-        // Set uppy server location.
-        // we only add this, if 'file' has the attribute remote, because
-        // this is the criteria to identify remote files. If we add it without
-        // the check, then the file automatically becomes a remote file.
-        // @TODO: this is quite hacky. Please fix this later
-        let remote
-        if (file.remote) {
+        // Set uppy server location. We only add this, if 'file' has the attribute
+        // remote, because this is the criteria to identify remote files.
+        // We only replace the hostname for Transloadit's uppy-servers, so that
+        // people can self-host them while still using Transloadit for encoding.
+        let remote = file.remote
+        if (file.remote && TL_UPPY_SERVER.test(file.remote)) {
           let newHost = assembly.uppyserver_url
           let newHost = assembly.uppyserver_url
-          // remove tailing slash
-          if (newHost.endsWith('/')) {
-            newHost = newHost.slice(0, -1)
-          }
           let path = file.remote.url.replace(file.remote.host, '')
           let path = file.remote.url.replace(file.remote.host, '')
+          // remove tailing slash
+          newHost = newHost.replace(/\/$/, '')
           // remove leading slash
           // remove leading slash
-          if (path.startsWith('/')) {
-            path = path.slice(1)
-          }
+          path = path.replace(/^\//, '')
+
           remote = Object.assign({}, file.remote, {
           remote = Object.assign({}, file.remote, {
             host: newHost,
             host: newHost,
             url: `${newHost}/${path}`
             url: `${newHost}/${path}`
@@ -227,7 +228,7 @@ module.exports = class Transloadit extends Plugin {
       return this.connectSocket(assembly)
       return this.connectSocket(assembly)
         .then(() => assembly)
         .then(() => assembly)
     }).then((assembly) => {
     }).then((assembly) => {
-      this.uppy.log('[Transloadit] Created Assembly')
+      this.uppy.log(`[Transloadit] Created Assembly ${assembly.assembly_id}`)
       return assembly
       return assembly
     }).catch((err) => {
     }).catch((err) => {
       this.uppy.info(this.i18n('creatingAssemblyFailed'), 'error', 0)
       this.uppy.info(this.i18n('creatingAssemblyFailed'), 'error', 0)
@@ -617,7 +618,7 @@ module.exports = class Transloadit extends Plugin {
 
 
     let optionsPromise
     let optionsPromise
     if (fileIDs.length > 0) {
     if (fileIDs.length > 0) {
-      optionsPromise = this.getAssemblyOptions(fileIDs)
+      optionsPromise = Promise.resolve(this.getAssemblyOptions(fileIDs))
         .then((allOptions) => this.dedupeAssemblyOptions(allOptions))
         .then((allOptions) => this.dedupeAssemblyOptions(allOptions))
     } else if (this.opts.alwaysRunAssembly) {
     } else if (this.opts.alwaysRunAssembly) {
       optionsPromise = Promise.resolve(
       optionsPromise = Promise.resolve(
@@ -634,9 +635,21 @@ module.exports = class Transloadit extends Plugin {
       return Promise.resolve()
       return Promise.resolve()
     }
     }
 
 
-    return optionsPromise.then((assemblies) => Promise.all(
-      assemblies.map(createAssembly)
-    ))
+    return optionsPromise.then(
+      (assemblies) => Promise.all(
+        assemblies.map(createAssembly)
+      ),
+      // If something went wrong before any assemblies could be created,
+      // clear all processing state.
+      (err) => {
+        fileIDs.forEach((fileID) => {
+          const file = this.uppy.getFile(fileID)
+          this.uppy.emit('preprocess-complete', file)
+          this.uppy.emit('upload-error', file, err)
+        })
+        throw err
+      }
+    )
   }
   }
 
 
   afterUpload (fileIDs, uploadID) {
   afterUpload (fileIDs, uploadID) {
@@ -771,10 +784,27 @@ module.exports = class Transloadit extends Plugin {
     })
     })
   }
   }
 
 
+  handleError (err, uploadID) {
+    this.uppy.log('[Transloadit] handleError')
+    this.uppy.log(err)
+    this.uppy.log(uploadID)
+    const state = this.getPluginState()
+    const assemblyIDs = state.uploadsAssemblies[uploadID]
+
+    assemblyIDs.forEach((assemblyID) => {
+      if (this.sockets[assemblyID]) {
+        this.sockets[assemblyID].close()
+      }
+    })
+  }
+
   install () {
   install () {
     this.uppy.addPreProcessor(this.prepareUpload)
     this.uppy.addPreProcessor(this.prepareUpload)
     this.uppy.addPostProcessor(this.afterUpload)
     this.uppy.addPostProcessor(this.afterUpload)
 
 
+    // We may need to close socket.io connections on error.
+    this.uppy.on('error', this.handleError)
+
     if (this.opts.importFromUploadURLs) {
     if (this.opts.importFromUploadURLs) {
       // No uploader needed when importing; instead we take the upload URL from an existing uploader.
       // No uploader needed when importing; instead we take the upload URL from an existing uploader.
       this.uppy.on('upload-success', this.onFileUploadURLAvailable)
       this.uppy.on('upload-success', this.onFileUploadURLAvailable)
@@ -783,6 +813,9 @@ module.exports = class Transloadit extends Plugin {
         // Disable tus-js-client fingerprinting, otherwise uploading the same file at different times
         // Disable tus-js-client fingerprinting, otherwise uploading the same file at different times
         // will upload to the same assembly.
         // will upload to the same assembly.
         resume: false,
         resume: false,
+        // Disable Uppy Server's retry optimisation; we need to change the endpoint on retry
+        // so it can't just reuse the same tus.Upload instance server-side.
+        useFastRemoteRetry: false,
         // Only send assembly metadata to the tus endpoint.
         // Only send assembly metadata to the tus endpoint.
         metaFields: ['assembly_url', 'filename', 'fieldname']
         metaFields: ['assembly_url', 'filename', 'fieldname']
       })
       })
@@ -806,6 +839,7 @@ module.exports = class Transloadit extends Plugin {
   uninstall () {
   uninstall () {
     this.uppy.removePreProcessor(this.prepareUpload)
     this.uppy.removePreProcessor(this.prepareUpload)
     this.uppy.removePostProcessor(this.afterUpload)
     this.uppy.removePostProcessor(this.afterUpload)
+    this.uppy.off('error', this.handleError)
 
 
     if (this.opts.importFromUploadURLs) {
     if (this.opts.importFromUploadURLs) {
       this.uppy.off('upload-success', this.onFileUploadURLAvailable)
       this.uppy.off('upload-success', this.onFileUploadURLAvailable)
@@ -826,3 +860,5 @@ module.exports = class Transloadit extends Plugin {
     })
     })
   }
   }
 }
 }
+
+module.exports.UPPY_SERVER = UPPY_SERVER

+ 76 - 30
src/plugins/Transloadit/index.test.js

@@ -45,15 +45,14 @@ describe('Transloadit', () => {
 
 
     const data = Buffer.alloc(4000)
     const data = Buffer.alloc(4000)
     data.size = data.byteLength
     data.size = data.byteLength
-    return uppy.addFile({
+    uppy.addFile({
       name: 'testfile',
       name: 'testfile',
       data
       data
-    }).then(() => {
-      return uppy.upload().then(() => {
-        throw new Error('should have rejected')
-      }, (err) => {
-        expect(err.message).toMatch(/The `params\.auth\.key` option is required/)
-      })
+    })
+    return uppy.upload().then(() => {
+      throw new Error('should have rejected')
+    }, (err) => {
+      expect(err.message).toMatch(/The `params\.auth\.key` option is required/)
     })
     })
   })
   })
 
 
@@ -84,17 +83,15 @@ describe('Transloadit', () => {
     const data = Buffer.alloc(10)
     const data = Buffer.alloc(10)
     data.size = data.byteLength
     data.size = data.byteLength
 
 
-    return Promise.all([
-      uppy.addFile({ name: 'a.png', data }),
-      uppy.addFile({ name: 'b.png', data }),
-      uppy.addFile({ name: 'c.png', data }),
-      uppy.addFile({ name: 'd.png', data })
-    ]).then(() => {
-      return uppy.upload().then(() => {
-        throw new Error('upload should have been rejected')
-      }, () => {
-        expect(i).toBe(4)
-      })
+    uppy.addFile({ name: 'a.png', data })
+    uppy.addFile({ name: 'b.png', data })
+    uppy.addFile({ name: 'c.png', data })
+    uppy.addFile({ name: 'd.png', data })
+
+    return uppy.upload().then(() => {
+      throw new Error('upload should have been rejected')
+    }, () => {
+      expect(i).toBe(4)
     })
     })
   })
   })
 
 
@@ -131,17 +128,15 @@ describe('Transloadit', () => {
     const data2 = Buffer.alloc(20)
     const data2 = Buffer.alloc(20)
     data2.size = data2.byteLength
     data2.size = data2.byteLength
 
 
-    return Promise.all([
-      uppy.addFile({ name: 'a.png', data }),
-      uppy.addFile({ name: 'b.png', data }),
-      uppy.addFile({ name: 'c.png', data }),
-      uppy.addFile({ name: 'd.png', data: data2 })
-    ]).then(() => {
-      return uppy.upload().then(() => {
-        throw new Error('Upload should have been rejected')
-      }, () => {
-        expect(i).toBe(2)
-      })
+    uppy.addFile({ name: 'a.png', data })
+    uppy.addFile({ name: 'b.png', data })
+    uppy.addFile({ name: 'c.png', data })
+    uppy.addFile({ name: 'd.png', data: data2 })
+
+    return uppy.upload().then(() => {
+      throw new Error('Upload should have been rejected')
+    }, () => {
+      expect(i).toBe(2)
     })
     })
   })
   })
 
 
@@ -152,7 +147,6 @@ describe('Transloadit', () => {
         throw new Error('should not create Assembly')
         throw new Error('should not create Assembly')
       }
       }
     })
     })
-    uppy.run()
 
 
     return uppy.upload()
     return uppy.upload()
   })
   })
@@ -170,4 +164,56 @@ describe('Transloadit', () => {
 
 
     return expect(uppy.upload()).rejects.toEqual(new Error('short-circuited'))
     return expect(uppy.upload()).rejects.toEqual(new Error('short-circuited'))
   })
   })
+
+  it('Does not leave lingering progress if getAssemblyOptions fails', () => {
+    const uppy = new Core()
+    uppy.use(Transloadit, {
+      getAssemblyOptions (file) {
+        return Promise.reject(new Error('Failure!'))
+      }
+    })
+
+    uppy.addFile({
+      source: 'jest',
+      name: 'abc',
+      data: new Uint8Array(100)
+    })
+
+    return uppy.upload().then(() => {
+      throw new Error('Should not have succeeded')
+    }, (err) => {
+      const fileID = Object.keys(uppy.getState().files)[0]
+
+      expect(err.message).toBe('Failure!')
+      expect(uppy.getFile(fileID).progress.uploadStarted).toBe(false)
+    })
+  })
+
+  it('Does not leave lingering progress if creating assembly fails', () => {
+    const uppy = new Core()
+    uppy.use(Transloadit, {
+      params: {
+        auth: { key: 'some auth key string' },
+        template_id: 'some template id string'
+      }
+    })
+
+    uppy.getPlugin('Transloadit').client.createAssembly = () =>
+      Promise.reject(new Error('Could not create assembly!'))
+
+    uppy.addFile({
+      source: 'jest',
+      name: 'abc',
+      data: new Uint8Array(100)
+    })
+
+    return uppy.upload().then(() => {
+      throw new Error('Should not have succeeded')
+    }, (err) => {
+      const fileID = Object.keys(uppy.getState().files)[0]
+
+      expect(err.message).toBe('Could not create assembly!')
+      expect(uppy.getFile(fileID).progress.uploadStarted).toBe(false)
+    })
+  })
 })
 })

+ 58 - 25
src/plugins/Tus.js

@@ -4,7 +4,8 @@ const UppySocket = require('../core/UppySocket')
 const {
 const {
   emitSocketProgress,
   emitSocketProgress,
   getSocketHost,
   getSocketHost,
-  settle
+  settle,
+  limitPromises
 } = require('../core/Utils')
 } = require('../core/Utils')
 require('whatwg-fetch')
 require('whatwg-fetch')
 
 
@@ -60,12 +61,21 @@ module.exports = class Tus extends Plugin {
     const defaultOptions = {
     const defaultOptions = {
       resume: true,
       resume: true,
       autoRetry: true,
       autoRetry: true,
+      useFastRemoteRetry: true,
+      limit: 0,
       retryDelays: [0, 1000, 3000, 5000]
       retryDelays: [0, 1000, 3000, 5000]
     }
     }
 
 
     // merge default options with the ones set by user
     // merge default options with the ones set by user
     this.opts = Object.assign({}, defaultOptions, opts)
     this.opts = Object.assign({}, defaultOptions, opts)
 
 
+    // Simultaneous upload limiting is shared across all uploads with this plugin.
+    if (typeof this.opts.limit === 'number' && this.opts.limit !== 0) {
+      this.limitUploads = limitPromises(this.opts.limit)
+    } else {
+      this.limitUploads = (fn) => fn
+    }
+
     this.uploaders = Object.create(null)
     this.uploaders = Object.create(null)
     this.uploaderEvents = Object.create(null)
     this.uploaderEvents = Object.create(null)
     this.uploaderSockets = Object.create(null)
     this.uploaderSockets = Object.create(null)
@@ -156,7 +166,21 @@ module.exports = class Tus extends Plugin {
         this.resetUploaderReferences(file.id)
         this.resetUploaderReferences(file.id)
         resolve(upload)
         resolve(upload)
       }
       }
-      optsTus.metadata = file.meta
+
+      const copyProp = (obj, srcProp, destProp) => {
+        if (
+          Object.prototype.hasOwnProperty.call(obj, srcProp) &&
+          !Object.prototype.hasOwnProperty.call(obj, destProp)
+        ) {
+          obj[destProp] = obj[srcProp]
+        }
+      }
+
+      // tusd uses metadata fields 'filetype' and 'filename'
+      const meta = Object.assign({}, file.meta)
+      copyProp(meta, 'type', 'filetype')
+      copyProp(meta, 'name', 'filename')
+      optsTus.metadata = meta
 
 
       const upload = new tus.Upload(file.data, optsTus)
       const upload = new tus.Upload(file.data, optsTus)
       this.uploaders[file.id] = upload
       this.uploaders[file.id] = upload
@@ -193,9 +217,6 @@ module.exports = class Tus extends Plugin {
       if (!file.isPaused) {
       if (!file.isPaused) {
         upload.start()
         upload.start()
       }
       }
-      if (!file.isRestored) {
-        this.uppy.emit('upload-started', file, upload)
-      }
     })
     })
   }
   }
 
 
@@ -217,8 +238,6 @@ module.exports = class Tus extends Plugin {
           .catch(reject)
           .catch(reject)
       }
       }
 
 
-      this.uppy.emit('upload-started', file)
-
       fetch(file.remote.url, {
       fetch(file.remote.url, {
         method: 'post',
         method: 'post',
         credentials: 'include',
         credentials: 'include',
@@ -241,7 +260,7 @@ module.exports = class Tus extends Plugin {
 
 
         return res.json().then((data) => {
         return res.json().then((data) => {
           this.uppy.setFileState(file.id, { serverToken: data.token })
           this.uppy.setFileState(file.id, { serverToken: data.token })
-          file = this.getFile(file.id)
+          file = this.uppy.getFile(file.id)
           return file
           return file
         })
         })
       })
       })
@@ -302,8 +321,21 @@ module.exports = class Tus extends Plugin {
       socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file))
       socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file))
 
 
       socket.on('error', (errData) => {
       socket.on('error', (errData) => {
-        this.uppy.emit('upload-error', file, new Error(errData.error))
-        reject(new Error(errData.error))
+        const { message } = errData.error
+        const error = Object.assign(new Error(message), { cause: errData.error })
+
+        // If the remote retry optimisation should not be used,
+        // close the socket—this will tell uppy-server to clear state and delete the file.
+        if (!this.opts.useFastRemoteRetry) {
+          this.resetUploaderReferences(file.id)
+          // Remove the serverToken so that a new one will be created for the retry.
+          this.uppy.setFileState(file.id, {
+            serverToken: null
+          })
+        }
+
+        this.uppy.emit('upload-error', file, error)
+        reject(error)
       })
       })
 
 
       socket.on('success', (data) => {
       socket.on('success', (data) => {
@@ -314,10 +346,6 @@ module.exports = class Tus extends Plugin {
     })
     })
   }
   }
 
 
-  getFile (fileID) {
-    return this.uppy.state.files[fileID]
-  }
-
   updateFile (file) {
   updateFile (file) {
     const files = Object.assign({}, this.uppy.state.files, {
     const files = Object.assign({}, this.uppy.state.files, {
       [file.id]: file
       [file.id]: file
@@ -326,7 +354,7 @@ module.exports = class Tus extends Plugin {
   }
   }
 
 
   onReceiveUploadUrl (file, uploadURL) {
   onReceiveUploadUrl (file, uploadURL) {
-    const currentFile = this.getFile(file.id)
+    const currentFile = this.uppy.getFile(file.id)
     if (!currentFile) return
     if (!currentFile) return
     // Only do the update if we didn't have an upload URL yet,
     // Only do the update if we didn't have an upload URL yet,
     // or resume: false in options
     // or resume: false in options
@@ -393,23 +421,28 @@ module.exports = class Tus extends Plugin {
   }
   }
 
 
   uploadFiles (files) {
   uploadFiles (files) {
-    const promises = files.map((file, index) => {
-      const current = parseInt(index, 10) + 1
+    const actions = files.map((file, i) => {
+      const current = parseInt(i, 10) + 1
       const total = files.length
       const total = files.length
 
 
       if (file.error) {
       if (file.error) {
-        return Promise.reject(new Error(file.error))
-      }
-
-      this.uppy.log(`uploading ${current} of ${total}`)
-
-      if (file.isRemote) {
-        return this.uploadRemote(file, current, total)
+        return () => Promise.reject(new Error(file.error))
+      } else if (file.isRemote) {
+        // We emit upload-started here, so that it's also emitted for files
+        // that have to wait due to the `limit` option.
+        this.uppy.emit('upload-started', file)
+        return this.uploadRemote.bind(this, file, current, total)
       } else {
       } else {
-        return this.upload(file, current, total)
+        this.uppy.emit('upload-started', file)
+        return this.upload.bind(this, file, current, total)
       }
       }
     })
     })
 
 
+    const promises = actions.map((action) => {
+      const limitedAction = this.limitUploads(action)
+      return limitedAction()
+    })
+
     return settle(promises)
     return settle(promises)
   }
   }
 
 

+ 62 - 0
src/plugins/Url/index.js

@@ -3,6 +3,7 @@ const Translator = require('../../core/Translator')
 const { h } = require('preact')
 const { h } = require('preact')
 const { RequestClient } = require('../../server')
 const { RequestClient } = require('../../server')
 const UrlUI = require('./UrlUI.js')
 const UrlUI = require('./UrlUI.js')
+const { toArray } = require('../../core/Utils')
 require('whatwg-fetch')
 require('whatwg-fetch')
 
 
 /**
 /**
@@ -54,6 +55,11 @@ module.exports = class Url extends Plugin {
     // Bind all event handlers for referencability
     // Bind all event handlers for referencability
     this.getMeta = this.getMeta.bind(this)
     this.getMeta = this.getMeta.bind(this)
     this.addFile = this.addFile.bind(this)
     this.addFile = this.addFile.bind(this)
+    this.handleDrop = this.handleDrop.bind(this)
+    this.handleDragOver = this.handleDragOver.bind(this)
+    this.handleDragLeave = this.handleDragLeave.bind(this)
+
+    this.handlePaste = this.handlePaste.bind(this)
 
 
     this.server = new RequestClient(uppy, {host: this.opts.host})
     this.server = new RequestClient(uppy, {host: this.opts.host})
   }
   }
@@ -144,6 +150,52 @@ module.exports = class Url extends Plugin {
       })
       })
   }
   }
 
 
+  handleDrop (e) {
+    e.preventDefault()
+    if (e.dataTransfer.items) {
+      const items = toArray(e.dataTransfer.items)
+      items.forEach((item) => {
+        if (item.kind === 'string' && item.type === 'text/uri-list') {
+          item.getAsString((url) => {
+            this.uppy.log(`[URL] Adding file from dropped url: ${url}`)
+            this.addFile(url)
+          })
+        }
+      })
+    }
+  }
+
+  handleDragOver (e) {
+    e.preventDefault()
+    this.el.classList.add('drag')
+  }
+
+  handleDragLeave (e) {
+    e.preventDefault()
+    this.el.classList.remove('drag')
+  }
+
+  handlePaste (e) {
+    if (e.clipboardData.items) {
+      const items = toArray(e.clipboardData.items)
+
+      // When a file is pasted, it appears as two items: file name string, then
+      // the file itself; Url then treats file name string as URL, which is wrong.
+      // This makes sure Url ignores paste event if it contains an actual file
+      const hasFiles = items.filter(item => item.kind === 'file').length > 0
+      if (hasFiles) return
+
+      items.forEach((item) => {
+        if (item.kind === 'string' && item.type === 'text/plain') {
+          item.getAsString((url) => {
+            this.uppy.log(`[URL] Adding file from pasted url: ${url}`)
+            this.addFile(url)
+          })
+        }
+      })
+    }
+  }
+
   render (state) {
   render (state) {
     return <UrlUI
     return <UrlUI
       i18n={this.i18n}
       i18n={this.i18n}
@@ -155,9 +207,19 @@ module.exports = class Url extends Plugin {
     if (target) {
     if (target) {
       this.mount(target, this)
       this.mount(target, this)
     }
     }
+
+    this.el.addEventListener('drop', this.handleDrop)
+    this.el.addEventListener('dragover', this.handleDragOver)
+    this.el.addEventListener('dragleave', this.handleDragLeave)
+    this.el.addEventListener('paste', this.handlePaste)
   }
   }
 
 
   uninstall () {
   uninstall () {
+    this.el.removeEventListener('drop', this.handleDrop)
+    this.el.removeEventListener('dragover', this.handleDragOver)
+    this.el.removeEventListener('dragleave', this.handleDragLeave)
+    this.el.removeEventListener('paste', this.handlePaste)
+
     this.unmount()
     this.unmount()
   }
   }
 }
 }

+ 1 - 3
src/plugins/Webcam/index.js

@@ -238,9 +238,7 @@ module.exports = class Webcam extends Plugin {
       this.captureInProgress = false
       this.captureInProgress = false
       const dashboard = this.uppy.getPlugin('Dashboard')
       const dashboard = this.uppy.getPlugin('Dashboard')
       if (dashboard) dashboard.hideAllPanels()
       if (dashboard) dashboard.hideAllPanels()
-      return this.uppy.addFile(tagFile).catch(() => {
-        // Ignore
-      })
+      return this.uppy.addFile(tagFile)
     }, (error) => {
     }, (error) => {
       this.captureInProgress = false
       this.captureInProgress = false
       throw error
       throw error

+ 13 - 11
src/plugins/XHRUpload.js

@@ -55,25 +55,25 @@ module.exports = class XHRUpload extends Plugin {
        * @property {string} statusText
        * @property {string} statusText
        * @property {Object.<string, string>} headers
        * @property {Object.<string, string>} headers
        *
        *
-       * @param {string} responseContent the response body
-       * @param {XMLHttpRequest | respObj} responseObject the response object
+       * @param {string} responseText the response body string
+       * @param {XMLHttpRequest | respObj} response the response object (XHR or similar)
        */
        */
-      getResponseData (responseContent, responseObject) {
-        let response = {}
+      getResponseData (responseText, response) {
+        let parsedResponse = {}
         try {
         try {
-          response = JSON.parse(responseContent)
+          parsedResponse = JSON.parse(responseText)
         } catch (err) {
         } catch (err) {
           console.log(err)
           console.log(err)
         }
         }
 
 
-        return response
+        return parsedResponse
       },
       },
       /**
       /**
        *
        *
-       * @param {string} responseContent the response body
-       * @param {XMLHttpRequest | respObj} responseObject the response object
+       * @param {string} responseText the response body string
+       * @param {XMLHttpRequest | respObj} response the response object (XHR or similar)
        */
        */
-      getResponseError (responseContent, responseObject) {
+      getResponseError (responseText, response) {
         return new Error('Upload error')
         return new Error('Upload error')
       }
       }
     }
     }
@@ -338,9 +338,11 @@ module.exports = class XHRUpload extends Plugin {
 
 
           socket.on('error', (errData) => {
           socket.on('error', (errData) => {
             const resp = errData.response
             const resp = errData.response
-            const error = resp ? opts.getResponseError(resp.responseText, resp) : new Error(errData.error)
+            const error = resp
+              ? opts.getResponseError(resp.responseText, resp)
+              : Object.assign(new Error(errData.error.message), { cause: errData.error })
             this.uppy.emit('upload-error', file, error)
             this.uppy.emit('upload-error', file, error)
-            reject(new Error(errData.error))
+            reject(error)
           })
           })
         })
         })
       })
       })

+ 4 - 15
src/react/Dashboard.js

@@ -1,7 +1,6 @@
 const React = require('react')
 const React = require('react')
-const PropTypes = require('prop-types')
-const UppyCore = require('../core/Core').Uppy
 const DashboardPlugin = require('../plugins/Dashboard')
 const DashboardPlugin = require('../plugins/Dashboard')
+const basePropTypes = require('./propTypes').dashboard
 
 
 const h = React.createElement
 const h = React.createElement
 
 
@@ -14,7 +13,7 @@ class Dashboard extends React.Component {
   componentDidMount () {
   componentDidMount () {
     const uppy = this.props.uppy
     const uppy = this.props.uppy
     const options = Object.assign(
     const options = Object.assign(
-      {},
+      { id: 'react:Dashboard' },
       this.props,
       this.props,
       { target: this.container }
       { target: this.container }
     )
     )
@@ -39,18 +38,8 @@ class Dashboard extends React.Component {
   }
   }
 }
 }
 
 
-Dashboard.propTypes = {
-  uppy: PropTypes.instanceOf(UppyCore).isRequired,
-  plugins: PropTypes.arrayOf(PropTypes.string),
-  inline: PropTypes.bool,
-  maxWidth: PropTypes.number,
-  maxHeight: PropTypes.number,
-  semiTransparent: PropTypes.bool,
-  showProgressDetails: PropTypes.bool,
-  hideUploadButton: PropTypes.bool,
-  note: PropTypes.string,
-  locale: PropTypes.object
-}
+Dashboard.propTypes = basePropTypes
+
 Dashboard.defaultProps = {
 Dashboard.defaultProps = {
   inline: true
   inline: true
 }
 }

+ 13 - 13
src/react/DashboardModal.js

@@ -1,7 +1,7 @@
 const React = require('react')
 const React = require('react')
 const PropTypes = require('prop-types')
 const PropTypes = require('prop-types')
-const UppyCore = require('../core/Core').Uppy
 const DashboardPlugin = require('../plugins/Dashboard')
 const DashboardPlugin = require('../plugins/Dashboard')
+const basePropTypes = require('./propTypes').dashboard
 
 
 const h = React.createElement
 const h = React.createElement
 
 
@@ -14,13 +14,17 @@ class DashboardModal extends React.Component {
   componentDidMount () {
   componentDidMount () {
     const uppy = this.props.uppy
     const uppy = this.props.uppy
     const options = Object.assign(
     const options = Object.assign(
-      {},
+      { id: 'react:DashboardModal' },
       this.props,
       this.props,
       {
       {
-        target: this.container,
         onRequestCloseModal: this.props.onRequestClose
         onRequestCloseModal: this.props.onRequestClose
       }
       }
     )
     )
+
+    if (!options.target) {
+      options.target = this.container
+    }
+
     delete options.uppy
     delete options.uppy
     uppy.use(DashboardPlugin, options)
     uppy.use(DashboardPlugin, options)
 
 
@@ -53,18 +57,14 @@ class DashboardModal extends React.Component {
   }
   }
 }
 }
 
 
-DashboardModal.propTypes = {
-  uppy: PropTypes.instanceOf(UppyCore).isRequired,
+DashboardModal.propTypes = Object.assign({
+  // Only check this prop type in the browser.
+  target: typeof window !== 'undefined' ? PropTypes.instanceOf(window.HTMLElement) : PropTypes.any,
   open: PropTypes.bool,
   open: PropTypes.bool,
   onRequestClose: PropTypes.func,
   onRequestClose: PropTypes.func,
-  plugins: PropTypes.arrayOf(PropTypes.string),
-  maxWidth: PropTypes.number,
-  maxHeight: PropTypes.number,
-  showProgressDetails: PropTypes.bool,
-  hideUploadButton: PropTypes.bool,
-  note: PropTypes.string,
-  locale: PropTypes.object
-}
+  closeModalOnClickOutside: PropTypes.bool,
+  disablePageScrollWhenModalOpen: PropTypes.bool
+}, basePropTypes)
 
 
 DashboardModal.defaultProps = {
 DashboardModal.defaultProps = {
 }
 }

+ 4 - 5
src/react/DragDrop.js

@@ -1,7 +1,6 @@
 const React = require('react')
 const React = require('react')
-const PropTypes = require('prop-types')
-const UppyCore = require('../core').Uppy
 const DragDropPlugin = require('../plugins/DragDrop')
 const DragDropPlugin = require('../plugins/DragDrop')
+const propTypes = require('./propTypes')
 
 
 const h = React.createElement
 const h = React.createElement
 
 
@@ -14,7 +13,7 @@ class DragDrop extends React.Component {
   componentDidMount () {
   componentDidMount () {
     const uppy = this.props.uppy
     const uppy = this.props.uppy
     const options = Object.assign(
     const options = Object.assign(
-      {},
+      { id: 'react:DragDrop' },
       this.props,
       this.props,
       { target: this.container }
       { target: this.container }
     )
     )
@@ -41,8 +40,8 @@ class DragDrop extends React.Component {
 }
 }
 
 
 DragDrop.propTypes = {
 DragDrop.propTypes = {
-  uppy: PropTypes.instanceOf(UppyCore).isRequired,
-  locale: PropTypes.object
+  uppy: propTypes.uppy,
+  locale: propTypes.locale
 }
 }
 DragDrop.defaultProps = {
 DragDrop.defaultProps = {
 }
 }

+ 5 - 4
src/react/ProgressBar.js

@@ -1,7 +1,7 @@
 const React = require('react')
 const React = require('react')
 const PropTypes = require('prop-types')
 const PropTypes = require('prop-types')
-const UppyCore = require('../core').Uppy
 const ProgressBarPlugin = require('../plugins/ProgressBar')
 const ProgressBarPlugin = require('../plugins/ProgressBar')
+const uppyPropType = require('./propTypes').uppy
 
 
 const h = React.createElement
 const h = React.createElement
 
 
@@ -13,7 +13,7 @@ class ProgressBar extends React.Component {
   componentDidMount () {
   componentDidMount () {
     const uppy = this.props.uppy
     const uppy = this.props.uppy
     const options = Object.assign(
     const options = Object.assign(
-      {},
+      { id: 'react:ProgressBar' },
       this.props,
       this.props,
       { target: this.container }
       { target: this.container }
     )
     )
@@ -40,8 +40,9 @@ class ProgressBar extends React.Component {
 }
 }
 
 
 ProgressBar.propTypes = {
 ProgressBar.propTypes = {
-  uppy: PropTypes.instanceOf(UppyCore).isRequired,
-  fixed: PropTypes.bool
+  uppy: uppyPropType,
+  fixed: PropTypes.bool,
+  hideAfterFinish: PropTypes.bool
 }
 }
 ProgressBar.defaultProps = {
 ProgressBar.defaultProps = {
 }
 }

+ 5 - 3
src/react/StatusBar.js

@@ -1,7 +1,7 @@
 const React = require('react')
 const React = require('react')
 const PropTypes = require('prop-types')
 const PropTypes = require('prop-types')
-const UppyCore = require('../core').Uppy
 const StatusBarPlugin = require('../plugins/StatusBar')
 const StatusBarPlugin = require('../plugins/StatusBar')
+const uppyPropType = require('./propTypes').uppy
 
 
 const h = React.createElement
 const h = React.createElement
 
 
@@ -14,7 +14,7 @@ class StatusBar extends React.Component {
   componentDidMount () {
   componentDidMount () {
     const uppy = this.props.uppy
     const uppy = this.props.uppy
     const options = Object.assign(
     const options = Object.assign(
-      {},
+      { id: 'react:StatusBar' },
       this.props,
       this.props,
       { target: this.container }
       { target: this.container }
     )
     )
@@ -41,7 +41,9 @@ class StatusBar extends React.Component {
 }
 }
 
 
 StatusBar.propTypes = {
 StatusBar.propTypes = {
-  uppy: PropTypes.instanceOf(UppyCore).isRequired
+  uppy: uppyPropType,
+  hideAfterFinish: PropTypes.bool,
+  showProgressDetails: PropTypes.bool
 }
 }
 StatusBar.defaultProps = {
 StatusBar.defaultProps = {
 }
 }

+ 2 - 2
src/react/Wrapper.js

@@ -1,6 +1,6 @@
 const React = require('react')
 const React = require('react')
 const PropTypes = require('prop-types')
 const PropTypes = require('prop-types')
-const UppyCore = require('../core').Uppy
+const uppyPropType = require('./propTypes').uppy
 
 
 const h = React.createElement
 const h = React.createElement
 
 
@@ -35,7 +35,7 @@ class UppyWrapper extends React.Component {
 }
 }
 
 
 UppyWrapper.propTypes = {
 UppyWrapper.propTypes = {
-  uppy: PropTypes.instanceOf(UppyCore).isRequired,
+  uppy: uppyPropType,
   plugin: PropTypes.string.isRequired
   plugin: PropTypes.string.isRequired
 }
 }
 
 

+ 47 - 0
src/react/propTypes.js

@@ -0,0 +1,47 @@
+const PropTypes = require('prop-types')
+const UppyCore = require('../core').Uppy
+
+// The `uppy` prop receives the Uppy core instance.
+const uppy = PropTypes.instanceOf(UppyCore).isRequired
+
+// A list of plugins to mount inside this component.
+const plugins = PropTypes.arrayOf(PropTypes.string)
+
+// Language strings for this component.
+const locale = PropTypes.shape({
+  strings: PropTypes.object,
+  pluralize: PropTypes.func
+})
+
+// List of meta fields for the editor in the Dashboard.
+const metaField = PropTypes.shape({
+  id: PropTypes.string.isRequired,
+  name: PropTypes.string.isRequired,
+  placeholder: PropTypes.string
+})
+const metaFields = PropTypes.arrayOf(metaField)
+
+// Common props for dashboardy components (Dashboard and DashboardModal).
+const dashboard = {
+  uppy,
+  inline: PropTypes.bool,
+  plugins,
+  width: PropTypes.number,
+  height: PropTypes.number,
+  showProgressDetails: PropTypes.bool,
+  hideUploadButton: PropTypes.bool,
+  hideProgressAfterFinish: PropTypes.bool,
+  note: PropTypes.string,
+  metaFields,
+  proudlyDisplayPoweredByUppy: PropTypes.bool,
+  disableStatusBar: PropTypes.bool,
+  disableInformer: PropTypes.bool,
+  disableThumbnailGenerator: PropTypes.bool,
+  locale
+}
+
+module.exports = {
+  uppy,
+  locale,
+  dashboard
+}

+ 7 - 10
src/scss/_common.scss

@@ -5,13 +5,10 @@
 .uppy-Root {
 .uppy-Root {
   all: initial;
   all: initial;
   box-sizing: border-box;
   box-sizing: border-box;
-  font-family: -apple-system, BlinkMacSystemFont,
-    'avenir next', avenir,
-    helvetica, 'helvetica neue',
-    ubuntu, roboto, noto,
-    'segoe ui', arial, sans-serif;
+  font-family: system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, 
+    Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
   line-height: 1;
   line-height: 1;
-  // -webkit-font-smoothing: antialiased;
+  -webkit-font-smoothing: antialiased;
 }
 }
 
 
 .uppy-Root *, .uppy-Root *:before, .uppy-Root *:after {
 .uppy-Root *, .uppy-Root *:before, .uppy-Root *:after {
@@ -188,7 +185,7 @@
   .uppy-c-textInput:focus {
   .uppy-c-textInput:focus {
     border-color: $color-cornflower-blue;
     border-color: $color-cornflower-blue;
     outline: none;
     outline: none;
-    box-shadow: 0 0 1px 1px rgba($color-cornflower-blue, 0.3);
+    box-shadow: 0 0 1px 1px rgba($color-cornflower-blue, 0.5);
   }
   }
 
 
 // Buttons
 // Buttons
@@ -201,7 +198,7 @@
   font-family: inherit;
   font-family: inherit;
   font-size: 16px;
   font-size: 16px;
   line-height: 1;
   line-height: 1;
-  font-weight: 300;
+  font-weight: 500;
   transition: all 0.3s;
   transition: all 0.3s;
   user-select: none;
   user-select: none;
 }
 }
@@ -230,7 +227,7 @@
 
 
   .uppy-c-btn-primary:focus {
   .uppy-c-btn-primary:focus {
     outline: none;
     outline: none;
-    box-shadow: 0 0 3px 1px rgba($color-cornflower-blue, 0.7);
+    box-shadow: 0 0 0 3px rgba($color-cornflower-blue, 0.5);
   }
   }
 
 
 .uppy-c-btn-link {
 .uppy-c-btn-link {
@@ -254,7 +251,7 @@
 
 
   .uppy-c-btn-link:focus {
   .uppy-c-btn-link:focus {
     outline: none;
     outline: none;
-    box-shadow: 0 0 3px 1px rgba($color-cornflower-blue, 0.7);
+    box-shadow: 0 0 0 0.2rem rgba($color-cornflower-blue, 0.5);
   }
   }
 
 
 .uppy-c-btn--small {
 .uppy-c-btn--small {

+ 24 - 20
src/scss/_dashboard.scss

@@ -30,35 +30,35 @@
 .uppy-Dashboard-inner {
 .uppy-Dashboard-inner {
   position: relative;
   position: relative;
   background-color: darken($color-white, 2%);
   background-color: darken($color-white, 2%);
-  // background-color: $color-white;
-  max-width: 100%;
-  max-height: 100%;
-  width: 100%;
-  height: 100%;
-  // overflow: hidden;
+  max-width: 100%; /* no !important */
+  max-height: 100%; /* no !important */
+  width: 100%; /* no !important */
+  height: 100%; /* no !important */
+  min-width: 300px;
+  min-height: 400px;
   outline: none;
   outline: none;
   border: 1px solid rgba($color-gray, 0.2);
   border: 1px solid rgba($color-gray, 0.2);
+  margin-bottom: 30px;
 
 
   .uppy-Dashboard--modal & {
   .uppy-Dashboard--modal & {
     z-index: $zIndex-3;
     z-index: $zIndex-3;
   }
   }
 
 
   @media #{$screen-medium} {
   @media #{$screen-medium} {
-    width: 750px;
-    height: 550px;
+    width: 750px; /* no !important */
+    height: 550px; /* no !important */
     border-radius: 5px;
     border-radius: 5px;
   }
   }
 }
 }
 
 
 .uppy-Dashboard-poweredBy {
 .uppy-Dashboard-poweredBy {
-  position: absolute;
-  right: 4px;
-  bottom: -23px;
+  display: block;
   font-size: 11px;
   font-size: 11px;
-  font-weight: 300;
   color: rgba($color-gray, 0.8);
   color: rgba($color-gray, 0.8);
   text-align: right;
   text-align: right;
   text-decoration: none;
   text-decoration: none;
+  padding-top: 8px;
+  padding-right: 2px;
 }
 }
 
 
   .uppy-Dashboard--modal .uppy-Dashboard-poweredBy {
   .uppy-Dashboard--modal .uppy-Dashboard-poweredBy {
@@ -77,11 +77,14 @@
   stroke: $color-gray;
   stroke: $color-gray;
   fill: none;
   fill: none;
   margin-left: 1px;
   margin-left: 1px;
-  margin-right: 2px;
+  margin-right: 1px;
+  position: relative;
+  top: 1px;
+  opacity: 0.9;
 }
 }
 
 
   .uppy-Dashboard--modal .uppy-Dashboard-poweredByIcon {
   .uppy-Dashboard--modal .uppy-Dashboard-poweredByIcon {
-    stroke: none;
+    stroke: transparent;
     fill: $color-uppy-pink;
     fill: $color-uppy-pink;
   }
   }
 
 
@@ -218,7 +221,7 @@
   font-size: 8px;
   font-size: 8px;
   margin-top: 5px;
   margin-top: 5px;
   margin-bottom: 0;
   margin-bottom: 0;
-  font-weight: normal;
+  font-weight: 500;
   overflow-x: hidden;
   overflow-x: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
   white-space: nowrap;
   white-space: nowrap;
@@ -301,7 +304,7 @@
   // left: 15px;
   // left: 15px;
   font-size: 14px;
   font-size: 14px;
   // line-height: 40px;
   // line-height: 40px;
-  font-weight: 400;
+  font-weight: 500;
   cursor: pointer;
   cursor: pointer;
   color: $color-cornflower-blue;
   color: $color-cornflower-blue;
 
 
@@ -465,7 +468,7 @@
   text-align: center;
   text-align: center;
   font-size: 18px;
   font-size: 18px;
   line-height: 1.45;
   line-height: 1.45;
-  font-weight: 300;
+  font-weight: 400;
   color: rgba($color-asphalt-gray, 0.8);
   color: rgba($color-asphalt-gray, 0.8);
   padding: 0 15px;
   padding: 0 15px;
   // margin: 0;
   // margin: 0;
@@ -583,7 +586,7 @@
     bottom: 0;
     bottom: 0;
     left: 0;
     left: 0;
     right: 0;
     right: 0;
-    background-color: rgba($color-black, 0.65);
+    background-color: rgba($color-black, 0.65) /* no !important */;
     display: none;
     display: none;
     z-index: $zIndex-2;
     z-index: $zIndex-2;
   }
   }
@@ -955,7 +958,7 @@
 }
 }
 
 
   .uppy-Dashboard--wide .uppy-Dashboard-actions {
   .uppy-Dashboard--wide .uppy-Dashboard-actions {
-    height: 75px;
+    height: 65px;
   }
   }
 
 
 .uppy-Dashboard-actionsBtn {
 .uppy-Dashboard-actionsBtn {
@@ -1044,7 +1047,7 @@
   justify-content: center;
   justify-content: center;
   flex-grow: 1;
   flex-grow: 1;
   border-bottom: 1px solid rgba($color-gray, 0.3);
   border-bottom: 1px solid rgba($color-gray, 0.3);
-  background-color: lighten($color-gray, 40%);
+  background-color: lighten($color-gray, 40%); /* no !important */
   position: relative;
   position: relative;
 }
 }
 
 
@@ -1054,6 +1057,7 @@
   max-height: 90%;
   max-height: 90%;
   object-fit: cover;
   object-fit: cover;
   border-radius: 3px;
   border-radius: 3px;
+  position: absolute;
 }
 }
 
 
 .uppy-DashboardFileCard-info {
 .uppy-DashboardFileCard-info {

+ 2 - 12
src/scss/_dragdrop.scss

@@ -23,7 +23,7 @@
     margin-bottom: 17px;
     margin-bottom: 17px;
   }
   }
   
   
-    .uppy-DragDrop-container.is-dragdrop-supported {
+    .uppy-DragDrop--is-dragdrop-supported {
       border: 2px dashed;
       border: 2px dashed;
       border-color: lighten($color-gray, 10%);
       border-color: lighten($color-gray, 10%);
     }
     }
@@ -41,16 +41,6 @@
       fill: $color-gray;
       fill: $color-gray;
     }
     }
   
   
-  /* http://tympanus.net/codrops/2015/09/15/styling-customizing-file-inputs-smart-way/ */
-  .uppy-DragDrop-input {
-    width: 0.1px;
-    height: 0.1px;
-    opacity: 0;
-    overflow: hidden;
-    position: absolute;
-    z-index: -1;
-  }
-  
   .uppy-DragDrop-label {
   .uppy-DragDrop-label {
     display: block;
     display: block;
     cursor: pointer;
     cursor: pointer;
@@ -67,4 +57,4 @@
     color: $color-cornflower-blue;
     color: $color-cornflower-blue;
   }
   }
 
 
-// }
+// }

+ 1 - 1
src/scss/_informer.scss

@@ -9,7 +9,7 @@
   padding: 0 15px;
   padding: 0 15px;
   height: 35px;
   height: 35px;
   line-height: 35px;
   line-height: 35px;
-  background-color: $color-black;
+  background-color: $color-black; /* no !important */
   color: $color-white;
   color: $color-white;
   opacity: 1;
   opacity: 1;
   transform: none;
   transform: none;

+ 80 - 44
src/scss/_microtip.scss

@@ -18,12 +18,14 @@
   [1] Base Styles
   [1] Base Styles
 -------------------------------------------------*/
 -------------------------------------------------*/
 
 
-[aria-label][role~="tooltip"] {
+.uppy-Root [aria-label][role~="tooltip"] {
+  /* no important */
   position: relative;
   position: relative;
 }
 }
 
 
-[aria-label][role~="tooltip"]::before,
-[aria-label][role~="tooltip"]::after {
+.uppy-Root [aria-label][role~="tooltip"]::before,
+.uppy-Root [aria-label][role~="tooltip"]::after {
+  /* no important */
   transform: translate3d(0, 0, 0);
   transform: translate3d(0, 0, 0);
   -webkit-backface-visibility: hidden;
   -webkit-backface-visibility: hidden;
   backface-visibility: hidden;
   backface-visibility: hidden;
@@ -37,12 +39,14 @@
   transform-origin: top;
   transform-origin: top;
 }
 }
 
 
-[aria-label][role~="tooltip"]::before {
+.uppy-Root [aria-label][role~="tooltip"]::before {
+  /* no important */
   background-size: 100% auto !important;
   background-size: 100% auto !important;
   content: "";
   content: "";
 }
 }
 
 
-[aria-label][role~="tooltip"]::after {
+.uppy-Root [aria-label][role~="tooltip"]::after {
+  /* no important */
   background: rgba(17, 17, 17, .9);
   background: rgba(17, 17, 17, .9);
   border-radius: 4px;
   border-radius: 4px;
   color: #ffffff;
   color: #ffffff;
@@ -55,10 +59,11 @@
   box-sizing: content-box;
   box-sizing: content-box;
 }
 }
 
 
-[aria-label][role~="tooltip"]:hover::before,
-[aria-label][role~="tooltip"]:hover::after,
-[aria-label][role~="tooltip"]:focus::before,
-[aria-label][role~="tooltip"]:focus::after {
+.uppy-Root [aria-label][role~="tooltip"]:hover::before,
+.uppy-Root [aria-label][role~="tooltip"]:hover::after,
+.uppy-Root [aria-label][role~="tooltip"]:focus::before,
+.uppy-Root [aria-label][role~="tooltip"]:focus::after {
+  /* no important */
   opacity: 1;
   opacity: 1;
   pointer-events: auto;
   pointer-events: auto;
 }
 }
@@ -69,46 +74,54 @@
   [2] Position Modifiers
   [2] Position Modifiers
 -------------------------------------------------*/
 -------------------------------------------------*/
 
 
-[role~="tooltip"][data-microtip-position|="top"]::before {
+.uppy-Root [role~="tooltip"][data-microtip-position|="top"]::before {
+  /* no important */
   background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2236px%22%20height%3D%2212px%22%3E%3Cpath%20fill%3D%22rgba%2817,%2017,%2017,%200.9%29%22%20transform%3D%22rotate%280%29%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E") no-repeat;
   background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2236px%22%20height%3D%2212px%22%3E%3Cpath%20fill%3D%22rgba%2817,%2017,%2017,%200.9%29%22%20transform%3D%22rotate%280%29%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E") no-repeat;
   height: 6px;
   height: 6px;
   width: 18px;
   width: 18px;
   margin-bottom: 5px;
   margin-bottom: 5px;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position|="top"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-position|="top"]::after {
+  /* no important */
   margin-bottom: 11px;
   margin-bottom: 11px;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position|="top"]::before {
+.uppy-Root [role~="tooltip"][data-microtip-position|="top"]::before {
+  /* no important */
   transform: translate3d(-50%, 0, 0);
   transform: translate3d(-50%, 0, 0);
   bottom: 100%;
   bottom: 100%;
   left: 50%;
   left: 50%;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position|="top"]:hover::before {
+.uppy-Root [role~="tooltip"][data-microtip-position|="top"]:hover::before {
+  /* no important */
   transform: translate3d(-50%, -5px, 0);
   transform: translate3d(-50%, -5px, 0);
 }
 }
 
 
-[role~="tooltip"][data-microtip-position|="top"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-position|="top"]::after {
+  /* no important */
   transform: translate3d(-50%, 0, 0);
   transform: translate3d(-50%, 0, 0);
   bottom: 100%;
   bottom: 100%;
   left: 50%;
   left: 50%;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position="top"]:hover::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="top"]:hover::after {
+  /* no important */
   transform: translate3d(-50%, -5px, 0);
   transform: translate3d(-50%, -5px, 0);
 }
 }
 
 
 /* ------------------------------------------------
 /* ------------------------------------------------
   [2.1] Top Left
   [2.1] Top Left
 -------------------------------------------------*/
 -------------------------------------------------*/
-[role~="tooltip"][data-microtip-position="top-left"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="top-left"]::after {
+  /* no important */
   transform: translate3d(calc(-100% + 16px), 0, 0);
   transform: translate3d(calc(-100% + 16px), 0, 0);
   bottom: 100%;
   bottom: 100%;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position="top-left"]:hover::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="top-left"]:hover::after {
+  /* no important */
   transform: translate3d(calc(-100% + 16px), -5px, 0);
   transform: translate3d(calc(-100% + 16px), -5px, 0);
 }
 }
 
 
@@ -116,12 +129,14 @@
 /* ------------------------------------------------
 /* ------------------------------------------------
   [2.2] Top Right
   [2.2] Top Right
 -------------------------------------------------*/
 -------------------------------------------------*/
-[role~="tooltip"][data-microtip-position="top-right"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="top-right"]::after {
+  /* no important */
   transform: translate3d(calc(0% + -16px), 0, 0);
   transform: translate3d(calc(0% + -16px), 0, 0);
   bottom: 100%;
   bottom: 100%;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position="top-right"]:hover::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="top-right"]:hover::after {
+  /* no important */
   transform: translate3d(calc(0% + -16px), -5px, 0);
   transform: translate3d(calc(0% + -16px), -5px, 0);
 }
 }
 
 
@@ -129,7 +144,8 @@
 /* ------------------------------------------------
 /* ------------------------------------------------
   [2.3] Bottom
   [2.3] Bottom
 -------------------------------------------------*/
 -------------------------------------------------*/
-[role~="tooltip"][data-microtip-position|="bottom"]::before {
+.uppy-Root [role~="tooltip"][data-microtip-position|="bottom"]::before {
+  /* no important */
   background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2236px%22%20height%3D%2212px%22%3E%3Cpath%20fill%3D%22rgba%2817,%2017,%2017,%200.9%29%22%20transform%3D%22rotate%28180%2018%206%29%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E") no-repeat;
   background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2236px%22%20height%3D%2212px%22%3E%3Cpath%20fill%3D%22rgba%2817,%2017,%2017,%200.9%29%22%20transform%3D%22rotate%28180%2018%206%29%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E") no-repeat;
   height: 6px;
   height: 6px;
   width: 18px;
   width: 18px;
@@ -137,28 +153,33 @@
   margin-bottom: 0;
   margin-bottom: 0;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position|="bottom"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-position|="bottom"]::after {
+  /* no important */
   margin-top: 11px;
   margin-top: 11px;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position|="bottom"]::before {
+.uppy-Root [role~="tooltip"][data-microtip-position|="bottom"]::before {
+  /* no important */
   transform: translate3d(-50%, -10px, 0);
   transform: translate3d(-50%, -10px, 0);
   bottom: auto;
   bottom: auto;
   left: 50%;
   left: 50%;
   top: 100%;
   top: 100%;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position|="bottom"]:hover::before {
+.uppy-Root [role~="tooltip"][data-microtip-position|="bottom"]:hover::before {
+  /* no important */
   transform: translate3d(-50%, 0, 0);
   transform: translate3d(-50%, 0, 0);
 }
 }
 
 
-[role~="tooltip"][data-microtip-position|="bottom"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-position|="bottom"]::after {
+  /* no important */
   transform: translate3d(-50%, -10px, 0);
   transform: translate3d(-50%, -10px, 0);
   top: 100%;
   top: 100%;
   left: 50%;
   left: 50%;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position="bottom"]:hover::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="bottom"]:hover::after {
+  /* no important */
   transform: translate3d(-50%, 0, 0);
   transform: translate3d(-50%, 0, 0);
 }
 }
 
 
@@ -166,12 +187,14 @@
 /* ------------------------------------------------
 /* ------------------------------------------------
   [2.4] Bottom Left
   [2.4] Bottom Left
 -------------------------------------------------*/
 -------------------------------------------------*/
-[role~="tooltip"][data-microtip-position="bottom-left"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="bottom-left"]::after {
+  /* no important */
   transform: translate3d(calc(-100% + 16px), -10px, 0);
   transform: translate3d(calc(-100% + 16px), -10px, 0);
   top: 100%;
   top: 100%;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position="bottom-left"]:hover::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="bottom-left"]:hover::after {
+  /* no important */
   transform: translate3d(calc(-100% + 16px), 0, 0);
   transform: translate3d(calc(-100% + 16px), 0, 0);
 }
 }
 
 
@@ -179,12 +202,14 @@
 /* ------------------------------------------------
 /* ------------------------------------------------
   [2.5] Bottom Right
   [2.5] Bottom Right
 -------------------------------------------------*/
 -------------------------------------------------*/
-[role~="tooltip"][data-microtip-position="bottom-right"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="bottom-right"]::after {
+  /* no important */
   transform: translate3d(calc(0% + -16px), -10px, 0);
   transform: translate3d(calc(0% + -16px), -10px, 0);
   top: 100%;
   top: 100%;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position="bottom-right"]:hover::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="bottom-right"]:hover::after {
+  /* no important */
   transform: translate3d(calc(0% + -16px), 0, 0);
   transform: translate3d(calc(0% + -16px), 0, 0);
 }
 }
 
 
@@ -192,8 +217,9 @@
 /* ------------------------------------------------
 /* ------------------------------------------------
   [2.6] Left
   [2.6] Left
 -------------------------------------------------*/
 -------------------------------------------------*/
-[role~="tooltip"][data-microtip-position="left"]::before,
-[role~="tooltip"][data-microtip-position="left"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="left"]::before,
+.uppy-Root [role~="tooltip"][data-microtip-position="left"]::after {
+  /* no important */
   bottom: auto;
   bottom: auto;
   left: auto;
   left: auto;
   right: 100%;
   right: 100%;
@@ -201,7 +227,8 @@
   transform: translate3d(10px, -50%, 0);
   transform: translate3d(10px, -50%, 0);
 }
 }
 
 
-[role~="tooltip"][data-microtip-position="left"]::before {
+.uppy-Root [role~="tooltip"][data-microtip-position="left"]::before {
+  /* no important */
   background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2212px%22%20height%3D%2236px%22%3E%3Cpath%20fill%3D%22rgba%2817,%2017,%2017,%200.9%29%22%20transform%3D%22rotate%28-90%2018%2018%29%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E") no-repeat;
   background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2212px%22%20height%3D%2236px%22%3E%3Cpath%20fill%3D%22rgba%2817,%2017,%2017,%200.9%29%22%20transform%3D%22rotate%28-90%2018%2018%29%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E") no-repeat;
   height: 18px;
   height: 18px;
   width: 6px;
   width: 6px;
@@ -209,12 +236,14 @@
   margin-bottom: 0;
   margin-bottom: 0;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position="left"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="left"]::after {
+  /* no important */
   margin-right: 11px;
   margin-right: 11px;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position="left"]:hover::before,
-[role~="tooltip"][data-microtip-position="left"]:hover::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="left"]:hover::before,
+.uppy-Root [role~="tooltip"][data-microtip-position="left"]:hover::after {
+  /* no important */
   transform: translate3d(0, -50%, 0);
   transform: translate3d(0, -50%, 0);
 }
 }
 
 
@@ -222,15 +251,17 @@
 /* ------------------------------------------------
 /* ------------------------------------------------
   [2.7] Right
   [2.7] Right
 -------------------------------------------------*/
 -------------------------------------------------*/
-[role~="tooltip"][data-microtip-position="right"]::before,
-[role~="tooltip"][data-microtip-position="right"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="right"]::before,
+.uppy-Root [role~="tooltip"][data-microtip-position="right"]::after {
+  /* no important */
   bottom: auto;
   bottom: auto;
   left: 100%;
   left: 100%;
   top: 50%;
   top: 50%;
   transform: translate3d(-10px, -50%, 0);
   transform: translate3d(-10px, -50%, 0);
 }
 }
 
 
-[role~="tooltip"][data-microtip-position="right"]::before {
+.uppy-Root [role~="tooltip"][data-microtip-position="right"]::before {
+  /* no important */
   background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2212px%22%20height%3D%2236px%22%3E%3Cpath%20fill%3D%22rgba%2817,%2017,%2017,%200.9%29%22%20transform%3D%22rotate%2890%206%206%29%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E") no-repeat;
   background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2212px%22%20height%3D%2236px%22%3E%3Cpath%20fill%3D%22rgba%2817,%2017,%2017,%200.9%29%22%20transform%3D%22rotate%2890%206%206%29%22%20d%3D%22M2.658,0.000%20C-13.615,0.000%2050.938,0.000%2034.662,0.000%20C28.662,0.000%2023.035,12.002%2018.660,12.002%20C14.285,12.002%208.594,0.000%202.658,0.000%20Z%22/%3E%3C/svg%3E") no-repeat;
   height: 18px;
   height: 18px;
   width: 6px;
   width: 6px;
@@ -238,29 +269,34 @@
   margin-left: 5px;
   margin-left: 5px;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position="right"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="right"]::after {
+  /* no important */
   margin-left: 11px;
   margin-left: 11px;
 }
 }
 
 
-[role~="tooltip"][data-microtip-position="right"]:hover::before,
-[role~="tooltip"][data-microtip-position="right"]:hover::after {
+.uppy-Root [role~="tooltip"][data-microtip-position="right"]:hover::before,
+.uppy-Root [role~="tooltip"][data-microtip-position="right"]:hover::after {
+  /* no important */
   transform: translate3d(0, -50%, 0);
   transform: translate3d(0, -50%, 0);
 }
 }
 
 
 /* ------------------------------------------------
 /* ------------------------------------------------
   [3] Size
   [3] Size
 -------------------------------------------------*/
 -------------------------------------------------*/
-[role~="tooltip"][data-microtip-size="small"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-size="small"]::after {
+  /* no important */
   white-space: initial;
   white-space: initial;
   width: 80px;
   width: 80px;
 }
 }
 
 
-[role~="tooltip"][data-microtip-size="medium"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-size="medium"]::after {
+  /* no important */
   white-space: initial;
   white-space: initial;
   width: 150px;
   width: 150px;
 }
 }
 
 
-[role~="tooltip"][data-microtip-size="large"]::after {
+.uppy-Root [role~="tooltip"][data-microtip-size="large"]::after {
+  /* no important */
   white-space: initial;
   white-space: initial;
   width: 260px;
   width: 260px;
 }
 }

+ 4 - 9
src/scss/_progressbar.scss

@@ -1,9 +1,5 @@
-// @import '_variables.scss';
-// @import '_utils.scss';
-// @import '_animation.scss';
-// @import '_common.scss';
-
 .uppy-ProgressBar {
 .uppy-ProgressBar {
+  /* no important */
   position: absolute;
   position: absolute;
   top: 0;
   top: 0;
   left: 0;
   left: 0;
@@ -14,10 +10,12 @@
 }
 }
 
 
 .uppy-ProgressBar[aria-hidden=true] {
 .uppy-ProgressBar[aria-hidden=true] {
+  /* no important */
   height: 0;
   height: 0;
 }
 }
 
 
 .uppy-ProgressBar-inner {
 .uppy-ProgressBar-inner {
+  /* no important */
   background-color: $color-cornflower-blue;
   background-color: $color-cornflower-blue;
   box-shadow: 0 0 10px rgba($color-cornflower-blue, 0.7);
   box-shadow: 0 0 10px rgba($color-cornflower-blue, 0.7);
   height: 100%;
   height: 100%;
@@ -26,6 +24,7 @@
 }
 }
 
 
 .uppy-ProgressBar-percentage {
 .uppy-ProgressBar-percentage {
+  /* no important */
   display: none;
   display: none;
   text-align: center;
   text-align: center;
   position: absolute;
   position: absolute;
@@ -33,8 +32,4 @@
   left: 50%;
   left: 50%;
   transform: translate(-50%, -50%);
   transform: translate(-50%, -50%);
   color: $color-white;
   color: $color-white;
-
-  // &:after {
-  //   content: ' %';
-  // }
 }
 }

+ 14 - 6
src/scss/_provider.scss

@@ -1,6 +1,7 @@
-.uppy-Provider-auth, 
-.uppy-Provider-error, 
-.uppy-Provider-loading {
+.uppy-Provider-auth,
+.uppy-Provider-error,
+.uppy-Provider-loading,
+.uppy-Provider-empty {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
@@ -18,7 +19,7 @@
 .uppy-Provider-authTitle {
 .uppy-Provider-authTitle {
   font-size: 20px;
   font-size: 20px;
   line-height: 1.4;
   line-height: 1.4;
-  font-weight: 300;
+  font-weight: 400;
   margin-bottom: 30px;
   margin-bottom: 30px;
   padding: 0 15px;
   padding: 0 15px;
   max-width: 500px;
   max-width: 500px;
@@ -81,9 +82,15 @@
 }
 }
 
 
 .uppy-ProviderBrowser-user {
 .uppy-ProviderBrowser-user {
-  margin: 16px 0;
+  margin: 0 8px 0 0;
 }
 }
 
 
+  .uppy-ProviderBrowser-user:after {
+    content: '\00B7';
+    position: relative;
+    left: 4px;
+  }
+
 .uppy-ProviderBrowser-header {
 .uppy-ProviderBrowser-header {
   z-index: $zIndex-2;
   z-index: $zIndex-2;
   border-bottom: 1px solid lighten($color-asphalt-gray, 60%);
   border-bottom: 1px solid lighten($color-asphalt-gray, 60%);
@@ -243,6 +250,7 @@
     flex-direction: row;
     flex-direction: row;
     flex-wrap: wrap;
     flex-wrap: wrap;
     justify-content: space-between;
     justify-content: space-between;
+    align-items: flex-start;
     padding: 6px;
     padding: 6px;
   }
   }
 
 
@@ -346,7 +354,7 @@
   height: 18px;
   height: 18px;
   width: 18px;
   width: 18px;
   top: 2px;
   top: 2px;
-  border: 1px solid $color-cornflower-blue; 
+  border: 1px solid $color-cornflower-blue;
   background-color: $color-white;
   background-color: $color-white;
   border-radius: 2px;
   border-radius: 2px;
   // border-radius: 50%;
   // border-radius: 50%;

+ 13 - 5
src/scss/_statusbar.scss

@@ -1,8 +1,8 @@
 .uppy-StatusBar {
 .uppy-StatusBar {
   display: flex;
   display: flex;
   position: relative;
   position: relative;
-  height: 35px;
-  line-height: 35px;
+  height: 40px;
+  line-height: 40px;
   font-size: 12px;
   font-size: 12px;
   font-weight: 400;
   font-weight: 400;
   color: $color-white;
   color: $color-white;
@@ -39,7 +39,8 @@
 }
 }
 
 
 .uppy-StatusBar:not([aria-hidden=true]).is-waiting {
 .uppy-StatusBar:not([aria-hidden=true]).is-waiting {
-  background-color: darken($color-white, 2%);
+  // background-color: darken($color-white, 2%);
+  background-color: $color-white;
   height: 65px;
   height: 65px;
   border-top: 1px solid rgba($color-gray, 0.3);
   border-top: 1px solid rgba($color-gray, 0.3);
 }
 }
@@ -80,6 +81,10 @@
   height: 100%;
   height: 100%;
 }
 }
 
 
+.uppy-StatusBar-contentPadding {
+  margin-right: 0.5ch; // ½ the size of a 0, roughly the size of a space usually
+}
+
 .uppy-StatusBar-status {
 .uppy-StatusBar-status {
   line-height: 1.35;
   line-height: 1.35;
   font-weight: normal;
   font-weight: normal;
@@ -160,11 +165,14 @@
   .uppy-StatusBar-actionBtn--retry {
   .uppy-StatusBar-actionBtn--retry {
     background-color: $color-white;
     background-color: $color-white;
     color: $color-red;
     color: $color-red;
+    border: 1px solid transparent;
   }
   }
 
 
   .uppy-StatusBar-actionBtn--cancel {
   .uppy-StatusBar-actionBtn--cancel {
-    background-color: lighten($color-asphalt-gray, 8%);
-    border: 1px solid lighten($color-black, 10%);
+    // background-color: lighten($color-asphalt-gray, 8%);
+    // border: 1px solid lighten($color-black, 10%);
+    background-color: transparent;
+    border: 1px solid $color-white;
     color: $color-white;
     color: $color-white;
   }
   }
 
 

+ 7 - 1
src/scss/_webcam.scss

@@ -63,6 +63,11 @@
     background-color: darken($color-red, 10%);
     background-color: darken($color-red, 10%);
   }
   }
 
 
+  .uppy-Webcam-button:focus {
+    outline: none;
+    box-shadow: 0 0 0 0.2rem rgba($color-cornflower-blue, 0.5);
+  }
+
   .uppy-Webcam-button .UppyIcon {
   .uppy-Webcam-button .UppyIcon {
     width: 30px;
     width: 30px;
     height: 30px;
     height: 30px;
@@ -84,12 +89,13 @@
 .uppy-Webcam-Title {
 .uppy-Webcam-Title {
   font-size: 22px;
   font-size: 22px;
   line-height: 1.35;
   line-height: 1.35;
-  font-weight: 300;
+  font-weight: 400;
   margin: 0;
   margin: 0;
   margin-bottom: 15px;
   margin-bottom: 15px;
   padding: 0 15px;
   padding: 0 15px;
   max-width: 500px;
   max-width: 500px;
   text-align: center;
   text-align: center;
+  color: $color-black;
 }
 }
 
 
 .uppy-Webcam-permissons p {
 .uppy-Webcam-permissons p {

+ 15 - 0
src/server/RequestClient.js

@@ -58,4 +58,19 @@ module.exports = class RequestClient {
       // @todo validate response status before calling json
       // @todo validate response status before calling json
       .then((res) => res.json())
       .then((res) => res.json())
   }
   }
+
+  delete (path, data) {
+    return fetch(`${this.hostname}/${path}`, {
+      method: 'delete',
+      credentials: 'include',
+      headers: {
+        'Accept': 'application/json',
+        'Content-Type': 'application/json'
+      },
+      body: data ? JSON.stringify(data) : null
+    })
+      .then(this.onReceiveResponse)
+      // @todo validate response status before calling json
+      .then((res) => res.json())
+  }
 }
 }

+ 0 - 559
src/vendor/file-type/index.js

@@ -1,559 +0,0 @@
-'use strict';
-
-module.exports = input => {
-	const buf = new Uint8Array(input);
-
-	if (!(buf && buf.length > 1)) {
-		return null;
-	}
-
-	const check = (header, opts) => {
-		opts = Object.assign({
-			offset: 0
-		}, opts);
-
-		for (let i = 0; i < header.length; i++) {
-			if (header[i] !== buf[i + opts.offset]) {
-				return false;
-			}
-		}
-
-		return true;
-	};
-
-	if (check([0xFF, 0xD8, 0xFF])) {
-		return {
-			ext: 'jpg',
-			mime: 'image/jpeg'
-		};
-	}
-
-	if (check([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) {
-		return {
-			ext: 'png',
-			mime: 'image/png'
-		};
-	}
-
-	if (check([0x47, 0x49, 0x46])) {
-		return {
-			ext: 'gif',
-			mime: 'image/gif'
-		};
-	}
-
-	if (check([0x57, 0x45, 0x42, 0x50], {offset: 8})) {
-		return {
-			ext: 'webp',
-			mime: 'image/webp'
-		};
-	}
-
-	if (check([0x46, 0x4C, 0x49, 0x46])) {
-		return {
-			ext: 'flif',
-			mime: 'image/flif'
-		};
-	}
-
-	// Needs to be before `tif` check
-	if (
-		(check([0x49, 0x49, 0x2A, 0x0]) || check([0x4D, 0x4D, 0x0, 0x2A])) &&
-		check([0x43, 0x52], {offset: 8})
-	) {
-		return {
-			ext: 'cr2',
-			mime: 'image/x-canon-cr2'
-		};
-	}
-
-	if (
-		check([0x49, 0x49, 0x2A, 0x0]) ||
-		check([0x4D, 0x4D, 0x0, 0x2A])
-	) {
-		return {
-			ext: 'tif',
-			mime: 'image/tiff'
-		};
-	}
-
-	if (check([0x42, 0x4D])) {
-		return {
-			ext: 'bmp',
-			mime: 'image/bmp'
-		};
-	}
-
-	if (check([0x49, 0x49, 0xBC])) {
-		return {
-			ext: 'jxr',
-			mime: 'image/vnd.ms-photo'
-		};
-	}
-
-	if (check([0x38, 0x42, 0x50, 0x53])) {
-		return {
-			ext: 'psd',
-			mime: 'image/vnd.adobe.photoshop'
-		};
-	}
-
-	// Needs to be before the `zip` check
-	if (
-		check([0x50, 0x4B, 0x3, 0x4]) &&
-		check([0x6D, 0x69, 0x6D, 0x65, 0x74, 0x79, 0x70, 0x65, 0x61, 0x70, 0x70, 0x6C, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6F, 0x6E, 0x2F, 0x65, 0x70, 0x75, 0x62, 0x2B, 0x7A, 0x69, 0x70], {offset: 30})
-	) {
-		return {
-			ext: 'epub',
-			mime: 'application/epub+zip'
-		};
-	}
-
-	// Needs to be before `zip` check
-	// Assumes signed `.xpi` from addons.mozilla.org
-	if (
-		check([0x50, 0x4B, 0x3, 0x4]) &&
-		check([0x4D, 0x45, 0x54, 0x41, 0x2D, 0x49, 0x4E, 0x46, 0x2F, 0x6D, 0x6F, 0x7A, 0x69, 0x6C, 0x6C, 0x61, 0x2E, 0x72, 0x73, 0x61], {offset: 30})
-	) {
-		return {
-			ext: 'xpi',
-			mime: 'application/x-xpinstall'
-		};
-	}
-
-	if (
-		check([0x50, 0x4B]) &&
-		(buf[2] === 0x3 || buf[2] === 0x5 || buf[2] === 0x7) &&
-		(buf[3] === 0x4 || buf[3] === 0x6 || buf[3] === 0x8)
-	) {
-		return {
-			ext: 'zip',
-			mime: 'application/zip'
-		};
-	}
-
-	if (check([0x75, 0x73, 0x74, 0x61, 0x72], {offset: 257})) {
-		return {
-			ext: 'tar',
-			mime: 'application/x-tar'
-		};
-	}
-
-	if (
-		check([0x52, 0x61, 0x72, 0x21, 0x1A, 0x7]) &&
-		(buf[6] === 0x0 || buf[6] === 0x1)
-	) {
-		return {
-			ext: 'rar',
-			mime: 'application/x-rar-compressed'
-		};
-	}
-
-	if (check([0x1F, 0x8B, 0x8])) {
-		return {
-			ext: 'gz',
-			mime: 'application/gzip'
-		};
-	}
-
-	if (check([0x42, 0x5A, 0x68])) {
-		return {
-			ext: 'bz2',
-			mime: 'application/x-bzip2'
-		};
-	}
-
-	if (check([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C])) {
-		return {
-			ext: '7z',
-			mime: 'application/x-7z-compressed'
-		};
-	}
-
-	if (check([0x78, 0x01])) {
-		return {
-			ext: 'dmg',
-			mime: 'application/x-apple-diskimage'
-		};
-	}
-
-	if (
-		(
-			check([0x0, 0x0, 0x0]) &&
-			(buf[3] === 0x18 || buf[3] === 0x20) &&
-			check([0x66, 0x74, 0x79, 0x70], {offset: 4})
-		) ||
-		check([0x33, 0x67, 0x70, 0x35]) ||
-		(
-			check([0x0, 0x0, 0x0, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x6D, 0x70, 0x34, 0x32]) &&
-			check([0x6D, 0x70, 0x34, 0x31, 0x6D, 0x70, 0x34, 0x32, 0x69, 0x73, 0x6F, 0x6D], {offset: 16})
-		) ||
-		check([0x0, 0x0, 0x0, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D]) ||
-		check([0x0, 0x0, 0x0, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x6D, 0x70, 0x34, 0x32, 0x0, 0x0, 0x0, 0x0])
-	) {
-		return {
-			ext: 'mp4',
-			mime: 'video/mp4'
-		};
-	}
-
-	if (check([0x0, 0x0, 0x0, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x56])) {
-		return {
-			ext: 'm4v',
-			mime: 'video/x-m4v'
-		};
-	}
-
-	if (check([0x4D, 0x54, 0x68, 0x64])) {
-		return {
-			ext: 'mid',
-			mime: 'audio/midi'
-		};
-	}
-
-	// https://github.com/threatstack/libmagic/blob/master/magic/Magdir/matroska
-	if (check([0x1A, 0x45, 0xDF, 0xA3])) {
-		const sliced = buf.subarray(4, 4 + 4096);
-		const idPos = sliced.findIndex((el, i, arr) => arr[i] === 0x42 && arr[i + 1] === 0x82);
-
-		if (idPos >= 0) {
-			const docTypePos = idPos + 3;
-			const findDocType = type => Array.from(type).every((c, i) => sliced[docTypePos + i] === c.charCodeAt(0));
-
-			if (findDocType('matroska')) {
-				return {
-					ext: 'mkv',
-					mime: 'video/x-matroska'
-				};
-			}
-
-			if (findDocType('webm')) {
-				return {
-					ext: 'webm',
-					mime: 'video/webm'
-				};
-			}
-		}
-	}
-
-	if (check([0x0, 0x0, 0x0, 0x14, 0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20]) ||
-		check([0x66, 0x72, 0x65, 0x65], {offset: 4}) ||
-		check([0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20], {offset: 4}) ||
-		check([0x6D, 0x64, 0x61, 0x74], {offset: 4}) || // MJPEG
-		check([0x77, 0x69, 0x64, 0x65], {offset: 4})) {
-		return {
-			ext: 'mov',
-			mime: 'video/quicktime'
-		};
-	}
-
-	if (
-		check([0x52, 0x49, 0x46, 0x46]) &&
-		check([0x41, 0x56, 0x49], {offset: 8})
-	) {
-		return {
-			ext: 'avi',
-			mime: 'video/x-msvideo'
-		};
-	}
-
-	if (check([0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11, 0xA6, 0xD9])) {
-		return {
-			ext: 'wmv',
-			mime: 'video/x-ms-wmv'
-		};
-	}
-
-	if (check([0x0, 0x0, 0x1, 0xBA])) {
-		return {
-			ext: 'mpg',
-			mime: 'video/mpeg'
-		};
-	}
-
-	if (
-		check([0x49, 0x44, 0x33]) ||
-		check([0xFF, 0xFB])
-	) {
-		return {
-			ext: 'mp3',
-			mime: 'audio/mpeg'
-		};
-	}
-
-	if (
-		check([0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x41], {offset: 4}) ||
-		check([0x4D, 0x34, 0x41, 0x20])
-	) {
-		return {
-			ext: 'm4a',
-			mime: 'audio/m4a'
-		};
-	}
-
-	// Needs to be before `ogg` check
-	if (check([0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], {offset: 28})) {
-		return {
-			ext: 'opus',
-			mime: 'audio/opus'
-		};
-	}
-
-	if (check([0x4F, 0x67, 0x67, 0x53])) {
-		return {
-			ext: 'ogg',
-			mime: 'audio/ogg'
-		};
-	}
-
-	if (check([0x66, 0x4C, 0x61, 0x43])) {
-		return {
-			ext: 'flac',
-			mime: 'audio/x-flac'
-		};
-	}
-
-	if (
-		check([0x52, 0x49, 0x46, 0x46]) &&
-		check([0x57, 0x41, 0x56, 0x45], {offset: 8})
-	) {
-		return {
-			ext: 'wav',
-			mime: 'audio/x-wav'
-		};
-	}
-
-	if (check([0x23, 0x21, 0x41, 0x4D, 0x52, 0x0A])) {
-		return {
-			ext: 'amr',
-			mime: 'audio/amr'
-		};
-	}
-
-	if (check([0x25, 0x50, 0x44, 0x46])) {
-		return {
-			ext: 'pdf',
-			mime: 'application/pdf'
-		};
-	}
-
-	if (check([0x4D, 0x5A])) {
-		return {
-			ext: 'exe',
-			mime: 'application/x-msdownload'
-		};
-	}
-
-	if (
-		(buf[0] === 0x43 || buf[0] === 0x46) &&
-		check([0x57, 0x53], {offset: 1})
-	) {
-		return {
-			ext: 'swf',
-			mime: 'application/x-shockwave-flash'
-		};
-	}
-
-	if (check([0x7B, 0x5C, 0x72, 0x74, 0x66])) {
-		return {
-			ext: 'rtf',
-			mime: 'application/rtf'
-		};
-	}
-
-	if (check([0x00, 0x61, 0x73, 0x6D])) {
-		return {
-			ext: 'wasm',
-			mime: 'application/wasm'
-		};
-	}
-
-	if (
-		check([0x77, 0x4F, 0x46, 0x46]) &&
-		(
-			check([0x00, 0x01, 0x00, 0x00], {offset: 4}) ||
-			check([0x4F, 0x54, 0x54, 0x4F], {offset: 4})
-		)
-	) {
-		return {
-			ext: 'woff',
-			mime: 'font/woff'
-		};
-	}
-
-	if (
-		check([0x77, 0x4F, 0x46, 0x32]) &&
-		(
-			check([0x00, 0x01, 0x00, 0x00], {offset: 4}) ||
-			check([0x4F, 0x54, 0x54, 0x4F], {offset: 4})
-		)
-	) {
-		return {
-			ext: 'woff2',
-			mime: 'font/woff2'
-		};
-	}
-
-	if (
-		check([0x4C, 0x50], {offset: 34}) &&
-		(
-			check([0x00, 0x00, 0x01], {offset: 8}) ||
-			check([0x01, 0x00, 0x02], {offset: 8}) ||
-			check([0x02, 0x00, 0x02], {offset: 8})
-		)
-	) {
-		return {
-			ext: 'eot',
-			mime: 'application/octet-stream'
-		};
-	}
-
-	if (check([0x00, 0x01, 0x00, 0x00, 0x00])) {
-		return {
-			ext: 'ttf',
-			mime: 'font/ttf'
-		};
-	}
-
-	if (check([0x4F, 0x54, 0x54, 0x4F, 0x00])) {
-		return {
-			ext: 'otf',
-			mime: 'font/otf'
-		};
-	}
-
-	if (check([0x00, 0x00, 0x01, 0x00])) {
-		return {
-			ext: 'ico',
-			mime: 'image/x-icon'
-		};
-	}
-
-	if (check([0x46, 0x4C, 0x56, 0x01])) {
-		return {
-			ext: 'flv',
-			mime: 'video/x-flv'
-		};
-	}
-
-	if (check([0x25, 0x21])) {
-		return {
-			ext: 'ps',
-			mime: 'application/postscript'
-		};
-	}
-
-	if (check([0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00])) {
-		return {
-			ext: 'xz',
-			mime: 'application/x-xz'
-		};
-	}
-
-	if (check([0x53, 0x51, 0x4C, 0x69])) {
-		return {
-			ext: 'sqlite',
-			mime: 'application/x-sqlite3'
-		};
-	}
-
-	if (check([0x4E, 0x45, 0x53, 0x1A])) {
-		return {
-			ext: 'nes',
-			mime: 'application/x-nintendo-nes-rom'
-		};
-	}
-
-	if (check([0x43, 0x72, 0x32, 0x34])) {
-		return {
-			ext: 'crx',
-			mime: 'application/x-google-chrome-extension'
-		};
-	}
-
-	if (
-		check([0x4D, 0x53, 0x43, 0x46]) ||
-		check([0x49, 0x53, 0x63, 0x28])
-	) {
-		return {
-			ext: 'cab',
-			mime: 'application/vnd.ms-cab-compressed'
-		};
-	}
-
-	// Needs to be before `ar` check
-	if (check([0x21, 0x3C, 0x61, 0x72, 0x63, 0x68, 0x3E, 0x0A, 0x64, 0x65, 0x62, 0x69, 0x61, 0x6E, 0x2D, 0x62, 0x69, 0x6E, 0x61, 0x72, 0x79])) {
-		return {
-			ext: 'deb',
-			mime: 'application/x-deb'
-		};
-	}
-
-	if (check([0x21, 0x3C, 0x61, 0x72, 0x63, 0x68, 0x3E])) {
-		return {
-			ext: 'ar',
-			mime: 'application/x-unix-archive'
-		};
-	}
-
-	if (check([0xED, 0xAB, 0xEE, 0xDB])) {
-		return {
-			ext: 'rpm',
-			mime: 'application/x-rpm'
-		};
-	}
-
-	if (
-		check([0x1F, 0xA0]) ||
-		check([0x1F, 0x9D])
-	) {
-		return {
-			ext: 'Z',
-			mime: 'application/x-compress'
-		};
-	}
-
-	if (check([0x4C, 0x5A, 0x49, 0x50])) {
-		return {
-			ext: 'lz',
-			mime: 'application/x-lzip'
-		};
-	}
-
-	if (check([0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1])) {
-		return {
-			ext: 'msi',
-			mime: 'application/x-msi'
-		};
-	}
-
-	if (check([0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x02])) {
-		return {
-			ext: 'mxf',
-			mime: 'application/mxf'
-		};
-	}
-
-	if (check([0x47], {offset: 4}) && (check([0x47], {offset: 192}) || check([0x47], {offset: 196}))) {
-		return {
-			ext: 'mts',
-			mime: 'video/mp2t'
-		};
-	}
-
-	if (check([0x42, 0x4C, 0x45, 0x4E, 0x44, 0x45, 0x52])) {
-		return {
-			ext: 'blend',
-			mime: 'application/x-blender'
-		};
-	}
-
-	if (check([0x42, 0x50, 0x47, 0xFB])) {
-		return {
-			ext: 'bpg',
-			mime: 'image/bpg'
-		};
-	}
-
-	return null;
-};

+ 0 - 21
src/vendor/file-type/license

@@ -1,21 +0,0 @@
-The MIT License (MIT)
-
-Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.

+ 0 - 109
src/vendor/file-type/package.json

@@ -1,109 +0,0 @@
-{
-  "name": "file-type",
-  "version": "5.2.0",
-  "description": "Detect the file type of a Buffer/Uint8Array",
-  "license": "MIT",
-  "repository": "sindresorhus/file-type",
-  "author": {
-    "name": "Sindre Sorhus",
-    "email": "sindresorhus@gmail.com",
-    "url": "sindresorhus.com"
-  },
-  "engines": {
-    "node": ">=4"
-  },
-  "scripts": {
-    "test": "xo && ava"
-  },
-  "files": [
-    "index.js"
-  ],
-  "keywords": [
-    "mime",
-    "file",
-    "type",
-    "archive",
-    "image",
-    "img",
-    "pic",
-    "picture",
-    "flash",
-    "photo",
-    "video",
-    "type",
-    "detect",
-    "check",
-    "is",
-    "exif",
-    "exe",
-    "binary",
-    "buffer",
-    "uint8array",
-    "jpg",
-    "png",
-    "gif",
-    "webp",
-    "flif",
-    "cr2",
-    "tif",
-    "bmp",
-    "jxr",
-    "psd",
-    "zip",
-    "tar",
-    "rar",
-    "gz",
-    "bz2",
-    "7z",
-    "dmg",
-    "mp4",
-    "m4v",
-    "mid",
-    "mkv",
-    "webm",
-    "mov",
-    "avi",
-    "mpg",
-    "mp3",
-    "m4a",
-    "ogg",
-    "opus",
-    "flac",
-    "wav",
-    "amr",
-    "pdf",
-    "epub",
-    "exe",
-    "swf",
-    "rtf",
-    "woff",
-    "woff2",
-    "eot",
-    "ttf",
-    "otf",
-    "ico",
-    "flv",
-    "ps",
-    "xz",
-    "sqlite",
-    "xpi",
-    "cab",
-    "deb",
-    "ar",
-    "rpm",
-    "Z",
-    "lz",
-    "msi",
-    "mxf",
-    "mts",
-    "wasm",
-    "webassembly",
-    "blend",
-    "bpg"
-  ],
-  "devDependencies": {
-    "ava": "*",
-    "read-chunk": "^2.0.0",
-    "xo": "*"
-  }
-}

+ 27 - 14
src/views/ProviderView/AuthView.js

@@ -1,28 +1,41 @@
 const LoaderView = require('./Loader')
 const LoaderView = require('./Loader')
 const { h, Component } = require('preact')
 const { h, Component } = require('preact')
 
 
+class AuthBlock extends Component {
+  componentDidMount () {
+    this.connectButton.focus()
+  }
+
+  render () {
+    return <div class="uppy-Provider-auth">
+      <div class="uppy-Provider-authIcon">{this.props.pluginIcon()}</div>
+      <h1 class="uppy-Provider-authTitle">Please authenticate with <span class="uppy-Provider-authTitleName">{this.props.pluginName}</span><br /> to select files</h1>
+      <button
+        type="button"
+        class="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Provider-authBtn"
+        onclick={this.props.handleAuth}
+        ref={(el) => { this.connectButton = el }}
+      >
+        Connect to {this.props.pluginName}
+      </button>
+      {this.props.demo &&
+        <button class="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Provider-authBtn" onclick={this.props.handleDemoAuth}>Proceed with Demo Account</button>
+      }
+    </div>
+  }
+}
+
 class AuthView extends Component {
 class AuthView extends Component {
   componentDidMount () {
   componentDidMount () {
     this.props.checkAuth()
     this.props.checkAuth()
   }
   }
 
 
   render () {
   render () {
-    const AuthBlock = () => {
-      return <div class="uppy-Provider-auth">
-        <div class="uppy-Provider-authIcon">{this.props.pluginIcon()}</div>
-        <h1 class="uppy-Provider-authTitle">Please authenticate with <span class="uppy-Provider-authTitleName">{this.props.pluginName}</span><br /> to select files</h1>
-        <button type="button" class="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Provider-authBtn" onclick={this.props.handleAuth}>Connect to {this.props.pluginName}</button>
-        {this.props.demo &&
-          <button class="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Provider-authBtn" onclick={this.props.handleDemoAuth}>Proceed with Demo Account</button>
-        }
-      </div>
-    }
-
     return (
     return (
-      <div style="height: 100%;">
+      <div style={{ height: '100%' }}>
         {this.props.checkAuthInProgress
         {this.props.checkAuthInProgress
-          ? LoaderView()
-          : AuthBlock()
+          ? <LoaderView />
+          : <AuthBlock {...this.props} />
         }
         }
       </div>
       </div>
     )
     )

+ 3 - 1
src/views/ProviderView/Browser.js

@@ -22,6 +22,7 @@ module.exports = (props) => {
             directories: props.directories,
             directories: props.directories,
             title: props.title
             title: props.title
           })}
           })}
+          <span class="uppy-ProviderBrowser-user">{props.username}</span>
           <button type="button" onclick={props.logout} class="uppy-ProviderBrowser-userLogout">Log out</button>
           <button type="button" onclick={props.logout} class="uppy-ProviderBrowser-userLogout">Log out</button>
         </div>
         </div>
       </div>
       </div>
@@ -45,7 +46,8 @@ module.exports = (props) => {
         handleScroll: props.handleScroll,
         handleScroll: props.handleScroll,
         title: props.title,
         title: props.title,
         showTitles: props.showTitles,
         showTitles: props.showTitles,
-        getItemId: props.getItemId
+        getItemId: props.getItemId,
+        i18n: props.i18n
       })}
       })}
       <button class="UppyButton--circular UppyButton--blue uppy-ProviderBrowser-doneBtn"
       <button class="UppyButton--circular UppyButton--blue uppy-ProviderBrowser-doneBtn"
         type="button"
         type="button"

+ 6 - 3
src/views/ProviderView/Item.js

@@ -22,7 +22,7 @@ module.exports = (props) => {
       <div class="uppy-ProviderBrowserItem-checkbox">
       <div class="uppy-ProviderBrowserItem-checkbox">
         <input type="checkbox"
         <input type="checkbox"
           role="option"
           role="option"
-          tabindex="0"
+          tabindex={0}
           aria-label={`Select ${props.title}`}
           aria-label={`Select ${props.title}`}
           id={props.id}
           id={props.id}
           checked={props.isChecked}
           checked={props.isChecked}
@@ -31,12 +31,15 @@ module.exports = (props) => {
           onkeyup={stop}
           onkeyup={stop}
           onkeydown={stop}
           onkeydown={stop}
           onkeypress={stop} />
           onkeypress={stop} />
-        <label for={props.id} />
+        <label
+          for={props.id}
+          onclick={props.handleCheckboxClick}
+         />
       </div>
       </div>
       <button type="button"
       <button type="button"
         class="uppy-ProviderBrowserItem-inner"
         class="uppy-ProviderBrowserItem-inner"
         aria-label={`Select ${props.title}`}
         aria-label={`Select ${props.title}`}
-        tabindex="0"
+        tabindex={0}
         onclick={handleItemClick}>
         onclick={handleItemClick}>
         {props.getItemIcon()} {props.showTitles && props.title}
         {props.getItemIcon()} {props.showTitles && props.title}
       </button>
       </button>

+ 3 - 11
src/views/ProviderView/ItemList.js

@@ -2,17 +2,9 @@ const Row = require('./Item')
 const { h } = require('preact')
 const { h } = require('preact')
 
 
 module.exports = (props) => {
 module.exports = (props) => {
-  // const headers = props.columns.map((column) => {
-  //   return html`
-  //     <th class="uppy-ProviderBrowserTable-headerColumn uppy-ProviderBrowserTable-column" onclick=${props.sortByTitle}>
-  //       ${column.name}
-  //     </th>
-  //   `
-  // })
-
-  // <thead class="uppy-ProviderBrowserTable-header">
-  //   <tr>${headers}</tr>
-  // </thead>
+  if (!props.folders.length && !props.files.length) {
+    return <div class="uppy-Provider-empty">{props.i18n('noFilesFound')}</div>
+  }
 
 
   return (
   return (
     <div class="uppy-ProviderBrowser-body">
     <div class="uppy-ProviderBrowser-body">

+ 15 - 14
src/views/ProviderView/index.js

@@ -131,6 +131,7 @@ module.exports = class ProviderView {
           updatedDirectories = state.directories.concat([{id, title: name || this.plugin.getItemName(res)}])
           updatedDirectories = state.directories.concat([{id, title: name || this.plugin.getItemName(res)}])
         }
         }
 
 
+        this.username = this.username ? this.username : this.plugin.getUsername(res)
         this._updateFilesAndFolders(res, files, folders)
         this._updateFilesAndFolders(res, files, folders)
         this.plugin.setPluginState({ directories: updatedDirectories })
         this.plugin.setPluginState({ directories: updatedDirectories })
       },
       },
@@ -167,18 +168,16 @@ module.exports = class ProviderView {
       }
       }
     }
     }
 
 
-    Utils.getFileType(tagFile).then(fileType => {
-      if (fileType && Utils.isPreviewSupported(fileType)) {
-        tagFile.preview = this.plugin.getItemThumbnailUrl(file)
-      }
-      this.plugin.uppy.log('Adding remote file')
-      this.plugin.uppy.addFile(tagFile).catch(() => {
-        // Ignore
-      })
-      if (!isCheckbox) {
-        this.donePicking()
-      }
-    })
+    const fileType = Utils.getFileType(tagFile)
+    // TODO Should we just always use the thumbnail URL if it exists?
+    if (fileType && Utils.isPreviewSupported(fileType)) {
+      tagFile.preview = this.plugin.getItemThumbnailUrl(file)
+    }
+    this.plugin.uppy.log('Adding remote file')
+    this.plugin.uppy.addFile(tagFile)
+    if (!isCheckbox) {
+      this.donePicking()
+    }
   }
   }
 
 
   /**
   /**
@@ -186,7 +185,6 @@ module.exports = class ProviderView {
    */
    */
   logout () {
   logout () {
     this.Provider.logout(location.href)
     this.Provider.logout(location.href)
-      .then((res) => res.json())
       .then((res) => {
       .then((res) => {
         if (res.ok) {
         if (res.ok) {
           const newState = {
           const newState = {
@@ -488,6 +486,7 @@ module.exports = class ProviderView {
     const link = `${this.Provider.authUrl()}?state=${authState}`
     const link = `${this.Provider.authUrl()}?state=${authState}`
 
 
     const authWindow = window.open(link, '_blank')
     const authWindow = window.open(link, '_blank')
+    authWindow.opener = null
     const checkAuth = () => {
     const checkAuth = () => {
       let authWindowUrl
       let authWindowUrl
 
 
@@ -567,6 +566,7 @@ module.exports = class ProviderView {
     }
     }
 
 
     const browserProps = Object.assign({}, this.plugin.getPluginState(), {
     const browserProps = Object.assign({}, this.plugin.getPluginState(), {
+      username: this.username,
       getNextFolder: this.getNextFolder,
       getNextFolder: this.getNextFolder,
       getFolder: this.getFolder,
       getFolder: this.getFolder,
       addFile: this.addFile,
       addFile: this.addFile,
@@ -590,7 +590,8 @@ module.exports = class ProviderView {
       showTitles: this.opts.showTitles,
       showTitles: this.opts.showTitles,
       showFilter: this.opts.showFilter,
       showFilter: this.opts.showFilter,
       showBreadcrumbs: this.opts.showBreadcrumbs,
       showBreadcrumbs: this.opts.showBreadcrumbs,
-      pluginIcon: this.plugin.icon
+      pluginIcon: this.plugin.icon,
+      i18n: this.plugin.uppy.i18n
     })
     })
 
 
     return Browser(browserProps)
     return Browser(browserProps)

+ 1 - 5
test/endtoend/src/main.js

@@ -15,7 +15,6 @@ const uppyDragDrop = Uppy({
   })
   })
   .use(ProgressBar, { target: '#uppyDragDrop-progress' })
   .use(ProgressBar, { target: '#uppyDragDrop-progress' })
   .use(Tus, { endpoint: 'https://master.tus.io/files/' })
   .use(Tus, { endpoint: 'https://master.tus.io/files/' })
-  .run()
 
 
 const uppyi18n = Uppy({
 const uppyi18n = Uppy({
   id: 'uppyi18n',
   id: 'uppyi18n',
@@ -25,14 +24,13 @@ const uppyi18n = Uppy({
     target: '#uppyi18n',
     target: '#uppyi18n',
     locale: {
     locale: {
       strings: {
       strings: {
-        dropHereOr: 'Перенесите файлы сюда или',
+        dropHereOr: 'Перенесите файлы сюда или %{browse}',
         browse: 'выберите'
         browse: 'выберите'
       }
       }
     }
     }
   })
   })
   .use(ProgressBar, { target: '#uppyi18n-progress' })
   .use(ProgressBar, { target: '#uppyi18n-progress' })
   .use(XHRUpload, { endpoint: 'https://api2.transloadit.com' })
   .use(XHRUpload, { endpoint: 'https://api2.transloadit.com' })
-  .run()
 
 
 const uppyDashboard = Uppy({
 const uppyDashboard = Uppy({
   id: 'uppyDashboard',
   id: 'uppyDashboard',
@@ -43,7 +41,6 @@ const uppyDashboard = Uppy({
     inline: true
     inline: true
   })
   })
   .use(Tus, { endpoint: 'https://master.tus.io/files/' })
   .use(Tus, { endpoint: 'https://master.tus.io/files/' })
-  .run()
 
 
 function startXHRLimitTest (endpoint) {
 function startXHRLimitTest (endpoint) {
   const uppy = Uppy({
   const uppy = Uppy({
@@ -53,7 +50,6 @@ function startXHRLimitTest (endpoint) {
   })
   })
     .use(DragDrop, { target: '#uppyXhrLimit' })
     .use(DragDrop, { target: '#uppyXhrLimit' })
     .use(XHRUpload, { endpoint, limit: 2 })
     .use(XHRUpload, { endpoint, limit: 2 })
-    .run()
 
 
   uppy.uploadsStarted = 0
   uppy.uploadsStarted = 0
   uppy.uploadsComplete = 0
   uppy.uploadsComplete = 0

Some files were not shown because too many files changed in this diff