333 lines
11 KiB
JavaScript
Raw Normal View History

import React from 'react';
import { connect } from 'react-redux';
import { orderBy, get, last, isEmpty, map } from 'lodash';
import c from 'classnames';
import fuzzy from 'fuzzy';
import Waypoint from 'react-waypoint';
import Dialog from '../UI/Dialog';
import { resolvePath } from '../../lib/pathHelper';
import { changeDraftField } from '../../actions/entries';
import {
loadMedia as loadMediaAction,
persistMedia as persistMediaAction,
deleteMedia as deleteMediaAction,
insertMedia as insertMediaAction,
closeMediaLibrary as closeMediaLibraryAction,
} from '../../actions/mediaLibrary';
import MediaLibraryFooter from './MediaLibraryFooter';
/**
* Extensions used to determine which files to show when the media library is
* accessed from an image insertion field.
*/
const IMAGE_EXTENSIONS_VIEWABLE = [ 'jpg', 'jpeg', 'webp', 'gif', 'png', 'bmp', 'tiff' ];
const IMAGE_EXTENSIONS = [ ...IMAGE_EXTENSIONS_VIEWABLE, 'svg' ];
class MediaLibrary extends React.Component {
/**
* The currently selected file and query are tracked in component state as
* they do not impact the rest of the application.
*/
state = {
selectedFile: {},
query: '',
};
componentDidMount() {
this.props.loadMedia();
}
componentWillReceiveProps(nextProps) {
/**
* We clear old state from the media library when it's being re-opened
* because, when doing so on close, the state is cleared while the media
* library is still fading away.
*/
const isOpening = !this.props.isVisible && nextProps.isVisible;
if (isOpening) {
this.setState({ selectedFile: {}, query: '' });
}
}
/**
* Filter an array of file data to include only images.
*/
filterImages = files => {
return files ? files.filter(file => IMAGE_EXTENSIONS.includes(last(file.name.split('.')))) : [];
};
/**
* Transform file data for table display.
*/
toTableData = files => {
const tableData = files && files.map(({ key, name, size, queryOrder, url, urlIsPublicPath }) => {
const ext = last(name.split('.'));
return {
key,
name,
type: ext.toUpperCase(),
size,
queryOrder,
url,
urlIsPublicPath,
isImage: IMAGE_EXTENSIONS.includes(ext),
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
};
});
/**
* Get the sort order for use with `lodash.orderBy`, and always add the
* `queryOrder` sort as the lowest priority sort order.
*/
const { sortFields } = this.state;
const fieldNames = map(sortFields, 'fieldName').concat('queryOrder');
const directions = map(sortFields, 'direction').concat('asc');
return orderBy(tableData, fieldNames, directions);
};
handleClose = () => {
this.props.closeMediaLibrary();
};
/**
* Toggle asset selection on click.
*/
handleAssetClick = asset => {
const selectedFile = this.state.selectedFile.key === asset.key ? {} : asset;
this.setState({ selectedFile });
};
/**
* Upload a file.
*/
handlePersist = async event => {
/**
* Stop the browser from automatically handling the file input click, and
* get the file for upload.
*/
event.stopPropagation();
event.preventDefault();
const { loadMedia, persistMedia, privateUpload } = this.props;
const { files: fileList } = event.dataTransfer || event.target;
const files = [...fileList];
const file = files[0];
/**
* Upload the selected file, then refresh the media library. This should be
* improved in the future, but isn't currently resulting in noticeable
* performance/load time issues.
*/
await persistMedia(file, privateUpload);
this.scrollToTop();
};
/**
* Stores the public path of the file in the application store, where the
* editor field that launched the media library can retrieve it.
*/
handleInsert = () => {
const { selectedFile } = this.state;
const { name, url, urlIsPublicPath } = selectedFile;
const { insertMedia, publicFolder } = this.props;
const publicPath = urlIsPublicPath ? url : resolvePath(name, publicFolder);
insertMedia(publicPath);
this.handleClose();
};
/**
* Removes the selected file from the backend.
*/
handleDelete = () => {
const { selectedFile } = this.state;
const { files, deleteMedia } = this.props;
if (!window.confirm('Are you sure you want to delete selected media?')) {
return;
}
const file = files.find(file => selectedFile.key === file.key);
deleteMedia(file)
.then(() => {
this.setState({ selectedFile: {} });
});
};
handleLoadMore = () => {
const { loadMedia, dynamicSearchQuery, page } = this.props;
loadMedia({ query: dynamicSearchQuery, page: page + 1 });
};
/**
* Executes media library search for implementations that support dynamic
* search via request. For these implementations, the Enter key must be
* pressed to execute search. If assets are being stored directly through
* the GitHub backend, search is in-memory and occurs as the query is typed,
* so this handler has no impact.
*/
handleSearchKeyDown = async (event) => {
if (event.key === 'Enter' && this.props.dynamicSearch) {
await this.props.loadMedia({ query: this.state.query })
this.scrollToTop();
}
};
scrollToTop = () => {
this.scrollContainerRef.scrollTop = 0;
}
/**
* Updates query state as the user types in the search field.
*/
handleSearchChange = event => {
this.setState({ query: event.target.value });
};
/**
* Filters files that do not match the query. Not used for dynamic search.
*/
queryFilter = (query, files) => {
/**
* Because file names don't have spaces, typing a space eliminates all
* potential matches, so we strip them all out internally before running the
* query.
*/
const strippedQuery = query.replace(/ /g, '');
const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name });
const matchFiles = matches.map((match, queryIndex) => {
const file = files[match.index];
return { ...file, queryIndex };
});
return matchFiles;
};
render() {
const {
isVisible,
canInsert,
files,
dynamicSearch,
dynamicSearchActive,
forImage,
isLoading,
isPersisting,
isDeleting,
hasNextPage,
page,
isPaginating,
} = this.props;
const { query, selectedFile } = this.state;
const filteredFiles = forImage ? this.filterImages(files) : files;
const queriedFiles = (!dynamicSearch && query) ? this.queryFilter(query, filteredFiles) : filteredFiles;
const tableData = this.toTableData(queriedFiles);
const hasFiles = files && !!files.length;
const hasFilteredFiles = filteredFiles && !!filteredFiles.length;
const hasSearchResults = queriedFiles && !!queriedFiles.length;
const hasMedia = hasSearchResults;
const shouldShowEmptyMessage = !hasMedia;
const emptyMessage = (isLoading && !hasMedia && 'Loading...')
|| (dynamicSearchActive && 'No results.')
|| (!hasFiles && 'No assets found.')
|| (!hasFilteredFiles && 'No images found.')
|| (!hasSearchResults && 'No results.');
return (
<Dialog
isVisible={isVisible}
onClose={this.handleClose}
className="nc-mediaLibrary-dialog"
footer={
<MediaLibraryFooter
onDelete={this.handleDelete}
onPersist={this.handlePersist}
onClose={this.handleClose}
onInsert={this.handleInsert}
hasSelection={hasMedia && !isEmpty(selectedFile)}
forImage={forImage}
canInsert={canInsert}
isPersisting={isPersisting}
isDeleting={isDeleting}
/>
}
>
<h1 className="nc-mediaLibrary-title">{forImage ? 'Images' : 'Assets'}</h1>
<input
className="nc-mediaLibrary-searchInput"
value={query}
onChange={this.handleSearchChange}
onKeyDown={event => this.handleSearchKeyDown(event)}
placeholder="Search..."
disabled={!dynamicSearchActive && !hasFilteredFiles}
autoFocus
/>
<div className="nc-mediaLibrary-cardGrid-container" ref={ref => (this.scrollContainerRef = ref)}>
<div className="nc-mediaLibrary-cardGrid">
{
tableData.map((file, idx) =>
<div
key={file.key}
className={c('nc-mediaLibrary-card', { 'nc-mediaLibrary-card-selected': selectedFile.key === file.key })}
onClick={() => this.handleAssetClick(file)}
tabIndex="-1"
>
<div className="nc-mediaLibrary-cardImage-container">
{
file.isViewableImage
? <img src={file.url} className="nc-mediaLibrary-cardImage"/>
: <div className="nc-mediaLibrary-cardImage"/>
}
</div>
<p className="nc-mediaLibrary-cardText">{file.name}</p>
</div>
)
}
{
hasNextPage
? <Waypoint onEnter={() => this.handleLoadMore()}/>
: null
}
</div>
{
shouldShowEmptyMessage
? <div className="nc-mediaLibrary-emptyMessage"><h1>{emptyMessage}</h1></div>
: null
}
{ isPaginating ? <h1 className="nc-mediaLibrary-paginatingMessage">Loading...</h1> : null }
</div>
</Dialog>
);
}
}
const mapStateToProps = state => {
const { config, mediaLibrary } = state;
const configProps = {
publicFolder: config.get('public_folder'),
};
const mediaLibraryProps = {
isVisible: mediaLibrary.get('isVisible'),
canInsert: mediaLibrary.get('canInsert'),
files: mediaLibrary.get('files'),
dynamicSearch: mediaLibrary.get('dynamicSearch'),
dynamicSearchActive: mediaLibrary.get('dynamicSearchActive'),
dynamicSearchQuery: mediaLibrary.get('dynamicSearchQuery'),
forImage: mediaLibrary.get('forImage'),
isLoading: mediaLibrary.get('isLoading'),
isPersisting: mediaLibrary.get('isPersisting'),
isDeleting: mediaLibrary.get('isDeleting'),
privateUpload: mediaLibrary.get('privateUpload'),
page: mediaLibrary.get('page'),
hasNextPage: mediaLibrary.get('hasNextPage'),
isPaginating: mediaLibrary.get('isPaginating'),
};
return { ...configProps, ...mediaLibraryProps };
};
const mapDispatchToProps = {
loadMedia: loadMediaAction,
persistMedia: persistMediaAction,
deleteMedia: deleteMediaAction,
insertMedia: insertMediaAction,
closeMediaLibrary: closeMediaLibraryAction,
};
export default connect(mapStateToProps, mapDispatchToProps)(MediaLibrary);