Add rewritten uploader and UI elements

This commit is contained in:
Brett Sun 2016-07-08 16:31:26 +02:00
parent 1069121e07
commit d5469b3efa
23 changed files with 1722 additions and 49 deletions

View File

@ -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
? (<span className="ascribe-icon icon-ascribe-ok uploader-button--label-icon" />)
: 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,
' [',
(<a key="remove-link" onClick={handleRemoveFiles} tabIndex={0}>{removeActionText}</a>),
']'
];
} else {
label = getLangText('No file selected');
}
return (<span className="upload-button--label">{label}</span>);
};
FileLabel.propTypes = {
files: arrayOf(shape({
name: string.isRequired
})).isRequired,
handleRemoveFiles: func.isRequired
};
const UploadButton = ({ className, ...props }) => (
<UploadButtonBase {...props} className={classNames(className, 'upload-button')} />
);
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 (
<UploadifiedUploadButton ref="uploader" {...props} />
);
}
}));
export default UploadButtonUploadifyWrapper;
// Also export the non-uploadify version for extension
export {
UploadButton as UploadButtonBase
};

View File

@ -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

View File

@ -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 (
<UploadDragAndDropArea ref="uploader" {...props} disabled={disabled}>
<UploadFileDialogUI {...this.state} />
</UploadDragAndDropArea>
);
}
}));
export default UploadFileDialog;

View File

@ -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 }) => (
<button
className='btn btn-default'
onClick={() => {
if (openIntercom) {
window.Intercom('showNewMessage', getLangText("I'm having trouble uploading my file."));
}
handleRetryFiles();
}}
type="button">
{children}
</button>
);
UploadErrorRetryButton.propTypes = {
handleRetryFiles: func.isRequired,
children: node,
openIntercom: bool
};
// Contact us dialog
const UploadErrorContactUs = ({ handleRetryFiles }) => (
<div className='file-drag-and-drop-error'>
<h4>{getLangText('Let us help you')}</h4>
<p>{getLangText('Still having problems? Send us a message.')}</p>
<UploadErrorRetryButton openIntercom handleRetryFiles={handleRetryFiles}>
{getLangText('Contact us')}
</UploadErrorRetryButton>
</div>
);
UploadErrorContactUs.propTypes = {
handleRetryFiles: func.isRequired
};
// Error details dialog
const UploadErrorDetails = ({ errorClass: { prettifiedText }, failedFiles, handleRetryFiles }) => (
<div className='file-drag-and-drop-error'>
<div
className={classNames(
'file-drag-and-drop-error-detail', {
'file-drag-and-drop-error-detail-multiple-files': failedFiles.length
}
)}>
<h4>{getLangText(failedFiles.length ? 'Some files did not upload correctly'
: 'Error uploading the file!')}
</h4>
<p>{prettifiedText}</p>
<UploadErrorRetryButton handleRetryFiles={handleRetryFiles}>
{getLangText('Retry')}
</UploadErrorRetryButton>
</div>
<span
className={classNames(
'file-drag-and-drop-error-icon-container', {
'file-drag-and-drop-error-icon-container-multiple-files': failedFiles.length
}
)}>
<span className='ascribe-icon icon-ascribe-thin-cross'></span>
</span>
<div className='file-drag-and-drop-error-file-names'>
<ul>
{failedFiles.map(({ id, originalName }) => (
<li key={id} className='file-name'>{originalName}</li>
))}
</ul>
</div>
</div>
);
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
? (<UploadErrorContactUs {...dialogProps} />)
: (<UploadErrorDetails {...dialogProps} />);
};
UploadFileDialogErrorHandler.propTypes = {
errorClass: shape({
name: string
}).isRequired,
failedFiles: arrayOf(object).isRequired,
handleRetryFile: func.isRequired
};
export default UploadFileDialogErrorHandler;

View File

@ -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 [
<p key="upload-drag-title" className="file-drag-and-drop-dialog-title">
{getLangText('Drag %s here', fileTypeName)}
</p>,
<p key="upload-drag-subtitle">{getLangText('or')}</p>
];
} 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 (
<div className="file-drag-and-drop-dialog">
{getDragDialog(multiple ? fileTypeNames.plural : fileTypeNames.singular)}
<button
className="btn btn-default"
onClick={handleSelectFiles}>
{buttonMsg}
</button>
</div>
);
};
UploadFileDialogFileSelector.propTypes = propTypes;
export default UploadFileDialogPrintWrapper(UploadFileDialogFileSelector);

View File

@ -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 (
<div className="file-drag-and-drop-hashing-dialog">
<p>
{getLangText(`Computing ${hashingFiles.length > 1 ? 'hashes' : 'hash'}... ` +
'This may take a few minutes.')}
</p>
<a onClick={handleCancelHashing} tabIndex={0}>
{getLangText('Cancel hashing')}
</a>
<ProgressBar
className="ascribe-progress-bar"
label="%(percent)s%"
now={Math.ceil(hashingProgress)} />
</div>
);
};
UploadFileDialogHashProgress.propTypes = propTypes;
export default UploadFileDialogPrintWrapper(UploadFileDialogHashProgress);

View File

@ -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 (
<div className="file-drag-and-drop-dialog">
<div className="present-options">
<p className="file-drag-and-drop-dialog-title">
{getLangText('Would you rather')}
</p>
<Link query={queryParamsHash} to={location.pathname}>
<button className="btn btn-default btn-sm">
{getLangText('Hash your work')}
</button>
</Link>
<span>{getLangText('or')}</span>
<Link query={queryParamsNormal} to={location.pathname}>
<button className="btn btn-default btn-sm">
{getLangText('Upload and hash your work')}
</button>
</Link>
</div>
</div>
);
};
UploadFileDialogMethodSelector.propTypes = propTypes;
export default UploadFileDialogPrintWrapper(withContext(UploadFileDialogMethodSelector, 'location'));

View File

@ -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 (
<div>
{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 (
<UploadFilePreview
key={key}
{...props}
file={file}
showName={!multipleFiles}
showProgress={multipleFiles}
thumbnailUrl={thumbnailUrl} />
);
})}
</div>
);
} else {
return null;
}
};
UploadFileDialogPreviewIterator.propTypes = propTypes;
export default UploadFileDialogPreviewIterator;

View File

@ -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) => (
<div>
<div className="hidden-print">
<Component {...props} />
</div>
<p className="text-align-center visible-print">
{getLangText('No files uploaded')}
</p>
</div>
)
);
export default UploadFileDialogPrintWrapper;

View File

@ -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 = (
<UploadFileDialogMethodSelector uploadMethod={UploadMethods.USE_URL_PARAMS} />
);
} else if (Array.isArray(hashingFiles) && hashingFiles.length) {
uploaderUI = (
<UploadFileDialogHashProgress
handleCancelHashing={handleCancelHashing}
hashingFiles={hashingFiles} />
);
} else {
const failedFiles = uploaderFiles.filter(failedFilesFilter);
const validFiles = uploaderFiles.filter(validProgressFilesFilter);
if (errorClass && showError && failedFiles.length) {
uploaderUI = (
<UploadFileDialogErrorHandler
errorClass={errorClass}
failedFiles={failedFiles}
handleRetryFile={handleRetryFile} />
);
} else if (validFiles.length) {
uploaderUI = [(
<UploadFileDialogPreviewIterator
key="upload-file-preview-iterator"
downloadable={areAssetsDownloadable}
files={validFiles}
handleCancelFile={handleCancelFile}
handleDeleteFile={handleDeleteFile}
handlePauseFile={handlePauseFile}
handleResumeFile={handleResumeFile}
removable={areAssetsEditable}
thumbnailMapping={thumbnailMapping} />
), (
<UploadProgressBar
key="upload-progress"
className="file-drag-and-drop-overall-progress-bar"
files={uploaderFiles} />
)];
} else {
uploaderUI = (
<UploadFileDialogFileSelector
handleSelectFiles={handleSelectFiles}
uploadMethod={uploadMethod} />
);
}
}
return (
<div className={classNames('file-drag-and-drop', { 'inactive-dropzone': disabled })}>
{uploaderUI}
</div>
);
};
UploadFileDialogUI.propTypes = propTypes;
UploadFileDialogUI.contextTypes = contextTypes;
export default UploadFileDialogUI;

View File

@ -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';

View File

@ -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 } }) => (
<span className="upload-file-preview--label">
{truncateText(name, 30, `(...).${extractFileExtensionFromString(name)}`)}
</span>
);
UploadFileName.propTypes = {
file: shape({
name: string.isRequired
}).isRequired
};
const UploadRemoveButton = ({ handleRemoveFile }) => (
<button className="remove-file-btn text-center" onClick={handleRemoveFile}>
<span
aria-hidden
className="glyphicon glyphicon-remove"
title={getLangText('Remove file')} />
</button>
);
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')
? (<UploadFilePreviewImage {...previewProps} thumbnailUrl={thumbnailUrl || file.url} />)
: (<UploadFilePreviewOther {...previewProps} />);
const fileProgressBar = showProgress ? (
<UploadProgressBar className="ascribe-progress-bar-xs" files={[file]} />
) : null;
const fileRemoveButton = removable ? (
<UploadRemoveButton handleRemoveFile={this.handleRemoveFile} />
) : null;
const fileName = showName ? (<UploadFileName file={file} />) : null;
return (
<div className={classNames(className, 'upload-file-preview-container')}>
<div className="upload-file-preview">
{fileProgressBar}
{previewElement}
{fileRemoveButton}
</div>
{fileName}
</div>
);
}
});
export default UploadFilePreview;

View File

@ -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 (
<div className="file-drag-and-drop-preview-image hidden-print" style={imageStyle}>
{children}
</div>
);
};
UploadFilePreviewImage.propTypes = propTypes;
export default UploadFilePreviewTypeWrapper(UploadFilePreviewImage);

View File

@ -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 } }) => (
<div className="file-drag-and-drop-preview-other">
{children}
{showType ? (
<p className="file-drag-and-drop-preview-other--label">
{`.${extractFileExtensionFromString(name) || 'file'}`}
</p>
) : null}
</div>
);
UploadFilePreviewOther.propTypes = propTypes;
export default UploadFilePreviewTypeWrapper(UploadFilePreviewOther);

View File

@ -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 ? (
<a
aria-hidden
className="glyphicon glyphicon-download action-file"
href={s3UrlSafe}
target="_blank"
title={getLangText('Download file')} />
) : (
<span className='ascribe-icon icon-ascribe-ok action-file' />
);
} else if (uploadingFilesFilter(file)) {
// Uploading
uploadStateSymbol = pausable ? (
<span
aria-hidden
className="glyphicon glyphicon-pause action-file"
onClick={toggleUploadProcess}
title={getLangText('Pause upload')} />
) : (
<AscribeSpinner className="spinner-file" color='dark-blue' size='md' />
);
} else if (pausedFilesFilter(file)) {
// Paused
if (pausable) {
uploadStateSymbol = (
<span
aria-hidden
className="glyphicon glyphicon-play action-file"
onClick={toggleUploadProcess}
title={getLangText('Resume uploading')} />
);
} 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 (
<PreviewComponent {...props} file={file}>
{uploadStateSymbol}
</PreviewComponent>
);
};
wrapperComponent.displayName = displayName;
wrapperComponent.propTypes = propTypes;
return wrapperComponent;
};
export default UploadFilePreviewTypeWrapper;

View File

@ -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 (
<ProgressBar
className={classNames(className, 'ascribe-progress-bar', { 'hidden': !files.length })}
label={`${overallProgress}%`}
now={overallProgress} />
);
};
UploadProgressBar.propTypes = propTypes;
export default UploadProgressBar;

View File

@ -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 ? (
<AscribeFileHashUploader
{...uploaderProps}
onFileHashError={this.onFileHashError} />
) : (<AscribeUploader {...uploaderProps} />);
}
}));
export default Uploader;

View File

@ -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);

View File

@ -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);

View File

@ -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,

View File

@ -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);
}

View File

@ -9,6 +9,7 @@ export {
deepMatchObject,
intersectLists,
omitFromObject,
safeInvoke,
safeMerge,
sanitize,
sanitizeList,

View File

@ -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;
}
}