diff --git a/package.json b/package.json
index 85e11c9a..a4620af6 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,7 @@
"draft-js": "^0.7.0",
"draft-js-export-markdown": "^0.2.0",
"draft-js-import-markdown": "^0.1.6",
- "json-loader": "^0.5.4"
+ "json-loader": "^0.5.4",
+ "localforage": "^1.4.2"
}
}
diff --git a/src/actions/entries.js b/src/actions/entries.js
index 1789592a..dffcecc8 100644
--- a/src/actions/entries.js
+++ b/src/actions/entries.js
@@ -1,15 +1,40 @@
import { currentBackend } from '../backends/backend';
+export const ENTRY_REQUEST = 'ENTRY_REQUEST';
+export const ENTRY_SUCCESS = 'ENTRY_SUCCESS';
+export const ENTRY_FAILURE = 'ENTRY_FAILURE';
+
export const ENTRIES_REQUEST = 'ENTRIES_REQUEST';
export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS';
export const ENTRIES_FAILURE = 'ENTRIES_FAILURE';
-export function entriesLoaded(collection, entries) {
+export function entryLoading(collection, slug) {
+ return {
+ type: ENTRY_REQUEST,
+ payload: {
+ collection: collection.get('name'),
+ slug: slug
+ }
+ };
+}
+
+export function entryLoaded(collection, entry) {
+ return {
+ type: ENTRY_SUCCESS,
+ payload: {
+ collection: collection.get('name'),
+ entry: entry
+ }
+ };
+}
+
+export function entriesLoaded(collection, entries, pagination) {
return {
type: ENTRIES_SUCCESS,
payload: {
collection: collection.get('name'),
- entries: entries
+ entries: entries,
+ pages: pagination
}
};
}
@@ -32,6 +57,17 @@ export function entriesFailed(collection, error) {
};
}
+export function loadEntry(collection, slug) {
+ return (dispatch, getState) => {
+ const state = getState();
+ const backend = currentBackend(state.config);
+
+ dispatch(entryLoading(collection, slug));
+ backend.entry(collection, slug)
+ .then((entry) => dispatch(entryLoaded(collection, entry)));
+ };
+}
+
export function loadEntries(collection) {
return (dispatch, getState) => {
if (collection.get('isFetching')) { return; }
@@ -40,10 +76,6 @@ export function loadEntries(collection) {
dispatch(entriesLoading(collection));
backend.entries(collection)
- .then((entries) => dispatch(entriesLoaded(collection, entries)))
- .catch((err) => {
- console.error(err);
- return dispatch(entriesFailed(collection, err));
- });
+ .then((response) => dispatch(entriesLoaded(collection, response.entries, response.pagination)))
};
}
diff --git a/src/backends/backend.js b/src/backends/backend.js
index 66643373..72dba0c6 100644
--- a/src/backends/backend.js
+++ b/src/backends/backend.js
@@ -1,4 +1,5 @@
import TestRepoBackend from './test-repo/Implementation';
+import GitHubBackend from './github/Implementation';
import { resolveFormat } from '../formats/formats';
class LocalStorageAuthStore {
@@ -25,7 +26,11 @@ class Backend {
currentUser() {
if (this.user) { return this.user; }
- return this.authStore && this.authStore.retrieve();
+ const stored = this.authStore && this.authStore.retrieve();
+ if (stored) {
+ this.implementation.setUser(stored);
+ return stored;
+ }
}
authComponent() {
@@ -39,20 +44,27 @@ class Backend {
});
}
- entries(collection) {
- return this.implementation.entries(collection).then((entries = []) => (
- entries.map((entry) => {
- const format = resolveFormat(collection, entry);
- if (entry && entry.raw) {
- entry.data = format && format.fromFile(entry.raw);
- }
- return entry;
- })
- ));
+ entries(collection, page, perPage) {
+ return this.implementation.entries(collection, page, perPage).then((response) => {
+ return {
+ pagination: response.pagination,
+ entries: response.entries.map(this.entryWithFormat(collection))
+ };
+ });
}
entry(collection, slug) {
- return this.implementation.entry(collection, slug);
+ return this.implementation.entry(collection, slug).then(this.entryWithFormat(collection));
+ }
+
+ entryWithFormat(collection) {
+ return (entry) => {
+ const format = resolveFormat(collection, entry);
+ if (entry && entry.raw) {
+ entry.data = format && format.fromFile(entry.raw);
+ }
+ return entry;
+ };
}
}
@@ -67,6 +79,8 @@ export function resolveBackend(config) {
switch (name) {
case 'test-repo':
return new Backend(new TestRepoBackend(config), authStore);
+ case 'github':
+ return new Backend(new GitHubBackend(config), authStore);
default:
throw `Backend not found: ${name}`;
}
diff --git a/src/backends/github/AuthenticationPage.js b/src/backends/github/AuthenticationPage.js
new file mode 100644
index 00000000..b28c5d47
--- /dev/null
+++ b/src/backends/github/AuthenticationPage.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import Authenticator from '../../lib/netlify-auth';
+
+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 auth = new Authenticator({site_id: 'cms.netlify.com'});
+ auth.authenticate({provider: 'github', scope: 'user'}, (err, data) => {
+ if (err) {
+ this.setState({loginError: err.toString()});
+ return;
+ }
+ this.props.onLogin(data);
+ });
+ }
+
+ render() {
+ const { loginError } = this.state;
+
+ return
;
+ }
+}
diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js
new file mode 100644
index 00000000..96790bd6
--- /dev/null
+++ b/src/backends/github/implementation.js
@@ -0,0 +1,118 @@
+import LocalForage from 'localforage';
+import AuthenticationPage from './AuthenticationPage';
+
+const API_ROOT = 'https://api.github.com';
+
+class API {
+ constructor(token, repo, branch) {
+ this.token = token;
+ this.repo = repo;
+ this.branch = branch;
+ this.baseURL = API_ROOT + `/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(`/contents/${path}`, {
+ headers: {Accept: 'application/vnd.github.VERSION.raw'},
+ data: {ref: this.branch},
+ cache: false
+ }).then((result) => {
+ if (sha) {
+ LocalForage.setItem(`gh.${sha}`, result);
+ }
+
+ return result;
+ });
+ });
+ }
+
+ listFiles(path) {
+ return this.request(`/contents/${path}`, {
+ data: {ref: this.branch}
+ });
+ }
+
+ 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(this.baseURL + path, {...options, headers: headers}).then((response) => {
+ if (response.headers.get('Content-Type').match(/json/)) {
+ return this.parseJsonResponse(response);
+ }
+
+ return response.text();
+ });
+ }
+}
+
+export default class GitHub {
+ constructor(config) {
+ this.config = config;
+ if (config.getIn(['backend', 'repo']) == null) {
+ throw 'The GitHub backend needs a "repo" in the backend configuration.';
+ }
+ this.repo = config.getIn(['backend', 'repo']);
+ }
+
+ authComponent() {
+ return AuthenticationPage;
+ }
+
+ setUser(user) {
+ this.api = new API(user.token, this.repo, this.branch || 'master');
+ }
+
+ authenticate(state) {
+ this.api = new API(state.token, this.repo, this.branch || 'master');
+ return this.api.user().then((user) => {
+ user.token = state.token;
+ return user;
+ });
+ }
+
+ 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) => ({
+ pagination: {},
+ entries
+ }));
+ }
+
+ entry(collection, slug) {
+ return this.entries(collection).then((response) => (
+ response.entries.filter((entry) => entry.slug === slug)[0]
+ ));
+ }
+}
diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js
index 882a826c..3f4ed4bd 100644
--- a/src/backends/test-repo/implementation.js
+++ b/src/backends/test-repo/implementation.js
@@ -13,6 +13,8 @@ export default class TestRepo {
}
}
+ setUser() {}
+
authComponent() {
return AuthenticationPage;
}
@@ -34,12 +36,15 @@ export default class TestRepo {
}
}
- return Promise.resolve(entries);
+ return Promise.resolve({
+ pagination: {},
+ entries
+ });
}
entry(collection, slug) {
- return this.entries(collection).then((entries) => (
- entries.filter((entry) => entry.slug === slug)[0]
+ return this.entries(collection).then((response) => (
+ response.entries.filter((entry) => entry.slug === slug)[0]
));
}
}
diff --git a/src/components/ControlPane.js b/src/components/ControlPane.js
index 9ac5a207..203bc581 100644
--- a/src/components/ControlPane.js
+++ b/src/components/ControlPane.js
@@ -8,8 +8,8 @@ export default class ControlPane extends React.Component {
return React.createElement(widget.Control, {
key: field.get('name'),
field: field,
- value: entry.get(field.get('name')),
- onChange: (value) => this.props.onChange(entry.set(field.get('name'), value))
+ value: entry.getIn(['data', field.get('name')]),
+ onChange: (value) => this.props.onChange(entry.setIn(['data', field.get('name')], value))
});
}
@@ -18,7 +18,7 @@ export default class ControlPane extends React.Component {
if (!collection) { return null; }
return
- {collection.get('fields').map((field) =>
{this.controlFor(field)}
)}
+ {collection.get('fields').map((field) =>
{this.controlFor(field)}
)}
;
}
}
diff --git a/src/components/EntryEditor.js b/src/components/EntryEditor.js
index bd872c63..488b5cb3 100644
--- a/src/components/EntryEditor.js
+++ b/src/components/EntryEditor.js
@@ -19,14 +19,23 @@ export default class EntryEditor extends React.Component {
return
Entry in {collection.get('label')}
{entry && entry.get('title')}
-
-
+
;
}
}
+
+const styles = {
+ container: {
+ display: 'flex'
+ },
+ pane: {
+ width: '50%'
+ }
+};
diff --git a/src/components/PreviewPane.js b/src/components/PreviewPane.js
index 0c075f65..baca6c86 100644
--- a/src/components/PreviewPane.js
+++ b/src/components/PreviewPane.js
@@ -8,7 +8,7 @@ export default class PreviewPane extends React.Component {
return React.createElement(widget.Preview, {
key: field.get('name'),
field: field,
- value: entry.get(field.get('name'))
+ value: entry.getIn(['data', field.get('name')])
});
}
diff --git a/src/components/Widgets/UnknownControl.js b/src/components/Widgets/UnknownControl.js
index 4a0689dc..4599523d 100644
--- a/src/components/Widgets/UnknownControl.js
+++ b/src/components/Widgets/UnknownControl.js
@@ -3,7 +3,6 @@ import React from 'react';
export default class UnknownControl extends React.Component {
render() {
const { field } = this.props;
- console.log('field: %o', field.toObject());
return
No control for widget '{field.get('widget')}'.
;
}
diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js
index 276a2168..f8e0072f 100644
--- a/src/containers/CollectionPage.js
+++ b/src/containers/CollectionPage.js
@@ -2,6 +2,7 @@ import React from 'react';
import { Link } from 'react-router';
import { connect } from 'react-redux';
import { loadEntries } from '../actions/entries';
+import { selectEntries } from '../reducers/entries';
import EntryListing from '../components/EntryListing';
class DashboardPage extends React.Component {
@@ -21,14 +22,12 @@ class DashboardPage extends React.Component {
}
render() {
- const { collections, collection } = this.props;
+ const { collections, collection, entries } = this.props;
if (collections == null) {
return
No collections defined in your config.yml
;
}
- const entries = collection.get('entries');
-
return
Dashboard
@@ -39,7 +38,7 @@ class DashboardPage extends React.Component {
)).toArray()}
- {entries ? : 'No entries...'}
+ {entries ? : 'Loading entries...'}
;
}
@@ -49,8 +48,9 @@ function mapStateToProps(state, ownProps) {
const { collections } = state;
const { name, slug } = ownProps.params;
const collection = name ? collections.get(name) : collections.first();
+ const entries = selectEntries(state, collection.get('name'));
- return {slug, collection, collections};
+ return {slug, collection, collections, entries};
}
export default connect(mapStateToProps)(DashboardPage);
diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js
index 17285f89..8abfdf18 100644
--- a/src/containers/EntryPage.js
+++ b/src/containers/EntryPage.js
@@ -1,21 +1,38 @@
import React from 'react';
import { connect } from 'react-redux';
import { Map } from 'immutable';
+import { loadEntry } from '../actions/entries';
+import { selectEntry } from '../reducers/entries';
import EntryEditor from '../components/EntryEditor';
class EntryPage extends React.Component {
- render() {
- const { collection, entry } = this.props;
+ constructor(props) {
+ super(props);
+ this.props.dispatch(loadEntry(props.collection, props.slug));
+ }
- return
;
+ render() {
+ const { entry, collection } = this.props;
+ if (entry == null || entry.get('isFetching')) {
+ return Loading...
;
+ }
+
+ return (
+
+ );
}
}
function mapStateToProps(state, ownProps) {
- const { collections, media } = state;
+ const { collections } = state;
const collection = collections.get(ownProps.params.name);
+ const slug = ownProps.params.slug;
+ const entry = selectEntry(state, collection.get('name'), slug);
- return {media, collection, collections};
+ return {collection, collections, slug, entry};
}
export default connect(mapStateToProps)(EntryPage);
diff --git a/src/lib/netlify-auth.js b/src/lib/netlify-auth.js
new file mode 100644
index 00000000..54a75283
--- /dev/null
+++ b/src/lib/netlify-auth.js
@@ -0,0 +1,119 @@
+const NETLIFY_API = 'https://api.netlify.com';
+
+class NetlifyError {
+ constructor(err) {
+ this.err = err;
+ }
+ toString() {
+ return this.err && this.err.message;
+ }
+}
+
+const PROVIDERS = {
+ github: {
+ width: 960,
+ height: 600
+ },
+ gitlab: {
+ width: 960,
+ height: 600
+ },
+ bitbucket: {
+ width: 960,
+ height: 500
+ },
+ email: {
+ width: 500,
+ height: 400
+ }
+};
+
+class Authenticator {
+ constructor(config) {
+ this.site_id = config.site_id;
+ this.base_url = config.base_url || NETLIFY_API;
+ }
+
+ handshakeCallback(options, cb) {
+ const fn = (e) => {
+ if (e.data === ('authorizing:' + options.provider) && e.origin === this.base_url) {
+ window.removeEventListener('message', fn, false);
+ window.addEventListener('message', this.authorizeCallback(options, cb), false);
+ return this.authWindow.postMessage(e.data, e.origin);
+ }
+ };
+ return fn;
+ }
+
+ authorizeCallback(options, cb) {
+ const fn = (e) => {
+ var data, err;
+ if (e.origin !== this.base_url) { return; }
+ if (e.data.indexOf('authorization:' + options.provider + ':success:') === 0) {
+ data = JSON.parse(e.data.match(new RegExp('^authorization:' + options.provider + ':success:(.+)$'))[1]);
+ window.removeEventListener('message', fn, false);
+ this.authWindow.close();
+ cb(null, data);
+ }
+ if (e.data.indexOf('authorization:' + options.provider + ':error:') === 0) {
+ console.log('Got authorization error');
+ err = JSON.parse(e.data.match(new RegExp('^authorization:' + options.provider + ':error:(.+)$'))[1]);
+ window.removeEventListener('message', fn, false);
+ this.authWindow.close();
+ cb(new NetlifyError(err));
+ }
+ };
+ return fn;
+ }
+
+ getSiteID() {
+ if (this.site_id) {
+ return this.site_id;
+ }
+ const host = document.location.host.split(':')[0];
+ return host === 'localhost' ? null : host;
+ }
+
+ authenticate(options, cb) {
+ var left, top, url,
+ siteID = this.getSiteID(),
+ provider = options.provider;
+ if (!provider) {
+ return cb(new NetlifyError({
+ message: 'You must specify a provider when calling netlify.authenticate'
+ }));
+ }
+ if (!siteID) {
+ return cb(new NetlifyError({
+ message: 'You must set a site_id with netlify.configure({site_id: \'your-site-id\'}) to make authentication work from localhost'
+ }));
+ }
+
+ const conf = PROVIDERS[provider] || PROVIDERS.github;
+ left = (screen.width / 2) - (conf.width / 2);
+ top = (screen.height / 2) - (conf.height / 2);
+ window.addEventListener('message', this.handshakeCallback(options, cb), false);
+ url = this.base_url + '/auth?provider=' + options.provider + '&site_id=' + siteID;
+ if (options.scope) {
+ url += '&scope=' + options.scope;
+ }
+ if (options.login === true) {
+ url += '&login=true';
+ }
+ if (options.beta_invite) {
+ url += '&beta_invite=' + options.beta_invite;
+ }
+ if (options.invite_code) {
+ url += '&invite_code=' + options.invite_code;
+ }
+ this.authWindow = window.open(
+ url,
+ 'Netlify Authorization',
+ 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, ' +
+ ('width=' + conf.width + ', height=' + conf.height + ', top=' + top + ', left=' + left + ');')
+ );
+ this.authWindow.focus();
+ }
+}
+
+export default Authenticator;
diff --git a/src/reducers/collections.js b/src/reducers/collections.js
index dc8a3f47..48c24002 100644
--- a/src/reducers/collections.js
+++ b/src/reducers/collections.js
@@ -1,6 +1,5 @@
import { OrderedMap, fromJS } from 'immutable';
import { CONFIG_SUCCESS } from '../actions/config';
-import { ENTRIES_REQUEST, ENTRIES_SUCCESS } from '../actions/entries';
export function collections(state = null, action) {
switch (action.type) {
@@ -11,10 +10,6 @@ export function collections(state = null, action) {
map.set(collection.name, fromJS(collection));
});
});
- case ENTRIES_REQUEST:
- return state && state.setIn([action.payload.collection, 'isFetching'], true);
- case ENTRIES_SUCCESS:
- return state && state.setIn([action.payload.collection, 'entries'], fromJS(action.payload.entries));
default:
return state;
}
diff --git a/src/reducers/entries.js b/src/reducers/entries.js
new file mode 100644
index 00000000..6c54c39e
--- /dev/null
+++ b/src/reducers/entries.js
@@ -0,0 +1,40 @@
+import { Map, List, fromJS } from 'immutable';
+import {
+ ENTRY_REQUEST, ENTRY_SUCCESS, ENTRIES_REQUEST, ENTRIES_SUCCESS
+} from '../actions/entries';
+
+export function 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) => {
+ entries.forEach((entry) => (
+ map.setIn(['entities', `${collection}.${entry.slug}`], fromJS(entry).set('isFetching', false))
+ ));
+ map.setIn(['pages', collection], Map({
+ ...pages,
+ ids: List(entries.map((entry) => entry.slug))
+ }));
+ });
+ default:
+ return state;
+ }
+}
+
+export function selectEntry(state, collection, slug) {
+ return state.entries.getIn(['entities', `${collection}.${slug}`]);
+}
+
+export function selectEntries(state, collection) {
+ const slugs = state.entries.getIn(['pages', collection, 'ids']);
+ return slugs && slugs.map((slug) => selectEntry(state, collection, slug));
+}
diff --git a/src/store/configureStore.js b/src/store/configureStore.js
index 92ea91d3..d87b77a1 100644
--- a/src/store/configureStore.js
+++ b/src/store/configureStore.js
@@ -4,12 +4,14 @@ import { browserHistory } from 'react-router';
import { syncHistory, routeReducer } from 'react-router-redux';
import { auth } from '../reducers/auth';
import { config } from '../reducers/config';
+import { entries } from '../reducers/entries';
import { collections } from '../reducers/collections';
const reducer = combineReducers({
auth,
config,
collections,
+ entries,
router: routeReducer
});