Load, display and edit entries from test repo and github

This commit is contained in:
Mathias Biilmann Christensen 2016-06-05 01:52:18 -07:00
parent 7601d3f5a1
commit 32e54cdbdc
16 changed files with 433 additions and 46 deletions

View File

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

View File

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

View File

@ -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) => {
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).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;
})
));
}
entry(collection, slug) {
return this.implementation.entry(collection, slug);
};
}
}
@ -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}`;
}

View File

@ -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 <div>
{loginError && <p>{loginError}</p>}
<p><a href="#" onClick={this.handleLogin}>Login with GitHub</a></p>
</div>;
}
}

View File

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

View File

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

View File

@ -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 <div>
{collection.get('fields').map((field) => <div>{this.controlFor(field)}</div>)}
{collection.get('fields').map((field) => <div key={field.get('names ')}>{this.controlFor(field)}</div>)}
</div>;
}
}

View File

@ -19,14 +19,23 @@ export default class EntryEditor extends React.Component {
return <div>
<h1>Entry in {collection.get('label')}</h1>
<h2>{entry && entry.get('title')}</h2>
<div className="cms-container">
<div className="cms-control-pane">
<div className="cms-container" style={styles.container}>
<div className="cms-control-pane" style={styles.pane}>
<ControlPane collection={collection} entry={this.state.entry} onChange={this.handleChange}/>
</div>
<div className="cms-preview-pane">
<div className="cms-preview-pane" style={styles.pane}>
<PreviewPane collection={collection} entry={this.state.entry}/>
</div>
</div>
</div>;
}
}
const styles = {
container: {
display: 'flex'
},
pane: {
width: '50%'
}
};

View File

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

View File

@ -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 <div>No control for widget '{field.get('widget')}'.</div>;
}

View File

@ -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 <h1>No collections defined in your config.yml</h1>;
}
const entries = collection.get('entries');
return <div>
<h1>Dashboard</h1>
<div>
@ -39,7 +38,7 @@ class DashboardPage extends React.Component {
)).toArray()}
</div>
<div>
{entries ? <EntryListing collection={collection} entries={entries}/> : 'No entries...'}
{entries ? <EntryListing collection={collection} entries={entries}/> : 'Loading entries...'}
</div>
</div>;
}
@ -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);

View File

@ -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 <EntryEditor entry={entry || new Map()} collection={collection}/>;
render() {
const { entry, collection } = this.props;
if (entry == null || entry.get('isFetching')) {
return <div>Loading...</div>;
}
return (
<EntryEditor
entry={entry || new Map()}
collection={collection}
/>
);
}
}
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);

119
src/lib/netlify-auth.js Normal file
View File

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

View File

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

40
src/reducers/entries.js Normal file
View File

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

View File

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