Merge pull request #67 from netlify/react-pr

React Editorial Workflow
This commit is contained in:
Cássio Souza 2016-09-19 15:32:06 -03:00 committed by GitHub
commit af1c188a86
89 changed files with 3216 additions and 660 deletions

View File

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

@ -2,3 +2,4 @@ dist/
node_modules/
npm-debug.log
.DS_Store
.tern-project

View File

@ -2,7 +2,6 @@ import { configure } from '@kadira/storybook';
import '../src/index.css';
function loadStories() {
require('../src/containers/stories/');
require('../src/components/stories/');
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View 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%;
}

View File

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

View File

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

View File

@ -0,0 +1,6 @@
.frame {
width: 100%;
height: 100vh;
border: none;
background: #fff;
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -1,5 +1,6 @@
:root {
--defaultColor: #333;
--defaultColorLight: #eee;
--backgroundColor: #fff;
--shadowColor: rgba(0, 0, 0, 0.117647);
--successColor: #1c7;

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

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

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

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

View File

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

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

View File

@ -0,0 +1,9 @@
import React, { PropTypes } from 'react';
export default function StringPreview({ value }) {
return <span>{value}</span>;
}
StringPreview.propTypes = {
value: PropTypes.node,
};

View File

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

View File

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

View File

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

View File

@ -62,4 +62,4 @@ export const SCHEMA = {
borderRadius: '4px'
}
}
}
};

View File

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

View File

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

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

View File

@ -0,0 +1,4 @@
import StringPreview from './StringPreview';
export default class TextPreview extends StringPreview {
}

View File

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

View File

@ -39,4 +39,4 @@ storiesOf('Card', module)
<p>header and footer elements are also not subject to margin</p>
<footer style={styles.footer}>&copy; Thousand Cats Corp</footer>
</Card>
))
));

View File

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

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

View File

@ -1,2 +1,4 @@
import './Card';
import './Icon';
import './Toast';
import './FindBar';

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

@ -1 +0,0 @@
import './FindBar';

View File

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

View File

@ -41,6 +41,6 @@ export default class YAML {
}
toFile(data) {
return yaml.safeDump(data, {schema: OutputSchema});
return yaml.safeDump(data, { schema: OutputSchema });
}
}

View File

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

View File

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

View 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
View 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
View 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';
}

View File

@ -0,0 +1,8 @@
import { combineReducers } from 'redux';
import { routerReducer } from 'react-router-redux';
import reducers from '.';
export default combineReducers({
...reducers,
routing: routerReducer
});

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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