290 lines
8.8 KiB
JavaScript
290 lines
8.8 KiB
JavaScript
import React from 'react';
|
|
import { connect } from 'react-redux';
|
|
import { orderBy, map } from 'lodash';
|
|
import fuzzy from 'fuzzy';
|
|
import { resolvePath, fileExtension } from 'netlify-cms-lib-util';
|
|
import {
|
|
loadMedia as loadMediaAction,
|
|
persistMedia as persistMediaAction,
|
|
deleteMedia as deleteMediaAction,
|
|
insertMedia as insertMediaAction,
|
|
closeMediaLibrary as closeMediaLibraryAction,
|
|
} from 'Actions/mediaLibrary';
|
|
import MediaLibraryModal from './MediaLibraryModal';
|
|
|
|
/**
|
|
* 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', 'svg' ];
|
|
const IMAGE_EXTENSIONS = [ ...IMAGE_EXTENSIONS_VIEWABLE ];
|
|
|
|
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: '' });
|
|
}
|
|
|
|
if (isOpening && (this.props.privateUpload !== nextProps.privateUpload)) {
|
|
this.props.loadMedia({ privateUpload: nextProps.privateUpload });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filter an array of file data to include only images.
|
|
*/
|
|
filterImages = (files = []) => {
|
|
return files.filter(file => {
|
|
const ext = fileExtension(file.name).toLowerCase();
|
|
return IMAGE_EXTENSIONS.includes(ext);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Transform file data for table display.
|
|
*/
|
|
toTableData = files => {
|
|
const tableData = files && files.map(({ key, name, size, queryOrder, url, urlIsPublicPath }) => {
|
|
const ext = fileExtension(name).toLowerCase();
|
|
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, and retain the synthetic event for access after
|
|
* the asynchronous persist operation.
|
|
*/
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
event.persist();
|
|
const { persistMedia, privateUpload } = this.props;
|
|
const { files: fileList } = event.dataTransfer || event.target;
|
|
const files = [...fileList];
|
|
const file = files[0];
|
|
|
|
await persistMedia(file, { privateUpload });
|
|
|
|
event.target.value = null;
|
|
|
|
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, privateUpload } = 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, { privateUpload })
|
|
.then(() => {
|
|
this.setState({ selectedFile: {} });
|
|
});
|
|
};
|
|
|
|
handleLoadMore = () => {
|
|
const { loadMedia, dynamicSearchQuery, page, privateUpload } = this.props;
|
|
loadMedia({ query: dynamicSearchQuery, page: page + 1, privateUpload });
|
|
};
|
|
|
|
/**
|
|
* 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) => {
|
|
const { dynamicSearch, loadMedia, privateUpload } = this.props;
|
|
if (event.key === 'Enter' && dynamicSearch) {
|
|
await loadMedia({ query: this.state.query, privateUpload })
|
|
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,
|
|
privateUpload,
|
|
} = this.props;
|
|
|
|
return (
|
|
<MediaLibraryModal
|
|
isVisible={isVisible}
|
|
canInsert={canInsert}
|
|
files={files}
|
|
dynamicSearch={dynamicSearch}
|
|
dynamicSearchActive={dynamicSearchActive}
|
|
forImage={forImage}
|
|
isLoading={isLoading}
|
|
isPersisting={isPersisting}
|
|
isDeleting={isDeleting}
|
|
hasNextPage={hasNextPage}
|
|
page={page}
|
|
isPaginating={isPaginating}
|
|
privateUpload={privateUpload}
|
|
query={this.state.query}
|
|
selectedFile={this.state.selectedFile}
|
|
handleFilter={this.filterImages}
|
|
handleQuery={this.queryFilter}
|
|
toTableData={this.toTableData}
|
|
handleClose={this.handleClose}
|
|
handleSearchChange={this.handleSearchChange}
|
|
handleSearchKeyDown={this.handleSearchKeyDown}
|
|
handlePersist={this.handlePersist}
|
|
handleDelete={this.handleDelete}
|
|
handleInsert={this.handleInsert}
|
|
setScrollContainerRef={ref => this.scrollContainerRef = ref}
|
|
handleAssetClick={this.handleAssetClick}
|
|
handleLoadMore={this.handleLoadMore}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|