diff --git a/js/components/ascribe_uploader/ui/upload_button.js b/js/components/ascribe_uploader/ui/upload_button.js
new file mode 100644
index 00000000..6a758be6
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_button.js
@@ -0,0 +1,126 @@
+import React from 'react';
+import classNames from 'classnames';
+
+import Uploadify from 'react-utility-belt/es6/uploader/uploadify';
+import { UploadButtonBase } from 'react-utility-belt/es6/uploader/upload_button';
+import { uploadedFilesFilter, uploadingFilesFilter } from 'react-utility-belt/es6/uploader/utils/file_filters';
+import uploaderSpecExtender from 'react-utility-belt/es6/uploader/utils/uploader_spec_extender';
+
+import Uploader from '../uploader';
+
+import { safeInvoke } from '../../../utils/general';
+import { getLangText } from '../../../utils/lang';
+import { truncateText } from '../../../utils/text';
+
+
+const { arrayOf, func, object, shape, string } = React.PropTypes;
+
+const FileLabel = ({ files, handleRemoveFiles }) => {
+ let label;
+
+ if (files.length) {
+ const uploadedFiles = files.filter(uploadedFilesFilter);
+ const uploadingFiles = files.filter(uploadingFilesFilter);
+
+ const uploadedIcon = uploadedFiles.length && !uploadingFiles.length
+ ? ( )
+ : null;
+
+ const labelText = files.length > 1 ? `${files.length} ${getLangText('files')}`
+ : truncateText(files[0].name, 40);
+
+ const removeActionText = getLangText(
+ uploadingFiles.length ? `cancel ${files.length > 1 ? 'uploads' : 'upload'}`
+ : 'remove'
+ );
+
+ label = [
+ uploadedIcon,
+ labelText,
+ ' [',
+ ({removeActionText} ),
+ ']'
+ ];
+ } else {
+ label = getLangText('No file selected');
+ }
+
+ return ({label} );
+};
+
+FileLabel.propTypes = {
+ files: arrayOf(shape({
+ name: string.isRequired
+ })).isRequired,
+
+ handleRemoveFiles: func.isRequired
+};
+
+
+const UploadButton = ({ className, ...props }) => (
+
+);
+
+UploadButton.propTypes = {
+ className: string
+};
+
+UploadButton.defaultProps = {
+ buttonType: 'button',
+ getUploadingButtonLabel: (uploaderFiles, progress) => (
+ `${getLangText('Upload progress')}: ${progress}%`
+ ),
+ fileLabelType: FileLabel
+};
+
+
+/**
+ * We want to add some additional default uploader functionality on top of the UI defaults we've
+ * added to the base upload button, so we wrap another class around the Uploadified instance of our
+ * UploadButton and export that as our default export.
+ */
+const UploadifiedUploadButton = Uploadify(UploadButton);
+const UploadButtonUploadifyWrapper = React.createClass(uploaderSpecExtender({
+ displayName: 'UploadbuttonUploadifyWrapper',
+
+ propTypes: {
+ uploaderProps: object,
+ uploaderType: func
+ },
+
+ getDefaultProps() {
+ return {
+ uploaderType: Uploader
+ };
+ },
+
+ onError(file, ...args) {
+ // Automatically cancel any files that fail.
+ this.refs.uploader.handleCancelFile(file);
+
+ safeInvoke(this.props.uploaderProps.onError, file, ...args);
+ },
+
+ render() {
+ const { uploaderProps, ...restProps } = this.props;
+ const props = {
+ ...restProps,
+
+ uploaderProps: {
+ ...uploaderProps,
+ onError: this.onError
+ }
+ };
+
+ return (
+
+ );
+ }
+}));
+
+export default UploadButtonUploadifyWrapper;
+
+// Also export the non-uploadify version for extension
+export {
+ UploadButton as UploadButtonBase
+};
diff --git a/js/components/ascribe_uploader/ui/upload_file_dialog/index.js b/js/components/ascribe_uploader/ui/upload_file_dialog/index.js
new file mode 100644
index 00000000..8b4b8d4f
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_dialog/index.js
@@ -0,0 +1,5 @@
+// Make it easier for users to import this component by default exporting the container in an
+// index.js
+export { default } from './upload_file_dialog';
+
+// Components originally adapted from https://github.com/fedosejev/react-file-drag-and-drop
diff --git a/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog.js b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog.js
new file mode 100644
index 00000000..f24bdd9e
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog.js
@@ -0,0 +1,281 @@
+import React from 'react';
+import update from 'react-addons-update';
+
+import UploadDragAndDropArea from 'react-utility-belt/es6/uploader/upload_drag_and_drop_area';
+import uploaderSpecExtender from 'react-utility-belt/es6/uploader/utils/uploader_spec_extender';
+
+import ErrorQueueStore from '../../../../stores/error_queue_store';
+
+import UploadFileDialogUI from './upload_file_dialog_ui';
+
+import Uploader from '../../uploader';
+
+import { ErrorClasses, testErrorAgainstAll } from '../../../../constants/error_constants';
+import { UploadMethods, RETRY_ATTEMPT_TO_SHOW_CONTACT_US } from '../../../../constants/uploader_constants';
+
+import { safeInvoke } from '../../../../utils/general';
+import { getCurrentQueryParams } from '../../../../utils/url';
+
+
+const { bool, func, object, oneOf, shape, string } = React.PropTypes;
+
+const UploadFileDialog = React.createClass(uploaderSpecExtender({
+ propTypes: {
+ /**
+ * Upload method to use. Defaults to normal uploading of files as-received.
+ *
+ * If UploadMethods.USE_URL_PARAM is set, check the current url's upload_method parameter to
+ * determine the upload method. For example, `https://www.ascribe.io/app/register_work?upload_method=hash`
+ * will use file hashing before uploading.
+ */
+ uploadMethod: oneOf([
+ UploadMethods.HASH,
+ UploadMethods.NORMAL,
+ UploadMethods.USE_URL_PARAM
+ ]).isRequired,
+
+ /**
+ * In the case that this dialog is used to load uploaded files from a previous session, it
+ * can sometimes be useful to disallow the ability to download or edit (ie. delete) those
+ * files.
+ */
+ areAssetsDownloadable: bool,
+ areAssetsEditable: bool,
+
+ className: string,
+ disabled: bool,
+ fileTypeNames: shape({
+ plural: string.isRequired,
+ singular: string.isRequired
+ }),
+
+ // For Uploadify (and UploadDragAndDropArea)
+ // eslint-disable-next-line react/sort-prop-types
+ uploaderProps: object.isRequired,
+
+ uploaderType: func
+
+ // Note that any drag event callbacks specified through the props will be properly attached
+ // to their events by UploadDragAndDropArea.
+ },
+
+ getDefaultProps() {
+ return {
+ areAssetsDownloadable: true,
+ areAssetsEditable: true,
+ fileTypeNames: {
+ plural: 'files',
+ singular: 'file'
+ },
+ uploadMethod: UploadMethods.NORMAL,
+ uploaderType: Uploader
+ };
+ },
+
+ getInitialState() {
+ return {
+ errorClass: null,
+ hashingFiles: [],
+ manualRetryAttempt: 0,
+ thumbnailMapping: {},
+ uploadInProgress: false
+ };
+ },
+
+ getUploadErrorClass({ type = 'upload', reason, xhr }) {
+ const { manualRetryAttempt } = this.state;
+ let matchedErrorClass;
+
+ if ('onLine' in window.navigator && !window.navigator.onLine) {
+ // If the user's offline, this is definitely the most important error to show.
+ // TODO: use a better mechanism for checking network state, ie. offline.js
+ matchedErrorClass = ErrorClasses.upload.offline;
+ } else if (manualRetryAttempt === RETRY_ATTEMPT_TO_SHOW_CONTACT_US) {
+ // Use the contact us error class if they've retried a number of times
+ // and are still unsuccessful
+ matchedErrorClass = ErrorClasses.upload.contactUs;
+ } else {
+ matchedErrorClass = testErrorAgainstAll({ type, reason, xhr });
+
+ if (!matchedErrorClass) {
+ // If none found, show the next error message in the queue for upload errors
+ matchedErrorClass = ErrorQueueStore.getNextError('upload');
+ }
+ }
+
+ return matchedErrorClass;
+ },
+
+ setThumbnailForFile(file, thumbnailUrl) {
+ if ('id' in file) {
+ this.setState({
+ thumbnailMapping: update(this.state.thumbnailMapping, {
+ [file.id]: { $set: thumbnailUrl }
+ })
+ });
+ } else {
+ console.logGlobal(
+ new Error('Attempt to set the thumbnail of a file without an id'),
+ { file, thumbnailUrl }
+ );
+ }
+ },
+
+ // Override Uploader's showErrorNotification to always show its generic message since we are
+ // already displaying the error in this dialog
+ showErrorNotification() {
+ Uploader.showErrorNotification();
+ },
+
+ onAllComplete(...args) {
+ this.setState({ uploadInProgress: false });
+
+ safeInvoke(this.props.uploaderProps.onAllComplete, ...args);
+ },
+
+ onError(file, errorReason, xhr, ...args) {
+ // If we've already found an error, just ignore other errors that pop up. They'll likely
+ // pop up again when the user retries.
+ if (!this.state.errorClass) {
+ this.setState({
+ errorClass: this.getUploadErrorClass({
+ reason: errorReason,
+ xhr
+ })
+ });
+ }
+
+ safeInvoke(this.props.uploaderProps.onError, file, errorReason, xhr, ...args);
+ },
+
+ onFileHashError(error, ...args) {
+ // Clear our tracked hashing files since they've failed
+ this.setState({
+ hashingFiles: []
+ });
+
+ const { onFileHashError } = this.props.uploaderProps;
+ const { invoked, result } = safeInvoke(onFileHashError, error, ...args);
+
+ if (invoked) {
+ return result;
+ } else {
+ // Just rethrow the error if no other onFileHashError was specified
+ throw error;
+ }
+ },
+
+ onFileHashProgress(file, hashId, progress, ...args) {
+ const { hashingFiles } = this.state;
+
+ // Note that if we've previously cleared our tracked hashing files before (because they've
+ // previously succeeded or failed), the hashIds won't start from 0 to fill up the array from
+ // the start. This is OK since Javascript's arrays are sparse, and the usual array
+ // operations (besides basic `for` loop) will skip any holes.
+ let updatedHashingFiles;
+ if (hashingFiles[hashId]) {
+ updatedHashingFiles = update(hashingFiles, {
+ [hashId]: {
+ progress: { $set: progress }
+ }
+ });
+ } else {
+ updatedHashingFiles = update(hashingFiles, {
+ [hashId]: {
+ $set: { file, progress }
+ }
+ });
+ }
+
+ this.setState({
+ hashingFiles: updatedHashingFiles
+ });
+
+ const { onFileHashProgress } = this.props.uploaderProps;
+ const { invoked, result } = safeInvoke(onFileHashProgress, file, hashId, progress, ...args);
+
+ return invoked ? result : undefined;
+ },
+
+ onFileHashSuccess(files, ...args) {
+ // Clear our tracked hashing files since they've succeeded
+ this.setState({
+ hashingFiles: []
+ });
+
+ const { onFileHashSuccess } = this.props.uploaderProps;
+ const { invoked, result } = safeInvoke(onFileHashSuccess, files, ...args);
+
+ return invoked ? result : files;
+ },
+
+ onManualRetry(...args) {
+ this.setState({
+ manualRetryAttempt: this.state.manualRetryAttempt + 1
+ });
+
+ safeInvoke(this.props.uploaderProps.onManualRetry, ...args);
+ },
+
+ onUpload(...args) {
+ if (!this.state.uploadInProgress) {
+ this.setState({ uploadInProgress: true });
+ }
+
+ safeInvoke(this.props.uploaderProps.onUpload, ...args);
+ },
+
+ render() {
+ const {
+ areAssetsEditable,
+ className,
+ disabled: isDisabled,
+ uploaderProps,
+ uploadMethod: method,
+ ...restProps
+ } = this.props;
+ const { uploadInProgress, errorClass } = this.state;
+
+ const uploadMethod = method === UploadMethods.USE_URL_PARAM
+ // If `upload_method` isn't in the current query parameters, tell the UI that we want
+ // it to control which method gets used by setting the url parameter
+ ? getCurrentQueryParams().uploadMethod || UploadMethods.USE_URL_PARAM
+ : method;
+
+ const disabled = isDisabled || uploadInProgress || errorClass || !uploadMethod;
+
+ const props = {
+ ...restProps,
+
+ className,
+ uploadMethod,
+ areAssetsEditable: !isDisabled && areAssetsEditable,
+
+ // Only show the error state once all files are finished
+ showError: !uploadInProgress && errorClass,
+
+ uploaderProps: {
+ ...uploaderProps,
+ hashLocally: uploadMethod === UploadMethods.HASH,
+ onAllComplete: this.onAllComplete,
+ onError: this.onError,
+ onFileHashError: this.onFileHashError,
+ onFileHashProgress: this.onFileHashProgress,
+ onFileHashSuccess: this.onFileHashSuccess,
+ onManualRetry: this.onManualRetry,
+ onUpload: this.onUpload,
+ showErrorNotification: this.showErrorNotification
+ }
+ };
+
+ // All props meant for UploadFileDialogUI will be passed through by
+ // UploadDragAndDropArea.
+ return (
+
+
+
+ );
+ }
+}));
+
+export default UploadFileDialog;
diff --git a/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_error_handler.js b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_error_handler.js
new file mode 100644
index 00000000..14e71c2d
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_error_handler.js
@@ -0,0 +1,126 @@
+import React from 'react';
+import classNames from 'classnames';
+
+import { ErrorClasses } from '../../../../constants/error_constants';
+
+import { getLangText } from '../../../../utils/lang';
+
+
+const { arrayOf, bool, func, node, number, object, shape, string } = React.PropTypes;
+
+/** HELPER COMPONENTS **/
+// Retry button
+const UploadErrorRetryButton = ({ children, handleRetryFiles, openIntercom }) => (
+ {
+ if (openIntercom) {
+ window.Intercom('showNewMessage', getLangText("I'm having trouble uploading my file."));
+ }
+
+ handleRetryFiles();
+ }}
+ type="button">
+ {children}
+
+);
+
+UploadErrorRetryButton.propTypes = {
+ handleRetryFiles: func.isRequired,
+
+ children: node,
+ openIntercom: bool
+};
+
+// Contact us dialog
+const UploadErrorContactUs = ({ handleRetryFiles }) => (
+
+
{getLangText('Let us help you')}
+
{getLangText('Still having problems? Send us a message.')}
+
+ {getLangText('Contact us')}
+
+
+);
+
+UploadErrorContactUs.propTypes = {
+ handleRetryFiles: func.isRequired
+};
+
+// Error details dialog
+const UploadErrorDetails = ({ errorClass: { prettifiedText }, failedFiles, handleRetryFiles }) => (
+
+
+
{getLangText(failedFiles.length ? 'Some files did not upload correctly'
+ : 'Error uploading the file!')}
+
+
{prettifiedText}
+
+ {getLangText('Retry')}
+
+
+
+
+
+
+
+ {failedFiles.map(({ id, originalName }) => (
+ {originalName}
+ ))}
+
+
+
+);
+
+UploadErrorDetails.propTypes = {
+ errorClass: shape({
+ prettifiedText: string.isRequired
+ }).isRequired,
+ failedFiles: arrayOf(shape({
+ id: number.isRequired,
+ originalName: string.isRequired
+ })).isRequired,
+ handleRetryFiles: func.isRequired
+};
+
+
+/** CONTAINER COMPONENT **/
+// Displays an error detail dialog or a contact us dialog depending on the type of upload error
+// encountered.
+const UploadFileDialogErrorHandler = ({ errorClass, failedFiles, handleRetryFile, ...props }) => {
+ // Just go through and retry all the files if the user wants to retry
+ const handleRetryFiles = () => {
+ failedFiles.forEach(handleRetryFile);
+ };
+
+ const dialogProps = {
+ ...props,
+ errorClass,
+ failedFiles,
+ handleRetryFiles
+ };
+
+ return errorClass.name === ErrorClasses.upload.contactUs.name
+ ? ( )
+ : ( );
+};
+
+UploadFileDialogErrorHandler.propTypes = {
+ errorClass: shape({
+ name: string
+ }).isRequired,
+ failedFiles: arrayOf(object).isRequired,
+ handleRetryFile: func.isRequired
+};
+
+export default UploadFileDialogErrorHandler;
diff --git a/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_file_selector.js b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_file_selector.js
new file mode 100644
index 00000000..ffa24f6b
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_file_selector.js
@@ -0,0 +1,70 @@
+import React from 'react';
+
+import { dragAndDropAvailable } from 'js-utility-belt/es6/feature_detection';
+
+import UploadFileDialogPrintWrapper from './upload_file_dialog_print_wrapper';
+
+import { UploadMethods } from '../../../../constants/uploader_constants';
+
+import { getLangText } from '../../../../utils/lang';
+
+
+const { bool, func, oneOf, shape, string } = React.PropTypes;
+
+const propTypes = {
+ fileTypeNames: shape({
+ plural: string.isRequired,
+ singular: string.isRequired
+ }).isRequired,
+ uploadMethod: oneOf([
+ UploadMethods.HASH,
+ UploadMethods.NORMAL,
+ UploadMethods.USE_URL_PARAM
+ ]).isRequired,
+
+ handleSelectFiles: func,
+ multiple: bool
+};
+
+const getDragDialog = (fileTypeName) => {
+ if (dragAndDropAvailable) {
+ return [
+
+ {getLangText('Drag %s here', fileTypeName)}
+
,
+ {getLangText('or')}
+ ];
+ } else {
+ return null;
+ }
+};
+
+const UploadFileDialogFileSelector = ({ fileTypeNames, handleSelectFiles, multiple, uploadMethod }) => {
+ const uploadMethodName = uploadMethod === UploadMethods.HASH ? 'hash' : 'upload';
+
+ if (uploadMethod !== UploadMethods.HASH || uploadMethod !== UploadMethods.NORMAL) {
+ console.logGlobal(
+ new Error('Unsupported upload method given to UploadFileSelector'),
+ { uploadMethod }
+ );
+ }
+
+ const buttonMsg = multiple
+ ? getLangText(`choose %s to ${uploadMethodName}`, fileTypeNames.plural)
+ : getLangText(`choose a %s to ${uploadMethodName}`, fileTypeNames.singular);
+
+ return (
+
+ {getDragDialog(multiple ? fileTypeNames.plural : fileTypeNames.singular)}
+
+ {buttonMsg}
+
+
+ );
+};
+
+UploadFileDialogFileSelector.propTypes = propTypes;
+
+export default UploadFileDialogPrintWrapper(UploadFileDialogFileSelector);
diff --git a/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_hash_progress.js b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_hash_progress.js
new file mode 100644
index 00000000..f2078cef
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_hash_progress.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import ProgressBar from 'react-bootstrap/lib/ProgressBar';
+
+import UploadFileDialogPrintWrapper from './upload_file_dialog_print_wrapper';
+
+import { getLangText } from '../../../../utils/lang';
+
+
+const { arrayOf, func, number, shape } = React.PropTypes;
+
+const propTypes = {
+ handleCancelHashing: func.isRequired,
+ hashingFiles: arrayOf(shape({
+ progress: number.isRequired
+ })).isRequired
+};
+
+const UploadFileDialogHashProgress = ({ handleCancelHashing, hashingFiles }) => {
+ const hashingProgress = 100 * hashingFiles
+ .reduce((total, { progress }) => total + (progress / hashingFiles.length), 0);
+
+ return (
+
+ );
+};
+
+UploadFileDialogHashProgress.propTypes = propTypes;
+
+export default UploadFileDialogPrintWrapper(UploadFileDialogHashProgress);
diff --git a/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_method_selector.js b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_method_selector.js
new file mode 100644
index 00000000..46004299
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_method_selector.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import Link from 'react-router/es6/Link';
+
+import UploadFileDialogPrintWrapper from './upload_file_dialog_print_wrapper';
+
+import withContext from '../../../context/with_context';
+
+import { locationShape } from '../../../prop_types';
+
+import { UploadMethods } from '../../../../constants/uploader_constants';
+
+import { getLangText } from '../../../../utils/lang';
+import { getCurrentQueryParams } from '../../../../utils/url';
+
+
+const propTypes = {
+ location: locationShape.isRequired
+};
+
+const UploadFileDialogMethodSelector = ({ location }) => {
+ const currentQueryParams = getCurrentQueryParams();
+ const queryParamsHash = Object.assign({}, currentQueryParams, {
+ uploadMethod: UploadMethods.HASH
+ });
+ const queryParamsNormal = Object.assign({}, currentQueryParams, {
+ uploadMethod: UploadMethods.NORMAL
+ });
+
+ return (
+
+
+
+ {getLangText('Would you rather')}
+
+
+
+ {getLangText('Hash your work')}
+
+
+
{getLangText('or')}
+
+
+ {getLangText('Upload and hash your work')}
+
+
+
+
+ );
+};
+
+UploadFileDialogMethodSelector.propTypes = propTypes;
+
+export default UploadFileDialogPrintWrapper(withContext(UploadFileDialogMethodSelector, 'location'));
diff --git a/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_preview_iterator.js b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_preview_iterator.js
new file mode 100644
index 00000000..96fe75ae
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_preview_iterator.js
@@ -0,0 +1,60 @@
+import React from 'react';
+
+import UploadFilePreview from '../upload_file_preview';
+
+
+const { arrayOf, func, object } = React.PropTypes;
+
+const propTypes = {
+ files: arrayOf(object).isRequired,
+
+ // Mapping of file ids to thumbnail urls for the file previews.
+ // If the file already has a `thumbnailUrl` property, prefer that over checking this mapping.
+ thumbnailMapping: object,
+
+ // Props used by UploadFilePreview
+ handleCancelFile: func.isRequired,
+ handleDeleteFile: func.isRequired,
+ handlePauseFile: func.isRequired,
+ handleResumeFile: func.isRequired,
+
+ // All other props are passed down to UploadFilePreviews, including:
+ // * downloadable: enable files to be downloaded
+ // * pausable: enable pause / resume functionality
+ // * removable: enable files to be removed
+};
+
+const UploadFileDialogPreviewIterator = ({ files, thumbnailMapping, ...props }) => {
+ if (files.length) {
+ const multipleFiles = files.length > 1;
+
+ return (
+
+ {files.map((file, i) => {
+ // Try to use an id from the file, but if we can't find one, just use its array
+ // index.
+ const key = file.uuid || file.id || i;
+
+ const thumbnailUrl = file.thumbnailUrl ||
+ (thumbnailMapping && 'id' in file ? thumbnailMapping[file.id] : null);
+
+ return (
+
+ );
+ })}
+
+ );
+ } else {
+ return null;
+ }
+};
+
+UploadFileDialogPreviewIterator.propTypes = propTypes;
+
+export default UploadFileDialogPreviewIterator;
diff --git a/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_print_wrapper.js b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_print_wrapper.js
new file mode 100644
index 00000000..7d7f1a7a
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_print_wrapper.js
@@ -0,0 +1,24 @@
+import React from 'react';
+
+import { getLangText } from '../../../../utils/lang';
+
+
+/**
+ * Wrapper component to hide the given upload file dialog component from being printed, instead
+ * replacing it with a message saying that there's nothing uploaded yet. Useful for UI components
+ * that are used to show a pre-upload state of the upload file dialog.
+ */
+const UploadFileDialogPrintWrapper = (Component) => (
+ (props) => (
+
+
+
+
+
+ {getLangText('No files uploaded')}
+
+
+ )
+);
+
+export default UploadFileDialogPrintWrapper;
diff --git a/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_ui.js b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_ui.js
new file mode 100644
index 00000000..212516df
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_dialog/upload_file_dialog_ui.js
@@ -0,0 +1,146 @@
+import React from 'react';
+import classNames from 'classnames';
+
+import { failedFilesFilter, validProgressFilesFilter } from 'react-utility-belt/es6/uploader/utils/file_filters';
+
+import UploadFileDialogErrorHandler from './upload_file_dialog_error_handler';
+import UploadFileDialogFileSelector from './upload_file_dialog_file_selector';
+import UploadFileDialogHashProgress from './upload_file_dialog_hash_progress';
+import UploadFileDialogMethodSelector from './upload_file_dialog_method_selector';
+import UploadFileDialogPreviewIterator from './upload_file_dialog_preview_iterator';
+
+import UploadProgressBar from '../upload_progress_bar';
+
+import { UploadMethods } from '../../../../constants/uploader_constants';
+
+
+const { arrayOf, bool, func, number, object, oneOf, shape, string } = React.PropTypes;
+
+const propTypes = {
+ /**
+ * Upload method being used by the uploader.
+ *
+ * If USE_URL_PARAM is used, assume that the upload method is being determined by the url
+ * parameters and show a selector that will modify the current url's query parameters to be
+ * one of the available methods.
+ */
+ uploadMethod: oneOf([
+ UploadMethods.HASH,
+ UploadMethods.NORMAL,
+ UploadMethods.USE_URL_PARAM
+ ]).isRequired,
+
+ areAssetsDownloadable: bool,
+ areAssetsEditable: bool,
+ disabled: bool,
+ errorClass: object,
+ fileTypeNames: shape({
+ plural: string.isRequired,
+ singular: string.isRequired
+ }),
+ hashingFiles: arrayOf(shape({
+ progress: number.isRequired
+ })),
+ showError: bool,
+ thumbnailMapping: object,
+
+ // Provided by ReactS3FineUploader
+ // eslint-disable-next-line react/sort-prop-types
+ uploaderFiles: arrayOf(object).isRequired,
+ multiple: bool
+
+ // All other props are passed through to the child components
+};
+
+const contextTypes = {
+ handleCancelFile: func.isRequired,
+ handleCancelHashing: func.isRequired,
+ handleDeleteFile: func.isRequired,
+ handlePauseFile: func.isRequired,
+ handleResumeFile: func.isRequired,
+ handleRetryFile: func.isRequired,
+ handleSelectFiles: func.isRequired
+};
+
+const UploadFileDialogUI = ({
+ areAssetsDownloadable,
+ areAssetsEditable,
+ disabled,
+ errorClass,
+ hashingFiles,
+ multiple,
+ showError,
+ thumbnailMapping,
+ uploaderFiles,
+ uploadMethod,
+ ...props
+}, {
+ handleCancelFile,
+ handleCancelHashing,
+ handleDeleteFile,
+ handlePauseFile,
+ handleResumeFile,
+ handleRetryFile,
+ handleSelectFiles
+}) => {
+ let uploaderUI;
+ if (uploadMethod === UploadMethods.USE_URL_PARAMS) {
+ // Show upload method selector that will change the current url's query parameters
+ uploaderUI = (
+
+ );
+ } else if (Array.isArray(hashingFiles) && hashingFiles.length) {
+ uploaderUI = (
+
+ );
+ } else {
+ const failedFiles = uploaderFiles.filter(failedFilesFilter);
+ const validFiles = uploaderFiles.filter(validProgressFilesFilter);
+
+ if (errorClass && showError && failedFiles.length) {
+ uploaderUI = (
+
+ );
+ } else if (validFiles.length) {
+ uploaderUI = [(
+
+ ), (
+
+ )];
+ } else {
+ uploaderUI = (
+
+ );
+ }
+ }
+
+ return (
+
+ {uploaderUI}
+
+ );
+};
+
+UploadFileDialogUI.propTypes = propTypes;
+UploadFileDialogUI.contextTypes = contextTypes;
+
+export default UploadFileDialogUI;
diff --git a/js/components/ascribe_uploader/ui/upload_file_preview/index.js b/js/components/ascribe_uploader/ui/upload_file_preview/index.js
new file mode 100644
index 00000000..c80417ca
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_preview/index.js
@@ -0,0 +1,3 @@
+// Make it easier for users to import this component by default exporting the container in an
+// index.js
+export { default } from './upload_file_preview';
diff --git a/js/components/ascribe_uploader/ui/upload_file_preview/upload_file_preview.js b/js/components/ascribe_uploader/ui/upload_file_preview/upload_file_preview.js
new file mode 100644
index 00000000..333d60b1
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_preview/upload_file_preview.js
@@ -0,0 +1,140 @@
+import React from 'react';
+import classNames from 'classnames';
+
+import { pausedFilesFilter, uploadedFilesFilter, uploadingFilesFilter } from 'react-utility-belt/es6/uploader/utils/file_filters';
+
+import UploadFilePreviewImage from './upload_file_preview_image';
+import UploadFilePreviewOther from './upload_file_preview_other';
+
+import UploadProgressBar from '../upload_progress_bar';
+
+import { extractFileExtensionFromString } from '../../../../utils/file';
+import { getLangText } from '../../../../utils/lang';
+import { truncateText } from '../../../../utils/text';
+
+
+const { bool, func, shape, string } = React.PropTypes;
+
+const UploadFileName = ({ file: { name } }) => (
+
+ {truncateText(name, 30, `(...).${extractFileExtensionFromString(name)}`)}
+
+);
+
+UploadFileName.propTypes = {
+ file: shape({
+ name: string.isRequired
+ }).isRequired
+};
+
+const UploadRemoveButton = ({ handleRemoveFile }) => (
+
+
+
+);
+
+UploadRemoveButton.propTypes = {
+ handleRemoveFile: func.isRequired
+};
+
+
+const UploadFilePreview = React.createClass({
+ propTypes: {
+ file: shape({
+ name: string.isRequired,
+ type: string.isRequired
+ }).isRequired,
+ handleCancelFile: func.isRequired,
+ handleDeleteFile: func.isRequired,
+ handlePauseFile: func.isRequired,
+ handleResumeFile: func.isRequired,
+
+ className: string,
+ downloadable: bool,
+ pausable: bool,
+ removable: bool,
+ showName: bool,
+ showProgress: bool,
+ thumbnailUrl: string
+ },
+
+ handleRemoveFile() {
+ const { file, handleCancelFile, handleDeleteFile } = this.props;
+
+ // We only want to delete when we're sure that the file has been *completely* uploaded to S3
+ // and can now be properly deleted using an HTTP DELETE request.
+ if (uploadedFilesFilter(file) && file.s3UrlSafe) {
+ handleDeleteFile(file);
+ } else {
+ handleCancelFile(file);
+ }
+ },
+
+ toggleUploadProcess() {
+ const { file, handlePauseFile, handleResumeFile } = this.props;
+
+ if (uploadingFilesFilter(file)) {
+ handlePauseFile(file.id);
+ } else if (pausedFilesFilter(file)) {
+ handleResumeFile(file.id);
+ } else {
+ console.logGlobal(
+ new Error('Tried to pause / resume upload of file that was not in a paused or ' +
+ 'uploading state'),
+ { file }
+ );
+ }
+ },
+
+ render() {
+ const {
+ className,
+ downloadable,
+ file,
+ pausable,
+ removable,
+ showName,
+ showProgress,
+ thumbnailUrl
+ } = this.props;
+
+ const previewProps = {
+ downloadable,
+ file,
+ pausable,
+ toggleUploadProcess: this.toggleUploadProcess
+ };
+
+ // Decide whether an image or a placeholder thumbnail should be displayed
+ // Even if a file is not an image, we'll display it as an image if it has has a thumbnail
+ const previewElement = (thumbnailUrl || file.type.split('/')[0] === 'image')
+ ? ( )
+ : ( );
+
+ const fileProgressBar = showProgress ? (
+
+ ) : null;
+
+ const fileRemoveButton = removable ? (
+
+ ) : null;
+
+ const fileName = showName ? ( ) : null;
+
+ return (
+
+
+ {fileProgressBar}
+ {previewElement}
+ {fileRemoveButton}
+
+ {fileName}
+
+ );
+ }
+});
+
+export default UploadFilePreview;
diff --git a/js/components/ascribe_uploader/ui/upload_file_preview/upload_file_preview_image.js b/js/components/ascribe_uploader/ui/upload_file_preview/upload_file_preview_image.js
new file mode 100644
index 00000000..9e85714a
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_preview/upload_file_preview_image.js
@@ -0,0 +1,29 @@
+import React from 'react';
+
+import UploadFilePreviewTypeWrapper from './upload_file_preview_type_wrapper';
+
+
+const { node, string } = React.PropTypes;
+
+const propTypes = {
+ thumbnailUrl: string.isRequired,
+
+ children: node
+};
+
+const UploadFilePreviewImage = ({ children, thumbnailUrl }) => {
+ const imageStyle = {
+ backgroundImage: `url("${thumbnailUrl}")`,
+ backgroundSize: 'cover'
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+UploadFilePreviewImage.propTypes = propTypes;
+
+export default UploadFilePreviewTypeWrapper(UploadFilePreviewImage);
diff --git a/js/components/ascribe_uploader/ui/upload_file_preview/upload_file_preview_other.js b/js/components/ascribe_uploader/ui/upload_file_preview/upload_file_preview_other.js
new file mode 100644
index 00000000..2365bd71
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_preview/upload_file_preview_other.js
@@ -0,0 +1,32 @@
+import React from 'react';
+
+import UploadFilePreviewTypeWrapper from './upload_file_preview_type_wrapper';
+
+import { extractFileExtensionFromString } from '../../../../utils/file';
+
+
+const { bool, node, shape, string } = React.PropTypes;
+
+const propTypes = {
+ file: shape({
+ name: string.isRequired
+ }).isRequired,
+
+ children: node,
+ showType: bool
+};
+
+const UploadFilePreviewOther = ({ children, showType, file: { name } }) => (
+
+ {children}
+ {showType ? (
+
+ {`.${extractFileExtensionFromString(name) || 'file'}`}
+
+ ) : null}
+
+);
+
+UploadFilePreviewOther.propTypes = propTypes;
+
+export default UploadFilePreviewTypeWrapper(UploadFilePreviewOther);
diff --git a/js/components/ascribe_uploader/ui/upload_file_preview/upload_file_preview_type_wrapper.js b/js/components/ascribe_uploader/ui/upload_file_preview/upload_file_preview_type_wrapper.js
new file mode 100644
index 00000000..a79b4429
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_file_preview/upload_file_preview_type_wrapper.js
@@ -0,0 +1,100 @@
+import React from 'react';
+
+import { pausedFilesFilter, uploadedFilesFilter, uploadingFilesFilter } from 'react-utility-belt/es6/uploader/utils/file_filters';
+
+import AscribeSpinner from '../../../ascribe_spinner';
+
+import { getLangText } from '../../../../utils/lang';
+
+
+const { bool, func, shape, string } = React.PropTypes;
+
+/**
+ * Wrapper component for preview types that handles upload states and pause / resume functionality
+ */
+const displayName = 'UploadFilePreviewTypeWrapper';
+const propTypes = {
+ file: shape({
+ s3UrlSafe: string.isRequired
+ }).isRequired,
+
+ // Allow the file to be downloaded
+ downloadable: bool,
+
+ // Enable upload pause / resume functionality
+ pausable: bool,
+
+ // Controls pausing / resuming the file if pause / resume functionality is enabled
+ toggleUploadProcess: func
+
+ // All props are passed through to the PreviewComponent as well
+};
+
+const UploadFilePreviewTypeWrapper = (PreviewComponent) => {
+ const wrapperComponent = (props) => {
+ const { downloadable, file, pausable, toggleUploadProcess } = props;
+ const { s3UrlSafe } = file;
+ let uploadStateSymbol;
+
+ if (uploadedFilesFilter(file)) {
+ // Uploaded
+ uploadStateSymbol = downloadable ? (
+
+ ) : (
+
+ );
+ } else if (uploadingFilesFilter(file)) {
+ // Uploading
+ uploadStateSymbol = pausable ? (
+
+ ) : (
+
+ );
+ } else if (pausedFilesFilter(file)) {
+ // Paused
+ if (pausable) {
+ uploadStateSymbol = (
+
+ );
+ } else {
+ console.logGlobal(
+ new Error('UploadFilePreview encountered paused file but did not have resume ' +
+ 'functionality enabled'),
+ { file }
+ );
+ }
+ } else {
+ console.logGlobal(
+ new Error('UploadFilePreview encountered file that was not uploading, uploaded, ' +
+ 'or paused'),
+ { file }
+ );
+ }
+
+ return (
+
+ {uploadStateSymbol}
+
+ );
+ };
+
+ wrapperComponent.displayName = displayName;
+ wrapperComponent.propTypes = propTypes;
+
+ return wrapperComponent;
+};
+
+export default UploadFilePreviewTypeWrapper;
diff --git a/js/components/ascribe_uploader/ui/upload_progress_bar.js b/js/components/ascribe_uploader/ui/upload_progress_bar.js
new file mode 100644
index 00000000..066c36bb
--- /dev/null
+++ b/js/components/ascribe_uploader/ui/upload_progress_bar.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import classNames from 'classnames';
+
+import ProgressBar from 'react-bootstrap/lib/ProgressBar';
+
+
+const { arrayOf, number, shape, string } = React.PropTypes;
+
+function calcOverallFileSize(files) {
+ // We just sum up all files' sizes
+ return files.reduce((overallSize, { size }) => overallSize + size, 0);
+}
+
+function calcOverallProgress(files) {
+ const overallFileSize = calcOverallFileSize(files);
+
+ // We calculate the overall progress by summing the individuals files' progresses in relation
+ // to the total size of all uploads
+ return files.reduce((overallProgress, { progress, size }) => (
+ (size / overallFileSize) * progress
+ ), 0);
+}
+
+const propTypes = {
+ files: arrayOf(shape({
+ progress: number.isRequired,
+ size: number.isRequired
+ })).isRequired,
+
+ className: string
+};
+
+const UploadProgressBar = ({ className, files }) => {
+ const overallProgress = Math.ceil(calcOverallProgress(files));
+
+ return (
+
+ );
+};
+
+UploadProgressBar.propTypes = propTypes;
+
+export default UploadProgressBar;
diff --git a/js/components/ascribe_uploader/uploader.js b/js/components/ascribe_uploader/uploader.js
new file mode 100644
index 00000000..cbec7ed1
--- /dev/null
+++ b/js/components/ascribe_uploader/uploader.js
@@ -0,0 +1,341 @@
+import React from 'react';
+
+import { AscribeFileHashUploaderFactory, AscribeUploaderFactory } from 'ascribe-react-components/es6/uploader/ascribe_uploader_factory';
+import { uploaderCreateBlobParamsShapeSpec } from 'ascribe-react-components/es6/prop_types/uploader_create_blob_params_shape';
+import { uploaderRequestKeyParamsShapeSpec } from 'ascribe-react-components/es6/prop_types/uploader_request_key_params_shape';
+import ValidationErrors from 'react-utility-belt/es6/uploader/constants/validation_errors';
+import uploaderSpecExtender from 'react-utility-belt/es6/uploader/utils/uploader_spec_extender';
+
+import { safeInvoke } from 'js-utility-belt/es6';
+
+import S3Fetcher from '../../fetchers/s3_fetcher';
+
+import GlobalNotificationModel from '../../models/global_notification_model';
+import GlobalNotificationActions from '../../actions/global_notification_actions';
+
+import { S3_ACCESS_KEY, S3_ACL, S3_BUCKET } from '../../constants/uploader_constants';
+
+import { makeCsrfHeader as createCsrfHeader } from '../../utils/csrf';
+import { kbToMb } from '../../utils/file';
+import { getLangText } from '../../utils/lang';
+import request from '../../utils/request';
+import { resolveUrl } from '../../utils/url_resolver';
+
+
+const { bool, func, number, shape, string } = React.PropTypes;
+
+const Uploader = React.createClass(uploaderSpecExtender({
+ displayName: 'Uploader',
+
+ propTypes: {
+ // Whether the selected files should be hashed locally before being uploaded
+ hashLocally: bool,
+
+ /**
+ * Override to control the notification (if any) that is shown when an upload fails.
+ *
+ * @param {File} file File that errored
+ * @param {string} errorReason Reason for the error
+ * @param {Xhr|Xdr} xhr The xhr used to make the request
+ */
+ showErrorNotification: func,
+
+ // AscribeBlobUploader props
+ // Extend the base createBlobParamsShape with piece details
+ // eslint-disable-next-line react/sort-prop-types
+ createBlobParams: shape({
+ ...uploaderCreateBlobParamsShapeSpec,
+ body: shape({
+ 'piece_id': number
+ }).isRequired
+ }).isRequired,
+
+ onCreateBlobError: func,
+ onCreateBlobSuccess: func,
+
+ // AscribeRequestKeyUploader props
+ // Extend the base requestKeyParamsShape with category and piece details
+ // eslint-disable-next-line react/sort-prop-types
+ requestKeyParams: shape({
+ ...uploaderRequestKeyParamsShapeSpec,
+ body: shape({
+ 'category': string.isRequired,
+ 'piece_id': number,
+ }).isRequired
+ }).isRequired,
+
+ onRequestKeyError: func,
+ onRequestKeySuccess: func,
+
+ // All other props are passed through to AscribeUploader
+ },
+
+ getDefaultProps() {
+ return {
+ onCreateBlobError: (err, file) => this.onError(file, err && err.message),
+ onCreateBlobSuccess: this.defaultOnCreateBlobSuccess,
+ onDeleteOnlineFile: this.defaultOnDeleteOnlineFile,
+ onRequestKeyError: (err, file) => this.onError(file, err && err.message),
+ onRequestKeySuccess: this.defaultOnRequestKeySuccess,
+ onSessionRequestComplete: this.defaultOnSessionRequestComplete,
+ showErrorNotification: (file, errorReason) => {
+ const message = errorReason || getLangText('Oops, we had a problem uploading ' +
+ 'your file. Please contact us if this ' +
+ 'happens repeatedly.');
+
+ const notification = new GlobalNotificationModel(message, 'danger', 5000);
+ GlobalNotificationActions.appendGlobalNotification(notification);
+ }
+ };
+ },
+
+ componentWillMount() {
+ // Create uploaders based on current app settings
+ // Let's assume that these settings won't change without forcing a remounting of this
+ // component.
+ const [AscribeUploader, AscribeFileHashUploader] = [
+ AscribeUploaderFactory, AscribeFileHashUploaderFactory
+ ].map((factory) => factory({
+ createCsrfHeader,
+ // Note that our request module already includes the CSRF token on every call, so we
+ // don't have to add it to the blob and request key headers ourselves
+ request,
+ S3_ACCESS_KEY,
+ S3_ACL,
+ S3_BUCKET,
+ Urls: {
+ S3_DELETE: resolveUrl('s3_delete_file'),
+ S3_SIGNATUURE: resolveUrl('s3_signature')
+ }
+ }));
+
+ // Cache uploaders on this component
+ this.AscribeUploader = AscribeUploader;
+ this.AscribeFileHashUploader = AscribeFileHashUploader;
+ },
+
+ getXhrErrorComment(xhr) {
+ return xhr && {
+ response: xhr.response,
+ url: xhr.responseURL,
+ status: xhr.status,
+ statusText: xhr.statusText
+ };
+ },
+
+ /** DEFAULT EVENT HANDLERS (CAN BE OVERRIDDEN COMPLETELY THROUGH PROPS) **/
+ defaultOnCreateBlobSuccess(res, file) {
+ // `res` should contain one of these file types as a property
+ const fileType = res.otherdata || res.digitalwork || res.contractblob || res.thumbnail;
+
+ if (!fileType) {
+ const errorMsg = getLangText(
+ 'Could not find a s3 url as the download location of the file: %s',
+ file.name
+ );
+
+ throw new Error(errorMsg);
+ }
+
+ const changeSet = { $set: fileType.url_safe };
+ return {
+ s3Url: changeSet,
+ s3UrlSafe: changeSet
+ };
+ },
+
+ defaultOnDeleteOnlineFile(file) {
+ return S3Fetcher.deleteFile(file.s3Key, file.s3Bucket);
+ },
+
+ defaultOnRequestKeySuccess(res) {
+ return res.key;
+ },
+
+ defaultOnSessionRequestComplete(response, success) {
+ if (!success) {
+ return undefined;
+ }
+
+ response.forEach((file) => {
+ file.url = file.s3UrlSafe;
+ });
+
+ return response;
+ },
+
+ /** EXTENDED EVENT HANDLERS (ADDS ADDITIONAL BEHAVIOUR TO CALLBACK) **/
+ onCanceled(file, ...args) {
+ const notification = new GlobalNotificationModel(
+ getLangText('Upload of "%s" cancelled', file.name),
+ 'success',
+ 5000
+ );
+ GlobalNotificationActions.appendGlobalNotification(notification);
+
+ safeInvoke(this.props.onCanceled, file, ...args);
+ },
+
+ onDeleteComplete(file, xhr, isError, ...args) {
+ const notificationTemplate = isError ? 'There was an error deleting "${name}"'
+ : '"${name}" deleted';
+
+ const notification = new GlobalNotificationModel(
+ getLangText(notificationTemplate, file),
+ isError ? 'danger' : 'success',
+ 5000
+ );
+
+ GlobalNotificationActions.appendGlobalNotification(notification);
+
+ safeInvoke(this.props.onDeleteComplete, file, xhr, isError, ...args);
+ },
+
+ onError(file, errorReason, xhr, ...args) {
+ const { onError, showErrorNotification } = this.props;
+ const { uploader } = this.refs;
+
+ console.logGlobal(errorReason, {
+ files: uploader.getFiles(),
+ chunks: uploader.getChunks(),
+ xhr: this.getXhrErrorComment(xhr)
+ });
+
+ safeInvoke(showErrorNotification, file, errorReason, xhr, ...args);
+ safeInvoke(onError, file, errorReason, xhr, ...args);
+ },
+
+ onFileHashError(error, ...args) {
+ let notification;
+
+ if (error && error.message && error.message.toLowerCase().includes('cancel')) {
+ notification = new GlobalNotificationModel(error.message, 'success', 5000);
+ } else {
+ notification = new GlobalNotificationModel(
+ 'Failed to hash files. Please contact us if this problem persists.',
+ 'danger',
+ 5000
+ );
+ }
+
+ GlobalNotificationActions.appendGlobalNotification(notification);
+
+ const {
+ invoked,
+ result: errorResult
+ } = safeInvoke(this.props.onFileHashError, error, ...args);
+
+ // If `onFileHashError` doesn't throw its own error or isn't invoked, rethrow the original
+ // error back to the uploader
+ if (invoked) {
+ return errorResult;
+ } else {
+ throw error;
+ }
+ },
+
+ onValidationError(errors, validFiles, ...args) {
+ const numFileLimitErrors = errors
+ .filter((error) => error.validationError.type === ValidationErrors.FILE_LIMIT)
+ .length;
+
+ if (numFileLimitErrors === errors.length) {
+ // Validation failed because number of files submitted was over the limit
+ const { limit, remaining } = errors[0].validationError.description;
+ let notification;
+
+ // If we are currently under the limit, just select as many files from the files as
+ // possible
+ if (remaining) {
+ const firstSubmittedFiles = errors
+ .slice(0, remaining)
+ .map((error) => error.file);
+
+ validFiles.push(...firstSubmittedFiles);
+
+ notification = new GlobalNotificationModel(
+ getLangText(
+ 'Only %s were allowed (took first %s)',
+ `${remaining} ${getLangText(remaining === 1 ? 'file' : 'files')}`,
+ remaining
+ ),
+ 'danger',
+ 10000
+ );
+ } else {
+ notification = new GlobalNotificationModel(
+ getLangText(
+ "Oops, you've already uploaded the maximum number of items (%s)!",
+ limit
+ ),
+ 'danger',
+ 10000
+ );
+ }
+
+ GlobalNotificationActions.appendGlobalNotification(notification);
+ } else {
+ // Validation failed because of only some items; ignore those and notify the user
+ // If a lot of submitted files fail validation, this might be spammy
+ errors
+ .map(({ file, validationError: { description, type } }) => {
+ switch (type) {
+ case ValidationErrors.SIZE:
+ // eslint-disable-next-line max-len
+ return getLangText('Cancelled upload of "${name}" as it was bigger than ${size}MB', {
+ name: file.name,
+ size: kbToMb(description.limit)
+ });
+ case ValidationErrors.EXTENSION:
+ return getLangText(
+ // eslint-disable-next-line max-len
+ 'Cancelled upload of "${name}" as it has an invalid file format (valid formats: ${allowedExt})', {
+ allowedExt: description.allowedExtensions.join(', '),
+ name: file.name
+ }
+ );
+ default:
+ return getLangText(
+ 'Cancelled upload of "${name}" as it failed file validation',
+ file
+ );
+ }
+ })
+ .forEach((errorMsg) => {
+ const notification = new GlobalNotificationModel(errorMsg, 'danger', 5000);
+ GlobalNotificationActions.appendGlobalNotification(notification);
+ });
+ }
+
+ const {
+ invoked,
+ result
+ } = safeInvoke(this.props.onValidationError, errors, validFiles, ...args);
+
+ return invoked ? result : validFiles;
+ },
+
+ render() {
+ const { AscribeUploader, AscribeFileHashUploader } = this;
+ const { hashLocally, ...props } = this.props;
+
+ const uploaderProps = {
+ ...props,
+ onCanceled: this.onCanceled,
+ onDeleteComplete: this.onDeleteComplete,
+ onError: this.onError,
+ onValidationError: this.onValidationError,
+
+ // Mandatory for uploaderSpecExtender
+ ref: 'uploader'
+ };
+
+ return hashLocally ? (
+
+ ) : ( );
+ }
+}));
+
+export default Uploader;
diff --git a/js/components/prop_types/current_user_shape.js b/js/components/prop_types/current_user_shape.js
index d854450b..3f3de931 100644
--- a/js/components/prop_types/current_user_shape.js
+++ b/js/components/prop_types/current_user_shape.js
@@ -4,11 +4,11 @@ import React from 'react';
const { number, object, shape, string } = React.PropTypes;
const currentUserShapeSpec = {
- acl: object,
- email: string,
- id: number,
- profile: object,
- username: string
+ acl: object.isRequired,
+ email: string.isRequired,
+ id: number.isRequired,
+ profile: object.isRequired,
+ username: string.isRequired
};
export default shape(currentUserShapeSpec);
diff --git a/js/components/prop_types/whitelabel_shape.js b/js/components/prop_types/whitelabel_shape.js
index 63c5b1a8..6da94b9c 100644
--- a/js/components/prop_types/whitelabel_shape.js
+++ b/js/components/prop_types/whitelabel_shape.js
@@ -4,10 +4,10 @@ import React from 'react';
const { shape, string } = React.PropTypes;
const whitelabelShapeSpec = {
- name: string,
- subdomain: string,
- title: string,
- user: string
+ name: string.isRequired,
+ subdomain: string.isRequired,
+ title: string.isRequired,
+ user: string.isRequired
};
export default shape(whitelabelShapeSpec);
diff --git a/js/constants/uploader_constants.js b/js/constants/uploader_constants.js
index bdfe539f..ece6794a 100644
--- a/js/constants/uploader_constants.js
+++ b/js/constants/uploader_constants.js
@@ -1,3 +1,13 @@
+// Upload types:
+// * HASH: Hash the file before uploading
+// * NORMAL: Upload the file as-is
+// * USE_URL_PARAM: Determine the type to use through the current url's `upload_method` parameter
+export const UploadMethods = {
+ HASH: 'hash',
+ NORMAL: 'normal',
+ USE_URL_PARAM: 'use_url_param'
+};
+
// Validation types
export const ValidationParts = {
allowedExtensions: {
@@ -39,6 +49,7 @@ export const S3_ACL = process.env.S3_ACL;
export const S3_BUCKET = process.env.S3_BUCKET;
export default {
+ UploadMethods,
ValidationParts,
ValidationTypes,
RETRY_ATTEMPT_TO_SHOW_CONTACT_US,
diff --git a/js/utils/file.js b/js/utils/file.js
index d611599c..5902ef88 100644
--- a/js/utils/file.js
+++ b/js/utils/file.js
@@ -5,3 +5,14 @@ export {
extractFileExtensionFromString,
extractFileExtensionFromUrl
} from 'js-utility-belt/es6/file';
+
+/**
+ * Transforms kb size to mb size, using an arbitrary rounding function at the end.
+ *
+ * @param {number} size Size in kb
+ * @param {function} roundFn Function to round out the precise size in mb, defaulting to Math.ceil.
+ * @return {number} Size in mb
+ */
+export function kbToMb(size, roundFn = Math.ceil) {
+ return roundFn(size / 1024);
+}
diff --git a/js/utils/general.js b/js/utils/general.js
index f1789621..20eb7175 100644
--- a/js/utils/general.js
+++ b/js/utils/general.js
@@ -9,6 +9,7 @@ export {
deepMatchObject,
intersectLists,
omitFromObject,
+ safeInvoke,
safeMerge,
sanitize,
sanitizeList,
diff --git a/sass/ascribe_uploader.scss b/sass/ascribe_uploader.scss
index dd08455a..cbcbacb0 100644
--- a/sass/ascribe_uploader.scss
+++ b/sass/ascribe_uploader.scss
@@ -9,33 +9,27 @@
text-align: center;
vertical-align: middle;
padding-top: 1.5em;
-
- @media screen and (max-width: 625px) {
- .file-name {
- display: block;
- }
- }
}
.inactive-dropzone {
- background-color: rgba(0, 0, 0, 0) !important;
+ background-color: transparent !important;
cursor: default !important;
outline: 0;
}
-.present-options {
- > p {
- margin-bottom: .75em !important;
- }
-
- .btn {
- margin: 0 1em;
- }
-}
-
.file-drag-and-drop-dialog {
margin: 0 0 1.5em 0;
+ .present-options {
+ > p {
+ margin-bottom: .75em !important;
+ }
+
+ .btn {
+ margin: 0 1em;
+ }
+ }
+
.file-drag-and-drop-dialog-title {
font-size: 1.5em !important;
margin-bottom: 0;
@@ -46,6 +40,10 @@
> .btn {
margin-bottom: 2em;
}
+
+ .file-drag-and-drop-overall-progress-bar {
+ margin-top: 1.3em;
+ }
}
.file-drag-and-drop-hashing-dialog {
@@ -53,44 +51,67 @@
margin: 1.5em 0 0 0;
}
-.file-drag-and-drop-position {
+.remove-file-btn {
+ background-color: black;
+ border-width: 0;
+ border-radius: 1em;
+ cursor: pointer;
+ display: block;
+ height: 20px;
+ padding: 0;
+ width: 20px;
+
+ .glyphicon-remove {
+ color: white;
+ font-size: .8em;
+ left: 0;
+ top: 1px;
+ }
+
+ &:hover .glyphicon-remove {
+ color: $brand-danger;
+ }
+}
+
+.upload-file-preview-container {
display: inline-block;
margin-left: .7em;
margin-right: .7em;
position: relative;
- .delete-file {
- background-color: black;
- border-radius: 1em;
- cursor: pointer;
- display: block;
- height: 20px;
- position: absolute;
- right: -7px;
- text-align: center;
- top: -7px;
- width: 20px;
+ &:not(:only-of-type) {
+ .upload-file-preview {
+ vertical-align: middle;
+ }
- span {
- color: white;
- font-size: .8em;
- left: 0;
- top: 1px;
-
- &:hover {
- color: $brand-danger;
- }
+ .upload-file-preview--label {
+ padding-top: 5px;
+ display: block;
}
}
}
-.file-drag-and-drop-preview {
+.upload-file-preview {
background-color: #eeeeee;
border: 1px solid #616161;
cursor: default;
overflow: hidden;
+
+ .remove-file-btn {
+ position: absolute;
+ right: -7px;
+ top: -7px;
+ }
}
+.upload-file-preview--label {
+ @media screen and (max-width: 625px) {
+ padding-top: 5px;
+ display: block;
+ }
+}
+
+
.action-file {
color: white;
cursor: pointer;
@@ -146,7 +167,7 @@
text-align: center;
width: 104px;
- p {
+ .file-drag-and-drop-preview-other--label {
margin-top: 5px;
overflow: hidden;
text-overflow: ellipsis;
@@ -319,8 +340,14 @@
}
}
+.upload-button .upload-button--label {
+ margin-left: 1em;
+ .btn {
+ font-size: 1em;
}
+ .upload-button--label-icon {
+ margin-right: 0.5em;
}
}