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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 715 additions and 135 deletions

View File

@ -2,9 +2,9 @@ backend:
name: test-repo
display_url: https://example.com
media_folder: "assets/uploads"
publish_mode: editorial_workflow
media_folder: assets/uploads
collections: # A list of collections the CMS should be able to edit
- name: "posts" # Used in routes, ie.: /admin/collections/:slug/edit
@ -19,7 +19,12 @@ collections: # A list of collections the CMS should be able to edit
fields: # The fields each document in this collection have
- {label: "Title", name: "title", widget: "string", tagname: "h1"}
- {label: "Publish Date", name: "date", widget: "datetime", format: "YYYY-MM-DD hh:mma"}
- {label: "Cover Image", name: "image", widget: "image", required: false, tagname: ""}
- label: "Cover Image"
name: "image"
widget: "image"
required: false
tagname: ""
- {label: "Body", name: "body", widget: "markdown", hint: "Main content goes here."}
meta:
- {label: "SEO Description", name: "description", widget: "text"}

View File

@ -83,12 +83,10 @@
var PostPreview = createClass({
render: function() {
var entry = this.props.entry;
var image = entry.getIn(['data', 'image']);
var bg = image && this.props.getAsset(image);
return h('div', {},
h('div', {className: "cover"},
h('h1', {}, entry.getIn(['data', 'title'])),
bg ? h('img', {src: bg.toString()}) : null
this.props.widgetFor('image'),
),
h('p', {},
h('small', {}, "Written " + entry.getIn(['data', 'date']))

View File

@ -7,5 +7,5 @@ module.exports = {
'netlify-cms-lib-util': '<rootDir>/packages/netlify-cms-lib-util/src/index.js',
'netlify-cms-ui-default': '<rootDir>/packages/netlify-cms-ui-default/src/index.js',
},
testEnvironment: 'node',
testURL: 'http://localhost:8080',
};

View File

@ -9,7 +9,7 @@
"clean": "rimraf packages/*/dist dev-test/*.js",
"reset": "npm run clean && lerna clean --yes",
"cache-ci": "node scripts/cache.js",
"test": "run-s jest e2e",
"test": "run-s jest e2e lint",
"test-ci": "run-s cache-ci jest e2e-ci lint-quiet",
"jest": "cross-env NODE_ENV=test jest --no-cache",
"e2e-prep": "npm run build && cp -r packages/netlify-cms/dist dev-test/",

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;

View File

@ -6,3 +6,4 @@ export { resolvePath, basename, fileExtensionWithSeparator, fileExtension } from
export { filterPromises, resolvePromiseProperties, then } from './promise';
export unsentRequest from './unsentRequest';
export { filterByPropExtension, parseResponse, responseParser } from './backendUtil';
export loadScript from './loadScript';

View File

@ -0,0 +1,24 @@
/**
* Simple script loader that returns a promise.
*/
export default function loadScript(url) {
return new Promise((resolve, reject) => {
let done = false;
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.src = url;
script.onload = script.onreadystatechange = function() {
if (
!done &&
(!this.readyState || this.readyState === 'loaded' || this.readyState === 'complete')
) {
done = true;
resolve();
} else {
reject();
}
};
script.onerror = error => reject(error);
head.appendChild(script);
});
}

View File

@ -0,0 +1,11 @@
# Docs coming soon!
Netlify CMS was recently converted from a single npm package to a "monorepo" of over 20 packages.
That's over 20 Readme's! We haven't created one for this package yet, but we will soon.
In the meantime, you can:
1. Check out the [main readme](https://github.com/netlify/netlify-cms/#readme) or the [documentation
site](https://www.netlifycms.org) for more info.
2. Reach out to the [community chat](https://gitter.im/netlify/netlifycms/) if you need help.
3. Help out and [write the readme yourself](https://github.com/netlify/netlify-cms/edit/master/packages/netlify-cms-media-library-uploadcare/README.md)!

View File

@ -0,0 +1,33 @@
{
"name": "netlify-cms-media-library-uploadcare",
"description": "Uploadcare integration for Netlify CMS",
"version": "0.1.0",
"repository": "https://github.com/netlify/netlify-cms/tree/master/packages/netlify-cms-media-library-uploadcare",
"bugs": "https://github.com/netlify/netlify-cms/issues",
"main": "dist/netlify-cms-media-library-uploadcare.js",
"license": "MIT",
"keywords": [
"netlify",
"netlify-cms",
"uploadcare",
"media",
"assets",
"files",
"uploads"
],
"sideEffects": false,
"scripts": {
"watch": "webpack -w",
"develop": "npm run watch",
"build": "cross-env NODE_ENV=production webpack"
},
"devDependencies": {
"cross-env": "^5.2.0",
"webpack": "^4.16.1",
"webpack-cli": "^3.1.0"
},
"peerDependencies": {
"netlify-cms-lib-util": "^2.0.4"
},
"private": true
}

View File

@ -0,0 +1,146 @@
import { loadScript } from 'netlify-cms-lib-util';
/**
* Default Uploadcare widget configuration, can be overriden via config.yml.
*/
const defaultConfig = {
previewStep: true,
};
/**
* Determine whether an array of urls represents an unaltered set of Uploadcare
* group urls. If they've been changed or any are missing, a new group will need
* to be created to represent the current values.
*/
function isFileGroup(files) {
const basePatternString = `~${files.length}/nth/`;
const mapExpression = (val, idx) => new RegExp(`${basePatternString}${idx}/$`);
const expressions = Array.from({ length: files.length }, mapExpression);
return expressions.every(exp => files.some(url => exp.test(url)));
}
/**
* Returns a fileGroupInfo object wrapped in a promise-like object.
*/
function getFileGroup(files) {
/**
* Capture the group id from the first file in the files array.
*/
const groupId = new RegExp(`^.+/([^/]+~${files.length})/nth/`).exec(files[0])[1];
/**
* The `openDialog` method handles the jQuery promise object returned by
* `fileFrom`, but requires the promise returned by `loadFileGroup` to provide
* the result of it's `done` method.
*/
return new Promise(resolve =>
window.uploadcare.loadFileGroup(groupId).done(group => resolve(group)),
);
}
/**
* Convert a url or array/List of urls to Uploadcare file objects wrapped in
* promises, or Uploadcare groups when possible. Output is wrapped in a promise
* because the value we're returning may be a promise that we created.
*/
function getFiles(value, cdnBase) {
if (typeof value === 'object') {
const arr = Array.isArray(value) ? value : value.toJS();
return isFileGroup(arr) ? getFileGroup(arr) : arr.map(val => getFile(val, cdnBase));
}
return value && typeof value === 'string' ? getFile(value, cdnBase) : null;
}
/**
* Convert a single url to an Uploadcare file object wrapped in a promise-like
* object. Group urls that get passed here were not a part of a complete and
* untouched group, so they'll be uploaded as new images (only way to do it).
*/
function getFile(url, cdnBase) {
const groupPattern = /~\d+\/nth\/\d+\//;
const baseUrls = ['https://ucarecdn.com', cdnBase].filter(v => v);
const uploaded = baseUrls.some(baseUrl => url.startsWith(baseUrl) && !groupPattern.test(url));
return window.uploadcare.fileFrom(uploaded ? 'uploaded' : 'url', url);
}
/**
* Open the standalone dialog. A single instance is created and destroyed for
* each use.
*/
function openDialog(files, config, handleInsert) {
window.uploadcare.openDialog(files, config).done(({ promise }) =>
promise().then(({ cdnUrl, count }) => {
if (config.multiple) {
const urls = Array.from({ length: count }, (val, idx) => `${cdnUrl}nth/${idx}/`);
handleInsert(urls);
} else {
handleInsert(cdnUrl);
}
}),
);
}
/**
* Initialization function will only run once, returns an API object for Netlify
* CMS to call methods on.
*/
async function init({ options = { config: {} }, handleInsert }) {
const { publicKey, ...globalConfig } = options.config;
const baseConfig = { ...defaultConfig, ...globalConfig };
window.UPLOADCARE_LIVE = false;
window.UPLOADCARE_MANUAL_START = true;
window.UPLOADCARE_PUBLIC_KEY = publicKey;
/**
* Loading scripts via url because the uploadcare widget includes
* non-strict-mode code that's incompatible with our build system
*/
await loadScript('https://unpkg.com/uploadcare-widget@^3.6.0/uploadcare.full.js');
await loadScript(
'https://unpkg.com/uploadcare-widget-tab-effects@^1.2.1/dist/uploadcare.tab-effects.js',
);
/**
* Register the effects tab by default because the effects tab is awesome. Can
* be disabled via config.
*/
window.uploadcare.registerTab('preview', window.uploadcareTabEffects);
return {
/**
* On show, create a new widget, cache it in the widgets object, and open.
* No hide method is provided because the widget doesn't provide it.
*/
show: ({ value, config: instanceConfig = {}, imagesOnly }) => {
const config = { ...baseConfig, imagesOnly, ...instanceConfig };
const files = getFiles(value);
/**
* Resolve the promise only if it's ours. Only the jQuery promise objects
* from the Uploadcare library will have a `state` method.
*/
if (files && !files.state) {
files.then(result => openDialog(result, config, handleInsert));
} else {
openDialog(files, config, handleInsert);
}
},
/**
* Uploadcare doesn't provide a "media library" widget for viewing and
* selecting existing files, so we return `false` here so Netlify CMS only
* opens the Uploadcare widget when called from an editor control. This
* results in the "Media" button in the global nav being hidden.
*/
enableStandalone: () => false,
};
}
/**
* The object that will be registered only needs a (default) name and `init`
* method. The `init` method returns the API object.
*/
const uploadcareMediaLibrary = { name: 'uploadcare', init };
export default uploadcareMediaLibrary;

View File

@ -0,0 +1,3 @@
const { getConfig } = require('../../scripts/webpack.js');
module.exports = getConfig();

View File

@ -31,6 +31,7 @@
},
"peerDependencies": {
"emotion": "^9.2.6",
"immutable": "^3.7.6",
"netlify-cms-ui-default": "^2.0.0",
"prop-types": "^15.5.10",
"react": "^16.4.1",

View File

@ -1,11 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'react-emotion';
import { List } from 'immutable';
import { WidgetPreviewContainer } from 'netlify-cms-ui-default';
const FilePreview = ({ value, getAsset }) => (
<WidgetPreviewContainer>
{value ? <a href={getAsset(value)}>{value}</a> : null}
</WidgetPreviewContainer>
const FileLink = styled(({ value, getAsset }) => (
<a href={getAsset(value)} rel="noopener noreferrer" target="_blank">
{value}
</a>
))`
display: block;
`;
function FileLinkList({ values, getAsset }) {
return (
<div>
{values.map(value => (
<FileLink key={value} value={value} getAsset={getAsset} />
))}
</div>
);
}
function FileContent({ value, getAsset }) {
if (Array.isArray(value) || List.isList(value)) {
return <FileLinkList values={value} getAsset={getAsset} />;
}
return <FileLink value={value} getAsset={getAsset} />;
}
const FilePreview = props => (
<WidgetPreviewContainer>{props.value ? <FileContent {...props} /> : null}</WidgetPreviewContainer>
);
FilePreview.propTypes = {

View File

@ -2,19 +2,18 @@ import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from 'react-emotion';
import { List } from 'immutable';
import uuid from 'uuid/v4';
import { lengths, components, buttons } from 'netlify-cms-ui-default';
const MAX_DISPLAY_LENGTH = 50;
const FileContent = styled.div`
display: flex;
`;
const ImageWrapper = styled.div`
flex-basis: 155px;
width: 155px;
height: 100px;
margin-right: 20px;
margin-bottom: 20px;
`;
const Image = styled.img`
@ -24,16 +23,31 @@ const Image = styled.img`
border-radius: ${lengths.borderRadius};
`;
const MultiImageWrapper = styled.div`
display: flex;
flex-wrap: wrap;
`;
const FileInfo = styled.div`
button:not(:first-child) {
margin-top: 12px;
}
`;
const FileName = styled.span`
display: block;
font-size: 16px;
const FileLink = styled.a`
margin-bottom: 20px;
font-weight: normal;
color: inherit;
&:hover,
&:active,
&:focus {
text-decoration: underline;
}
`;
const FileLinkList = styled.ul`
list-style-type: none;
`;
const FileWidgetButton = styled.button`
@ -46,6 +60,10 @@ const FileWidgetButtonRemove = styled.button`
${components.badgeDanger};
`;
function isMultiple(value) {
return Array.isArray(value) || List.isList(value);
}
export default function withFileControl({ forImage } = {}) {
return class FileControl extends React.Component {
static propTypes = {
@ -56,8 +74,10 @@ export default function withFileControl({ forImage } = {}) {
onChange: PropTypes.func.isRequired,
onRemoveInsertedMedia: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
onClearMediaControl: PropTypes.func.isRequired,
onRemoveMediaControl: PropTypes.func.isRequired,
classNameWrapper: PropTypes.string.isRequired,
value: PropTypes.node,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
};
static defaultProps = {
@ -99,56 +119,94 @@ export default function withFileControl({ forImage } = {}) {
}
}
componentWillUnmount() {
this.props.onRemoveMediaControl(this.controlID);
}
handleChange = e => {
const { field, onOpenMediaLibrary } = this.props;
const { field, onOpenMediaLibrary, value } = this.props;
e.preventDefault();
return onOpenMediaLibrary({
controlID: this.controlID,
forImage,
privateUpload: field.get('private'),
value,
config: field.getIn(['options', 'media_library', 'config']),
});
};
handleRemove = e => {
e.preventDefault();
this.props.onClearMediaControl(this.controlID);
return this.props.onChange('');
};
renderFileName = () => {
const { value } = this.props;
renderFileLink = value => {
const size = MAX_DISPLAY_LENGTH;
if (!value || value.length <= size) {
return value;
}
return `${value.substring(0, size / 2)}\u2026${value.substring(
const text = `${value.substring(0, size / 2)}\u2026${value.substring(
value.length - size / 2 + 1,
value.length,
)}`;
};
renderSelection = subject => {
const fileName = this.renderFileName();
const { getAsset, value } = this.props;
return (
<FileContent>
{forImage ? (
<ImageWrapper>
<Image src={getAsset(value)} />
</ImageWrapper>
) : null}
<FileInfo>
<FileName>{fileName}</FileName>
<FileWidgetButton onClick={this.handleChange}>
Choose different {subject}
</FileWidgetButton>
<FileWidgetButtonRemove onClick={this.handleRemove}>
Remove {subject}
</FileWidgetButtonRemove>
</FileInfo>
</FileContent>
<FileLink href={value} rel="noopener" target="_blank">
{text}
</FileLink>
);
};
renderFileLinks = () => {
const { value } = this.props;
if (isMultiple(value)) {
return (
<FileLinkList>
{value.map(val => (
<li key={val}>{this.renderFileLink(val)}</li>
))}
</FileLinkList>
);
}
return this.renderFileLink(value);
};
renderImages = () => {
const { getAsset, value } = this.props;
if (isMultiple(value)) {
return (
<MultiImageWrapper>
{value.map(val => (
<ImageWrapper key={val}>
<Image src={getAsset(val)} />
</ImageWrapper>
))}
</MultiImageWrapper>
);
}
return (
<ImageWrapper>
<Image src={getAsset(value)} />
</ImageWrapper>
);
};
renderSelection = subject => (
<div>
{forImage ? this.renderImages() : null}
<FileInfo>
{forImage ? null : this.renderFileLinks()}
<FileWidgetButton onClick={this.handleChange}>
Choose different {subject}
</FileWidgetButton>
<FileWidgetButtonRemove onClick={this.handleRemove}>
Remove {subject}
</FileWidgetButtonRemove>
</FileInfo>
</div>
);
renderNoSelection = (subject, article) => (
<FileWidgetButton onClick={this.handleChange}>
Choose {article} {subject}

View File

@ -30,6 +30,7 @@
"webpack-cli": "^3.1.0"
},
"peerDependencies": {
"immutable": "^3.7.6",
"netlify-cms-ui-default": "^2.0.0",
"prop-types": "^15.5.10",
"react": "^16.4.1",

View File

@ -1,18 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'react-emotion';
import { List } from 'immutable';
import { WidgetPreviewContainer } from 'netlify-cms-ui-default';
const Image = styled.img`
const StyledImage = styled(({ getAsset, value }) => (
<img src={getAsset(value)} role="presentation" />
))`
display: block;
max-width: 100%;
height: auto;
`;
const ImagePreview = ({ value, getAsset }) => (
<WidgetPreviewContainer>
{value ? <Image src={getAsset(value)} role="presentation" /> : null}
</WidgetPreviewContainer>
);
const ImagePreviewContent = props => {
const { value, getAsset } = props;
if (Array.isArray(value) || List.isList(value)) {
return value.map(val => <StyledImage key={val} value={val} getAsset={getAsset} />);
}
return <StyledImage {...props} />;
};
const ImagePreview = props => {
return (
<WidgetPreviewContainer>
{props.value ? <ImagePreviewContent {...props} /> : null}
</WidgetPreviewContainer>
);
};
ImagePreview.propTypes = {
getAsset: PropTypes.func.isRequired,

View File

@ -2,5 +2,6 @@ import CMS, { init } from 'netlify-cms-core/src';
import './backends';
import './widgets';
import './editor-components';
import './media-libraries';
export { CMS as default, init };

View File

@ -0,0 +1,6 @@
import cms from 'netlify-cms-core/src';
import uploadcare from 'netlify-cms-media-library-uploadcare/src';
const { registerMediaLibrary } = cms;
registerMediaLibrary(uploadcare);

View File

@ -70,6 +70,21 @@ public_folder: "/images/uploads"
Based on the settings above, if a user used an image widget field called `avatar` to upload and select an image called `philosoraptor.png`, the image would be saved to the repository at `/static/images/uploads/philosoraptor.png`, and the `avatar` field for the file would be set to `/images/uploads/philosoraptor.png`.
## Media Library
Media library integrations are configured via the `media_library` property, and it's value should be
an object with at least a `name` property. A `config` property can also be used for options that
should be passed to the library in use.
**Example**
```yaml
media_library:
name: uploadcare
config:
publicKey: demopublickey
```
## Display URL
When the `display_url` setting is specified, the CMS UI will include a link in the fixed area at the top of the page, allowing content authors to easily return to your main site. The text of the link consists of the URL less the protocol portion (e.g., `your-site.com`).

View File

@ -0,0 +1,84 @@
---
title: Uploadcare
weight: 10
group: media
---
Uploadcare is a sleek service that allows you to upload files needed without worrying about
maintaining a growing collection - more of an asset store than a library. Just upload when you need
to, and the files are hosted on their CDN. They provide image processing controls from simple
cropping and rotation to filters and face detection, and a lot more. You can check out Uploadcare's
full feature set on their [website](https://uploadcare.com/).
The Uploadcare media library integration for Netlify CMS allows you to use Uploadcare's media widget
allows you to use Uploadcare as your media handler within the CMS itself. It's available by default
as of our 2.1.0 release, and works in tandem with the existing file and image widgets, so using it
only requires creating an Uploadcare account and updating your Netlify CMS configuration.
**Please make sure that Netlify CMS is updated to 2.1.0 or greater before proceeding.**
## Creating an Uploadcare Account
You can [sign up](https://uploadcare.com/accounts/signup/) for a free Uploadcare account to get
started. Once you've signed up, go to your dashboard, select a project, and then select "API keys"
from the menu on the left. The public key on the API keys page will be needed in your Netlify CMS
configuration. For more info on getting your API key, visit their
[walkthrough](https://uploadcare.com/docs/keys/).
## Updating Netlify CMS Configuration
The next and final step is updating your Netlify CMS configuration file:
1. Add a `media_library` property at the same level as `media_folder`, with an object as it's value.
2. In the `media_library` object, add the name of the media player under `name`.
3. Add a `config` object under name with a `publicKey` property with your Uploadcare public key as
it's value.
Your `config.yml` should now include something like this (except with a real API key):
```yaml
media_library:
name: uploadcare
config:
publickey: demopublickey
```
Once you've finished updating your Netlify CMS configuration, the Uploadcare widget will appear when
using the image or file widgets.
## Configuring the Uploadcare Widget
The Uploadcare widget can be configured with settings that are outlined [in their
docs](https://uploadcare.com/docs/uploads/widget/config/). The widget itself accepts configration
through global variables and data properties on HTML elements, but with Netlify CMS you can pass
configuration options directly through your `config.yml`.
**Note:** all default values described in Uploadcare's documentation also apply in the Netlify CMS
integration, except for `previewStep`, which is set to `true`. This was done because the preview
step provides helpful information like upload status, and proivdes access to image editing controls.
This option can be disabled through the configuration options below.
### Global configuration
Global configuration, which is meant to affect the Uploadcare widget at all times, can be provided
as seen above, under the primary `media_library` property. Settings applied here will affect every
instance of the Uploadcare widget.
## Field configuration
Configuration can also be provided for individual fields that use the media library. The structure
is very similar to the global configuration, except the settings are added to an individual `field`.
Forexample:
```yaml
...
fields:
name: cover
label: Cover Image
widget: image
options:
media_library:
config:
multiple: true
previewStep: false
```

View File

@ -10,10 +10,20 @@ The image widget allows editors to upload an image or select an existing one fro
- **Data type:** file path string, based on `media_folder`/`public_folder` configuration
- **Options:**
- `default`: accepts a file path string; defaults to null
- `options`: an object for settings that are unique to the image widget
- `media_library`: media library settings to apply when a media library is opened by the
current widget
- `config`: a configuration object that will be passed directly to the media library being
used - available options are determined by the library
- **Example:**
```yaml
- label: "Featured Image"
name: "thumbnail"
widget: "image"
default: "/uploads/chocolate-dogecoin.jpg"
options:
media_library:
config:
publicKey: "demopublickey"
multiple: true
```

View File

@ -15,6 +15,10 @@ module.exports = {
name: 'guides',
title: 'Guides',
},
{
name: 'media',
title: 'Media',
},
{
name: 'reference',
title: 'Reference',