commit
af1c188a86
20
.eslintrc
20
.eslintrc
@ -1,28 +1,10 @@
|
||||
env:
|
||||
browser: true
|
||||
es6: true
|
||||
|
||||
parser: babel-eslint
|
||||
|
||||
plugins: [ "react" ]
|
||||
|
||||
# enable ECMAScript features
|
||||
ecmaFeatures:
|
||||
arrowFunctions: true
|
||||
binaryLiterals: true
|
||||
blockBindings: true
|
||||
classes: true
|
||||
defaultParams: true
|
||||
destructuring: true
|
||||
forOf: true
|
||||
generators: true
|
||||
jsx: true
|
||||
modules: true
|
||||
objectLiteralShorthandMethods: true
|
||||
objectLiteralShorthandProperties: true
|
||||
octalLiterals: true
|
||||
spread: true
|
||||
templateStrings: true
|
||||
|
||||
rules:
|
||||
# Possible Errors
|
||||
# https://github.com/eslint/eslint/tree/master/docs/rules#possible-errors
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@ dist/
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
.tern-project
|
||||
|
@ -2,7 +2,6 @@ import { configure } from '@kadira/storybook';
|
||||
import '../src/index.css';
|
||||
|
||||
function loadStories() {
|
||||
require('../src/containers/stories/');
|
||||
require('../src/components/stories/');
|
||||
}
|
||||
|
||||
|
@ -1,34 +1 @@
|
||||
/* global module, __dirname, require */
|
||||
var webpack = require("webpack");
|
||||
const path = require("path");
|
||||
|
||||
module.exports = {
|
||||
module: {
|
||||
loaders: [
|
||||
{
|
||||
test: /\.((png)|(eot)|(woff)|(woff2)|(ttf)|(svg)|(gif))(\?v=\d+\.\d+\.\d+)?$/,
|
||||
loader: 'url-loader?limit=100000'
|
||||
},
|
||||
{ test: /\.json$/, loader: 'json-loader' },
|
||||
{
|
||||
test: /\.css$/,
|
||||
loader: 'style!css?modules&importLoaders=1!postcss'
|
||||
},
|
||||
{
|
||||
loader: 'babel',
|
||||
test: /\.js?$/,
|
||||
exclude: /(node_modules|bower_components)/,
|
||||
query: {
|
||||
cacheDirectory: true,
|
||||
presets: ['react', 'es2015'],
|
||||
plugins: ['transform-class-properties', 'transform-object-assign', 'transform-object-rest-spread']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
postcss: [
|
||||
require("postcss-import")({addDependencyTo: webpack}),
|
||||
require("postcss-cssnext")()
|
||||
],
|
||||
};
|
||||
module.exports = require('../webpack.base.js');
|
||||
|
15
example/example.css
Normal file
15
example/example.css
Normal file
@ -0,0 +1,15 @@
|
||||
html, body {
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #444;
|
||||
}
|
||||
body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
font-size: 32px;
|
||||
margin-top: 20px;
|
||||
}
|
@ -69,6 +69,26 @@
|
||||
|
||||
<script src='/cms.js'></script>
|
||||
<script>
|
||||
var PostPreview = createClass({
|
||||
render: function() {
|
||||
var entry = this.props.entry;
|
||||
var image = entry.getIn(['data', 'image']);
|
||||
var bg = image && this.props.getMedia(image);
|
||||
return h('div', {},
|
||||
h('div', {className: "cover"},
|
||||
h('h1', {}, entry.getIn(['data', 'title'])),
|
||||
bg ? h('img', {src: bg.toString()}) : null
|
||||
),
|
||||
h('p', {},
|
||||
h('small', {}, "Written " + entry.getIn(['data', 'date']))
|
||||
),
|
||||
h('div', {"className": "text"}, this.props.widgetFor('body'))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
CMS.registerPreviewTemplate("posts", PostPreview);
|
||||
CMS.registerPreviewStyle("/example.css");
|
||||
CMS.registerEditorComponent({
|
||||
id: "youtube",
|
||||
label: "Youtube",
|
||||
@ -88,7 +108,7 @@
|
||||
'<img src="http://img.youtube.com/vi/' + obj.id + '/maxresdefault.jpg" alt="Youtube Video"/>'
|
||||
);
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
40
package.json
40
package.json
@ -4,13 +4,23 @@
|
||||
"description": "Netlify CMS lets content editors work on structured content stored in git",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "webpack-dev-server -d --inline --config webpack.config.js",
|
||||
"start": "webpack-dev-server --config webpack.dev.js",
|
||||
"test": "NODE_ENV=test mocha --recursive --compilers js:babel-register --require ./test/setup.js",
|
||||
"test:watch": "npm test -- --watch",
|
||||
"build": "webpack --config webpack.config.js",
|
||||
"storybook": "start-storybook -p 9001",
|
||||
"storybook-build": "build-storybook -o dist"
|
||||
"storybook-build": "build-storybook -o dist",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"lint:staged": "lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.@(js|jsx)": [
|
||||
"eslint --fix",
|
||||
"git add"
|
||||
]
|
||||
},
|
||||
"pre-commit": "lint:staged",
|
||||
"keywords": [
|
||||
"netlify",
|
||||
"cms"
|
||||
@ -21,7 +31,7 @@
|
||||
"@kadira/storybook": "^1.36.0",
|
||||
"autoprefixer": "^6.3.3",
|
||||
"babel-core": "^6.5.1",
|
||||
"babel-eslint": "^4.1.8",
|
||||
"babel-eslint": "^6.1.2",
|
||||
"babel-loader": "^6.2.2",
|
||||
"babel-plugin-lodash": "^3.2.0",
|
||||
"babel-plugin-transform-class-properties": "^6.5.2",
|
||||
@ -32,25 +42,26 @@
|
||||
"babel-register": "^6.5.2",
|
||||
"babel-runtime": "^6.5.0",
|
||||
"css-loader": "^0.23.1",
|
||||
"eslint": "^1.10.3",
|
||||
"eslint-loader": "^1.2.1",
|
||||
"eslint": "^3.5.0",
|
||||
"eslint-plugin-react": "^5.1.1",
|
||||
"expect": "^1.20.2",
|
||||
"exports-loader": "^0.6.3",
|
||||
"express": "^4.13.4",
|
||||
"extract-text-webpack-plugin": "^1.0.1",
|
||||
"file-loader": "^0.8.5",
|
||||
"immutable": "^3.7.6",
|
||||
"imports-loader": "^0.6.5",
|
||||
"js-yaml": "^3.5.3",
|
||||
"lint-staged": "^3.0.2",
|
||||
"mocha": "^2.4.5",
|
||||
"moment": "^2.11.2",
|
||||
"node-sass": "^3.10.0",
|
||||
"normalizr": "^2.0.0",
|
||||
"postcss-cssnext": "^2.7.0",
|
||||
"postcss-import": "^8.1.2",
|
||||
"postcss-loader": "^0.9.1",
|
||||
"pre-commit": "^1.1.3",
|
||||
"react": "^15.1.0",
|
||||
"react-dom": "^15.1.0",
|
||||
"react-hot-loader": "^3.0.0-beta.2",
|
||||
"react-immutable-proptypes": "^1.6.0",
|
||||
"react-lazy-load": "^3.0.3",
|
||||
"react-pure-render": "^1.0.2",
|
||||
@ -59,25 +70,36 @@
|
||||
"react-router-redux": "^4.0.5",
|
||||
"redux": "^3.3.1",
|
||||
"redux-thunk": "^1.0.3",
|
||||
"sass-loader": "^4.0.2",
|
||||
"style-loader": "^0.13.0",
|
||||
"url-loader": "^0.5.7",
|
||||
"webpack": "^1.12.13",
|
||||
"webpack-dev-server": "^1.14.1",
|
||||
"webpack": "^1.13.2",
|
||||
"webpack-dev-server": "^1.15.1",
|
||||
"webpack-merge": "^0.14.1",
|
||||
"webpack-postcss-tools": "^1.1.1",
|
||||
"whatwg-fetch": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bricks.js": "^1.7.0",
|
||||
"dateformat": "^1.0.12",
|
||||
"fuzzy": "^0.1.1",
|
||||
"immutability-helper": "^2.0.0",
|
||||
"js-base64": "^2.1.9",
|
||||
"json-loader": "^0.5.4",
|
||||
"localforage": "^1.4.2",
|
||||
"lodash": "^4.13.1",
|
||||
"markup-it": "git+https://github.com/cassiozen/markup-it.git",
|
||||
"material-design-icons": "^3.0.1",
|
||||
"normalize.css": "^4.2.0",
|
||||
"pluralize": "^3.0.0",
|
||||
"prismjs": "^1.5.1",
|
||||
"react-addons-css-transition-group": "^15.3.1",
|
||||
"react-datetime": "^2.6.0",
|
||||
"react-portal": "^2.2.1",
|
||||
"react-toolbox": "^1.2.1",
|
||||
"react-simple-dnd": "^0.1.2",
|
||||
"selection-position": "^1.0.0",
|
||||
"semaphore": "^1.0.5",
|
||||
"slate": "^0.13.6"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import yaml from 'js-yaml';
|
||||
import _ from 'lodash';
|
||||
import { currentBackend } from '../backends/backend';
|
||||
import { authenticate } from '../actions/auth';
|
||||
import * as publishModes from '../constants/publishModes';
|
||||
import * as MediaProxy from '../valueObjects/MediaProxy';
|
||||
|
||||
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
|
||||
@ -62,7 +64,6 @@ export function loadConfig(config) {
|
||||
|
||||
function parseConfig(data) {
|
||||
const config = yaml.safeLoad(data);
|
||||
|
||||
if (typeof CMS_ENV === 'string' && config[CMS_ENV]) {
|
||||
for (var key in config[CMS_ENV]) {
|
||||
if (config[CMS_ENV].hasOwnProperty(key)) {
|
||||
@ -70,5 +71,20 @@ function parseConfig(data) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!('publish_mode' in config) || _.values(publishModes).indexOf(config.publish_mode) === -1) {
|
||||
// Make sure there is a publish workflow mode set
|
||||
config.publish_mode = publishModes.SIMPLE;
|
||||
}
|
||||
|
||||
if (!('public_folder' in config)) {
|
||||
// Make sure there is a public folder
|
||||
config.public_folder = config.media_folder;
|
||||
}
|
||||
|
||||
if (config.public_folder.charAt(0) !== '/') {
|
||||
config.public_folder = '/' + config.public_folder;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
179
src/actions/editorialWorkflow.js
Normal file
179
src/actions/editorialWorkflow.js
Normal file
@ -0,0 +1,179 @@
|
||||
import { currentBackend } from '../backends/backend';
|
||||
import { getMedia } from '../reducers';
|
||||
import { EDITORIAL_WORKFLOW } from '../constants/publishModes';
|
||||
/*
|
||||
* Contant Declarations
|
||||
*/
|
||||
export const UNPUBLISHED_ENTRY_REQUEST = 'UNPUBLISHED_ENTRY_REQUEST';
|
||||
export const UNPUBLISHED_ENTRY_SUCCESS = 'UNPUBLISHED_ENTRY_SUCCESS';
|
||||
|
||||
export const UNPUBLISHED_ENTRIES_REQUEST = 'UNPUBLISHED_ENTRIES_REQUEST';
|
||||
export const UNPUBLISHED_ENTRIES_SUCCESS = 'UNPUBLISHED_ENTRIES_SUCCESS';
|
||||
export const UNPUBLISHED_ENTRIES_FAILURE = 'UNPUBLISHED_ENTRIES_FAILURE';
|
||||
|
||||
export const UNPUBLISHED_ENTRY_PERSIST_REQUEST = 'UNPUBLISHED_ENTRY_PERSIST_REQUEST';
|
||||
export const UNPUBLISHED_ENTRY_PERSIST_SUCCESS = 'UNPUBLISHED_ENTRY_PERSIST_SUCCESS';
|
||||
|
||||
export const UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST';
|
||||
export const UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS';
|
||||
|
||||
export const UNPUBLISHED_ENTRY_PUBLISH_REQUEST = 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST';
|
||||
export const UNPUBLISHED_ENTRY_PUBLISH_SUCCESS = 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS';
|
||||
|
||||
/*
|
||||
* Simple Action Creators (Internal)
|
||||
*/
|
||||
|
||||
function unpublishedEntryLoading(status, slug) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_REQUEST,
|
||||
payload: { status, slug }
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryLoaded(status, entry) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_SUCCESS,
|
||||
payload: { status, entry }
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntriesLoading() {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRIES_REQUEST
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntriesLoaded(entries, pagination) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRIES_SUCCESS,
|
||||
payload: {
|
||||
entries: entries,
|
||||
pages: pagination
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntriesFailed(error) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRIES_FAILURE,
|
||||
error: 'Failed to load entries',
|
||||
payload: error.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function unpublishedEntryPersisting(entry) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_PERSIST_REQUEST,
|
||||
payload: { entry }
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryPersisted(entry) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
|
||||
payload: { entry }
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryPersistedFail(error) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
|
||||
payload: { error }
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST,
|
||||
payload: { collection, slug, oldStatus, newStatus }
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
|
||||
payload: { collection, slug, oldStatus, newStatus }
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryPublishRequest(collection, slug, status) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_PUBLISH_REQUEST,
|
||||
payload: { collection, slug, status }
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryPublished(collection, slug, status) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_PUBLISH_SUCCESS,
|
||||
payload: { collection, slug, status }
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Exported Thunk Action Creators
|
||||
*/
|
||||
|
||||
export function loadUnpublishedEntry(collection, status, slug) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
dispatch(unpublishedEntryLoading(status, slug));
|
||||
backend.unpublishedEntry(collection, slug)
|
||||
.then((entry) => dispatch(unpublishedEntryLoaded(status, entry)));
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUnpublishedEntries() {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
if (state.config.get('publish_mode') !== EDITORIAL_WORKFLOW) return;
|
||||
const backend = currentBackend(state.config);
|
||||
dispatch(unpublishedEntriesLoading());
|
||||
backend.unpublishedEntries().then(
|
||||
(response) => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination)),
|
||||
(error) => dispatch(unpublishedEntriesFailed(error))
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function persistUnpublishedEntry(collection, entry) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const MediaProxies = entry && entry.get('mediaFiles').map(path => getMedia(state, path));
|
||||
dispatch(unpublishedEntryPersisting(entry));
|
||||
backend.persistUnpublishedEntry(state.config, collection, entry, MediaProxies.toJS()).then(
|
||||
() => {
|
||||
dispatch(unpublishedEntryPersisted(entry));
|
||||
},
|
||||
(error) => dispatch(unpublishedEntryPersistedFail(error))
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function updateUnpublishedEntryStatus(collection, slug, oldStatus, newStatus) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
dispatch(unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus));
|
||||
backend.updateUnpublishedEntryStatus(collection, slug, newStatus)
|
||||
.then(() => {
|
||||
dispatch(unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function publishUnpublishedEntry(collection, slug, status) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
dispatch(unpublishedEntryPublishRequest(collection, slug, status));
|
||||
backend.publishUnpublishedEntry(collection, slug, status)
|
||||
.then(() => {
|
||||
dispatch(unpublishedEntryPublished(collection, slug, status));
|
||||
});
|
||||
};
|
||||
}
|
@ -13,10 +13,10 @@ export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS';
|
||||
export const ENTRIES_FAILURE = 'ENTRIES_FAILURE';
|
||||
|
||||
export const DRAFT_CREATE_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY';
|
||||
export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY';
|
||||
export const DRAFT_DISCARD = 'DRAFT_DISCARD';
|
||||
export const DRAFT_CHANGE = 'DRAFT_CHANGE';
|
||||
|
||||
|
||||
export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST';
|
||||
export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS';
|
||||
export const ENTRY_PERSIST_FAILURE = 'ENTRY_PERSIST_FAILURE';
|
||||
@ -102,6 +102,13 @@ function entryPersistFail(collection, entry, error) {
|
||||
};
|
||||
}
|
||||
|
||||
function emmptyDraftCreated(entry) {
|
||||
return {
|
||||
type: DRAFT_CREATE_EMPTY,
|
||||
payload: entry
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Exported simple Action Creators
|
||||
*/
|
||||
@ -153,14 +160,22 @@ export function loadEntries(collection) {
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyDraft(collection) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const newEntry = backend.newEntry(collection);
|
||||
dispatch(emmptyDraftCreated(newEntry));
|
||||
};
|
||||
}
|
||||
|
||||
export function persistEntry(collection, entry) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const MediaProxies = entry.get('mediaFiles').map(path => getMedia(state, path));
|
||||
|
||||
dispatch(entryPersisting(collection, entry));
|
||||
backend.persistEntry(collection, entry, MediaProxies.toJS()).then(
|
||||
backend.persistEntry(state.config, collection, entry, MediaProxies.toJS()).then(
|
||||
() => {
|
||||
dispatch(entryPersisted(collection, entry));
|
||||
},
|
||||
|
@ -1,5 +1,5 @@
|
||||
import history from '../routing/history';
|
||||
import { SEARCH } from '../containers/FindBar';
|
||||
import { SEARCH } from '../components/UI/FindBar/FindBar';
|
||||
|
||||
export const RUN_COMMAND = 'RUN_COMMAND';
|
||||
export const SHOW_COLLECTION = 'SHOW_COLLECTION';
|
||||
@ -10,14 +10,22 @@ export function run(commandName, payload) {
|
||||
return { type: RUN_COMMAND, command: commandName, payload };
|
||||
}
|
||||
|
||||
export function navigateToCollection(collectionName) {
|
||||
return runCommand(SHOW_COLLECTION, { collectionName });
|
||||
}
|
||||
|
||||
export function createNewEntryInCollection(collectionName) {
|
||||
return runCommand(CREATE_COLLECTION, { collectionName });
|
||||
}
|
||||
|
||||
export function runCommand(commandName, payload) {
|
||||
return (dispatch, getState) => {
|
||||
return dispatch => {
|
||||
switch (commandName) {
|
||||
case SHOW_COLLECTION:
|
||||
history.push(`/collections/${payload.collectionName}`);
|
||||
break;
|
||||
case CREATE_COLLECTION:
|
||||
window.alert(`Create a new ${payload.collectionName} - not supported yet`);
|
||||
history.push(`/collections/${payload.collectionName}/entries/new`);
|
||||
break;
|
||||
case HELP:
|
||||
window.alert('Find Bar Help (PLACEHOLDER)\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit.');
|
||||
|
@ -1,6 +1,8 @@
|
||||
import TestRepoBackend from './test-repo/implementation';
|
||||
import GitHubBackend from './github/implementation';
|
||||
import NetlifyGitBackend from './netlify-git/implementation';
|
||||
import { resolveFormat } from '../formats/formats';
|
||||
import { createEntry } from '../valueObjects/Entry';
|
||||
|
||||
class LocalStorageAuthStore {
|
||||
storageKey = 'nf-cms-user';
|
||||
@ -19,7 +21,7 @@ class Backend {
|
||||
constructor(implementation, authStore = null) {
|
||||
this.implementation = implementation;
|
||||
this.authStore = authStore;
|
||||
if (this.implementation == null) {
|
||||
if (this.implementation === null) {
|
||||
throw 'Cannot instantiate a Backend with no implementation';
|
||||
}
|
||||
}
|
||||
@ -57,9 +59,14 @@ class Backend {
|
||||
return this.implementation.entry(collection, slug).then(this.entryWithFormat(collection));
|
||||
}
|
||||
|
||||
entryWithFormat(collection) {
|
||||
newEntry(collection) {
|
||||
const newEntry = createEntry();
|
||||
return this.entryWithFormat(collection)(newEntry);
|
||||
}
|
||||
|
||||
entryWithFormat(collectionOrEntity) {
|
||||
return (entry) => {
|
||||
const format = resolveFormat(collection, entry);
|
||||
const format = resolveFormat(collectionOrEntity, entry);
|
||||
if (entry && entry.raw) {
|
||||
entry.data = format && format.fromFile(entry.raw);
|
||||
}
|
||||
@ -67,21 +74,88 @@ class Backend {
|
||||
};
|
||||
}
|
||||
|
||||
persistEntry(collection, entryDraft, MediaFiles) {
|
||||
const entryData = entryDraft.getIn(['entry', 'data']).toObject();
|
||||
const entryObj = {
|
||||
path: entryDraft.getIn(['entry', 'path']),
|
||||
slug: entryDraft.getIn(['entry', 'slug']),
|
||||
raw: this.entryToRaw(collection, entryData)
|
||||
unpublishedEntries(page, perPage) {
|
||||
return this.implementation.unpublishedEntries(page, perPage).then((response) => {
|
||||
return {
|
||||
pagination: response.pagination,
|
||||
entries: response.entries.map(this.entryWithFormat('editorialWorkflow'))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
unpublishedEntry(collection, slug) {
|
||||
return this.implementation.unpublishedEntry(collection, slug).then(this.entryWithFormat(collection));
|
||||
}
|
||||
|
||||
slugFormatter(template, entry) {
|
||||
var date = new Date();
|
||||
return template.replace(/\{\{([^\}]+)\}\}/g, function(_, name) {
|
||||
switch (name) {
|
||||
case 'year':
|
||||
return date.getFullYear();
|
||||
case 'month':
|
||||
return ('0' + (date.getMonth() + 1)).slice(-2);
|
||||
case 'day':
|
||||
return ('0' + date.getDate()).slice(-2);
|
||||
case 'slug':
|
||||
return entry.getIn(['data', 'title']).trim().toLowerCase().replace(/[^a-z0-9\.\-\_]+/gi, '-');
|
||||
default:
|
||||
return entry.getIn(['data', name]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
persistEntry(config, collection, entryDraft, MediaFiles, options) {
|
||||
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
|
||||
|
||||
const parsedData = {
|
||||
title: entryDraft.getIn(['entry', 'data', 'title'], 'No Title'),
|
||||
description: entryDraft.getIn(['entry', 'data', 'description'], 'No Description'),
|
||||
};
|
||||
|
||||
const commitMessage = (entryDraft.getIn(['entry', 'newRecord']) ? 'Created ' : 'Updated ') +
|
||||
const entryData = entryDraft.getIn(['entry', 'data']).toJS();
|
||||
let entryObj;
|
||||
if (newEntry) {
|
||||
const slug = this.slugFormatter(collection.get('slug'), entryDraft.get('entry'));
|
||||
entryObj = {
|
||||
path: `${collection.get('folder')}/${slug}.md`,
|
||||
slug: slug,
|
||||
raw: this.entryToRaw(collection, entryData)
|
||||
};
|
||||
} else {
|
||||
entryObj = {
|
||||
path: entryDraft.getIn(['entry', 'path']),
|
||||
slug: entryDraft.getIn(['entry', 'slug']),
|
||||
raw: this.entryToRaw(collection, entryData)
|
||||
};
|
||||
}
|
||||
|
||||
const commitMessage = (newEntry ? 'Created ' : 'Updated ') +
|
||||
collection.get('label') + ' “' +
|
||||
entryDraft.getIn(['entry', 'data', 'title']) + '”';
|
||||
|
||||
return this.implementation.persistEntry(collection, entryObj, MediaFiles, { commitMessage });
|
||||
const mode = config.get('publish_mode');
|
||||
|
||||
const collectionName = collection.get('name');
|
||||
|
||||
return this.implementation.persistEntry(entryObj, MediaFiles, {
|
||||
newEntry, parsedData, commitMessage, collectionName, mode, ...options
|
||||
});
|
||||
}
|
||||
|
||||
persistUnpublishedEntry(config, collection, entryDraft, MediaFiles) {
|
||||
return this.persistEntry(config, collection, entryDraft, MediaFiles, { unpublished: true });
|
||||
}
|
||||
|
||||
updateUnpublishedEntryStatus(collection, slug, newStatus) {
|
||||
return this.implementation.updateUnpublishedEntryStatus(collection, slug, newStatus);
|
||||
}
|
||||
|
||||
publishUnpublishedEntry(collection, slug, status) {
|
||||
return this.implementation.publishUnpublishedEntry(collection, slug, status);
|
||||
}
|
||||
|
||||
|
||||
entryToRaw(collection, entry) {
|
||||
const format = resolveFormat(collection, entry);
|
||||
return format && format.toFile(entry);
|
||||
@ -101,6 +175,8 @@ export function resolveBackend(config) {
|
||||
return new Backend(new TestRepoBackend(config), authStore);
|
||||
case 'github':
|
||||
return new Backend(new GitHubBackend(config), authStore);
|
||||
case 'netlify-git':
|
||||
return new Backend(new NetlifyGitBackend(config), authStore);
|
||||
default:
|
||||
throw `Backend not found: ${name}`;
|
||||
}
|
||||
|
422
src/backends/github/API.js
Normal file
422
src/backends/github/API.js
Normal file
@ -0,0 +1,422 @@
|
||||
import LocalForage from 'localforage';
|
||||
import MediaProxy from '../../valueObjects/MediaProxy';
|
||||
import { Base64 } from 'js-base64';
|
||||
import _ from 'lodash';
|
||||
import { SIMPLE, EDITORIAL_WORKFLOW, status } from '../../constants/publishModes';
|
||||
|
||||
const API_ROOT = 'https://api.github.com';
|
||||
|
||||
export default class API {
|
||||
constructor(token, repo, branch) {
|
||||
this.token = token;
|
||||
this.repo = repo;
|
||||
this.branch = branch;
|
||||
this.repoURL = `/repos/${this.repo}`;
|
||||
}
|
||||
|
||||
user() {
|
||||
return this.request('/user');
|
||||
}
|
||||
|
||||
requestHeaders(headers = {}) {
|
||||
return {
|
||||
Authorization: `token ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
};
|
||||
}
|
||||
|
||||
parseJsonResponse(response) {
|
||||
return response.json().then((json) => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(json);
|
||||
}
|
||||
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
urlFor(path, options) {
|
||||
const params = [];
|
||||
if (options.params) {
|
||||
for (const key in options.params) {
|
||||
params.push(`${key}=${encodeURIComponent(options.params[key])}`);
|
||||
}
|
||||
}
|
||||
if (params.length) {
|
||||
path += `?${params.join('&')}`;
|
||||
}
|
||||
return API_ROOT + path;
|
||||
}
|
||||
|
||||
request(path, options = {}) {
|
||||
const headers = this.requestHeaders(options.headers || {});
|
||||
const url = this.urlFor(path, options);
|
||||
return fetch(url, { ...options, headers: headers }).then((response) => {
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
|
||||
checkMetadataRef() {
|
||||
return this.request(`${this.repoURL}/git/refs/meta/_netlify_cms?${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then(response => response.object)
|
||||
.catch(error => {
|
||||
// Meta ref doesn't exist
|
||||
const readme = {
|
||||
raw: '# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.'
|
||||
};
|
||||
|
||||
return this.uploadBlob(readme)
|
||||
.then(item => this.request(`${this.repoURL}/git/trees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }] })
|
||||
}))
|
||||
.then(tree => this.commit('First Commit', tree))
|
||||
.then(response => this.createRef('meta', '_netlify_cms', response.sha))
|
||||
.then(response => response.object);
|
||||
});
|
||||
}
|
||||
|
||||
storeMetadata(key, data) {
|
||||
return this.checkMetadataRef()
|
||||
.then((branchData) => {
|
||||
const fileTree = {
|
||||
[`${key}.json`]: {
|
||||
path: `${key}.json`,
|
||||
raw: JSON.stringify(data),
|
||||
file: true
|
||||
}
|
||||
};
|
||||
|
||||
return this.uploadBlob(fileTree[`${key}.json`])
|
||||
.then(item => this.updateTree(branchData.sha, '/', fileTree))
|
||||
.then(changeTree => this.commit(`Updating “${key}” metadata`, changeTree))
|
||||
.then(response => this.patchRef('meta', '_netlify_cms', response.sha))
|
||||
.then(() => {
|
||||
LocalForage.setItem(`gh.meta.${key}`, {
|
||||
expires: Date.now() + 300000, // In 5 minutes
|
||||
data
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
retrieveMetadata(key) {
|
||||
const cache = LocalForage.getItem(`gh.meta.${key}`);
|
||||
return cache.then((cached) => {
|
||||
if (cached && cached.expires > Date.now()) { return cached.data; }
|
||||
|
||||
return this.request(`${this.repoURL}/contents/${key}.json`, {
|
||||
params: { ref: 'refs/meta/_netlify_cms' },
|
||||
headers: { Accept: 'application/vnd.github.VERSION.raw' },
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then(response => JSON.parse(response));
|
||||
});
|
||||
}
|
||||
|
||||
readFile(path, sha, branch = this.branch) {
|
||||
const cache = sha ? LocalForage.getItem(`gh.${sha}`) : Promise.resolve(null);
|
||||
return cache.then((cached) => {
|
||||
if (cached) { return cached; }
|
||||
|
||||
return this.request(`${this.repoURL}/contents/${path}`, {
|
||||
headers: { Accept: 'application/vnd.github.VERSION.raw' },
|
||||
params: { ref: branch },
|
||||
cache: false
|
||||
}).then((result) => {
|
||||
if (sha) {
|
||||
LocalForage.setItem(`gh.${sha}`, result);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
listFiles(path) {
|
||||
return this.request(`${this.repoURL}/contents/${path}`, {
|
||||
params: { ref: this.branch }
|
||||
});
|
||||
}
|
||||
|
||||
readUnpublishedBranchFile(contentKey) {
|
||||
let metaData;
|
||||
return this.retrieveMetadata(contentKey)
|
||||
.then(data => {
|
||||
metaData = data;
|
||||
return this.readFile(data.objects.entry, null, data.branch);
|
||||
})
|
||||
.then(file => {
|
||||
return { metaData, file };
|
||||
});
|
||||
}
|
||||
|
||||
listUnpublishedBranches() {
|
||||
return this.request(`${this.repoURL}/git/refs/heads/cms`);
|
||||
}
|
||||
|
||||
persistFiles(entry, mediaFiles, options) {
|
||||
let filename, part, parts, subtree;
|
||||
const fileTree = {};
|
||||
const uploadPromises = [];
|
||||
|
||||
const files = mediaFiles.concat(entry);
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.uploaded) { return; }
|
||||
uploadPromises.push(this.uploadBlob(file));
|
||||
parts = file.path.split('/').filter((part) => part);
|
||||
filename = parts.pop();
|
||||
subtree = fileTree;
|
||||
while (part = parts.shift()) {
|
||||
subtree[part] = subtree[part] || {};
|
||||
subtree = subtree[part];
|
||||
}
|
||||
subtree[filename] = file;
|
||||
file.file = true;
|
||||
});
|
||||
return Promise.all(uploadPromises).then(() => {
|
||||
if (!options.mode || (options.mode && options.mode === SIMPLE)) {
|
||||
return this.getBranch()
|
||||
.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 => file.path);
|
||||
return this.editorialWorkflowGit(fileTree, entry, mediaFilesList, options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
editorialWorkflowGit(fileTree, entry, filesList, options) {
|
||||
const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug;
|
||||
const branchName = `cms/${contentKey}`;
|
||||
const unpublished = options.unpublished || false;
|
||||
|
||||
if (!unpublished) {
|
||||
// Open new editorial review workflow for this entry - Create new metadata and commit to new branch
|
||||
const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug;
|
||||
const branchName = `cms/${contentKey}`;
|
||||
|
||||
return this.getBranch()
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(commitResponse => this.createBranch(branchName, commitResponse.sha))
|
||||
.then(branchResponse => this.createPR(options.commitMessage, branchName))
|
||||
.then((prResponse) => {
|
||||
return this.user().then(user => {
|
||||
return user.name ? user.name : user.login;
|
||||
})
|
||||
.then(username => this.storeMetadata(contentKey, {
|
||||
type: 'PR',
|
||||
pr: {
|
||||
number: prResponse.number,
|
||||
head: prResponse.head && prResponse.head.sha
|
||||
},
|
||||
user: username,
|
||||
status: status.first(),
|
||||
branch: branchName,
|
||||
collection: options.collectionName,
|
||||
title: options.parsedData && options.parsedData.title,
|
||||
description: options.parsedData && options.parsedData.description,
|
||||
objects: {
|
||||
entry: entry.path,
|
||||
files: filesList
|
||||
},
|
||||
timeStamp: new Date().toISOString()
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
// Entry is already on editorial review workflow - just update metadata and commit to existing branch
|
||||
return this.getBranch(branchName)
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then((response) => {
|
||||
const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug;
|
||||
const branchName = `cms/${contentKey}`;
|
||||
return this.user().then(user => {
|
||||
return user.name ? user.name : user.login;
|
||||
})
|
||||
.then(username => this.retrieveMetadata(contentKey))
|
||||
.then(metadata => {
|
||||
let files = metadata.objects && metadata.objects.files || [];
|
||||
files = files.concat(filesList);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
title: options.parsedData && options.parsedData.title,
|
||||
description: options.parsedData && options.parsedData.description,
|
||||
objects: {
|
||||
entry: entry.path,
|
||||
files: _.uniq(files)
|
||||
},
|
||||
timeStamp: new Date().toISOString()
|
||||
};
|
||||
})
|
||||
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata))
|
||||
.then(this.patchBranch(branchName, response.sha));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateUnpublishedEntryStatus(collection, slug, status) {
|
||||
const contentKey = collection ? `${collection}-${slug}` : slug;
|
||||
return this.retrieveMetadata(contentKey)
|
||||
.then(metadata => {
|
||||
return {
|
||||
...metadata,
|
||||
status
|
||||
};
|
||||
})
|
||||
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata));
|
||||
}
|
||||
|
||||
publishUnpublishedEntry(collection, slug, status) {
|
||||
const contentKey = collection ? `${collection}-${slug}` : slug;
|
||||
return this.retrieveMetadata(contentKey)
|
||||
.then(metadata => {
|
||||
const headSha = metadata.pr && metadata.pr.head;
|
||||
const number = metadata.pr && metadata.pr.number;
|
||||
return this.mergePR(headSha, number);
|
||||
})
|
||||
.then(() => this.deleteBranch(`cms/${contentKey}`));
|
||||
}
|
||||
|
||||
createRef(type, name, sha) {
|
||||
return this.request(`${this.repoURL}/git/refs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }),
|
||||
});
|
||||
}
|
||||
|
||||
patchRef(type, name, sha) {
|
||||
return this.request(`${this.repoURL}/git/refs/${type}/${name}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ sha })
|
||||
});
|
||||
}
|
||||
|
||||
deleteRef(type, name, sha) {
|
||||
return this.request(`${this.repoURL}/git/refs/${type}/${name}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
getBranch(branch = this.branch) {
|
||||
return this.request(`${this.repoURL}/branches/${branch}`);
|
||||
}
|
||||
|
||||
createBranch(branchName, sha) {
|
||||
return this.createRef('heads', branchName, sha);
|
||||
}
|
||||
|
||||
patchBranch(branchName, sha) {
|
||||
return this.patchRef('heads', branchName, sha);
|
||||
}
|
||||
|
||||
deleteBranch(branchName) {
|
||||
return this.deleteRef('heads', branchName);
|
||||
}
|
||||
|
||||
createPR(title, head, base = 'master') {
|
||||
const body = 'Automatically generated by Netlify CMS';
|
||||
return this.request(`${this.repoURL}/pulls`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, body, head, base }),
|
||||
});
|
||||
}
|
||||
|
||||
mergePR(headSha, number) {
|
||||
return this.request(`${this.repoURL}/pulls/${number}/merge`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
commit_message: 'Automatically generated. Merged on Netlify CMS.',
|
||||
sha: headSha
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
getTree(sha) {
|
||||
return sha ? this.request(`${this.repoURL}/git/trees/${sha}`) : Promise.resolve({ tree: [] });
|
||||
}
|
||||
|
||||
toBase64(str) {
|
||||
return Promise.resolve(
|
||||
Base64.encode(str)
|
||||
);
|
||||
}
|
||||
|
||||
uploadBlob(item) {
|
||||
const content = item instanceof MediaProxy ? item.toBase64() : this.toBase64(item.raw);
|
||||
|
||||
return content.then((contentBase64) => {
|
||||
return this.request(`${this.repoURL}/git/blobs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content: contentBase64,
|
||||
encoding: 'base64'
|
||||
})
|
||||
}).then((response) => {
|
||||
item.sha = response.sha;
|
||||
item.uploaded = true;
|
||||
return item;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateTree(sha, path, fileTree) {
|
||||
return this.getTree(sha)
|
||||
.then((tree) => {
|
||||
var obj, filename, fileOrDir;
|
||||
var updates = [];
|
||||
var added = {};
|
||||
|
||||
for (var i = 0, len = tree.tree.length; i < len; i++) {
|
||||
obj = tree.tree[i];
|
||||
if (fileOrDir = fileTree[obj.path]) {
|
||||
added[obj.path] = true;
|
||||
if (fileOrDir.file) {
|
||||
updates.push({ path: obj.path, mode: obj.mode, type: obj.type, sha: fileOrDir.sha });
|
||||
} else {
|
||||
updates.push(this.updateTree(obj.sha, obj.path, fileOrDir));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (filename in fileTree) {
|
||||
fileOrDir = fileTree[filename];
|
||||
if (added[filename]) { continue; }
|
||||
updates.push(
|
||||
fileOrDir.file ?
|
||||
{ path: filename, mode: '100644', type: 'blob', sha: fileOrDir.sha } :
|
||||
this.updateTree(null, filename, fileOrDir)
|
||||
);
|
||||
}
|
||||
return Promise.all(updates)
|
||||
.then((updates) => {
|
||||
return this.request(`${this.repoURL}/git/trees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ base_tree: sha, tree: updates })
|
||||
});
|
||||
}).then((response) => {
|
||||
return { path: path, mode: '040000', type: 'tree', sha: response.sha, parentSha: sha };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
commit(message, changeTree) {
|
||||
const tree = changeTree.sha;
|
||||
const parents = changeTree.parentSha ? [changeTree.parentSha] : [];
|
||||
return this.request(`${this.repoURL}/git/commits`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message, tree, parents })
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@ -21,9 +21,9 @@ export default class AuthenticationPage extends React.Component {
|
||||
auth = new Authenticator();
|
||||
}
|
||||
|
||||
auth.authenticate({provider: 'github', scope: 'repo'}, (err, data) => {
|
||||
auth.authenticate({ provider: 'github', scope: 'repo' }, (err, data) => {
|
||||
if (err) {
|
||||
this.setState({loginError: err.toString()});
|
||||
this.setState({ loginError: err.toString() });
|
||||
return;
|
||||
}
|
||||
this.props.onLogin(data);
|
||||
|
@ -1,185 +1,9 @@
|
||||
import LocalForage from 'localforage';
|
||||
import MediaProxy from '../../valueObjects/MediaProxy';
|
||||
import semaphore from 'semaphore';
|
||||
import { createEntry } from '../../valueObjects/Entry';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import { Base64 } from 'js-base64';
|
||||
import API from './API';
|
||||
|
||||
const API_ROOT = 'https://api.github.com';
|
||||
|
||||
class API {
|
||||
constructor(token, repo, branch) {
|
||||
this.token = token;
|
||||
this.repo = repo;
|
||||
this.branch = branch;
|
||||
this.repoURL = `/repos/${this.repo}`;
|
||||
}
|
||||
|
||||
user() {
|
||||
return this.request('/user');
|
||||
}
|
||||
|
||||
readFile(path, sha) {
|
||||
const cache = sha ? LocalForage.getItem(`gh.${sha}`) : Promise.resolve(null);
|
||||
return cache.then((cached) => {
|
||||
if (cached) { return cached; }
|
||||
|
||||
return this.request(`${this.repoURL}/contents/${path}`, {
|
||||
headers: { Accept: 'application/vnd.github.VERSION.raw' },
|
||||
body: { ref: this.branch },
|
||||
cache: false
|
||||
}).then((result) => {
|
||||
if (sha) {
|
||||
LocalForage.setItem(`gh.${sha}`, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
listFiles(path) {
|
||||
return this.request(`${this.repoURL}/contents/${path}`, {
|
||||
body: { ref: this.branch }
|
||||
});
|
||||
}
|
||||
|
||||
persistFiles(collection, entry, mediaFiles, options) {
|
||||
let filename, part, parts, subtree;
|
||||
const fileTree = {};
|
||||
const files = [];
|
||||
|
||||
mediaFiles.concat(entry).forEach((file) => {
|
||||
if (file.uploaded) { return; }
|
||||
files.push(this.uploadBlob(file));
|
||||
parts = file.path.split('/').filter((part) => part);
|
||||
filename = parts.pop();
|
||||
subtree = fileTree;
|
||||
while (part = parts.shift()) {
|
||||
subtree[part] = subtree[part] || {};
|
||||
subtree = subtree[part];
|
||||
}
|
||||
subtree[filename] = file;
|
||||
file.file = true;
|
||||
});
|
||||
|
||||
return Promise.all(files)
|
||||
.then(() => this.getBranch())
|
||||
.then((branchData) => {
|
||||
return this.updateTree(branchData.commit.sha, '/', fileTree);
|
||||
})
|
||||
.then((changeTree) => {
|
||||
return this.request(`${this.repoURL}/git/commits`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message: options.commitMessage, tree: changeTree.sha, parents: [changeTree.parentSha] })
|
||||
});
|
||||
}).then((response) => {
|
||||
return this.request(`${this.repoURL}/git/refs/heads/${this.branch}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ sha: response.sha })
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
requestHeaders(headers = {}) {
|
||||
return {
|
||||
Authorization: `token ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
};
|
||||
}
|
||||
|
||||
parseJsonResponse(response) {
|
||||
return response.json().then((json) => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(json);
|
||||
}
|
||||
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
request(path, options = {}) {
|
||||
const headers = this.requestHeaders(options.headers || {});
|
||||
return fetch(API_ROOT + path, { ...options, headers: headers }).then((response) => {
|
||||
if (response.headers.get('Content-Type').match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
|
||||
getBranch() {
|
||||
return this.request(`${this.repoURL}/branches/${this.branch}`);
|
||||
}
|
||||
|
||||
getTree(sha) {
|
||||
return sha ? this.request(`${this.repoURL}/git/trees/${sha}`) : Promise.resolve({ tree: [] });
|
||||
}
|
||||
|
||||
toBase64(str) {
|
||||
return Promise.resolve(
|
||||
Base64.encode(str)
|
||||
);
|
||||
}
|
||||
|
||||
uploadBlob(item) {
|
||||
const content = item instanceof MediaProxy ? item.toBase64() : this.toBase64(item.raw);
|
||||
|
||||
return content.then((contentBase64) => {
|
||||
return this.request(`${this.repoURL}/git/blobs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content: contentBase64,
|
||||
encoding: 'base64'
|
||||
})
|
||||
}).then((response) => {
|
||||
item.sha = response.sha;
|
||||
item.uploaded = true;
|
||||
return item;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateTree(sha, path, fileTree) {
|
||||
return this.getTree(sha)
|
||||
.then((tree) => {
|
||||
var obj, filename, fileOrDir;
|
||||
var updates = [];
|
||||
var added = {};
|
||||
|
||||
for (var i = 0, len = tree.tree.length; i < len; i++) {
|
||||
obj = tree.tree[i];
|
||||
if (fileOrDir = fileTree[obj.path]) {
|
||||
added[obj.path] = true;
|
||||
if (fileOrDir.file) {
|
||||
updates.push({ path: obj.path, mode: obj.mode, type: obj.type, sha: fileOrDir.sha });
|
||||
} else {
|
||||
updates.push(this.updateTree(obj.sha, obj.path, fileOrDir));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (filename in fileTree) {
|
||||
fileOrDir = fileTree[filename];
|
||||
if (added[filename]) { continue; }
|
||||
updates.push(
|
||||
fileOrDir.file ?
|
||||
{ path: filename, mode: '100644', type: 'blob', sha: fileOrDir.sha } :
|
||||
this.updateTree(null, filename, fileOrDir)
|
||||
);
|
||||
}
|
||||
return Promise.all(updates)
|
||||
.then((updates) => {
|
||||
return this.request(`${this.repoURL}/git/trees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ base_tree: sha, tree: updates })
|
||||
});
|
||||
}).then((response) => {
|
||||
return { path: path, mode: '040000', type: 'tree', sha: response.sha, parentSha: sha };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
export default class GitHub {
|
||||
constructor(config) {
|
||||
@ -188,6 +12,7 @@ export default class GitHub {
|
||||
throw 'The GitHub backend needs a "repo" in the backend configuration.';
|
||||
}
|
||||
this.repo = config.getIn(['backend', 'repo']);
|
||||
this.branch = config.getIn(['backend', 'branch']) || 'master';
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
@ -207,15 +32,22 @@ export default class GitHub {
|
||||
}
|
||||
|
||||
entries(collection) {
|
||||
return this.api.listFiles(collection.get('folder')).then((files) => (
|
||||
Promise.all(files.map((file) => (
|
||||
this.api.readFile(file.path, file.sha).then((data) => {
|
||||
file.slug = file.path.split('/').pop().replace(/\.[^\.]+$/, '');
|
||||
file.raw = data;
|
||||
return file;
|
||||
})
|
||||
)))
|
||||
)).then((entries) => ({
|
||||
return this.api.listFiles(collection.get('folder')).then((files) => {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [];
|
||||
files.map((file) => {
|
||||
promises.push(new Promise((resolve, reject) => {
|
||||
return sem.take(() => this.api.readFile(file.path, file.sha).then((data) => {
|
||||
resolve(createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data));
|
||||
sem.leave();
|
||||
}).catch((err) => {
|
||||
sem.leave();
|
||||
reject(err);
|
||||
}));
|
||||
}));
|
||||
});
|
||||
return Promise.all(promises);
|
||||
}).then((entries) => ({
|
||||
pagination: {},
|
||||
entries
|
||||
}));
|
||||
@ -227,7 +59,51 @@ export default class GitHub {
|
||||
));
|
||||
}
|
||||
|
||||
persistEntry(collection, entry, mediaFiles = [], options = {}) {
|
||||
return this.api.persistFiles(collection, entry, mediaFiles, options);
|
||||
persistEntry(entry, mediaFiles = [], options = {}) {
|
||||
return this.api.persistFiles(entry, mediaFiles, options);
|
||||
}
|
||||
|
||||
unpublishedEntries() {
|
||||
return this.api.listUnpublishedBranches().then((branches) => {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [];
|
||||
branches.map((branch) => {
|
||||
promises.push(new Promise((resolve, reject) => {
|
||||
const contentKey = branch.ref.split('refs/heads/cms/').pop();
|
||||
return sem.take(() => this.api.readUnpublishedBranchFile(contentKey).then((data) => {
|
||||
const entryPath = data.metaData.objects.entry;
|
||||
const entry = createEntry(entryPath, entryPath.split('/').pop().replace(/\.[^\.]+$/, ''), data.file);
|
||||
entry.metaData = data.metaData;
|
||||
resolve(entry);
|
||||
sem.leave();
|
||||
}).catch((err) => {
|
||||
sem.leave();
|
||||
reject(err);
|
||||
}));
|
||||
}));
|
||||
});
|
||||
return Promise.all(promises);
|
||||
}).then((entries) => {
|
||||
return {
|
||||
pagination: {},
|
||||
entries
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
unpublishedEntry(collection, slug) {
|
||||
return this.unpublishedEntries().then((response) => (
|
||||
response.entries.filter((entry) => (
|
||||
entry.metaData && entry.metaData.collection === collection.get('name') && entry.slug === slug
|
||||
))[0]
|
||||
));
|
||||
}
|
||||
|
||||
updateUnpublishedEntryStatus(collection, slug, newStatus) {
|
||||
return this.api.updateUnpublishedEntryStatus(collection, slug, newStatus);
|
||||
}
|
||||
|
||||
publishUnpublishedEntry(collection, slug, status) {
|
||||
return this.api.publishUnpublishedEntry(collection, slug, status);
|
||||
}
|
||||
}
|
||||
|
287
src/backends/netlify-git/API.js
Normal file
287
src/backends/netlify-git/API.js
Normal file
@ -0,0 +1,287 @@
|
||||
import LocalForage from 'localforage';
|
||||
import MediaProxy from '../../valueObjects/MediaProxy';
|
||||
import { Base64 } from 'js-base64';
|
||||
|
||||
export default class API {
|
||||
constructor(token, url, branch) {
|
||||
this.token = token;
|
||||
this.url = url;
|
||||
this.branch = branch;
|
||||
this.repoURL = '';
|
||||
}
|
||||
|
||||
requestHeaders(headers = {}) {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
};
|
||||
}
|
||||
|
||||
parseJsonResponse(response) {
|
||||
return response.json().then((json) => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(json);
|
||||
}
|
||||
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
urlFor(path, options) {
|
||||
const params = [];
|
||||
if (options.params) {
|
||||
for (const key in options.params) {
|
||||
params.push(`${key}=${encodeURIComponent(options.params[key])}`);
|
||||
}
|
||||
}
|
||||
if (params.length) {
|
||||
path += `?${params.join('&')}`;
|
||||
}
|
||||
return this.url + path;
|
||||
}
|
||||
|
||||
request(path, options = {}) {
|
||||
const headers = this.requestHeaders(options.headers || {});
|
||||
const url = this.urlFor(path, options);
|
||||
return fetch(url, { ...options, headers: headers }).then((response) => {
|
||||
if (response.headers.get('Content-Type').match(/json/) && !options.raw) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
|
||||
checkMetadataRef() {
|
||||
return this.request(`${this.repoURL}/refs/meta/_netlify_cms`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then(response => response.object)
|
||||
.catch(error => {
|
||||
// Meta ref doesn't exist
|
||||
const readme = {
|
||||
raw: '# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.'
|
||||
};
|
||||
|
||||
return this.uploadBlob(readme)
|
||||
.then(item => this.request(`${this.repoURL}/trees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }] })
|
||||
}))
|
||||
.then(tree => this.commit('First Commit', tree))
|
||||
.then(response => this.createRef('meta', '_netlify_cms', response.sha))
|
||||
.then(response => response.object);
|
||||
});
|
||||
}
|
||||
|
||||
storeMetadata(key, data) {
|
||||
return this.checkMetadataRef()
|
||||
.then((branchData) => {
|
||||
const fileTree = {
|
||||
[`${key}.json`]: {
|
||||
path: `${key}.json`,
|
||||
raw: JSON.stringify(data),
|
||||
file: true
|
||||
}
|
||||
};
|
||||
|
||||
return this.uploadBlob(fileTree[`${key}.json`])
|
||||
.then(item => this.updateTree(branchData.sha, '/', fileTree))
|
||||
.then(changeTree => this.commit(`Updating “${key}” metadata`, changeTree))
|
||||
.then(response => this.patchRef('meta', '_netlify_cms', response.sha));
|
||||
}).then(() => {
|
||||
LocalForage.setItem(`gh.meta.${key}`, {
|
||||
expires: Date.now() + 300000, // In 5 minutes
|
||||
data
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
retrieveMetadata(key, data) {
|
||||
const cache = LocalForage.getItem(`gh.meta.${key}`);
|
||||
return cache.then((cached) => {
|
||||
if (cached && cached.expires > Date.now()) { return cached.data; }
|
||||
|
||||
return this.request(`${this.repoURL}/files/${key}.json?ref=refs/meta/_netlify_cms`, {
|
||||
params: { ref: 'refs/meta/_netlify_cms' },
|
||||
headers: { 'Content-Type': 'application/vnd.netlify.raw' },
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then(response => JSON.parse(response));
|
||||
});
|
||||
}
|
||||
|
||||
readFile(path, sha, branch = this.branch) {
|
||||
const cache = sha ? LocalForage.getItem(`gh.${sha}`) : Promise.resolve(null);
|
||||
return cache.then((cached) => {
|
||||
if (cached) { return cached; }
|
||||
|
||||
return this.request(`${this.repoURL}/files/${path}`, {
|
||||
headers: { 'Content-Type': 'application/vnd.netlify.raw' },
|
||||
params: { ref: branch },
|
||||
cache: false,
|
||||
raw: true
|
||||
}).then((result) => {
|
||||
if (sha) {
|
||||
LocalForage.setItem(`gh.${sha}`, result);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
listFiles(path) {
|
||||
return this.request(`${this.repoURL}/files/${path}`, {
|
||||
params: { ref: this.branch }
|
||||
});
|
||||
}
|
||||
|
||||
persistFiles(entry, mediaFiles, options) {
|
||||
let filename, part, parts, subtree;
|
||||
const fileTree = {};
|
||||
const uploadPromises = [];
|
||||
|
||||
const files = mediaFiles.concat(entry);
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.uploaded) { return; }
|
||||
uploadPromises.push(this.uploadBlob(file));
|
||||
parts = file.path.split('/').filter((part) => part);
|
||||
filename = parts.pop();
|
||||
subtree = fileTree;
|
||||
while (part = parts.shift()) {
|
||||
subtree[part] = subtree[part] || {};
|
||||
subtree = subtree[part];
|
||||
}
|
||||
subtree[filename] = file;
|
||||
file.file = true;
|
||||
});
|
||||
return Promise.all(uploadPromises)
|
||||
.then(() => this.getBranch())
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then((response) => this.patchBranch(this.branch, response.sha));
|
||||
}
|
||||
|
||||
createRef(type, name, sha) {
|
||||
return this.request(`${this.repoURL}/refs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }),
|
||||
});
|
||||
}
|
||||
|
||||
patchRef(type, name, sha) {
|
||||
return this.request(`${this.repoURL}/refs/${type}/${name}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ sha })
|
||||
});
|
||||
}
|
||||
|
||||
deleteRef(type, name, sha) {
|
||||
return this.request(`${this.repoURL}/refs/${type}/${name}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
getBranch(branch = this.branch) {
|
||||
return this.request(`${this.repoURL}/refs/heads/${this.branch}`);
|
||||
}
|
||||
|
||||
createBranch(branchName, sha) {
|
||||
return this.createRef('heads', branchName, sha);
|
||||
}
|
||||
|
||||
patchBranch(branchName, sha) {
|
||||
return this.patchRef('heads', branchName, sha);
|
||||
}
|
||||
|
||||
deleteBranch(branchName) {
|
||||
return this.deleteRef('heads', branchName);
|
||||
}
|
||||
|
||||
createPR(title, head, base = 'master') {
|
||||
const body = 'Automatically generated by Netlify CMS';
|
||||
return this.request(`${this.repoURL}/pulls`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, body, head, base }),
|
||||
});
|
||||
}
|
||||
|
||||
getTree(sha) {
|
||||
return sha ? this.request(`${this.repoURL}/trees/${sha}`) : Promise.resolve({ tree: [] });
|
||||
}
|
||||
|
||||
toBase64(str) {
|
||||
return Promise.resolve(
|
||||
Base64.encode(str)
|
||||
);
|
||||
}
|
||||
|
||||
uploadBlob(item) {
|
||||
const content = item instanceof MediaProxy ? item.toBase64() : this.toBase64(item.raw);
|
||||
|
||||
return content.then((contentBase64) => {
|
||||
return this.request(`${this.repoURL}/blobs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content: contentBase64,
|
||||
encoding: 'base64'
|
||||
})
|
||||
}).then((response) => {
|
||||
item.sha = response.sha;
|
||||
item.uploaded = true;
|
||||
return item;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateTree(sha, path, fileTree) {
|
||||
return this.getTree(sha)
|
||||
.then((tree) => {
|
||||
var obj, filename, fileOrDir;
|
||||
var updates = [];
|
||||
var added = {};
|
||||
|
||||
for (var i = 0, len = tree.tree.length; i < len; i++) {
|
||||
obj = tree.tree[i];
|
||||
if (fileOrDir = fileTree[obj.path]) {
|
||||
added[obj.path] = true;
|
||||
if (fileOrDir.file) {
|
||||
updates.push({ path: obj.path, mode: obj.mode, type: obj.type, sha: fileOrDir.sha });
|
||||
} else {
|
||||
updates.push(this.updateTree(obj.sha, obj.path, fileOrDir));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (filename in fileTree) {
|
||||
fileOrDir = fileTree[filename];
|
||||
if (added[filename]) { continue; }
|
||||
updates.push(
|
||||
fileOrDir.file ?
|
||||
{ path: filename, mode: '100644', type: 'blob', sha: fileOrDir.sha } :
|
||||
this.updateTree(null, filename, fileOrDir)
|
||||
);
|
||||
}
|
||||
return Promise.all(updates)
|
||||
.then((updates) => {
|
||||
return this.request(`${this.repoURL}/trees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ base_tree: sha, tree: updates })
|
||||
});
|
||||
}).then((response) => {
|
||||
return { path: path, mode: '040000', type: 'tree', sha: response.sha, parentSha: sha };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
commit(message, changeTree) {
|
||||
const tree = changeTree.sha;
|
||||
const parents = changeTree.parentSha ? [changeTree.parentSha] : [];
|
||||
return this.request(`${this.repoURL}/commits`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message, tree, parents })
|
||||
});
|
||||
}
|
||||
|
||||
}
|
60
src/backends/netlify-git/AuthenticationPage.js
Normal file
60
src/backends/netlify-git/AuthenticationPage.js
Normal file
@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
|
||||
export default class AuthenticationPage extends React.Component {
|
||||
static propTypes = {
|
||||
onLogin: React.PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
this.handleLogin = this.handleLogin.bind(this);
|
||||
}
|
||||
|
||||
handleLogin(e) {
|
||||
e.preventDefault();
|
||||
const { email, password } = this.state;
|
||||
this.setState({ authenticating: true });
|
||||
fetch(`${AuthenticationPage.url}/token`, {
|
||||
method: 'POST',
|
||||
body: 'grant_type=client_credentials',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': 'Basic ' + btoa(`${email}:${password}`)
|
||||
}
|
||||
}).then((response) => {
|
||||
console.log(response);
|
||||
if (response.ok) {
|
||||
return response.json().then((data) => {
|
||||
this.props.onLogin(Object.assign({ email }, data));
|
||||
});
|
||||
}
|
||||
response.json().then((data) => {
|
||||
this.setState({ loginError: data.msg });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleChange(key) {
|
||||
return (e) => {
|
||||
this.setState({ [key]: e.target.value });
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loginError } = this.state;
|
||||
|
||||
return <form onSubmit={this.handleLogin}>
|
||||
{loginError && <p>{loginError}</p>}
|
||||
<p>
|
||||
<label>Your Email: <input type="email" onChange={this.handleChange('email')}/></label>
|
||||
</p>
|
||||
<p>
|
||||
<label>Your Password: <input type="password" onChange={this.handleChange('password')}/></label>
|
||||
</p>
|
||||
<p>
|
||||
<button>Login</button>
|
||||
</p>
|
||||
</form>;
|
||||
}
|
||||
}
|
62
src/backends/netlify-git/implementation.js
Normal file
62
src/backends/netlify-git/implementation.js
Normal file
@ -0,0 +1,62 @@
|
||||
import semaphore from 'semaphore';
|
||||
import { createEntry } from '../../valueObjects/Entry';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import API from './API';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
export default class NetlifyGit {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
if (config.getIn(['backend', 'url']) == null) {
|
||||
throw 'The netlify-git backend needs a "url" in the backend configuration.';
|
||||
}
|
||||
this.url = config.getIn(['backend', 'url']);
|
||||
this.branch = config.getIn(['backend', 'branch']) || 'master';
|
||||
AuthenticationPage.url = this.url;
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
||||
setUser(user) {
|
||||
this.api = new API(user.access_token, this.url, this.branch || 'master');
|
||||
}
|
||||
|
||||
authenticate(state) {
|
||||
return Promise.resolve(state);
|
||||
}
|
||||
|
||||
entries(collection) {
|
||||
return this.api.listFiles(collection.get('folder')).then((files) => {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [];
|
||||
files.map((file) => {
|
||||
promises.push(new Promise((resolve, reject) => {
|
||||
return sem.take(() => this.api.readFile(file.path, file.sha).then((data) => {
|
||||
resolve(createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data));
|
||||
sem.leave();
|
||||
}).catch((err) => {
|
||||
sem.leave();
|
||||
reject(err);
|
||||
}));
|
||||
}));
|
||||
});
|
||||
return Promise.all(promises);
|
||||
}).then((entries) => ({
|
||||
pagination: {},
|
||||
entries
|
||||
}));
|
||||
}
|
||||
|
||||
entry(collection, slug) {
|
||||
return this.entries(collection).then((response) => (
|
||||
response.entries.filter((entry) => entry.slug === slug)[0]
|
||||
));
|
||||
}
|
||||
|
||||
persistEntry(entry, mediaFiles = [], options = {}) {
|
||||
return this.api.persistFiles(entry, mediaFiles, options);
|
||||
}
|
||||
}
|
@ -7,7 +7,7 @@ export default class AuthenticationPage extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {email: ''};
|
||||
this.state = { email: '' };
|
||||
this.handleLogin = this.handleLogin.bind(this);
|
||||
this.handleEmailChange = this.handleEmailChange.bind(this);
|
||||
}
|
||||
@ -18,7 +18,7 @@ export default class AuthenticationPage extends React.Component {
|
||||
}
|
||||
|
||||
handleEmailChange(e) {
|
||||
this.setState({email: e.target.value});
|
||||
this.setState({ email: e.target.value });
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import { createEntry } from '../../valueObjects/Entry';
|
||||
|
||||
function getSlug(path) {
|
||||
const m = path.match(/([^\/]+?)(\.[^\/\.]+)?$/);
|
||||
@ -28,11 +29,7 @@ export default class TestRepo {
|
||||
const folder = collection.get('folder');
|
||||
if (folder) {
|
||||
for (var path in window.repoFiles[folder]) {
|
||||
entries.push({
|
||||
path: folder + '/' + path,
|
||||
slug: getSlug(path),
|
||||
raw: window.repoFiles[folder][path].content
|
||||
});
|
||||
entries.push(createEntry(folder + '/' + path, getSlug(path), window.repoFiles[folder][path].content));
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,11 +45,17 @@ export default class TestRepo {
|
||||
));
|
||||
}
|
||||
|
||||
persistEntry(collection, entry, mediaFiles = []) {
|
||||
persistEntry(entry, mediaFiles = [], options) {
|
||||
const newEntry = options.newEntry || false;
|
||||
const folder = entry.path.substring(0, entry.path.lastIndexOf('/'));
|
||||
const fileName = entry.path.substring(entry.path.lastIndexOf('/') + 1);
|
||||
window.repoFiles[folder][fileName]['content'] = entry.raw;
|
||||
if (newEntry) {
|
||||
window.repoFiles[folder][fileName] = { content: entry.raw };
|
||||
} else {
|
||||
window.repoFiles[folder][fileName]['content'] = entry.raw;
|
||||
}
|
||||
mediaFiles.forEach(media => media.uploaded = true);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,26 +1,29 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Widgets from './Widgets';
|
||||
import { resolveWidget } from './Widgets';
|
||||
|
||||
export default class ControlPane extends React.Component {
|
||||
controlFor(field) {
|
||||
const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props;
|
||||
const widget = Widgets[field.get('widget')] || Widgets._unknown;
|
||||
return React.createElement(widget.Control, {
|
||||
field: field,
|
||||
value: entry.getIn(['data', field.get('name')]),
|
||||
onChange: (value) => onChange(entry.setIn(['data', field.get('name')], value)),
|
||||
onAddMedia: onAddMedia,
|
||||
onRemoveMedia: onRemoveMedia,
|
||||
getMedia: getMedia
|
||||
});
|
||||
const widget = resolveWidget(field.get('widget'));
|
||||
return <div className="cms-control">
|
||||
<label>{field.get('label')}</label>
|
||||
{React.createElement(widget.control, {
|
||||
field: field,
|
||||
value: entry.getIn(['data', field.get('name')]),
|
||||
onChange: (value) => onChange(entry.setIn(['data', field.get('name')], value)),
|
||||
onAddMedia: onAddMedia,
|
||||
onRemoveMedia: onRemoveMedia,
|
||||
getMedia: getMedia
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { collection } = this.props;
|
||||
if (!collection) { return null; }
|
||||
return <div>
|
||||
{collection.get('fields').map((field) => <div key={field.get('name')}>{this.controlFor(field)}</div>)}
|
||||
{collection.get('fields').map((field) => <div key={field.get('name')} className="cms-widget">{this.controlFor(field)}</div>)}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
26
src/components/EntryEditor.css
Normal file
26
src/components/EntryEditor.css
Normal file
@ -0,0 +1,26 @@
|
||||
.entryEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
.footer {
|
||||
background: #fff;
|
||||
height: 45px;
|
||||
border-top: 1px solid #e8eae8;
|
||||
padding: 10px 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
.controlPane {
|
||||
width: 50%;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
padding: 0 20px;
|
||||
border-right: 1px solid #e8eae8;
|
||||
}
|
||||
.previewPane {
|
||||
width: 50%;
|
||||
}
|
@ -2,43 +2,60 @@ import React, { PropTypes } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ControlPane from './ControlPane';
|
||||
import PreviewPane from './PreviewPane';
|
||||
import styles from './EntryEditor.css';
|
||||
|
||||
export default function EntryEditor({ collection, entry, getMedia, onChange, onAddMedia, onRemoveMedia, onPersist }) {
|
||||
return <div>
|
||||
<h1>Entry in {collection.get('label')}</h1>
|
||||
<h2>{entry && entry.get('title')}</h2>
|
||||
<div className="cms-container" style={styles.container}>
|
||||
<div className="cms-control-pane" style={styles.controlPane}>
|
||||
<ControlPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
getMedia={getMedia}
|
||||
onChange={onChange}
|
||||
onAddMedia={onAddMedia}
|
||||
onRemoveMedia={onRemoveMedia}
|
||||
/>
|
||||
</div>
|
||||
<div className="cms-preview-pane" style={styles.pane}>
|
||||
<PreviewPane collection={collection} entry={entry} getMedia={getMedia} />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onPersist}>Save</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'flex'
|
||||
},
|
||||
controlPane: {
|
||||
width: '50%',
|
||||
paddingLeft: '10px',
|
||||
paddingRight: '10px'
|
||||
},
|
||||
pane: {
|
||||
width: '50%'
|
||||
export default class EntryEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.calculateHeight();
|
||||
window.addEventListener('resize', this.handleResize, false);
|
||||
}
|
||||
|
||||
componengWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
this.calculateHeight();
|
||||
}
|
||||
|
||||
calculateHeight() {
|
||||
const height = window.innerHeight - 54;
|
||||
console.log('setting height to %s', height);
|
||||
this.setState({ height });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { collection, entry, getMedia, onChange, onAddMedia, onRemoveMedia, onPersist } = this.props;
|
||||
const { height } = this.state;
|
||||
|
||||
return <div className={styles.entryEditor} style={{ height }}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.controlPane}>
|
||||
<ControlPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
getMedia={getMedia}
|
||||
onChange={onChange}
|
||||
onAddMedia={onAddMedia}
|
||||
onRemoveMedia={onRemoveMedia}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.previewPane}>
|
||||
<PreviewPane collection={collection} entry={entry} getMedia={getMedia} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<button onClick={onPersist}>Save</button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
EntryEditor.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
|
@ -60,7 +60,8 @@ export default class EntryListing extends React.Component {
|
||||
|
||||
cardFor(collection, entry, link) {
|
||||
//const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props;
|
||||
const card = Cards[collection.getIn(['card', 'type'])] || Cards._unknown;
|
||||
const cartType = collection.getIn(['card', 'type']) || 'alltype';
|
||||
const card = Cards[cartType] || Cards._unknown;
|
||||
return React.createElement(card, {
|
||||
key: entry.get('slug'),
|
||||
collection: collection,
|
||||
|
6
src/components/PreviewPane.css
Normal file
6
src/components/PreviewPane.css
Normal file
@ -0,0 +1,6 @@
|
||||
.frame {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
border: none;
|
||||
background: #fff;
|
||||
}
|
@ -1,12 +1,15 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { render } from 'react-dom';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Widgets from './Widgets';
|
||||
import registry from '../lib/registry';
|
||||
import { resolveWidget } from './Widgets';
|
||||
import styles from './PreviewPane.css';
|
||||
|
||||
export default class PreviewPane extends React.Component {
|
||||
class Preview extends React.Component {
|
||||
previewFor(field) {
|
||||
const { entry, getMedia } = this.props;
|
||||
const widget = Widgets[field.get('widget')] || Widgets._unknown;
|
||||
return React.createElement(widget.Preview, {
|
||||
const widget = resolveWidget(field.get('widget'));
|
||||
return React.createElement(widget.preview, {
|
||||
field: field,
|
||||
value: entry.getIn(['data', field.get('name')]),
|
||||
getMedia: getMedia,
|
||||
@ -17,13 +20,69 @@ export default class PreviewPane extends React.Component {
|
||||
const { collection } = this.props;
|
||||
if (!collection) { return null; }
|
||||
|
||||
|
||||
return <div>
|
||||
{collection.get('fields').map((field) => <div key={field.get('name')}>{this.previewFor(field)}</div>)}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
Preview.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
getMedia: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default class PreviewPane extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleIframeRef = this.handleIframeRef.bind(this);
|
||||
this.widgetFor = this.widgetFor.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.renderPreview();
|
||||
}
|
||||
|
||||
widgetFor(name) {
|
||||
const { collection, entry, getMedia } = this.props;
|
||||
const field = collection.get('fields').find((field) => field.get('name') === name);
|
||||
const widget = resolveWidget(field.get('widget'));
|
||||
return React.createElement(widget.preview, {
|
||||
field: field,
|
||||
value: entry.getIn(['data', field.get('name')]),
|
||||
getMedia: getMedia,
|
||||
});
|
||||
}
|
||||
|
||||
renderPreview() {
|
||||
const props = Object.assign({}, this.props, { widgetFor: this.widgetFor });
|
||||
const component = registry.getPreviewTemplate(props.collection.get('name')) || Preview;
|
||||
|
||||
render(React.createElement(component, props), this.previewEl);
|
||||
}
|
||||
|
||||
handleIframeRef(ref) {
|
||||
if (ref) {
|
||||
registry.getPreviewStyles().forEach((style) => {
|
||||
const linkEl = document.createElement('link');
|
||||
linkEl.setAttribute('rel', 'stylesheet');
|
||||
linkEl.setAttribute('href', style);
|
||||
ref.contentDocument.head.appendChild(linkEl);
|
||||
});
|
||||
this.previewEl = document.createElement('div');
|
||||
ref.contentDocument.body.appendChild(this.previewEl);
|
||||
this.renderPreview();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { collection } = this.props;
|
||||
if (!collection) { return null; }
|
||||
|
||||
return <iframe className={styles.frame} ref={this.handleIframeRef}></iframe>;
|
||||
}
|
||||
}
|
||||
|
||||
PreviewPane.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
|
17
src/components/UI/AppHeader/AppHeader.css
Normal file
17
src/components/UI/AppHeader/AppHeader.css
Normal file
@ -0,0 +1,17 @@
|
||||
:root {
|
||||
--foregroundColor: #fff;
|
||||
--backgroundColor: #272e30;
|
||||
--textFieldBorderColor: #e7e7e7;
|
||||
--highlightFGColor: #fff;
|
||||
--highlightBGColor: #3ab7a5;
|
||||
}
|
||||
|
||||
.appBar {
|
||||
background-color: var(--backgroundColor);
|
||||
}
|
||||
|
||||
.createBtn {
|
||||
position: fixed;
|
||||
right: 2rem;
|
||||
top: 3.5rem;
|
||||
}
|
90
src/components/UI/AppHeader/AppHeader.js
Normal file
90
src/components/UI/AppHeader/AppHeader.js
Normal file
@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import pluralize from 'pluralize';
|
||||
import { IndexLink } from 'react-router';
|
||||
import { Menu, MenuItem, Button, IconButton } from 'react-toolbox';
|
||||
import AppBar from 'react-toolbox/lib/app_bar';
|
||||
import FindBar from '../FindBar/FindBar';
|
||||
import styles from './AppHeader.css';
|
||||
|
||||
export default class AppHeader extends React.Component {
|
||||
|
||||
state = {
|
||||
createMenuActive: false
|
||||
}
|
||||
|
||||
handleCreatePostClick = collectionName => {
|
||||
const { onCreateEntryClick } = this.props;
|
||||
if (onCreateEntryClick) {
|
||||
onCreateEntryClick(collectionName);
|
||||
}
|
||||
}
|
||||
|
||||
handleCreateButtonClick = () => {
|
||||
this.setState({
|
||||
createMenuActive: true
|
||||
});
|
||||
}
|
||||
|
||||
handleCreateMenuHide = () => {
|
||||
this.setState({
|
||||
createMenuActive: false
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
collections,
|
||||
commands,
|
||||
defaultCommands,
|
||||
runCommand,
|
||||
toggleNavDrawer
|
||||
} = this.props;
|
||||
const { createMenuActive } = this.state;
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
fixed
|
||||
theme={styles}
|
||||
>
|
||||
<IconButton
|
||||
icon="menu"
|
||||
inverse
|
||||
onClick={toggleNavDrawer}
|
||||
/>
|
||||
<IndexLink to="/">
|
||||
Dashboard
|
||||
</IndexLink>
|
||||
<FindBar
|
||||
commands={commands}
|
||||
defaultCommands={defaultCommands}
|
||||
runCommand={runCommand}
|
||||
/>
|
||||
<Button
|
||||
className={styles.createBtn}
|
||||
icon='add'
|
||||
floating
|
||||
accent
|
||||
onClick={this.handleCreateButtonClick}
|
||||
>
|
||||
<Menu
|
||||
active={createMenuActive}
|
||||
position="topRight"
|
||||
onHide={this.handleCreateMenuHide}
|
||||
>
|
||||
{
|
||||
collections.valueSeq().map(collection =>
|
||||
<MenuItem
|
||||
key={collection.get('name')}
|
||||
value={collection.get('name')}
|
||||
onClick={this.handleCreatePostClick.bind(this, collection.get('name'))}
|
||||
caption={pluralize(collection.get('label'), 1)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
</Button>
|
||||
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
}
|
@ -7,15 +7,14 @@
|
||||
}
|
||||
|
||||
.root {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background-color: var(--backgroundColor);
|
||||
padding: 1px 0;
|
||||
margin: 4px auto;
|
||||
padding: 5px;
|
||||
}
|
||||
.inputArea {
|
||||
display: table;
|
||||
width: calc(100% - 10px);
|
||||
margin: 5px;
|
||||
width: 100%;
|
||||
color: var(--foregroundColor);
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--textFieldBorderColor);
|
@ -1,9 +1,7 @@
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import fuzzy from 'fuzzy';
|
||||
import _ from 'lodash';
|
||||
import { runCommand } from '../actions/findbar';
|
||||
import { connect } from 'react-redux';
|
||||
import { Icon } from '../components/UI';
|
||||
import { Icon } from '../index';
|
||||
import styles from './FindBar.css';
|
||||
|
||||
export const SEARCH = 'SEARCH';
|
||||
@ -13,7 +11,12 @@ class FindBar extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._compiledCommands = [];
|
||||
this._searchCommand = { search: true, regexp:`(?:${SEARCH})?(.*)`, param:{ name:'searchTerm', display:'' }, token: SEARCH };
|
||||
this._searchCommand = {
|
||||
search: true,
|
||||
regexp: `(?:${SEARCH})?(.*)`,
|
||||
param: { name: 'searchTerm', display: '' },
|
||||
token: SEARCH
|
||||
};
|
||||
this.state = {
|
||||
value: '',
|
||||
placeholder: PLACEHOLDER,
|
||||
@ -68,7 +71,7 @@ class FindBar extends Component {
|
||||
|
||||
if (match && match[1]) {
|
||||
regexp += '(.*)';
|
||||
param = { name:match[1], display:match[2] || this._camelCaseToSpace(match[1]) };
|
||||
param = { name: match[1], display: match[2] || this._camelCaseToSpace(match[1]) };
|
||||
}
|
||||
|
||||
return Object.assign({}, command, {
|
||||
@ -97,13 +100,15 @@ class FindBar extends Component {
|
||||
const paramName = command && command.param ? command.param.name : null;
|
||||
const enteredParamValue = command && command.param && match[1] ? match[1].trim() : null;
|
||||
|
||||
console.log(this.props.runCommand);
|
||||
|
||||
if (command.search) {
|
||||
this.setState({
|
||||
activeScope: SEARCH,
|
||||
placeholder: ''
|
||||
});
|
||||
|
||||
enteredParamValue && this.props.dispatch(runCommand(SEARCH, { searchTerm: enteredParamValue }));
|
||||
enteredParamValue && this.props.runCommand(SEARCH, { searchTerm: enteredParamValue });
|
||||
} else if (command.param && !enteredParamValue) {
|
||||
// Partial Match
|
||||
// Command was partially matched: It requires a param, but param wasn't entered
|
||||
@ -128,7 +133,7 @@ class FindBar extends Component {
|
||||
if (paramName) {
|
||||
payload[paramName] = enteredParamValue;
|
||||
}
|
||||
this.props.dispatch(runCommand(command.type, payload));
|
||||
this.props.runCommand(command.type, payload);
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,6 +149,7 @@ class FindBar extends Component {
|
||||
getSuggestions() {
|
||||
return this._getSuggestions(this.state.value, this.state.activeScope, this._compiledCommands, this.props.defaultCommands);
|
||||
}
|
||||
|
||||
// Memoized version
|
||||
_getSuggestions(value, scope, commands, defaultCommands) {
|
||||
if (scope) return []; // No autocomplete for scoped input
|
||||
@ -152,7 +158,7 @@ class FindBar extends Component {
|
||||
.filter(command => defaultCommands.indexOf(command.id) !== -1)
|
||||
.map(result => (
|
||||
Object.assign({}, result, { string: result.token }
|
||||
)));
|
||||
)));
|
||||
}
|
||||
|
||||
const results = fuzzy.filter(value, commands, {
|
||||
@ -162,8 +168,8 @@ class FindBar extends Component {
|
||||
});
|
||||
|
||||
const returnResults = results.slice(0, 4).map(result => (
|
||||
Object.assign({}, result.original, { string:result.string }
|
||||
)));
|
||||
Object.assign({}, result.original, { string: result.string }
|
||||
)));
|
||||
returnResults.push(this._searchCommand);
|
||||
|
||||
return returnResults;
|
||||
@ -178,7 +184,7 @@ class FindBar extends Component {
|
||||
index = (
|
||||
highlightedIndex === this.getSuggestions().length - 1 ||
|
||||
this.state.isOpen === false
|
||||
) ? 0 : highlightedIndex + 1;
|
||||
) ? 0 : highlightedIndex + 1;
|
||||
this.setState({
|
||||
highlightedIndex: index,
|
||||
isOpen: true,
|
||||
@ -290,7 +296,7 @@ class FindBar extends Component {
|
||||
let children;
|
||||
if (!command.search) {
|
||||
children = (
|
||||
<span><span dangerouslySetInnerHTML={{__html: command.string}} /></span>
|
||||
<span><span dangerouslySetInnerHTML={{ __html: command.string }}/></span>
|
||||
);
|
||||
} else {
|
||||
children = (
|
||||
@ -299,7 +305,8 @@ class FindBar extends Component {
|
||||
<span><Icon type="search"/>Search... </span> :
|
||||
<span className={styles.faded}><Icon type="search"/>Search for: </span>
|
||||
}
|
||||
<strong>{this.state.value}</strong></span>
|
||||
<strong>{this.state.value}</strong>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@ -317,7 +324,7 @@ class FindBar extends Component {
|
||||
return commands.length === 0 ? null : (
|
||||
<div className={styles.menu}>
|
||||
<div className={styles.suggestions}>
|
||||
{ commands }
|
||||
{commands}
|
||||
</div>
|
||||
<div className={styles.history}>
|
||||
Your past searches and commands
|
||||
@ -328,7 +335,7 @@ class FindBar extends Component {
|
||||
|
||||
renderActiveScope() {
|
||||
if (this.state.activeScope === SEARCH) {
|
||||
return <div className={styles.inputScope}><Icon type="search"/> </div>;
|
||||
return <div className={styles.inputScope}><Icon type="search"/></div>;
|
||||
} else {
|
||||
return <div className={styles.inputScope}>{this.state.activeScope}</div>;
|
||||
}
|
||||
@ -358,6 +365,7 @@ class FindBar extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FindBar.propTypes = {
|
||||
commands: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
@ -365,8 +373,7 @@ FindBar.propTypes = {
|
||||
pattern: PropTypes.string.isRequired
|
||||
})).isRequired,
|
||||
defaultCommands: PropTypes.arrayOf(PropTypes.string),
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
runCommand: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export { FindBar };
|
||||
export default connect()(FindBar);
|
||||
export default FindBar;
|
@ -1,8 +1,7 @@
|
||||
@import "../theme.css";
|
||||
|
||||
.card {
|
||||
composes: base from "../theme.css";
|
||||
composes: container from "../theme.css";
|
||||
composes: rounded from "../theme.css";
|
||||
composes: depth from "../theme.css";
|
||||
composes: base container rounded depth;
|
||||
overflow: hidden;
|
||||
width: 240px;
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ const availableIcons = [
|
||||
'bold', 'italic', 'list', 'font', 'text-height', 'text-width', 'align-left', 'align-center', 'align-right',
|
||||
'align-justify', 'indent-left', 'indent-right', 'list-bullet', 'list-numbered', 'strike', 'underline', 'table',
|
||||
'superscript', 'subscript', 'header', 'h1', 'h2', 'paragraph', 'link', 'unlink', 'quote-left', 'quote-right', 'code',
|
||||
'picture','video',
|
||||
'picture', 'video',
|
||||
// Entypo
|
||||
'note', 'note-beamed',
|
||||
'music',
|
||||
@ -199,7 +199,7 @@ const iconPropType = (props, propName) => {
|
||||
|
||||
const noop = function() {};
|
||||
|
||||
export default function Icon({ style, className = '', type, onClick = noop}) {
|
||||
export default function Icon({ style, className = '', type, onClick = noop }) {
|
||||
return <span className={`${styles.root} ${styles[type]} ${className}`} style={style} onClick={onClick} />;
|
||||
}
|
||||
|
||||
|
@ -1,2 +1,5 @@
|
||||
export { default as Card } from './card/Card';
|
||||
export { default as Loader } from './loader/Loader';
|
||||
export { default as Icon } from './icon/Icon';
|
||||
export { default as Toast } from './toast/Toast';
|
||||
export { default as AppHeader } from './AppHeader/AppHeader';
|
||||
|
115
src/components/UI/loader/Loader.css
Normal file
115
src/components/UI/loader/Loader.css
Normal file
@ -0,0 +1,115 @@
|
||||
.loader {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: 0px;
|
||||
text-align: center;
|
||||
z-index: 1000;
|
||||
-webkit-transform: translateX(-50%) translateY(-50%);
|
||||
-ms-transform: translateX(-50%) translateY(-50%);
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
}
|
||||
|
||||
/* Static Shape */
|
||||
|
||||
.loader:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
top: 0%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 500rem;
|
||||
border: 0.2em solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Active Shape */
|
||||
|
||||
.loader:after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
top: 0%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
animation: loader 0.6s linear;
|
||||
animation-iteration-count: infinite;
|
||||
border-radius: 500rem;
|
||||
border-color: #767676 transparent transparent;
|
||||
border-style: solid;
|
||||
border-width: 0.2em;
|
||||
box-shadow: 0px 0px 0px 1px transparent;
|
||||
}
|
||||
|
||||
/* Active Animation */
|
||||
|
||||
@-webkit-keyframes loader {
|
||||
from {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loader {
|
||||
from {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loader:before,
|
||||
.loader:after {
|
||||
width: 2.28571429rem;
|
||||
height: 2.28571429rem;
|
||||
margin: 0em 0em 0em -1.14285714rem;
|
||||
}
|
||||
|
||||
|
||||
.text {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
text-align: center;
|
||||
color: #767676;
|
||||
margin-top: 35px;
|
||||
}
|
||||
|
||||
.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*Animations*/
|
||||
.animateItem{
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.enter {
|
||||
opacity: 0.01;
|
||||
}
|
||||
.enter.enterActive {
|
||||
opacity: 1;
|
||||
transition: opacity 500ms ease-in;
|
||||
}
|
||||
.leave {
|
||||
opacity: 1;
|
||||
}
|
||||
.leave.leaveActive {
|
||||
opacity: 0.01;
|
||||
transition: opacity 300ms ease-in;
|
||||
}
|
68
src/components/UI/loader/Loader.js
Normal file
68
src/components/UI/loader/Loader.js
Normal file
@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
|
||||
import styles from './Loader.css';
|
||||
|
||||
export default class Loader extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
currentItem: 0,
|
||||
};
|
||||
this.setAnimation = this.setAnimation.bind(this);
|
||||
this.renderChild = this.renderChild.bind(this);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
setAnimation() {
|
||||
if (this.interval) return;
|
||||
const { children } = this.props;
|
||||
|
||||
this.interval = setInterval(() => {
|
||||
|
||||
const nextItem = (this.state.currentItem === children.length - 1) ? 0 : this.state.currentItem + 1;
|
||||
this.setState({ currentItem: nextItem });
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
renderChild() {
|
||||
const { children } = this.props;
|
||||
const { currentItem } = this.state;
|
||||
if (!children) {
|
||||
return null;
|
||||
} else if (typeof children == 'string') {
|
||||
return <div className={styles.text}>{children}</div>;
|
||||
} else if (Array.isArray(children)) {
|
||||
this.setAnimation();
|
||||
return <div className={styles.text}>
|
||||
<ReactCSSTransitionGroup
|
||||
transitionName={styles}
|
||||
transitionEnterTimeout={500}
|
||||
transitionLeaveTimeout={500}
|
||||
>
|
||||
<div key={currentItem} className={styles.animateItem}>{children[currentItem]}</div>
|
||||
</ReactCSSTransitionGroup>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { active, style, className = '' } = this.props;
|
||||
|
||||
// Class names
|
||||
let classNames = styles.loader;
|
||||
if (active) {
|
||||
classNames += ` ${styles.active}`;
|
||||
}
|
||||
if (className.length > 0) {
|
||||
classNames += ` ${className}`;
|
||||
}
|
||||
|
||||
return <div className={classNames} style={style}>{this.renderChild()}</div>;
|
||||
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
:root {
|
||||
--defaultColor: #333;
|
||||
--defaultColorLight: #eee;
|
||||
--backgroundColor: #fff;
|
||||
--shadowColor: rgba(0, 0, 0, 0.117647);
|
||||
--successColor: #1c7;
|
||||
|
40
src/components/UI/toast/Toast.css
Normal file
40
src/components/UI/toast/Toast.css
Normal file
@ -0,0 +1,40 @@
|
||||
@import "../theme.css";
|
||||
|
||||
.toast {
|
||||
composes: base container rounded depth;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 100;
|
||||
width: 350px;
|
||||
padding: 20px 10px;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
color: var(--defaultColorLight);
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
transition: opacity .3s ease-in;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: calc(50% - 15px);
|
||||
left: 15px;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: var(--successColor);
|
||||
}
|
||||
|
||||
.warning {
|
||||
background-color: var(--warningColor);
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: var(--errorColor);
|
||||
}
|
74
src/components/UI/toast/Toast.js
Normal file
74
src/components/UI/toast/Toast.js
Normal file
@ -0,0 +1,74 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { Icon } from '../index';
|
||||
import styles from './Toast.css';
|
||||
|
||||
export default class Toast extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
shown: false
|
||||
};
|
||||
|
||||
this.autoHideTimeout = this.autoHideTimeout.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
if (this.props.show) {
|
||||
this.autoHideTimeout();
|
||||
this.setState({ shown: true });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps !== this.props) {
|
||||
if (nextProps.show) this.autoHideTimeout();
|
||||
this.setState({ shown: nextProps.show });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.timeOut) {
|
||||
clearTimeout(this.timeOut);
|
||||
}
|
||||
}
|
||||
|
||||
autoHideTimeout() {
|
||||
clearTimeout(this.timeOut);
|
||||
this.timeOut = setTimeout(() => {
|
||||
this.setState({ shown: false });
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { style, type, className, children } = this.props;
|
||||
const icons = {
|
||||
success: 'check',
|
||||
warning: 'attention',
|
||||
error: 'alert'
|
||||
};
|
||||
const classes = [styles.toast];
|
||||
if (className) classes.push(className);
|
||||
|
||||
let icon = '';
|
||||
if (type) {
|
||||
classes.push(styles[type]);
|
||||
icon = <Icon type={icons[type]} className={styles.icon} />;
|
||||
}
|
||||
|
||||
if (!this.state.shown) {
|
||||
classes.push(styles.hidden);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.join(' ')} style={style}>{icon}{children}</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Toast.propTypes = {
|
||||
style: PropTypes.object,
|
||||
type: PropTypes.oneOf(['success', 'warning', 'error']).isRequired,
|
||||
className: PropTypes.string,
|
||||
show: PropTypes.bool,
|
||||
children: PropTypes.node
|
||||
};
|
53
src/components/UnpublishedListing.css
Normal file
53
src/components/UnpublishedListing.css
Normal file
@ -0,0 +1,53 @@
|
||||
.container {
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
width: 33%;
|
||||
height: 100%;
|
||||
transition: background-color .5s ease;
|
||||
& h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
background-color: #e1eeea;
|
||||
}
|
||||
|
||||
.column:not(:last-child) {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100% !important;
|
||||
margin: 7px 0;
|
||||
|
||||
& h2 {
|
||||
font-size: 17px;
|
||||
& small {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
& p {
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
& button {
|
||||
margin: 10px 10px 0 0;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.clear::after {
|
||||
content:"";
|
||||
display:block;
|
||||
clear:both;
|
||||
}
|
98
src/components/UnpublishedListing.js
Normal file
98
src/components/UnpublishedListing.js
Normal file
@ -0,0 +1,98 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { DragSource, DropTarget, HTML5DragDrop } from 'react-simple-dnd';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import moment from 'moment';
|
||||
import { Card } from './UI';
|
||||
import { Link } from 'react-router';
|
||||
import { status, statusDescriptions } from '../constants/publishModes';
|
||||
import styles from './UnpublishedListing.css';
|
||||
|
||||
class UnpublishedListing extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.renderColumns = this.renderColumns.bind(this);
|
||||
this.handleChangeStatus = this.handleChangeStatus.bind(this);
|
||||
this.requestPublish = this.requestPublish.bind(this);
|
||||
}
|
||||
|
||||
handleChangeStatus(newStatus, dragProps) {
|
||||
const slug = dragProps.slug;
|
||||
const collection = dragProps.collection;
|
||||
const oldStatus = dragProps.ownStatus;
|
||||
this.props.handleChangeStatus(collection, slug, oldStatus, newStatus);
|
||||
}
|
||||
|
||||
requestPublish(collection, slug, ownStatus) {
|
||||
if (ownStatus !== status.last()) return;
|
||||
if (window.confirm('Are you sure you want to publish this entry?')) {
|
||||
this.props.handlePublish(collection, slug, ownStatus);
|
||||
}
|
||||
}
|
||||
|
||||
renderColumns(entries, column) {
|
||||
if (!entries) return;
|
||||
|
||||
if (!column) {
|
||||
/* eslint-disable */
|
||||
return entries.entrySeq().map(([currColumn, currEntries]) => (
|
||||
<DropTarget key={currColumn} onDrop={this.handleChangeStatus.bind(this, currColumn)}>
|
||||
{(isOver) => (
|
||||
<div className={isOver ? `${styles.column} ${styles.highlighted}` : styles.column}>
|
||||
<h2>{statusDescriptions.get(currColumn)}</h2>
|
||||
{this.renderColumns(currEntries, currColumn)}
|
||||
</div>
|
||||
)}
|
||||
</DropTarget>
|
||||
/* eslint-enable */
|
||||
));
|
||||
} else {
|
||||
return <div>
|
||||
{entries.map(entry => {
|
||||
// Look for an "author" field. Fallback to username on backend implementation;
|
||||
const author = entry.getIn(['data', 'author'], entry.getIn(['metaData', 'user']));
|
||||
const timeStamp = moment(entry.getIn(['metaData', 'timeStamp'])).format('llll');
|
||||
const link = `/editorialworkflow/${entry.getIn(['metaData', 'collection'])}/${entry.getIn(['metaData', 'status'])}/${entry.get('slug')}`;
|
||||
const slug = entry.get('slug');
|
||||
const ownStatus = entry.getIn(['metaData', 'status']);
|
||||
const collection = entry.getIn(['metaData', 'collection']);
|
||||
return (
|
||||
/* eslint-disable */
|
||||
<DragSource key={slug} slug={slug} collection={collection} ownStatus={ownStatus}>
|
||||
<div className={styles.drag}>
|
||||
<Card className={styles.card}>
|
||||
<h2><Link to={link}>{entry.getIn(['data', 'title'])}</Link> <small>by {author}</small></h2>
|
||||
<p>Last updated: {timeStamp} by {entry.getIn(['metaData', 'user'])}</p>
|
||||
{(ownStatus === status.last()) &&
|
||||
<button onClick={this.requestPublish.bind(this, collection, slug, status)}>Publish now</button>
|
||||
}
|
||||
</Card>
|
||||
</div>
|
||||
</DragSource>
|
||||
/* eslint-enable */
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const columns = this.renderColumns(this.props.entries);
|
||||
return (
|
||||
<div className={styles.clear}>
|
||||
<h1>Editorial Workflow</h1>
|
||||
<div className={styles.container}>
|
||||
{columns}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UnpublishedListing.propTypes = {
|
||||
entries: ImmutablePropTypes.orderedMap,
|
||||
handleChangeStatus: PropTypes.func.isRequired,
|
||||
handlePublish: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default HTML5DragDrop(UnpublishedListing);
|
@ -1,30 +1,24 @@
|
||||
import registry from '../lib/registry';
|
||||
import UnknownControl from './Widgets/UnknownControl';
|
||||
import UnknownPreview from './Widgets/UnknownPreview';
|
||||
import StringControl from './Widgets/StringControl';
|
||||
import StringPreview from './Widgets/StringPreview';
|
||||
import TextControl from './Widgets/TextControl';
|
||||
import TextPreview from './Widgets/TextPreview';
|
||||
import MarkdownControl from './Widgets/MarkdownControl';
|
||||
import MarkdownPreview from './Widgets/MarkdownPreview';
|
||||
import ImageControl from './Widgets/ImageControl';
|
||||
import ImagePreview from './Widgets/ImagePreview';
|
||||
import DateTimeControl from './Widgets/DateTimeControl';
|
||||
import DateTimePreview from './Widgets/DateTimePreview';
|
||||
|
||||
registry.registerWidget('string', StringControl, StringPreview);
|
||||
registry.registerWidget('text', TextControl, TextPreview);
|
||||
registry.registerWidget('markdown', MarkdownControl, MarkdownPreview);
|
||||
registry.registerWidget('image', ImageControl, ImagePreview);
|
||||
registry.registerWidget('datetime', DateTimeControl, DateTimePreview);
|
||||
registry.registerWidget('_unknown', UnknownControl, UnknownPreview);
|
||||
|
||||
const Widgets = {
|
||||
_unknown: {
|
||||
Control: UnknownControl,
|
||||
Preview: UnknownPreview
|
||||
},
|
||||
string: {
|
||||
Control: StringControl,
|
||||
Preview: StringPreview
|
||||
},
|
||||
markdown: {
|
||||
Control: MarkdownControl,
|
||||
Preview: MarkdownPreview
|
||||
},
|
||||
image: {
|
||||
Control: ImageControl,
|
||||
Preview: ImagePreview
|
||||
}
|
||||
};
|
||||
|
||||
export default Widgets;
|
||||
export function resolveWidget(name) {
|
||||
return registry.getWidget(name) || registry.getWidget('_unknown');
|
||||
}
|
||||
|
22
src/components/Widgets/DateTimeControl.js
Normal file
22
src/components/Widgets/DateTimeControl.js
Normal file
@ -0,0 +1,22 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import DateTime from 'react-datetime';
|
||||
|
||||
export default class DateTimeControl extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
handleChange(datetime) {
|
||||
this.props.onChange(datetime);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <DateTime value={this.props.value || new Date()} onChange={this.handleChange}/>;
|
||||
}
|
||||
}
|
||||
|
||||
DateTimeControl.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
value: PropTypes.object,
|
||||
};
|
9
src/components/Widgets/DateTimePreview.js
Normal file
9
src/components/Widgets/DateTimePreview.js
Normal file
@ -0,0 +1,9 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
|
||||
export default function StringPreview({ value }) {
|
||||
return <span>{value}</span>;
|
||||
}
|
||||
|
||||
StringPreview.propTypes = {
|
||||
value: PropTypes.node,
|
||||
};
|
@ -53,7 +53,7 @@ export default class ImageControl extends React.Component {
|
||||
if (file) {
|
||||
const mediaProxy = new MediaProxy(file.name, file);
|
||||
this.props.onAddMedia(mediaProxy);
|
||||
this.props.onChange(mediaProxy.path);
|
||||
this.props.onChange(mediaProxy.public_path);
|
||||
} else {
|
||||
this.props.onChange(null);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import registry from '../../lib/registry';
|
||||
import RawEditor from './MarkdownControlElements/RawEditor';
|
||||
import VisualEditor from './MarkdownControlElements/VisualEditor';
|
||||
import { processEditorPlugins } from './richText';
|
||||
@ -13,7 +14,8 @@ class MarkdownControl extends React.Component {
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
processEditorPlugins(this.context.plugins.editor);
|
||||
this.useRawEditor();
|
||||
processEditorPlugins(registry.getEditorComponents());
|
||||
}
|
||||
|
||||
useVisualEditor() {
|
||||
@ -28,8 +30,8 @@ class MarkdownControl extends React.Component {
|
||||
const { editor, onChange, onAddMedia, getMedia, value } = this.props;
|
||||
if (editor.get('useVisualMode')) {
|
||||
return (
|
||||
<div>
|
||||
<button onClick={this.useRawEditor}>Switch to Raw Editor</button>
|
||||
<div className='cms-editor-visual'>
|
||||
{null && <button onClick={this.useRawEditor}>Switch to Raw Editor</button>}
|
||||
<VisualEditor
|
||||
onChange={onChange}
|
||||
onAddMedia={onAddMedia}
|
||||
@ -41,8 +43,8 @@ class MarkdownControl extends React.Component {
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<button onClick={this.useVisualEditor}>Switch to Visual Editor</button>
|
||||
<div className='cms-editor-raw'>
|
||||
{null && <button onClick={this.useVisualEditor}>Switch to Visual Editor</button>}
|
||||
<RawEditor
|
||||
onChange={onChange}
|
||||
onAddMedia={onAddMedia}
|
||||
@ -58,14 +60,12 @@ class MarkdownControl extends React.Component {
|
||||
return (
|
||||
<div>
|
||||
|
||||
{ this.renderEditor() }
|
||||
{this.renderEditor()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MarkdownControl;
|
||||
|
||||
MarkdownControl.propTypes = {
|
||||
editor: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
|
@ -42,7 +42,8 @@ class VisualEditor extends React.Component {
|
||||
let rawJson;
|
||||
if (props.value !== undefined) {
|
||||
const content = this.markdown.toContent(props.value);
|
||||
rawJson = SlateUtils.encode(content, null, getPlugins().map(plugin => plugin.id));
|
||||
console.log('md: %o', content);
|
||||
rawJson = SlateUtils.encode(content, null, ['mediaproxy'].concat(getPlugins().map(plugin => plugin.id)));
|
||||
} else {
|
||||
rawJson = emptyParagraphBlock;
|
||||
}
|
||||
@ -253,7 +254,7 @@ class VisualEditor extends React.Component {
|
||||
.insertInline({
|
||||
type: 'mediaproxy',
|
||||
isVoid: true,
|
||||
data: { src: mediaProxy.path }
|
||||
data: { src: mediaProxy.public_path }
|
||||
})
|
||||
.collapseToEnd()
|
||||
.insertBlock(DEFAULT_NODE)
|
||||
|
@ -62,4 +62,4 @@ export const SCHEMA = {
|
||||
borderRadius: '4px'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -16,23 +16,6 @@ const EditorComponent = Record({
|
||||
toPreview: function(attributes) { return 'Plugin'; }
|
||||
});
|
||||
|
||||
function CMS() {
|
||||
this.registerEditorComponent = (config) => {
|
||||
const configObj = new EditorComponent({
|
||||
id: config.id || config.label.replace(/[^A-Z0-9]+/ig, '_'),
|
||||
label: config.label,
|
||||
icon: config.icon,
|
||||
fields: config.fields,
|
||||
pattern: config.pattern,
|
||||
fromBlock: _.isFunction(config.fromBlock) ? config.fromBlock.bind(null) : null,
|
||||
toBlock: _.isFunction(config.toBlock) ? config.toBlock.bind(null) : null,
|
||||
toPreview: _.isFunction(config.toPreview) ? config.toPreview.bind(null) : config.toBlock.bind(null)
|
||||
});
|
||||
|
||||
plugins.editor = plugins.editor.push(configObj);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
class Plugin extends Component {
|
||||
getChildContext() {
|
||||
@ -51,8 +34,18 @@ Plugin.childContextTypes = {
|
||||
plugins: PropTypes.object
|
||||
};
|
||||
|
||||
export function newEditorPlugin(config) {
|
||||
const configObj = new EditorComponent({
|
||||
id: config.id || config.label.replace(/[^A-Z0-9]+/ig, '_'),
|
||||
label: config.label,
|
||||
icon: config.icon,
|
||||
fields: config.fields,
|
||||
pattern: config.pattern,
|
||||
fromBlock: _.isFunction(config.fromBlock) ? config.fromBlock.bind(null) : null,
|
||||
toBlock: _.isFunction(config.toBlock) ? config.toBlock.bind(null) : null,
|
||||
toPreview: _.isFunction(config.toPreview) ? config.toPreview.bind(null) : config.toBlock.bind(null)
|
||||
});
|
||||
|
||||
export const initPluginAPI = () => {
|
||||
window.CMS = new CMS();
|
||||
return Plugin;
|
||||
};
|
||||
|
||||
return configObj;
|
||||
}
|
@ -11,7 +11,7 @@ export default class StringControl extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
return <input value={this.props.value} onChange={this.handleChange}/>;
|
||||
return <input type="text" value={this.props.value || ''} onChange={this.handleChange}/>;
|
||||
}
|
||||
}
|
||||
|
||||
|
37
src/components/Widgets/TextControl.js
Normal file
37
src/components/Widgets/TextControl.js
Normal file
@ -0,0 +1,37 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
|
||||
export default class StringControl extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleRef = this.handleRef.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateHeight();
|
||||
}
|
||||
|
||||
handleChange(e) {
|
||||
this.props.onChange(e.target.value);
|
||||
this.updateHeight();
|
||||
}
|
||||
|
||||
updateHeight() {
|
||||
if (this.element.scrollHeight > this.element.clientHeight) {
|
||||
this.element.style.height = this.element.scrollHeight + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
handleRef(ref) {
|
||||
this.element = ref;
|
||||
}
|
||||
|
||||
render() {
|
||||
return <textarea ref={this.handleRef} value={this.props.value || ''} onChange={this.handleChange}/>;
|
||||
}
|
||||
}
|
||||
|
||||
StringControl.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
value: PropTypes.node,
|
||||
};
|
4
src/components/Widgets/TextPreview.js
Normal file
4
src/components/Widgets/TextPreview.js
Normal file
@ -0,0 +1,4 @@
|
||||
import StringPreview from './StringPreview';
|
||||
|
||||
export default class TextPreview extends StringPreview {
|
||||
}
|
@ -46,10 +46,8 @@ function processEditorPlugins(plugins) {
|
||||
<div {...props.attributes} className={className}>
|
||||
<div className="plugin_icon" contentEditable={false}><Icon type={plugin.icon}/></div>
|
||||
<div className="plugin_fields" contentEditable={false}>
|
||||
{ plugin.fields.map(field => `${field.label}: “${node.data.get(field.name)}”`) }
|
||||
{plugin.fields.map(field => `${field.label}: “${node.data.get(field.name)}”`)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -82,7 +80,7 @@ function processMediaProxyPlugins(getMedia) {
|
||||
const mediaProxyMarkdownRule = mediaProxyRule.toText((state, token) => {
|
||||
var data = token.getData();
|
||||
var alt = data.get('alt', '');
|
||||
var src = getMedia(data.get('src', ''));
|
||||
var src = data.get('src', '');
|
||||
var title = data.get('title', '');
|
||||
|
||||
if (title) {
|
||||
@ -95,7 +93,7 @@ function processMediaProxyPlugins(getMedia) {
|
||||
var data = token.getData();
|
||||
var alt = data.get('alt', '');
|
||||
var src = data.get('src', '');
|
||||
return `<img src=${src} alt=${alt} />`;
|
||||
return `<img src=${getMedia(src)} alt=${alt} />`;
|
||||
});
|
||||
|
||||
nodes['mediaproxy'] = (props) => {
|
||||
|
@ -39,4 +39,4 @@ storiesOf('Card', module)
|
||||
<p>header and footer elements are also not subject to margin</p>
|
||||
<footer style={styles.footer}>© Thousand Cats Corp</footer>
|
||||
</Card>
|
||||
))
|
||||
));
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { storiesOf, action } from '@kadira/storybook';
|
||||
|
||||
import { FindBar } from '../FindBar';
|
||||
import FindBar from '../UI/FindBar/FindBar';
|
||||
|
||||
const CREATE_COLLECTION = 'CREATE_COLLECTION';
|
||||
const CREATE_POST = 'CREATE_POST';
|
||||
@ -30,15 +30,13 @@ const style = {
|
||||
margin: 20
|
||||
};
|
||||
|
||||
const dispatch = action('DISPATCH');
|
||||
|
||||
storiesOf('FindBar', module)
|
||||
.add('Default View', () => (
|
||||
<div style={style}>
|
||||
<FindBar
|
||||
commands={commands}
|
||||
defaultCommands={[CREATE_POST, CREATE_COLLECTION, OPEN_SETTINGS, HELP, MORE_COMMANDS]}
|
||||
dispatch={f => f(dispatch)}
|
||||
runCommand={action}
|
||||
/>
|
||||
</div>
|
||||
));
|
19
src/components/stories/Toast.js
Normal file
19
src/components/stories/Toast.js
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Toast } from '../UI';
|
||||
import { storiesOf } from '@kadira/storybook';
|
||||
|
||||
|
||||
storiesOf('Toast', module)
|
||||
.add('Success', () => (
|
||||
<div>
|
||||
<Toast type='success' show>A Toast Message</Toast>
|
||||
</div>
|
||||
)).add('Waring', () => (
|
||||
<div>
|
||||
<Toast type='warning' show>A Toast Message</Toast>
|
||||
</div>
|
||||
)).add('Error', () => (
|
||||
<div>
|
||||
<Toast type='error' show>A Toast Message</Toast>
|
||||
</div>
|
||||
));
|
@ -1,2 +1,4 @@
|
||||
import './Card';
|
||||
import './Icon';
|
||||
import './Toast';
|
||||
import './FindBar';
|
||||
|
18
src/constants/publishModes.js
Normal file
18
src/constants/publishModes.js
Normal file
@ -0,0 +1,18 @@
|
||||
import { Map, OrderedMap } from 'immutable';
|
||||
|
||||
// Create/edit workflow modes
|
||||
export const SIMPLE = 'simple';
|
||||
export const EDITORIAL_WORKFLOW = 'editorial_workflow';
|
||||
|
||||
// Available status
|
||||
export const status = OrderedMap({
|
||||
DRAFT: 'draft',
|
||||
PENDING_REVIEW: 'pending_review',
|
||||
PENDING_PUBLISH: 'pending_publish',
|
||||
});
|
||||
|
||||
export const statusDescriptions = Map({
|
||||
[status.get('DRAFT')]: 'Draft',
|
||||
[status.get('PENDING_REVIEW')]: 'Waiting for Review',
|
||||
[status.get('PENDING_PUBLISH')]: 'Waiting to go live',
|
||||
});
|
@ -1,43 +1,10 @@
|
||||
.alignable {
|
||||
margin: 0px auto;
|
||||
.layout .navDrawer .drawerContent {
|
||||
padding-top: 54px;
|
||||
}
|
||||
|
||||
@media (max-width: 749px) and (min-width: 495px) {
|
||||
.alignable {
|
||||
width: 495px;
|
||||
}
|
||||
.nav {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1004px) and (min-width: 750px) {
|
||||
.alignable {
|
||||
width: 750px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1259px) and (min-width: 1005px) {
|
||||
.alignable {
|
||||
width: 1005px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1514px) and (min-width: 1260px) {
|
||||
.alignable {
|
||||
width: 1260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1769px) and (min-width: 1515px) {
|
||||
.alignable {
|
||||
width: 1515px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1770px) {
|
||||
.alignable {
|
||||
width: 1770px;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-top: 60px;
|
||||
padding-top: 54px;
|
||||
}
|
||||
|
@ -1,14 +1,27 @@
|
||||
import React from 'react';
|
||||
import pluralize from 'pluralize';
|
||||
import { connect } from 'react-redux';
|
||||
import { Layout, Panel, NavDrawer, Navigation, Link } from 'react-toolbox';
|
||||
import { loadConfig } from '../actions/config';
|
||||
import { loginUser } from '../actions/auth';
|
||||
import { currentBackend } from '../backends/backend';
|
||||
import { SHOW_COLLECTION, CREATE_COLLECTION, HELP } from '../actions/findbar';
|
||||
import FindBar from './FindBar';
|
||||
import {
|
||||
SHOW_COLLECTION,
|
||||
CREATE_COLLECTION,
|
||||
HELP,
|
||||
runCommand,
|
||||
navigateToCollection,
|
||||
createNewEntryInCollection
|
||||
} from '../actions/findbar';
|
||||
import { AppHeader, Loader } from '../components/UI/index';
|
||||
import styles from './App.css';
|
||||
import pluralize from 'pluralize';
|
||||
|
||||
class App extends React.Component {
|
||||
|
||||
state = {
|
||||
navDrawerIsVisible: false
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(loadConfig());
|
||||
}
|
||||
@ -26,7 +39,7 @@ class App extends React.Component {
|
||||
|
||||
configLoading() {
|
||||
return <div>
|
||||
<h1>Loading configuration...</h1>
|
||||
<Loader active>Loading configuration...</Loader>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@ -61,7 +74,7 @@ class App extends React.Component {
|
||||
id: `show_${collection.get('name')}`,
|
||||
pattern: `Show ${pluralize(collection.get('label'))}`,
|
||||
type: SHOW_COLLECTION,
|
||||
payload: { collectionName:collection.get('name') }
|
||||
payload: { collectionName: collection.get('name') }
|
||||
});
|
||||
|
||||
if (defaultCommands.length < 5) defaultCommands.push(`show_${collection.get('name')}`);
|
||||
@ -71,7 +84,7 @@ class App extends React.Component {
|
||||
id: `create_${collection.get('name')}`,
|
||||
pattern: `Create new ${pluralize(collection.get('label'), 1)}(:itemName as ${pluralize(collection.get('label'), 1)} Name)`,
|
||||
type: CREATE_COLLECTION,
|
||||
payload: { collectionName:collection.get('name') }
|
||||
payload: { collectionName: collection.get('name') }
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -82,8 +95,23 @@ class App extends React.Component {
|
||||
return { commands, defaultCommands };
|
||||
}
|
||||
|
||||
toggleNavDrawer = () => {
|
||||
this.setState({
|
||||
navDrawerIsVisible: !this.state.navDrawerIsVisible
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user, config, children } = this.props;
|
||||
const { navDrawerIsVisible } = this.state;
|
||||
const {
|
||||
user,
|
||||
config,
|
||||
children,
|
||||
collections,
|
||||
runCommand,
|
||||
navigateToCollection,
|
||||
createNewEntryInCollection
|
||||
} = this.props;
|
||||
|
||||
if (config === null) {
|
||||
return null;
|
||||
@ -104,19 +132,42 @@ class App extends React.Component {
|
||||
const { commands, defaultCommands } = this.generateFindBarCommands();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header>
|
||||
<div className={styles.alignable}>
|
||||
<FindBar
|
||||
commands={commands}
|
||||
defaultCommands={defaultCommands}
|
||||
/>
|
||||
<Layout theme={styles}>
|
||||
<NavDrawer
|
||||
active={navDrawerIsVisible}
|
||||
scrollY
|
||||
permanentAt="md"
|
||||
>
|
||||
<nav className={styles.nav}>
|
||||
<h1>Collections</h1>
|
||||
<Navigation type='vertical'>
|
||||
{
|
||||
collections.valueSeq().map(collection =>
|
||||
<Link
|
||||
key={collection.get('name')}
|
||||
onClick={navigateToCollection.bind(this, collection.get('name'))}
|
||||
>
|
||||
{collection.get('label')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
</Navigation>
|
||||
</nav>
|
||||
</NavDrawer>
|
||||
<Panel scrollY>
|
||||
<AppHeader
|
||||
collections={collections}
|
||||
commands={commands}
|
||||
defaultCommands={defaultCommands}
|
||||
runCommand={runCommand}
|
||||
onCreateEntryClick={createNewEntryInCollection}
|
||||
toggleNavDrawer={this.toggleNavDrawer}
|
||||
/>
|
||||
<div className={`${styles.alignable} ${styles.main}`}>
|
||||
{children}
|
||||
</div>
|
||||
</header>
|
||||
<div className={`${styles.alignable} ${styles.main}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -128,4 +179,19 @@ function mapStateToProps(state) {
|
||||
return { auth, config, collections, user };
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(App);
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
dispatch,
|
||||
runCommand: (type, payload) => {
|
||||
dispatch(runCommand(type, payload));
|
||||
},
|
||||
navigateToCollection: (collection) => {
|
||||
dispatch(navigateToCollection(collection));
|
||||
},
|
||||
createNewEntryInCollection: (collectionName) => {
|
||||
dispatch(createNewEntryInCollection(collectionName));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(App);
|
||||
|
39
src/containers/CollectionPage.css
Normal file
39
src/containers/CollectionPage.css
Normal file
@ -0,0 +1,39 @@
|
||||
.alignable {
|
||||
margin: 0px auto;
|
||||
}
|
||||
|
||||
@media (max-width: 749px) and (min-width: 495px) {
|
||||
.alignable {
|
||||
width: 495px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1004px) and (min-width: 750px) {
|
||||
.alignable {
|
||||
width: 750px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1259px) and (min-width: 1005px) {
|
||||
.alignable {
|
||||
width: 1005px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1514px) and (min-width: 1260px) {
|
||||
.alignable {
|
||||
width: 1260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1769px) and (min-width: 1515px) {
|
||||
.alignable {
|
||||
width: 1515px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1770px) {
|
||||
.alignable {
|
||||
width: 1770px;
|
||||
}
|
||||
}
|
@ -3,12 +3,14 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import { loadEntries } from '../actions/entries';
|
||||
import { selectEntries } from '../reducers';
|
||||
import { Loader } from '../components/UI';
|
||||
import EntryListing from '../components/EntryListing';
|
||||
import styles from './CollectionPage.css';
|
||||
import CollectionPageHOC from './editorialWorkflow/CollectionPageHOC';
|
||||
|
||||
class DashboardPage extends React.Component {
|
||||
componentDidMount() {
|
||||
const { collection, dispatch } = this.props;
|
||||
|
||||
if (collection) {
|
||||
dispatch(loadEntries(collection));
|
||||
}
|
||||
@ -27,12 +29,16 @@ class DashboardPage extends React.Component {
|
||||
return <h1>No collections defined in your config.yml</h1>;
|
||||
}
|
||||
|
||||
return <div>
|
||||
{entries ? <EntryListing collection={collection} entries={entries}/> : 'Loading entries...'}
|
||||
|
||||
return <div className={styles.alignable}>
|
||||
{entries ?
|
||||
<EntryListing collection={collection} entries={entries}/>
|
||||
:
|
||||
<Loader active>{['Loading Entries', 'Caching Entries', 'This might take several minutes']}</Loader>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
DashboardPage.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
collections: ImmutablePropTypes.orderedMap.isRequired,
|
||||
@ -40,6 +46,13 @@ DashboardPage.propTypes = {
|
||||
entries: ImmutablePropTypes.list,
|
||||
};
|
||||
|
||||
/*
|
||||
* Instead of checking the publish mode everywhere to dispatch & render the additional editorial workflow stuff,
|
||||
* We delegate it to a Higher Order Component
|
||||
*/
|
||||
DashboardPage = CollectionPageHOC(DashboardPage);
|
||||
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collections } = state;
|
||||
const { name, slug } = ownProps.params;
|
||||
|
@ -4,6 +4,7 @@ import { connect } from 'react-redux';
|
||||
import {
|
||||
loadEntry,
|
||||
createDraftFromEntry,
|
||||
createEmptyDraft,
|
||||
discardDraft,
|
||||
changeDraft,
|
||||
persistEntry
|
||||
@ -11,23 +12,32 @@ import {
|
||||
import { addMedia, removeMedia } from '../actions/media';
|
||||
import { selectEntry, getMedia } from '../reducers';
|
||||
import EntryEditor from '../components/EntryEditor';
|
||||
import EntryPageHOC from './editorialWorkflow/EntryPageHOC';
|
||||
|
||||
class EntryPage extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.props.loadEntry(props.collection, props.slug);
|
||||
this.createDraft = this.createDraft.bind(this);
|
||||
this.handlePersistEntry = this.handlePersistEntry.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entry) {
|
||||
this.props.createDraftFromEntry(this.props.entry);
|
||||
if (!this.props.newEntry) {
|
||||
this.props.loadEntry(this.props.collection, this.props.slug);
|
||||
|
||||
this.createDraft(this.props.entry);
|
||||
} else {
|
||||
this.props.createEmptyDraft(this.props.collection);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.props.entry !== nextProps.entry && !nextProps.entry.get('isFetching')) {
|
||||
this.props.createDraftFromEntry(nextProps.entry);
|
||||
if (this.props.entry === nextProps.entry) return;
|
||||
|
||||
if (nextProps.entry && !nextProps.entry.get('isFetching')) {
|
||||
this.createDraft(nextProps.entry);
|
||||
} else if (nextProps.newEntry) {
|
||||
this.props.createEmptyDraft(nextProps.collection);
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,17 +45,20 @@ class EntryPage extends React.Component {
|
||||
this.props.discardDraft();
|
||||
}
|
||||
|
||||
createDraft(entry) {
|
||||
if (entry) this.props.createDraftFromEntry(entry);
|
||||
}
|
||||
|
||||
handlePersistEntry() {
|
||||
this.props.persistEntry(this.props.collection, this.props.entryDraft);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
const {
|
||||
entry, entryDraft, boundGetMedia, collection, changeDraft, addMedia, removeMedia
|
||||
} = this.props;
|
||||
|
||||
if (entry == null || entryDraft.get('entry') == undefined || entry.get('isFetching')) {
|
||||
if (entryDraft == null || entryDraft.get('entry') == undefined || entry && entry.get('isFetching')) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
return (
|
||||
@ -68,24 +81,33 @@ EntryPage.propTypes = {
|
||||
changeDraft: PropTypes.func.isRequired,
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
createDraftFromEntry: PropTypes.func.isRequired,
|
||||
createEmptyDraft: PropTypes.func.isRequired,
|
||||
discardDraft: PropTypes.func.isRequired,
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
entry: ImmutablePropTypes.map,
|
||||
entryDraft: ImmutablePropTypes.map.isRequired,
|
||||
loadEntry: PropTypes.func.isRequired,
|
||||
persistEntry: PropTypes.func.isRequired,
|
||||
removeMedia: PropTypes.func.isRequired,
|
||||
slug: PropTypes.string.isRequired,
|
||||
slug: PropTypes.string,
|
||||
newEntry: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collections, entryDraft } = state;
|
||||
const collection = collections.get(ownProps.params.name);
|
||||
const newEntry = ownProps.route && ownProps.route.newRecord === true;
|
||||
const slug = ownProps.params.slug;
|
||||
const entry = selectEntry(state, collection.get('name'), slug);
|
||||
const entry = newEntry ? null : selectEntry(state, collection.get('name'), slug);
|
||||
const boundGetMedia = getMedia.bind(null, state);
|
||||
return { collection, collections, entryDraft, boundGetMedia, slug, entry };
|
||||
return { collection, collections, newEntry, entryDraft, boundGetMedia, slug, entry };
|
||||
}
|
||||
|
||||
/*
|
||||
* Instead of checking the publish mode everywhere to dispatch & render the additional editorial workflow stuff,
|
||||
* We delegate it to a Higher Order Component
|
||||
*/
|
||||
EntryPage = EntryPageHOC(EntryPage);
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{
|
||||
@ -94,6 +116,7 @@ export default connect(
|
||||
removeMedia,
|
||||
loadEntry,
|
||||
createDraftFromEntry,
|
||||
createEmptyDraft,
|
||||
discardDraft,
|
||||
persistEntry
|
||||
}
|
||||
|
67
src/containers/editorialWorkflow/CollectionPageHOC.js
Normal file
67
src/containers/editorialWorkflow/CollectionPageHOC.js
Normal file
@ -0,0 +1,67 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { OrderedMap } from 'immutable';
|
||||
import { loadUnpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry } from '../../actions/editorialWorkflow';
|
||||
import { selectUnpublishedEntries } from '../../reducers';
|
||||
import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes';
|
||||
import UnpublishedListing from '../../components/UnpublishedListing';
|
||||
import { connect } from 'react-redux';
|
||||
import styles from '../CollectionPage.css';
|
||||
|
||||
export default function CollectionPageHOC(CollectionPage) {
|
||||
class CollectionPageHOC extends CollectionPage {
|
||||
|
||||
componentDidMount() {
|
||||
const { dispatch, isEditorialWorkflow } = this.props;
|
||||
if (isEditorialWorkflow) {
|
||||
dispatch(loadUnpublishedEntries());
|
||||
}
|
||||
super.componentDidMount();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isEditorialWorkflow, unpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry } = this.props;
|
||||
if (!isEditorialWorkflow) return super.render();
|
||||
|
||||
return (
|
||||
<div className={styles.alignable}>
|
||||
<UnpublishedListing
|
||||
entries={unpublishedEntries}
|
||||
handleChangeStatus={updateUnpublishedEntryStatus}
|
||||
handlePublish={publishUnpublishedEntry}
|
||||
/>
|
||||
{super.render()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CollectionPageHOC.propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
isEditorialWorkflow: PropTypes.bool.isRequired,
|
||||
unpublishedEntries: ImmutablePropTypes.map,
|
||||
};
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const publish_mode = state.config.get('publish_mode');
|
||||
const isEditorialWorkflow = (publish_mode === EDITORIAL_WORKFLOW);
|
||||
const returnObj = { isEditorialWorkflow };
|
||||
|
||||
if (isEditorialWorkflow) {
|
||||
/*
|
||||
* Generates an ordered Map of the available status as keys.
|
||||
* Each key containing a List of available unpubhlished entries
|
||||
* Eg.: OrderedMap{'draft':List(), 'pending_review':List(), 'pending_publish':List()}
|
||||
*/
|
||||
returnObj.unpublishedEntries = status.reduce((acc, currStatus) => {
|
||||
return acc.set(currStatus, selectUnpublishedEntries(state, currStatus));
|
||||
}, OrderedMap());
|
||||
}
|
||||
return returnObj;
|
||||
}
|
||||
|
||||
return connect(mapStateToProps, {
|
||||
updateUnpublishedEntryStatus,
|
||||
publishUnpublishedEntry
|
||||
})(CollectionPageHOC);
|
||||
}
|
47
src/containers/editorialWorkflow/EntryPageHOC.js
Normal file
47
src/containers/editorialWorkflow/EntryPageHOC.js
Normal file
@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { EDITORIAL_WORKFLOW } from '../../constants/publishModes';
|
||||
import { selectUnpublishedEntry } from '../../reducers';
|
||||
import { loadUnpublishedEntry, persistUnpublishedEntry } from '../../actions/editorialWorkflow';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
export default function EntryPageHOC(EntryPage) {
|
||||
class EntryPageHOC extends React.Component {
|
||||
render() {
|
||||
return <EntryPage {...this.props}/>;
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const publish_mode = state.config.get('publish_mode');
|
||||
const isEditorialWorkflow = (publish_mode === EDITORIAL_WORKFLOW);
|
||||
const unpublishedEntry = ownProps.route && ownProps.route.unpublishedEntry === true;
|
||||
|
||||
const returnObj = {};
|
||||
if (isEditorialWorkflow && unpublishedEntry) {
|
||||
const status = ownProps.params.status;
|
||||
const slug = ownProps.params.slug;
|
||||
const entry = selectUnpublishedEntry(state, status, slug);
|
||||
returnObj.entry = entry;
|
||||
}
|
||||
return returnObj;
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch, ownProps) {
|
||||
const unpublishedEntry = ownProps.route && ownProps.route.unpublishedEntry === true;
|
||||
const returnObj = {};
|
||||
if (unpublishedEntry) {
|
||||
// Overwrite loadEntry to loadUnpublishedEntry
|
||||
const status = ownProps.params.status;
|
||||
returnObj.loadEntry = (collection, slug) => {
|
||||
dispatch(loadUnpublishedEntry(collection, status, slug));
|
||||
};
|
||||
|
||||
returnObj.persistEntry = (collection, entryDraft) => {
|
||||
dispatch(persistUnpublishedEntry(collection, entryDraft));
|
||||
};
|
||||
}
|
||||
return returnObj;
|
||||
}
|
||||
|
||||
return connect(mapStateToProps, mapDispatchToProps)(EntryPageHOC);
|
||||
}
|
@ -1 +0,0 @@
|
||||
import './FindBar';
|
@ -1,5 +1,37 @@
|
||||
import YAML from './yaml';
|
||||
import YAMLFrontmatter from './yaml-frontmatter';
|
||||
|
||||
export function resolveFormat(collection, entry) {
|
||||
return new YAMLFrontmatter();
|
||||
const yamlFormatter = new YAML();
|
||||
const YamlFrontmatterFormatter = new YAMLFrontmatter();
|
||||
|
||||
function formatByType(type) {
|
||||
// Right now the only type is "editorialWorkflow" and
|
||||
// we always returns the same format
|
||||
return YamlFrontmatterFormatter;
|
||||
}
|
||||
|
||||
function formatByExtension(extension) {
|
||||
return {
|
||||
'yml': yamlFormatter,
|
||||
'md': YamlFrontmatterFormatter,
|
||||
'markdown': YamlFrontmatterFormatter,
|
||||
'html': YamlFrontmatterFormatter
|
||||
}[extension] || YamlFrontmatterFormatter;
|
||||
}
|
||||
|
||||
function formatByName(name) {
|
||||
return {
|
||||
'yaml': yamlFormatter,
|
||||
'frontmatter': YamlFrontmatterFormatter
|
||||
}[name] || YamlFrontmatterFormatter;
|
||||
}
|
||||
|
||||
export function resolveFormat(collectionOrEntity, entry) {
|
||||
if (typeof collectionOrEntity === 'string') {
|
||||
return formatByType(collectionOrEntity);
|
||||
}
|
||||
if (entry && entry.path) {
|
||||
return formatByExtension(entry.path.split('.').pop());
|
||||
}
|
||||
return formatByName(collectionOrEntity.get('format'));
|
||||
}
|
||||
|
@ -41,6 +41,6 @@ export default class YAML {
|
||||
}
|
||||
|
||||
toFile(data) {
|
||||
return yaml.safeDump(data, {schema: OutputSchema});
|
||||
return yaml.safeDump(data, { schema: OutputSchema });
|
||||
}
|
||||
}
|
||||
|
306
src/index.css
306
src/index.css
@ -1,10 +1,13 @@
|
||||
@import "material-icons.css";
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
margin: 0;
|
||||
font-family: Roboto,"Helvetica Neue",HelveticaNeue,Helvetica,Arial,sans-serif;
|
||||
font-family: Roboto, "Helvetica Neue", HelveticaNeue, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
*, *:before, *:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
@ -13,20 +16,10 @@ body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
height: 100%;
|
||||
background-color: #f2f5f4;
|
||||
color:#7c8382;
|
||||
color: #7c8382;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: #272e30;
|
||||
box-shadow: 0 1px 2px 0 rgba(0,0,0,0.22);
|
||||
height: 54px;
|
||||
border-bottom:2px solid #3ab7a5;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
:global #root, :global #root > * {
|
||||
height: 100%;
|
||||
}
|
||||
@ -35,28 +28,275 @@ h1, h2, h3, h4, h5, h6, p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1{
|
||||
color: #3ab7a5;
|
||||
border-bottom: 1px solid #3ab7a5;
|
||||
margin: 30px auto 25px;
|
||||
padding-bottom: 15px;
|
||||
font-size: 25px;
|
||||
h1 {
|
||||
color: #3ab7a5;
|
||||
border-bottom: 1px solid #3ab7a5;
|
||||
margin: 30px auto 25px;
|
||||
padding-bottom: 15px;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
input{
|
||||
width:100%;
|
||||
padding:3px;
|
||||
font-size:14px;
|
||||
margin-bottom:10px;
|
||||
:global {
|
||||
& .cms-widget {
|
||||
border-bottom: 1px solid #e8eae8;
|
||||
position: relative;
|
||||
}
|
||||
& .cms-widget:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 42px;
|
||||
bottom: -7px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #f2f5f4;
|
||||
-webkit-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
z-index: 1;
|
||||
border-right: 1px solid #e8eae8;
|
||||
border-bottom: 1px solid #e8eae8;
|
||||
}
|
||||
& .cms-widget:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
& .cms-widget:last-child:after {
|
||||
display: none;
|
||||
}
|
||||
& .cms-control {
|
||||
color: #7c8382;
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
& label {
|
||||
color: #AAB0AF;
|
||||
font-size: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
& input,
|
||||
& textarea,
|
||||
& select,
|
||||
& .cms-editor-raw {
|
||||
font-family: monospace;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background: 0 0;
|
||||
font-size: 18px;
|
||||
color: #7c8382;
|
||||
}
|
||||
}
|
||||
}
|
||||
header input{
|
||||
margin-bottom:0;
|
||||
|
||||
:global {
|
||||
& .rdt {
|
||||
position: relative;
|
||||
}
|
||||
& .rdtPicker {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 250px;
|
||||
padding: 4px;
|
||||
margin-top: 1px;
|
||||
z-index: 99999 !important;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .1);
|
||||
border: 1px solid #f9f9f9;
|
||||
}
|
||||
& .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;
|
||||
}
|
||||
}
|
||||
button{
|
||||
border: 1px solid #3ab7a5;
|
||||
padding: 3px 20px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
background-color:#fff;
|
||||
cursor: pointer;
|
||||
}
|
47
src/index.js
47
src/index.js
@ -1,31 +1,38 @@
|
||||
import React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Router } from 'react-router';
|
||||
import configureStore from './store/configureStore';
|
||||
import routes from './routing/routes';
|
||||
import history, { syncHistory } from './routing/history';
|
||||
import { initPluginAPI } from './plugins';
|
||||
import { AppContainer } from 'react-hot-loader';
|
||||
import Root from './root';
|
||||
import registry from './lib/registry';
|
||||
import 'file?name=index.html!../example/index.html';
|
||||
import 'react-toolbox/lib/commons.scss';
|
||||
import './index.css';
|
||||
|
||||
const store = configureStore();
|
||||
|
||||
// Create an enhanced history that syncs navigation events with the store
|
||||
syncHistory(store);
|
||||
|
||||
const Plugin = initPluginAPI();
|
||||
|
||||
// Create mount element dynamically
|
||||
const el = document.createElement('div');
|
||||
el.id = 'root';
|
||||
document.body.appendChild(el);
|
||||
|
||||
render((
|
||||
<Provider store={store}>
|
||||
<Plugin>
|
||||
<Router history={history}>
|
||||
{routes}
|
||||
</Router>
|
||||
</Plugin>
|
||||
</Provider>
|
||||
<AppContainer>
|
||||
<Root />
|
||||
</AppContainer>
|
||||
), el);
|
||||
|
||||
if (module.hot) {
|
||||
module.hot.accept('./root', () => {
|
||||
const NextRoot = require('./root').default;
|
||||
render((
|
||||
<AppContainer>
|
||||
<NextRoot />
|
||||
</AppContainer>
|
||||
), el);
|
||||
});
|
||||
}
|
||||
|
||||
window.CMS = {};
|
||||
console.log('reg: ', registry);
|
||||
for (const method in registry) {
|
||||
window.CMS[method] = registry[method];
|
||||
}
|
||||
window.createClass = React.createClass;
|
||||
window.h = React.createElement;
|
||||
|
31
src/lib/randomGenerator.js
Normal file
31
src/lib/randomGenerator.js
Normal file
@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Random number generator
|
||||
*/
|
||||
|
||||
let rng;
|
||||
|
||||
if (window.crypto && crypto.getRandomValues) {
|
||||
// WHATWG crypto-based RNG - http://wiki.whatwg.org/wiki/Crypto
|
||||
// Moderately fast, high quality
|
||||
const _rnds32 = new Uint32Array(1);
|
||||
rng = function whatwgRNG() {
|
||||
crypto.getRandomValues(_rnds32);
|
||||
return _rnds32[0];
|
||||
};
|
||||
}
|
||||
|
||||
if (!rng) {
|
||||
// Math.random()-based (RNG)
|
||||
// If no Crypto available, use Math.random().
|
||||
rng = function() {
|
||||
const r = Math.random() * 0x100000000;
|
||||
const _rnds = r >>> 0;
|
||||
return _rnds;
|
||||
};
|
||||
}
|
||||
|
||||
export function randomStr() {
|
||||
return rng().toString(36);
|
||||
}
|
||||
|
||||
export default rng;
|
36
src/lib/registry.js
Normal file
36
src/lib/registry.js
Normal file
@ -0,0 +1,36 @@
|
||||
import { List } from 'immutable';
|
||||
import { newEditorPlugin } from '../components/Widgets/MarkdownControlElements/plugins';
|
||||
|
||||
const _registry = {
|
||||
templates: {},
|
||||
previewStyles: [],
|
||||
widgets: {},
|
||||
editorComponents: List([])
|
||||
};
|
||||
|
||||
export default {
|
||||
registerPreviewStyle(style) {
|
||||
_registry.previewStyles.push(style);
|
||||
},
|
||||
registerPreviewTemplate(name, component) {
|
||||
_registry.templates[name] = component;
|
||||
},
|
||||
getPreviewTemplate(name) {
|
||||
return _registry.templates[name];
|
||||
},
|
||||
getPreviewStyles() {
|
||||
return _registry.previewStyles;
|
||||
},
|
||||
registerWidget(name, control, preview) {
|
||||
_registry.widgets[name] = { control, preview };
|
||||
},
|
||||
getWidget(name) {
|
||||
return _registry.widgets[name];
|
||||
},
|
||||
registerEditorComponent(component) {
|
||||
_registry.editorComponents = _registry.editorComponents.push(newEditorPlugin(component));
|
||||
},
|
||||
getEditorComponents() {
|
||||
return _registry.editorComponents;
|
||||
}
|
||||
};
|
35
src/material-icons.css
Normal file
35
src/material-icons.css
Normal file
@ -0,0 +1,35 @@
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local('Material Icons'),
|
||||
local('MaterialIcons-Regular'),
|
||||
url('material-design-icons/iconfont/MaterialIcons-Regular.woff2') format('woff2'),
|
||||
url('material-design-icons/iconfont/MaterialIcons-Regular.woff') format('woff'),
|
||||
url('material-design-icons/iconfont/MaterialIcons-Regular.ttf') format('truetype');
|
||||
}
|
||||
|
||||
:global .material-icons {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px; /* Preferred icon size */
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
|
||||
/* Support for all WebKit browsers. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Support for Safari and Chrome. */
|
||||
text-rendering: optimizeLegibility;
|
||||
|
||||
/* Support for Firefox. */
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Support for IE. */
|
||||
font-feature-settings: 'liga';
|
||||
}
|
8
src/reducers/combinedReducer.js
Normal file
8
src/reducers/combinedReducer.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import { routerReducer } from 'react-router-redux';
|
||||
import reducers from '.';
|
||||
|
||||
export default combineReducers({
|
||||
...reducers,
|
||||
routing: routerReducer
|
||||
});
|
88
src/reducers/editorialWorkflow.js
Normal file
88
src/reducers/editorialWorkflow.js
Normal file
@ -0,0 +1,88 @@
|
||||
import { Map, List, fromJS } from 'immutable';
|
||||
import { EDITORIAL_WORKFLOW } from '../constants/publishModes';
|
||||
import {
|
||||
UNPUBLISHED_ENTRY_REQUEST,
|
||||
UNPUBLISHED_ENTRY_SUCCESS,
|
||||
UNPUBLISHED_ENTRIES_REQUEST,
|
||||
UNPUBLISHED_ENTRIES_SUCCESS,
|
||||
UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
|
||||
UNPUBLISHED_ENTRY_PUBLISH_SUCCESS
|
||||
} from '../actions/editorialWorkflow';
|
||||
import { CONFIG_SUCCESS } from '../actions/config';
|
||||
|
||||
const unpublishedEntries = (state = null, action) => {
|
||||
switch (action.type) {
|
||||
case CONFIG_SUCCESS:
|
||||
const publish_mode = action.payload && action.payload.publish_mode;
|
||||
if (publish_mode === EDITORIAL_WORKFLOW) {
|
||||
// Editorial workflow state is explicetelly initiated after the config.
|
||||
return Map({ entities: Map(), pages: Map() });
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
case UNPUBLISHED_ENTRY_REQUEST:
|
||||
return state.setIn(['entities', `${action.payload.status}.${action.payload.slug}`, 'isFetching'], true);
|
||||
|
||||
case UNPUBLISHED_ENTRY_SUCCESS:
|
||||
return state.setIn(
|
||||
['entities', `${action.payload.status}.${action.payload.entry.slug}`],
|
||||
fromJS(action.payload.entry)
|
||||
);
|
||||
|
||||
|
||||
case UNPUBLISHED_ENTRIES_REQUEST:
|
||||
return state.setIn(['pages', 'isFetching'], true);
|
||||
|
||||
case UNPUBLISHED_ENTRIES_SUCCESS:
|
||||
const { entries, pages } = action.payload;
|
||||
return state.withMutations((map) => {
|
||||
entries.forEach((entry) => (
|
||||
map.setIn(['entities', `${entry.metaData.status}.${entry.slug}`], fromJS(entry).set('isFetching', false))
|
||||
));
|
||||
map.set('pages', Map({
|
||||
...pages,
|
||||
ids: List(entries.map((entry) => entry.slug))
|
||||
}));
|
||||
});
|
||||
|
||||
case UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS:
|
||||
return state.withMutations((map) => {
|
||||
let entry = map.getIn(['entities', `${action.payload.oldStatus}.${action.payload.slug}`]);
|
||||
entry = entry.setIn(['metaData', 'status'], action.payload.newStatus);
|
||||
|
||||
let entities = map.get('entities').filter((val, key) => (
|
||||
key !== `${action.payload.oldStatus}.${action.payload.slug}`
|
||||
));
|
||||
entities = entities.set(`${action.payload.newStatus}.${action.payload.slug}`, entry);
|
||||
|
||||
map.set('entities', entities);
|
||||
});
|
||||
|
||||
case UNPUBLISHED_ENTRY_PUBLISH_SUCCESS:
|
||||
return state.deleteIn(['entities', `${action.payload.status}.${action.payload.slug}`]);
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const selectUnpublishedEntry = (state, status, slug) => {
|
||||
return state && state.getIn(['entities', `${status}.${slug}`]);
|
||||
};
|
||||
|
||||
export const selectUnpublishedEntries = (state, status) => {
|
||||
if (!state) return;
|
||||
const slugs = state.getIn(['pages', 'ids']);
|
||||
|
||||
return slugs && slugs.reduce((acc, slug) => {
|
||||
const entry = selectUnpublishedEntry(state, status, slug);
|
||||
if (entry) {
|
||||
return acc.push(entry);
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}, List());
|
||||
};
|
||||
|
||||
|
||||
export default unpublishedEntries;
|
@ -7,13 +7,16 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
|
||||
switch (action.type) {
|
||||
case ENTRY_REQUEST:
|
||||
return state.setIn(['entities', `${action.payload.collection}.${action.payload.slug}`, 'isFetching'], true);
|
||||
|
||||
case ENTRY_SUCCESS:
|
||||
return state.setIn(
|
||||
['entities', `${action.payload.collection}.${action.payload.entry.slug}`],
|
||||
fromJS(action.payload.entry)
|
||||
);
|
||||
|
||||
case ENTRIES_REQUEST:
|
||||
return state.setIn(['pages', action.payload.collection, 'isFetching'], true);
|
||||
|
||||
case ENTRIES_SUCCESS:
|
||||
const { collection, entries, pages } = action.payload;
|
||||
return state.withMutations((map) => {
|
||||
@ -25,6 +28,7 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
|
||||
ids: List(entries.map((entry) => entry.slug))
|
||||
}));
|
||||
});
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Map, List } from 'immutable';
|
||||
import { DRAFT_CREATE_FROM_ENTRY, DRAFT_DISCARD, DRAFT_CHANGE } from '../actions/entries';
|
||||
import { Map, List, fromJS } from 'immutable';
|
||||
import { DRAFT_CREATE_FROM_ENTRY, DRAFT_CREATE_EMPTY, DRAFT_DISCARD, DRAFT_CHANGE } from '../actions/entries';
|
||||
import { ADD_MEDIA, REMOVE_MEDIA } from '../actions/media';
|
||||
|
||||
const initialState = Map({ entry: Map(), mediaFiles: List() });
|
||||
@ -7,23 +7,26 @@ const initialState = Map({ entry: Map(), mediaFiles: List() });
|
||||
const entryDraft = (state = Map(), action) => {
|
||||
switch (action.type) {
|
||||
case DRAFT_CREATE_FROM_ENTRY:
|
||||
if (!action.payload) {
|
||||
// New entry
|
||||
return initialState;
|
||||
}
|
||||
// Existing Entry
|
||||
return state.withMutations((state) => {
|
||||
state.set('entry', action.payload);
|
||||
state.setIn(['entry', 'newRecord'], false);
|
||||
state.set('mediaFiles', List());
|
||||
});
|
||||
case DRAFT_CREATE_EMPTY:
|
||||
// New Entry
|
||||
return state.withMutations((state) => {
|
||||
state.set('entry', fromJS(action.payload));
|
||||
state.setIn(['entry', 'newRecord'], true);
|
||||
state.set('mediaFiles', List());
|
||||
});
|
||||
case DRAFT_DISCARD:
|
||||
return initialState;
|
||||
case DRAFT_CHANGE:
|
||||
return state.set('entry', action.payload);
|
||||
|
||||
case ADD_MEDIA:
|
||||
return state.update('mediaFiles', (list) => list.push(action.payload.path));
|
||||
return state.update('mediaFiles', (list) => list.push(action.payload.public_path));
|
||||
case REMOVE_MEDIA:
|
||||
return state.update('mediaFiles', (list) => list.filterNot((path) => path === action.payload));
|
||||
|
||||
|
@ -2,6 +2,7 @@ import auth from './auth';
|
||||
import config from './config';
|
||||
import editor from './editor';
|
||||
import entries, * as fromEntries from './entries';
|
||||
import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow';
|
||||
import entryDraft from './entryDraft';
|
||||
import collections from './collections';
|
||||
import medias, * as fromMedias from './medias';
|
||||
@ -12,18 +13,27 @@ const reducers = {
|
||||
collections,
|
||||
editor,
|
||||
entries,
|
||||
editorialWorkflow,
|
||||
entryDraft,
|
||||
medias
|
||||
};
|
||||
|
||||
export default reducers;
|
||||
|
||||
/*
|
||||
* Selectors
|
||||
*/
|
||||
export const selectEntry = (state, collection, slug) =>
|
||||
fromEntries.selectEntry(state.entries, collection, slug);
|
||||
|
||||
|
||||
export const selectEntries = (state, collection) =>
|
||||
fromEntries.selectEntries(state.entries, collection);
|
||||
|
||||
export const selectUnpublishedEntry = (state, status, slug) =>
|
||||
fromEditorialWorkflow.selectUnpublishedEntry(state.editorialWorkflow, status, slug);
|
||||
|
||||
export const selectUnpublishedEntries = (state, status) =>
|
||||
fromEditorialWorkflow.selectUnpublishedEntries(state.editorialWorkflow, status);
|
||||
|
||||
export const getMedia = (state, path) =>
|
||||
fromMedias.getMedia(state.medias, path);
|
||||
|
@ -5,7 +5,7 @@ import MediaProxy from '../valueObjects/MediaProxy';
|
||||
const medias = (state = Map(), action) => {
|
||||
switch (action.type) {
|
||||
case ADD_MEDIA:
|
||||
return state.set(action.payload.path, action.payload);
|
||||
return state.set(action.payload.public_path, action.payload);
|
||||
case REMOVE_MEDIA:
|
||||
return state.delete(action.payload);
|
||||
|
||||
|
21
src/root.js
Normal file
21
src/root.js
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Router } from 'react-router';
|
||||
import routes from './routing/routes';
|
||||
import history, { syncHistory } from './routing/history';
|
||||
import configureStore from './store/configureStore';
|
||||
|
||||
const store = configureStore();
|
||||
|
||||
// Create an enhanced history that syncs navigation events with the store
|
||||
syncHistory(store);
|
||||
|
||||
const Root = () => (
|
||||
<Provider store={store}>
|
||||
<Router history={history}>
|
||||
{routes}
|
||||
</Router>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
export default Root;
|
@ -10,7 +10,9 @@ export default (
|
||||
<Route path="/" component={App}>
|
||||
<IndexRoute component={CollectionPage}/>
|
||||
<Route path="/collections/:name" component={CollectionPage}/>
|
||||
<Route path="/collections/:name/entries/:slug" component={EntryPage}/>
|
||||
<Route path="/collections/:name/entries/new" component={EntryPage} newRecord />
|
||||
<Route path="/collections/:name/entries/:slug" component={EntryPage} />
|
||||
<Route path="/editorialworkflow/:name/:status/:slug" component={EntryPage} unpublishedEntry />
|
||||
<Route path="/search" component={SearchPage}/>
|
||||
<Route path="*" component={NotFoundPage}/>
|
||||
</Route>
|
||||
|
@ -1,18 +1,20 @@
|
||||
import { createStore, applyMiddleware, combineReducers, compose } from 'redux';
|
||||
import { createStore, applyMiddleware, compose } from 'redux';
|
||||
import thunkMiddleware from 'redux-thunk';
|
||||
import { routerReducer } from 'react-router-redux';
|
||||
import reducers from '../reducers';
|
||||
import reducer from '../reducers/combinedReducer';
|
||||
|
||||
const reducer = combineReducers({
|
||||
...reducers,
|
||||
routing: routerReducer
|
||||
});
|
||||
export default function configureStore(initialState) {
|
||||
const store = createStore(reducer, initialState, compose(
|
||||
applyMiddleware(thunkMiddleware),
|
||||
window.devToolsExtension ? window.devToolsExtension() : f => f
|
||||
));
|
||||
|
||||
const createStoreWithMiddleware = compose(
|
||||
applyMiddleware(thunkMiddleware),
|
||||
window.devToolsExtension ? window.devToolsExtension() : (f) => f
|
||||
)(createStore);
|
||||
if (module.hot) {
|
||||
// Enable Webpack hot module replacement for reducers
|
||||
module.hot.accept('../reducers/combinedReducer', () => {
|
||||
const nextReducer = require('../reducers/combinedReducer') // eslint-disable-line
|
||||
store.replaceReducer(nextReducer);
|
||||
});
|
||||
}
|
||||
|
||||
export default (initialState) => (
|
||||
createStoreWithMiddleware(reducer, initialState)
|
||||
);
|
||||
return store;
|
||||
}
|
||||
|
9
src/valueObjects/Entry.js
Normal file
9
src/valueObjects/Entry.js
Normal file
@ -0,0 +1,9 @@
|
||||
export function createEntry(path = '', slug = '', raw = '') {
|
||||
const returnObj = {};
|
||||
returnObj.path = path;
|
||||
returnObj.slug = slug;
|
||||
returnObj.raw = raw;
|
||||
returnObj.data = {};
|
||||
returnObj.metaData = {};
|
||||
return returnObj;
|
||||
}
|
@ -9,10 +9,11 @@ export default function MediaProxy(value, file, uploaded = false) {
|
||||
this.uploaded = uploaded;
|
||||
this.sha = null;
|
||||
this.path = config.media_folder && !uploaded ? config.media_folder + '/' + value : value;
|
||||
this.public_path = config.public_folder && !uploaded ? config.public_folder + '/' + value : value;
|
||||
}
|
||||
|
||||
MediaProxy.prototype.toString = function() {
|
||||
return this.uploaded ? this.path : window.URL.createObjectURL(this.file, { oneTimeOnly: true });
|
||||
return this.uploaded ? this.public_path : window.URL.createObjectURL(this.file, { oneTimeOnly: true });
|
||||
};
|
||||
|
||||
MediaProxy.prototype.toBase64 = function() {
|
||||
|
@ -16,15 +16,15 @@ describe('auth', () => {
|
||||
expect(
|
||||
auth(undefined, authenticating())
|
||||
).toEqual(
|
||||
Immutable.Map({isFetching: true})
|
||||
Immutable.Map({ isFetching: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle authentication', () => {
|
||||
expect(
|
||||
auth(undefined, authenticate({email: 'joe@example.com'}))
|
||||
auth(undefined, authenticate({ email: 'joe@example.com' }))
|
||||
).toEqual(
|
||||
Immutable.fromJS({user: {email: 'joe@example.com'}})
|
||||
Immutable.fromJS({ user: { email: 'joe@example.com' } })
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -15,39 +15,39 @@ describe('collections', () => {
|
||||
|
||||
it('should load the collections from the config', () => {
|
||||
expect(
|
||||
collections(undefined, configLoaded({collections: [
|
||||
{name: 'posts', folder: '_posts', fields: [{name: 'title', widget: 'string'}]}
|
||||
]}))
|
||||
collections(undefined, configLoaded({ collections: [
|
||||
{ name: 'posts', folder: '_posts', fields: [{ name: 'title', widget: 'string' }] }
|
||||
] }))
|
||||
).toEqual(
|
||||
OrderedMap({
|
||||
posts: fromJS({name: 'posts', folder: '_posts', fields: [{name: 'title', widget: 'string'}]})
|
||||
posts: fromJS({ name: 'posts', folder: '_posts', fields: [{ name: 'title', widget: 'string' }] })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should mark entries as loading', () => {
|
||||
const state = OrderedMap({
|
||||
'posts': Map({name: 'posts'})
|
||||
'posts': Map({ name: 'posts' })
|
||||
});
|
||||
expect(
|
||||
collections(state, entriesLoading(Map({name: 'posts'})))
|
||||
collections(state, entriesLoading(Map({ name: 'posts' })))
|
||||
).toEqual(
|
||||
OrderedMap({
|
||||
'posts': Map({name: 'posts', isFetching: true})
|
||||
'posts': Map({ name: 'posts', isFetching: true })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle loaded entries', () => {
|
||||
const state = OrderedMap({
|
||||
'posts': Map({name: 'posts'})
|
||||
'posts': Map({ name: 'posts' })
|
||||
});
|
||||
const entries = [{slug: 'a', path: ''}, {slug: 'b', title: 'B'}];
|
||||
const entries = [{ slug: 'a', path: '' }, { slug: 'b', title: 'B' }];
|
||||
expect(
|
||||
collections(state, entriesLoaded(Map({name: 'posts'}), entries))
|
||||
collections(state, entriesLoaded(Map({ name: 'posts' }), entries))
|
||||
).toEqual(
|
||||
OrderedMap({
|
||||
'posts': fromJS({name: 'posts', entries: entries})
|
||||
'posts': fromJS({ name: 'posts', entries: entries })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
@ -14,9 +14,9 @@ describe('config', () => {
|
||||
|
||||
it('should handle an update', () => {
|
||||
expect(
|
||||
config(Immutable.Map({'a': 'b', 'c': 'd'}), configLoaded({'a': 'changed', 'e': 'new'}))
|
||||
config(Immutable.Map({ 'a': 'b', 'c': 'd' }), configLoaded({ 'a': 'changed', 'e': 'new' }))
|
||||
).toEqual(
|
||||
Immutable.Map({'a': 'changed', 'e': 'new'})
|
||||
Immutable.Map({ 'a': 'changed', 'e': 'new' })
|
||||
);
|
||||
});
|
||||
|
||||
@ -24,15 +24,15 @@ describe('config', () => {
|
||||
expect(
|
||||
config(undefined, configLoading())
|
||||
).toEqual(
|
||||
Immutable.Map({isFetching: true})
|
||||
Immutable.Map({ isFetching: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle an error', () => {
|
||||
expect(
|
||||
config(Immutable.Map({isFetching: true}), configFailed(new Error('Config could not be loaded')))
|
||||
config(Immutable.Map({ isFetching: true }), configFailed(new Error('Config could not be loaded')))
|
||||
).toEqual(
|
||||
Immutable.Map({error: 'Error: Config could not be loaded'})
|
||||
Immutable.Map({ error: 'Error: Config could not be loaded' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
45
webpack.base.js
Normal file
45
webpack.base.js
Normal file
@ -0,0 +1,45 @@
|
||||
const webpack = require('webpack');
|
||||
|
||||
module.exports = {
|
||||
module: {
|
||||
loaders: [
|
||||
{
|
||||
test: /\.((png)|(eot)|(woff)|(woff2)|(ttf)|(svg)|(gif))(\?v=\d+\.\d+\.\d+)?$/,
|
||||
loader: 'url-loader?limit=100000'
|
||||
},
|
||||
{
|
||||
test: /\.json$/,
|
||||
loader: 'json-loader'
|
||||
},
|
||||
{
|
||||
test: /\.scss$/,
|
||||
loader: 'style!css?modules!sass',
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
loader: 'style!css?modules&importLoaders=1&&localIdentName=cms__[name]__[local]!postcss',
|
||||
},
|
||||
{
|
||||
loader: 'babel',
|
||||
test: /\.js?$/,
|
||||
exclude: /(node_modules|bower_components)/,
|
||||
query: {
|
||||
cacheDirectory: true,
|
||||
presets: ['react', 'es2015'],
|
||||
plugins: [
|
||||
'transform-class-properties',
|
||||
'transform-object-assign',
|
||||
'transform-object-rest-spread',
|
||||
'lodash',
|
||||
'react-hot-loader/babel'
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
postcss: [
|
||||
require('postcss-import')({ addDependencyTo: webpack }),
|
||||
require('postcss-cssnext')
|
||||
],
|
||||
};
|
@ -1,57 +0,0 @@
|
||||
/* global module, __dirname, require */
|
||||
var webpack = require('webpack');
|
||||
var ExtractTextPlugin = require('extract-text-webpack-plugin');
|
||||
var path = require('path');
|
||||
|
||||
module.exports = {
|
||||
module: {
|
||||
loaders: [
|
||||
{
|
||||
test: /\.((png)|(eot)|(woff)|(woff2)|(ttf)|(svg)|(gif))(\?v=\d+\.\d+\.\d+)?$/,
|
||||
loader: 'url-loader?limit=100000'
|
||||
},
|
||||
{ test: /\.json$/, loader: 'json-loader' },
|
||||
{
|
||||
test: /\.css$/,
|
||||
loader: ExtractTextPlugin.extract("style", "css?modules&importLoaders=1!postcss"),
|
||||
},
|
||||
{
|
||||
loader: 'babel',
|
||||
test: /\.js?$/,
|
||||
exclude: /(node_modules|bower_components)/,
|
||||
query: {
|
||||
cacheDirectory: true,
|
||||
presets: ['react', 'es2015'],
|
||||
plugins: ['transform-class-properties', 'transform-object-assign', 'transform-object-rest-spread', 'lodash']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
postcss: [
|
||||
require('postcss-import')({ addDependencyTo: webpack }),
|
||||
require('postcss-cssnext')
|
||||
],
|
||||
|
||||
plugins: [
|
||||
new ExtractTextPlugin('cms.css', { allChunks: true }),
|
||||
new webpack.ProvidePlugin({
|
||||
'fetch': 'imports?this=>global!exports?global.fetch!whatwg-fetch'
|
||||
})
|
||||
],
|
||||
|
||||
context: path.join(__dirname, 'src'),
|
||||
entry: {
|
||||
cms: './index',
|
||||
},
|
||||
output: {
|
||||
path: path.join(__dirname, 'dist'),
|
||||
filename: '[name].js'
|
||||
},
|
||||
externals: [/^vendor\/.+\.js$/],
|
||||
devServer: {
|
||||
contentBase: 'example/',
|
||||
historyApiFallback: true,
|
||||
devTool: 'source-map'
|
||||
},
|
||||
};
|
37
webpack.dev.js
Normal file
37
webpack.dev.js
Normal file
@ -0,0 +1,37 @@
|
||||
/* global module, __dirname, require */
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const merge = require('webpack-merge');
|
||||
const HOST = 'localhost';
|
||||
const PORT = '8080';
|
||||
|
||||
module.exports = merge.smart(require('./webpack.base.js'), {
|
||||
entry: {
|
||||
cms: [
|
||||
'webpack/hot/dev-server',
|
||||
`webpack-dev-server/client?http://${HOST}:${PORT}/`,
|
||||
'react-hot-loader/patch',
|
||||
'./index'
|
||||
],
|
||||
},
|
||||
output: {
|
||||
path: path.join(__dirname, 'dist'),
|
||||
filename: '[name].js',
|
||||
publicPath: `http://${HOST}:${PORT}/`,
|
||||
},
|
||||
context: path.join(__dirname, 'src'),
|
||||
plugins: [
|
||||
new webpack.optimize.OccurenceOrderPlugin(),
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
new webpack.NoErrorsPlugin(),
|
||||
new webpack.ProvidePlugin({
|
||||
'fetch': 'imports?this=>global!exports?global.fetch!whatwg-fetch'
|
||||
})
|
||||
],
|
||||
devServer: {
|
||||
hot: true,
|
||||
contentBase: 'example/',
|
||||
historyApiFallback: true,
|
||||
devTool: 'cheap-module-source-map'
|
||||
},
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user