diff --git a/example/index.html b/example/index.html
index d343c456..effe5f93 100644
--- a/example/index.html
+++ b/example/index.html
@@ -2,9 +2,44 @@
This is an example
+
-
diff --git a/package.json b/package.json
index 001fd544..028dd68e 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"imports-loader": "^0.6.5",
"js-yaml": "^3.5.3",
"mocha": "^2.4.5",
+ "moment": "^2.11.2",
"normalizr": "^2.0.0",
"react": "^0.14.7",
"react-dom": "^0.14.7",
diff --git a/src/actions/config.js b/src/actions/config.js
index 1bb3a346..15b567fb 100644
--- a/src/actions/config.js
+++ b/src/actions/config.js
@@ -32,7 +32,7 @@ export function loadConfig(config) {
return (dispatch, getState) => {
dispatch(configLoading());
- fetch('config.yml').then((response) => {
+ fetch('/config.yml').then((response) => {
if (response.status !== 200) {
throw `Failed to load config.yml (${response.status})`;
}
diff --git a/src/actions/entries.js b/src/actions/entries.js
new file mode 100644
index 00000000..1789592a
--- /dev/null
+++ b/src/actions/entries.js
@@ -0,0 +1,49 @@
+import { currentBackend } from '../backends/backend';
+
+export const ENTRIES_REQUEST = 'ENTRIES_REQUEST';
+export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS';
+export const ENTRIES_FAILURE = 'ENTRIES_FAILURE';
+
+export function entriesLoaded(collection, entries) {
+ return {
+ type: ENTRIES_SUCCESS,
+ payload: {
+ collection: collection.get('name'),
+ entries: entries
+ }
+ };
+}
+
+export function entriesLoading(collection) {
+ return {
+ type: ENTRIES_REQUEST,
+ payload: {
+ collection: collection.get('name')
+ }
+ };
+}
+
+export function entriesFailed(collection, error) {
+ return {
+ type: ENTRIES_FAILURE,
+ error: 'Failed to load entries',
+ payload: error.toString(),
+ meta: {collection: collection.get('name')}
+ };
+}
+
+export function loadEntries(collection) {
+ return (dispatch, getState) => {
+ if (collection.get('isFetching')) { return; }
+ const state = getState();
+ const backend = currentBackend(state.config);
+
+ dispatch(entriesLoading(collection));
+ backend.entries(collection)
+ .then((entries) => dispatch(entriesLoaded(collection, entries)))
+ .catch((err) => {
+ console.error(err);
+ return dispatch(entriesFailed(collection, err));
+ });
+ };
+}
diff --git a/src/backends/backend.js b/src/backends/backend.js
index 073b96f6..8f99e507 100644
--- a/src/backends/backend.js
+++ b/src/backends/backend.js
@@ -1,4 +1,5 @@
import TestRepoBackend from './test-repo/Implementation';
+import { resolveFormat } from '../formats/formats';
export function resolveBackend(config) {
const name = config.getIn(['backend', 'name']);
@@ -32,8 +33,24 @@ class Backend {
return this.implementation.authComponent();
}
- authenticate(state) {
- return this.implementation.authenticate(state);
+ authenticate(credentials) {
+ return this.implementation.authenticate(credentials);
+ }
+
+ 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;
+ })
+ ));
+ }
+
+ entry(collection, slug) {
+ return this.implementation.entry(collection, slug);
}
}
diff --git a/src/backends/test-repo/AuthenticationPage.js b/src/backends/test-repo/AuthenticationPage.js
index 6e46d546..fd5c3ddc 100644
--- a/src/backends/test-repo/AuthenticationPage.js
+++ b/src/backends/test-repo/AuthenticationPage.js
@@ -12,7 +12,8 @@ export default class AuthenticationPage extends React.Component {
this.handleEmailChange = this.handleEmailChange.bind(this);
}
- handleLogin() {
+ handleLogin(e) {
+ e.preventDefault();
this.props.onLogin(this.state);
}
@@ -21,13 +22,13 @@ export default class AuthenticationPage extends React.Component {
}
render() {
- return ;
+ ;
}
}
diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js
index f029d873..510b7cd3 100644
--- a/src/backends/test-repo/implementation.js
+++ b/src/backends/test-repo/implementation.js
@@ -1,8 +1,16 @@
import AuthenticationPage from './AuthenticationPage';
+function getSlug(path) {
+ const m = path.match(/([^\/]+)(\.[^\/\.]+)?$/);
+ return m && m[1];
+}
+
export default class TestRepo {
constructor(config) {
this.config = config;
+ if (window.repoFiles == null) {
+ throw 'The TestRepo backend needs a "window.repoFiles" object.';
+ }
}
authComponent() {
@@ -12,4 +20,24 @@ export default class TestRepo {
authenticate(state) {
return Promise.resolve({email: state.email});
}
+
+ entries(collection) {
+ const entries = [];
+ 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
+ });
+ }
+ }
+
+ return Promise.resolve(entries);
+ }
+
+ entry(collection, slug) {
+ return Promise.resolve({slug: slug, title: 'hello'});
+ }
}
diff --git a/src/components/EntryListing.js b/src/components/EntryListing.js
new file mode 100644
index 00000000..ab1ffd40
--- /dev/null
+++ b/src/components/EntryListing.js
@@ -0,0 +1,19 @@
+import React from 'react';
+import { Link } from 'react-router';
+
+export default class EntryListing extends React.Component {
+ render() {
+ const { collection, entries } = this.props;
+ const name = collection.get('name');
+
+ return
+
Listing entries!
+ {entries.map((entry) => {
+ const path = `/collections/${name}/entries/${entry.get('slug')}`;
+ return
+ {entry.getIn(['data', 'title'])}
+ ;
+ })}
+ ;
+ }
+}
diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js
new file mode 100644
index 00000000..cda3010d
--- /dev/null
+++ b/src/containers/CollectionPage.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import { Link } from 'react-router';
+import { connect } from 'react-redux';
+import { loadEntries } from '../actions/entries';
+import EntryListing from '../components/EntryListing';
+
+class DashboardPage extends React.Component {
+ componentDidMount() {
+ const { collection, dispatch } = this.props;
+
+ if (collection) {
+ dispatch(loadEntries(collection));
+ }
+ }
+
+ componentWillReceiveProps(nextProps) {
+ const { collection, dispatch } = this.props;
+ if (nextProps.collection !== collection) {
+ dispatch(loadEntries(nextProps.collection));
+ }
+ }
+
+ render() {
+ const { collections, collection, slug, children } = this.props;
+
+ if (collections == null) {
+ return No collections defined in your config.yml
;
+ }
+
+ const entries = collection.get('entries');
+
+ return
+
Dashboard
+
+ {collections.map((collection) => (
+
+ {collection.get('name')}
+
+ )).toArray()}
+
+
+ {slug ? children :
+ entries ? : 'No entries...'
+ }
+
+
;
+ }
+}
+
+function mapStateToProps(state, ownProps) {
+ const { collections } = state;
+ const { name, slug } = ownProps.params;
+
+ return {
+ slug: slug,
+ collection: name ? collections.get(name) : collections.first(),
+ collections: collections
+ };
+}
+
+export default connect(mapStateToProps)(DashboardPage);
diff --git a/src/containers/DashboardPage.js b/src/containers/DashboardPage.js
deleted file mode 100644
index 3c872da7..00000000
--- a/src/containers/DashboardPage.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-import { Link } from 'react-router';
-import { connect } from 'react-redux';
-
-class DashboardPage extends React.Component {
- render() {
- const { collections } = this.props;
-
- return
-
Dashboard
- {collections && collections.map((collection) => (
-
- {collection.get('name')}
-
- )).toArray()}
-
;
- }
-}
-
-function mapStateToProps(state) {
- return {
- collections: state.collections
- };
-}
-
-export default connect(mapStateToProps)(DashboardPage);
diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js
new file mode 100644
index 00000000..9e1d64bc
--- /dev/null
+++ b/src/containers/EntryPage.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import { Link } from 'react-router';
+import { connect } from 'react-redux';
+import { loadEntries } from '../actions/entries';
+import EntryListing from '../components/EntryListing';
+
+class DashboardPage extends React.Component {
+ componentDidMount() {
+ }
+
+ componentWillReceiveProps(nextProps) {
+ }
+
+ render() {
+ const { collection, entry } = this.props;
+
+ return
+
Entry in {collection.get('label')}
+ {entry && entry.get('title')}
+ ;
+ }
+}
+
+function mapStateToProps(state, ownProps) {
+ const { collections } = state;
+
+ return {
+ collection: collections.get(ownProps.params.name),
+ collections: collections
+ };
+}
+
+export default connect(mapStateToProps)(DashboardPage);
diff --git a/src/formats/formats.js b/src/formats/formats.js
new file mode 100644
index 00000000..9d6f72ad
--- /dev/null
+++ b/src/formats/formats.js
@@ -0,0 +1,5 @@
+import YAMLFrontmatter from './yaml-frontmatter';
+
+export function resolveFormat(collection, entry) {
+ return new YAMLFrontmatter();
+}
diff --git a/src/formats/yaml-frontmatter.js b/src/formats/yaml-frontmatter.js
new file mode 100644
index 00000000..545cc56e
--- /dev/null
+++ b/src/formats/yaml-frontmatter.js
@@ -0,0 +1,31 @@
+import YAML from './yaml';
+
+const regexp = /^---\n([^]*?)\n---\n([^]*)$/;
+
+export default class YAMLFrontmatter {
+ fromFile(content) {
+ const match = content.match(regexp);
+ const obj = match ? new YAML().fromFile(match[1]) : {};
+ obj.body = match ? (match[2] || '').replace(/^\n+/, '') : content;
+ return obj;
+ }
+
+ toFile(data) {
+ const meta = {};
+ let body = '';
+ let content = '';
+ for (var key in data) {
+ if (key === 'body') {
+ body = data[key];
+ } else {
+ meta[key] = data[key];
+ }
+ }
+
+ content += '---\n';
+ content += new YAML().toFile(meta);
+ content += '---\n\n';
+ content += body;
+ return content;
+ }
+}
diff --git a/src/formats/yaml.js b/src/formats/yaml.js
new file mode 100644
index 00000000..c0384e4b
--- /dev/null
+++ b/src/formats/yaml.js
@@ -0,0 +1,31 @@
+import yaml from 'js-yaml';
+import moment from 'moment';
+
+const MomentType = new yaml.Type('date', {
+ kind: 'scalar',
+ predicate: function(value) {
+ return moment.isMoment(value);
+ },
+ represent: function(value) {
+ return value.format(value._f);
+ },
+ resolve: function(value) {
+ return moment.isMoment(value) && value._f;
+ }
+});
+
+const OutputSchema = new yaml.Schema({
+ include: yaml.DEFAULT_SAFE_SCHEMA.include,
+ implicit: [MomentType].concat(yaml.DEFAULT_SAFE_SCHEMA.implicit),
+ explicit: yaml.DEFAULT_SAFE_SCHEMA.explicit
+});
+
+export default class YAML {
+ fromFile(content) {
+ return yaml.safeLoad(content);
+ }
+
+ toFile(data) {
+ return yaml.safeDump(data, {schema: OutputSchema});
+ }
+}
diff --git a/src/reducers/collections.js b/src/reducers/collections.js
index 5451dbbb..dc8a3f47 100644
--- a/src/reducers/collections.js
+++ b/src/reducers/collections.js
@@ -1,15 +1,20 @@
-import Immutable from 'immutable';
+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) {
case CONFIG_SUCCESS:
const collections = action.payload && action.payload.collections;
- return Immutable.OrderedMap().withMutations((map) => {
+ return OrderedMap().withMutations((map) => {
(collections || []).forEach(function(collection) {
- map.set(collection.name, Immutable.fromJS(collection));
+ 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/routes/routes.js b/src/routes/routes.js
index 98eed6d1..5e166eee 100644
--- a/src/routes/routes.js
+++ b/src/routes/routes.js
@@ -1,13 +1,17 @@
import React from 'react';
import { Router, Route, IndexRoute, browserHistory } from 'react-router';
import App from '../containers/App';
-import DashboardPage from '../containers/DashboardPage';
+import DashboardPage from '../containers/CollectionPage';
+import EntryPage from '../containers/EntryPage';
import NotFoundPage from '../containers/NotFoundPage';
export default () => (
+
+
+
diff --git a/test/reducers/collections.spec.js b/test/reducers/collections.spec.js
index a4678477..f3f31583 100644
--- a/test/reducers/collections.spec.js
+++ b/test/reducers/collections.spec.js
@@ -1,6 +1,7 @@
import expect from 'expect';
-import Immutable from 'immutable';
+import { Map, OrderedMap, fromJS } from 'immutable';
import { configLoaded } from '../../src/actions/config';
+import { entriesLoading, entriesLoaded } from '../../src/actions/entries';
import { collections } from '../../src/reducers/collections';
describe('collections', () => {
@@ -18,8 +19,35 @@ describe('collections', () => {
{name: 'posts', folder: '_posts', fields: [{name: 'title', widget: 'string'}]}
]}))
).toEqual(
- Immutable.OrderedMap({
- posts: Immutable.fromJS({name: 'posts', folder: '_posts', fields: [{name: 'title', widget: 'string'}]})
+ OrderedMap({
+ posts: fromJS({name: 'posts', folder: '_posts', fields: [{name: 'title', widget: 'string'}]})
+ })
+ );
+ });
+
+ it('should mark entries as loading', () => {
+ const state = OrderedMap({
+ 'posts': Map({name: 'posts'})
+ });
+ expect(
+ collections(state, entriesLoading(Map({name: 'posts'})))
+ ).toEqual(
+ OrderedMap({
+ 'posts': Map({name: 'posts', isFetching: true})
+ })
+ );
+ });
+
+ it('should handle loaded entries', () => {
+ const state = OrderedMap({
+ 'posts': Map({name: 'posts'})
+ });
+ const entries = [{slug: 'a', path: ''}, {slug: 'b', title: 'B'}];
+ expect(
+ collections(state, entriesLoaded(Map({name: 'posts'}), entries))
+ ).toEqual(
+ OrderedMap({
+ 'posts': fromJS({name: 'posts', entries: entries})
})
);
});