├── .gitignore
├── ct.png
├── demo.gif
├── webhook.png
├── src
├── config.json
├── pubnub.js
├── index.js
├── links.js
├── articles.js
├── style.js
├── field-value.js
├── contentful.js
└── app.js
├── .eslintrc.json
├── public
└── index.html
├── package.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /public/app.js
3 |
--------------------------------------------------------------------------------
/ct.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/contentful-labs/gazette/HEAD/ct.png
--------------------------------------------------------------------------------
/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/contentful-labs/gazette/HEAD/demo.gif
--------------------------------------------------------------------------------
/webhook.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/contentful-labs/gazette/HEAD/webhook.png
--------------------------------------------------------------------------------
/src/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "contentful": {
3 | "spaceId": "3n1pc3gv7mdd",
4 | "deliveryAccessToken": "937eeea4a30a957ef44ffd3b2330a3c5beabdeefe76a723e138e30f2aa1b2cbe",
5 | "contentTypeId": "article",
6 | "locale": "en-US"
7 | },
8 | "pubnub": {
9 | "subscribeKey": "sub-c-237a790e-fce1-11e6-9c1a-0619f8945a4f",
10 | "channel": "articles"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/pubnub.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const PubNub = require('pubnub');
4 |
5 | const config = require('./config.json').pubnub;
6 | const pubnub = new PubNub({subscribeKey: config.subscribeKey});
7 |
8 | const channels = [config.channel];
9 |
10 | let subscribers = [];
11 |
12 | pubnub.addListener({message: onMessage});
13 | pubnub.subscribe({channels});
14 |
15 | module.exports = {
16 | subscribe: fn => subscribers.push(fn),
17 | off
18 | };
19 |
20 | function onMessage (payload) {
21 | subscribers.forEach(fn => fn(payload.message));
22 | }
23 |
24 | function off () {
25 | subscribers = [];
26 | pubnub.unsubscribe({channels});
27 | }
28 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "commonjs": true,
5 | "es6": true
6 | },
7 | "parserOptions": {
8 | "ecmaFeatures": {
9 | "jsx": true
10 | }
11 | },
12 | "plugins": [
13 | "react"
14 | ],
15 | "extends": [
16 | "eslint:recommended",
17 | "plugin:react/recommended"
18 | ],
19 | "rules": {
20 | "indent": [
21 | "error",
22 | 2
23 | ],
24 | "linebreak-style": [
25 | "error",
26 | "unix"
27 | ],
28 | "quotes": [
29 | "error",
30 | "single"
31 | ],
32 | "semi": [
33 | "error",
34 | "always"
35 | ],
36 | "strict": [
37 | "error",
38 | "global"
39 | ]
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | require('es6-promise/auto');
4 |
5 | const React = require('react');
6 | const ReactDOM = require('react-dom');
7 | const FontFaceObserver = require('fontfaceobserver');
8 |
9 | const App = require('./app.js');
10 |
11 | const loaderEl = document.getElementById('pre-app-loader');
12 | const appEl = document.getElementById('app');
13 |
14 | ReactDOM.render( , appEl);
15 |
16 | Promise.all([
17 | loadFont('Playfair Display'),
18 | loadFont('Playfair Display', 700),
19 | loadFont('Playfair Display', 900),
20 | loadFont('Playfair Display', 400, 'italic'),
21 | loadFont('Droid Serif'),
22 | loadFont('Droid Serif', 700),
23 | loadFont('Droid Serif', 400, 'italic'),
24 | loadFont('Droid Serif', 700, 'italic')
25 | ]).then(showApp, showApp);
26 |
27 | function showApp () {
28 | loaderEl.style.display = 'none';
29 | appEl.style.display = 'block';
30 | }
31 |
32 | function loadFont (ff, weight = 400, style = 'normal') {
33 | const font = new FontFaceObserver(ff, {weight, style});
34 | return font.load(null, 1500);
35 | }
36 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Contentful Gazette
7 |
8 |
9 |
16 |
17 |
18 | Loading...
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/links.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const React = require('react');
4 | const {Style} = require('radium');
5 |
6 | module.exports = {Link, Ribbon};
7 |
8 | function Link ({to, label}) {
9 | return
15 | {label}
16 | ;
17 | }
18 |
19 | Link.propTypes = {
20 | to: React.PropTypes.string.isRequired,
21 | label: React.PropTypes.string.isRequired
22 | };
23 |
24 | function Ribbon ({pos, color, label, href, onClick}) {
25 | color = color || 'c00';
26 | const id = `ribbon-color-${color}`;
27 | const handler = onClick && (e => {
28 | e.preventDefault();
29 | onClick(e);
30 | });
31 |
32 | const style = ;
35 |
36 | const ribbon =
44 | {label}
45 | ;
46 |
47 | return {style}{ribbon}
;
48 | }
49 |
50 | Ribbon.propTypes = {
51 | pos: React.PropTypes.string,
52 | color: React.PropTypes.string,
53 | label: React.PropTypes.string.isRequired,
54 | href: React.PropTypes.string,
55 | onClick: React.PropTypes.func
56 | };
57 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "contentful-gazette",
3 | "version": "0.2.0",
4 | "private": true,
5 | "license": "MIT",
6 | "scripts": {
7 | "lint": "eslint 'src/**/*.js'",
8 | "serve": "http-server public -p 8000 -c-1",
9 | "watch": "watchify src/index.js -o public/app.js -v -t [ babelify --presets [ es2015 react ] ]",
10 | "build-dev": "concurrently -k \"npm run serve\" \"npm run watch\"",
11 | "build-dist": "NODE_ENV=production browserify src/index.js -o public/app.js -v -t [ babelify --presets [ es2015 react ] ] && uglifyjs public/app.js -m -o public/app.js",
12 | "deploy-gh": "npm run build-dist && gh-pages -d public"
13 | },
14 | "dependencies": {
15 | "contentful": "^3.8.1",
16 | "contentful-management": "^1.3.1",
17 | "es6-promise": "^4.0.5",
18 | "fontfaceobserver": "^2.0.8",
19 | "pubnub": "^4.4.4",
20 | "radium": "^0.18.1",
21 | "react": "^15.4.2",
22 | "react-dom": "^15.4.2",
23 | "sanitize-html": "^1.14.1"
24 | },
25 | "devDependencies": {
26 | "babel-preset-es2015": "^6.22.0",
27 | "babel-preset-react": "^6.23.0",
28 | "babelify": "^7.3.0",
29 | "browserify": "^14.1.0",
30 | "concurrently": "^3.3.0",
31 | "eslint": "^3.16.1",
32 | "eslint-plugin-react": "^6.10.0",
33 | "gh-pages": "^0.12.0",
34 | "http-server": "^0.9.0",
35 | "uglify-js": "^2.7.5",
36 | "watchify": "^3.9.0"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/articles.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const React = require('react');
4 | const Radium = require('radium');
5 |
6 | const style = require('./style.js').articles;
7 | const FieldValue = require('./field-value.js');
8 |
9 | const Article = React.createClass({
10 | propTypes: {
11 | entry: React.PropTypes.object.isRequired,
12 | i: React.PropTypes.number.isRequired
13 | },
14 | getChildContext: function () {
15 | return {getEntry: () => this.props.entry};
16 | },
17 | render: function () {
18 | return
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
;
31 | }
32 | });
33 |
34 | Article.childContextTypes = {
35 | getEntry: React.PropTypes.func
36 | };
37 |
38 | const StyledArticle = Radium(Article);
39 |
40 | const Articles = ({entries}) => {
41 | if (Array.isArray(entries) && entries.length > 0) {
42 | return
43 | {entries.map((entry, i) => {
44 | return ;
45 | })}
46 |
;
47 | } else {
48 | return There are no articles yet.
;
49 | }
50 | };
51 |
52 | Articles.propTypes = {
53 | entries: React.PropTypes.array
54 | };
55 |
56 | module.exports = Radium(Articles);
57 |
--------------------------------------------------------------------------------
/src/style.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const color = '#2f2f2f';
4 | const border = `1px solid ${color}`;
5 |
6 | const app = {
7 | container: {
8 | margin: '10px'
9 | },
10 | heading: {
11 | lineHeight: 1,
12 | textTransform: 'uppercase',
13 | textAlign: 'center',
14 | },
15 | title: {
16 | margin: '40px 0',
17 | fontFamily: '"Playfair Display", serif',
18 | fontWeight: 900,
19 | fontSize: '6vw'
20 | },
21 | line: {
22 | borderStyle: 'solid',
23 | borderWidth: '2px 0',
24 | borderColor: color,
25 | padding: '12px'
26 | },
27 | footer: {
28 | marginTop: '-1px',
29 | fontSize: '12px',
30 | textAlign: 'right',
31 | }
32 | };
33 |
34 | const articles = {
35 | grid: {
36 | display: 'flex',
37 | flexWrap: 'wrap',
38 | justifyContent: 'flex-start'
39 | },
40 | article: {
41 | padding: '30px 30px 50px',
42 | width: '33.33%',
43 | '@media (max-width: 800px)': {
44 | width: '100%',
45 | borderRight: 0
46 | }
47 | },
48 | articleBorder: i => ({borderBottom: border, borderRight: i%3 !== 2 && border}),
49 | heading: {
50 | padding: '10px 0',
51 | textAlign: 'center',
52 | fontFamily: '"Playfair Display", serif'
53 | },
54 | title: {
55 | fontWeight: 400,
56 | fontSize: '30px',
57 | textTransform: 'uppercase',
58 | fontStyle: 'italic'
59 | },
60 | titleVariant: i => [
61 | {fontWeight: 700, fontStyle: 'normal'},
62 | {fontSize: '36px', textTransform: 'none'},
63 | {fontSize: '42px'}
64 | ][i%3],
65 | leadBeforeAfter: {
66 | width: '100px',
67 | borderTop: border,
68 | margin: '5px auto'
69 | }
70 | };
71 |
72 | module.exports = {app, articles};
73 |
--------------------------------------------------------------------------------
/src/field-value.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const React = require('react');
4 | const sanitizeHtml = require('sanitize-html');
5 |
6 | const FieldValue = React.createClass({
7 | propTypes: {
8 | fieldId: React.PropTypes.string.isRequired,
9 | html: React.PropTypes.bool
10 | },
11 | getInitialState: function () {
12 | return {editing: false};
13 | },
14 | edit: function () {
15 | if (this.context.canUpdate()) {
16 | this.setState({editing: true}, () => this.el.focus());
17 | } else {
18 | // eslint-disable-next-line no-console
19 | console.log('Cannot edit w/o CMA token.');
20 | }
21 | },
22 | sanitize: function (value) {
23 | const allowedTags = this.props.html ? ['b', 'strong', 'i', 'em', 'div', 'br'] : [];
24 | return sanitizeHtml(value, {allowedTags, allowedAttributes: []});
25 | },
26 | save: function () {
27 | this.setState({editing: false});
28 | if (this.context.canUpdate()) {
29 | this.context.update(
30 | this.context.getEntry().sys.id,
31 | this.props.fieldId,
32 | this.sanitize(this.el.innerHTML)
33 | );
34 | }
35 | },
36 | getSanitizedValue: function () {
37 | const entry = this.context.getEntry();
38 | const value = entry.fields[this.props.fieldId];
39 | return {__html: this.sanitize(value)};
40 | },
41 | render: function () {
42 | const props = {
43 | style: {padding: '10px', backgroundColor: this.state.editing && '#fff'},
44 | ref: el => this.el = el,
45 | onClick: this.edit,
46 | onBlur: this.save,
47 | suppressContentEditableWarning: true,
48 | contentEditable: this.context.canUpdate() && this.state.editing,
49 | dangerouslySetInnerHTML: this.getSanitizedValue()
50 | };
51 |
52 | return
;
53 | }
54 | });
55 |
56 | FieldValue.contextTypes = {
57 | getEntry: React.PropTypes.func,
58 | canUpdate: React.PropTypes.func,
59 | update: React.PropTypes.func
60 | };
61 |
62 | module.exports = FieldValue;
63 |
--------------------------------------------------------------------------------
/src/contentful.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const delivery = require('contentful');
4 | const mgmt = require('contentful-management');
5 |
6 | const config = require('./config.json').contentful;
7 |
8 | const cda = delivery.createClient({
9 | space: config.spaceId,
10 | accessToken: config.deliveryAccessToken
11 | });
12 |
13 | module.exports = {
14 | getList,
15 | createUpdater,
16 | handleWebhookEntry
17 | };
18 |
19 | function getList (limit) {
20 | limit = limit || 6;
21 | const order = '-sys.createdAt';
22 | const content_type = config.contentTypeId;
23 | const params = {limit, order, content_type};
24 |
25 | return cda.getEntries(params).then(
26 | res => res.items,
27 | () => alert('Something went wrong. Is a valid space ID provided?')
28 | );
29 | }
30 |
31 | function createUpdater (cb) {
32 | const cmaToken = prompt('Please provide your CMA token:');
33 | const fail = () => alert('Something went wrong. Is your token valid?');
34 |
35 | if (typeof cmaToken !== 'string') {
36 | return;
37 | } else if (cmaToken.length < 15) {
38 | return fail();
39 | }
40 |
41 | mgmt.createClient({accessToken: cmaToken})
42 | .getSpace(config.spaceId).then(space => {
43 | cb(createUpdaterFor(space, config.locale));
44 | }, fail);
45 | }
46 |
47 | function createUpdaterFor (space, locale) {
48 | return (eid, fid, val) => space.getEntry(eid).then(entry => {
49 | if (val === entry.fields[fid][locale]) {
50 | return Promise.resolve(entry);
51 | } else {
52 | entry.fields[fid][locale] = val;
53 | return entry.update().then(updated => updated.publish());
54 | }
55 | });
56 | }
57 |
58 | function handleWebhookEntry (entry, entries) {
59 | if (entry.sys.contentType.sys.id !== config.contentTypeId) {
60 | return entries;
61 | }
62 |
63 | entry.fields = Object.keys(entry.fields).reduce((acc, key) => {
64 | acc[key] = entry.fields[key][config.locale];
65 | return acc;
66 | }, {});
67 |
68 | return entries.map(existing => {
69 | return existing.sys.id === entry.sys.id ? entry : existing;
70 | });
71 | }
72 |
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const React = require('react');
4 | const Radium = require('radium');
5 | const StyleRoot = Radium.StyleRoot;
6 |
7 | const client = require('./contentful.js');
8 | const pubsub = require('./pubnub.js');
9 | const style = require('./style.js').app;
10 |
11 | const {Link, Ribbon} = require('./links.js');
12 | const Articles = require('./articles.js');
13 |
14 | const App = React.createClass({
15 | getInitialState: function () {
16 | return {loading: true, entries: null};
17 | },
18 | componentDidMount: function () {
19 | client.getList()
20 | .then(entries => {
21 | this.setState({loading: false, entries});
22 | pubsub.subscribe(e => this.setState(s => {
23 | return {entries: client.handleWebhookEntry(e, s.entries)};
24 | }));
25 | });
26 | },
27 | componentWillUnmount: function () {
28 | pubsub.off();
29 | },
30 | getChildContext: function () {
31 | return {
32 | canUpdate: () => this.hasUpdater(),
33 | update: (eid, fid, val) => this.hasUpdater() && this.state.updater(eid, fid, val)
34 | };
35 | },
36 | hasUpdater: function () {
37 | return typeof this.state.updater === 'function';
38 | },
39 | edit: function () {
40 | client.createUpdater(updater => this.setState({updater}));
41 | },
42 | render: function () {
43 | if (this.state.loading) {
44 | return Loading...
;
45 | }
46 |
47 | return
48 |
49 | {!this.state.updater &&
}
50 | {this.state.updater &&
}
51 |
52 |
53 |
54 | Contentful Gazette
55 |
56 |
57 | Berlin, Germany – {(new Date()).toDateString()}
58 |
59 |
60 |
61 |
62 |
63 | ♥ Built with
64 | {', '}
65 | {' and '} .
66 |
67 |
68 | ;
69 | }
70 | });
71 |
72 | App.childContextTypes = {
73 | canUpdate: React.PropTypes.func,
74 | update: React.PropTypes.func
75 | };
76 |
77 | module.exports = Radium(App);
78 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Contentful Gazette
2 |
3 | *bidirectional real-time content management and delivery*
4 |
5 | 
6 |
7 | ## Disclaimers
8 |
9 | This application was produced during a hackathon. It's a quick Proof of Concept created in 8h. It's not ready to be used in production.
10 |
11 | ### Known shortcomings
12 |
13 | - there's no [server side rendering](https://facebook.github.io/react/docs/react-dom-server.html). It won't work without JavaScript enabled and load times will most likely suffer
14 | - entry changes are populated with the real-time sync faster than they end up in the CDA. A page refreshed right after syncing may still show the previous version. To mitigate this problem we could use `localStorage` to cache versions received with the sync channel
15 | - failed update are not reverted from the state of the app. You can provide a value that won't pass validations and will be rejected by the CMA, but it'll stay as is in the front-end
16 |
17 | ### Security
18 |
19 | **Never ever give your CMA token away to applications you don't trust**. Contentful Gazette will store your CMA token in-memory only until the browser tab is closed. You can verify what we do with your token by studying the code of [src/contentful.js](./src/contentful.js).
20 |
21 | ## Setting up your own Gazette
22 |
23 | ### Prepare content
24 |
25 | 1. create a new space in [Contentful](https://www.contentful.com)
26 | 2. create a content type with ID `article` consisting of 3 fields: `title`, `lead`, `content` (see below)
27 | 3. create a Content Delivery API key (or use the default one)
28 | 4. write some articles :)
29 |
30 | ### Run it locally and on GitHub Pages
31 |
32 | 1. update [src/config.json](./src/config.json) with your space ID and the CDA key
33 | 2. install dependencies with `npm install`
34 | 3. start dev server by running `npm run build-dev`
35 | 4. push it to GitHub Pages using `npm run deploy-gh`
36 |
37 | ### Get live updates
38 |
39 | 1. create a [PubNub](https://www.pubnub.com/) application with a key set
40 | 2. for your space create a webhook for entry publication events calling PubNub (see below)
41 | 3. update [src/config.json](./src/config.json) with your PubNub subscribe key
42 |
43 | ### Write changes from Gazette back to Contentful
44 |
45 | 1. click on the `edit this website` ribbon on the top left corner of gazette and paste your Content Management API token
46 | 2. click in any of the textfields and change a value
47 | 3. open gazette in a different tab or look at the entries in the Contentful web app to see you changes get synchronized
48 |
49 | ### Appendix: content type
50 |
51 | 
52 |
53 | ```js
54 | {
55 | "name": "article",
56 | "description": "Contentful Gazette article",
57 | "displayField": "title",
58 | "fields": [
59 | {
60 | "name": "title",
61 | "id": "title",
62 | "type": "Symbol",
63 | "localized": false,
64 | "required": false,
65 | "disabled": false,
66 | "omitted": false,
67 | "validations": []
68 | },
69 | {
70 | "name": "lead",
71 | "id": "lead",
72 | "type": "Symbol",
73 | "localized": false,
74 | "required": false,
75 | "disabled": false,
76 | "omitted": false,
77 | "validations": []
78 | },
79 | {
80 | "name": "content",
81 | "id": "content",
82 | "type": "Text",
83 | "localized": false,
84 | "required": false,
85 | "disabled": false,
86 | "omitted": false,
87 | "validations": []
88 | }
89 | ],
90 | "sys": {/* redacted */}
91 | }
92 | ```
93 |
94 | ### Appendix: webhook
95 |
96 | First you have to compose your publish URL. Refer the [PubNub documentation](https://www.pubnub.com/docs/pubnub-rest-api-documentation#publish-subscribe-publish-v1-via-post-post):
97 |
98 | ```
99 | https://pubsub.pubnub.com/publish/{pub_key}/{sub_key}/0/articles/0?store=1
100 | ```
101 |
102 | Then this URL should be used for as a [Contentful webhook](https://www.contentful.com/developers/docs/concepts/webhooks/). Please note it should be triggerd only on entry publication.
103 |
104 | 
105 |
106 | ## Authors
107 |
108 | This project won the 2017 Winter Hackathon at Contentful.
109 |
110 | Contributors:
111 |
112 | - [andrefs](https://github.com/andrefs) (code)
113 | - [hlabas](https://github.com/hlabas) (product ownership)
114 | - [jelz](https://github.com/jelz) (code)
115 | - [joaoramos](https://github.com/joaoramos) (visuals)
116 | - [TimBeyer](https://github.com/TimBeyer) (code)
117 |
--------------------------------------------------------------------------------