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 }) => ( + +); + +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)} + +
+ ); +}; + +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 ( +
+

+ {getLangText(`Computing ${hashingFiles.length > 1 ? 'hashes' : 'hash'}... ` + + 'This may take a few minutes.')} +

+ + {getLangText('Cancel hashing')} + + +
+ ); +}; + +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('or')} + + + +
+
+ ); +}; + +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; } }