diff --git a/.babelrc b/.babelrc
index 6ec82922c801..825ea6b87287 100644
--- a/.babelrc
+++ b/.babelrc
@@ -7,7 +7,6 @@
"react"
],
"plugins": [
- "react-hot-loader/babel",
"lodash",
["babel-plugin-transform-builtin-extend", {
"globals": ["Error"]
diff --git a/.storybook/config.js b/.storybook/config.js
deleted file mode 100644
index 1977c69a672e..000000000000
--- a/.storybook/config.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { configure } from '@kadira/storybook';
-import '../src/index.css';
-
-function loadStories() {
- require('../src/components/stories/');
-}
-
-configure(loadStories, module);
diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js
deleted file mode 100644
index e62e19d4dd04..000000000000
--- a/.storybook/webpack.config.js
+++ /dev/null
@@ -1 +0,0 @@
-module.exports = require('../webpack.base.js');
diff --git a/example/config.yml b/example/config.yml
index c1866961b0f7..3a2a746fca0d 100644
--- a/example/config.yml
+++ b/example/config.yml
@@ -1,11 +1,15 @@
backend:
name: test-repo
+display_url: https://example.com
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
label: "Post" # Used in the UI, ie.: "New Post"
+ description: >
+ The description is a great place for tone setting, high level information, and editing
+ guidelines that are specific to a collection.
folder: "_posts"
slug: "{{year}}-{{month}}-{{day}}-{{slug}}"
create: true # Allow users to create new documents in this collection
diff --git a/example/moby-dick.jpg b/example/moby-dick.jpg
index cbbbffbd753c..0eb9c8b59581 100644
Binary files a/example/moby-dick.jpg and b/example/moby-dick.jpg differ
diff --git a/package.json b/package.json
index 84abe699b1c9..d2956cdbc653 100644
--- a/package.json
+++ b/package.json
@@ -11,8 +11,6 @@
"build:scripts": "cross-env NODE_ENV=production webpack --config webpack.cli.js",
"add-contributor": "all-contributors add",
"generate-contributors": "all-contributors generate",
- "storybook": "start-storybook -p 9001",
- "storybook-build": "build-storybook -o dist",
"lint": "npm run lint:js & npm run lint:css",
"lint:js": "eslint .",
"lint:js:fix": "npm run lint:js -- --fix",
@@ -73,7 +71,6 @@
"last 2 ChromeAndroid versions"
],
"devDependencies": {
- "@kadira/storybook": "^1.36.0",
"all-contributors-cli": "^4.4.0",
"babel": "^6.5.2",
"babel-cli": "^6.18.0",
@@ -87,7 +84,6 @@
"babel-preset-react": "^6.23.0",
"babel-preset-stage-1": "^6.22.0",
"babel-runtime": "^6.23.0",
- "caniuse-lite": "^1.0.30000745",
"cross-env": "^5.0.2",
"css-loader": "^0.28.7",
"cssnano": "^v4.0.0-rc.2",
@@ -110,7 +106,6 @@
"postcss-import": "^11.0.0",
"postcss-loader": "^2.0.7",
"raf": "^3.4.0",
- "react-hot-loader": "^3.0.0-beta.7",
"react-test-renderer": "^16.0.0",
"style-loader": "^0.18.2",
"stylefmt": "^4.3.1",
@@ -119,8 +114,8 @@
"stylelint-config-standard": "^13.0.2",
"stylelint-declaration-block-order": "^0.1.0",
"stylelint-declaration-use-variable": "^1.6.0",
+ "svg-inline-loader": "^0.8.0",
"uglifyjs-webpack-plugin": "^1.0.1",
- "url-loader": "^0.5.9",
"webpack": "^3.6.0",
"webpack-dev-server": "^2.9.1",
"webpack-merge": "^4.1.0",
@@ -129,7 +124,6 @@
"dependencies": {
"classnames": "^2.2.5",
"create-react-class": "^15.6.0",
- "focus-trap-react": "^3.0.3",
"fuzzy": "^0.1.1",
"gotrue-js": "^0.9.15",
"gray-matter": "^3.0.6",
@@ -140,27 +134,28 @@
"jwt-decode": "^2.1.0",
"localforage": "^1.4.2",
"lodash": "^4.13.1",
- "material-design-icons": "^3.0.1",
"mdast-util-definitions": "^1.2.2",
"mdast-util-to-string": "^1.0.4",
"moment": "^2.11.2",
- "normalize.css": "^4.2.0",
"prop-types": "^15.5.10",
"react": "^16.0.0",
+ "react-aria-menubutton": "^5.1.0",
"react-autosuggest": "^9.3.2",
- "react-datetime": "^2.6.0",
+ "react-datetime": "^2.11.0",
"react-dnd": "^2.5.4",
"react-dnd-html5-backend": "^2.5.4",
"react-dom": "^16.0.0",
"react-frame-component": "^2.0.0",
"react-immutable-proptypes": "^2.1.0",
+ "react-modal": "^3.1.5",
"react-redux": "^4.4.0",
"react-router-dom": "^4.2.2",
"react-router-redux": "^5.0.0-alpha.8",
- "react-sidebar": "^2.2.1",
+ "react-scroll-sync": "^0.4.0",
"react-sortable-hoc": "^0.6.8",
"react-split-pane": "^0.1.66",
- "react-toolbox": "^2.0.0-beta.12",
+ "react-textarea-autosize": "^5.2.0",
+ "react-toggled": "^1.1.2",
"react-topbar-progress-indicator": "^2.0.0",
"react-transition-group": "^2.2.1",
"react-waypoint": "^7.1.0",
@@ -189,7 +184,8 @@
"unist-builder": "^1.0.2",
"unist-util-visit-parents": "^1.1.1",
"url": "^0.11.0",
- "uuid": "^3.1.0"
+ "uuid": "^3.1.0",
+ "what-input": "^5.0.3"
},
"optionalDependencies": {
"fsevents": "^1.0.14"
diff --git a/postcss.config.js b/postcss.config.js
index 377a7b27b61a..4668f798aa7c 100644
--- a/postcss.config.js
+++ b/postcss.config.js
@@ -3,17 +3,7 @@ const webpack = require('webpack');
module.exports = {
plugins: [
require('postcss-import')({ addDependencyTo: webpack }),
- require('postcss-cssnext')({
- features: {
- customProperties: {
- variables: {
- "preferred-font": 'inherit', // Override react-toolbox font setting
- },
- },
- },
- }),
- require('cssnano')({
- preset: 'default',
- }),
+ require('postcss-cssnext')(),
+ require('cssnano')({ preset: 'default' }),
],
};
diff --git a/src/actions/auth.js b/src/actions/auth.js
index a0e3ee840403..8b19aa9ba4bf 100644
--- a/src/actions/auth.js
+++ b/src/actions/auth.js
@@ -1,5 +1,5 @@
-import { currentBackend } from '../backends/backend';
import { actions as notifActions } from 'redux-notifications';
+import { currentBackend } from 'Backends/backend';
const { notifSend } = notifActions;
diff --git a/src/actions/collections.js b/src/actions/collections.js
new file mode 100644
index 000000000000..70cf1b3c161f
--- /dev/null
+++ b/src/actions/collections.js
@@ -0,0 +1,14 @@
+import history from 'Routing/history';
+import { getCollectionUrl, getNewEntryUrl } from 'Lib/urlHelper';
+
+export function searchCollections(query) {
+ history.push(`/search/${query}`);
+}
+
+export function showCollection(collectionName) {
+ history.push(getCollectionUrl(collectionName));
+}
+
+export function createNewEntry(collectionName) {
+ history.push(getNewEntryUrl(collectionName));
+}
diff --git a/src/actions/config.js b/src/actions/config.js
index 5f1c3f2dcf63..3dd7bde851d0 100644
--- a/src/actions/config.js
+++ b/src/actions/config.js
@@ -1,7 +1,7 @@
import yaml from "js-yaml";
import { set, defaultsDeep, get } from "lodash";
-import { authenticateUser } from "../actions/auth";
-import * as publishModes from "../constants/publishModes";
+import { authenticateUser } from "Actions/auth";
+import * as publishModes from "Constants/publishModes";
export const CONFIG_REQUEST = "CONFIG_REQUEST";
export const CONFIG_SUCCESS = "CONFIG_SUCCESS";
diff --git a/src/actions/editor.js b/src/actions/editor.js
deleted file mode 100644
index c7a98c5f2874..000000000000
--- a/src/actions/editor.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import history from '../routing/history';
-
-export const SWITCH_VISUAL_MODE = 'SWITCH_VISUAL_MODE';
-export const CLOSED_ENTRY = 'CLOSED_ENTRY';
-
-export function switchVisualMode(useVisualMode) {
- return {
- type: SWITCH_VISUAL_MODE,
- payload: useVisualMode,
- };
-}
-
-export function closeEntry(collection) {
- return (dispatch) => {
- if (collection && collection.get('name', false)) {
- history.push(`collections/${ collection.get('name') }`);
- } else {
- history.goBack();
- }
- dispatch({ type: CLOSED_ENTRY });
- };
-}
diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js
index 9255cf1408bc..92b9f57c7f33 100644
--- a/src/actions/editorialWorkflow.js
+++ b/src/actions/editorialWorkflow.js
@@ -1,14 +1,13 @@
import uuid from 'uuid/v4';
import { actions as notifActions } from 'redux-notifications';
-import { serializeValues } from '../lib/serializeEntryValues';
-import { closeEntry } from './editor';
import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
-import { currentBackend } from '../backends/backend';
-import { getAsset } from '../reducers';
-import { selectFields } from '../reducers/collections';
+import { serializeValues } from 'Lib/serializeEntryValues';
+import { currentBackend } from 'Backends/backend';
+import { getAsset } from 'Reducers';
+import { selectFields } from 'Reducers/collections';
+import { status, EDITORIAL_WORKFLOW } from 'Constants/publishModes';
+import { EditorialWorkflowError } from "ValueObjects/errors";
import { loadEntry } from './entries';
-import { status, EDITORIAL_WORKFLOW } from '../constants/publishModes';
-import { EditorialWorkflowError } from "../valueObjects/errors";
const { notifSend } = notifActions;
@@ -35,6 +34,10 @@ export const UNPUBLISHED_ENTRY_PUBLISH_REQUEST = 'UNPUBLISHED_ENTRY_PUBLISH_REQU
export const UNPUBLISHED_ENTRY_PUBLISH_SUCCESS = 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS';
export const UNPUBLISHED_ENTRY_PUBLISH_FAILURE = 'UNPUBLISHED_ENTRY_PUBLISH_FAILURE';
+export const UNPUBLISHED_ENTRY_DELETE_REQUEST = 'UNPUBLISHED_ENTRY_DELETE_REQUEST';
+export const UNPUBLISHED_ENTRY_DELETE_SUCCESS = 'UNPUBLISHED_ENTRY_DELETE_SUCCESS';
+export const UNPUBLISHED_ENTRY_DELETE_FAILURE = 'UNPUBLISHED_ENTRY_DELETE_FAILURE';
+
/*
* Simple Action Creators (Internal)
*/
@@ -105,12 +108,13 @@ function unpublishedEntryPersisting(collection, entry, transactionID) {
};
}
-function unpublishedEntryPersisted(collection, entry, transactionID) {
+function unpublishedEntryPersisted(collection, entry, transactionID, slug) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
payload: {
collection: collection.get('name'),
entry,
+ slug,
},
optimist: { type: COMMIT, id: transactionID },
};
@@ -183,6 +187,30 @@ function unpublishedEntryPublishError(collection, slug, transactionID) {
};
}
+function unpublishedEntryDeleteRequest(collection, slug, transactionID) {
+ return {
+ type: UNPUBLISHED_ENTRY_DELETE_REQUEST,
+ payload: { collection, slug },
+ optimist: { type: BEGIN, id: transactionID },
+ };
+}
+
+function unpublishedEntryDeleted(collection, slug, transactionID) {
+ return {
+ type: UNPUBLISHED_ENTRY_DELETE_SUCCESS,
+ payload: { collection, slug },
+ optimist: { type: COMMIT, id: transactionID },
+ };
+}
+
+function unpublishedEntryDeleteError(collection, slug, transactionID) {
+ return {
+ type: UNPUBLISHED_ENTRY_DELETE_FAILURE,
+ payload: { collection, slug },
+ optimist: { type: REVERT, id: transactionID },
+ };
+}
+
/*
* Exported Thunk Action Creators
*/
@@ -223,7 +251,7 @@ export function loadUnpublishedEntries(collections) {
}
export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
- return (dispatch, getState) => {
+ return async (dispatch, getState) => {
const state = getState();
const entryDraft = state.entryDraft;
@@ -246,23 +274,32 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
dispatch(unpublishedEntryPersisting(collection, serializedEntry, transactionID));
const persistAction = existingUnpublishedEntry ? backend.persistUnpublishedEntry : backend.persistEntry;
- return persistAction.call(backend, state.config, collection, serializedEntryDraft, assetProxies.toJS(), state.integrations)
- .then(() => {
+ const persistCallArgs = [
+ backend,
+ state.config,
+ collection,
+ serializedEntryDraft,
+ assetProxies.toJS(),
+ state.integrations,
+ ];
+
+ try {
+ const newSlug = await persistAction.call(...persistCallArgs);
dispatch(notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}));
- return dispatch(unpublishedEntryPersisted(collection, serializedEntry, transactionID));
- })
- .catch((error) => {
+ dispatch(unpublishedEntryPersisted(collection, serializedEntry, transactionID, newSlug));
+ }
+ catch(error) {
dispatch(notifSend({
message: `Failed to persist entry: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
return Promise.reject(dispatch(unpublishedEntryPersistedFail(error, transactionID)));
- });
+ }
};
}
@@ -274,9 +311,19 @@ export function updateUnpublishedEntryStatus(collection, slug, oldStatus, newSta
dispatch(unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus, transactionID));
backend.updateUnpublishedEntryStatus(collection, slug, newStatus)
.then(() => {
+ dispatch(notifSend({
+ message: 'Entry status updated',
+ kind: 'success',
+ dismissAfter: 4000,
+ }));
dispatch(unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus, transactionID));
})
.catch(() => {
+ dispatch(notifSend({
+ message: `Failed to update status: ${ error }`,
+ kind: 'danger',
+ dismissAfter: 8000,
+ }));
dispatch(unpublishedEntryStatusChangeError(collection, slug, transactionID));
});
};
@@ -287,18 +334,23 @@ export function deleteUnpublishedEntry(collection, slug) {
const state = getState();
const backend = currentBackend(state.config);
const transactionID = uuid();
- dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID));
- backend.deleteUnpublishedEntry(collection, slug)
+ dispatch(unpublishedEntryDeleteRequest(collection, slug, transactionID));
+ return backend.deleteUnpublishedEntry(collection, slug)
.then(() => {
- dispatch(unpublishedEntryPublished(collection, slug, transactionID));
+ dispatch(notifSend({
+ message: 'Unpublished changes deleted',
+ kind: 'success',
+ dismissAfter: 4000,
+ }));
+ dispatch(unpublishedEntryDeleted(collection, slug, transactionID));
})
.catch((error) => {
dispatch(notifSend({
- message: `Failed to close PR: ${ error }`,
+ message: `Failed to delete unpublished changes: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
- dispatch(unpublishedEntryPublishError(collection, slug, transactionID));
+ dispatch(unpublishedEntryDeleteError(collection, slug, transactionID));
});
};
}
@@ -309,13 +361,18 @@ export function publishUnpublishedEntry(collection, slug) {
const backend = currentBackend(state.config);
const transactionID = uuid();
dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID));
- backend.publishUnpublishedEntry(collection, slug)
+ return backend.publishUnpublishedEntry(collection, slug)
.then(() => {
+ dispatch(notifSend({
+ message: 'Entry published',
+ kind: 'success',
+ dismissAfter: 4000,
+ }));
dispatch(unpublishedEntryPublished(collection, slug, transactionID));
})
.catch((error) => {
dispatch(notifSend({
- message: `Failed to merge: ${ error }`,
+ message: `Failed to publish: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
diff --git a/src/actions/entries.js b/src/actions/entries.js
index 72df4a40707b..4797f4797eee 100644
--- a/src/actions/entries.js
+++ b/src/actions/entries.js
@@ -1,13 +1,12 @@
import { List } from 'immutable';
import { actions as notifActions } from 'redux-notifications';
-import { serializeValues } from '../lib/serializeEntryValues';
-import { closeEntry } from './editor';
-import { currentBackend } from '../backends/backend';
-import { getIntegrationProvider } from '../integrations';
-import { getAsset, selectIntegration } from '../reducers';
-import { selectFields } from '../reducers/collections';
-import { createEntry } from '../valueObjects/Entry';
-import ValidationErrorTypes from '../constants/validationErrorTypes';
+import { serializeValues } from 'Lib/serializeEntryValues';
+import { currentBackend } from 'Backends/backend';
+import { getIntegrationProvider } from 'Integrations';
+import { getAsset, selectIntegration } from 'Reducers';
+import { selectFields } from 'Reducers/collections';
+import { createEntry } from 'ValueObjects/Entry';
+import ValidationErrorTypes from 'Constants/validationErrorTypes';
const { notifSend } = notifActions;
@@ -111,12 +110,17 @@ export function entryPersisting(collection, entry) {
};
}
-export function entryPersisted(collection, entry) {
+export function entryPersisted(collection, entry, slug) {
return {
type: ENTRY_PERSIST_SUCCESS,
payload: {
collectionName: collection.get('name'),
entrySlug: entry.get('slug'),
+
+ /**
+ * Pass slug from backend for newly created entries.
+ */
+ slug,
},
};
}
@@ -299,13 +303,13 @@ export function persistEntry(collection) {
dispatch(entryPersisting(collection, serializedEntry));
return backend
.persistEntry(state.config, collection, serializedEntryDraft, assetProxies.toJS())
- .then(() => {
+ .then(slug => {
dispatch(notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}));
- return dispatch(entryPersisted(collection, serializedEntry));
+ dispatch(entryPersisted(collection, serializedEntry, slug))
})
.catch((error) => {
console.error(error);
diff --git a/src/actions/findbar.js b/src/actions/findbar.js
deleted file mode 100644
index d257abc286e2..000000000000
--- a/src/actions/findbar.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import history from '../routing/history';
-import { SEARCH } from '../components/FindBar/FindBar';
-import { getCollectionUrl, getNewEntryUrl } from '../lib/urlHelper';
-
-export const RUN_COMMAND = 'RUN_COMMAND';
-export const SHOW_COLLECTION = 'SHOW_COLLECTION';
-export const CREATE_COLLECTION = 'CREATE_COLLECTION';
-export const HELP = 'HELP';
-
-export function runCommand(command, payload) {
- return (dispatch) => {
- switch (command) {
- case SHOW_COLLECTION:
- history.push(getCollectionUrl(payload.collectionName));
- break;
- case CREATE_COLLECTION:
- history.push(getNewEntryUrl(payload.collectionName));
- break;
- case HELP:
- window.alert('Find Bar Help (PLACEHOLDER)\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit.');
- break;
- case SEARCH:
- history.push(`/search/${ payload.searchTerm }`);
- break;
- default:
- break;
- }
- dispatch({ type: RUN_COMMAND, command, payload });
- };
-}
-
-export function navigateToCollection(collectionName) {
- return runCommand(SHOW_COLLECTION, { collectionName });
-}
-
-export function createNewEntryInCollection(collectionName) {
- return runCommand(CREATE_COLLECTION, { collectionName });
-}
diff --git a/src/actions/globalUI.js b/src/actions/globalUI.js
deleted file mode 100644
index ca9e4088cfbf..000000000000
--- a/src/actions/globalUI.js
+++ /dev/null
@@ -1,13 +0,0 @@
-export const TOGGLE_SIDEBAR = 'TOGGLE_SIDEBAR';
-export const OPEN_SIDEBAR = 'OPEN_SIDEBAR';
-
-export function toggleSidebar() {
- return { type: TOGGLE_SIDEBAR };
-}
-
-export function openSidebar(open = false) {
- return {
- type: OPEN_SIDEBAR,
- payload: { open },
- };
-}
diff --git a/src/actions/mediaLibrary.js b/src/actions/mediaLibrary.js
index 805065e34d3e..a58616644b69 100644
--- a/src/actions/mediaLibrary.js
+++ b/src/actions/mediaLibrary.js
@@ -1,15 +1,16 @@
import { actions as notifActions } from 'redux-notifications';
-import { currentBackend } from '../backends/backend';
-import { createAssetProxy } from '../valueObjects/AssetProxy';
-import { getAsset, selectIntegration } from '../reducers';
+import { currentBackend } from 'Backends/backend';
+import { createAssetProxy } from 'ValueObjects/AssetProxy';
+import { getAsset, selectIntegration } from 'Reducers';
+import { getIntegrationProvider } from 'Integrations';
import { addAsset } from './media';
-import { getIntegrationProvider } from '../integrations';
const { notifSend } = notifActions;
export const MEDIA_LIBRARY_OPEN = 'MEDIA_LIBRARY_OPEN';
export const MEDIA_LIBRARY_CLOSE = 'MEDIA_LIBRARY_CLOSE';
export const MEDIA_INSERT = 'MEDIA_INSERT';
+export const MEDIA_REMOVE_INSERTED = 'MEDIA_REMOVE_INSERTED';
export const MEDIA_LOAD_REQUEST = 'MEDIA_LOAD_REQUEST';
export const MEDIA_LOAD_SUCCESS = 'MEDIA_LOAD_SUCCESS';
export const MEDIA_LOAD_FAILURE = 'MEDIA_LOAD_FAILURE';
@@ -32,6 +33,10 @@ export function insertMedia(mediaPath) {
return { type: MEDIA_INSERT, payload: { mediaPath } };
}
+export function removeInsertedMedia(controlID) {
+ return { type: MEDIA_REMOVE_INSERTED, payload: { controlID } };
+}
+
export function loadMedia(opts = {}) {
const { delay = 0, query = '', page = 1, privateUpload } = opts;
return async (dispatch, getState) => {
diff --git a/src/actions/search.js b/src/actions/search.js
index 81e96ce74e26..dfc22b99348c 100644
--- a/src/actions/search.js
+++ b/src/actions/search.js
@@ -1,9 +1,9 @@
import fuzzy from 'fuzzy';
-import { currentBackend } from '../backends/backend';
-import { getIntegrationProvider } from '../integrations';
-import { selectIntegration, selectEntries } from '../reducers';
-import { selectInferedField } from '../reducers/collections';
-import { WAIT_UNTIL_ACTION } from '../redux/middleware/waitUntilAction';
+import { currentBackend } from 'Backends/backend';
+import { getIntegrationProvider } from 'Integrations';
+import { selectIntegration, selectEntries } from 'Reducers';
+import { selectInferedField } from 'Reducers/collections';
+import { WAIT_UNTIL_ACTION } from 'Redux/middleware/waitUntilAction';
import { loadEntries, ENTRIES_SUCCESS } from './entries';
/*
diff --git a/src/backends/backend.js b/src/backends/backend.js
index 4b73307dbabf..4f2771aff30d 100644
--- a/src/backends/backend.js
+++ b/src/backends/backend.js
@@ -1,12 +1,19 @@
import { attempt, isError } from 'lodash';
+import { resolveFormat } from "Formats/formats";
+import { selectIntegration } from 'Reducers/integrations';
+import {
+ selectListMethod,
+ selectEntrySlug,
+ selectEntryPath,
+ selectAllowNewEntries,
+ selectAllowDeletion,
+ selectFolderEntryExtension
+} from "Reducers/collections";
+import { createEntry } from "ValueObjects/Entry";
+import { sanitizeSlug } from "Lib/urlHelper";
import TestRepoBackend from "./test-repo/implementation";
import GitHubBackend from "./github/implementation";
import GitGatewayBackend from "./git-gateway/implementation";
-import { resolveFormat } from "../formats/formats";
-import { selectIntegration } from '../reducers/integrations';
-import { selectListMethod, selectEntrySlug, selectEntryPath, selectAllowNewEntries, selectAllowDeletion, selectFolderEntryExtension } from "../reducers/collections";
-import { createEntry } from "../valueObjects/Entry";
-import { sanitizeSlug } from "../lib/urlHelper";
class LocalStorageAuthStore {
storageKey = "netlify-cms-user";
@@ -252,10 +259,10 @@ class Backend {
*/
const hasAssetStore = integrations && !!selectIntegration(integrations, null, 'assetStore');
const updatedOptions = { ...options, hasAssetStore };
+ const opts = { newEntry, parsedData, commitMessage, collectionName, mode, ...updatedOptions };
- return this.implementation.persistEntry(entryObj, MediaFiles, {
- newEntry, parsedData, commitMessage, collectionName, mode, ...updatedOptions,
- });
+ return this.implementation.persistEntry(entryObj, MediaFiles, opts)
+ .then(() => entryObj.slug);
}
persistMedia(file) {
diff --git a/src/backends/git-gateway/API.js b/src/backends/git-gateway/API.js
index 3f75c1cc0b46..10737bb35a61 100644
--- a/src/backends/git-gateway/API.js
+++ b/src/backends/git-gateway/API.js
@@ -1,5 +1,5 @@
-import GithubAPI from "../github/API";
-import { APIError } from "../../valueObjects/errors";
+import GithubAPI from "Backends/github/API";
+import { APIError } from "ValueObjects/errors";
export default class API extends GithubAPI {
constructor(config) {
diff --git a/src/backends/git-gateway/AuthenticationPage.css b/src/backends/git-gateway/AuthenticationPage.css
index d71790be879d..0a51b8fa7a6a 100644
--- a/src/backends/git-gateway/AuthenticationPage.css
+++ b/src/backends/git-gateway/AuthenticationPage.css
@@ -1,31 +1,45 @@
.nc-gitGatewayAuthenticationPage-root {
display: flex;
+ flex-flow: column nowrap;
align-items: center;
justify-content: center;
height: 100vh;
}
-.nc-gitGatewayAuthenticationPage-card {
+.nc-gitGatewayAuthenticationPage-form {
width: 350px;
- padding: 10px;
-}
+ margin-top: -80px;
+ & input {
+ background-color: #fff;
+ border-radius: var(--borderRadius);
-.nc-gitGatewayAuthenticationPage-card img {
- display: block;
- margin: auto;
- padding-bottom: 5px;
-}
+ font-size: 14px;
+ padding: 10px 10px;
+ margin-bottom: 15px;
+ margin-top: 6px;
+ width: 100%;
+ position: relative;
+ z-index: 1;
-.nc-gitGatewayAuthenticationPage-errorMsg {
- color: #dd0000;
+ &:focus {
+ outline: none;
+ box-shadow: inset 0 0 0 2px var(--colorBlue);
+ }
+ }
}
-.nc-gitGatewayAuthenticationPage-message {
- font-size: 1.1em;
- margin: 20px 10px;
+.nc-gitGatewayAuthenticationPage-button {
+ @apply(--button);
+ @apply(--dropShadowDeep);
+ @apply(--buttonDefault);
+ @apply(--buttonGray);
+
+ padding: 0 30px;
+ display: block;
+ margin-top: 20px;
+ margin-left: auto;
}
-.nc-gitGatewayAuthenticationPage-button {
- padding: .25em 1em;
- height: auto;
+.nc-gitGatewayAuthenticationPage-errorMsg {
+ color: var(--colorErrorText);
}
diff --git a/src/backends/git-gateway/AuthenticationPage.js b/src/backends/git-gateway/AuthenticationPage.js
index 058030b30ba4..820a13546b7f 100644
--- a/src/backends/git-gateway/AuthenticationPage.js
+++ b/src/backends/git-gateway/AuthenticationPage.js
@@ -1,11 +1,8 @@
import PropTypes from 'prop-types';
import React from "react";
-import Input from "react-toolbox/lib/input";
-import Button from "react-toolbox/lib/button";
+import { partial } from 'lodash';
import { Notifs } from 'redux-notifications';
-import { Toast } from '../../components/UI/index';
-import { Card, Icon } from "../../components/UI";
-import logo from "./netlify_logo.svg";
+import { Toast, Icon } from 'UI';
let component = null;
@@ -59,8 +56,8 @@ export default class AuthenticationPage extends React.Component {
state = { email: "", password: "", errors: {} };
- handleChange = (name, value) => {
- this.setState({ ...this.state, [name]: value });
+ handleChange = (name, e) => {
+ this.setState({ ...this.state, [name]: e.target.value });
};
handleLogin = (e) => {
@@ -96,48 +93,42 @@ export default class AuthenticationPage extends React.Component {
if (window.netlifyIdentity) {
return
-
}
return (
);
}
diff --git a/src/backends/git-gateway/implementation.js b/src/backends/git-gateway/implementation.js
index cdc3ebe496cc..cd8f029cc17d 100644
--- a/src/backends/git-gateway/implementation.js
+++ b/src/backends/git-gateway/implementation.js
@@ -2,7 +2,7 @@ import GoTrue from "gotrue-js";
import jwtDecode from 'jwt-decode';
import {List} from 'immutable';
import { get, pick, intersection } from "lodash";
-import GitHubBackend from "../github/implementation";
+import GitHubBackend from "Backends/github/implementation";
import API from "./API";
import AuthenticationPage from "./AuthenticationPage";
diff --git a/src/backends/git-gateway/netlify_logo.svg b/src/backends/git-gateway/netlify_logo.svg
deleted file mode 100644
index 7e42689990a3..000000000000
--- a/src/backends/git-gateway/netlify_logo.svg
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
diff --git a/src/backends/github/API.js b/src/backends/github/API.js
index 7e3eda86071e..60152b2f6b7f 100644
--- a/src/backends/github/API.js
+++ b/src/backends/github/API.js
@@ -1,10 +1,10 @@
import LocalForage from "localforage";
import { Base64 } from "js-base64";
import { uniq, initial, last, get, find } from "lodash";
-import { filterPromises, resolvePromiseProperties } from "../../lib/promiseHelper";
-import AssetProxy from "../../valueObjects/AssetProxy";
-import { SIMPLE, EDITORIAL_WORKFLOW, status } from "../../constants/publishModes";
-import { APIError, EditorialWorkflowError } from "../../valueObjects/errors";
+import { filterPromises, resolvePromiseProperties } from "Lib/promiseHelper";
+import AssetProxy from "ValueObjects/AssetProxy";
+import { SIMPLE, EDITORIAL_WORKFLOW, status } from "Constants/publishModes";
+import { APIError, EditorialWorkflowError } from "ValueObjects/errors";
const CMS_BRANCH_PREFIX = 'cms/';
@@ -270,6 +270,7 @@ export default class API {
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha));
+
} else if (options.mode && options.mode === EDITORIAL_WORKFLOW) {
const mediaFilesList = mediaFiles.map(file => ({ path: file.path, sha: file.sha }));
return this.editorialWorkflowGit(fileTree, entry, mediaFilesList, options);
diff --git a/src/backends/github/AuthenticationPage.css b/src/backends/github/AuthenticationPage.css
index 1465a12e4bcf..124099ae52b2 100644
--- a/src/backends/github/AuthenticationPage.css
+++ b/src/backends/github/AuthenticationPage.css
@@ -6,7 +6,24 @@
height: 100vh;
}
+.nc-githubAuthenticationPage-logo {
+ color: #c4c6d2;
+ margin-top: -300px;
+}
+
.nc-githubAuthenticationPage-button {
- padding: .25em 1em;
- height: auto;
+ @apply(--button);
+ @apply(--dropShadowDeep);
+ @apply(--buttonDefault);
+ @apply(--buttonGray);
+
+ padding: 0 30px;
+ margin-top: -80px;
+ display: flex;
+ align-items: center;
+ position: relative;
+
+ & .nc-icon {
+ margin-right: 18px;
+ }
}
diff --git a/src/backends/github/AuthenticationPage.js b/src/backends/github/AuthenticationPage.js
index 71ec7a06ea9c..319309d04613 100644
--- a/src/backends/github/AuthenticationPage.js
+++ b/src/backends/github/AuthenticationPage.js
@@ -1,10 +1,8 @@
import PropTypes from 'prop-types';
import React from 'react';
-import Button from 'react-toolbox/lib/button';
-import Authenticator from '../../lib/netlify-auth';
-import { Icon } from '../../components/UI';
+import Authenticator from 'Lib/netlify-auth';
import { Notifs } from 'redux-notifications';
-import { Toast } from '../../components/UI/index';
+import { Icon, Toast } from 'UI';
export default class AuthenticationPage extends React.Component {
static propTypes = {
@@ -37,16 +35,16 @@ export default class AuthenticationPage extends React.Component {
return (
+
{loginError && {loginError}
}
-
{inProgress ? "Logging in..." : "Login with GitHub"}
-
+
);
}
diff --git a/src/backends/github/__tests__/API.spec.js b/src/backends/github/__tests__/API.spec.js
index ba6659617b8d..de2a74bb08fd 100644
--- a/src/backends/github/__tests__/API.spec.js
+++ b/src/backends/github/__tests__/API.spec.js
@@ -1,4 +1,4 @@
-import AssetProxy from "../../../valueObjects/AssetProxy";
+import AssetProxy from "ValueObjects/AssetProxy";
import API from "../API";
describe('github API', () => {
diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js
index bba53bdaf3ba..eb3b865d62df 100644
--- a/src/backends/github/implementation.js
+++ b/src/backends/github/implementation.js
@@ -1,8 +1,8 @@
import trimStart from 'lodash/trimStart';
import semaphore from "semaphore";
+import { fileExtension } from 'Lib/pathHelper'
import AuthenticationPage from "./AuthenticationPage";
import API from "./API";
-import { fileExtension } from '../../lib/pathHelper'
const MAX_CONCURRENT_DOWNLOADS = 10;
@@ -15,7 +15,7 @@ export default class GitHub {
}
this.repo = config.getIn(["backend", "repo"], "");
- this.branch = config.getIn(["backend", "branch"], "master");
+ this.branch = config.getIn(["backend", "branch"], "master").trim();
this.api_root = config.getIn(["backend", "api_root"], "https://api.github.com");
this.token = '';
}
diff --git a/src/backends/test-repo/AuthenticationPage.js b/src/backends/test-repo/AuthenticationPage.js
index 17c5d77ff37b..ca38d68b80dc 100644
--- a/src/backends/test-repo/AuthenticationPage.js
+++ b/src/backends/test-repo/AuthenticationPage.js
@@ -1,9 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
-import Input from "react-toolbox/lib/input";
-import Button from "react-toolbox/lib/button";
-import { Card, Icon } from "../../components/UI";
-import logo from "../git-gateway/netlify_logo.svg";
+import { Icon } from 'UI';
export default class AuthenticationPage extends React.Component {
static propTypes = {
@@ -11,40 +8,25 @@ export default class AuthenticationPage extends React.Component {
inProgress: PropTypes.bool.isRequired,
};
- state = { email: '' };
-
handleLogin = (e) => {
e.preventDefault();
this.props.onLogin(this.state);
};
- handleEmailChange = (value) => {
- this.setState({ email: value });
- };
-
render() {
const { inProgress } = this.props;
- return ();
+ {inProgress ? "Logging in..." : "Login"}
+
+
+ );
}
}
diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js
index 9e82204e7d49..55a723ded3ef 100644
--- a/src/backends/test-repo/implementation.js
+++ b/src/backends/test-repo/implementation.js
@@ -1,7 +1,7 @@
import { remove, attempt, isError } from 'lodash';
import uuid from 'uuid/v4';
+import { fileExtension } from 'Lib/pathHelper'
import AuthenticationPage from './AuthenticationPage';
-import { fileExtension } from '../../lib/pathHelper'
window.repoFiles = window.repoFiles || {};
@@ -14,15 +14,6 @@ function getFile(path) {
return obj || {};
}
-function nameFromEmail(email) {
- return email
- .split('@').shift().replace(/[.-_]/g, ' ')
- .split(' ')
- .filter(f => f)
- .map(s => s.substr(0, 1).toUpperCase() + (s.substr(1) || ''))
- .join(' ');
-}
-
export default class TestRepo {
constructor(config) {
this.config = config;
@@ -37,8 +28,8 @@ export default class TestRepo {
return this.authenticate(user);
}
- authenticate(state) {
- return Promise.resolve({ email: state.email, name: nameFromEmail(state.email) });
+ authenticate() {
+ return Promise.resolve();
}
logout() {
diff --git a/src/components/App/App.css b/src/components/App/App.css
new file mode 100644
index 000000000000..0f7e895e24ca
--- /dev/null
+++ b/src/components/App/App.css
@@ -0,0 +1,8 @@
+@import "./NotFoundPage.css";
+@import "./Header.css";
+
+.nc-app-main {
+ min-width: 800px;
+ max-width: 1440px;
+ margin: 0 auto;
+}
diff --git a/src/components/App/App.js b/src/components/App/App.js
new file mode 100644
index 000000000000..4b575ae5aa36
--- /dev/null
+++ b/src/components/App/App.js
@@ -0,0 +1,180 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { Route, Switch, Link, 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 { currentBackend } from 'Backends/backend';
+import { showCollection, createNewEntry } from 'Actions/collections';
+import { openMediaLibrary as actionOpenMediaLibrary } from 'Actions/mediaLibrary';
+import MediaLibrary from 'MediaLibrary/MediaLibrary';
+import { Loader, Toast } from 'UI';
+import { getCollectionUrl, getNewEntryUrl } from 'Lib/urlHelper';
+import { SIMPLE, EDITORIAL_WORKFLOW } from 'Constants/publishModes';
+import Collection from 'Collection/Collection';
+import Workflow from 'Workflow/Workflow';
+import Editor from 'Editor/Editor';
+import NotFoundPage from './NotFoundPage';
+import Header from './Header';
+
+TopBarProgress.config({
+ barColors: {
+ /**
+ * Uses value from CSS --colorActive.
+ */
+ "0": '#3a69c8',
+ '1.0': '#3a69c8',
+ },
+ shadowBlur: 0,
+ barThickness: 2,
+});
+
+class App extends React.Component {
+
+ static propTypes = {
+ auth: ImmutablePropTypes.map,
+ config: ImmutablePropTypes.map,
+ collections: ImmutablePropTypes.orderedMap,
+ logoutUser: PropTypes.func.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ user: ImmutablePropTypes.map,
+ isFetching: PropTypes.bool.isRequired,
+ publishMode: PropTypes.oneOf([SIMPLE, EDITORIAL_WORKFLOW]),
+ siteId: PropTypes.string,
+ };
+
+ static configError(config) {
+ return (
+
Error loading the CMS configuration
+
+
+
The config.yml
file could not be loaded or failed to parse properly.
+
Error message: {config.get('error')}
+
+
);
+ }
+
+ componentDidMount() {
+ this.props.dispatch(actionLoadConfig());
+ }
+
+ handleLogin(credentials) {
+ this.props.dispatch(actionLoginUser(credentials));
+ }
+
+ authenticating() {
+ const { auth } = this.props;
+ const backend = currentBackend(this.props.config);
+
+ if (backend == null) {
+ return Waiting for backend...
;
+ }
+
+ return (
+
+ {
+ React.createElement(backend.authComponent(), {
+ onLogin: this.handleLogin.bind(this),
+ error: auth && auth.get('error'),
+ isFetching: auth && auth.get('isFetching'),
+ siteId: this.props.config.getIn(["backend", "site_domain"]),
+ base_url: this.props.config.getIn(["backend", "base_url"], null)
+ })
+ }
+
+ );
+ }
+
+ handleLinkClick(event, handler, ...args) {
+ event.preventDefault();
+ handler(...args);
+ }
+
+ render() {
+ const {
+ user,
+ config,
+ collections,
+ logoutUser,
+ isFetching,
+ publishMode,
+ openMediaLibrary,
+ } = this.props;
+
+
+ if (config === null) {
+ return null;
+ }
+
+ if (config.get('error')) {
+ return App.configError(config);
+ }
+
+ if (config.get('isFetching')) {
+ return Loading configuration...;
+ }
+
+ if (user == null) {
+ return this.authenticating();
+ }
+
+ const defaultPath = `/collections/${collections.first().get('name')}`;
+ const hasWorkflow = publishMode === EDITORIAL_WORKFLOW;
+
+ return (
+
+
+
+
+ { isFetching &&
}
+
+
+
+
+ { hasWorkflow ? : null }
+
+ } />
+
+ } />
+
+
+
+
+
+
+ );
+ }
+}
+
+function mapStateToProps(state, ownProps) {
+ const { auth, config, collections, globalUI } = 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) {
+ return {
+ dispatch,
+ openMediaLibrary: () => {
+ dispatch(actionOpenMediaLibrary());
+ },
+ logoutUser: () => {
+ dispatch(actionLogoutUser());
+ },
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(App);
diff --git a/src/components/App/Header.css b/src/components/App/Header.css
new file mode 100644
index 000000000000..f8d3f70aa1fc
--- /dev/null
+++ b/src/components/App/Header.css
@@ -0,0 +1,91 @@
+.nc-appHeader-container {
+ z-index: 300;
+}
+
+.nc-appHeader-main {
+ @apply(--dropShadowMain);
+ position: fixed;
+ width: 100%;
+ top: 0;
+ background-color: var(--colorForeground);
+ z-index: 300;
+ height: var(--topBarHeight);
+}
+
+.nc-appHeader-content {
+ display: flex;
+ justify-content: space-between;
+ min-width: 800px;
+ max-width: 1440px;
+ padding: 0 12px;
+ margin: 0 auto;
+}
+
+.nc-appHeader-button {
+ background-color: transparent;
+ color: #7b8290;
+ font-size: 16px;
+ font-weight: 500;
+ display: inline-flex;
+ padding: 16px 20px;
+ align-items: center;
+
+ & .nc-icon {
+ margin-right: 4px;
+ color: #b3b9c4;
+ }
+
+ &:hover,
+ &:active,
+ &:focus,
+ &.nc-appHeader-button-active {
+ background-color: white;
+ color: var(--colorActive);
+
+ & .nc-icon {
+ color: var(--colorActive);
+ }
+ }
+}
+
+.nc-appHeader-actions {
+ display: inline-flex;
+ align-items: center;
+}
+
+.nc-appHeader-siteLink {
+ font-size: 14px;
+ font-weight: 400;
+ color: #7b8290;
+ padding: 10px 16px;
+}
+
+.nc-appHeader-quickNew {
+ @apply(--buttonMedium);
+ @apply(--buttonGray);
+ margin-right: 8px;
+
+ &:after {
+ top: 11px;
+ }
+}
+
+.nc-appHeader-avatar {
+ border: 0;
+ padding: 8px;
+ cursor: pointer;
+ color: #1e2532;
+ background-color: transparent;
+}
+
+.nc-appHeader-avatar-image,
+.nc-appHeader-avatar-placeholder {
+ width: 32px;
+ border-radius: 32px;
+}
+
+.nc-appHeader-avatar-placeholder {
+ height: 32px;
+ color: #1e2532;
+ background-color: var(--textFieldBorderColor);
+}
diff --git a/src/components/App/Header.js b/src/components/App/Header.js
new file mode 100644
index 000000000000..1d770fab4fde
--- /dev/null
+++ b/src/components/App/Header.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React from "react";
+import ImmutablePropTypes from "react-immutable-proptypes";
+import { NavLink } from 'react-router-dom';
+import { Icon, Dropdown, DropdownItem } from 'UI';
+import { stripProtocol } from 'Lib/urlHelper';
+
+export default class Header extends React.Component {
+ static propTypes = {
+ user: ImmutablePropTypes.map.isRequired,
+ collections: ImmutablePropTypes.orderedMap.isRequired,
+ onCreateEntryClick: PropTypes.func.isRequired,
+ onLogoutClick: PropTypes.func.isRequired,
+ displayUrl: PropTypes.string,
+ };
+
+ handleCreatePostClick = (collectionName) => {
+ const { onCreateEntryClick } = this.props;
+ if (onCreateEntryClick) {
+ onCreateEntryClick(collectionName);
+ }
+ };
+
+ render() {
+ const {
+ user,
+ collections,
+ toggleDrawer,
+ onLogoutClick,
+ openMediaLibrary,
+ hasWorkflow,
+ displayUrl,
+ } = this.props;
+
+ const avatarUrl = user.get('avatar_url');
+
+ return (
+
+
+
+
+
+
+ {
+ collections.filter(collection => collection.get('create')).toList().map(collection =>
+ this.handleCreatePostClick(collection.get('name'))}
+ />
+ )
+ }
+
+ {
+ displayUrl
+ ?
+ {stripProtocol(displayUrl)}
+
+ : null
+ }
+
+ {
+ avatarUrl
+ ?
+ :
+ }
+
+ }
+ >
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/src/components/App/NotFoundPage.css b/src/components/App/NotFoundPage.css
new file mode 100644
index 000000000000..9022a94b2eb7
--- /dev/null
+++ b/src/components/App/NotFoundPage.css
@@ -0,0 +1,3 @@
+.nc-notFound-container {
+ margin: var(--pageMargin);
+}
diff --git a/src/components/App/NotFoundPage.js b/src/components/App/NotFoundPage.js
new file mode 100644
index 000000000000..5115a424845b
--- /dev/null
+++ b/src/components/App/NotFoundPage.js
@@ -0,0 +1,7 @@
+import React from 'react';
+
+export default () => (
+
+
Not Found
+
+);
diff --git a/src/components/AppHeader/AppHeader.css b/src/components/AppHeader/AppHeader.css
deleted file mode 100644
index 8ee8475dc150..000000000000
--- a/src/components/AppHeader/AppHeader.css
+++ /dev/null
@@ -1,37 +0,0 @@
-.nc-appHeader-appBar {
- padding: 8px 24px;
- height: auto;
- background-color: var(--backgroundAltColor);
- color: var(--defaultColorLight);
-}
-
-/* Gross stuff below, React Toolbox hacks */
-
-.nc-appHeader-button,
-.nc-appHeader-iconMenu {
- margin-left: 16px;
-}
-
-.nc-appHeader-button {
- cursor: pointer;
- border: 0;
- background-color: transparent;
- width: 36px;
- padding: 6px 0;
- text-align: center;
-
- & .nc-appHeader-icon {
- vertical-align: top;
- }
-}
-
-.nc-appHeader-icon,
-.nc-appHeader-icon span,
-.nc-appHeader-leftIcon span {
- /* stylelint-disable */
-
- color: var(--defaultColorLight) !important;
- font-size: 24px !important;
-
- /* stylelint-enable */
-}
diff --git a/src/components/AppHeader/AppHeader.js b/src/components/AppHeader/AppHeader.js
deleted file mode 100644
index 486b98e77cbc..000000000000
--- a/src/components/AppHeader/AppHeader.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import PropTypes from 'prop-types';
-import React from "react";
-import ImmutablePropTypes from "react-immutable-proptypes";
-import { Link } from 'react-router-dom';
-import { IconMenu, Menu, MenuItem } from "react-toolbox/lib/menu";
-import Avatar from "react-toolbox/lib/avatar";
-import AppBar from "react-toolbox/lib/app_bar";
-import FontIcon from "react-toolbox/lib/font_icon";
-import FindBar from "../FindBar/FindBar";
-import { stringToRGB } from "../../lib/textHelper";
-
-export default class AppHeader extends React.Component {
-
- static propTypes = {
- user: ImmutablePropTypes.map.isRequired,
- collections: ImmutablePropTypes.orderedMap.isRequired,
- runCommand: PropTypes.func.isRequired,
- toggleDrawer: PropTypes.func.isRequired,
- onCreateEntryClick: PropTypes.func.isRequired,
- onLogoutClick: PropTypes.func.isRequired,
- };
-
- handleCreatePostClick = (collectionName) => {
- const { onCreateEntryClick } = this.props;
- if (onCreateEntryClick) {
- onCreateEntryClick(collectionName);
- }
- };
-
- render() {
- const {
- user,
- collections,
- runCommand,
- toggleDrawer,
- onLogoutClick,
- openMediaLibrary,
- } = this.props;
-
- const avatarStyle = {
- backgroundColor: `#${ stringToRGB(user.get("name")) }`,
- };
-
- const theme = {
- appBar: 'nc-appHeader-appBar',
- iconMenu: 'nc-appHeader-iconMenu',
- icon: 'nc-appHeader-icon',
- leftIcon: 'nc-appHeader-leftIcon',
- base: 'nc-theme-base',
- container: 'nc-theme-container',
- rounded: 'nc-theme-rounded',
- depth: 'nc-theme-depth',
- clearfix: 'nc-theme-clearfix',
- };
-
- return (
-
-
-
-
-
-
-
-
- {
- collections.filter(collection => collection.get('create')).toList().map(collection =>
-
- )
- }
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/src/components/Collection/Collection.css b/src/components/Collection/Collection.css
new file mode 100644
index 000000000000..ca7ec0ea5a14
--- /dev/null
+++ b/src/components/Collection/Collection.css
@@ -0,0 +1,11 @@
+@import "./Sidebar.css";
+@import "./CollectionTop.css";
+@import "./Entries/Entries.css";
+
+.nc-collectionPage-container {
+ margin: var(--pageMargin);
+}
+
+.nc-collectionPage-main {
+ padding-left: 280px;
+}
diff --git a/src/components/Collection/Collection.js b/src/components/Collection/Collection.js
new file mode 100644
index 000000000000..f954e736a797
--- /dev/null
+++ b/src/components/Collection/Collection.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { getNewEntryUrl } from 'Lib/urlHelper';
+import Sidebar from './Sidebar';
+import CollectionTop from './CollectionTop';
+import EntriesCollection from './Entries/EntriesCollection';
+import EntriesSearch from './Entries/EntriesSearch';
+import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
+
+class Collection extends React.Component {
+ static propTypes = {
+ collection: ImmutablePropTypes.map.isRequired,
+ collections: ImmutablePropTypes.orderedMap.isRequired,
+ };
+
+ state = {
+ viewStyle: VIEW_STYLE_LIST,
+ };
+
+ renderEntriesCollection = () => {
+ const { name, collection } = this.props;
+ return
+ };
+
+ renderEntriesSearch = () => {
+ const { searchTerm, collections } = this.props;
+ return
+ };
+
+ handleChangeViewStyle = (viewStyle) => {
+ if (this.state.viewStyle !== viewStyle) {
+ this.setState({ viewStyle });
+ }
+ }
+
+ render() {
+ const { collection, collections, collectionName, isSearchResults, searchTerm } = this.props;
+ const newEntryUrl = collection.get('create') && getNewEntryUrl(collectionName);
+ return (
+
+
+
+ {
+ isSearchResults
+ ? null
+ :
+ }
+ { isSearchResults ? this.renderEntriesSearch() : this.renderEntriesCollection() }
+
+
+ );
+ }
+}
+
+function mapStateToProps(state, ownProps) {
+ const { collections } = state;
+ const { isSearchResults, match } = ownProps;
+ const { name, searchTerm } = match.params;
+ const collection = name ? collections.get(name) : collections.first();
+ return { collection, collections, collectionName: name, isSearchResults, searchTerm };
+}
+
+export default connect(mapStateToProps)(Collection);
diff --git a/src/components/Collection/CollectionTop.css b/src/components/Collection/CollectionTop.css
new file mode 100644
index 000000000000..095a2d49db16
--- /dev/null
+++ b/src/components/Collection/CollectionTop.css
@@ -0,0 +1,64 @@
+.nc-collectionPage-top {
+ @apply(--cardTop);
+}
+
+.nc-collectionPage-top-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 20px;
+}
+
+.nc-collectionPage-top-description {
+ @apply(--cardTopDescription)
+}
+
+.nc-collectionPage-topHeading {
+ @apply(--cardTopHeading)
+}
+
+.nc-collectionPage-topNewButton {
+ @apply(--button);
+ @apply(--dropShadowDeep);
+ @apply(--buttonDefault);
+ @apply(--buttonGray);
+
+ padding: 0 30px;
+}
+
+.nc-collectionPage-top-viewControls {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ margin-top: 14px;
+}
+
+.nc-collectionPage-top-viewControls {
+ margin-top: 24px;
+}
+
+.nc-collectionPage-top-viewControls-text {
+ font-size: 14px;
+ color: var(--colorText);
+ margin-right: 12px;
+}
+
+.nc-collectionPage-top-viewControls-button {
+ color: #b3b9c4;
+ background-color: transparent;
+ display: block;
+ padding: 0;
+ margin: 0 4px;
+
+ &:last-child {
+ margin-right: 0;
+ }
+
+ & .nc-icon {
+ display: block;
+ }
+}
+
+.nc-collectionPage-top-viewControls-buttonActive {
+ color: var(--colorActive);
+}
diff --git a/src/components/Collection/CollectionTop.js b/src/components/Collection/CollectionTop.js
new file mode 100644
index 000000000000..28c38adbecdf
--- /dev/null
+++ b/src/components/Collection/CollectionTop.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import c from 'classnames';
+import { Link } from 'react-router-dom';
+import { Icon } from 'UI';
+import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
+
+const CollectionTop = ({
+ collectionLabel,
+ collectionDescription,
+ viewStyle,
+ onChangeViewStyle,
+ newEntryUrl,
+}) => {
+ return (
+
+
+
{collectionLabel}
+ {
+ newEntryUrl
+ ?
+ {`New ${collectionLabel}`}
+
+ : null
+ }
+
+ {
+ collectionDescription
+ ?
{collectionDescription}
+ : null
+ }
+
+ View as:
+ onChangeViewStyle(VIEW_STYLE_LIST)}
+ >
+
+
+ onChangeViewStyle(VIEW_STYLE_GRID)}
+ >
+
+
+
+
+ );
+};
+
+CollectionTop.propTypes = {
+ collectionLabel: PropTypes.string.isRequired,
+ collectionDescription: PropTypes.string,
+ newEntryUrl: PropTypes.string
+};
+
+export default CollectionTop;
diff --git a/src/components/Collection/Entries/Entries.css b/src/components/Collection/Entries/Entries.css
new file mode 100644
index 000000000000..8f62e0a25a43
--- /dev/null
+++ b/src/components/Collection/Entries/Entries.css
@@ -0,0 +1,2 @@
+@import "./EntryListing.css";
+@import "./EntryCard.css";
diff --git a/src/components/Collection/Entries/Entries.js b/src/components/Collection/Entries/Entries.js
new file mode 100644
index 000000000000..bc9974b2e33d
--- /dev/null
+++ b/src/components/Collection/Entries/Entries.js
@@ -0,0 +1,51 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { Loader } from 'UI';
+import EntryListing from './EntryListing';
+
+const Entries = ({
+ collections,
+ entries,
+ publicFolder,
+ page,
+ onPaginate,
+ isFetching,
+ viewStyle
+}) => {
+ const loadingMessages = [
+ 'Loading Entries',
+ 'Caching Entries',
+ 'This might take several minutes',
+ ];
+
+ if (entries) {
+ return (
+
+ );
+ }
+
+ if (isFetching) {
+ return {loadingMessages};
+ }
+
+ return No Entries
;
+}
+
+Entries.propTypes = {
+ collections: ImmutablePropTypes.map.isRequired,
+ entries: ImmutablePropTypes.list,
+ publicFolder: PropTypes.string.isRequired,
+ page: PropTypes.number,
+ isFetching: PropTypes.bool,
+ viewStyle: PropTypes.string,
+};
+
+export default Entries;
diff --git a/src/components/Collection/Entries/EntriesCollection.js b/src/components/Collection/Entries/EntriesCollection.js
new file mode 100644
index 000000000000..37b727471795
--- /dev/null
+++ b/src/components/Collection/Entries/EntriesCollection.js
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { loadEntries } from 'Actions/entries';
+import { selectEntries } from 'Reducers';
+import Entries from './Entries';
+
+class EntriesCollection extends React.Component {
+ static propTypes = {
+ collection: ImmutablePropTypes.map.isRequired,
+ publicFolder: PropTypes.string.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ page: PropTypes.number,
+ entries: ImmutablePropTypes.list,
+ isFetching: PropTypes.bool.isRequired,
+ viewStyle: PropTypes.string,
+ };
+
+ componentDidMount() {
+ const { collection, dispatch } = this.props;
+ if (collection) {
+ dispatch(loadEntries(collection));
+ }
+ }
+
+ componentWillReceiveProps(nextProps) {
+ const { collection, dispatch } = this.props;
+ if (nextProps.collection !== collection) {
+ dispatch(loadEntries(nextProps.collection));
+ }
+ }
+
+ render () {
+ const { dispatch, collection, entries, publicFolder, page, isFetching, viewStyle } = this.props;
+
+ return (
+ dispatch(loadEntries(collection, page))}
+ isFetching={isFetching}
+ collectionName={collection.get('label')}
+ viewStyle={viewStyle}
+ />
+ );
+ }
+}
+
+function mapStateToProps(state, ownProps) {
+ const { name, collection, viewStyle } = ownProps;
+ const { config } = state;
+ const publicFolder = config.get('public_folder');
+ const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
+
+ const entries = selectEntries(state, collection.get('name'));
+ const isFetching = state.entries.getIn(['pages', collection.get('name'), 'isFetching'], false);
+
+ return { publicFolder, collection, page, entries, isFetching, viewStyle };
+}
+
+export default connect(mapStateToProps)(EntriesCollection);
diff --git a/src/containers/SearchPage.js b/src/components/Collection/Entries/EntriesSearch.js
similarity index 54%
rename from src/containers/SearchPage.js
rename to src/components/Collection/Entries/EntriesSearch.js
index e27d5721c38b..02c66ffa3b3a 100644
--- a/src/containers/SearchPage.js
+++ b/src/components/Collection/Entries/EntriesSearch.js
@@ -1,14 +1,15 @@
-import PropTypes from 'prop-types';
import React from 'react';
+import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
-import { selectSearchedEntries } from '../reducers';
-import { searchEntries as actionSearchEntries, clearSearch as actionClearSearch } from '../actions/search';
-import { Loader } from '../components/UI';
-import EntryListing from '../components/EntryListing/EntryListing';
-
-class SearchPage extends React.Component {
+import { selectSearchedEntries } from 'Reducers';
+import {
+ searchEntries as actionSearchEntries,
+ clearSearch as actionClearSearch
+} from 'Actions/search';
+import Entries from './Entries';
+class EntriesSearch extends React.Component {
static propTypes = {
isFetching: PropTypes.bool,
searchEntries: PropTypes.func.isRequired,
@@ -40,43 +41,35 @@ class SearchPage extends React.Component {
if (!isNaN(page)) searchEntries(searchTerm, page);
};
- render() {
- const { collections, searchTerm, entries, isFetching, page, publicFolder } = this.props;
- return (
- {(isFetching === true || !entries) ?
- {['Loading Entries', 'Caching Entries', 'This might take several minutes']}
- :
-
- Results for “{searchTerm}”
-
- }
-
);
+ render () {
+ const { dispatch, collections, entries, publicFolder, page, isFetching } = this.props;
+ return (
+
+ );
}
}
-
function mapStateToProps(state, ownProps) {
+ const { searchTerm } = ownProps;
+ const collections = ownProps.collections.toIndexedSeq();
const isFetching = state.entries.getIn(['search', 'isFetching']);
const page = state.entries.getIn(['search', 'page']);
const entries = selectSearchedEntries(state);
- const collections = state.collections.toIndexedSeq();
const publicFolder = state.config.get('public_folder');
- const { searchTerm } = ownProps.match.params;
return { isFetching, page, collections, entries, publicFolder, searchTerm };
}
+const mapDispatchToProps = {
+ searchEntries: actionSearchEntries,
+ clearSearch: actionClearSearch,
+};
-export default connect(
- mapStateToProps,
- {
- searchEntries: actionSearchEntries,
- clearSearch: actionClearSearch,
- }
-)(SearchPage);
+export default connect(mapStateToProps, mapDispatchToProps)(EntriesSearch);
diff --git a/src/components/Collection/Entries/EntryCard.css b/src/components/Collection/Entries/EntryCard.css
new file mode 100644
index 000000000000..8dfc18766ce0
--- /dev/null
+++ b/src/components/Collection/Entries/EntryCard.css
@@ -0,0 +1,70 @@
+.nc-entryListing-gridCard {
+ @apply(--card);
+ flex: 0 0 335px;
+ height: 240px;
+ background-color: var(--colorForeground);
+ color: var(--colorText);
+ overflow: hidden;
+ margin-bottom: 16px;
+ margin-left: 12px;
+
+ &:hover {
+ background-color: var(--colorForeground);
+ color: var(--colorText);
+ }
+}
+
+.nc-entryListing-cardImage {
+ background-position: center center;
+ background-size: cover;
+ background-repeat: no-repeat;
+ height: 150px;
+}
+
+.nc-entryListing-cardBody {
+ padding: 16px 22px;
+ height: 90px;
+ position: relative;
+
+ &:after {
+ content: '';
+ position: absolute;
+ display: block;
+ z-index: 1;
+ bottom: 0;
+ left: -20%;
+ height: 140%;
+ width: 140%;
+ box-shadow: inset 0 -15px 24px #fff;
+ }
+}
+
+.nc-entryListing-listCard {
+ @apply(--card);
+ width: var(--topCardWidth);
+ max-width: 100%;
+ padding: 16px 22px;
+ margin-left: 12px;
+ margin-bottom: 16px;
+
+ &:hover {
+ background-color: var(--colorForeground);
+ }
+}
+
+.nc-entryListing-listCard-title {
+ margin-bottom: 0;
+}
+
+.nc-entryListing-cardBody-full {
+ height: 100%;
+}
+
+.nc-entryListing-cardHeading {
+ margin: 0 0 2px;
+}
+
+.nc-entryListing-cardListLabel {
+ white-space: nowrap;
+ font-weight: bold;
+}
diff --git a/src/components/Collection/Entries/EntryCard.js b/src/components/Collection/Entries/EntryCard.js
new file mode 100644
index 000000000000..e9f3f9fcd030
--- /dev/null
+++ b/src/components/Collection/Entries/EntryCard.js
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { Link } from 'react-router-dom';
+import c from 'classnames';
+import history from 'Routing/history';
+import { resolvePath } from 'Lib/pathHelper';
+import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
+
+const EntryCard = ({
+ collection,
+ entry,
+ inferedFields,
+ publicFolder,
+ viewStyle = VIEW_STYLE_LIST,
+}) => {
+ const label = entry.get('label');
+ const title = label || entry.getIn(['data', inferedFields.titleField]);
+ const path = `/collections/${collection.get('name')}/entries/${entry.get('slug')}`;
+ let image = entry.getIn(['data', inferedFields.imageField]);
+ image = resolvePath(image, publicFolder);
+ if(image) {
+ image = encodeURI(image);
+ }
+
+ if (viewStyle === VIEW_STYLE_LIST) {
+ return (
+
+ {title}
+
+ );
+ }
+
+ if (viewStyle === VIEW_STYLE_GRID) {
+ return (
+
+
+
{title}
+
+ {
+ image
+ ?
+ : null
+ }
+
+ );
+ }
+}
+
+export default EntryCard;
diff --git a/src/components/Collection/Entries/EntryListing.css b/src/components/Collection/Entries/EntryListing.css
new file mode 100644
index 000000000000..4980857feadb
--- /dev/null
+++ b/src/components/Collection/Entries/EntryListing.css
@@ -0,0 +1,9 @@
+.nc-entryListing-cardsGrid {
+ display: flex;
+ flex-flow: row wrap;
+ margin-left: -12px;
+}
+
+.nc-entryListing-cardsList {
+ margin-left: -12px;
+}
diff --git a/src/components/Collection/Entries/EntryListing.js b/src/components/Collection/Entries/EntryListing.js
new file mode 100644
index 000000000000..dff560bbad07
--- /dev/null
+++ b/src/components/Collection/Entries/EntryListing.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Waypoint from 'react-waypoint';
+import { Map } from 'immutable';
+import { selectFields, selectInferedField } from 'Reducers/collections';
+import EntryCard from './EntryCard';
+
+export default class EntryListing extends React.Component {
+ static propTypes = {
+ publicFolder: PropTypes.string.isRequired,
+ collections: PropTypes.oneOfType([
+ ImmutablePropTypes.map,
+ ImmutablePropTypes.iterable,
+ ]).isRequired,
+ entries: ImmutablePropTypes.list,
+ onPaginate: PropTypes.func.isRequired,
+ page: PropTypes.number,
+ viewStyle: PropTypes.string,
+ };
+
+ handleLoadMore = () => {
+ this.props.onPaginate(this.props.page + 1);
+ };
+
+ inferFields = collection => {
+ const titleField = selectInferedField(collection, 'title');
+ const descriptionField = selectInferedField(collection, 'description');
+ const imageField = selectInferedField(collection, 'image');
+ const fields = selectFields(collection);
+ const inferedFields = [titleField, descriptionField, imageField];
+ const remainingFields = fields && fields.filter(f => inferedFields.indexOf(f.get('name')) === -1);
+ return { titleField, descriptionField, imageField, remainingFields };
+ };
+
+ renderCardsForSingleCollection = () => {
+ const { collections, entries, publicFolder, viewStyle } = this.props;
+ const inferedFields = this.inferFields(collections);
+ const entryCardProps = { collection: collections, inferedFields, publicFolder, viewStyle };
+ return entries.map((entry, idx) => );
+ };
+
+ renderCardsForMultipleCollections = () => {
+ const { collections, entries, publicFolder } = this.props;
+ return entries.map((entry, idx) => {
+ const collectionName = entry.get('collection');
+ const collection = collections.find(coll => coll.get('name') === collectionName);
+ const inferedFields = this.inferFields(collection);
+ const entryCardProps = { collection, entry, inferedFields, publicFolder, key: idx };
+ return ;
+ });
+ };
+
+ render() {
+ const { collections, entries, publicFolder } = this.props;
+
+ return (
+
+
+ {
+ Map.isMap(collections)
+ ? this.renderCardsForSingleCollection()
+ : this.renderCardsForMultipleCollections()
+ }
+
+
+
+ );
+ }
+}
diff --git a/src/components/Collection/Sidebar.css b/src/components/Collection/Sidebar.css
new file mode 100644
index 000000000000..ace061b0f0c2
--- /dev/null
+++ b/src/components/Collection/Sidebar.css
@@ -0,0 +1,68 @@
+.nc-collectionPage-sidebar {
+ @apply(--card);
+ width: 250px;
+ padding: 8px 0 12px;
+ position: fixed;
+}
+
+.nc-collectionPage-sidebarHeading {
+ font-size: 23px;
+ font-weight: 600;
+ padding: 0;
+ margin: 18px 12px 12px;
+ color: var(--colorTextLead);
+}
+
+.nc-collectionPage-sidebarSearch {
+ display: flex;
+ align-items: center;
+ margin: 0 8px;
+ position: relative;
+
+ & input {
+ background-color: #eff0f4;
+ border-radius: var(--borderRadius);
+
+ font-size: 14px;
+ padding: 10px 6px 10px 32px;
+ width: 100%;
+ position: relative;
+ z-index: 1;
+
+ &:focus {
+ outline: none;
+ box-shadow: inset 0 0 0 2px var(--colorBlue);
+ }
+ }
+
+ & .nc-icon {
+ position: absolute;
+ left: 6px;
+ z-index: 2;
+ }
+}
+
+.nc-collectionPage-sidebarLink {
+ display: flex;
+ font-size: 14px;
+ font-weight: 500;
+ align-items: center;
+ padding: 8px 12px;
+ border-left: 2px solid #fff;
+
+ & .nc-icon {
+ margin-right: 8px;
+ }
+
+ &:hover,
+ &:active,
+ &.nc-collectionPage-sidebarLink-active {
+ color: var(--colorActive);
+ background-color: var(--colorActiveBackground);
+ border-left-color: #4863c6;
+ }
+
+ &:first-of-type {
+ margin-top: 16px;
+ }
+}
diff --git a/src/components/Collection/Sidebar.js b/src/components/Collection/Sidebar.js
new file mode 100644
index 000000000000..671a8bcfa139
--- /dev/null
+++ b/src/components/Collection/Sidebar.js
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { NavLink } from 'react-router-dom';
+import { searchCollections } from 'Actions/collections';
+import { getCollectionUrl } from 'Lib/urlHelper';
+import { Icon } from 'UI';
+
+export default class Collection extends React.Component {
+
+ static propTypes = {
+ collections: ImmutablePropTypes.orderedMap.isRequired,
+ };
+
+ state = { query: this.props.searchTerm || '' };
+
+ renderLink = collection => {
+ const collectionName = collection.get('name');
+ return (
+
+
+ {collection.get('label')}
+
+ );
+ };
+
+
+ render() {
+ const { collections } = this.props;
+ const { query } = this.state;
+
+ return (
+
+
Collections
+
+
+ this.setState({ query: e.target.value })}
+ onKeyDown={e => e.key === 'Enter' && searchCollections(query)}
+ placeholder="Search all"
+ value={query}
+ />
+
+ {collections.toList().map(this.renderLink)}
+
+ );
+ }
+}
diff --git a/src/components/ControlPanel/ControlPane.css b/src/components/ControlPanel/ControlPane.css
deleted file mode 100644
index 7d4967765c68..000000000000
--- a/src/components/ControlPanel/ControlPane.css
+++ /dev/null
@@ -1,35 +0,0 @@
-.nc-controlPane-root p {
- font-size: 16px;
-}
-
-.nc-controlPane-control {
- color: var(--textColor);
- position: relative;
- padding: 20px 0 10px 0;
- margin-top: 16px;
-
- & input,
- & textarea,
- & select {
- @apply --input;
- }
-}
-
-.nc-controlPane-label {
- display: block;
- color: var(--controlLabelColor);
- font-size: 12px;
- text-transform: uppercase;
- font-weight: 600;
-}
-
-.nc-controlPane-labelWithError {
- color: #FF706F;
-}
-
-.nc-controlPane-errors {
- list-style-type: none;
- font-size: 10px;
- color: #FF706F;
- margin-bottom: 5px;
-}
diff --git a/src/components/ControlPanel/ControlPane.js b/src/components/ControlPanel/ControlPane.js
deleted file mode 100644
index 1d7525d6b2ee..000000000000
--- a/src/components/ControlPanel/ControlPane.js
+++ /dev/null
@@ -1,109 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { Map, fromJS } from 'immutable';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { resolveWidget } from '../Widgets';
-import ControlHOC from '../Widgets/ControlHOC';
-
-function isHidden(field) {
- return field.get('widget') === 'hidden';
-}
-
-export default class ControlPane extends Component {
- componentValidate = {};
- processControlRef(fieldName, wrappedControl) {
- if (!wrappedControl) return;
- this.componentValidate[fieldName] = wrappedControl.validate;
- }
-
- validate = () => {
- this.props.fields.forEach((field) => {
- if (isHidden(field)) return;
- this.componentValidate[field.get("name")]();
- });
- };
-
- controlFor(field) {
- const {
- entry,
- fieldsMetaData,
- fieldsErrors,
- mediaPaths,
- getAsset,
- onChange,
- onOpenMediaLibrary,
- onAddAsset,
- onRemoveAsset
- } = this.props;
- const widget = resolveWidget(field.get('widget'));
- const fieldName = field.get('name');
- const value = entry.getIn(['data', fieldName]);
- const metadata = fieldsMetaData.get(fieldName);
- const errors = fieldsErrors.get(fieldName);
- const labelClass = errors ? 'nc-controlPane-label nc-controlPane-labelWithError' : 'nc-controlPane-label';
- if (entry.size === 0 || entry.get('partial') === true) return null;
- return (
-
-
-
- {
- errors && errors.map(error =>
- error.message &&
- typeof error.message === 'string' &&
- - {error.message}
- )
- }
-
-
onChange(fieldName, newValue, newMetadata)}
- onValidate={this.props.onValidate.bind(this, fieldName)}
- onOpenMediaLibrary={onOpenMediaLibrary}
- onAddAsset={onAddAsset}
- onRemoveAsset={onRemoveAsset}
- getAsset={getAsset}
- ref={this.processControlRef.bind(this, fieldName)}
- />
-
- );
- }
-
- render() {
- const { collection, fields } = this.props;
- if (!collection || !fields) {
- return null;
- }
-
- return (
-
- {
- fields.map((field, i) => {
- if (isHidden(field)) {
- return null;
- }
- return
{this.controlFor(field)}
;
- })
- }
-
- );
- }
-}
-
-ControlPane.propTypes = {
- collection: ImmutablePropTypes.map.isRequired,
- entry: ImmutablePropTypes.map.isRequired,
- fields: ImmutablePropTypes.list.isRequired,
- fieldsMetaData: ImmutablePropTypes.map.isRequired,
- fieldsErrors: ImmutablePropTypes.map.isRequired,
- mediaPaths: ImmutablePropTypes.map.isRequired,
- getAsset: PropTypes.func.isRequired,
- onOpenMediaLibrary: PropTypes.func.isRequired,
- onAddAsset: PropTypes.func.isRequired,
- onChange: PropTypes.func.isRequired,
- onValidate: PropTypes.func.isRequired,
- onRemoveAsset: PropTypes.func.isRequired,
-};
diff --git a/src/components/Editor/Editor.css b/src/components/Editor/Editor.css
new file mode 100644
index 000000000000..edcd4e7b3eae
--- /dev/null
+++ b/src/components/Editor/Editor.css
@@ -0,0 +1,6 @@
+@import "./EditorInterface.css";
+@import "./EditorToolbar.css";
+@import "./EditorToggle.css";
+@import "./EditorControlPane/EditorControlPane.css";
+@import "./EditorControlPane/EditorControl.css";
+@import "./EditorPreviewPane/EditorPreviewPane.css";
diff --git a/src/components/Editor/Editor.js b/src/components/Editor/Editor.js
new file mode 100644
index 000000000000..b0bb7ec5bd74
--- /dev/null
+++ b/src/components/Editor/Editor.js
@@ -0,0 +1,390 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { Map } from 'immutable';
+import { get } from 'lodash';
+import { connect } from 'react-redux';
+import history from 'Routing/history';
+import { logoutUser } from 'Actions/auth';
+import {
+ loadEntry,
+ loadEntries,
+ createDraftFromEntry,
+ createEmptyDraft,
+ discardDraft,
+ changeDraftField,
+ changeDraftFieldValidation,
+ persistEntry,
+ deleteEntry,
+} from 'Actions/entries';
+import {
+ updateUnpublishedEntryStatus,
+ publishUnpublishedEntry,
+ deleteUnpublishedEntry
+} from 'Actions/editorialWorkflow';
+import { deserializeValues } from 'Lib/serializeEntryValues';
+import { addAsset } from 'Actions/media';
+import { openMediaLibrary, removeInsertedMedia } from 'Actions/mediaLibrary';
+import { selectEntry, selectUnpublishedEntry, getAsset } from 'Reducers';
+import { selectFields } from 'Reducers/collections';
+import { Loader } from 'UI';
+import { status } from 'Constants/publishModes';
+import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
+import EditorInterface from './EditorInterface';
+import withWorkflow from './withWorkflow';
+
+const navigateCollection = (collectionPath) => history.push(`/collections/${collectionPath}`);
+const navigateToCollection = collectionName => navigateCollection(collectionName);
+const navigateToNewEntry = collectionName => navigateCollection(`${collectionName}/new`);
+const navigateToEntry = (collectionName, slug) => navigateCollection(`${collectionName}/entries/${slug}`);
+
+class Editor extends React.Component {
+ static propTypes = {
+ addAsset: PropTypes.func.isRequired,
+ boundGetAsset: PropTypes.func.isRequired,
+ changeDraftField: PropTypes.func.isRequired,
+ changeDraftFieldValidation: PropTypes.func.isRequired,
+ collection: ImmutablePropTypes.map.isRequired,
+ createDraftFromEntry: PropTypes.func.isRequired,
+ createEmptyDraft: PropTypes.func.isRequired,
+ discardDraft: PropTypes.func.isRequired,
+ entry: ImmutablePropTypes.map,
+ mediaPaths: ImmutablePropTypes.map.isRequired,
+ entryDraft: ImmutablePropTypes.map.isRequired,
+ loadEntry: PropTypes.func.isRequired,
+ persistEntry: PropTypes.func.isRequired,
+ deleteEntry: PropTypes.func.isRequired,
+ showDelete: PropTypes.bool.isRequired,
+ openMediaLibrary: PropTypes.func.isRequired,
+ removeInsertedMedia: PropTypes.func.isRequired,
+ closeEntry: PropTypes.func.isRequired,
+ fields: ImmutablePropTypes.list.isRequired,
+ slug: PropTypes.string,
+ newEntry: PropTypes.bool.isRequired,
+ displayUrl: PropTypes.string,
+ hasWorkflow: PropTypes.bool,
+ unpublishedEntry: PropTypes.bool,
+ isModification: PropTypes.bool,
+ collectionEntriesLoaded: PropTypes.bool,
+ updateUnpublishedEntryStatus: PropTypes.func.isRequired,
+ publishUnpublishedEntry: PropTypes.func.isRequired,
+ deleteUnpublishedEntry: PropTypes.func.isRequired,
+ currentStatus: PropTypes.string,
+ logoutUser: PropTypes.func.isRequired,
+ };
+
+ componentDidMount() {
+ const {
+ entry,
+ newEntry,
+ entryDraft,
+ collection,
+ slug,
+ loadEntry,
+ createEmptyDraft,
+ loadEntries,
+ collectionEntriesLoaded,
+ } = this.props;
+
+ if (newEntry) {
+ createEmptyDraft(collection);
+ } else {
+ loadEntry(collection, slug);
+ }
+
+ const leaveMessage = 'Are you sure you want to leave this page?';
+
+ this.exitBlocker = (event) => {
+ if (this.props.entryDraft.get('hasChanged')) {
+ // This message is ignored in most browsers, but its presence
+ // triggers the confirmation dialog
+ event.returnValue = leaveMessage;
+ return leaveMessage;
+ }
+ };
+ window.addEventListener('beforeunload', this.exitBlocker);
+
+ const navigationBlocker = (location, action) => {
+ /**
+ * New entry being saved and redirected to it's new slug based url.
+ */
+ const isPersisting = this.props.entryDraft.getIn(['entry', 'isPersisting']);
+ const newRecord = this.props.entryDraft.getIn(['entry', 'newRecord']);
+ const newEntryPath = `/collections/${collection.get('name')}/new`;
+ if (isPersisting && newRecord && this.props.location.pathname === newEntryPath && action === 'PUSH') {
+ return;
+ }
+
+ if (this.props.hasChanged) {
+ return leaveMessage;
+ }
+
+ };
+ const unblock = history.block(navigationBlocker);
+
+ /**
+ * This will run as soon as the location actually changes, unless creating
+ * a new post. The confirmation above will run first.
+ */
+ this.unlisten = history.listen((location, action) => {
+ const newEntryPath = `/collections/${collection.get('name')}/new`;
+ const entriesPath = `/collections/${collection.get('name')}/entries/`;
+ const { pathname } = location;
+ if (pathname.startsWith(newEntryPath) || pathname.startsWith(entriesPath) && action === 'PUSH') {
+ return;
+ }
+ unblock();
+ this.unlisten();
+ });
+
+ if (!collectionEntriesLoaded) {
+ loadEntries(collection);
+ }
+ }
+
+ componentWillReceiveProps(nextProps) {
+ /**
+ * If the old slug is empty and the new slug is not, a new entry was just
+ * saved, and we need to update navigation to the correct url using the
+ * slug.
+ */
+ const newSlug = nextProps.entryDraft && nextProps.entryDraft.getIn(['entry', 'slug']);
+ if (!this.props.slug && newSlug && nextProps.newEntry) {
+ navigateToEntry(this.props.collection.get('name'), newSlug);
+ nextProps.loadEntry(nextProps.collection, newSlug);
+ }
+
+ if (this.props.entry === nextProps.entry) return;
+ const { entry, newEntry, fields, collection } = nextProps;
+
+ if (entry && !entry.get('isFetching') && !entry.get('error')) {
+
+ /**
+ * Deserialize entry values for widgets with registered serializers before
+ * creating the entry draft.
+ */
+ const values = deserializeValues(entry.get('data'), fields);
+ const deserializedEntry = entry.set('data', values);
+ this.createDraft(deserializedEntry);
+ } else if (newEntry) {
+ this.props.createEmptyDraft(collection);
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.discardDraft();
+ window.removeEventListener('beforeunload', this.exitBlocker);
+ }
+
+ createDraft = (entry) => {
+ if (entry) this.props.createDraftFromEntry(entry);
+ };
+
+ handleChangeStatus = (newStatusName) => {
+ const { updateUnpublishedEntryStatus, collection, slug, currentStatus } = this.props;
+ const newStatus = status.get(newStatusName);
+ this.props.updateUnpublishedEntryStatus(collection.get('name'), slug, currentStatus, newStatus);
+ }
+
+ handlePersistEntry = async (opts = {}) => {
+ const { createNew = false } = opts;
+ const { persistEntry, collection, entryDraft, newEntry, currentStatus, hasWorkflow, loadEntry, slug, createEmptyDraft } = this.props;
+
+ await persistEntry(collection)
+
+ if (createNew) {
+ navigateToNewEntry(collection.get('name'));
+ createEmptyDraft(collection);
+ }
+ else if (slug && hasWorkflow && !currentStatus) {
+ loadEntry(collection, slug);
+ }
+ };
+
+ handlePublishEntry = async (opts = {}) => {
+ const { createNew = false } = opts;
+ const { publishUnpublishedEntry, entryDraft, collection, slug, currentStatus, loadEntry } = this.props;
+ if (currentStatus !== status.last()) {
+ window.alert('Please update status to "Ready" before publishing.');
+ return;
+ } else if (!window.confirm('Are you sure you want to publish this entry?')) {
+ return;
+ } else if (entryDraft.get('hasChanged')) {
+ if (window.confirm('Your unsaved changes will be saved before publishing. Are you sure you want to publish?')) {
+ await persistEntry(collection);
+ } else {
+ return;
+ }
+ }
+
+ await publishUnpublishedEntry(collection.get('name'), slug);
+
+ if (createNew) {
+ navigateToNewEntry(collection.get('name'));
+ }
+ else {
+ loadEntry(collection, slug);
+ }
+ };
+
+ handleDeleteEntry = () => {
+ const { entryDraft, newEntry, collection, deleteEntry, slug } = this.props;
+ if (entryDraft.get('hasChanged')) {
+ if (!window.confirm('Are you sure you want to delete this published entry, as well as your unsaved changes from the current session?')) {
+ return;
+ }
+ } else if (!window.confirm('Are you sure you want to delete this published entry?')) {
+ return;
+ }
+ if (newEntry) {
+ return navigateToCollection(collection.get('name'));
+ }
+
+ setTimeout(async () => {
+ await deleteEntry(collection, slug);
+ return navigateToCollection(collection.get('name'));
+ }, 0);
+ };
+
+ handleDeleteUnpublishedChanges = async () => {
+ const { entryDraft, collection, slug, deleteUnpublishedEntry, loadEntry, isModification } = this.props;
+ if (entryDraft.get('hasChanged') && !window.confirm('This will delete all unpublished changes to this entry, as well as your unsaved changes from the current session. Do you still want to delete?')) {
+ return;
+ } else if (!window.confirm('All unpublished changes to this entry will be deleted. Do you still want to delete?')) {
+ return;
+ }
+ await deleteUnpublishedEntry(collection.get('name'), slug);
+
+ if (isModification) {
+ loadEntry(collection, slug);
+ } else {
+ navigateToCollection(collection.get('name'));
+ }
+ };
+
+ render() {
+ const {
+ entry,
+ entryDraft,
+ fields,
+ mediaPaths,
+ boundGetAsset,
+ collection,
+ changeDraftField,
+ changeDraftFieldValidation,
+ openMediaLibrary,
+ addAsset,
+ removeInsertedMedia,
+ user,
+ hasChanged,
+ displayUrl,
+ hasWorkflow,
+ unpublishedEntry,
+ newEntry,
+ isModification,
+ currentStatus,
+ logoutUser,
+ } = this.props;
+
+ if (entry && entry.get('error')) {
+ return { entry.get('error') }
;
+ } else if (entryDraft == null
+ || entryDraft.get('entry') === undefined
+ || (entry && entry.get('isFetching'))) {
+ return Loading entry...;
+ }
+
+ return (
+
+ );
+ }
+}
+
+function mapStateToProps(state, ownProps) {
+ const { collections, entryDraft, mediaLibrary, auth, config, entries } = state;
+ const slug = ownProps.match.params.slug;
+ const collection = collections.get(ownProps.match.params.name);
+ const collectionName = collection.get('name');
+ const newEntry = ownProps.newRecord === true;
+ const fields = selectFields(collection, slug);
+ const entry = newEntry ? null : selectEntry(state, collectionName, slug);
+ const boundGetAsset = getAsset.bind(null, state);
+ const mediaPaths = mediaLibrary.get('controlMedia');
+ const user = auth && auth.get('user');
+ const hasChanged = entryDraft.get('hasChanged');
+ const displayUrl = config.get('display_url');
+ const hasWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
+ const isModification = entryDraft.getIn(['entry', 'isModification']);
+ const collectionEntriesLoaded = !!entries.getIn(['entities', collectionName])
+ const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
+ const currentStatus = unpublishedEntry && unpublishedEntry.getIn(['metaData', 'status']);
+ return {
+ collection,
+ collections,
+ newEntry,
+ entryDraft,
+ mediaPaths,
+ boundGetAsset,
+ fields,
+ slug,
+ entry,
+ user,
+ hasChanged,
+ displayUrl,
+ hasWorkflow,
+ isModification,
+ collectionEntriesLoaded,
+ currentStatus,
+ };
+}
+
+export default connect(
+ mapStateToProps,
+ {
+ changeDraftField,
+ changeDraftFieldValidation,
+ openMediaLibrary,
+ removeInsertedMedia,
+ addAsset,
+ loadEntry,
+ loadEntries,
+ createDraftFromEntry,
+ createEmptyDraft,
+ discardDraft,
+ persistEntry,
+ deleteEntry,
+ updateUnpublishedEntryStatus,
+ publishUnpublishedEntry,
+ deleteUnpublishedEntry,
+ logoutUser,
+ }
+)(withWorkflow(Editor));
diff --git a/src/components/Editor/EditorControlPane/EditorControl.css b/src/components/Editor/EditorControlPane/EditorControl.css
new file mode 100644
index 000000000000..432eb70b227e
--- /dev/null
+++ b/src/components/Editor/EditorControlPane/EditorControl.css
@@ -0,0 +1,7 @@
+.nc-controlPane-control {
+ margin-top: 16px;
+
+ &:first-child {
+ margin-top: 36px;
+ }
+}
diff --git a/src/components/Editor/EditorControlPane/EditorControl.js b/src/components/Editor/EditorControlPane/EditorControl.js
new file mode 100644
index 000000000000..4b8370b169b6
--- /dev/null
+++ b/src/components/Editor/EditorControlPane/EditorControl.js
@@ -0,0 +1,83 @@
+import React from 'react';
+import { partial } from 'lodash';
+import c from 'classnames';
+import { resolveWidget } from 'Lib/registry';
+import Widget from './Widget';
+
+export default class EditorControl extends React.Component {
+ state = {
+ activeLabel: false,
+ };
+
+ render() {
+ const {
+ value,
+ field,
+ fieldsMetaData,
+ fieldsErrors,
+ mediaPaths,
+ getAsset,
+ onChange,
+ onOpenMediaLibrary,
+ onAddAsset,
+ onRemoveInsertedMedia,
+ onValidate,
+ processControlRef,
+ } = this.props;
+ const widgetName = field.get('widget');
+ const widget = resolveWidget(widgetName);
+ const fieldName = field.get('name');
+ const metadata = fieldsMetaData && fieldsMetaData.get(fieldName);
+ const errors = fieldsErrors && fieldsErrors.get(fieldName);
+ return (
+
+
+ {
+ errors && errors.map(error =>
+ error.message &&
+ typeof error.message === 'string' &&
+ - {error.message}
+ )
+ }
+
+
+
onChange(fieldName, newValue, newMetadata)}
+ onValidate={onValidate && partial(onValidate, fieldName)}
+ onOpenMediaLibrary={onOpenMediaLibrary}
+ onRemoveInsertedMedia={onRemoveInsertedMedia}
+ onAddAsset={onAddAsset}
+ getAsset={getAsset}
+ hasActiveStyle={this.state.styleActive}
+ setActiveStyle={() => this.setState({ styleActive: true })}
+ setInactiveStyle={() => this.setState({ styleActive: false })}
+ ref={processControlRef && partial(processControlRef, fieldName)}
+ editorControl={EditorControl}
+ />
+
+ );
+ }
+}
diff --git a/src/components/Editor/EditorControlPane/EditorControlPane.css b/src/components/Editor/EditorControlPane/EditorControlPane.css
new file mode 100644
index 000000000000..1c773401f343
--- /dev/null
+++ b/src/components/Editor/EditorControlPane/EditorControlPane.css
@@ -0,0 +1,106 @@
+:root {
+ --controlPaneLabel: {
+ display: inline-block;
+ color: var(--controlLabelColor);
+ font-size: 12px;
+ text-transform: uppercase;
+ font-weight: 600;
+ background-color: var(--textFieldBorderColor);
+ border: 0;
+ border-radius: 3px 3px 0 0;
+ padding: 3px 6px 2px;
+ margin: 0;
+ transition: all var(--transition);
+ position: relative;
+
+ /**
+ * Faux outside curve into top of input
+ */
+ &:before,
+ &:after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 0;
+ right: -4px;
+ height: 100%;
+ width: 4px;
+ background-color: inherit;
+ }
+
+ &:after {
+ border-bottom-left-radius: 3px;
+ background-color: #fff;
+ }
+ }
+
+ --controlPaneWidget: {
+ display: block;
+ width: 100%;
+ padding: var(--inputPadding);
+ margin: 0;
+ border: var(--textFieldBorder);
+ border-radius: var(--borderRadius);
+ border-top-left-radius: 0;
+ outline: 0;
+ box-shadow: none;
+ background-color: var(--colorInputBackground);
+ color: #444a57;
+ transition: border-color var(--transition);
+ position: relative;
+ font-size: 15px;
+ line-height: 1.5;
+ }
+}
+
+.nc-controlPane-root {
+ max-width: 800px;
+ margin: 0 auto;
+
+ & p {
+ font-size: 16px;
+ }
+}
+
+.nc-controlPane-label {
+ @apply(--controlPaneLabel);
+}
+
+.nc-controlPane-labelActive {
+ background-color: var(--colorActive);
+ color: var(--colorTextLight);
+}
+
+.nc-controlPane-widget {
+ @apply(--controlPaneWidget);
+
+ &.nc-controlPane-widgetActive {
+ border-color: var(--colorActive);
+ }
+}
+
+select.nc-controlPane-widget {
+ text-indent: 14px;
+ height: 58px;
+}
+
+.nc-controlPane-labelWithError {
+ background-color: var(--colorErrorText);
+ color: #fff;
+}
+
+.nc-controlPane-widgetError {
+ border-color: var(--colorErrorText);
+}
+
+.nc-controlPane-errors {
+ list-style-type: none;
+ font-size: 12px;
+ color: var(--colorErrorText);
+ margin-bottom: 5px;
+ text-align: right;
+ text-transform: uppercase;
+ position: relative;
+ font-weight: 600;
+ top: 20px;
+}
diff --git a/src/components/Editor/EditorControlPane/EditorControlPane.js b/src/components/Editor/EditorControlPane/EditorControlPane.js
new file mode 100644
index 000000000000..41f0cf983f52
--- /dev/null
+++ b/src/components/Editor/EditorControlPane/EditorControlPane.js
@@ -0,0 +1,82 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import EditorControl from './EditorControl';
+
+export default class ControlPane extends React.Component {
+ componentValidate = {};
+
+ processControlRef = (fieldName, wrappedControl) => {
+ if (!wrappedControl) return;
+ this.componentValidate[fieldName] = wrappedControl.validate;
+ };
+
+ validate = () => {
+ this.props.fields.forEach((field) => {
+ if (field.get('widget') === 'hidden') return;
+ this.componentValidate[field.get("name")]();
+ });
+ };
+
+ render() {
+ const {
+ collection,
+ fields,
+ entry,
+ fieldsMetaData,
+ fieldsErrors,
+ mediaPaths,
+ getAsset,
+ onChange,
+ onOpenMediaLibrary,
+ onAddAsset,
+ onRemoveInsertedMedia,
+ onValidate,
+ } = this.props;
+
+ if (!collection || !fields) {
+ return null;
+ }
+
+ if (entry.size === 0 || entry.get('partial') === true) {
+ return null;
+ }
+
+ return (
+
+ {fields.map((field, i) => field.get('widget') === 'hidden' ? null :
+
+ )}
+
+ );
+ }
+}
+
+ControlPane.propTypes = {
+ collection: ImmutablePropTypes.map.isRequired,
+ entry: ImmutablePropTypes.map.isRequired,
+ fields: ImmutablePropTypes.list.isRequired,
+ fieldsMetaData: ImmutablePropTypes.map.isRequired,
+ fieldsErrors: ImmutablePropTypes.map.isRequired,
+ mediaPaths: ImmutablePropTypes.map.isRequired,
+ getAsset: PropTypes.func.isRequired,
+ onOpenMediaLibrary: PropTypes.func.isRequired,
+ onAddAsset: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onValidate: PropTypes.func.isRequired,
+ onRemoveInsertedMedia: PropTypes.func.isRequired,
+};
diff --git a/src/components/Widgets/ControlHOC.js b/src/components/Editor/EditorControlPane/Widget.js
similarity index 70%
rename from src/components/Widgets/ControlHOC.js
rename to src/components/Editor/EditorControlPane/Widget.js
index 5016cb39e7ec..75c18bbb836d 100644
--- a/src/components/Widgets/ControlHOC.js
+++ b/src/components/Editor/EditorControlPane/Widget.js
@@ -1,15 +1,24 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ImmutablePropTypes from "react-immutable-proptypes";
-import ValidationErrorTypes from '../../constants/validationErrorTypes';
+import { Map } from 'immutable';
+import ValidationErrorTypes from 'Constants/validationErrorTypes';
const truthy = () => ({ error: false });
-class ControlHOC extends Component {
-
+export default class Widget extends Component {
static propTypes = {
controlComponent: PropTypes.func.isRequired,
field: ImmutablePropTypes.map.isRequired,
+ hasActiveStyle: PropTypes.bool,
+ setActiveStyle: PropTypes.func.isRequired,
+ setInactiveStyle: PropTypes.func.isRequired,
+ className: PropTypes.string.isRequired,
+ classNameWrapper: PropTypes.string.isRequired,
+ classNameWidget: PropTypes.string.isRequired,
+ classNameWidgetActive: PropTypes.string.isRequired,
+ classNameLabel: PropTypes.string.isRequired,
+ classNameLabelActive: PropTypes.string.isRequired,
value: PropTypes.oneOfType([
PropTypes.node,
PropTypes.object,
@@ -22,7 +31,7 @@ class ControlHOC extends Component {
onValidate: PropTypes.func,
onOpenMediaLibrary: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
- onRemoveAsset: PropTypes.func.isRequired,
+ onRemoveInsertedMedia: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
};
@@ -33,7 +42,9 @@ class ControlHOC extends Component {
if (this.wrappedControlShouldComponentUpdate) {
return this.wrappedControlShouldComponentUpdate(nextProps);
}
- return this.props.value !== nextProps.value;
+ return this.props.value !== nextProps.value
+ || this.props.classNameWrapper !== nextProps.classNameWrapper
+ || this.props.hasActiveStyle !== nextProps.hasActiveStyle;
}
processInnerControlRef = ref => {
@@ -136,6 +147,21 @@ class ControlHOC extends Component {
return { error: false };
};
+ /**
+ * In case the `onChangeObject` function is frozen by a child widget implementation,
+ * e.g. when debounced, always get the latest object value instead of using
+ * `this.props.value` directly.
+ */
+ getObjectValue = () => this.props.value || Map();
+
+ /**
+ * Change handler for fields that are nested within another field.
+ */
+ onChangeObject = (fieldName, newValue, newMetadata) => {
+ const newObjectValue = this.getObjectValue().set(fieldName, newValue);
+ return this.props.onChange(newObjectValue, newMetadata);
+ };
+
render() {
const {
controlComponent,
@@ -146,8 +172,17 @@ class ControlHOC extends Component {
onChange,
onOpenMediaLibrary,
onAddAsset,
- onRemoveAsset,
- getAsset
+ onRemoveInsertedMedia,
+ getAsset,
+ classNameWrapper,
+ classNameWidget,
+ classNameWidgetActive,
+ classNameLabel,
+ classNameLabelActive,
+ setActiveStyle,
+ setInactiveStyle,
+ hasActiveStyle,
+ editorControl,
} = this.props;
return React.createElement(controlComponent, {
field,
@@ -155,14 +190,22 @@ class ControlHOC extends Component {
mediaPaths,
metadata,
onChange,
+ onChangeObject: this.onChangeObject,
onOpenMediaLibrary,
onAddAsset,
- onRemoveAsset,
+ onRemoveInsertedMedia,
getAsset,
forID: field.get('name'),
ref: this.processInnerControlRef,
+ classNameWrapper,
+ classNameWidget,
+ classNameWidgetActive,
+ classNameLabel,
+ classNameLabelActive,
+ setActiveStyle,
+ setInactiveStyle,
+ hasActiveStyle,
+ editorControl,
});
}
}
-
-export default ControlHOC;
diff --git a/src/components/Editor/EditorInterface.css b/src/components/Editor/EditorInterface.css
new file mode 100644
index 000000000000..c8a5d2a3c260
--- /dev/null
+++ b/src/components/Editor/EditorInterface.css
@@ -0,0 +1,78 @@
+/**
+ * React Split Pane
+ */
+.Resizer.vertical {
+ width: 21px;
+ cursor: col-resize;
+ position: relative;
+ transition: background-color var(--transition);
+
+ &:before {
+ content: '';
+ width: 1px;
+ height: 100%;
+ position: relative;
+ left: 10px;
+ background-color: var(--textFieldBorderColor);
+ display: block;
+ }
+
+ &:hover,
+ &:active {
+ background-color: var(--colorGrayLight);
+ }
+}
+
+/* Quick fix for preview pane not fully displaying in Safari */
+.SplitPane .Pane {
+ height: 100%;
+}
+
+.SplitPane,
+.nc-entryEditor-noPreviewEditorContainer {
+ @apply(--card);
+ border-radius: 0;
+ height: 100%;
+}
+
+.nc-entryEditor-containerOuter {
+ width: 100%;
+ min-width: 800px;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ overflow: hidden;
+ padding-top: 66px;
+ background-color: var(--colorBackground);
+}
+
+.nc-entryEditor-container {
+ max-width: 1600px;
+ height: 100%;
+ margin: 0 auto;
+ position: relative;
+}
+
+.nc-entryEditor-controlPane,
+.nc-entryEditor-previewPane {
+ height: 100%;
+ overflow-y: auto;
+}
+
+.nc-entryEditor-controlPane {
+ padding: 0 16px 16px;
+ position: relative;
+ overflow-x: hidden;
+}
+
+.nc-entryEditor-viewControls {
+ position: absolute;
+ top: 10px;
+ right: -10px;
+ z-index: 299;
+}
+
+.nc-entryEditor-blocker > * {
+ pointer-events: none;
+}
diff --git a/src/components/Editor/EditorInterface.js b/src/components/Editor/EditorInterface.js
new file mode 100644
index 000000000000..7afe6572a3f7
--- /dev/null
+++ b/src/components/Editor/EditorInterface.js
@@ -0,0 +1,224 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import SplitPane from 'react-split-pane';
+import classnames from 'classnames';
+import { ScrollSync, ScrollSyncPane } from './EditorScrollSync';
+import { Icon } from 'UI'
+import EditorControlPane from './EditorControlPane/EditorControlPane';
+import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane';
+import EditorToolbar from './EditorToolbar';
+import EditorToggle from './EditorToggle';
+
+const PREVIEW_VISIBLE = 'cms.preview-visible';
+const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled';
+
+class EditorInterface extends Component {
+ state = {
+ showEventBlocker: false,
+ previewVisible: localStorage.getItem(PREVIEW_VISIBLE) !== "false",
+ scrollSyncEnabled: localStorage.getItem(SCROLL_SYNC_ENABLED) !== "false",
+ };
+
+ handleSplitPaneDragStart = () => {
+ this.setState({ showEventBlocker: true });
+ };
+
+ handleSplitPaneDragFinished = () => {
+ this.setState({ showEventBlocker: false });
+ };
+
+ handleOnPersist = (opts = {}) => {
+ const { createNew = false } = opts;
+ this.controlPaneRef.validate();
+ this.props.onPersist({ createNew });
+ };
+
+ handleOnPublish = (opts = {}) => {
+ const { createNew = false } = opts;
+ this.controlPaneRef.validate();
+ this.props.onPublish({ createNew });
+ };
+
+ handleTogglePreview = () => {
+ const newPreviewVisible = !this.state.previewVisible;
+ this.setState({ previewVisible: newPreviewVisible });
+ localStorage.setItem(PREVIEW_VISIBLE, newPreviewVisible);
+ };
+
+ handleToggleScrollSync = () => {
+ const newScrollSyncEnabled = !this.state.scrollSyncEnabled;
+ this.setState({ scrollSyncEnabled: newScrollSyncEnabled });
+ localStorage.setItem(SCROLL_SYNC_ENABLED, newScrollSyncEnabled);
+ };
+
+ render() {
+ const {
+ collection,
+ entry,
+ fields,
+ fieldsMetaData,
+ fieldsErrors,
+ mediaPaths,
+ getAsset,
+ onChange,
+ enableSave,
+ showDelete,
+ onDelete,
+ onDeleteUnpublishedChanges,
+ onChangeStatus,
+ onPublish,
+ onValidate,
+ onOpenMediaLibrary,
+ onAddAsset,
+ onRemoveInsertedMedia,
+ user,
+ hasChanged,
+ displayUrl,
+ hasWorkflow,
+ hasUnpublishedChanges,
+ isNewEntry,
+ isModification,
+ currentStatus,
+ onLogoutClick,
+ } = this.props;
+
+ const { previewVisible, scrollSyncEnabled, showEventBlocker } = this.state;
+
+ const collectionPreviewEnabled = collection.getIn(['editor', 'preview'], true);
+
+ const editor = (
+
+ this.controlPaneRef = c} // eslint-disable-line
+ />
+
+ );
+
+ const editorWithPreview = (
+
+
+
+ );
+
+ const editorWithoutPreview = (
+
+ {editor}
+
+ );
+
+ return (
+
+
this.handleOnPersist({ createNew: true })}
+ onDelete={onDelete}
+ onDeleteUnpublishedChanges={onDeleteUnpublishedChanges}
+ onChangeStatus={onChangeStatus}
+ showDelete={showDelete}
+ onPublish={onPublish}
+ onPublishAndNew={() => this.handleOnPublish({ createNew: true })}
+ enableSave={enableSave}
+ user={user}
+ hasChanged={hasChanged}
+ displayUrl={displayUrl}
+ collection={collection}
+ hasWorkflow={hasWorkflow}
+ hasUnpublishedChanges={hasUnpublishedChanges}
+ isNewEntry={isNewEntry}
+ isModification={isModification}
+ currentStatus={currentStatus}
+ onLogoutClick={onLogoutClick}
+ />
+
+
+
+
+
+ {
+ collectionPreviewEnabled && this.state.previewVisible
+ ? editorWithPreview
+ : editorWithoutPreview
+ }
+
+
+ );
+ }
+}
+
+EditorInterface.propTypes = {
+ collection: ImmutablePropTypes.map.isRequired,
+ entry: ImmutablePropTypes.map.isRequired,
+ fields: ImmutablePropTypes.list.isRequired,
+ fieldsMetaData: ImmutablePropTypes.map.isRequired,
+ fieldsErrors: ImmutablePropTypes.map.isRequired,
+ mediaPaths: ImmutablePropTypes.map.isRequired,
+ getAsset: PropTypes.func.isRequired,
+ onOpenMediaLibrary: PropTypes.func.isRequired,
+ onAddAsset: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onValidate: PropTypes.func.isRequired,
+ onPersist: PropTypes.func.isRequired,
+ enableSave: PropTypes.bool.isRequired,
+ showDelete: PropTypes.bool.isRequired,
+ onDelete: PropTypes.func.isRequired,
+ onDeleteUnpublishedChanges: PropTypes.func.isRequired,
+ onPublish: PropTypes.func.isRequired,
+ onChangeStatus: PropTypes.func.isRequired,
+ onRemoveInsertedMedia: PropTypes.func.isRequired,
+ user: ImmutablePropTypes.map,
+ hasChanged: PropTypes.bool,
+ displayUrl: PropTypes.string,
+ hasWorkflow: PropTypes.bool,
+ hasUnpublishedChanges: PropTypes.bool,
+ isNewEntry: PropTypes.bool,
+ isModification: PropTypes.bool,
+ currentStatus: PropTypes.string,
+ onLogoutClick: PropTypes.func.isRequired,
+};
+
+export default EditorInterface;
diff --git a/src/components/PreviewPane/Preview.js b/src/components/Editor/EditorPreviewPane/EditorPreview.js
similarity index 100%
rename from src/components/PreviewPane/Preview.js
rename to src/components/Editor/EditorPreviewPane/EditorPreview.js
diff --git a/src/components/PreviewPane/PreviewContent.js b/src/components/Editor/EditorPreviewPane/EditorPreviewContent.js
similarity index 92%
rename from src/components/PreviewPane/PreviewContent.js
rename to src/components/Editor/EditorPreviewPane/EditorPreviewContent.js
index a8bae522289c..c7a466bad308 100644
--- a/src/components/PreviewPane/PreviewContent.js
+++ b/src/components/Editor/EditorPreviewPane/EditorPreviewContent.js
@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
-import { ScrollSyncPane } from '../ScrollSync';
+import { ScrollSyncPane } from 'react-scroll-sync';
/**
* We need to create a lightweight component here so that we can access the
diff --git a/src/components/PreviewPane/PreviewPane.css b/src/components/Editor/EditorPreviewPane/EditorPreviewPane.css
similarity index 70%
rename from src/components/PreviewPane/PreviewPane.css
rename to src/components/Editor/EditorPreviewPane/EditorPreviewPane.css
index e91d1e105254..9b92a1f7adeb 100644
--- a/src/components/PreviewPane/PreviewPane.css
+++ b/src/components/Editor/EditorPreviewPane/EditorPreviewPane.css
@@ -3,4 +3,5 @@
height: 100%;
border: none;
background: #fff;
+ border-radius: var(--borderRadius);
}
diff --git a/src/components/PreviewPane/PreviewPane.js b/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js
similarity index 89%
rename from src/components/PreviewPane/PreviewPane.js
rename to src/components/Editor/EditorPreviewPane/EditorPreviewPane.js
index 0f13ddb1f239..fd7654be7e14 100644
--- a/src/components/PreviewPane/PreviewPane.js
+++ b/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js
@@ -3,14 +3,13 @@ import React from 'react';
import { List, Map } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Frame from 'react-frame-component';
-import registry from '../../lib/registry';
-import ErrorBoundary from '../UI/ErrorBoundary/ErrorBoundary';
-import { resolveWidget } from '../Widgets';
-import { selectTemplateName, selectInferedField } from '../../reducers/collections';
-import { INFERABLE_FIELDS } from '../../constants/fieldInference';
-import PreviewContent from './PreviewContent.js';
-import PreviewHOC from '../Widgets/PreviewHOC';
-import Preview from './Preview';
+import { resolveWidget, getPreviewTemplate, getPreviewStyles } from 'Lib/registry';
+import { ErrorBoundary } from 'UI';
+import { selectTemplateName, selectInferedField } from 'Reducers/collections';
+import { INFERABLE_FIELDS } from 'Constants/fieldInference';
+import EditorPreviewContent from './EditorPreviewContent.js';
+import PreviewHOC from './PreviewHOC';
+import EditorPreview from './EditorPreview';
export default class PreviewPane extends React.Component {
@@ -127,8 +126,8 @@ export default class PreviewPane extends React.Component {
}
const previewComponent =
- registry.getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) ||
- Preview;
+ getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) ||
+ EditorPreview;
this.inferFields();
@@ -138,7 +137,7 @@ export default class PreviewPane extends React.Component {
widgetsFor: this.widgetsFor,
};
- const styleEls = registry.getPreviewStyles()
+ const styleEls = getPreviewStyles()
.map((style, i) => );
if (!collection) {
@@ -152,10 +151,11 @@ export default class PreviewPane extends React.Component {