diff --git a/dev-test/config.yml b/dev-test/config.yml index fc0a5ae5..f2923606 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -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"} diff --git a/dev-test/index.html b/dev-test/index.html index d1fd3baf..2d1b5e40 100644 --- a/dev-test/index.html +++ b/dev-test/index.html @@ -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'])) diff --git a/jest.config.js b/jest.config.js index fe43e915..2cbd528e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,5 +7,5 @@ module.exports = { 'netlify-cms-lib-util': '/packages/netlify-cms-lib-util/src/index.js', 'netlify-cms-ui-default': '/packages/netlify-cms-ui-default/src/index.js', }, - testEnvironment: 'node', + testURL: 'http://localhost:8080', }; diff --git a/package.json b/package.json index f8973948..c2fac09c 100644 --- a/package.json +++ b/package.json @@ -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/", diff --git a/packages/netlify-cms-core/src/actions/mediaLibrary.js b/packages/netlify-cms-core/src/actions/mediaLibrary.js index 440b15ac..2f6ef7a2 100644 --- a/packages/netlify-cms-core/src/actions/mediaLibrary.js +++ b/packages/netlify-cms-core/src/actions/mediaLibrary.js @@ -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) { diff --git a/packages/netlify-cms-core/src/bootstrap.js b/packages/netlify-cms-core/src/bootstrap.js index 9ba62d97..29b5effa 100644 --- a/packages/netlify-cms-core/src/bootstrap.js +++ b/packages/netlify-cms-core/src/bootstrap.js @@ -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. */ diff --git a/packages/netlify-cms-core/src/components/App/App.js b/packages/netlify-cms-core/src/components/App/App.js index 469ae954..16bcd82f 100644 --- a/packages/netlify-cms-core/src/components/App/App.js +++ b/packages/netlify-cms-core/src/components/App/App.js @@ -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} /> {isFetching && } @@ -180,7 +187,7 @@ class App extends React.Component { /> - + {useMediaLibrary ? : null} @@ -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, diff --git a/packages/netlify-cms-core/src/components/App/Header.js b/packages/netlify-cms-core/src/components/App/Header.js index 0cea304d..db260a2f 100644 --- a/packages/netlify-cms-core/src/components/App/Header.js +++ b/packages/netlify-cms-core/src/components/App/Header.js @@ -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 ) : null} - - - Media - + {showMediaButton ? ( + + + Media + + ) : null} {createableCollections.size > 0 && ( diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js index afeb300e..aa70b204 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js @@ -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, diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/Widget.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/Widget.js index 4bc179d1..3824a868 100644 --- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/Widget.js +++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/Widget.js @@ -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, diff --git a/packages/netlify-cms-core/src/constants/configSchema.js b/packages/netlify-cms-core/src/constants/configSchema.js index 329c4e9b..2648eb53 100644 --- a/packages/netlify-cms-core/src/constants/configSchema.js +++ b/packages/netlify-cms-core/src/constants/configSchema.js @@ -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 { diff --git a/packages/netlify-cms-core/src/lib/registry.js b/packages/netlify-cms-core/src/lib/registry.js index e82de6b3..0fceb769 100644 --- a/packages/netlify-cms-core/src/lib/registry.js +++ b/packages/netlify-cms-core/src/lib/registry.js @@ -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); +} diff --git a/packages/netlify-cms-core/src/mediaLibrary.js b/packages/netlify-cms-core/src/mediaLibrary.js new file mode 100644 index 00000000..29c660da --- /dev/null +++ b/packages/netlify-cms-core/src/mediaLibrary.js @@ -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); + } +}); diff --git a/packages/netlify-cms-core/src/reducers/mediaLibrary.js b/packages/netlify-cms-core/src/reducers/mediaLibrary.js index dd7181eb..285ade4a 100644 --- a/packages/netlify-cms-core/src/reducers/mediaLibrary.js +++ b/packages/netlify-cms-core/src/reducers/mediaLibrary.js @@ -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; } diff --git a/packages/netlify-cms-core/src/redux/configureStore.js b/packages/netlify-cms-core/src/redux/configureStore.js deleted file mode 100644 index 27453407..00000000 --- a/packages/netlify-cms-core/src/redux/configureStore.js +++ /dev/null @@ -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; -} diff --git a/packages/netlify-cms-core/src/redux/index.js b/packages/netlify-cms-core/src/redux/index.js new file mode 100644 index 00000000..d3076d1f --- /dev/null +++ b/packages/netlify-cms-core/src/redux/index.js @@ -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; diff --git a/packages/netlify-cms-core/src/valueObjects/AssetProxy.js b/packages/netlify-cms-core/src/valueObjects/AssetProxy.js index 49cdeefa..79d69f73 100644 --- a/packages/netlify-cms-core/src/valueObjects/AssetProxy.js +++ b/packages/netlify-cms-core/src/valueObjects/AssetProxy.js @@ -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; diff --git a/packages/netlify-cms-lib-util/src/index.js b/packages/netlify-cms-lib-util/src/index.js index 66451797..7a5efebc 100644 --- a/packages/netlify-cms-lib-util/src/index.js +++ b/packages/netlify-cms-lib-util/src/index.js @@ -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'; diff --git a/packages/netlify-cms-lib-util/src/loadScript.js b/packages/netlify-cms-lib-util/src/loadScript.js new file mode 100644 index 00000000..fded8722 --- /dev/null +++ b/packages/netlify-cms-lib-util/src/loadScript.js @@ -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); + }); +} diff --git a/packages/netlify-cms-media-library-uploadcare/README.md b/packages/netlify-cms-media-library-uploadcare/README.md new file mode 100644 index 00000000..78c73c5c --- /dev/null +++ b/packages/netlify-cms-media-library-uploadcare/README.md @@ -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)! diff --git a/packages/netlify-cms-media-library-uploadcare/package.json b/packages/netlify-cms-media-library-uploadcare/package.json new file mode 100644 index 00000000..88e87954 --- /dev/null +++ b/packages/netlify-cms-media-library-uploadcare/package.json @@ -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 +} diff --git a/packages/netlify-cms-media-library-uploadcare/src/index.js b/packages/netlify-cms-media-library-uploadcare/src/index.js new file mode 100644 index 00000000..c6d536e5 --- /dev/null +++ b/packages/netlify-cms-media-library-uploadcare/src/index.js @@ -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; diff --git a/packages/netlify-cms-media-library-uploadcare/webpack.config.js b/packages/netlify-cms-media-library-uploadcare/webpack.config.js new file mode 100644 index 00000000..42edd361 --- /dev/null +++ b/packages/netlify-cms-media-library-uploadcare/webpack.config.js @@ -0,0 +1,3 @@ +const { getConfig } = require('../../scripts/webpack.js'); + +module.exports = getConfig(); diff --git a/packages/netlify-cms-widget-file/package.json b/packages/netlify-cms-widget-file/package.json index 57ad7957..8616bf71 100644 --- a/packages/netlify-cms-widget-file/package.json +++ b/packages/netlify-cms-widget-file/package.json @@ -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", diff --git a/packages/netlify-cms-widget-file/src/FilePreview.js b/packages/netlify-cms-widget-file/src/FilePreview.js index 28917900..55b9eb34 100644 --- a/packages/netlify-cms-widget-file/src/FilePreview.js +++ b/packages/netlify-cms-widget-file/src/FilePreview.js @@ -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 }) => ( - - {value ? {value} : null} - +const FileLink = styled(({ value, getAsset }) => ( + + {value} + +))` + display: block; +`; + +function FileLinkList({ values, getAsset }) { + return ( +
+ {values.map(value => ( + + ))} +
+ ); +} + +function FileContent({ value, getAsset }) { + if (Array.isArray(value) || List.isList(value)) { + return ; + } + return ; +} + +const FilePreview = props => ( + {props.value ? : null} ); FilePreview.propTypes = { diff --git a/packages/netlify-cms-widget-file/src/withFileControl.js b/packages/netlify-cms-widget-file/src/withFileControl.js index ac9e9a5f..4830ee87 100644 --- a/packages/netlify-cms-widget-file/src/withFileControl.js +++ b/packages/netlify-cms-widget-file/src/withFileControl.js @@ -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 ( - - {forImage ? ( - - - - ) : null} - - {fileName} - - Choose different {subject} - - - Remove {subject} - - - + + {text} + ); }; + renderFileLinks = () => { + const { value } = this.props; + + if (isMultiple(value)) { + return ( + + {value.map(val => ( +
  • {this.renderFileLink(val)}
  • + ))} +
    + ); + } + return this.renderFileLink(value); + }; + + renderImages = () => { + const { getAsset, value } = this.props; + if (isMultiple(value)) { + return ( + + {value.map(val => ( + + + + ))} + + ); + } + return ( + + + + ); + }; + + renderSelection = subject => ( +
    + {forImage ? this.renderImages() : null} + + {forImage ? null : this.renderFileLinks()} + + Choose different {subject} + + + Remove {subject} + + +
    + ); + renderNoSelection = (subject, article) => ( Choose {article} {subject} diff --git a/packages/netlify-cms-widget-image/package.json b/packages/netlify-cms-widget-image/package.json index 9e238665..251ca06e 100644 --- a/packages/netlify-cms-widget-image/package.json +++ b/packages/netlify-cms-widget-image/package.json @@ -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", diff --git a/packages/netlify-cms-widget-image/src/ImagePreview.js b/packages/netlify-cms-widget-image/src/ImagePreview.js index 021ee011..7aec89f5 100644 --- a/packages/netlify-cms-widget-image/src/ImagePreview.js +++ b/packages/netlify-cms-widget-image/src/ImagePreview.js @@ -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 }) => ( + +))` + display: block; max-width: 100%; height: auto; `; -const ImagePreview = ({ value, getAsset }) => ( - - {value ? : null} - -); +const ImagePreviewContent = props => { + const { value, getAsset } = props; + if (Array.isArray(value) || List.isList(value)) { + return value.map(val => ); + } + return ; +}; + +const ImagePreview = props => { + return ( + + {props.value ? : null} + + ); +}; ImagePreview.propTypes = { getAsset: PropTypes.func.isRequired, diff --git a/packages/netlify-cms/src/index.js b/packages/netlify-cms/src/index.js index e460cc92..c8fd5234 100644 --- a/packages/netlify-cms/src/index.js +++ b/packages/netlify-cms/src/index.js @@ -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 }; diff --git a/packages/netlify-cms/src/media-libraries.js b/packages/netlify-cms/src/media-libraries.js new file mode 100644 index 00000000..1feab800 --- /dev/null +++ b/packages/netlify-cms/src/media-libraries.js @@ -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); diff --git a/website/content/docs/configuration-options.md b/website/content/docs/configuration-options.md index 23963709..dd127619 100644 --- a/website/content/docs/configuration-options.md +++ b/website/content/docs/configuration-options.md @@ -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`). diff --git a/website/content/docs/uploadcare.md b/website/content/docs/uploadcare.md new file mode 100644 index 00000000..fdc06a8b --- /dev/null +++ b/website/content/docs/uploadcare.md @@ -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 +``` diff --git a/website/content/docs/widgets/image.md b/website/content/docs/widgets/image.md index b97510aa..c3c915d6 100644 --- a/website/content/docs/widgets/image.md +++ b/website/content/docs/widgets/image.md @@ -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 ``` diff --git a/website/gatsby-config.js b/website/gatsby-config.js index 6b849846..feabb527 100644 --- a/website/gatsby-config.js +++ b/website/gatsby-config.js @@ -15,6 +15,10 @@ module.exports = { name: 'guides', title: 'Guides', }, + { + name: 'media', + title: 'Media', + }, { name: 'reference', title: 'Reference',