feat(media): add external media library support, Uploadcare integration (#1602)

This commit is contained in:
Shawn Erquhart
2018-08-30 16:24:28 -04:00
committed by GitHub
parent ae28f6301e
commit 0596904e0b
34 changed files with 715 additions and 135 deletions

View File

@ -1,3 +1,4 @@
import { Map } from 'immutable';
import { actions as notifActions } from 'redux-notifications';
import { currentBackend } from 'src/backend';
import { createAssetProxy } from 'ValueObjects/AssetProxy';
@ -10,6 +11,7 @@ const { notifSend } = notifActions;
export const MEDIA_LIBRARY_OPEN = 'MEDIA_LIBRARY_OPEN';
export const MEDIA_LIBRARY_CLOSE = 'MEDIA_LIBRARY_CLOSE';
export const MEDIA_LIBRARY_CREATE = 'MEDIA_LIBRARY_CREATE';
export const MEDIA_INSERT = 'MEDIA_INSERT';
export const MEDIA_REMOVE_INSERTED = 'MEDIA_REMOVE_INSERTED';
export const MEDIA_LOAD_REQUEST = 'MEDIA_LOAD_REQUEST';
@ -25,12 +27,58 @@ export const MEDIA_DISPLAY_URL_REQUEST = 'MEDIA_DISPLAY_URL_REQUEST';
export const MEDIA_DISPLAY_URL_SUCCESS = 'MEDIA_DISPLAY_URL_SUCCESS';
export const MEDIA_DISPLAY_URL_FAILURE = 'MEDIA_DISPLAY_URL_FAILURE';
export function openMediaLibrary(payload) {
return { type: MEDIA_LIBRARY_OPEN, payload };
export function createMediaLibrary(instance) {
const api = {
show: instance.show || (() => {}),
hide: instance.hide || (() => {}),
onClearControl: instance.onClearControl || (() => {}),
onRemoveControl: instance.onRemoveControl || (() => {}),
enableStandalone: instance.enableStandalone || (() => {}),
};
return { type: MEDIA_LIBRARY_CREATE, payload: api };
}
export function clearMediaControl(id) {
return (dispatch, getState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
if (mediaLibrary) {
mediaLibrary.onClearControl({ id });
}
};
}
export function removeMediaControl(id) {
return (dispatch, getState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
if (mediaLibrary) {
mediaLibrary.onRemoveControl({ id });
}
};
}
export function openMediaLibrary(payload = {}) {
return (dispatch, getState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
if (mediaLibrary) {
const { controlID: id, value, config = Map(), forImage } = payload;
mediaLibrary.show({ id, value, config: config.toJS(), imagesOnly: forImage });
}
dispatch({ type: MEDIA_LIBRARY_OPEN, payload });
};
}
export function closeMediaLibrary() {
return { type: MEDIA_LIBRARY_CLOSE };
return (dispatch, getState) => {
const state = getState();
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
if (mediaLibrary) {
mediaLibrary.hide();
}
dispatch({ type: MEDIA_LIBRARY_CLOSE });
};
}
export function insertMedia(mediaPath) {

View File

@ -4,12 +4,12 @@ import { Provider } from 'react-redux';
import { Route } from 'react-router-dom';
import { ConnectedRouter } from 'react-router-redux';
import history from 'Routing/history';
import configureStore from 'Redux/configureStore';
import store from 'Redux';
import { mergeConfig } from 'Actions/config';
import { setStore } from 'ValueObjects/AssetProxy';
import { ErrorBoundary } from 'UI';
import App from 'App/App';
import 'EditorWidgets';
import 'src/mediaLibrary';
import 'what-input';
const ROOT_ID = 'nc-root';
@ -47,11 +47,6 @@ function bootstrap(opts = {}) {
return newRoot;
}
/**
* Configure Redux store.
*/
const store = configureStore();
/**
* Dispatch config to store if received. This config will be merged into
* config.yml if it exists, and any portion that produces a conflict will be
@ -61,11 +56,6 @@ function bootstrap(opts = {}) {
store.dispatch(mergeConfig(config));
}
/**
* Pass initial state into AssetProxy factory.
*/
setStore(store);
/**
* Create connected root component.
*/

View File

@ -7,11 +7,11 @@ import { connect } from 'react-redux';
import { Route, Switch, Redirect } from 'react-router-dom';
import { Notifs } from 'redux-notifications';
import TopBarProgress from 'react-topbar-progress-indicator';
import { loadConfig as actionLoadConfig } from 'Actions/config';
import { loginUser as actionLoginUser, logoutUser as actionLogoutUser } from 'Actions/auth';
import { loadConfig } from 'Actions/config';
import { loginUser, logoutUser } from 'Actions/auth';
import { currentBackend } from 'src/backend';
import { createNewEntry } from 'Actions/collections';
import { openMediaLibrary as actionOpenMediaLibrary } from 'Actions/mediaLibrary';
import { openMediaLibrary } from 'Actions/mediaLibrary';
import MediaLibrary from 'MediaLibrary/MediaLibrary';
import { Toast } from 'UI';
import { Loader, colors } from 'netlify-cms-ui-default';
@ -53,13 +53,16 @@ class App extends React.Component {
auth: ImmutablePropTypes.map,
config: ImmutablePropTypes.map,
collections: ImmutablePropTypes.orderedMap,
loadConfig: PropTypes.func.isRequired,
loginUser: PropTypes.func.isRequired,
logoutUser: PropTypes.func.isRequired,
dispatch: PropTypes.func.isRequired,
user: ImmutablePropTypes.map,
isFetching: PropTypes.bool.isRequired,
publishMode: PropTypes.oneOf([SIMPLE, EDITORIAL_WORKFLOW]),
siteId: PropTypes.string,
useMediaLibrary: PropTypes.bool,
openMediaLibrary: PropTypes.func.isRequired,
showMediaButton: PropTypes.bool,
};
static configError(config) {
@ -77,11 +80,12 @@ class App extends React.Component {
}
componentDidMount() {
this.props.dispatch(actionLoadConfig());
const { loadConfig } = this.props;
loadConfig();
}
handleLogin(credentials) {
this.props.dispatch(actionLoginUser(credentials));
this.props.loginUser(credentials);
}
authenticating() {
@ -127,7 +131,9 @@ class App extends React.Component {
logoutUser,
isFetching,
publishMode,
useMediaLibrary,
openMediaLibrary,
showMediaButton,
} = this.props;
if (config === null) {
@ -160,6 +166,7 @@ class App extends React.Component {
openMediaLibrary={openMediaLibrary}
hasWorkflow={hasWorkflow}
displayUrl={config.get('display_url')}
showMediaButton={showMediaButton}
/>
<AppMainContainer>
{isFetching && <TopBarProgress />}
@ -180,7 +187,7 @@ class App extends React.Component {
/>
<Route component={NotFoundPage} />
</Switch>
<MediaLibrary />
{useMediaLibrary ? <MediaLibrary /> : null}
</div>
</AppMainContainer>
</div>
@ -189,25 +196,31 @@ class App extends React.Component {
}
function mapStateToProps(state) {
const { auth, config, collections, globalUI } = state;
const { auth, config, collections, globalUI, mediaLibrary } = state;
const user = auth && auth.get('user');
const isFetching = globalUI.get('isFetching');
const publishMode = config && config.get('publish_mode');
return { auth, config, collections, user, isFetching, publishMode };
}
function mapDispatchToProps(dispatch) {
const useMediaLibrary = !mediaLibrary.get('externalLibrary');
const showMediaButton = mediaLibrary.get('showMediaButton');
return {
dispatch,
openMediaLibrary: () => {
dispatch(actionOpenMediaLibrary());
},
logoutUser: () => {
dispatch(actionLogoutUser());
},
auth,
config,
collections,
user,
isFetching,
publishMode,
showMediaButton,
useMediaLibrary,
};
}
const mapDispatchToProps = {
openMediaLibrary,
loadConfig,
loginUser,
logoutUser,
};
export default hot(module)(
connect(
mapStateToProps,

View File

@ -125,6 +125,7 @@ export default class Header extends React.Component {
openMediaLibrary,
hasWorkflow,
displayUrl,
showMediaButton,
} = this.props;
const createableCollections = collections
@ -150,10 +151,12 @@ export default class Header extends React.Component {
Workflow
</AppHeaderNavLink>
) : null}
<AppHeaderButton onClick={openMediaLibrary}>
<Icon type="media-alt" />
Media
</AppHeaderButton>
{showMediaButton ? (
<AppHeaderButton onClick={openMediaLibrary}>
<Icon type="media-alt" />
Media
</AppHeaderButton>
) : null}
</nav>
<AppHeaderActions>
{createableCollections.size > 0 && (

View File

@ -8,7 +8,12 @@ import { colors, colorsRaw, transitions, lengths, borders } from 'netlify-cms-ui
import { resolveWidget, getEditorComponents } from 'Lib/registry';
import { addAsset } from 'Actions/media';
import { query, clearSearch } from 'Actions/search';
import { openMediaLibrary, removeInsertedMedia } from 'Actions/mediaLibrary';
import {
openMediaLibrary,
removeInsertedMedia,
clearMediaControl,
removeMediaControl,
} from 'Actions/mediaLibrary';
import { getAsset } from 'Reducers';
import Widget from './Widget';
@ -153,6 +158,8 @@ class EditorControl extends React.Component {
boundGetAsset,
onChange,
openMediaLibrary,
clearMediaControl,
removeMediaControl,
addAsset,
removeInsertedMedia,
onValidate,
@ -210,6 +217,8 @@ class EditorControl extends React.Component {
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)}
onValidate={onValidate && partial(onValidate, fieldName)}
onOpenMediaLibrary={openMediaLibrary}
onClearMediaControl={clearMediaControl}
onRemoveMediaControl={removeMediaControl}
onRemoveInsertedMedia={removeInsertedMedia}
onAddAsset={addAsset}
getAsset={boundGetAsset}
@ -244,6 +253,8 @@ const mapStateToProps = state => ({
const mapDispatchToProps = {
openMediaLibrary,
clearMediaControl,
removeMediaControl,
removeInsertedMedia,
addAsset,
query,

View File

@ -35,6 +35,8 @@ export default class Widget extends Component {
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func,
onOpenMediaLibrary: PropTypes.func.isRequired,
onClearMediaControl: PropTypes.func.isRequired,
onRemoveMediaControl: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveInsertedMedia: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
@ -191,6 +193,8 @@ export default class Widget extends Component {
metadata,
onChange,
onOpenMediaLibrary,
onRemoveMediaControl,
onClearMediaControl,
onAddAsset,
onRemoveInsertedMedia,
getAsset,
@ -219,6 +223,8 @@ export default class Widget extends Component {
onChange,
onChangeObject: this.onChangeObject,
onOpenMediaLibrary,
onClearMediaControl,
onRemoveMediaControl,
onAddAsset,
onRemoveInsertedMedia,
getAsset,

View File

@ -38,6 +38,14 @@ const getConfigSchema = () => ({
display_url: { type: 'string', examples: ['https://example.com'] },
media_folder: { type: 'string', examples: ['assets/uploads'] },
public_folder: { type: 'string', examples: ['/uploads'] },
media_library: {
type: 'object',
properties: {
name: { type: 'string', examples: ['uploadcare'] },
config: { type: 'object' },
},
required: ['name'],
},
publish_mode: {
type: 'string',
enum: ['editorial_workflow'],
@ -128,7 +136,8 @@ const getConfigSchema = () => ({
},
},
},
required: ['backend', 'media_folder', 'collections'],
required: ['backend', 'collections'],
anyOf: [{ required: ['media_folder'] }, { required: ['media_library'] }],
});
class ConfigError extends Error {

View File

@ -11,6 +11,7 @@ const registry = {
widgets: {},
editorComponents: Map(),
widgetValueSerializers: {},
mediaLibraries: [],
};
export default {
@ -27,6 +28,8 @@ export default {
getWidgetValueSerializer,
registerBackend,
getBackend,
registerMediaLibrary,
getMediaLibrary,
};
/**
@ -109,3 +112,17 @@ export function registerBackend(name, BackendClass) {
export function getBackend(name) {
return registry.backends[name];
}
/**
* Media Libraries
*/
export function registerMediaLibrary(mediaLibrary, options) {
if (registry.mediaLibraries.find(ml => mediaLibrary.name === ml.name)) {
throw new Error(`A media library named ${mediaLibrary.name} has already been registered.`);
}
registry.mediaLibraries.push({ ...mediaLibrary, options });
}
export function getMediaLibrary(name) {
return registry.mediaLibraries.find(ml => ml.name === name);
}

View File

@ -0,0 +1,24 @@
/**
* This module is currently concerned only with external media libraries
* registered via `registerMediaLibrary`.
*/
import { once } from 'lodash';
import { getMediaLibrary } from 'Lib/registry';
import store from 'Redux';
import { createMediaLibrary, insertMedia } from 'Actions/mediaLibrary';
const initializeMediaLibrary = once(async function initializeMediaLibrary(name, options) {
const lib = getMediaLibrary(name);
const handleInsert = url => store.dispatch(insertMedia(url));
const instance = await lib.init({ options, handleInsert });
store.dispatch(createMediaLibrary(instance));
});
store.subscribe(() => {
const state = store.getState();
const mediaLibraryName = state.config.getIn(['media_library', 'name']);
if (mediaLibraryName && !state.mediaLibrary.get('externalLibrary')) {
const mediaLibraryConfig = state.config.get('media_library').toJS();
initializeMediaLibrary(mediaLibraryName, mediaLibraryConfig);
}
});

View File

@ -1,9 +1,9 @@
import { get } from 'lodash';
import { Map } from 'immutable';
import uuid from 'uuid/v4';
import {
MEDIA_LIBRARY_OPEN,
MEDIA_LIBRARY_CLOSE,
MEDIA_LIBRARY_CREATE,
MEDIA_INSERT,
MEDIA_REMOVE_INSERTED,
MEDIA_LOAD_REQUEST,
@ -20,16 +20,23 @@ import {
MEDIA_DISPLAY_URL_FAILURE,
} from 'Actions/mediaLibrary';
const mediaLibrary = (
state = Map({ isVisible: false, controlMedia: Map(), displayURLs: Map() }),
action,
) => {
const privateUploadChanged =
state.get('privateUpload') !== get(action, ['payload', 'privateUpload']);
let displayURLPath;
const defaultState = {
isVisible: false,
showMediaButton: true,
controlMedia: Map(),
displayURLs: Map(),
};
const mediaLibrary = (state = Map(defaultState), action) => {
switch (action.type) {
case MEDIA_LIBRARY_CREATE:
return state.withMutations(map => {
map.set('externalLibrary', action.payload);
map.set('showMediaButton', action.payload.enableStandalone());
});
case MEDIA_LIBRARY_OPEN: {
const { controlID, forImage, privateUpload } = action.payload || {};
const { controlID, forImage, privateUpload } = action.payload;
const privateUploadChanged = state.get('privateUpload') !== privateUpload;
if (privateUploadChanged) {
return Map({
isVisible: true,
@ -51,12 +58,14 @@ const mediaLibrary = (
case MEDIA_LIBRARY_CLOSE:
return state.set('isVisible', false);
case MEDIA_INSERT: {
const { mediaPath } = action.payload;
const controlID = state.get('controlID');
const mediaPath = get(action, ['payload', 'mediaPath']);
return state.setIn(['controlMedia', controlID], mediaPath);
return state.withMutations(map => {
map.setIn(['controlMedia', controlID], mediaPath);
});
}
case MEDIA_REMOVE_INSERTED: {
const controlID = get(action, ['payload', 'controlID']);
const controlID = action.payload.controlID;
return state.setIn(['controlMedia', controlID], '');
}
case MEDIA_LOAD_REQUEST:
@ -65,7 +74,15 @@ const mediaLibrary = (
map.set('isPaginating', action.payload.page > 1);
});
case MEDIA_LOAD_SUCCESS: {
const { files = [], page, canPaginate, dynamicSearch, dynamicSearchQuery } = action.payload;
const {
files = [],
page,
canPaginate,
dynamicSearch,
dynamicSearchQuery,
privateUpload,
} = action.payload;
const privateUploadChanged = state.get('privateUpload') !== privateUpload;
if (privateUploadChanged) {
return state;
@ -88,15 +105,18 @@ const mediaLibrary = (
}
});
}
case MEDIA_LOAD_FAILURE:
case MEDIA_LOAD_FAILURE: {
const privateUploadChanged = state.get('privateUpload') !== action.payload.privateUpload;
if (privateUploadChanged) {
return state;
}
return state.set('isLoading', false);
}
case MEDIA_PERSIST_REQUEST:
return state.set('isPersisting', true);
case MEDIA_PERSIST_SUCCESS: {
const { file } = action.payload;
const { file, privateUpload } = action.payload;
const privateUploadChanged = state.get('privateUpload') !== privateUpload;
if (privateUploadChanged) {
return state;
}
@ -107,15 +127,18 @@ const mediaLibrary = (
map.set('isPersisting', false);
});
}
case MEDIA_PERSIST_FAILURE:
case MEDIA_PERSIST_FAILURE: {
const privateUploadChanged = state.get('privateUpload') !== action.payload.privateUpload;
if (privateUploadChanged) {
return state;
}
return state.set('isPersisting', false);
}
case MEDIA_DELETE_REQUEST:
return state.set('isDeleting', true);
case MEDIA_DELETE_SUCCESS: {
const { id, key } = action.payload.file;
const { id, key, privateUpload } = action.payload.file;
const privateUploadChanged = state.get('privateUpload') !== privateUpload;
if (privateUploadChanged) {
return state;
}
@ -126,28 +149,31 @@ const mediaLibrary = (
map.set('isDeleting', false);
});
}
case MEDIA_DELETE_FAILURE:
case MEDIA_DELETE_FAILURE: {
const privateUploadChanged = state.get('privateUpload') !== action.payload.privateUpload;
if (privateUploadChanged) {
return state;
}
return state.set('isDeleting', false);
}
case MEDIA_DISPLAY_URL_REQUEST:
return state.setIn(['displayURLs', action.payload.key, 'isFetching'], true);
case MEDIA_DISPLAY_URL_SUCCESS:
displayURLPath = ['displayURLs', action.payload.key];
case MEDIA_DISPLAY_URL_SUCCESS: {
const displayURLPath = ['displayURLs', action.payload.key];
return state
.setIn([...displayURLPath, 'isFetching'], false)
.setIn([...displayURLPath, 'url'], action.payload.url);
}
case MEDIA_DISPLAY_URL_FAILURE:
displayURLPath = ['displayURLs', action.payload.key];
case MEDIA_DISPLAY_URL_FAILURE: {
const displayURLPath = ['displayURLs', action.payload.key];
return state
.setIn([...displayURLPath, 'isFetching'], false)
.setIn([...displayURLPath, 'err'], action.payload.err)
.deleteIn([...displayURLPath, 'url']);
}
default:
return state;
}

View File

@ -1,17 +0,0 @@
import { createStore, applyMiddleware, compose } from 'redux';
import thunkMiddleware from 'redux-thunk';
import waitUntilAction from './middleware/waitUntilAction';
import reducer from 'Reducers/combinedReducer';
export default function configureStore(initialState) {
const store = createStore(
reducer,
initialState,
compose(
applyMiddleware(thunkMiddleware, waitUntilAction),
window.devToolsExtension ? window.devToolsExtension() : f => f,
),
);
return store;
}

View File

@ -0,0 +1,14 @@
import { createStore, applyMiddleware, compose } from 'redux';
import thunkMiddleware from 'redux-thunk';
import waitUntilAction from './middleware/waitUntilAction';
import reducer from 'Reducers/combinedReducer';
const store = createStore(
reducer,
compose(
applyMiddleware(thunkMiddleware, waitUntilAction),
window.devToolsExtension ? window.devToolsExtension() : f => f,
),
);
export default store;

View File

@ -1,13 +1,9 @@
import { resolvePath } from 'netlify-cms-lib-util';
import { currentBackend } from 'src/backend';
import store from 'Redux';
import { getIntegrationProvider } from 'Integrations';
import { selectIntegration } from 'Reducers';
let store;
export const setStore = storeObj => {
store = storeObj;
};
export default function AssetProxy(value, fileObj, uploaded = false, asset) {
const config = store.getState().config;
this.value = value;