add media library
* rebase editorial workflow pull requests when behind * fix async/await transpilation * add media library pagination * switch media library to grid layout * ensure that only cms branches can be force updated
This commit is contained in:
@ -7,13 +7,22 @@
|
||||
|
||||
/* Gross stuff below, React Toolbox hacks */
|
||||
|
||||
.nc-appHeader-homeLink,
|
||||
.nc-appHeader-button,
|
||||
.nc-appHeader-iconMenu {
|
||||
margin-left: 2%;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.nc-appHeader-homeLink &icon {
|
||||
vertical-align: top;
|
||||
.nc-appHeader-button {
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
width: 36px;
|
||||
padding: 6px 0;
|
||||
text-align: center;
|
||||
|
||||
& .nc-appHeader-icon {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-appHeader-icon,
|
||||
|
@ -20,11 +20,6 @@ export default class AppHeader extends React.Component {
|
||||
onLogoutClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
createMenuActive: false,
|
||||
userMenuActive: false,
|
||||
};
|
||||
|
||||
handleCreatePostClick = (collectionName) => {
|
||||
const { onCreateEntryClick } = this.props;
|
||||
if (onCreateEntryClick) {
|
||||
@ -32,18 +27,6 @@ export default class AppHeader extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
handleCreateButtonClick = () => {
|
||||
this.setState({
|
||||
createMenuActive: true,
|
||||
});
|
||||
};
|
||||
|
||||
handleCreateMenuHide = () => {
|
||||
this.setState({
|
||||
createMenuActive: false,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
@ -51,6 +34,7 @@ export default class AppHeader extends React.Component {
|
||||
runCommand,
|
||||
toggleDrawer,
|
||||
onLogoutClick,
|
||||
openMediaLibrary,
|
||||
} = this.props;
|
||||
|
||||
const avatarStyle = {
|
||||
@ -59,7 +43,6 @@ export default class AppHeader extends React.Component {
|
||||
|
||||
const theme = {
|
||||
appBar: 'nc-appHeader-appBar',
|
||||
homeLink: 'nc-appHeader-homeLink',
|
||||
iconMenu: 'nc-appHeader-iconMenu',
|
||||
icon: 'nc-appHeader-icon',
|
||||
leftIcon: 'nc-appHeader-leftIcon',
|
||||
@ -76,17 +59,14 @@ export default class AppHeader extends React.Component {
|
||||
theme={theme}
|
||||
leftIcon="menu"
|
||||
onLeftIconClick={toggleDrawer}
|
||||
onRightIconClick={this.handleRightIconClick}
|
||||
>
|
||||
<Link to="/" className="nc-appHeader-homeLink">
|
||||
<Link to="/" className="nc-appHeader-button">
|
||||
<FontIcon value="home" className="nc-appHeader-icon" />
|
||||
</Link>
|
||||
<IconMenu
|
||||
theme={theme}
|
||||
icon="add"
|
||||
onClick={this.handleCreateButtonClick}
|
||||
onHide={this.handleCreateMenuHide}
|
||||
>
|
||||
<button onClick={openMediaLibrary} className="nc-appHeader-button">
|
||||
<FontIcon value="perm_media" className="nc-appHeader-icon" />
|
||||
</button>
|
||||
<IconMenu icon="add" theme={theme}>
|
||||
{
|
||||
collections.filter(collection => collection.get('create')).toList().map(collection =>
|
||||
<MenuItem
|
||||
|
@ -7,39 +7,15 @@
|
||||
position: relative;
|
||||
padding: 20px 0 10px 0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
|
||||
.nc-controlPane-control input,
|
||||
.nc-controlPane-control textarea,
|
||||
.nc-controlPane-control select,
|
||||
.nc-controlPane-control div[contenteditable=true] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
border: var(--textFieldBorder);
|
||||
border-radius: var(--borderRadius);
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background-color: var(--controlBGColor);
|
||||
font-size: 16px;
|
||||
color: var(--textColor);
|
||||
transition: border-color .3s ease;
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
border-color: var(--primaryColor);
|
||||
& input,
|
||||
& textarea,
|
||||
& select,
|
||||
& div[contenteditable=true] {
|
||||
@apply --input;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-controlPane-control input,
|
||||
.nc-controlPane-control textarea,
|
||||
.nc-controlPane-control select {
|
||||
font-family: var(--fontFamilyMono);
|
||||
}
|
||||
|
||||
|
||||
.nc-controlPane-label {
|
||||
display: block;
|
||||
color: var(--controlLabelColor);
|
||||
|
@ -24,7 +24,17 @@ export default class ControlPane extends Component {
|
||||
};
|
||||
|
||||
controlFor(field) {
|
||||
const { entry, fieldsMetaData, fieldsErrors, getAsset, onChange, onAddAsset, onRemoveAsset } = this.props;
|
||||
const {
|
||||
entry,
|
||||
fieldsMetaData,
|
||||
fieldsErrors,
|
||||
mediaPaths,
|
||||
getAsset,
|
||||
onChange,
|
||||
onOpenMediaLibrary,
|
||||
onAddAsset,
|
||||
onRemoveAsset
|
||||
} = this.props;
|
||||
const widget = resolveWidget(field.get('widget'));
|
||||
const fieldName = field.get('name');
|
||||
const value = entry.getIn(['data', fieldName]);
|
||||
@ -48,9 +58,11 @@ export default class ControlPane extends Component {
|
||||
controlComponent={widget.control}
|
||||
field={field}
|
||||
value={value}
|
||||
mediaPaths={mediaPaths}
|
||||
metadata={metadata}
|
||||
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)}
|
||||
onValidate={this.props.onValidate.bind(this, fieldName)}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
getAsset={getAsset}
|
||||
@ -87,7 +99,9 @@ ControlPane.propTypes = {
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
||||
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func.isRequired,
|
||||
|
@ -7,7 +7,7 @@
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
z-index: 299;
|
||||
opacity: 0.8;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@ -41,7 +41,7 @@
|
||||
height: 55px;
|
||||
padding: 10px 20px;
|
||||
position: absolute;
|
||||
z-index: 9999;
|
||||
z-index: 299;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
@ -52,12 +52,14 @@ class EntryEditor extends Component {
|
||||
fields,
|
||||
fieldsMetaData,
|
||||
fieldsErrors,
|
||||
mediaPaths,
|
||||
getAsset,
|
||||
onChange,
|
||||
enableSave,
|
||||
showDelete,
|
||||
onDelete,
|
||||
onValidate,
|
||||
onOpenMediaLibrary,
|
||||
onAddAsset,
|
||||
onRemoveAsset,
|
||||
onCancelEdit,
|
||||
@ -102,9 +104,11 @@ class EntryEditor extends Component {
|
||||
fields={fields}
|
||||
fieldsMetaData={fieldsMetaData}
|
||||
fieldsErrors={fieldsErrors}
|
||||
mediaPaths={mediaPaths}
|
||||
getAsset={getAsset}
|
||||
onChange={onChange}
|
||||
onValidate={onValidate}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
ref={c => this.controlPaneRef = c} // eslint-disable-line
|
||||
@ -166,7 +170,9 @@ EntryEditor.propTypes = {
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
||||
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func.isRequired,
|
||||
|
98
src/components/MediaLibrary/MediaLibrary.css
Normal file
98
src/components/MediaLibrary/MediaLibrary.css
Normal file
@ -0,0 +1,98 @@
|
||||
@import './MediaLibraryFooter.css';
|
||||
|
||||
:root {
|
||||
--mediaLibraryCardWidth: 300px;
|
||||
--mediaLibraryCardMargin: 10px;
|
||||
--mediaLibraryCardOutsideWidth: calc(var(--mediaLibraryCardWidth) + var(--mediaLibraryCardMargin) * 2);
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-dialog {
|
||||
width: calc(var(--mediaLibraryCardOutsideWidth) + 48px);
|
||||
|
||||
@media (width >= 800px) {
|
||||
width: calc(var(--mediaLibraryCardOutsideWidth) * 2 + 48px);
|
||||
}
|
||||
|
||||
@media (width >= 1120px) {
|
||||
width: calc(var(--mediaLibraryCardOutsideWidth) * 3 + 48px);
|
||||
}
|
||||
|
||||
@media (width >= 1440px) {
|
||||
width: calc(var(--mediaLibraryCardOutsideWidth) * 4 + 48px);
|
||||
}
|
||||
|
||||
@media (width >= 1760px) {
|
||||
width: calc(var(--mediaLibraryCardOutsideWidth) * 5 + 48px);
|
||||
}
|
||||
|
||||
@media (width >= 2080px) {
|
||||
width: calc(var(--mediaLibraryCardOutsideWidth) * 6 + 48px);
|
||||
}
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-title {
|
||||
position: absolute;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-searchInput {
|
||||
@apply --input;
|
||||
font-family: var(--fontFamilyPrimary);
|
||||
width: 50%;
|
||||
max-width: 800px;
|
||||
margin: 12px auto;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-emptyMessage {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-cardGrid-container {
|
||||
height: calc(100% - 150px);
|
||||
margin: 20px auto 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-cardGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-card {
|
||||
width: var(--mediaLibraryCardWidth);
|
||||
height: 240px;
|
||||
margin: var(--mediaLibraryCardMargin);
|
||||
border: var(--textFieldBorder);
|
||||
border-radius: var(--borderRadius);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-card-selected {
|
||||
border-color: var(--primaryColor);
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-cardImage {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-cardText {
|
||||
color: var(--textColor);
|
||||
padding: 8px;
|
||||
margin-top: 20px;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.3 !important;
|
||||
}
|
332
src/components/MediaLibrary/MediaLibrary.js
Normal file
332
src/components/MediaLibrary/MediaLibrary.js
Normal file
@ -0,0 +1,332 @@
|
||||
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);
|
25
src/components/MediaLibrary/MediaLibraryFooter.css
Normal file
25
src/components/MediaLibrary/MediaLibraryFooter.css
Normal file
@ -0,0 +1,25 @@
|
||||
.nc-mediaLibrary-footer-buttonRight {
|
||||
float: right;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-footer-buttonLeft {
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-footer-button-loader {
|
||||
float: left;
|
||||
margin: 8px 20px;
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-footer-button-loaderSpinner {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.nc-mediaLibrary-footer-button-loaderText {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
margin-left: 18px;
|
||||
}
|
||||
|
64
src/components/MediaLibrary/MediaLibraryFooter.js
Normal file
64
src/components/MediaLibrary/MediaLibraryFooter.js
Normal file
@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Button, BrowseButton } from 'react-toolbox/lib/button';
|
||||
import Loader from '../UI/loader/Loader';
|
||||
|
||||
const MediaLibraryFooter = ({
|
||||
onDelete,
|
||||
onPersist,
|
||||
onClose,
|
||||
onInsert,
|
||||
hasSelection,
|
||||
forImage,
|
||||
canInsert,
|
||||
isPersisting,
|
||||
isDeleting,
|
||||
}) => {
|
||||
const shouldShowLoader = isPersisting || isDeleting;
|
||||
const loaderText = isPersisting ? 'Uploading...' : 'Deleting...';
|
||||
const loader = (
|
||||
<div className="nc-mediaLibrary-footer-button-loader">
|
||||
<Loader className="nc-mediaLibrary-footer-button-loaderSpinner" active/>
|
||||
<strong className="nc-mediaLibrary-footer-button-loaderText">{loaderText}</strong>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
label="Delete"
|
||||
onClick={onDelete}
|
||||
className="nc-mediaLibrary-footer-buttonLeft"
|
||||
disabled={shouldShowLoader || !hasSelection}
|
||||
accent
|
||||
raised
|
||||
/>
|
||||
<BrowseButton
|
||||
label="Upload"
|
||||
accept={forImage}
|
||||
onChange={onPersist}
|
||||
className="nc-mediaLibrary-footer-buttonLeft"
|
||||
disabled={shouldShowLoader}
|
||||
primary
|
||||
raised
|
||||
/>
|
||||
{ shouldShowLoader ? loader : null }
|
||||
<Button
|
||||
label="Close"
|
||||
onClick={onClose}
|
||||
className="nc-mediaLibrary-footer-buttonRight"
|
||||
raised
|
||||
/>
|
||||
{ !canInsert ? null :
|
||||
<Button
|
||||
label="Insert"
|
||||
onClick={onInsert}
|
||||
className="nc-mediaLibrary-footer-buttonRight"
|
||||
disabled={!hasSelection}
|
||||
primary
|
||||
raised
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaLibraryFooter;
|
52
src/components/UI/Dialog/Dialog.css
Normal file
52
src/components/UI/Dialog/Dialog.css
Normal file
@ -0,0 +1,52 @@
|
||||
.nc-dialog-wrapper {
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.nc-dialog-root {
|
||||
height: 80%;
|
||||
text-align: center;
|
||||
max-width: 2200px;
|
||||
}
|
||||
|
||||
.nc-dialog-body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nc-dialog-contentWrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nc-dialog-footer {
|
||||
margin: 24px 0;
|
||||
width: calc(100% - 48px);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Progress Bar
|
||||
*/
|
||||
.nc-dialog-progressOverlay {
|
||||
background-color: rgba(255, 255, 255, 0.75);
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nc-dialog-progressOverlay-active {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.nc-dialog-progressBarContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nc-dialog-progressBar-linear {
|
||||
width: 80%;
|
||||
}
|
||||
|
16
src/components/UI/Dialog/FocusTrap.js
Normal file
16
src/components/UI/Dialog/FocusTrap.js
Normal file
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import FocusTrapReact from 'focus-trap-react';
|
||||
|
||||
/**
|
||||
* A wrapper for focus-trap-react, which we use to completely remove focus traps
|
||||
* from the DOM rather than using the library's own internal activation/pausing
|
||||
* mechanisms, which can manifest bugs when nested.
|
||||
*/
|
||||
const FocusTrap = props => {
|
||||
const { active, children, focusTrapOptions, className } = props;
|
||||
return active
|
||||
? <FocusTrapReact focusTrapOptions={focusTrapOptions} className={className}>{children}</FocusTrapReact>
|
||||
: <div className={className}>{children}</div>
|
||||
}
|
||||
|
||||
export default FocusTrap;
|
65
src/components/UI/Dialog/index.js
Normal file
65
src/components/UI/Dialog/index.js
Normal file
@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import RTDialog from 'react-toolbox/lib/dialog';
|
||||
import Overlay from 'react-toolbox/lib/overlay';
|
||||
import ProgressBar from 'react-toolbox/lib/progress_bar';
|
||||
import FocusTrap from './FocusTrap';
|
||||
|
||||
const dialogTheme = {
|
||||
wrapper: 'nc-dialog-wrapper',
|
||||
dialog: 'nc-dialog-root',
|
||||
body: 'nc-dialog-body',
|
||||
};
|
||||
|
||||
const progressOverlayTheme = {
|
||||
overlay: 'nc-dialog-progressOverlay',
|
||||
active: 'nc-dialog-progressOverlay-active',
|
||||
};
|
||||
|
||||
const progressBarTheme = {
|
||||
linear: 'nc-dialog-progressBar-linear',
|
||||
};
|
||||
|
||||
const Dialog = ({
|
||||
type,
|
||||
isVisible,
|
||||
isLoading,
|
||||
loadingMessage,
|
||||
onClose,
|
||||
footer,
|
||||
className,
|
||||
children,
|
||||
}) =>
|
||||
<RTDialog
|
||||
type={type || 'large'}
|
||||
active={isVisible}
|
||||
onEscKeyDown={onClose}
|
||||
onOverlayClick={onClose}
|
||||
theme={dialogTheme}
|
||||
className={className}
|
||||
>
|
||||
<FocusTrap
|
||||
active={isVisible && !isLoading}
|
||||
focusTrapOptions={{ clickOutsideDeactivates: true, fallbackFocus: '.fallbackFocus' }}
|
||||
className="nc-dialog-contentWrapper"
|
||||
>
|
||||
<Overlay active={isLoading} theme={progressOverlayTheme}>
|
||||
<FocusTrap
|
||||
active={isVisible && isLoading}
|
||||
focusTrapOptions={{ clickOutsideDeactivates: true, initialFocus: 'h1' }}
|
||||
className="nc-dialog-progressBarContainer"
|
||||
>
|
||||
<h1 style={{ marginTop: '-40px' }} tabIndex="-1">{ loadingMessage }</h1>
|
||||
<ProgressBar type="linear" mode="indeterminate" theme={progressBarTheme}/>
|
||||
</FocusTrap>
|
||||
</Overlay>
|
||||
|
||||
<div className="fallbackFocus" className="nc-dialog-contentWrapper">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{ footer ? <div className="nc-dialog-footer">{footer}</div> : null }
|
||||
|
||||
</FocusTrap>
|
||||
</RTDialog>;
|
||||
|
||||
export default Dialog;
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import CSSTransition from 'react-transition-group/CSSTransition';
|
||||
import classnames from 'classnames';
|
||||
import c from 'classnames';
|
||||
|
||||
export default class Loader extends React.Component {
|
||||
|
||||
@ -50,8 +50,8 @@ export default class Loader extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { active } = this.props;
|
||||
const className = classnames('nc-loader-root', { 'nc-loader-active': active });
|
||||
return <div className={className}>{this.renderChild()}</div>;
|
||||
const { active, className } = this.props;
|
||||
const combinedClassName = c('nc-loader-root', { 'nc-loader-active': active }, className);
|
||||
return <div className={combinedClassName}>{this.renderChild()}</div>;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
:root {
|
||||
--fontFamily: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
--fontFamilyPrimary: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
--fontFamilyMono: 'SFMono-Regular', Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
--defaultColor: #333;
|
||||
--defaultColorLight: #fff;
|
||||
@ -32,6 +32,27 @@
|
||||
--borderWidth: 2px;
|
||||
--border: solid var(--borderWidth) var(--secondaryColor);
|
||||
--textFieldBorder: var(--border);
|
||||
|
||||
--input: {
|
||||
font-family: 'SFMono-Regular', Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
border: var(--textFieldBorder);
|
||||
border-radius: var(--borderRadius);
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background-color: var(--controlBGColor);
|
||||
font-size: 16px;
|
||||
color: var(--textColor);
|
||||
transition: border-color .3s ease;
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
border-color: var(--primaryColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nc-theme-base {
|
||||
|
@ -16,21 +16,36 @@ class ControlHOC extends Component {
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
]),
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
metadata: ImmutablePropTypes.map,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
/**
|
||||
* Allow widgets to provide their own `shouldComponentUpdate` method.
|
||||
*/
|
||||
if (this.wrappedControlShouldComponentUpdate) {
|
||||
return this.wrappedControlShouldComponentUpdate(nextProps);
|
||||
}
|
||||
return this.props.value !== nextProps.value;
|
||||
}
|
||||
|
||||
processInnerControlRef = (wrappedControl) => {
|
||||
if (!wrappedControl) return;
|
||||
this.wrappedControlValid = wrappedControl.isValid || truthy;
|
||||
|
||||
/**
|
||||
* Get the `shouldComponentUpdate` method from the wrapped control, and
|
||||
* provide the control instance is the `this` binding.
|
||||
*/
|
||||
const { shouldComponentUpdate: scu } = wrappedControl;
|
||||
this.wrappedControlShouldComponentUpdate = scu && scu.bind(wrappedControl);
|
||||
};
|
||||
|
||||
validate = (skipWrapped = false) => {
|
||||
@ -113,12 +128,25 @@ class ControlHOC extends Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { controlComponent, field, value, metadata, onChange, onAddAsset, onRemoveAsset, getAsset } = this.props;
|
||||
const {
|
||||
controlComponent,
|
||||
field,
|
||||
value,
|
||||
mediaPaths,
|
||||
metadata,
|
||||
onChange,
|
||||
onOpenMediaLibrary,
|
||||
onAddAsset,
|
||||
onRemoveAsset,
|
||||
getAsset
|
||||
} = this.props;
|
||||
return React.createElement(controlComponent, {
|
||||
field,
|
||||
value,
|
||||
mediaPaths,
|
||||
metadata,
|
||||
onChange,
|
||||
onOpenMediaLibrary,
|
||||
onAddAsset,
|
||||
onRemoveAsset,
|
||||
getAsset,
|
||||
|
@ -1,115 +1,76 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||
import { get } from 'lodash';
|
||||
import uuid from 'uuid/v4';
|
||||
import { truncateMiddle } from '../../lib/textHelper';
|
||||
import { Loader } from '../UI';
|
||||
import AssetProxy, { createAssetProxy } from '../../valueObjects/AssetProxy';
|
||||
|
||||
const MAX_DISPLAY_LENGTH = 50;
|
||||
|
||||
export default class FileControl extends React.Component {
|
||||
state = {
|
||||
processing: false,
|
||||
};
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.controlID = uuid();
|
||||
}
|
||||
|
||||
promise = null;
|
||||
|
||||
isValid = () => {
|
||||
if (this.promise) {
|
||||
return this.promise;
|
||||
shouldComponentUpdate(nextProps) {
|
||||
/**
|
||||
* Always update if the value changes.
|
||||
*/
|
||||
if (this.props.value !== nextProps.value) {
|
||||
return true;
|
||||
}
|
||||
return { error: false };
|
||||
};
|
||||
|
||||
/**
|
||||
* If there is a media path for this control in the state object, and that
|
||||
* path is different than the value in `nextProps`, update.
|
||||
*/
|
||||
const mediaPath = nextProps.mediaPaths.get(this.controlID);
|
||||
if (mediaPath && (nextProps.value !== mediaPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
handleFileInputRef = (el) => {
|
||||
this._fileInput = el;
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const { mediaPaths, value } = nextProps;
|
||||
const mediaPath = mediaPaths.get(this.controlID);
|
||||
if (mediaPath && mediaPath !== value) {
|
||||
this.props.onChange(mediaPath);
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
this._fileInput.click();
|
||||
};
|
||||
|
||||
handleDragEnter = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
handleDragOver = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
handleChange = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const fileList = e.dataTransfer ? e.dataTransfer.files : e.target.files;
|
||||
const files = [...fileList];
|
||||
const imageType = /^image\//;
|
||||
|
||||
// Return the first file on the list
|
||||
const file = files[0];
|
||||
|
||||
this.props.onRemoveAsset(this.props.value);
|
||||
if (file) {
|
||||
this.setState({ processing: true });
|
||||
this.promise = createAssetProxy(file.name, file, false, this.props.field.get('private', false))
|
||||
.then((assetProxy) => {
|
||||
this.setState({ processing: false });
|
||||
this.props.onAddAsset(assetProxy);
|
||||
this.props.onChange(assetProxy.public_path);
|
||||
});
|
||||
} else {
|
||||
this.props.onChange(null);
|
||||
}
|
||||
const { field, onOpenMediaLibrary } = this.props;
|
||||
return onOpenMediaLibrary({ controlID: this.controlID, privateUpload: field.private });
|
||||
};
|
||||
|
||||
renderFileName = () => {
|
||||
if (!this.props.value) return null;
|
||||
if (this.value instanceof AssetProxy) {
|
||||
return truncateMiddle(this.props.value.path, MAX_DISPLAY_LENGTH);
|
||||
} else {
|
||||
return truncateMiddle(this.props.value, MAX_DISPLAY_LENGTH);
|
||||
}
|
||||
const { value } = this.props;
|
||||
return value ? truncateMiddle(value, MAX_DISPLAY_LENGTH) : null;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { processing } = this.state;
|
||||
const fileName = this.renderFileName();
|
||||
if (processing) {
|
||||
return (
|
||||
<div className="nc-fileControl-imageUpload">
|
||||
<span className="nc-fileControl-message">
|
||||
<Loader active />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="nc-fileControl-imageUpload"
|
||||
onDragEnter={this.handleDragEnter}
|
||||
onDragOver={this.handleDragOver}
|
||||
onDrop={this.handleChange}
|
||||
>
|
||||
<div className="nc-fileControl-imageUpload">
|
||||
<span className="nc-fileControl-message" onClick={this.handleClick}>
|
||||
{fileName ? fileName : 'Click here to upload a file from your computer, or drag and drop a file directly into this box'}
|
||||
{fileName ? fileName : 'Click here to select an asset from the asset library'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
onChange={this.handleChange}
|
||||
className="nc-fileControl-input"
|
||||
ref={this.handleFileInputRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FileControl.propTypes = {
|
||||
field: PropTypes.object.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
value: PropTypes.node,
|
||||
field: PropTypes.object,
|
||||
};
|
||||
|
@ -1,119 +1,74 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||
import uuid from 'uuid/v4';
|
||||
import { truncateMiddle } from '../../lib/textHelper';
|
||||
import { Loader } from '../UI';
|
||||
import AssetProxy, { createAssetProxy } from '../../valueObjects/AssetProxy';
|
||||
|
||||
const MAX_DISPLAY_LENGTH = 50;
|
||||
|
||||
export default class ImageControl extends React.Component {
|
||||
state = {
|
||||
processing: false,
|
||||
};
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.controlID = uuid();
|
||||
}
|
||||
|
||||
promise = null;
|
||||
|
||||
isValid = () => {
|
||||
if (this.promise) {
|
||||
return this.promise;
|
||||
shouldComponentUpdate(nextProps) {
|
||||
/**
|
||||
* Always update if the value changes.
|
||||
*/
|
||||
if (this.props.value !== nextProps.value) {
|
||||
return true;
|
||||
}
|
||||
return { error: false };
|
||||
};
|
||||
|
||||
/**
|
||||
* If there is a media path for this control in the state object, and that
|
||||
* path is different than the value in `nextProps`, update.
|
||||
*/
|
||||
const mediaPath = nextProps.mediaPaths.get(this.controlID);
|
||||
if (mediaPath && (nextProps.value !== mediaPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
handleFileInputRef = (el) => {
|
||||
this._fileInput = el;
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const { mediaPaths, value } = nextProps;
|
||||
const mediaPath = mediaPaths.get(this.controlID);
|
||||
if (mediaPath && mediaPath !== value) {
|
||||
this.props.onChange(mediaPath);
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
this._fileInput.click();
|
||||
const { field, onOpenMediaLibrary } = this.props;
|
||||
return onOpenMediaLibrary({ controlID: this.controlID, forImage: true, privateUpload: field.private });
|
||||
};
|
||||
|
||||
handleDragEnter = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
handleDragOver = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
handleChange = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const fileList = e.dataTransfer ? e.dataTransfer.files : e.target.files;
|
||||
const files = [...fileList];
|
||||
const imageType = /^image\//;
|
||||
|
||||
// Iterate through the list of files and return the first image on the list
|
||||
const file = files.find((currentFile) => {
|
||||
if (imageType.test(currentFile.type)) {
|
||||
return currentFile;
|
||||
}
|
||||
});
|
||||
|
||||
this.props.onRemoveAsset(this.props.value);
|
||||
if (file) {
|
||||
this.setState({ processing: true });
|
||||
this.promise = createAssetProxy(file.name, file)
|
||||
.then((assetProxy) => {
|
||||
this.setState({ processing: false });
|
||||
this.props.onAddAsset(assetProxy);
|
||||
this.props.onChange(assetProxy.public_path);
|
||||
});
|
||||
} else {
|
||||
this.props.onChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
renderImageName = () => {
|
||||
if (!this.props.value) return null;
|
||||
if (this.value instanceof AssetProxy) {
|
||||
return truncateMiddle(this.props.value.path, MAX_DISPLAY_LENGTH);
|
||||
} else {
|
||||
return truncateMiddle(this.props.value, MAX_DISPLAY_LENGTH);
|
||||
}
|
||||
renderFileName = () => {
|
||||
const { value } = this.props;
|
||||
return value ? truncateMiddle(value, MAX_DISPLAY_LENGTH) : null;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { processing } = this.state;
|
||||
const imageName = this.renderImageName();
|
||||
if (processing) {
|
||||
return (
|
||||
<div className="nc-fileControl-imageUpload">
|
||||
<span className="nc-fileControl-message">
|
||||
<Loader active />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const fileName = this.renderFileName();
|
||||
return (
|
||||
<div
|
||||
className="nc-fileControl-imageUpload"
|
||||
onDragEnter={this.handleDragEnter}
|
||||
onDragOver={this.handleDragOver}
|
||||
onDrop={this.handleChange}
|
||||
>
|
||||
<div className="nc-fileControl-imageUpload">
|
||||
<span className="nc-fileControl-message" onClick={this.handleClick}>
|
||||
{imageName ? imageName : 'Click here to upload an image from your computer, or drag and drop a file directly into this box'}
|
||||
{fileName ? fileName : 'Click here to select an image from the image library'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={this.handleChange}
|
||||
className="nc-fileControl-input"
|
||||
ref={this.handleFileInputRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImageControl.propTypes = {
|
||||
field: PropTypes.object.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
value: PropTypes.node,
|
||||
};
|
||||
|
@ -1,6 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { List, Map, fromJS } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
|
||||
import FontIcon from 'react-toolbox/lib/font_icon';
|
||||
import ObjectControl from './ObjectControl';
|
||||
@ -36,7 +37,9 @@ export default class ListControl extends Component {
|
||||
value: PropTypes.node,
|
||||
field: PropTypes.node,
|
||||
forID: PropTypes.string,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
};
|
||||
@ -47,6 +50,16 @@ export default class ListControl extends Component {
|
||||
this.valueType = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Always update so that each nested widget has the option to update. This is
|
||||
* required because ControlHOC provides a default `shouldComponentUpdate`
|
||||
* which only updates if the value changes, but every widget must be allowed
|
||||
* to override this.
|
||||
*/
|
||||
shouldComponentUpdate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { field } = this.props;
|
||||
if (field.get('fields')) {
|
||||
@ -147,7 +160,7 @@ export default class ListControl extends Component {
|
||||
};
|
||||
|
||||
renderItem = (item, index) => {
|
||||
const { field, getAsset, onAddAsset, onRemoveAsset } = this.props;
|
||||
const { field, getAsset, mediaPaths, onOpenMediaLibrary, onAddAsset, onRemoveAsset } = this.props;
|
||||
const { itemsCollapsed } = this.state;
|
||||
const collapsed = itemsCollapsed.get(index);
|
||||
const classNames = ['nc-listControl-item', collapsed ? 'nc-listControl-collapsed' : ''];
|
||||
@ -167,6 +180,8 @@ export default class ListControl extends Component {
|
||||
className="nc-listControl-objectControl"
|
||||
onChange={this.handleChangeFor(index)}
|
||||
getAsset={getAsset}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
mediaPaths={mediaPaths}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
/>
|
||||
|
@ -1,10 +1,13 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||
import { connect } from 'react-redux';
|
||||
import { Map } from 'immutable';
|
||||
import { Button } from 'react-toolbox/lib/button';
|
||||
import { openMediaLibrary } from '../../../../../actions/mediaLibrary';
|
||||
import ToolbarPluginFormControl from './ToolbarPluginFormControl';
|
||||
|
||||
export default class ToolbarPluginForm extends React.Component {
|
||||
class ToolbarPluginForm extends React.Component {
|
||||
static propTypes = {
|
||||
plugin: PropTypes.object.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
@ -12,6 +15,8 @@ export default class ToolbarPluginForm extends React.Component {
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
@ -37,6 +42,8 @@ export default class ToolbarPluginForm extends React.Component {
|
||||
onRemoveAsset,
|
||||
getAsset,
|
||||
onChange,
|
||||
onOpenMediaLibrary,
|
||||
mediaPaths,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@ -54,6 +61,8 @@ export default class ToolbarPluginForm extends React.Component {
|
||||
onChange={(val) => {
|
||||
this.setState({ data: this.state.data.set(field.get('name'), val) });
|
||||
}}
|
||||
mediaPaths={mediaPaths}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -66,3 +75,13 @@ export default class ToolbarPluginForm extends React.Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
mediaPaths: state.mediaLibrary.get('controlMedia'),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
onOpenMediaLibrary: openMediaLibrary,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ToolbarPluginForm);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||
import { resolveWidget } from '../../../../Widgets';
|
||||
|
||||
const ToolbarPluginFormControl = ({
|
||||
@ -10,11 +11,13 @@ const ToolbarPluginFormControl = ({
|
||||
onRemoveAsset,
|
||||
getAsset,
|
||||
onChange,
|
||||
mediaPaths,
|
||||
onOpenMediaLibrary,
|
||||
}) => {
|
||||
const widget = resolveWidget(field.get('widget') || 'string');
|
||||
const key = `field-${ field.get('name') }`;
|
||||
const Control = widget.control;
|
||||
const controlProps = { field, value, onAddAsset, onRemoveAsset, getAsset, onChange };
|
||||
const controlProps = { field, value, onAddAsset, onRemoveAsset, getAsset, onChange, mediaPaths, onOpenMediaLibrary };
|
||||
|
||||
return (
|
||||
<div className="nc-controlPane-control" key={key}>
|
||||
@ -34,6 +37,8 @@ ToolbarPluginFormControl.propTypes = {
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ToolbarPluginFormControl;
|
||||
|
@ -18,7 +18,7 @@
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
min-height: var(--richTextEditorMinHeight);
|
||||
font-family: var(--fontFamily);
|
||||
font-family: var(--fontFamilyPrimary);
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor h1 {
|
||||
|
@ -1,11 +1,15 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Map } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ControlHOC from './ControlHOC';
|
||||
import { resolveWidget } from '../Widgets';
|
||||
|
||||
export default class ObjectControl extends Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
@ -24,10 +28,25 @@ export default class ObjectControl extends Component {
|
||||
* e.g. when debounced, always get the latest object value instead of usin
|
||||
* `this.props.value` directly.
|
||||
*/
|
||||
getObjectValue = () => this.props.value;
|
||||
getObjectValue = () => this.props.value || Map();
|
||||
|
||||
/*
|
||||
* Always update so that each nested widget has the option to update. This is
|
||||
* required because ControlHOC provides a default `shouldComponentUpdate`
|
||||
* which only updates if the value changes, but every widget must be allowed
|
||||
* to override this.
|
||||
*/
|
||||
shouldComponentUpdate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
onChange = (fieldName, newValue, newMetadata) => {
|
||||
const newObjectValue = this.getObjectValue().set(fieldName, newValue);
|
||||
return this.props.onChange(newObjectValue, newMetadata);
|
||||
};
|
||||
|
||||
controlFor(field) {
|
||||
const { onAddAsset, onRemoveAsset, getAsset, value, onChange } = this.props;
|
||||
const { onAddAsset, onOpenMediaLibrary, mediaPaths, onRemoveAsset, getAsset, value, onChange } = this.props;
|
||||
if (field.get('widget') === 'hidden') {
|
||||
return null;
|
||||
}
|
||||
@ -37,20 +56,18 @@ export default class ObjectControl extends Component {
|
||||
return (<div className="nc-controlPane-widget" key={field.get('name')}>
|
||||
<div className="nc-controlPane-control" key={field.get('name')}>
|
||||
<label className="nc-controlPane-label" htmlFor={field.get('name')}>{field.get('label')}</label>
|
||||
{
|
||||
React.createElement(widget.control, {
|
||||
id: field.get('name'),
|
||||
field,
|
||||
value: fieldValue,
|
||||
onChange: (val, metadata) => {
|
||||
onChange((this.getObjectValue() || Map()).set(field.get('name'), val), metadata);
|
||||
},
|
||||
onAddAsset,
|
||||
onRemoveAsset,
|
||||
getAsset,
|
||||
forID: field.get('name'),
|
||||
})
|
||||
}
|
||||
<ControlHOC
|
||||
controlComponent={widget.control}
|
||||
field={field}
|
||||
value={fieldValue}
|
||||
onChange={this.onChange.bind(this, field.get('name'))}
|
||||
mediaPaths={mediaPaths}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
getAsset={getAsset}
|
||||
forID={field.get('name')}
|
||||
/>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
Reference in New Issue
Block a user