feat(media): add external media library support, Uploadcare integration (#1602)
This commit is contained in:
parent
ae28f6301e
commit
0596904e0b
@ -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"}
|
||||
|
@ -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']))
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -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/",
|
||||
|
@ -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) {
|
||||
|
14
packages/netlify-cms-core/src/bootstrap.js
vendored
14
packages/netlify-cms-core/src/bootstrap.js
vendored
@ -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.
|
||||
*/
|
||||
|
@ -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,
|
||||
|
@ -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 && (
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
}
|
||||
|
24
packages/netlify-cms-core/src/mediaLibrary.js
Normal file
24
packages/netlify-cms-core/src/mediaLibrary.js
Normal 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);
|
||||
}
|
||||
});
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
14
packages/netlify-cms-core/src/redux/index.js
Normal file
14
packages/netlify-cms-core/src/redux/index.js
Normal 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;
|
@ -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;
|
||||
|
@ -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';
|
||||
|
24
packages/netlify-cms-lib-util/src/loadScript.js
Normal file
24
packages/netlify-cms-lib-util/src/loadScript.js
Normal 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);
|
||||
});
|
||||
}
|
11
packages/netlify-cms-media-library-uploadcare/README.md
Normal file
11
packages/netlify-cms-media-library-uploadcare/README.md
Normal 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)!
|
33
packages/netlify-cms-media-library-uploadcare/package.json
Normal file
33
packages/netlify-cms-media-library-uploadcare/package.json
Normal 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
|
||||
}
|
146
packages/netlify-cms-media-library-uploadcare/src/index.js
Normal file
146
packages/netlify-cms-media-library-uploadcare/src/index.js
Normal 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;
|
@ -0,0 +1,3 @@
|
||||
const { getConfig } = require('../../scripts/webpack.js');
|
||||
|
||||
module.exports = getConfig();
|
@ -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",
|
||||
|
@ -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 = {
|
||||
|
@ -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}
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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 };
|
||||
|
6
packages/netlify-cms/src/media-libraries.js
Normal file
6
packages/netlify-cms/src/media-libraries.js
Normal 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);
|
@ -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`).
|
||||
|
84
website/content/docs/uploadcare.md
Normal file
84
website/content/docs/uploadcare.md
Normal 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
|
||||
```
|
@ -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
|
||||
```
|
||||
|
@ -15,6 +15,10 @@ module.exports = {
|
||||
name: 'guides',
|
||||
title: 'Guides',
|
||||
},
|
||||
{
|
||||
name: 'media',
|
||||
title: 'Media',
|
||||
},
|
||||
{
|
||||
name: 'reference',
|
||||
title: 'Reference',
|
||||
|
Loading…
x
Reference in New Issue
Block a user