WIP - Global UI (#785)

* update top bar and collections sidebar UI

* update collection entries UI

* improve global layout

* merge search page into collection page

* enable new entry button

* search fixup

* wip -initial editor update

* update editor scrolling and markdown toolbar position

* wip

* editor toolbar progress

* editor toolbar wip

* finished basic editor toolbar

* add standalone toggle component

* improve markdown toolbar spacing

* add user avatar placeholder

* finish markdown toggle styling

* refactor icon setup, add new icons

* add new icons to markdown editor toolbar

* remove extra app container

* add markdown active mark style

* relation and text widget styling

* widget design updates, basic list/object design update

* widget style updates, image widget improvements

* refactor widget directory, fix file removal

* widget focus styles

* finish editor widget focus styles

* migrate media library modal to react-modal

* wip - migrate editor component form to modal

* wip - move editor component form to modal

* wip - embed plugin forms in the editor

* inline shortcode forms working

* disable react hot loading, its breaking things

* improve shortcode form styles

* make shortcode form collapsible, improve styling

* add close functionality to shortcode blocks

* improve base media library styling

* fix shortcode label

* migrate unstyled workflow to new UI

* wip - reorganizing everything

* more work moving everything

* finish more moving and eliminating stuff

* restructure, remove react-toolbox

* wip - removing old stuff, more restructure

* finish restructure

* wip - css arch

* switch back to test repo

* update react-datetime to ^2.11.0

* remove leftover react-toolbox button

* more restructuring clean-up

* fix UI component directory case

* wip -css editor control style

* wip - consolidate widget styles

* wip - use a single control renderer

* fixed object values breaking

* wip - editor control active styles

* pass control wrapper to widgets

* ensure branch name is trimmed

* wip - improve widget authoring support

* import Map to Widget component

* refactor toolbar buttons

* wip - more widget active styles

* break out editor toggle component

* add local scroll sync back

* update editor toggle icons

* limit editor control pane content width

* fix editor control spacing

* migrate markdown toolbar stickiness to css

* fix markdown toolbar border radius

* temporarily use test backend

* stop markdown toolbar from going to bottom

* restore disabled markdown toolbar buttons for raw

* test markdown widget without focus styles

* more widget updates

* remove card visuals from editor

* disable dragging editor split off screen

* use editorControl component for shortcode fields

* make header site link configurable

* add configurable collection descriptions

* temporarily add example assets

* add basic list view

* remove outdated css mixins

* add and implement search icon

* activate quick add menu

* visualize usable space in editor view

* fix entry close, other improvements

* wip - editorial workflow updates

* some dropshadow and other CSS tweaks

* workflow ui updates

* add worfklow card buttons

* fix workflow card button handlers

* some dropshadow and other CSS tweaks

* make workflow board wider

* center workflow and collection views

* add basic responsiveness

* a bunch of fun UI fixes! a BUNCH! (#875)

* give `.nc-entryEditor-toolbar-mainSection` left and right child divs

* a bunch of fun UI fixes! a BUNCH!

* remove obscure --buttonShadow

* revert to test repo

* fix not found page styling

* allow workflow publishing from any column

* disallow publishing from all columns, with feedback

* fix new entry button

* fix markdown state persisting across entries

* enable simple workflow save and new from editor

* update slug in address bar when saving new entry

* wip - workflow updates, deletion working

* add status change functionality to editor

* wip - improving status change from editor

* editor toolbar back button improvements, loading improvements, cleanup

* progress on the media library UI cleanup

* remove font smothing css

* a quick fix for these buttons

* tweaks

* progress on media library modal— broken FYI

* fix media library functionality, finish migrating footer

* remove media library footer files

* remove leftover css import

* fix media library

* editor publishing functionality complete (unstyled)

* remove leftover loader var from media library

* wip - editor publishing styles

* add status dropdown styling

* editor toolbar style updates

* editor toolbar state improvements

* progress on the media library UI cleanup, style improvements

* finish editorial workflow editor styling

* finish media library styling

* fix config

* add what-input to optimize focus styling

* fix button

* fix navigation blocking for simple workflow

* improve simple workflow publishing

* add avatar dropdown to editor top bar

* style github and test-repo auth pages

* add git gateway auth page styles

* improve editor error styling
This commit is contained in:
Shawn Erquhart 2017-12-07 12:37:10 -05:00 committed by GitHub
parent 41af113d5b
commit cfbf31b130
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
344 changed files with 6964 additions and 7415 deletions

View File

@ -7,7 +7,6 @@
"react"
],
"plugins": [
"react-hot-loader/babel",
"lodash",
["babel-plugin-transform-builtin-extend", {
"globals": ["Error"]

View File

@ -1,8 +0,0 @@
import { configure } from '@kadira/storybook';
import '../src/index.css';
function loadStories() {
require('../src/components/stories/');
}
configure(loadStories, module);

View File

@ -1 +0,0 @@
module.exports = require('../webpack.base.js');

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 356 KiB

View File

@ -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"

View File

@ -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' }),
],
};

View File

@ -1,5 +1,5 @@
import { currentBackend } from '../backends/backend';
import { actions as notifActions } from 'redux-notifications';
import { currentBackend } from 'Backends/backend';
const { notifSend } = notifActions;

View File

@ -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));
}

View File

@ -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";

View File

@ -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 });
};
}

View File

@ -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,
}));

View File

@ -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);

View File

@ -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 });
}

View File

@ -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 },
};
}

View File

@ -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) => {

View File

@ -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';
/*

View File

@ -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) {

View File

@ -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) {

View File

@ -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;
}
.nc-gitGatewayAuthenticationPage-message {
font-size: 1.1em;
margin: 20px 10px;
&:focus {
outline: none;
box-shadow: inset 0 0 0 2px var(--colorBlue);
}
}
}
.nc-gitGatewayAuthenticationPage-button {
padding: .25em 1em;
height: auto;
@apply(--button);
@apply(--dropShadowDeep);
@apply(--buttonDefault);
@apply(--buttonGray);
padding: 0 30px;
display: block;
margin-top: 20px;
margin-left: auto;
}
.nc-gitGatewayAuthenticationPage-errorMsg {
color: var(--colorErrorText);
}

View File

@ -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 <section className="nc-gitGatewayAuthenticationPage-root">
<Notifs CustomComponent={Toast} />
<Button className="nc-gitGatewayAuthenticationPage-button" raised onClick={this.handleIdentity}>
<button className="nc-gitGatewayAuthenticationPage-button" onClick={this.handleIdentity}>
Login with Netlify Identity
</Button>
</button>
</section>
}
return (
<section className="nc-gitGatewayAuthenticationPage-root">
<Card className="nc-gitGatewayAuthenticationPage-card">
<form onSubmit={this.handleLogin}>
<img src={logo} width={100} role="presentation" />
{error && <p>
<span className="nc-gitGatewayAuthenticationPage-errorMsg">{error}</span>
</p>}
{errors.server && <p>
<span className="nc-gitGatewayAuthenticationPage-errorMsg">{errors.server}</span>
</p>}
<Input
type="text"
label="Email"
name="email"
value={this.state.email}
error={errors.email}
onChange={this.handleChange.bind(this, "email")} // eslint-disable-line
/>
<Input
type="password"
label="Password"
name="password"
value={this.state.password}
error={errors.password}
onChange={this.handleChange.bind(this, "password")} // eslint-disable-line
/>
<Button
className="nc-gitGatewayAuthenticationPage-button"
raised
disabled={inProgress}
>
<Icon type="login" /> {inProgress ? "Logging in..." : "Login"}
</Button>
</form>
</Card>
<Icon className="nc-githubAuthenticationPage-logo" size="500px" type="netlify-cms"/>
<form className="nc-gitGatewayAuthenticationPage-form" onSubmit={this.handleLogin}>
{!error && <p>
<span className="nc-gitGatewayAuthenticationPage-errorMsg">{error}</span>
</p>}
{!errors.server && <p>
<span className="nc-gitGatewayAuthenticationPage-errorMsg">{errors.server}</span>
</p>}
<div className="nc-gitGatewayAuthenticationPage-errorMsg">{ errors.email || null }</div>
<input
type="text"
name="email"
placeholder="Email"
value={this.state.email}
onChange={partial(this.handleChange, 'email')}
/>
<div className="nc-gitGatewayAuthenticationPage-errorMsg">{ errors.password || null }</div>
<input
type="password"
name="password"
placeholder="Password"
value={this.state.password}
onChange={partial(this.handleChange, 'password')}
/>
<button className="nc-gitGatewayAuthenticationPage-button" disabled={inProgress}>
{inProgress ? "Logging in..." : "Login"}
</button>
</form>
</section>
);
}

View File

@ -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";

View File

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="295px" height="284px" viewBox="0 0 295 284" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Netlify</title>
<g transform="translate(149.500000, 142.500000) rotate(-315.000000) translate(-149.500000, -142.500000) translate(45.000000, 38.000000)">
<g transform="translate(4.000000, 4.000000)" fill="#3FB5A0">
<path d="M0,0 L200,0 L200,200 L0,200 L0,0 L0,0 Z" id="Shape"></path>
</g>
<g stroke="#FFFFFF" stroke-width="8">
<path d="M209,70 L0,209 L209,70 Z" id="Shape"></path>
<path d="M209,6 L0,93 L209,6 Z" id="Shape"></path>
<path d="M209,180 L0,145 L209,180 Z" id="Shape"></path>
<path d="M88,209 L43,0 L88,209 Z" id="Shape"></path>
<path d="M209,172 L89,0 L209,172 Z" id="Shape"></path>
<path d="M137,0 L57,209 L137,0 Z" id="Shape"></path>
</g>
<g transform="translate(43.000000, 33.000000)" fill="#FFFFFF">
<circle id="Oval" cx="14" cy="38" r="14"></circle>
<circle id="Oval" cx="77" cy="12" r="12"></circle>
<circle id="Oval" cx="116" cy="70" r="12"></circle>
<circle id="Oval" cx="35" cy="125" r="16"></circle>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -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);

View File

@ -6,7 +6,24 @@
height: 100vh;
}
.nc-githubAuthenticationPage-button {
padding: .25em 1em;
height: auto;
.nc-githubAuthenticationPage-logo {
color: #c4c6d2;
margin-top: -300px;
}
.nc-githubAuthenticationPage-button {
@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;
}
}

View File

@ -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 (
<section className="nc-githubAuthenticationPage-root">
<Icon className="nc-githubAuthenticationPage-logo" size="500px" type="netlify-cms"/>
<Notifs CustomComponent={Toast} />
{loginError && <p>{loginError}</p>}
<Button
<button
className="nc-githubAuthenticationPage-button"
raised
disabled={inProgress}
onClick={this.handleLogin}
>
<Icon type="github" /> {inProgress ? "Logging in..." : "Login with GitHub"}
</Button>
</button>
</section>
);
}

View File

@ -1,4 +1,4 @@
import AssetProxy from "../../../valueObjects/AssetProxy";
import AssetProxy from "ValueObjects/AssetProxy";
import API from "../API";
describe('github API', () => {

View File

@ -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 = '';
}

View File

@ -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 (<section className="nc-gitGatewayAuthenticationPage-root">
<Card className="nc-gitGatewayAuthenticationPage-card">
<img src={logo} width={100} role="presentation" />
<p className="nc-gitGatewayAuthenticationPage-message">This is a demo, enter your email to start</p>
<Input
type="text"
label="Email"
name="email"
value={this.state.email}
onChange={this.handleEmailChange}
/>
<Button
className="nc-gitGatewayAuthenticationPage-button"
raised
return (
<section className="nc-githubAuthenticationPage-root">
<Icon className="nc-githubAuthenticationPage-logo" size="500px" type="netlify-cms"/>
<button
className="nc-githubAuthenticationPage-button"
disabled={inProgress}
onClick={this.handleLogin}
>
<Icon type="login" /> {inProgress ? "Logging in..." : "Login"}
</Button>
</Card>
</section>);
{inProgress ? "Logging in..." : "Login"}
</button>
</section>
);
}
}

View File

@ -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() {

View File

@ -0,0 +1,8 @@
@import "./NotFoundPage.css";
@import "./Header.css";
.nc-app-main {
min-width: 800px;
max-width: 1440px;
margin: 0 auto;
}

180
src/components/App/App.js Normal file
View File

@ -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 (<div>
<h1>Error loading the CMS configuration</h1>
<div>
<p>The <code>config.yml</code> file could not be loaded or failed to parse properly.</p>
<p><strong>Error message:</strong> {config.get('error')}</p>
</div>
</div>);
}
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 <div><h1>Waiting for backend...</h1></div>;
}
return (
<div>
{
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)
})
}
</div>
);
}
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 <Loader active>Loading configuration...</Loader>;
}
if (user == null) {
return this.authenticating();
}
const defaultPath = `/collections/${collections.first().get('name')}`;
const hasWorkflow = publishMode === EDITORIAL_WORKFLOW;
return (
<div className="nc-app-container">
<Notifs CustomComponent={Toast} />
<Header
user={user}
collections={collections}
onCreateEntryClick={createNewEntry}
onLogoutClick={logoutUser}
openMediaLibrary={openMediaLibrary}
hasWorkflow={hasWorkflow}
displayUrl={config.get('display_url')}
/>
<div className="nc-app-main">
{ isFetching && <TopBarProgress /> }
<div>
<Switch>
<Redirect exact from="/" to={defaultPath} />
<Redirect exact from="/search/" to={defaultPath} />
{ hasWorkflow ? <Route path="/workflow" component={Workflow}/> : null }
<Route exact path="/collections/:name" component={Collection} />
<Route path="/collections/:name/new" render={props => <Editor {...props} newRecord />} />
<Route path="/collections/:name/entries/:slug" component={Editor} />
<Route path="/search/:searchTerm" render={props => <Collection {...props} isSearchResults />} />
<Route component={NotFoundPage} />
</Switch>
<MediaLibrary/>
</div>
</div>
</div>
);
}
}
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);

View File

@ -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);
}

View File

@ -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 (
<div className="nc-appHeader-container">
<div className="nc-appHeader-main">
<div className="nc-appHeader-content">
<nav>
<NavLink
to="/"
className="nc-appHeader-button"
activeClassName="nc-appHeader-button-active"
isActive={(match, location) => location.pathname.startsWith('/collections/')}
>
<Icon type="page"/>
Content
</NavLink>
{
hasWorkflow
? <NavLink to="/workflow" className="nc-appHeader-button" activeClassName="nc-appHeader-button-active">
<Icon type="workflow"/>
Workflow
</NavLink>
: null
}
<button onClick={openMediaLibrary} className="nc-appHeader-button">
<Icon type="media-alt"/>
Media
</button>
</nav>
<div className="nc-appHeader-actions">
<Dropdown
classNameButton="nc-appHeader-button nc-appHeader-quickNew"
label="Quick add"
dropdownTopOverlap="30px"
dropdownWidth="160px"
dropdownPosition="left"
>
{
collections.filter(collection => collection.get('create')).toList().map(collection =>
<DropdownItem
key={collection.get("name")}
label={collection.get("label")}
onClick={() => this.handleCreatePostClick(collection.get('name'))}
/>
)
}
</Dropdown>
{
displayUrl
? <a
className="nc-appHeader-siteLink"
href={displayUrl}
target="_blank"
>
{stripProtocol(displayUrl)}
</a>
: null
}
<Dropdown
dropdownTopOverlap="50px"
dropdownWidth="100px"
dropdownPosition="right"
button={
<button className="nc-appHeader-avatar">
{
avatarUrl
? <img className="nc-appHeader-avatar-image" src={user.get('avatar_url')}/>
: <Icon className="nc-appHeader-avatar-placeholder" type="user" size="large"/>
}
</button>
}
>
<DropdownItem label="Log Out" onClick={onLogoutClick}/>
</Dropdown>
</div>
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,3 @@
.nc-notFound-container {
margin: var(--pageMargin);
}

View File

@ -0,0 +1,7 @@
import React from 'react';
export default () => (
<div class="nc-notFound-container">
<h2>Not Found</h2>
</div>
);

View File

@ -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 */
}

View File

@ -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 (
<AppBar
fixed
theme={theme}
leftIcon="menu"
onLeftIconClick={toggleDrawer}
>
<Link to="/" className="nc-appHeader-button">
<FontIcon value="home" className="nc-appHeader-icon" />
</Link>
<button onClick={openMediaLibrary} className="nc-appHeader-button">
<FontIcon value="perm_media" className="nc-appHeader-icon" />
</button>
<IconMenu icon="add" theme={theme}>
{
collections.filter(collection => collection.get('create')).toList().map(collection =>
<MenuItem
key={collection.get("name")}
value={collection.get("name")}
onClick={this.handleCreatePostClick.bind(this, collection.get('name'))} // eslint-disable-line
caption={collection.get("label")}
/>
)
}
</IconMenu>
<FindBar runCommand={runCommand} />
<Avatar style={avatarStyle} title={user.get("name")} image={user.get("avatar_url")} />
<IconMenu icon="settings" position="topRight" theme={theme}>
<MenuItem onClick={onLogoutClick} value="log out" caption="Log Out" />
</IconMenu>
</AppBar>
);
}
}

View File

@ -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;
}

View File

@ -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 <EntriesCollection collection={collection} name={name} viewStyle={this.state.viewStyle}/>
};
renderEntriesSearch = () => {
const { searchTerm, collections } = this.props;
return <EntriesSearch collections={collections} searchTerm={searchTerm} />
};
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 (
<div className="nc-collectionPage-container">
<Sidebar collections={collections} searchTerm={searchTerm}/>
<div className="nc-collectionPage-main">
{
isSearchResults
? null
: <CollectionTop
collectionLabel={collection.get('label')}
collectionDescription={collection.get('description')}
newEntryUrl={newEntryUrl}
viewStyle={this.state.viewStyle}
onChangeViewStyle={this.handleChangeViewStyle}
/>
}
{ isSearchResults ? this.renderEntriesSearch() : this.renderEntriesCollection() }
</div>
</div>
);
}
}
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);

View File

@ -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);
}

View File

@ -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 (
<div className="nc-collectionPage-top">
<div className="nc-collectionPage-top-row">
<h1 className="nc-collectionPage-topHeading">{collectionLabel}</h1>
{
newEntryUrl
? <Link className="nc-collectionPage-topNewButton" to={newEntryUrl}>
{`New ${collectionLabel}`}
</Link>
: null
}
</div>
{
collectionDescription
? <p className="nc-collectionPage-top-description">{collectionDescription}</p>
: null
}
<div className={c('nc-collectionPage-top-viewControls', {
'nc-collectionPage-top-viewControls-noDescription': !collectionDescription,
})}>
<span className="nc-collectionPage-top-viewControls-text">View as:</span>
<button
className={c('nc-collectionPage-top-viewControls-button', {
'nc-collectionPage-top-viewControls-buttonActive': viewStyle === VIEW_STYLE_LIST,
})}
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
>
<Icon type="list"/>
</button>
<button
className={c('nc-collectionPage-top-viewControls-button', {
'nc-collectionPage-top-viewControls-buttonActive': viewStyle === VIEW_STYLE_GRID,
})}
onClick={() => onChangeViewStyle(VIEW_STYLE_GRID)}
>
<Icon type="grid"/>
</button>
</div>
</div>
);
};
CollectionTop.propTypes = {
collectionLabel: PropTypes.string.isRequired,
collectionDescription: PropTypes.string,
newEntryUrl: PropTypes.string
};
export default CollectionTop;

View File

@ -0,0 +1,2 @@
@import "./EntryListing.css";
@import "./EntryCard.css";

View File

@ -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 (
<EntryListing
collections={collections}
entries={entries}
publicFolder={publicFolder}
page={page}
onPaginate={onPaginate}
viewStyle={viewStyle}
/>
);
}
if (isFetching) {
return <Loader active>{loadingMessages}</Loader>;
}
return <div className="nc-collectionPage-noEntries">No Entries</div>;
}
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;

View File

@ -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 (
<Entries
collections={collection}
entries={entries}
publicFolder={publicFolder}
page={page}
onPaginate={() => 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);

View File

@ -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 (<div>
{(isFetching === true || !entries) ?
<Loader active>{['Loading Entries', 'Caching Entries', 'This might take several minutes']}</Loader>
:
<EntryListing
collections={collections}
entries={entries}
page={page}
publicFolder={publicFolder}
onPaginate={this.handleLoadMore}
>
Results for {searchTerm}
</EntryListing>
}
</div>);
render () {
const { dispatch, collections, entries, publicFolder, page, isFetching } = this.props;
return (
<Entries
collections={collections}
entries={entries}
publicFolder={publicFolder}
page={page}
onPaginate={this.handleLoadMore}
isFetching={isFetching}
/>
);
}
}
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);

View File

@ -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;
}

View File

@ -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 (
<Link to={path} className="nc-entryListing-listCard">
<h2 className="nc-entryListing-listCard-title">{title}</h2>
</Link>
);
}
if (viewStyle === VIEW_STYLE_GRID) {
return (
<Link to={path} className="nc-entryListing-gridCard">
<div className={c('nc-entryListing-cardBody', { 'nc-entryListing-cardBody-full': !image })}>
<h2 className="nc-entryListing-cardHeading">{title}</h2>
</div>
{
image
? <div
className="nc-entryListing-cardImage"
style={{ backgroundImage: `url(${ image })` }}
/>
: null
}
</Link>
);
}
}
export default EntryCard;

View File

@ -0,0 +1,9 @@
.nc-entryListing-cardsGrid {
display: flex;
flex-flow: row wrap;
margin-left: -12px;
}
.nc-entryListing-cardsList {
margin-left: -12px;
}

View File

@ -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) => <EntryCard {...{ ...entryCardProps, entry, key: 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 <EntryCard {...entryCardProps}/>;
});
};
render() {
const { collections, entries, publicFolder } = this.props;
return (
<div>
<div className="nc-entryListing-cardsGrid">
{
Map.isMap(collections)
? this.renderCardsForSingleCollection()
: this.renderCardsForMultipleCollections()
}
<Waypoint onEnter={this.handleLoadMore} />
</div>
</div>
);
}
}

View File

@ -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;
}
}

View File

@ -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 (
<NavLink
key={collectionName}
to={`/collections/${collectionName}`}
className="nc-collectionPage-sidebarLink"
activeClassName="nc-collectionPage-sidebarLink-active"
>
<Icon type="write"/>
{collection.get('label')}
</NavLink>
);
};
render() {
const { collections } = this.props;
const { query } = this.state;
return (
<div className="nc-collectionPage-sidebar">
<h1 className="nc-collectionPage-sidebarHeading">Collections</h1>
<div className="nc-collectionPage-sidebarSearch">
<Icon type="search" size="small"/>
<input
onChange={e => this.setState({ query: e.target.value })}
onKeyDown={e => e.key === 'Enter' && searchCollections(query)}
placeholder="Search all"
value={query}
/>
</div>
{collections.toList().map(this.renderLink)}
</div>
);
}
}

View File

@ -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;
}

View File

@ -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 (
<div className="nc-controlPane-control">
<label className={labelClass} htmlFor={fieldName}>{field.get('label')}</label>
<ul className="nc-controlPane-errors">
{
errors && errors.map(error =>
error.message &&
typeof error.message === 'string' &&
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>{error.message}</li>
)
}
</ul>
<ControlHOC
controlComponent={widget.control}
field={field}
value={value}
mediaPaths={mediaPaths}
metadata={metadata}
onChange={(newValue, newMetadata) => 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)}
/>
</div>
);
}
render() {
const { collection, fields } = this.props;
if (!collection || !fields) {
return null;
}
return (
<div className="nc-controlPane-root">
{
fields.map((field, i) => {
if (isHidden(field)) {
return null;
}
return <div key={i} className="nc-controlPane-widget">{this.controlFor(field)}</div>;
})
}
</div>
);
}
}
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,
};

View File

@ -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";

View File

@ -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 <div><h3>{ entry.get('error') }</h3></div>;
} else if (entryDraft == null
|| entryDraft.get('entry') === undefined
|| (entry && entry.get('isFetching'))) {
return <Loader active>Loading entry...</Loader>;
}
return (
<EditorInterface
entry={entryDraft.get('entry')}
getAsset={boundGetAsset}
collection={collection}
fields={fields}
fieldsMetaData={entryDraft.get('fieldsMetaData')}
fieldsErrors={entryDraft.get('fieldsErrors')}
mediaPaths={mediaPaths}
onChange={changeDraftField}
onValidate={changeDraftFieldValidation}
onOpenMediaLibrary={openMediaLibrary}
onAddAsset={addAsset}
onRemoveInsertedMedia={removeInsertedMedia}
onPersist={this.handlePersistEntry}
onDelete={this.handleDeleteEntry}
onDeleteUnpublishedChanges={this.handleDeleteUnpublishedChanges}
onChangeStatus={this.handleChangeStatus}
onPublish={this.handlePublishEntry}
showDelete={this.props.showDelete}
enableSave={entryDraft.get('hasChanged')}
user={user}
hasChanged={hasChanged}
displayUrl={displayUrl}
hasWorkflow={hasWorkflow}
hasUnpublishedChanges={unpublishedEntry}
isNewEntry={newEntry}
isModification={isModification}
currentStatus={currentStatus}
onLogoutClick={logoutUser}
/>
);
}
}
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));

View File

@ -0,0 +1,7 @@
.nc-controlPane-control {
margin-top: 16px;
&:first-child {
margin-top: 36px;
}
}

View File

@ -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 (
<div className="nc-controlPane-control">
<ul className="nc-controlPane-errors">
{
errors && errors.map(error =>
error.message &&
typeof error.message === 'string' &&
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>{error.message}</li>
)
}
</ul>
<label
className={c({
'nc-controlPane-label': true,
'nc-controlPane-labelActive': this.state.styleActive,
'nc-controlPane-labelWithError': !!errors,
})}
htmlFor={fieldName}
>
{field.get('label')}
</label>
<Widget
classNameWrapper={c({
'nc-controlPane-widget': true,
'nc-controlPane-widgetActive': this.state.styleActive,
'nc-controlPane-widgetError': !!errors,
})}
classNameWidget="nc-controlPane-widget"
classNameWidgetActive="nc-controlPane-widgetNestable"
classNameLabel="nc-controlPane-label"
classNameLabelActive="nc-controlPane-labelActive"
controlComponent={widget.control}
field={field}
value={value}
mediaPaths={mediaPaths}
metadata={metadata}
onChange={(newValue, newMetadata) => 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}
/>
</div>
);
}
}

View File

@ -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;
}

View File

@ -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 (
<div className="nc-controlPane-root">
{fields.map((field, i) => field.get('widget') === 'hidden' ? null :
<EditorControl
key={i}
field={field}
value={entry.getIn(['data', field.get('name')])}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
mediaPaths={mediaPaths}
getAsset={getAsset}
onChange={onChange}
onOpenMediaLibrary={onOpenMediaLibrary}
onAddAsset={onAddAsset}
onRemoveInsertedMedia={onRemoveInsertedMedia}
onValidate={onValidate}
processControlRef={this.processControlRef}
/>
)}
</div>
);
}
}
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,
};

View File

@ -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;

View File

@ -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;
}

View File

@ -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 = (
<div className={classnames('nc-entryEditor-controlPane', { 'nc-entryEditor-blocker': showEventBlocker })}>
<EditorControlPane
collection={collection}
entry={entry}
fields={fields}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
mediaPaths={mediaPaths}
getAsset={getAsset}
onChange={onChange}
onValidate={onValidate}
onOpenMediaLibrary={onOpenMediaLibrary}
onAddAsset={onAddAsset}
onRemoveInsertedMedia={onRemoveInsertedMedia}
ref={c => this.controlPaneRef = c} // eslint-disable-line
/>
</div>
);
const editorWithPreview = (
<ScrollSync enabled={this.state.scrollSyncEnabled}>
<div>
<SplitPane
maxSize={-100}
defaultSize="50%"
onDragStarted={this.handleSplitPaneDragStart}
onDragFinished={this.handleSplitPaneDragFinished}
>
<ScrollSyncPane>{editor}</ScrollSyncPane>
<div className={classnames('nc-entryEditor-previewPane', { 'nc-entryEditor-blocker': showEventBlocker })}>
<EditorPreviewPane
collection={collection}
entry={entry}
fields={fields}
fieldsMetaData={fieldsMetaData}
getAsset={getAsset}
/>
</div>
</SplitPane>
</div>
</ScrollSync>
);
const editorWithoutPreview = (
<div className="nc-entryEditor-noPreviewEditorContainer">
{editor}
</div>
);
return (
<div className="nc-entryEditor-containerOuter">
<EditorToolbar
isPersisting={entry.get('isPersisting')}
isPublishing={entry.get('isPublishing')}
isUpdatingStatus={entry.get('isUpdatingStatus')}
isDeleting={entry.get('isDeleting')}
onPersist={this.handleOnPersist}
onPersistAndNew={() => 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}
/>
<div className="nc-entryEditor-container">
<div className="nc-entryEditor-viewControls">
<EditorToggle
enabled={collectionPreviewEnabled}
active={previewVisible}
onClick={this.handleTogglePreview}
icon="eye"
/>
<EditorToggle
enabled={collectionPreviewEnabled && previewVisible}
active={scrollSyncEnabled}
onClick={this.handleToggleScrollSync}
icon="scroll"
/>
</div>
{
collectionPreviewEnabled && this.state.previewVisible
? editorWithPreview
: editorWithoutPreview
}
</div>
</div>
);
}
}
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;

View File

@ -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

View File

@ -3,4 +3,5 @@
height: 100%;
border: none;
background: #fff;
border-radius: var(--borderRadius);
}

View File

@ -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) => <link key={i} href={style} type="text/css" rel="stylesheet" />);
if (!collection) {
@ -152,10 +151,11 @@ export default class PreviewPane extends React.Component {
<body><div></div></body>
</html>
`;
return (
<ErrorBoundary>
<Frame className="nc-previewPane-frame" head={styleEls} initialContent={initialContent}>
<PreviewContent {...{ previewComponent, previewProps }}/>
<EditorPreviewContent {...{ previewComponent, previewProps }}/>
</Frame>
</ErrorBoundary>
);

View File

@ -0,0 +1,127 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
/**
* ScrollSync provider component
*
*/
export default class ScrollSync extends Component {
static propTypes = {
children: PropTypes.element.isRequired,
proportional: PropTypes.bool,
vertical: PropTypes.bool,
horizontal: PropTypes.bool,
enabled: PropTypes.bool
};
static defaultProps = {
proportional: true,
vertical: true,
horizontal: true,
enabled: true
};
static childContextTypes = {
registerPane: PropTypes.func,
unregisterPane: PropTypes.func
}
getChildContext() {
return {
registerPane: this.registerPane,
unregisterPane: this.unregisterPane
}
}
panes = {}
registerPane = (node, group) => {
if (!this.panes[group]) {
this.panes[group] = []
}
if (!this.findPane(node, group)) {
this.addEvents(node, group)
this.panes[group].push(node)
}
}
unregisterPane = (node, group) => {
if (this.findPane(node, group)) {
this.removeEvents(node)
this.panes[group].splice(this.panes[group].indexOf(node), 1)
}
}
addEvents = (node, group) => {
/* For some reason element.addEventListener doesnt work with document.body */
node.onscroll = this.handlePaneScroll.bind(this, node, group) // eslint-disable-line
}
removeEvents = (node) => {
/* For some reason element.removeEventListener doesnt work with document.body */
node.onscroll = null // eslint-disable-line
}
findPane = (node, group) => {
if (!this.panes[group]) {
return false
}
return this.panes[group].find(pane => pane === node)
}
handlePaneScroll = (node, group) => {
if (!this.props.enabled) {
return;
}
window.requestAnimationFrame(() => {
this.syncScrollPositions(node, group)
})
}
syncScrollPositions = (scrolledPane, group) => {
const {
scrollTop,
scrollHeight,
clientHeight,
scrollLeft,
scrollWidth,
clientWidth
} = scrolledPane
const scrollTopOffset = scrollHeight - clientHeight
const scrollLeftOffset = scrollWidth - clientWidth
const { proportional, vertical, horizontal } = this.props
this.panes[group].forEach((pane) => {
/* For all panes beside the currently scrolling one */
if (scrolledPane !== pane) {
/* Remove event listeners from the node that we'll manipulate */
this.removeEvents(pane, group)
/* Calculate the actual pane height */
const paneHeight = pane.scrollHeight - clientHeight
const paneWidth = pane.scrollWidth - clientWidth
/* Adjust the scrollTop position of it accordingly */
if (vertical && scrollTopOffset > 0) {
pane.scrollTop = proportional ? (paneHeight * scrollTop) / scrollTopOffset : scrollTop // eslint-disable-line
}
if (horizontal && scrollLeftOffset > 0) {
pane.scrollLeft = proportional ? (paneWidth * scrollLeft) / scrollLeftOffset : scrollLeft // eslint-disable-line
}
/* Re-attach event listeners after we're done scrolling */
window.requestAnimationFrame(() => {
this.addEvents(pane, group)
})
}
})
}
render() {
return React.Children.only(this.props.children)
}
}

View File

@ -0,0 +1,50 @@
import { Component } from 'react'
import ReactDOM from 'react-dom'
import PropTypes from 'prop-types'
/**
* ScrollSyncPane Component
*
* Wrap your content in it to keep its scroll position in sync with other panes
*
* @example ./example.md
*/
export default class ScrollSyncPane extends Component {
static propTypes = {
children: PropTypes.node.isRequired,
attachTo: PropTypes.object,
group: PropTypes.string
}
static defaultProps = {
group: 'default'
}
static contextTypes = {
registerPane: PropTypes.func.isRequired,
unregisterPane: PropTypes.func.isRequired
};
componentDidMount() {
this.node = this.props.attachTo || ReactDOM.findDOMNode(this)
this.context.registerPane(this.node, this.props.group)
}
componentWillReceiveProps(nextProps) {
if (this.props.group !== nextProps.group) {
this.context.unregisterPane(this.node, this.props.group)
this.context.registerPane(this.node, nextProps.group)
}
}
componentWillUnmount() {
this.context.unregisterPane(this.node, this.props.group)
}
render() {
return this.props.children
}
}

View File

@ -0,0 +1,18 @@
.nc-editor-toggle {
@apply(--dropShadowMiddle);
background-color: #fff;
color: var(--colorInactive);
border-radius: 32px;
display: block;
width: 40px;
height: 40px;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 12px;
}
.nc-editor-toggleActive {
color: var(--colorActive);
}

View File

@ -0,0 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import c from 'classnames';
import { Icon } from 'UI';
const EditorToggle = ({ enabled, active, onClick, icon }) => !enabled ? null :
<button className={c('nc-editor-toggle', {'nc-editor-toggleActive': active })} onClick={onClick}>
<Icon type={icon} size="large"/>
</button>;
EditorToggle.propTypes = {
enabled: PropTypes.bool,
active: PropTypes.bool,
onClick: PropTypes.func.isRequired,
icon: PropTypes.string.isRequired,
};
export default EditorToggle;

View File

@ -0,0 +1,151 @@
:root {
--editorToolbarButtonMargin: 0 10px;
}
.nc-entryEditor-toolbar {
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05),
0 1px 3px 0 rgba(68, 74, 87, 0.10),
0 2px 54px rgba(0, 0, 0, 0.1);
position: fixed;
top: 0;
left: 0;
width: 100%;
min-width: 800px;
z-index: 300;
background-color: #fff;
height: 66px;
display: flex;
justify-content: space-between;
}
.nc-entryEditor-toolbar-mainSection,
.nc-entryEditor-toolbar-backSection,
.nc-entryEditor-toolbar-metaSection {
height: 100%;
display: flex;
align-items: center;
}
.nc-entryEditor-toolbar-mainSection {
flex: 10;
display: flex;
justify-content: space-between;
padding: 0 10px;
& .nc-entryEditor-toolbar-mainSection-left {
display: flex;
}
& .nc-entryEditor-toolbar-mainSection-right {
display: flex;
justify-content: flex-end;
}
}
.nc-entryEditor-toolbar-backSection,
.nc-entryEditor-toolbar-metaSection {
border: 0 solid var(--textFieldBorderColor);
}
.nc-entryEditor-toolbar-dropdown {
margin: var(--editorToolbarButtonMargin);
& .nc-icon {
color: var(--colorTeal);
}
}
.nc-entryEditor-toolbar-publishButton {
background-color: var(--colorTeal);
}
.nc-entryEditor-toolbar-statusButton {
background-color: var(--colorTealLight);
color: var(--colorTeal);
}
.nc-entryEditor-toolbar-backSection {
border-right-width: 1px;
font-weight: normal;
padding: 0 20px;
&:hover,
&:focus {
background-color: #F1F2F4;
}
}
.nc-entryEditor-toolbar-metaSection {
border-left-width: 1px;
padding: 0 7px;
}
.nc-entryEditor-toolbar-backArrow {
color: var(--colorTextLead);
font-size: 21px;
font-weight: 600;
margin-right: 16px;
}
.nc-entryEditor-toolbar-backCollection {
color: var(--colorTextLead);
font-size: 14px;
}
.nc-entryEditor-toolbar-backStatus {
@apply(--textBadgeSuccess);
&::after {
height: 12px;
width: 15.5px;
color: #005614;
margin-left: 5px;
position: relative;
top: 1px;
content: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg' width='15' height='11'><path fill='#005614' fill-rule='nonzero' d='M4.016 11l-.648-.946a6.202 6.202 0 0 0-.157-.22 9.526 9.526 0 0 1-.096-.133l-.511-.7a7.413 7.413 0 0 0-.162-.214l-.102-.134-.265-.346a26.903 26.903 0 0 0-.543-.687l-.11-.136c-.143-.179-.291-.363-.442-.54l-.278-.332a8.854 8.854 0 0 0-.192-.225L.417 6.28l-.283-.324L0 5.805l1.376-1.602c.04.027.186.132.186.132l.377.272.129.095c.08.058.16.115.237.175l.37.28c.192.142.382.292.565.436l.162.126c.27.21.503.398.714.574l.477.393c.078.064.156.127.23.194l.433.375.171-.205A50.865 50.865 0 0 1 8.18 4.023a35.163 35.163 0 0 1 2.382-2.213c.207-.174.42-.349.635-.518l.328-.255.333-.245c.072-.055.146-.107.221-.159l.117-.083c.11-.077.225-.155.341-.23.163-.11.334-.217.503-.32l1.158 1.74a11.908 11.908 0 0 0-.64.55l-.065.06c-.07.062-.139.125-.207.192l-.258.249-.26.265c-.173.176-.345.357-.512.539a32.626 32.626 0 0 0-1.915 2.313 52.115 52.115 0 0 0-2.572 3.746l-.392.642-.19.322-.233.382H4.016z'/></svg>");
}
}
.nc-entryEditor-toolbar-backStatus-hasChanged {
@apply(--textBadgeDanger);
}
.nc-entryEditor-toolbar-backStatus,
.nc-entryEditor-toolbar-backStatus-hasChanged {
margin-top: 6px;
}
.nc-entryEditor-toolbar-deleteButton,
.nc-entryEditor-toolbar-saveButton {
@apply(--buttonDefault);
display: block;
margin: var(--editorToolbarButtonMargin);
}
.nc-entryEditor-toolbar-deleteButton {
@apply(--buttonLightRed);
}
.nc-entryEditor-toolbar-saveButton {
@apply(--buttonLightBlue);
}
.nc-entryEditor-toolbar-statusPublished {
margin: var(--editorToolbarButtonMargin);
border: 1px solid var(--textFieldBorderColor);
border-radius: var(--borderRadius);
background-color: var(--colorWhite);
color: var(--colorTeal);
padding: 0 24px;
line-height: 36px;
cursor: default;
font-size: 14px;
font-weight: 500;
}
.nc-entryEditor-toolbar-statusMenu-status .nc-icon {
color: var(--colorInfoText);
}

View File

@ -0,0 +1,237 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import c from 'classnames';
import { Link } from 'react-router-dom';
import { status } from 'Constants/publishModes';
import { Icon, Dropdown, DropdownItem } from 'UI';
import { stripProtocol } from 'Lib/urlHelper';
export default class EditorToolbar extends React.Component {
static propTypes = {
isPersisting: PropTypes.bool,
isPublishing: PropTypes.bool,
isUpdatingStatus: PropTypes.bool,
isDeleting: PropTypes.bool,
onPersist: PropTypes.func.isRequired,
onPersistAndNew: PropTypes.func.isRequired,
enableSave: PropTypes.bool.isRequired,
showDelete: PropTypes.bool.isRequired,
onDelete: PropTypes.func.isRequired,
onDeleteUnpublishedChanges: PropTypes.func.isRequired,
onChangeStatus: PropTypes.func.isRequired,
onPublish: PropTypes.func.isRequired,
onPublishAndNew: PropTypes.func.isRequired,
user: ImmutablePropTypes.map,
hasChanged: PropTypes.bool,
displayUrl: PropTypes.string,
collection: ImmutablePropTypes.map.isRequired,
hasWorkflow: PropTypes.bool,
hasUnpublishedChanges: PropTypes.bool,
isNewEntry: PropTypes.bool,
isModification: PropTypes.bool,
currentStatus: PropTypes.string,
onLogoutClick: PropTypes.func.isRequired,
};
renderSimpleSaveControls = () => {
const { showDelete, onDelete } = this.props;
return (
<div>
{
showDelete
? <button className="nc-entryEditor-toolbar-deleteButton" onClick={onDelete}>
Delete entry
</button>
: null
}
</div>
);
};
renderSimplePublishControls = () => {
const { onPersist, onPersistAndNew, isPersisting, hasChanged, isNewEntry } = this.props;
if (!isNewEntry && !hasChanged) {
return <div className="nc-entryEditor-toolbar-statusPublished">Published</div>;
}
return (
<div>
<Dropdown
className="nc-entryEditor-toolbar-dropdown"
classNameButton="nc-entryEditor-toolbar-publishButton"
dropdownTopOverlap="40px"
dropdownWidth="150px"
label={isPersisting ? 'Publishing...' : 'Publish'}
>
<DropdownItem label="Publish now" icon="arrow" iconDirection="right" onClick={onPersist}/>
<DropdownItem label="Publish and create new" icon="add" onClick={onPersistAndNew}/>
</Dropdown>
</div>
);
};
renderWorkflowSaveControls = () => {
const {
onPersist,
onDelete,
onDeleteUnpublishedChanges,
hasChanged,
hasUnpublishedChanges,
isPersisting,
isDeleting,
isNewEntry,
isModification,
} = this.props;
const deleteLabel = (hasUnpublishedChanges && isModification && 'Delete unpublished changes')
|| (hasUnpublishedChanges && (isNewEntry || !isModification) && 'Delete unpublished entry')
|| (!hasUnpublishedChanges && !isModification && 'Delete published entry');
return [
<button
className="nc-entryEditor-toolbar-saveButton"
onClick={() => hasChanged && onPersist()}
>
{isPersisting ? 'Saving...' : 'Save'}
</button>,
isNewEntry || !deleteLabel ? null
: <button
className="nc-entryEditor-toolbar-deleteButton"
onClick={hasUnpublishedChanges ? onDeleteUnpublishedChanges : onDelete}
>
{isDeleting ? 'Deleting...' : deleteLabel}
</button>,
];
};
renderWorkflowPublishControls = () => {
const {
onPersist,
onPersistAndNew,
isUpdatingStatus,
isPublishing,
onChangeStatus,
onPublish,
onPublishAndNew,
currentStatus,
isNewEntry,
} = this.props;
if (currentStatus) {
return [
<Dropdown
className="nc-entryEditor-toolbar-dropdown"
classNameButton="nc-entryEditor-toolbar-statusButton"
dropdownTopOverlap="40px"
dropdownWidth="120px"
label={isUpdatingStatus ? 'Updating...' : 'Set status'}
>
<DropdownItem
className="nc-entryEditor-toolbar-statusMenu-status"
label="Draft"
onClick={() => onChangeStatus('DRAFT')}
icon={currentStatus === status.get('DRAFT') && 'check'}
/>
<DropdownItem
className="nc-entryEditor-toolbar-statusMenu-status"
label="In review"
onClick={() => onChangeStatus('PENDING_REVIEW')}
icon={currentStatus === status.get('PENDING_REVIEW') && 'check'}
/>
<DropdownItem
className="nc-entryEditor-toolbar-statusMenu-status"
label="Ready"
onClick={() => onChangeStatus('PENDING_PUBLISH')}
icon={currentStatus === status.get('PENDING_PUBLISH') && 'check'}
/>
</Dropdown>,
<Dropdown
className="nc-entryEditor-toolbar-dropdown"
classNameButton="nc-entryEditor-toolbar-publishButton"
dropdownTopOverlap="40px"
dropdownWidth="150px"
label={isPublishing ? 'Publishing...' : 'Publish'}
>
<DropdownItem label="Publish now" icon="arrow" iconDirection="right" onClick={onPublish}/>
<DropdownItem label="Publish and create new" icon="add" onClick={onPublishAndNew}/>
</Dropdown>
];
}
if (!isNewEntry) {
return <div className="nc-entryEditor-toolbar-statusPublished">Published</div>;
}
};
render() {
const {
isPersisting,
onPersist,
onPersistAndNew,
enableSave,
showDelete,
onDelete,
user,
hasChanged,
displayUrl,
collection,
hasWorkflow,
hasUnpublishedChanges,
onLogoutClick,
} = this.props;
const disabled = !enableSave || isPersisting;
const avatarUrl = user.get('avatar_url');
return (
<div className="nc-entryEditor-toolbar">
<Link to={`/collections/${collection.get('name')}`} className="nc-entryEditor-toolbar-backSection">
<div className="nc-entryEditor-toolbar-backArrow"></div>
<div>
<div className="nc-entryEditor-toolbar-backCollection">
Writing in <strong>{collection.get('label')}</strong> collection
</div>
{
hasChanged
? <div className="nc-entryEditor-toolbar-backStatus-hasChanged">Unsaved Changes</div>
: <div className="nc-entryEditor-toolbar-backStatus">Changes saved</div>
}
</div>
</Link>
<div className="nc-entryEditor-toolbar-mainSection">
<div className="nc-entryEditor-toolbar-mainSection-left">
{ hasWorkflow ? this.renderWorkflowSaveControls() : this.renderSimpleSaveControls() }
</div>
<div className="nc-entryEditor-toolbar-mainSection-right">
{ hasWorkflow ? this.renderWorkflowPublishControls() : this.renderSimplePublishControls() }
</div>
</div>
<div className="nc-entryEditor-toolbar-metaSection">
{
displayUrl
? <a className="nc-appHeader-siteLink" href={displayUrl} target="_blank">
{stripProtocol(displayUrl)}
</a>
: null
}
<Dropdown
dropdownTopOverlap="50px"
dropdownWidth="100px"
dropdownPosition="right"
button={
<button className="nc-appHeader-avatar">
{
avatarUrl
? <img className="nc-appHeader-avatar-image" src={user.get('avatar_url')}/>
: <Icon className="nc-appHeader-avatar-placeholder" type="user" size="large"/>
}
</button>
}
>
<DropdownItem label="Log Out" onClick={onLogoutClick}/>
</Dropdown>
</div>
</div>
);
}
};

View File

@ -1,6 +1,6 @@
import React from 'react';
import { shallow } from 'enzyme';
import EntryEditorToolbar from '../EntryEditorToolbar';
import EditorToolbar from '../EditorInterface/EditorToolbar';
describe('EntryEditorToolbar', () => {
it('should have the Save button disabled initally, and the Cancel button enabled', () => {

View File

@ -0,0 +1,58 @@
import React from 'react';
import { connect } from 'react-redux';
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import { selectUnpublishedEntry, selectEntry } from 'Reducers';
import { selectAllowDeletion } from 'Reducers/collections';
import { loadUnpublishedEntry, persistUnpublishedEntry } from 'Actions/editorialWorkflow';
function mapStateToProps(state, ownProps) {
const { collections } = state;
const isEditorialWorkflow = (state.config.get('publish_mode') === EDITORIAL_WORKFLOW);
const collection = collections.get(ownProps.match.params.name);
const returnObj = {
isEditorialWorkflow,
showDelete: !ownProps.newEntry && selectAllowDeletion(collection),
};
if (isEditorialWorkflow) {
const slug = ownProps.match.params.slug;
const unpublishedEntry = selectUnpublishedEntry(state, collection.get('name'), slug);
if (unpublishedEntry) {
returnObj.unpublishedEntry = true;
returnObj.entry = unpublishedEntry;
}
}
return returnObj;
}
function mergeProps(stateProps, dispatchProps, ownProps) {
const { isEditorialWorkflow, unpublishedEntry } = stateProps;
const { dispatch } = dispatchProps;
const returnObj = {};
if (isEditorialWorkflow) {
// Overwrite loadEntry to loadUnpublishedEntry
returnObj.loadEntry = (collection, slug) =>
dispatch(loadUnpublishedEntry(collection, slug));
// Overwrite persistEntry to persistUnpublishedEntry
returnObj.persistEntry = collection =>
dispatch(persistUnpublishedEntry(collection, unpublishedEntry));
}
return {
...ownProps,
...stateProps,
...returnObj,
};
}
export default function withWorkflow(Editor) {
return connect(mapStateToProps, null, mergeProps)(
class extends React.Component {
render() {
return <Editor {...this.props} />;
}
}
);
};

View File

@ -0,0 +1,9 @@
.nc-booleanControl-switch {
& .nc-toggle-background {
background-color: var(--textFieldBorderColor);
}
& .nc-toggle-active .nc-toggle-background {
background-color: var(--colorActive);
}
}

View File

@ -0,0 +1,40 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from "react-immutable-proptypes";
import { isBoolean } from 'lodash';
import { Toggle } from 'UI';
export default class BooleanControl extends React.Component {
render() {
const {
value,
field,
forID,
onChange,
classNameWrapper,
setActiveStyle,
setInactiveStyle
} = this.props;
return (
<div className={`${classNameWrapper} nc-booleanControl-switch`}>
<Toggle
id={forID}
active={isBoolean(value) ? value : field.get('defaultValue', false)}
onChange={onChange}
onFocus={setActiveStyle}
onBlur={setInactiveStyle}
/>
</div>
);
}
}
BooleanControl.propTypes = {
field: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
classNameWrapper: PropTypes.string.isRequired,
setActiveStyle: PropTypes.func.isRequired,
setInactiveStyle: PropTypes.func.isRequired,
forID: PropTypes.string,
value: PropTypes.bool,
};

View File

@ -1,9 +1,22 @@
import PropTypes from 'prop-types';
import React from 'react';
import PropTypes from 'prop-types';
import DateTime from 'react-datetime';
import moment from 'moment';
export default class DateControl extends React.Component {
static propTypes = {
field: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
classNameWrapper: PropTypes.string.isRequired,
setActiveStyle: PropTypes.func.isRequired,
setInactiveStyle: PropTypes.func.isRequired,
value: PropTypes.oneOfType([
PropTypes.object,
PropTypes.string,
]),
includeTime: PropTypes.bool,
};
componentDidMount() {
const { value, field, onChange } = this.props;
this.format = field.get('format');
@ -28,22 +41,17 @@ export default class DateControl extends React.Component {
};
render() {
const { includeTime, value } = this.props;
const { includeTime, value, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props;
const format = this.format || moment.defaultFormat;
return (<DateTime
timeFormat={!!includeTime}
value={moment(value, format)}
onChange={this.handleChange}
/>);
return (
<DateTime
timeFormat={!!includeTime}
value={moment(value, format)}
onChange={this.handleChange}
onFocus={setActiveStyle}
onBlur={setInactiveStyle}
inputProps={{ className: classNameWrapper }}
/>
);
}
}
DateControl.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.oneOfType([
PropTypes.object,
PropTypes.string,
]),
includeTime: PropTypes.bool,
field: PropTypes.object,
};

View File

@ -1,9 +1,8 @@
import PropTypes from 'prop-types';
import React from 'react';
import previewStyle from './defaultPreviewStyle';
export default function DatePreview({ value }) {
return <div style={previewStyle}>{value ? value.toString() : null}</div>;
return <div className="nc-widgetPreview">{value ? value.toString() : null}</div>;
}
DatePreview.propTypes = {

View File

@ -0,0 +1 @@
@import "./ReactDatetime.css";

View File

@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import DateControl from 'EditorWidgets/Date/DateControl';
export default class DateTimeControl extends React.Component {
static propTypes = {
field: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
classNameWrapper: PropTypes.string.isRequired,
setActiveStyle: PropTypes.func.isRequired,
setInactiveStyle: PropTypes.func.isRequired,
value: PropTypes.oneOfType([
PropTypes.object,
PropTypes.string,
]),
format: PropTypes.string,
};
render() {
const {
field,
format,
onChange,
value,
classNameWrapper,
setActiveStyle,
setInactiveStyle
} = this.props;
return (
<DateControl
onChange={onChange}
format={format}
value={value}
field={field}
classNameWrapper={classNameWrapper}
setActiveStyle={setActiveStyle}
setInactiveStyle={setInactiveStyle}
includeTime
/>
);
}
}

View File

@ -1,9 +1,8 @@
import PropTypes from 'prop-types';
import React from 'react';
import previewStyle from './defaultPreviewStyle';
export default function DateTimePreview({ value }) {
return <div style={previewStyle}>{value ? value.toString() : null}</div>;
return <div className="nc-widgetPreview">{value ? value.toString() : null}</div>;
}
DateTimePreview.propTypes = {

View File

@ -0,0 +1,210 @@
.rdt {
position: relative;
}
.rdtPicker {
display: none;
position: absolute;
width: 250px;
padding: 4px;
margin-top: 1px;
z-index: 99999 !important;
background: #fff;
border: 2px solid var(--colorGray);
border-radius: 2px;
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, .16);
}
.rdtOpen .rdtPicker {
display: block;
}
.rdtStatic .rdtPicker {
box-shadow: none;
position: static;
}
.rdtPicker .rdtTimeToggle {
text-align: center;
}
.rdtPicker table {
width: 100%;
margin: 0;
}
.rdtPicker td,
.rdtPicker th {
text-align: center;
height: 28px;
}
.rdtPicker td {
cursor: pointer;
}
.rdtPicker td.rdtDay:hover,
.rdtPicker td.rdtHour:hover,
.rdtPicker td.rdtMinute:hover,
.rdtPicker td.rdtSecond:hover,
.rdtPicker .rdtTimeToggle:hover {
background: #eeeeee;
cursor: pointer;
}
.rdtPicker td.rdtOld,
.rdtPicker td.rdtNew {
color: #999999;
}
.rdtPicker td.rdtToday {
position: relative;
}
.rdtPicker td.rdtToday:before {
content: '';
display: inline-block;
border-left: 7px solid transparent;
border-bottom: 7px solid #428bca;
border-top-color: rgba(0, 0, 0, 0.2);
position: absolute;
bottom: 4px;
right: 4px;
}
.rdtPicker td.rdtActive,
.rdtPicker td.rdtActive:hover {
background-color: #428bca;
color: #fff;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
}
.rdtPicker td.rdtActive.rdtToday:before {
border-bottom-color: #fff;
}
.rdtPicker td.rdtDisabled,
.rdtPicker td.rdtDisabled:hover {
background: none;
color: #999999;
cursor: not-allowed;
}
.rdtPicker td span.rdtOld {
color: #999999;
}
.rdtPicker td span.rdtDisabled,
.rdtPicker td span.rdtDisabled:hover {
background: none;
color: #999999;
cursor: not-allowed;
}
.rdtPicker th {
border-bottom: 1px solid #f9f9f9;
}
.rdtPicker .dow {
width: 14.2857%;
border-bottom: none;
}
.rdtPicker th.rdtSwitch {
width: 100px;
}
.rdtPicker th.rdtNext,
.rdtPicker th.rdtPrev {
font-size: 21px;
vertical-align: top;
}
.rdtPrev span,
.rdtNext span {
display: block;
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Chrome/Safari/Opera */
-khtml-user-select: none; /* Konqueror */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none;
}
.rdtPicker th.rdtDisabled,
.rdtPicker th.rdtDisabled:hover {
background: none;
color: #999999;
cursor: not-allowed;
}
.rdtPicker thead tr:first-child th {
cursor: pointer;
}
.rdtPicker thead tr:first-child th:hover {
background: #eeeeee;
}
.rdtPicker tfoot {
border-top: 1px solid #f9f9f9;
}
.rdtPicker button {
border: none;
background: none;
cursor: pointer;
}
.rdtPicker button:hover {
background-color: #eee;
}
.rdtPicker thead button {
width: 100%;
height: 100%;
}
td.rdtMonth,
td.rdtYear {
height: 50px;
width: 25%;
cursor: pointer;
}
td.rdtMonth:hover,
td.rdtYear:hover {
background: #eee;
}
.rdtCounters {
display: inline-block;
}
.rdtCounters > div {
float: left;
}
.rdtCounter {
height: 100px;
}
.rdtCounter {
width: 40px;
}
.rdtCounterSeparator {
line-height: 100px;
}
.rdtCounter .rdtBtn {
height: 40%;
line-height: 40px;
cursor: pointer;
display: block;
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Chrome/Safari/Opera */
-khtml-user-select: none; /* Konqueror */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none;
}
.rdtCounter .rdtBtn:hover {
background: #eee;
}
.rdtCounter .rdtCount {
height: 20%;
font-size: 1.2em;
}
.rdtMilli {
vertical-align: middle;
padding-left: 8px;
width: 48px;
}
.rdtMilli input {
width: 100%;
font-size: 1.2em;
margin-top: 37px;
}

View File

@ -0,0 +1,17 @@
@import "./Object/Object.css";
@import "./List/List.css";
@import "./withMedia/withMedia.css";
@import "./Image/Image.css";
@import "./File/FileControl.css";
@import "./Markdown/Markdown.css";
@import "./Boolean/Boolean.css";
@import "./Relation/Relation.css";
@import "./DateTime/DateTime.css";
:root {
--widgetNestDistance: 14px;
}
.nc-widgetPreview {
margin: 15px 2px;
}

View File

@ -0,0 +1,7 @@
.nc-fileControl-input {
display: none !important;
}
.nc-fileControl-imageUpload {
cursor: pointer;
}

View File

@ -0,0 +1,5 @@
import withMediaControl from 'EditorWidgets/withMedia/withMediaControl';
const FileControl = withMediaControl();
export default FileControl;

View File

@ -1,9 +1,8 @@
import PropTypes from 'prop-types';
import React from 'react';
import previewStyle from './defaultPreviewStyle';
export default function FilePreview({ value, getAsset }) {
return (<div style={previewStyle}>
return (<div className="nc-widgetPreview">
{ value ?
<a href={getAsset(value)}>{ value }</a>
: null}

View File

@ -0,0 +1,4 @@
.nc-imagePreview-image {
max-width: 100%;
height: auto;
}

View File

@ -0,0 +1,5 @@
import withMediaControl from 'EditorWidgets/withMedia/withMediaControl';
const ImageControl = withMediaControl(true);
export default ImageControl;

View File

@ -1,13 +1,12 @@
import PropTypes from 'prop-types';
import React from 'react';
import previewStyle, { imagePreviewStyle } from './defaultPreviewStyle';
export default function ImagePreview({ value, getAsset }) {
return (<div style={previewStyle}>
return (<div className='nc-widgetPreview'>
{ value ?
<img
src={getAsset(value)}
style={imagePreviewStyle}
className='nc-imageWidget-image'
role="presentation"
/>
: null}

View File

@ -0,0 +1,83 @@
.nc-listControl {
padding: 0 14px 14px;
&.nc-listControl-collapsed {
padding-bottom: 0;
}
}
.list-item-dragging {
opacity: 0.5;
}
.nc-listControl-topBar {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 -14px;
background-color: var(--textFieldBorderColor);
padding: 13px;
}
.nc-listControl-addButton {
display: flex;
justify-content: center;
align-items: center;
padding: 2px 12px;
font-size: 12px;
font-weight: bold;
border-radius: 3px;
& .nc-icon {
padding-left: 6px;
}
}
.nc-listControl-listCollapseToggle {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
cursor: pointer;
line-height: 1;
& .nc-icon {
padding-right: 8px;
}
}
.nc-listControl-item {
margin-top: 18px;
&:first-of-type {
margin-top: 26px;
}
}
.nc-listControl-itemTopBar {
background-color: var(--textFieldBorderColor);
}
.nc-listControl-objectLabel {
display: none;
border-top: 0;
background-color: var(--textFieldBorderColor);
padding: 13px;
border-radius: 0 0 var(--borderRadius) var(--borderRadius);
}
.nc-listControl-objectControl {
padding: 6px 14px 14px;
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.nc-listControl-collapsed {
& .nc-listControl-objectLabel {
display: block;
}
& .nc-listControl-objectControl {
display: none;
}
}

View File

@ -2,9 +2,11 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { List, Map, fromJS } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { partial } from 'lodash';
import c from 'classnames';
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
import FontIcon from 'react-toolbox/lib/font_icon';
import ObjectControl from './ObjectControl';
import { Icon, ListItemTopBar } from 'UI';
import ObjectControl from 'EditorWidgets/Object/ObjectControl';
function ListItem(props) {
return <div {...props} className={`list-item ${ props.className || '' }`}>{props.children}</div>;
@ -20,11 +22,22 @@ function valueToString(value) {
}
const SortableListItem = SortableElement(ListItem);
const DragHandle = SortableHandle(
() => <FontIcon value="drag_handle" className="nc-listControl-dragIcon" />
const TopBar = ({ onAdd, listLabel, collapsed, onCollapseToggle, itemsCount }) => (
<div className="nc-listControl-topBar">
<div className="nc-listControl-listCollapseToggle" onClick={onCollapseToggle}>
<Icon type="caret" direction={collapsed ? 'up' : 'down'} size="small"/>
{itemsCount} {listLabel}
</div>
<button className="nc-listControl-addButton" onClick={onAdd}>
Add {listLabel} <Icon type="add" size="xsmall" />
</button>
</div>
);
const SortableList = SortableContainer(({ items, renderItem }) =>
(<div>{items.map(renderItem)}</div>));
const SortableList = SortableContainer(({ items, renderItem }) => {
return <div>{items.map(renderItem)}</div>;
});
const valueTypes = {
SINGLE: 'SINGLE',
@ -34,6 +47,7 @@ const valueTypes = {
export default class ListControl extends Component {
static propTypes = {
onChange: PropTypes.func.isRequired,
onChangeObject: PropTypes.func.isRequired,
value: PropTypes.node,
field: PropTypes.node,
forID: PropTypes.string,
@ -41,12 +55,19 @@ export default class ListControl extends Component {
getAsset: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveAsset: PropTypes.func.isRequired,
onRemoveInsertedMedia: PropTypes.func.isRequired,
classNameWrapper: PropTypes.string.isRequired,
setActiveStyle: PropTypes.func.isRequired,
setInactiveStyle: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = { itemsCollapsed: List(), value: valueToString(props.value) };
this.state = {
collapsed: false,
itemsCollapsed: List(),
value: valueToString(props.value),
};
this.valueType = null;
}
@ -87,28 +108,42 @@ export default class ListControl extends Component {
if (newValue.match(/,$/) && oldValue.match(/, $/)) {
listValue.pop();
}
const parsedValue = valueToString(listValue);
this.setState({ value: parsedValue });
onChange(listValue.map(val => val.trim()));
};
handleCleanup = (e) => {
handleFocus = () => {
this.props.setActiveStyle();
}
handleBlur = (e) => {
const listValue = e.target.value.split(',').map(el => el.trim()).filter(el => el);
this.setState({ value: valueToString(listValue) });
this.props.setInactiveStyle();
};
handleAdd = (e) => {
e.preventDefault();
const { value, onChange } = this.props;
const parsedValue = (this.valueType === valueTypes.SINGLE) ? null : Map();
this.setState({ collapsed: false });
onChange((value || List()).push(parsedValue));
};
/**
* 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 = idx => this.props.value.get(idx) || Map();
handleChangeFor(index) {
return (newValue, newMetadata) => {
return (fieldName, newValue, newMetadata) => {
const { value, metadata, onChange, forID } = this.props;
const parsedValue = (this.valueType === valueTypes.SINGLE) ? newValue.first() : newValue;
const newObjectValue = this.getObjectValue(index).set(fieldName, newValue);
const parsedValue = (this.valueType === valueTypes.SINGLE) ? newObjectValue.first() : newObjectValue;
const parsedMetadata = {
[forID]: Object.assign(metadata ? metadata.toJS() : {}, newMetadata ? newMetadata[forID] : {}),
};
@ -116,23 +151,23 @@ export default class ListControl extends Component {
};
}
handleRemove(index) {
return (e) => {
e.preventDefault();
const { value, metadata, onChange, forID } = this.props;
const parsedMetadata = metadata && { [forID]: metadata.removeIn(value.get(index).valueSeq()) };
onChange(value.remove(index), parsedMetadata);
};
handleRemove = (index, event) => {
event.preventDefault();
const { value, metadata, onChange, forID } = this.props;
const parsedMetadata = metadata && { [forID]: metadata.removeIn(value.get(index).valueSeq()) };
onChange(value.remove(index), parsedMetadata);
}
handleToggle(index) {
return (e) => {
e.preventDefault();
const { itemsCollapsed } = this.state;
this.setState({
itemsCollapsed: itemsCollapsed.set(index, !itemsCollapsed.get(index, false)),
});
};
handleCollapseToggle = () => {
this.setState({ collapsed: !this.state.collapsed });
}
handleItemCollapseToggle = (index, event) => {
event.preventDefault();
const { itemsCollapsed } = this.state;
this.setState({
itemsCollapsed: itemsCollapsed.set(index, !itemsCollapsed.get(index, false)),
});
}
objectLabel(item) {
@ -160,55 +195,75 @@ export default class ListControl extends Component {
};
renderItem = (item, index) => {
const { field, getAsset, mediaPaths, onOpenMediaLibrary, onAddAsset, onRemoveAsset } = this.props;
const {
field,
getAsset,
mediaPaths,
onOpenMediaLibrary,
onAddAsset,
onRemoveInsertedMedia,
classNameWrapper,
} = this.props;
const { itemsCollapsed } = this.state;
const collapsed = itemsCollapsed.get(index);
const classNames = ['nc-listControl-item', collapsed ? 'nc-listControl-collapsed' : ''];
return (<SortableListItem className={classNames.join(' ')} index={index} key={`item-${ index }`}>
<button className="nc-listControl-toggleButton" onClick={this.handleToggle(index)}>
<FontIcon value={collapsed ? 'expand_more' : 'expand_less'} />
</button>
<DragHandle />
<button className="nc-listControl-removeButton" onClick={this.handleRemove(index)}>
<FontIcon value="close" />
</button>
<ListItemTopBar
className="nc-listControl-itemTopBar"
collapsed={collapsed}
onCollapseToggle={partial(this.handleItemCollapseToggle, index)}
onRemove={partial(this.handleRemove, index)}
dragHandleHOC={SortableHandle}
/>
<div className="nc-listControl-objectLabel">{this.objectLabel(item)}</div>
<ObjectControl
value={item}
field={field}
className="nc-listControl-objectControl"
onChange={this.handleChangeFor(index)}
onChangeObject={this.handleChangeFor(index)}
getAsset={getAsset}
onOpenMediaLibrary={onOpenMediaLibrary}
mediaPaths={mediaPaths}
onAddAsset={onAddAsset}
onRemoveAsset={onRemoveAsset}
onRemoveInsertedMedia={onRemoveInsertedMedia}
classNameWrapper={`${classNameWrapper} nc-listControl-objectControl`}
/>
</SortableListItem>);
};
renderListControl() {
const { value, forID, field } = this.props;
const listLabel = field.get('label');
const { value, forID, field, classNameWrapper } = this.props;
const { collapsed } = this.state;
const items = value || List();
const className = c(classNameWrapper, 'nc-listControl', {
'nc-listControl-collapsed' : collapsed,
});
return (<div id={forID}>
<SortableList
items={value || List()}
renderItem={this.renderItem}
onSortEnd={this.onSortEnd}
useDragHandle
lockAxis="y"
/>
<button className="nc-listControl-addButton" onClick={this.handleAdd}>
<FontIcon value="add" className="nc-listControl-addButtonIcon" />
<span className="nc-listControl-addButtonText">new {listLabel}</span>
</button>
</div>);
return (
<div id={forID} className={className}>
<TopBar
onAdd={this.handleAdd}
listLabel={field.get('label').toLowerCase()}
onCollapseToggle={this.handleCollapseToggle}
collapsed={collapsed}
itemsCount={items.size}
/>
{
collapsed ? null :
<SortableList
items={items}
renderItem={this.renderItem}
onSortEnd={this.onSortEnd}
useDragHandle
lockAxis="y"
/>
}
</div>
);
}
render() {
const { field, forID } = this.props;
const { field, forID, classNameWrapper } = this.props;
const { value } = this.state;
if (field.get('field') || field.get('fields')) {
@ -220,7 +275,9 @@ export default class ListControl extends Component {
id={forID}
value={value}
onChange={this.handleChange}
onBlur={this.handleCleanup}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
className={classNameWrapper}
/>);
}
};

View File

@ -1,8 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { resolveWidget } from '../Widgets';
import previewStyle from './defaultPreviewStyle';
import ObjectPreview from './ObjectPreview';
import ObjectPreview from 'EditorWidgets/Object/ObjectPreview';
const ListPreview = ObjectPreview;

View File

@ -0,0 +1,4 @@
@import "./MarkdownControl/RawEditor/index.css";
@import "./MarkdownControl/Toolbar/Toolbar.css";
@import "./MarkdownControl/Toolbar/ToolbarButton.css";
@import "./MarkdownControl/VisualEditor/index.css";

View File

@ -3,10 +3,13 @@
}
.nc-rawEditor-rawEditor {
@apply(--input);
position: relative;
overflow: hidden;
overflow-x: auto;
min-height: var(--richTextEditorMinHeight);
font-family: var(--fontFamilyMono);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
margin-top: -var(--stickyDistanceBottom);
}

View File

@ -3,8 +3,7 @@ import React from 'react';
import { Editor as Slate } from 'slate-react';
import Plain from 'slate-plain-serializer';
import { debounce } from 'lodash';
import Toolbar from '../Toolbar/Toolbar';
import { Sticky } from '../../../../UI/Sticky/Sticky';
import Toolbar from 'EditorWidgets/Markdown/MarkdownControl/Toolbar/Toolbar';
export default class RawEditor extends React.Component {
constructor(props) {
@ -51,17 +50,20 @@ export default class RawEditor extends React.Component {
};
render() {
const { className } = this.props;
return (
<div className="nc-rawEditor-rawWrapper">
<Sticky
className="nc-visualEditor-editorControlBar"
classNameActive="nc-visualEditor-editorControlBarSticky"
fillContainerWidth
>
<Toolbar onToggleMode={this.handleToggleMode} disabled rawMode />
</Sticky>
<div className="nc-visualEditor-editorControlBar">
<Toolbar
onToggleMode={this.handleToggleMode}
className="nc-markdownWidget-toolbarRaw"
disabled
rawMode
/>
</div>
<Slate
className="nc-rawEditor-rawEditor"
className={`${className} nc-rawEditor-rawEditor`}
value={this.state.value}
onChange={this.handleChange}
onPaste={this.handlePaste}
@ -74,5 +76,6 @@ export default class RawEditor extends React.Component {
RawEditor.propTypes = {
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
className: PropTypes.string.isRequired,
value: PropTypes.string,
};

View File

@ -0,0 +1,48 @@
.nc-toolbar-Toolbar {
background-color: var(--textFieldBorderColor);
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 11px 14px;
min-height: 58px;
transition: background-color var(--transition), color var(--transition);
}
.nc-markdownWidget-toolbar-toggle {
flex-shrink: 0;
display: flex;
align-items: center;
font-size: 14px;
margin: 0 10px;
}
.nc-markdownWidget-toolbar-toggle-label {
display: inline-block;
text-align: center;
white-space: nowrap;
line-height: 20px;
}
.nc-markdownWidget-toolbar-toggle-label-active {
font-weight: 600;
color: #3a69c7;
}
.nc-toolbar-ToolbarActive {
background-color: var(--colorActive);
color: var(--colorTextLight);
& .nc-markdownWidget-toolbar-toggle-label {
color: var(--colorTextLight);
}
& .nc-markdownWidget-toolbar-toggle-background {
background-color: var(--textFieldBorderColor);
}
}
.nc-toolbar-dropdown {
display: inline-block;
position: relative;
}

Some files were not shown because too many files have changed in this diff Show More