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
+ {loginError &&

{loginError}

} +

Login with GitHub

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