From fd793811603020ce9ec2a30cca5b211a9169a139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 23 Aug 2016 15:25:44 -0300 Subject: [PATCH 01/77] include mediaproxy block --- .../Widgets/MarkdownControlElements/VisualEditor/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js index b7d57822..6e8ecc18 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js @@ -42,7 +42,7 @@ class VisualEditor extends React.Component { let rawJson; if (props.value !== undefined) { const content = this.markdown.toContent(props.value); - rawJson = SlateUtils.encode(content, null, getPlugins().map(plugin => plugin.id)); + rawJson = SlateUtils.encode(content, null, ['mediaproxy'].concat(getPlugins().map(plugin => plugin.id))); } else { rawJson = emptyParagraphBlock; } From b717874e7b8fdeff7a1c756a378af76425a3fff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Wed, 24 Aug 2016 21:36:44 -0300 Subject: [PATCH 02/77] Allow the creation of new entries --- src/actions/entries.js | 17 +++++++++++ src/backends/backend.js | 6 ++++ src/backends/github/implementation.js | 5 ++- src/backends/test-repo/implementation.js | 7 ++--- src/components/Widgets/StringControl.js | 2 +- src/containers/EntryPage.js | 39 ++++++++++++++++-------- src/reducers/entryDraft.js | 15 ++++----- src/routing/routes.js | 3 +- src/valueObjects/Entry.js | 8 +++++ 9 files changed, 73 insertions(+), 29 deletions(-) create mode 100644 src/valueObjects/Entry.js diff --git a/src/actions/entries.js b/src/actions/entries.js index 0f49031a..2f060a32 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -13,6 +13,7 @@ export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS'; export const ENTRIES_FAILURE = 'ENTRIES_FAILURE'; export const DRAFT_CREATE_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY'; +export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY'; export const DRAFT_DISCARD = 'DRAFT_DISCARD'; export const DRAFT_CHANGE = 'DRAFT_CHANGE'; @@ -102,6 +103,13 @@ function entryPersistFail(collection, entry, error) { }; } +function emmptyDraftCreated(entry) { + return { + type: DRAFT_CREATE_EMPTY, + payload: entry + }; +} + /* * Exported simple Action Creators */ @@ -153,6 +161,15 @@ export function loadEntries(collection) { }; } +export function createEmptyDraft(collection) { + return (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + const newEntry = backend.newEntry(collection); + dispatch(emmptyDraftCreated(newEntry)); + }; +} + export function persistEntry(collection, entry) { return (dispatch, getState) => { const state = getState(); diff --git a/src/backends/backend.js b/src/backends/backend.js index e66ab9cd..049f1262 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -1,6 +1,7 @@ import TestRepoBackend from './test-repo/implementation'; import GitHubBackend from './github/implementation'; import { resolveFormat } from '../formats/formats'; +import { createEntry } from '../valueObjects/Entry'; class LocalStorageAuthStore { storageKey = 'nf-cms-user'; @@ -57,6 +58,11 @@ class Backend { return this.implementation.entry(collection, slug).then(this.entryWithFormat(collection)); } + newEntry(collection) { + const newEntry = createEntry(); + return this.entryWithFormat(collection)(newEntry); + } + entryWithFormat(collection) { return (entry) => { const format = resolveFormat(collection, entry); diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 67ad9535..194dd98b 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -1,5 +1,6 @@ import LocalForage from 'localforage'; import MediaProxy from '../../valueObjects/MediaProxy'; +import { createEntry } from '../../valueObjects/Entry'; import AuthenticationPage from './AuthenticationPage'; import { Base64 } from 'js-base64'; @@ -210,9 +211,7 @@ export default class GitHub { 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; + return createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data); }) ))) )).then((entries) => ({ diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index c4f41080..806791e2 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -1,4 +1,5 @@ import AuthenticationPage from './AuthenticationPage'; +import { createEntry } from '../../valueObjects/Entry'; function getSlug(path) { const m = path.match(/([^\/]+?)(\.[^\/\.]+)?$/); @@ -28,11 +29,7 @@ export default class TestRepo { 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 - }); + entries.push(createEntry(folder + '/' + path, getSlug(path), window.repoFiles[folder][path].content)); } } diff --git a/src/components/Widgets/StringControl.js b/src/components/Widgets/StringControl.js index b159a6e9..43de2170 100644 --- a/src/components/Widgets/StringControl.js +++ b/src/components/Widgets/StringControl.js @@ -11,7 +11,7 @@ export default class StringControl extends React.Component { } render() { - return ; + return ; } } diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js index 79784c0e..81c9947b 100644 --- a/src/containers/EntryPage.js +++ b/src/containers/EntryPage.js @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { loadEntry, createDraftFromEntry, + createEmptyDraft, discardDraft, changeDraft, persistEntry @@ -15,19 +16,27 @@ import EntryEditor from '../components/EntryEditor'; class EntryPage extends React.Component { constructor(props) { super(props); - this.props.loadEntry(props.collection, props.slug); + this.createDraft = this.createDraft.bind(this); this.handlePersistEntry = this.handlePersistEntry.bind(this); } componentDidMount() { - if (this.props.entry) { - this.props.createDraftFromEntry(this.props.entry); + if (!this.props.newEntry) { + this.props.loadEntry(this.props.collection, this.props.slug); + + this.createDraft(this.props.entry); + } else { + this.props.createEmptyDraft(this.props.collection); } } componentWillReceiveProps(nextProps) { - if (this.props.entry !== nextProps.entry && !nextProps.entry.get('isFetching')) { - this.props.createDraftFromEntry(nextProps.entry); + if (this.props.entry === nextProps.entry) return; + + if (nextProps.entry && !nextProps.entry.get('isFetching')) { + this.createDraft(nextProps.entry); + } else if (nextProps.newEntry) { + this.props.createEmptyDraft(nextProps.collection); } } @@ -35,17 +44,19 @@ class EntryPage extends React.Component { this.props.discardDraft(); } + createDraft(entry) { + if (entry) this.props.createDraftFromEntry(entry); + } + handlePersistEntry() { this.props.persistEntry(this.props.collection, this.props.entryDraft); } render() { - const { entry, entryDraft, boundGetMedia, collection, changeDraft, addMedia, removeMedia } = this.props; - - if (entry == null || entryDraft.get('entry') == undefined || entry.get('isFetching')) { + if (entryDraft == null || entryDraft.get('entry') == undefined || entry && entry.get('isFetching')) { return
Loading...
; } return ( @@ -68,22 +79,25 @@ EntryPage.propTypes = { changeDraft: PropTypes.func.isRequired, collection: ImmutablePropTypes.map.isRequired, createDraftFromEntry: PropTypes.func.isRequired, + createEmptyDraft: PropTypes.func.isRequired, discardDraft: PropTypes.func.isRequired, - entry: ImmutablePropTypes.map.isRequired, + entry: ImmutablePropTypes.map, entryDraft: ImmutablePropTypes.map.isRequired, loadEntry: PropTypes.func.isRequired, persistEntry: PropTypes.func.isRequired, removeMedia: PropTypes.func.isRequired, - slug: PropTypes.string.isRequired, + slug: PropTypes.string, + newEntry: PropTypes.bool.isRequired, }; function mapStateToProps(state, ownProps) { const { collections, entryDraft } = state; const collection = collections.get(ownProps.params.name); + const newEntry = ownProps.route && ownProps.route.newRecord === true; const slug = ownProps.params.slug; - const entry = selectEntry(state, collection.get('name'), slug); + const entry = newEntry ? null : selectEntry(state, collection.get('name'), slug); const boundGetMedia = getMedia.bind(null, state); - return { collection, collections, entryDraft, boundGetMedia, slug, entry }; + return { collection, collections, newEntry, entryDraft, boundGetMedia, slug, entry }; } export default connect( @@ -94,6 +108,7 @@ export default connect( removeMedia, loadEntry, createDraftFromEntry, + createEmptyDraft, discardDraft, persistEntry } diff --git a/src/reducers/entryDraft.js b/src/reducers/entryDraft.js index 8f23e8c9..c058742d 100644 --- a/src/reducers/entryDraft.js +++ b/src/reducers/entryDraft.js @@ -1,5 +1,5 @@ -import { Map, List } from 'immutable'; -import { DRAFT_CREATE_FROM_ENTRY, DRAFT_DISCARD, DRAFT_CHANGE } from '../actions/entries'; +import { Map, List, fromJS } from 'immutable'; +import { DRAFT_CREATE_FROM_ENTRY, DRAFT_CREATE_EMPTY, DRAFT_DISCARD, DRAFT_CHANGE } from '../actions/entries'; import { ADD_MEDIA, REMOVE_MEDIA } from '../actions/media'; const initialState = Map({ entry: Map(), mediaFiles: List() }); @@ -7,14 +7,15 @@ const initialState = Map({ entry: Map(), mediaFiles: List() }); const entryDraft = (state = Map(), action) => { switch (action.type) { case DRAFT_CREATE_FROM_ENTRY: - if (!action.payload) { - // New entry - return initialState; - } // Existing Entry return state.withMutations((state) => { state.set('entry', action.payload); - state.setIn(['entry', 'newRecord'], false); + state.set('mediaFiles', List()); + }); + case DRAFT_CREATE_EMPTY: + // New Entry + return state.withMutations((state) => { + state.set('entry', fromJS(action.payload)); state.set('mediaFiles', List()); }); case DRAFT_DISCARD: diff --git a/src/routing/routes.js b/src/routing/routes.js index 39f1bf81..59f2aa7f 100644 --- a/src/routing/routes.js +++ b/src/routing/routes.js @@ -10,7 +10,8 @@ export default ( - + + diff --git a/src/valueObjects/Entry.js b/src/valueObjects/Entry.js new file mode 100644 index 00000000..36ce0de0 --- /dev/null +++ b/src/valueObjects/Entry.js @@ -0,0 +1,8 @@ +export function createEntry(path = '', slug = '', raw = '') { + const returnObj = {}; + returnObj.path = path; + returnObj.slug = slug; + returnObj.raw = raw; + returnObj.data = {}; + return returnObj; +} From 2b70893e898b3ee0e0d2cd9f5e2c912016524854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Thu, 25 Aug 2016 16:11:00 -0300 Subject: [PATCH 03/77] content creation internal + test_repo implementations --- src/backends/backend.js | 48 +++++++++++++++++++----- src/backends/test-repo/implementation.js | 8 +++- src/reducers/entryDraft.js | 2 + 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/backends/backend.js b/src/backends/backend.js index 049f1262..312b93ec 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -73,19 +73,49 @@ class Backend { }; } - persistEntry(collection, entryDraft, MediaFiles) { - const entryData = entryDraft.getIn(['entry', 'data']).toObject(); - const entryObj = { - path: entryDraft.getIn(['entry', 'path']), - slug: entryDraft.getIn(['entry', 'slug']), - raw: this.entryToRaw(collection, entryData) - }; + slugFormatter(template, entry) { + var date = new Date(); + return template.replace(/\{\{([^\}]+)\}\}/g, function(_, name) { + switch (name) { + case 'year': + return date.getFullYear(); + case 'month': + return ('0' + (date.getMonth() + 1)).slice(-2); + case 'day': + return ('0' + date.getDate()).slice(-2); + case 'slug': + return entry.getIn(['data', 'title']).trim().toLowerCase().replace(/[^a-z0-9\.\-\_]+/gi, '-'); + default: + return entry.getIn(['data', name]); + } + }); + } - const commitMessage = (entryDraft.getIn(['entry', 'newRecord']) ? 'Created ' : 'Updated ') + + persistEntry(collection, entryDraft, MediaFiles) { + const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; + const entryData = entryDraft.getIn(['entry', 'data']).toObject(); + let entryObj; + + if (newEntry) { + const slug = this.slugFormatter(collection.get('slug'), entryDraft.get('entry')); + entryObj = { + path: `${collection.get('folder')}/${slug}.md`, + slug: slug, + raw: this.entryToRaw(collection, entryData) + }; + } else { + entryObj = { + path: entryDraft.getIn(['entry', 'path']), + slug: entryDraft.getIn(['entry', 'slug']), + raw: this.entryToRaw(collection, entryData) + }; + } + + const commitMessage = (newEntry ? 'Created ' : 'Updated ') + collection.get('label') + ' “' + entryDraft.getIn(['entry', 'data', 'title']) + '”'; - return this.implementation.persistEntry(collection, entryObj, MediaFiles, { commitMessage }); + return this.implementation.persistEntry(collection, entryObj, MediaFiles, { commitMessage }, newEntry); } entryToRaw(collection, entry) { diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index 806791e2..3d534029 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -45,10 +45,14 @@ export default class TestRepo { )); } - persistEntry(collection, entry, mediaFiles = []) { + persistEntry(collection, entry, mediaFiles = [], newEntry = false) { const folder = entry.path.substring(0, entry.path.lastIndexOf('/')); const fileName = entry.path.substring(entry.path.lastIndexOf('/') + 1); - window.repoFiles[folder][fileName]['content'] = entry.raw; + if (newEntry) { + window.repoFiles[folder][fileName] = { content: entry.raw }; + } else { + window.repoFiles[folder][fileName]['content'] = entry.raw; + } mediaFiles.forEach(media => media.uploaded = true); return Promise.resolve(); } diff --git a/src/reducers/entryDraft.js b/src/reducers/entryDraft.js index c058742d..b00de43a 100644 --- a/src/reducers/entryDraft.js +++ b/src/reducers/entryDraft.js @@ -10,12 +10,14 @@ const entryDraft = (state = Map(), action) => { // Existing Entry return state.withMutations((state) => { state.set('entry', action.payload); + state.setIn(['entry', 'newRecord'], false); state.set('mediaFiles', List()); }); case DRAFT_CREATE_EMPTY: // New Entry return state.withMutations((state) => { state.set('entry', fromJS(action.payload)); + state.setIn(['entry', 'newRecord'], true); state.set('mediaFiles', List()); }); case DRAFT_DISCARD: From 6eec0feb72ff4fdb820a0e8f28b27b3070723197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 29 Aug 2016 17:09:04 -0300 Subject: [PATCH 04/77] Small refactor preparing for branch creating/editing. Also closes #58 --- example/config.yml | 1 + src/actions/config.js | 10 +++++- src/actions/entries.js | 3 +- src/backends/backend.js | 21 ++++++++++--- src/backends/github/implementation.js | 7 ++--- src/backends/test-repo/implementation.js | 3 +- src/components/Widgets/ImageControl.js | 2 +- .../VisualEditor/index.js | 2 +- src/lib/randomGenerator.js | 31 +++++++++++++++++++ src/reducers/entryDraft.js | 2 +- src/reducers/medias.js | 2 +- src/valueObjects/MediaProxy.js | 5 +-- 12 files changed, 70 insertions(+), 19 deletions(-) create mode 100644 src/lib/randomGenerator.js diff --git a/example/config.yml b/example/config.yml index a930e59b..06fe0687 100644 --- a/example/config.yml +++ b/example/config.yml @@ -3,6 +3,7 @@ backend: delay: 0.1 media_folder: "assets/uploads" +publish_mode: branch collections: # A list of collections the CMS should be able to edit - name: "posts" # Used in routes, ie.: /admin/collections/:slug/edit diff --git a/src/actions/config.js b/src/actions/config.js index 7a2d568f..4a18a046 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -62,7 +62,6 @@ export function loadConfig(config) { function parseConfig(data) { const config = yaml.safeLoad(data); - if (typeof CMS_ENV === 'string' && config[CMS_ENV]) { for (var key in config[CMS_ENV]) { if (config[CMS_ENV].hasOwnProperty(key)) { @@ -70,5 +69,14 @@ function parseConfig(data) { } } } + + if ('media_folder' in config && typeof config.media_folder === 'string') { + // Parse source & public paths for media folder. + config.media_folder = { + path: config.media_folder, + public_path: config.media_folder + }; + } + return config; } diff --git a/src/actions/entries.js b/src/actions/entries.js index 2f060a32..4f239d2f 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -175,9 +175,8 @@ export function persistEntry(collection, entry) { const state = getState(); const backend = currentBackend(state.config); const MediaProxies = entry.get('mediaFiles').map(path => getMedia(state, path)); - dispatch(entryPersisting(collection, entry)); - backend.persistEntry(collection, entry, MediaProxies.toJS()).then( + backend.persistEntry(state.config, collection, entry, MediaProxies.toJS()).then( () => { dispatch(entryPersisted(collection, entry)); }, diff --git a/src/backends/backend.js b/src/backends/backend.js index 312b93ec..05fbc153 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -1,6 +1,7 @@ import TestRepoBackend from './test-repo/implementation'; import GitHubBackend from './github/implementation'; import { resolveFormat } from '../formats/formats'; +import { randomStr } from '../lib/randomGenerator'; import { createEntry } from '../valueObjects/Entry'; class LocalStorageAuthStore { @@ -91,11 +92,21 @@ class Backend { }); } - persistEntry(collection, entryDraft, MediaFiles) { - const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; - const entryData = entryDraft.getIn(['entry', 'data']).toObject(); - let entryObj; + getPublishMode(config) { + const publish_modes = ['simple', 'branch']; + const mode = config.get('publish_mode'); + if (publish_modes.indexOf(mode) !== -1) { + return mode; + } else { + return 'simple'; + } + } + persistEntry(config, collection, entryDraft, MediaFiles) { + const mode = this.getPublishMode(config); + const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; + const entryData = entryDraft.getIn(['entry', 'data']).toJS(); + let entryObj; if (newEntry) { const slug = this.slugFormatter(collection.get('slug'), entryDraft.get('entry')); entryObj = { @@ -115,7 +126,7 @@ class Backend { collection.get('label') + ' “' + entryDraft.getIn(['entry', 'data', 'title']) + '”'; - return this.implementation.persistEntry(collection, entryObj, MediaFiles, { commitMessage }, newEntry); + return this.implementation.persistEntry(entryObj, MediaFiles, { newEntry, commitMessage, mode }); } entryToRaw(collection, entry) { diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 194dd98b..fbd18407 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -43,11 +43,10 @@ class API { }); } - persistFiles(collection, entry, mediaFiles, options) { + persistFiles(entry, mediaFiles, options) { let filename, part, parts, subtree; const fileTree = {}; const files = []; - mediaFiles.concat(entry).forEach((file) => { if (file.uploaded) { return; } files.push(this.uploadBlob(file)); @@ -226,7 +225,7 @@ export default class GitHub { )); } - persistEntry(collection, entry, mediaFiles = [], options = {}) { - return this.api.persistFiles(collection, entry, mediaFiles, options); + persistEntry(entry, mediaFiles = [], options = {}) { + return this.api.persistFiles(entry, mediaFiles, options); } } diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index 3d534029..fce8d3d8 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -45,7 +45,8 @@ export default class TestRepo { )); } - persistEntry(collection, entry, mediaFiles = [], newEntry = false) { + persistEntry(entry, mediaFiles = [], options) { + const newEntry = options.newEntry || false; const folder = entry.path.substring(0, entry.path.lastIndexOf('/')); const fileName = entry.path.substring(entry.path.lastIndexOf('/') + 1); if (newEntry) { diff --git a/src/components/Widgets/ImageControl.js b/src/components/Widgets/ImageControl.js index d80cd80e..2006215b 100644 --- a/src/components/Widgets/ImageControl.js +++ b/src/components/Widgets/ImageControl.js @@ -53,7 +53,7 @@ export default class ImageControl extends React.Component { if (file) { const mediaProxy = new MediaProxy(file.name, file); this.props.onAddMedia(mediaProxy); - this.props.onChange(mediaProxy.path); + this.props.onChange(mediaProxy.public_path); } else { this.props.onChange(null); } diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js index 6e8ecc18..7525bcb5 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js @@ -253,7 +253,7 @@ class VisualEditor extends React.Component { .insertInline({ type: 'mediaproxy', isVoid: true, - data: { src: mediaProxy.path } + data: { src: mediaProxy.public_path } }) .collapseToEnd() .insertBlock(DEFAULT_NODE) diff --git a/src/lib/randomGenerator.js b/src/lib/randomGenerator.js new file mode 100644 index 00000000..7d73aadc --- /dev/null +++ b/src/lib/randomGenerator.js @@ -0,0 +1,31 @@ +/* + * Random number generator + */ + +let rng; + +if (window.crypto && crypto.getRandomValues) { + // WHATWG crypto-based RNG - http://wiki.whatwg.org/wiki/Crypto + // Moderately fast, high quality + const _rnds32 = new Uint32Array(1); + rng = function whatwgRNG() { + crypto.getRandomValues(_rnds32); + return _rnds32[0]; + }; +} + +if (!rng) { + // Math.random()-based (RNG) + // If no Crypto available, use Math.random(). + rng = function() { + const r = Math.random() * 0x100000000; + const _rnds = r >>> 0; + return _rnds; + }; +} + +export function randomStr() { + return rng().toString(36); +} + +export default rng; diff --git a/src/reducers/entryDraft.js b/src/reducers/entryDraft.js index b00de43a..d9100ca0 100644 --- a/src/reducers/entryDraft.js +++ b/src/reducers/entryDraft.js @@ -26,7 +26,7 @@ const entryDraft = (state = Map(), action) => { return state.set('entry', action.payload); case ADD_MEDIA: - return state.update('mediaFiles', (list) => list.push(action.payload.path)); + return state.update('mediaFiles', (list) => list.push(action.payload.public_path)); case REMOVE_MEDIA: return state.update('mediaFiles', (list) => list.filterNot((path) => path === action.payload)); diff --git a/src/reducers/medias.js b/src/reducers/medias.js index c0cfaa34..87b7f8cc 100644 --- a/src/reducers/medias.js +++ b/src/reducers/medias.js @@ -5,7 +5,7 @@ import MediaProxy from '../valueObjects/MediaProxy'; const medias = (state = Map(), action) => { switch (action.type) { case ADD_MEDIA: - return state.set(action.payload.path, action.payload); + return state.set(action.payload.public_path, action.payload); case REMOVE_MEDIA: return state.delete(action.payload); diff --git a/src/valueObjects/MediaProxy.js b/src/valueObjects/MediaProxy.js index c02e39ca..141b7441 100644 --- a/src/valueObjects/MediaProxy.js +++ b/src/valueObjects/MediaProxy.js @@ -8,11 +8,12 @@ export default function MediaProxy(value, file, uploaded = false) { this.file = file; this.uploaded = uploaded; this.sha = null; - this.path = config.media_folder && !uploaded ? config.media_folder + '/' + value : value; + this.path = config.media_folder && !uploaded ? config.media_folder.path + '/' + value : value; + this.public_path = config.media_folder && !uploaded ? config.media_folder.public_path + '/' + value : value; } MediaProxy.prototype.toString = function() { - return this.uploaded ? this.path : window.URL.createObjectURL(this.file, { oneTimeOnly: true }); + return this.uploaded ? this.public_path : window.URL.createObjectURL(this.file, { oneTimeOnly: true }); }; MediaProxy.prototype.toBase64 = function() { From 704bc41b598b1ee838efcef5ebb6bc9aba159673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 29 Aug 2016 17:26:47 -0300 Subject: [PATCH 05/77] bugfix, closes #59 --- src/components/Widgets/richText.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Widgets/richText.js b/src/components/Widgets/richText.js index 08a8331c..d62ca68e 100644 --- a/src/components/Widgets/richText.js +++ b/src/components/Widgets/richText.js @@ -82,7 +82,7 @@ function processMediaProxyPlugins(getMedia) { const mediaProxyMarkdownRule = mediaProxyRule.toText((state, token) => { var data = token.getData(); var alt = data.get('alt', ''); - var src = getMedia(data.get('src', '')); + var src = data.get('src', ''); var title = data.get('title', ''); if (title) { @@ -95,7 +95,7 @@ function processMediaProxyPlugins(getMedia) { var data = token.getData(); var alt = data.get('alt', ''); var src = data.get('src', ''); - return `${alt}`; + return `${alt}`; }); nodes['mediaproxy'] = (props) => { From a1c01323e058f79f51db3b4bd09045c7cccabbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 29 Aug 2016 19:32:56 -0300 Subject: [PATCH 06/77] branch commiting skeleton --- example/config.yml | 1 - src/backends/backend.js | 8 +++--- src/backends/constants.js | 3 +++ src/backends/github/implementation.js | 35 +++++++++++++++++++++++---- 4 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 src/backends/constants.js diff --git a/example/config.yml b/example/config.yml index 06fe0687..a930e59b 100644 --- a/example/config.yml +++ b/example/config.yml @@ -3,7 +3,6 @@ backend: delay: 0.1 media_folder: "assets/uploads" -publish_mode: branch collections: # A list of collections the CMS should be able to edit - name: "posts" # Used in routes, ie.: /admin/collections/:slug/edit diff --git a/src/backends/backend.js b/src/backends/backend.js index 05fbc153..d588aea6 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -1,8 +1,8 @@ import TestRepoBackend from './test-repo/implementation'; import GitHubBackend from './github/implementation'; import { resolveFormat } from '../formats/formats'; -import { randomStr } from '../lib/randomGenerator'; import { createEntry } from '../valueObjects/Entry'; +import { SIMPLE, BRANCH } from './constants'; class LocalStorageAuthStore { storageKey = 'nf-cms-user'; @@ -93,12 +93,12 @@ class Backend { } getPublishMode(config) { - const publish_modes = ['simple', 'branch']; - const mode = config.get('publish_mode'); + const publish_modes = [SIMPLE, BRANCH]; + const mode = config.getIn(['backend', 'publish_mode']); if (publish_modes.indexOf(mode) !== -1) { return mode; } else { - return 'simple'; + return SIMPLE; } } diff --git a/src/backends/constants.js b/src/backends/constants.js new file mode 100644 index 00000000..6f6c6852 --- /dev/null +++ b/src/backends/constants.js @@ -0,0 +1,3 @@ +// Create/edit modes +export const SIMPLE = 'simple'; +export const BRANCH = 'branch'; diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index fbd18407..b742ce62 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -3,6 +3,8 @@ import MediaProxy from '../../valueObjects/MediaProxy'; import { createEntry } from '../../valueObjects/Entry'; import AuthenticationPage from './AuthenticationPage'; import { Base64 } from 'js-base64'; +import { randomStr } from '../../lib/randomGenerator'; +import { BRANCH } from '../constants'; const API_ROOT = 'https://api.github.com'; @@ -47,6 +49,7 @@ class API { let filename, part, parts, subtree; const fileTree = {}; const files = []; + const branchName = ( options.mode === BRANCH ) ? 'cms-' + randomStr() : this.branch; mediaFiles.concat(entry).forEach((file) => { if (file.uploaded) { return; } files.push(this.uploadBlob(file)); @@ -62,9 +65,15 @@ class API { }); return Promise.all(files) - .then(() => this.getBranch()) - .then((branchData) => { - return this.updateTree(branchData.commit.sha, '/', fileTree); + .then(() => { + if (options.mode === BRANCH) { + return this.createBranch(branchName); + } else { + return this.getBranch(); + } + }) + .then((BranchCommit) => { + return this.updateTree(BranchCommit.sha, '/', fileTree); }) .then((changeTree) => { return this.request(`${this.repoURL}/git/commits`, { @@ -72,7 +81,7 @@ class API { body: JSON.stringify({ message: options.commitMessage, tree: changeTree.sha, parents: [changeTree.parentSha] }) }); }).then((response) => { - return this.request(`${this.repoURL}/git/refs/heads/${this.branch}`, { + return this.request(`${this.repoURL}/git/refs/heads/${branchName}`, { method: 'PATCH', body: JSON.stringify({ sha: response.sha }) }); @@ -108,8 +117,24 @@ class API { }); } + createBranch(branchName) { + return this.getBranch() + .then(branchCommit => { + const branchData = { + ref: 'refs/heads/' + branchName, + sha: branchCommit.sha + }; + return this.request(`${this.repoURL}/git/refs`, { + method: 'POST', + body: JSON.stringify(branchData), + }); + }) + .then(branchData => branchData.object); + } + getBranch() { - return this.request(`${this.repoURL}/branches/${this.branch}`); + return this.request(`${this.repoURL}/branches/${this.branch}`) + .then(branchData => branchData.commit); } getTree(sha) { From c7544cc0a2e4c56ac3c9aa4a99207da3e0306f02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 30 Aug 2016 11:18:14 -0300 Subject: [PATCH 07/77] put public_folder config in parity with original ember version --- src/actions/config.js | 13 +++++++------ src/valueObjects/MediaProxy.js | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/actions/config.js b/src/actions/config.js index 4a18a046..00082c97 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -70,12 +70,13 @@ function parseConfig(data) { } } - if ('media_folder' in config && typeof config.media_folder === 'string') { - // Parse source & public paths for media folder. - config.media_folder = { - path: config.media_folder, - public_path: config.media_folder - }; + if (!('public_folder' in config)) { + // Make sure there is a public folder + config.public_folder = config.media_folder; + } + + if (config.public_folder.charAt(0) !== '/') { + config.public_folder = '/' + config.public_folder; } return config; diff --git a/src/valueObjects/MediaProxy.js b/src/valueObjects/MediaProxy.js index 141b7441..04c56e6b 100644 --- a/src/valueObjects/MediaProxy.js +++ b/src/valueObjects/MediaProxy.js @@ -8,8 +8,8 @@ export default function MediaProxy(value, file, uploaded = false) { this.file = file; this.uploaded = uploaded; this.sha = null; - this.path = config.media_folder && !uploaded ? config.media_folder.path + '/' + value : value; - this.public_path = config.media_folder && !uploaded ? config.media_folder.public_path + '/' + value : value; + this.path = config.media_folder && !uploaded ? config.media_folder + '/' + value : value; + this.public_path = config.public_folder && !uploaded ? config.public_folder + '/' + value : value; } MediaProxy.prototype.toString = function() { From 672b43f15366f2976861426283888be6455f5de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 30 Aug 2016 14:39:53 -0300 Subject: [PATCH 08/77] branching refactor --- src/backends/github/implementation.js | 53 +++++++++++---------------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index b742ce62..832cabaf 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -3,7 +3,6 @@ import MediaProxy from '../../valueObjects/MediaProxy'; import { createEntry } from '../../valueObjects/Entry'; import AuthenticationPage from './AuthenticationPage'; import { Base64 } from 'js-base64'; -import { randomStr } from '../../lib/randomGenerator'; import { BRANCH } from '../constants'; const API_ROOT = 'https://api.github.com'; @@ -49,7 +48,6 @@ class API { let filename, part, parts, subtree; const fileTree = {}; const files = []; - const branchName = ( options.mode === BRANCH ) ? 'cms-' + randomStr() : this.branch; mediaFiles.concat(entry).forEach((file) => { if (file.uploaded) { return; } files.push(this.uploadBlob(file)); @@ -65,15 +63,9 @@ class API { }); return Promise.all(files) - .then(() => { - if (options.mode === BRANCH) { - return this.createBranch(branchName); - } else { - return this.getBranch(); - } - }) - .then((BranchCommit) => { - return this.updateTree(BranchCommit.sha, '/', fileTree); + .then(() => this.getBranch()) + .then((branchData) => { + return this.updateTree(branchData.commit.sha, '/', fileTree); }) .then((changeTree) => { return this.request(`${this.repoURL}/git/commits`, { @@ -81,10 +73,11 @@ class API { body: JSON.stringify({ message: options.commitMessage, tree: changeTree.sha, parents: [changeTree.parentSha] }) }); }).then((response) => { - return this.request(`${this.repoURL}/git/refs/heads/${branchName}`, { - method: 'PATCH', - body: JSON.stringify({ sha: response.sha }) - }); + if (options.mode === BRANCH) { + return this.createBranch(`cms/${entry.slug}`, response.sha); + } else { + return this.patchBranch(this.branch, response.sha); + } }); } @@ -117,24 +110,22 @@ class API { }); } - createBranch(branchName) { - return this.getBranch() - .then(branchCommit => { - const branchData = { - ref: 'refs/heads/' + branchName, - sha: branchCommit.sha - }; - return this.request(`${this.repoURL}/git/refs`, { - method: 'POST', - body: JSON.stringify(branchData), - }); - }) - .then(branchData => branchData.object); + getBranch() { + return this.request(`${this.repoURL}/branches/${this.branch}`); } - getBranch() { - return this.request(`${this.repoURL}/branches/${this.branch}`) - .then(branchData => branchData.commit); + createBranch(branchName, sha) { + return this.request(`${this.repoURL}/git/refs`, { + method: 'POST', + body: JSON.stringify({ ref: `refs/heads/${branchName}`, sha }), + }); + } + + patchBranch(branchName, sha) { + return this.request(`${this.repoURL}/git/refs/heads/${branchName}`, { + method: 'PATCH', + body: JSON.stringify({ sha }) + }); } getTree(sha) { From 388cada3de2b3582557ef4a313d32cc95162097c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 30 Aug 2016 19:06:20 -0300 Subject: [PATCH 09/77] On application mount, check/create metadata branch --- src/backends/backend.js | 8 +- src/backends/github/API.js | 228 ++++++++++++++++++++++++++ src/backends/github/implementation.js | 197 +--------------------- 3 files changed, 235 insertions(+), 198 deletions(-) create mode 100644 src/backends/github/API.js diff --git a/src/backends/backend.js b/src/backends/backend.js index d588aea6..43e00126 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -103,7 +103,7 @@ class Backend { } persistEntry(config, collection, entryDraft, MediaFiles) { - const mode = this.getPublishMode(config); + const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; const entryData = entryDraft.getIn(['entry', 'data']).toJS(); let entryObj; @@ -126,7 +126,11 @@ class Backend { collection.get('label') + ' “' + entryDraft.getIn(['entry', 'data', 'title']) + '”'; - return this.implementation.persistEntry(entryObj, MediaFiles, { newEntry, commitMessage, mode }); + const mode = this.getPublishMode(config); + + const collectionName = collection.get('name'); + + return this.implementation.persistEntry(entryObj, MediaFiles, { newEntry, commitMessage, collectionName, mode }); } entryToRaw(collection, entry) { diff --git a/src/backends/github/API.js b/src/backends/github/API.js new file mode 100644 index 00000000..4e26684d --- /dev/null +++ b/src/backends/github/API.js @@ -0,0 +1,228 @@ +import LocalForage from 'localforage'; +import MediaProxy from '../../valueObjects/MediaProxy'; +import { Base64 } from 'js-base64'; +import { BRANCH } from '../constants'; + +const API_ROOT = 'https://api.github.com'; + +export default class API { + constructor(token, repo, branch) { + this.token = token; + this.repo = repo; + this.branch = branch; + this.repoURL = `/repos/${this.repo}`; + this.checkMetadataBranch(); + } + + user() { + return this.request('/user'); + } + + 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(API_ROOT + path, { ...options, headers: headers }).then((response) => { + if (response.headers.get('Content-Type').match(/json/)) { + return this.parseJsonResponse(response); + } + + return response.text(); + }); + } + + checkMetadataBranch() { + this.request(`${this.repoURL}/contents?ref=_netlify_cms`, { + cache: false + }) + .then(result => console.log(result)) + .catch(error => { + //Branch doesn't exist + const readme = { + raw: '# Netlify CMS\n\nThis branch is used by the Netlify CMS to store metadata information for specific files and branches.' + }; + + this.uploadBlob(readme) + .then(item => this.request(`${this.repoURL}/git/trees`, { + method: 'POST', + body: JSON.stringify({ tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }] }) + })) + .then(tree => this.request(`${this.repoURL}/git/commits`, { + method: 'POST', + body: JSON.stringify({ message: 'First Commit', tree: tree.sha, parents: [] }) + })) + .then(response => this.createBranch('_netlify_cms', response.sha)); + }); + + // List all branches inside /cms + // this.request(`${this.repoURL}/git/refs/heads/cms/`).then((result) => { + // console.log(result); + // }); + + } + + readFile(path, sha) { + const cache = sha ? LocalForage.getItem(`gh.${sha}`) : Promise.resolve(null); + return cache.then((cached) => { + if (cached) { return cached; } + + return this.request(`${this.repoURL}/contents/${path}`, { + headers: { Accept: 'application/vnd.github.VERSION.raw' }, + body: { ref: this.branch }, + cache: false + }).then((result) => { + if (sha) { + LocalForage.setItem(`gh.${sha}`, result); + } + + return result; + }); + }); + } + + listFiles(path) { + return this.request(`${this.repoURL}/contents/${path}`, { + body: { ref: this.branch } + }); + } + + persistFiles(entry, mediaFiles, options) { + let filename, part, parts, subtree; + const fileTree = {}; + const files = []; + mediaFiles.concat(entry).forEach((file) => { + if (file.uploaded) { return; } + files.push(this.uploadBlob(file)); + parts = file.path.split('/').filter((part) => part); + filename = parts.pop(); + subtree = fileTree; + while (part = parts.shift()) { + subtree[part] = subtree[part] || {}; + subtree = subtree[part]; + } + subtree[filename] = file; + file.file = true; + }); + + return Promise.all(files) + .then(() => this.getBranch()) + .then((branchData) => { + return this.updateTree(branchData.commit.sha, '/', fileTree); + }) + .then((changeTree) => { + return this.request(`${this.repoURL}/git/commits`, { + method: 'POST', + body: JSON.stringify({ message: options.commitMessage, tree: changeTree.sha, parents: [changeTree.parentSha] }) + }); + }).then((response) => { + if (options.mode && options.mode === BRANCH) { + const newBranch = options.collectionName ? `cms/${options.collectionName}-${entry.slug}` : `cms/${entry.slug}`; + return this.createBranch(newBranch, response.sha); + } else { + return this.patchBranch(this.branch, response.sha); + } + }); + } + + getBranch() { + return this.request(`${this.repoURL}/branches/${this.branch}`); + } + + createBranch(branchName, sha) { + return this.request(`${this.repoURL}/git/refs`, { + method: 'POST', + body: JSON.stringify({ ref: `refs/heads/${branchName}`, sha }), + }); + } + + patchBranch(branchName, sha) { + return this.request(`${this.repoURL}/git/refs/heads/${branchName}`, { + method: 'PATCH', + body: JSON.stringify({ sha }) + }); + } + + getTree(sha) { + return sha ? this.request(`${this.repoURL}/git/trees/${sha}`) : Promise.resolve({ tree: [] }); + } + + toBase64(str) { + return Promise.resolve( + Base64.encode(str) + ); + } + + uploadBlob(item) { + const content = item instanceof MediaProxy ? item.toBase64() : this.toBase64(item.raw); + + return content.then((contentBase64) => { + return this.request(`${this.repoURL}/git/blobs`, { + method: 'POST', + body: JSON.stringify({ + content: contentBase64, + encoding: 'base64' + }) + }).then((response) => { + item.sha = response.sha; + item.uploaded = true; + return item; + }); + }); + } + + updateTree(sha, path, fileTree) { + return this.getTree(sha) + .then((tree) => { + var obj, filename, fileOrDir; + var updates = []; + var added = {}; + + for (var i = 0, len = tree.tree.length; i < len; i++) { + obj = tree.tree[i]; + if (fileOrDir = fileTree[obj.path]) { + added[obj.path] = true; + if (fileOrDir.file) { + updates.push({ path: obj.path, mode: obj.mode, type: obj.type, sha: fileOrDir.sha }); + } else { + updates.push(this.updateTree(obj.sha, obj.path, fileOrDir)); + } + } + } + for (filename in fileTree) { + fileOrDir = fileTree[filename]; + if (added[filename]) { continue; } + updates.push( + fileOrDir.file ? + { path: filename, mode: '100644', type: 'blob', sha: fileOrDir.sha } : + this.updateTree(null, filename, fileOrDir) + ); + } + return Promise.all(updates) + .then((updates) => { + return this.request(`${this.repoURL}/git/trees`, { + method: 'POST', + body: JSON.stringify({ base_tree: sha, tree: updates }) + }); + }).then((response) => { + return { path: path, mode: '040000', type: 'tree', sha: response.sha, parentSha: sha }; + }); + }); + } + +} diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 832cabaf..acabf3fd 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -1,201 +1,6 @@ -import LocalForage from 'localforage'; -import MediaProxy from '../../valueObjects/MediaProxy'; import { createEntry } from '../../valueObjects/Entry'; import AuthenticationPage from './AuthenticationPage'; -import { Base64 } from 'js-base64'; -import { BRANCH } from '../constants'; - -const API_ROOT = 'https://api.github.com'; - -class API { - constructor(token, repo, branch) { - this.token = token; - this.repo = repo; - this.branch = branch; - this.repoURL = `/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(`${this.repoURL}/contents/${path}`, { - headers: { Accept: 'application/vnd.github.VERSION.raw' }, - body: { ref: this.branch }, - cache: false - }).then((result) => { - if (sha) { - LocalForage.setItem(`gh.${sha}`, result); - } - - return result; - }); - }); - } - - listFiles(path) { - return this.request(`${this.repoURL}/contents/${path}`, { - body: { ref: this.branch } - }); - } - - persistFiles(entry, mediaFiles, options) { - let filename, part, parts, subtree; - const fileTree = {}; - const files = []; - mediaFiles.concat(entry).forEach((file) => { - if (file.uploaded) { return; } - files.push(this.uploadBlob(file)); - parts = file.path.split('/').filter((part) => part); - filename = parts.pop(); - subtree = fileTree; - while (part = parts.shift()) { - subtree[part] = subtree[part] || {}; - subtree = subtree[part]; - } - subtree[filename] = file; - file.file = true; - }); - - return Promise.all(files) - .then(() => this.getBranch()) - .then((branchData) => { - return this.updateTree(branchData.commit.sha, '/', fileTree); - }) - .then((changeTree) => { - return this.request(`${this.repoURL}/git/commits`, { - method: 'POST', - body: JSON.stringify({ message: options.commitMessage, tree: changeTree.sha, parents: [changeTree.parentSha] }) - }); - }).then((response) => { - if (options.mode === BRANCH) { - return this.createBranch(`cms/${entry.slug}`, response.sha); - } else { - return this.patchBranch(this.branch, response.sha); - } - }); - } - - 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(API_ROOT + path, { ...options, headers: headers }).then((response) => { - if (response.headers.get('Content-Type').match(/json/)) { - return this.parseJsonResponse(response); - } - - return response.text(); - }); - } - - getBranch() { - return this.request(`${this.repoURL}/branches/${this.branch}`); - } - - createBranch(branchName, sha) { - return this.request(`${this.repoURL}/git/refs`, { - method: 'POST', - body: JSON.stringify({ ref: `refs/heads/${branchName}`, sha }), - }); - } - - patchBranch(branchName, sha) { - return this.request(`${this.repoURL}/git/refs/heads/${branchName}`, { - method: 'PATCH', - body: JSON.stringify({ sha }) - }); - } - - getTree(sha) { - return sha ? this.request(`${this.repoURL}/git/trees/${sha}`) : Promise.resolve({ tree: [] }); - } - - toBase64(str) { - return Promise.resolve( - Base64.encode(str) - ); - } - - uploadBlob(item) { - const content = item instanceof MediaProxy ? item.toBase64() : this.toBase64(item.raw); - - return content.then((contentBase64) => { - return this.request(`${this.repoURL}/git/blobs`, { - method: 'POST', - body: JSON.stringify({ - content: contentBase64, - encoding: 'base64' - }) - }).then((response) => { - item.sha = response.sha; - item.uploaded = true; - return item; - }); - }); - } - - updateTree(sha, path, fileTree) { - return this.getTree(sha) - .then((tree) => { - var obj, filename, fileOrDir; - var updates = []; - var added = {}; - - for (var i = 0, len = tree.tree.length; i < len; i++) { - obj = tree.tree[i]; - if (fileOrDir = fileTree[obj.path]) { - added[obj.path] = true; - if (fileOrDir.file) { - updates.push({ path: obj.path, mode: obj.mode, type: obj.type, sha: fileOrDir.sha }); - } else { - updates.push(this.updateTree(obj.sha, obj.path, fileOrDir)); - } - } - } - for (filename in fileTree) { - fileOrDir = fileTree[filename]; - if (added[filename]) { continue; } - updates.push( - fileOrDir.file ? - { path: filename, mode: '100644', type: 'blob', sha: fileOrDir.sha } : - this.updateTree(null, filename, fileOrDir) - ); - } - return Promise.all(updates) - .then((updates) => { - return this.request(`${this.repoURL}/git/trees`, { - method: 'POST', - body: JSON.stringify({ base_tree: sha, tree: updates }) - }); - }).then((response) => { - return { path: path, mode: '040000', type: 'tree', sha: response.sha, parentSha: sha }; - }); - }); - } - -} +import API from './API'; export default class GitHub { constructor(config) { From 2f635944fdfd67273815816fa16cd130fec2fc51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Wed, 31 Aug 2016 13:30:14 -0300 Subject: [PATCH 10/77] Metadata saving --- src/backends/github/API.js | 59 ++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 4e26684d..0b8f0ff7 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -11,7 +11,6 @@ export default class API { this.repo = repo; this.branch = branch; this.repoURL = `/repos/${this.repo}`; - this.checkMetadataBranch(); } user() { @@ -48,33 +47,43 @@ export default class API { } checkMetadataBranch() { - this.request(`${this.repoURL}/contents?ref=_netlify_cms`, { - cache: false + return this.request(`${this.repoURL}/branches/_netlify_cms?${Date.now()}`, { + cache: 'no-store', }) - .then(result => console.log(result)) + .then(response => response.commit) .catch(error => { //Branch doesn't exist const readme = { raw: '# Netlify CMS\n\nThis branch is used by the Netlify CMS to store metadata information for specific files and branches.' }; - this.uploadBlob(readme) + return this.uploadBlob(readme) .then(item => this.request(`${this.repoURL}/git/trees`, { method: 'POST', body: JSON.stringify({ tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }] }) })) - .then(tree => this.request(`${this.repoURL}/git/commits`, { - method: 'POST', - body: JSON.stringify({ message: 'First Commit', tree: tree.sha, parents: [] }) - })) - .then(response => this.createBranch('_netlify_cms', response.sha)); + .then(tree => this.commit('First Commit', tree)) + .then(response => this.createBranch('_netlify_cms', response.sha)) + .then(response => response.object); }); + } - // List all branches inside /cms - // this.request(`${this.repoURL}/git/refs/heads/cms/`).then((result) => { - // console.log(result); - // }); + storeMetadata(name, data) { + this.checkMetadataBranch() + .then((branchData) => { + const fileTree = { + [`${name}.json`]: { + path: `${name}.json`, + raw: JSON.stringify(data), + file: true + } + }; + return this.uploadBlob(fileTree[`${name}.json`]) + .then(item => this.updateTree(branchData.sha, '/', fileTree)) + .then(changeTree => this.commit(`Updating “${name}” metadata`, changeTree)) + .then(response => this.patchBranch('_netlify_cms', response.sha)); + }); } readFile(path, sha) { @@ -119,18 +128,11 @@ export default class API { subtree[filename] = file; file.file = true; }); - return Promise.all(files) .then(() => this.getBranch()) - .then((branchData) => { - return this.updateTree(branchData.commit.sha, '/', fileTree); - }) - .then((changeTree) => { - return this.request(`${this.repoURL}/git/commits`, { - method: 'POST', - body: JSON.stringify({ message: options.commitMessage, tree: changeTree.sha, parents: [changeTree.parentSha] }) - }); - }).then((response) => { + .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) + .then(changeTree => this.commit(options.commitMessage, changeTree)) + .then((response) => { if (options.mode && options.mode === BRANCH) { const newBranch = options.collectionName ? `cms/${options.collectionName}-${entry.slug}` : `cms/${entry.slug}`; return this.createBranch(newBranch, response.sha); @@ -225,4 +227,13 @@ export default class API { }); } + commit(message, changeTree) { + const tree = changeTree.sha; + const parents = changeTree.parentSha ? [changeTree.parentSha] : []; + return this.request(`${this.repoURL}/git/commits`, { + method: 'POST', + body: JSON.stringify({ message, tree, parents }) + }); + } + } From a2420647d5e96584a227c20a7f7c621f88a34a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Wed, 31 Aug 2016 15:44:00 -0300 Subject: [PATCH 11/77] Metadata retrieving --- src/backends/github/API.js | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 0b8f0ff7..758e8caf 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -68,24 +68,43 @@ export default class API { }); } - storeMetadata(name, data) { + storeMetadata(key, data) { this.checkMetadataBranch() .then((branchData) => { const fileTree = { - [`${name}.json`]: { - path: `${name}.json`, + [`${key}.json`]: { + path: `${key}.json`, raw: JSON.stringify(data), file: true } }; - return this.uploadBlob(fileTree[`${name}.json`]) + return this.uploadBlob(fileTree[`${key}.json`]) .then(item => this.updateTree(branchData.sha, '/', fileTree)) - .then(changeTree => this.commit(`Updating “${name}” metadata`, changeTree)) + .then(changeTree => this.commit(`Updating “${key}” metadata`, changeTree)) .then(response => this.patchBranch('_netlify_cms', response.sha)); }); } + retrieveMetadata(key, data) { + const cache = LocalForage.getItem(`gh.meta.${key}`); + return cache.then((cached) => { + if (cached && cached.expires > Date.now()) { return cached.data; } + + return this.request(`${this.repoURL}/contents/${key}.json?ref=_netlify_cms`, { + headers: { Accept: 'application/vnd.github.VERSION.raw' }, + cache: 'no-store', + }).then((result) => { + console.log(result); + LocalForage.setItem(`gh.meta.${key}`, { + expires: Date.now() + 300000, // In 5 minutes + data: result, + }); + return result; + }); + }); + } + readFile(path, sha) { const cache = sha ? LocalForage.getItem(`gh.${sha}`) : Promise.resolve(null); return cache.then((cached) => { From 666d66ab819114a7c5a0fb05eb7cedd270f27b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Wed, 31 Aug 2016 16:41:11 -0300 Subject: [PATCH 12/77] Ignore tern files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 54664d63..76dc6e31 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ dist/ node_modules/ npm-debug.log .DS_Store +.tern-project From 1406d9225f6c0e17bf8a22b7c2d55620033e2f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Wed, 31 Aug 2016 16:41:29 -0300 Subject: [PATCH 13/77] Saving metadata as a custom ref --- src/backends/github/API.js | 49 ++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 758e8caf..39f0763f 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -46,15 +46,15 @@ export default class API { }); } - checkMetadataBranch() { - return this.request(`${this.repoURL}/branches/_netlify_cms?${Date.now()}`, { + checkMetadataRef() { + return this.request(`${this.repoURL}/git/refs/meta/_netlify_cms?${Date.now()}`, { cache: 'no-store', }) - .then(response => response.commit) + .then(response => response.object) .catch(error => { - //Branch doesn't exist + // Meta ref doesn't exist const readme = { - raw: '# Netlify CMS\n\nThis branch is used by the Netlify CMS to store metadata information for specific files and branches.' + raw: '# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.' }; return this.uploadBlob(readme) @@ -63,13 +63,13 @@ export default class API { body: JSON.stringify({ tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }] }) })) .then(tree => this.commit('First Commit', tree)) - .then(response => this.createBranch('_netlify_cms', response.sha)) + .then(response => this.createRef('meta', '_netlify_cms', response.sha)) .then(response => response.object); }); } storeMetadata(key, data) { - this.checkMetadataBranch() + this.checkMetadataRef() .then((branchData) => { const fileTree = { [`${key}.json`]: { @@ -82,7 +82,7 @@ export default class API { return this.uploadBlob(fileTree[`${key}.json`]) .then(item => this.updateTree(branchData.sha, '/', fileTree)) .then(changeTree => this.commit(`Updating “${key}” metadata`, changeTree)) - .then(response => this.patchBranch('_netlify_cms', response.sha)); + .then(response => this.patchRef('meta', '_netlify_cms', response.sha)); }); } @@ -91,7 +91,7 @@ export default class API { return cache.then((cached) => { if (cached && cached.expires > Date.now()) { return cached.data; } - return this.request(`${this.repoURL}/contents/${key}.json?ref=_netlify_cms`, { + return this.request(`${this.repoURL}/contents/${key}.json?ref=refs/meta/_netlify_cms`, { headers: { Accept: 'application/vnd.github.VERSION.raw' }, cache: 'no-store', }).then((result) => { @@ -153,30 +153,39 @@ export default class API { .then(changeTree => this.commit(options.commitMessage, changeTree)) .then((response) => { if (options.mode && options.mode === BRANCH) { - const newBranch = options.collectionName ? `cms/${options.collectionName}-${entry.slug}` : `cms/${entry.slug}`; - return this.createBranch(newBranch, response.sha); + const branchKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; + return this.createBranch(`cms/${branchKey}`, response.sha) + .then(this.storeMetadata(branchKey, { status: 'draft' })); } else { return this.patchBranch(this.branch, response.sha); } }); } - getBranch() { - return this.request(`${this.repoURL}/branches/${this.branch}`); + createRef(type, name, sha) { + return this.request(`${this.repoURL}/git/refs`, { + method: 'POST', + body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }), + }); } createBranch(branchName, sha) { - return this.request(`${this.repoURL}/git/refs`, { - method: 'POST', - body: JSON.stringify({ ref: `refs/heads/${branchName}`, sha }), + return this.createRef('heads', branchName, sha); + } + + patchRef(type, name, sha) { + return this.request(`${this.repoURL}/git/refs/${type}/${name}`, { + method: 'PATCH', + body: JSON.stringify({ sha }) }); } patchBranch(branchName, sha) { - return this.request(`${this.repoURL}/git/refs/heads/${branchName}`, { - method: 'PATCH', - body: JSON.stringify({ sha }) - }); + return this.patchRef('heads', branchName, sha); + } + + getBranch() { + return this.request(`${this.repoURL}/branches/${this.branch}`); } getTree(sha) { From 4e35a27934a8aa86dabc960ccbd3d6acdeb3056d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Wed, 31 Aug 2016 16:42:34 -0300 Subject: [PATCH 14/77] simpler eslint config for es6 features --- .eslintrc | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/.eslintrc b/.eslintrc index c10de0c1..8f6cd62a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,28 +1,10 @@ env: browser: true + es6: true parser: babel-eslint - plugins: [ "react" ] -# enable ECMAScript features -ecmaFeatures: - arrowFunctions: true - binaryLiterals: true - blockBindings: true - classes: true - defaultParams: true - destructuring: true - forOf: true - generators: true - jsx: true - modules: true - objectLiteralShorthandMethods: true - objectLiteralShorthandProperties: true - octalLiterals: true - spread: true - templateStrings: true - rules: # Possible Errors # https://github.com/eslint/eslint/tree/master/docs/rules#possible-errors From 4a55bb029672180050ea5cc19a248e9078442d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Wed, 31 Aug 2016 17:33:12 -0300 Subject: [PATCH 15/77] create PR --- src/backends/github/API.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 39f0763f..13025037 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -69,7 +69,7 @@ export default class API { } storeMetadata(key, data) { - this.checkMetadataRef() + return this.checkMetadataRef() .then((branchData) => { const fileTree = { [`${key}.json`]: { @@ -95,7 +95,6 @@ export default class API { headers: { Accept: 'application/vnd.github.VERSION.raw' }, cache: 'no-store', }).then((result) => { - console.log(result); LocalForage.setItem(`gh.meta.${key}`, { expires: Date.now() + 300000, // In 5 minutes data: result, @@ -153,9 +152,10 @@ export default class API { .then(changeTree => this.commit(options.commitMessage, changeTree)) .then((response) => { if (options.mode && options.mode === BRANCH) { - const branchKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; - return this.createBranch(`cms/${branchKey}`, response.sha) - .then(this.storeMetadata(branchKey, { status: 'draft' })); + const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; + return this.createBranch(`cms/${contentKey}`, response.sha) + .then(this.storeMetadata(contentKey, { status: 'draft' })) + .then(this.createPR(options.commitMessage, `cms/${contentKey}`)); } else { return this.patchBranch(this.branch, response.sha); } @@ -188,6 +188,14 @@ export default class API { return this.request(`${this.repoURL}/branches/${this.branch}`); } + createPR(title, head, base = 'master') { + const body = 'Automatically generated by Netlify CMS'; + return this.request(`${this.repoURL}/pulls`, { + method: 'POST', + body: JSON.stringify({ title, body, head, base }), + }); + } + getTree(sha) { return sha ? this.request(`${this.repoURL}/git/trees/${sha}`) : Promise.resolve({ tree: [] }); } From e04b1e80c5169f6dd6cf1f94b3a00dcbbf1011bb Mon Sep 17 00:00:00 2001 From: Mathias Biilmann Christensen Date: Sun, 4 Sep 2016 14:01:28 +0200 Subject: [PATCH 16/77] Make GitHub backend respect branch setting --- src/backends/github/API.js | 20 +++++++++++++++++--- src/backends/github/implementation.js | 3 ++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 13025037..fa165b9a 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -35,9 +35,23 @@ export default class API { }); } + urlFor(path, options) { + const params = []; + if (options.params) { + for (const key in options.params) { + params.push(`${key}=${encodeURIComponent(options.params[key])}`); + } + } + if (params.length) { + path += `?${params.join('&')}`; + } + return API_ROOT + path; + } + request(path, options = {}) { const headers = this.requestHeaders(options.headers || {}); - return fetch(API_ROOT + path, { ...options, headers: headers }).then((response) => { + const url = this.urlFor(path, options); + return fetch(url, { ...options, headers: headers }).then((response) => { if (response.headers.get('Content-Type').match(/json/)) { return this.parseJsonResponse(response); } @@ -111,7 +125,7 @@ export default class API { return this.request(`${this.repoURL}/contents/${path}`, { headers: { Accept: 'application/vnd.github.VERSION.raw' }, - body: { ref: this.branch }, + params: { ref: this.branch }, cache: false }).then((result) => { if (sha) { @@ -125,7 +139,7 @@ export default class API { listFiles(path) { return this.request(`${this.repoURL}/contents/${path}`, { - body: { ref: this.branch } + params: { ref: this.branch } }); } diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index acabf3fd..f1583492 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -1,4 +1,4 @@ -import { createEntry } from '../../valueObjects/Entry'; +import {createEntry} from '../../valueObjects/Entry'; import AuthenticationPage from './AuthenticationPage'; import API from './API'; @@ -9,6 +9,7 @@ export default class GitHub { throw 'The GitHub backend needs a "repo" in the backend configuration.'; } this.repo = config.getIn(['backend', 'repo']); + this.branch = config.getIn(['backend', 'branch']) || 'master'; } authComponent() { From 2980ba85653b2a74fe95c23bc3fd1649c7022840 Mon Sep 17 00:00:00 2001 From: Mathias Biilmann Christensen Date: Sun, 4 Sep 2016 19:55:14 +0200 Subject: [PATCH 17/77] Add netlify-git backend --- package.json | 1 + src/backends/backend.js | 3 + src/backends/github/implementation.js | 23 +- src/backends/netlify-git/API.js | 284 ++++++++++++++++++ .../netlify-git/AuthenticationPage.js | 61 ++++ src/backends/netlify-git/implementation.js | 59 ++++ 6 files changed, 424 insertions(+), 7 deletions(-) create mode 100644 src/backends/netlify-git/API.js create mode 100644 src/backends/netlify-git/AuthenticationPage.js create mode 100644 src/backends/netlify-git/implementation.js diff --git a/package.json b/package.json index 05201017..c1db9538 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "prismjs": "^1.5.1", "react-portal": "^2.2.1", "selection-position": "^1.0.0", + "semaphore": "^1.0.5", "slate": "^0.13.6" } } diff --git a/src/backends/backend.js b/src/backends/backend.js index 43e00126..c5af5be5 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -1,5 +1,6 @@ import TestRepoBackend from './test-repo/implementation'; import GitHubBackend from './github/implementation'; +import NetlifyGitBackend from './netlify-git/implementation'; import { resolveFormat } from '../formats/formats'; import { createEntry } from '../valueObjects/Entry'; import { SIMPLE, BRANCH } from './constants'; @@ -152,6 +153,8 @@ export function resolveBackend(config) { return new Backend(new TestRepoBackend(config), authStore); case 'github': return new Backend(new GitHubBackend(config), authStore); + case 'netlify-git': + return new Backend(new NetlifyGitBackend(config), authStore); default: throw `Backend not found: ${name}`; } diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index f1583492..a11d8ee6 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -1,7 +1,10 @@ +import semaphore from 'semaphore'; import {createEntry} from '../../valueObjects/Entry'; import AuthenticationPage from './AuthenticationPage'; import API from './API'; +const MAX_CONCURRENT_DOWNLOADS = 10; + export default class GitHub { constructor(config) { this.config = config; @@ -29,13 +32,19 @@ export default class GitHub { } 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) => { - return createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data); - }) - ))) - )).then((entries) => ({ + return this.api.listFiles(collection.get('folder')).then((files) => { + const sem = semaphore(MAX_CONCURRENT_DOWNLOADS); + const promises = []; + files.map((file) => { + sem.take(() => { + promises.push(this.api.readFile(file.path, file.sha).then((data) => { + sem.leave(); + return createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data); + })); + }); + }); + return Promise.all(promises); + }).then((entries) => ({ pagination: {}, entries })); diff --git a/src/backends/netlify-git/API.js b/src/backends/netlify-git/API.js new file mode 100644 index 00000000..8c072a30 --- /dev/null +++ b/src/backends/netlify-git/API.js @@ -0,0 +1,284 @@ +import LocalForage from 'localforage'; +import MediaProxy from '../../valueObjects/MediaProxy'; +import { Base64 } from 'js-base64'; +import { BRANCH } from '../constants'; + +export default class API { + constructor(token, url, branch) { + this.token = token; + this.url = url; + this.branch = branch; + this.repoURL = ''; + } + + requestHeaders(headers = {}) { + return { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json', + ...headers + }; + } + + parseJsonResponse(response) { + return response.json().then((json) => { + if (!response.ok) { + return Promise.reject(json); + } + + return json; + }); + } + + urlFor(path, options) { + const params = []; + if (options.params) { + for (const key in options.params) { + params.push(`${key}=${encodeURIComponent(options.params[key])}`); + } + } + if (params.length) { + path += `?${params.join('&')}`; + } + return this.url + path; + } + + request(path, options = {}) { + const headers = this.requestHeaders(options.headers || {}); + const url = this.urlFor(path, options); + return fetch(url, { ...options, headers: headers }).then((response) => { + if (response.headers.get('Content-Type').match(/json/) && !options.raw) { + return this.parseJsonResponse(response); + } + + return response.text(); + }); + } + + checkMetadataRef() { + return this.request(`${this.repoURL}/git/refs/meta/_netlify_cms?${Date.now()}`, { + cache: 'no-store', + }) + .then(response => response.object) + .catch(error => { + // Meta ref doesn't exist + const readme = { + raw: '# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.' + }; + + return this.uploadBlob(readme) + .then(item => this.request(`${this.repoURL}/git/trees`, { + method: 'POST', + body: JSON.stringify({ tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }] }) + })) + .then(tree => this.commit('First Commit', tree)) + .then(response => this.createRef('meta', '_netlify_cms', response.sha)) + .then(response => response.object); + }); + } + + storeMetadata(key, data) { + return this.checkMetadataRef() + .then((branchData) => { + const fileTree = { + [`${key}.json`]: { + path: `${key}.json`, + raw: JSON.stringify(data), + file: true + } + }; + + return this.uploadBlob(fileTree[`${key}.json`]) + .then(item => this.updateTree(branchData.sha, '/', fileTree)) + .then(changeTree => this.commit(`Updating “${key}” metadata`, changeTree)) + .then(response => this.patchRef('meta', '_netlify_cms', response.sha)); + }); + } + + retrieveMetadata(key, data) { + const cache = LocalForage.getItem(`gh.meta.${key}`); + return cache.then((cached) => { + if (cached && cached.expires > Date.now()) { return cached.data; } + + return this.request(`${this.repoURL}/files/${key}.json?ref=refs/meta/_netlify_cms`, { + headers: { Accept: 'application/vnd.github.VERSION.raw' }, + cache: 'no-store', + }).then((result) => { + LocalForage.setItem(`gh.meta.${key}`, { + expires: Date.now() + 300000, // In 5 minutes + data: result, + }); + return result; + }); + }); + } + + readFile(path, sha) { + const cache = sha ? LocalForage.getItem(`gh.${sha}`) : Promise.resolve(null); + return cache.then((cached) => { + if (cached) { return cached; } + + return this.request(`${this.repoURL}/files/${path}`, { + headers: { Accept: 'application/vnd.github.VERSION.raw' }, + params: { ref: this.branch }, + cache: false, + raw: true + }).then((result) => { + if (sha) { + LocalForage.setItem(`gh.${sha}`, result); + } + + return result; + }); + }); + } + + listFiles(path) { + return this.request(`${this.repoURL}/files/${path}`, { + params: { ref: this.branch } + }); + } + + persistFiles(entry, mediaFiles, options) { + let filename, part, parts, subtree; + const fileTree = {}; + const files = []; + mediaFiles.concat(entry).forEach((file) => { + if (file.uploaded) { return; } + files.push(this.uploadBlob(file)); + parts = file.path.split('/').filter((part) => part); + filename = parts.pop(); + subtree = fileTree; + while (part = parts.shift()) { + subtree[part] = subtree[part] || {}; + subtree = subtree[part]; + } + subtree[filename] = file; + file.file = true; + }); + return Promise.all(files) + .then(() => this.getBranch()) + .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) + .then(changeTree => this.commit(options.commitMessage, changeTree)) + .then((response) => { + if (options.mode && options.mode === BRANCH) { + const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; + return this.createBranch(`cms/${contentKey}`, response.sha) + .then(this.storeMetadata(contentKey, { status: 'draft' })) + .then(this.createPR(options.commitMessage, `cms/${contentKey}`)); + } else { + return this.patchBranch(this.branch, response.sha); + } + }); + } + + createRef(type, name, sha) { + return this.request(`${this.repoURL}/git/refs`, { + method: 'POST', + body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }), + }); + } + + createBranch(branchName, sha) { + return this.createRef('heads', branchName, sha); + } + + patchRef(type, name, sha) { + return this.request(`${this.repoURL}/git/refs/${type}/${name}`, { + method: 'PATCH', + body: JSON.stringify({ sha }) + }); + } + + patchBranch(branchName, sha) { + return this.patchRef('heads', branchName, sha); + } + + getBranch() { + return this.request(`${this.repoURL}/branches/${this.branch}`); + } + + createPR(title, head, base = 'master') { + const body = 'Automatically generated by Netlify CMS'; + return this.request(`${this.repoURL}/pulls`, { + method: 'POST', + body: JSON.stringify({ title, body, head, base }), + }); + } + + getTree(sha) { + return sha ? this.request(`${this.repoURL}/git/trees/${sha}`) : Promise.resolve({ tree: [] }); + } + + toBase64(str) { + return Promise.resolve( + Base64.encode(str) + ); + } + + uploadBlob(item) { + const content = item instanceof MediaProxy ? item.toBase64() : this.toBase64(item.raw); + + return content.then((contentBase64) => { + return this.request(`${this.repoURL}/git/blobs`, { + method: 'POST', + body: JSON.stringify({ + content: contentBase64, + encoding: 'base64' + }) + }).then((response) => { + item.sha = response.sha; + item.uploaded = true; + return item; + }); + }); + } + + updateTree(sha, path, fileTree) { + return this.getTree(sha) + .then((tree) => { + var obj, filename, fileOrDir; + var updates = []; + var added = {}; + + for (var i = 0, len = tree.tree.length; i < len; i++) { + obj = tree.tree[i]; + if (fileOrDir = fileTree[obj.path]) { + added[obj.path] = true; + if (fileOrDir.file) { + updates.push({ path: obj.path, mode: obj.mode, type: obj.type, sha: fileOrDir.sha }); + } else { + updates.push(this.updateTree(obj.sha, obj.path, fileOrDir)); + } + } + } + for (filename in fileTree) { + fileOrDir = fileTree[filename]; + if (added[filename]) { continue; } + updates.push( + fileOrDir.file ? + { path: filename, mode: '100644', type: 'blob', sha: fileOrDir.sha } : + this.updateTree(null, filename, fileOrDir) + ); + } + return Promise.all(updates) + .then((updates) => { + return this.request(`${this.repoURL}/git/trees`, { + method: 'POST', + body: JSON.stringify({ base_tree: sha, tree: updates }) + }); + }).then((response) => { + return { path: path, mode: '040000', type: 'tree', sha: response.sha, parentSha: sha }; + }); + }); + } + + commit(message, changeTree) { + const tree = changeTree.sha; + const parents = changeTree.parentSha ? [changeTree.parentSha] : []; + return this.request(`${this.repoURL}/git/commits`, { + method: 'POST', + body: JSON.stringify({ message, tree, parents }) + }); + } + +} diff --git a/src/backends/netlify-git/AuthenticationPage.js b/src/backends/netlify-git/AuthenticationPage.js new file mode 100644 index 00000000..4503a660 --- /dev/null +++ b/src/backends/netlify-git/AuthenticationPage.js @@ -0,0 +1,61 @@ +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 {email, password} = this.state; + this.setState({authenticating: true}); + fetch(`${AuthenticationPage.url}/token`, { + method: 'POST', + body: 'grant_type=client_credentials', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Basic ' + btoa(`${email}:${password}`) + } + }).then((response) => { + console.log(response); + if (response.ok) { + return response.json().then((data) => { + this.props.onLogin(Object.assign({email}, data)); + }); + } + response.json().then((data) => { + this.setState({loginError: data.msg}); + }) + }) + } + + handleChange(key) { + return (e) => { + this.setState({[key]: e.target.value}); + }; + } + + render() { + const { loginError } = this.state; + + return
+ {loginError &&

{loginError}

} +

+ +

+

+ +

+

+ +

+
; + } +} diff --git a/src/backends/netlify-git/implementation.js b/src/backends/netlify-git/implementation.js new file mode 100644 index 00000000..c3dc2ac8 --- /dev/null +++ b/src/backends/netlify-git/implementation.js @@ -0,0 +1,59 @@ +import semaphore from 'semaphore'; +import {createEntry} from '../../valueObjects/Entry'; +import AuthenticationPage from './AuthenticationPage'; +import API from './API'; + +const MAX_CONCURRENT_DOWNLOADS = 10; + +export default class NetlifyGit { + constructor(config) { + this.config = config; + if (config.getIn(['backend', 'url']) == null) { + throw 'The netlify-git backend needs a "url" in the backend configuration.'; + } + this.url = config.getIn(['backend', 'url']); + this.branch = config.getIn(['backend', 'branch']) || 'master'; + AuthenticationPage.url = this.url; + } + + authComponent() { + return AuthenticationPage; + } + + setUser(user) { + this.api = new API(user.access_token, this.url, this.branch || 'master'); + } + + authenticate(state) { + return Promise.resolve(state); + } + + entries(collection) { + return this.api.listFiles(collection.get('folder')).then((files) => { + const sem = semaphore(MAX_CONCURRENT_DOWNLOADS); + const promises = []; + files.map((file) => { + sem.take(() => { + promises.push(this.api.readFile(file.path, file.sha).then((data) => { + sem.leave(); + return createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data); + })); + }); + }); + return Promise.all(promises); + }).then((entries) => ({ + pagination: {}, + entries + })); + } + + entry(collection, slug) { + return this.entries(collection).then((response) => ( + response.entries.filter((entry) => entry.slug === slug)[0] + )); + } + + persistEntry(entry, mediaFiles = [], options = {}) { + return this.api.persistFiles(entry, mediaFiles, options); + } +} From 964e158a5f0c2325d2ccf9db33fe3b6570e69463 Mon Sep 17 00:00:00 2001 From: Mathias Biilmann Christensen Date: Sun, 4 Sep 2016 20:55:05 +0200 Subject: [PATCH 18/77] Fix semaphore implementation for only doing 10 concurrent fetches at a time --- src/backends/backend.js | 1 + src/backends/github/implementation.js | 11 +++++++---- src/backends/netlify-git/implementation.js | 11 +++++++---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/backends/backend.js b/src/backends/backend.js index c5af5be5..d7d52a15 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -49,6 +49,7 @@ class Backend { entries(collection, page, perPage) { return this.implementation.entries(collection, page, perPage).then((response) => { + console.log("Got %s entries", response.entries.length); return { pagination: response.pagination, entries: response.entries.map(this.entryWithFormat(collection)) diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index a11d8ee6..9cb11f45 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -36,12 +36,15 @@ export default class GitHub { const sem = semaphore(MAX_CONCURRENT_DOWNLOADS); const promises = []; files.map((file) => { - sem.take(() => { - promises.push(this.api.readFile(file.path, file.sha).then((data) => { + promises.push(new Promise((resolve, reject) => { + return sem.take(() => this.api.readFile(file.path, file.sha).then((data) => { + resolve(createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data)); sem.leave(); - return createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data); + }).catch((err) => { + sem.leave(); + reject(err); })); - }); + })); }); return Promise.all(promises); }).then((entries) => ({ diff --git a/src/backends/netlify-git/implementation.js b/src/backends/netlify-git/implementation.js index c3dc2ac8..cd52f5ff 100644 --- a/src/backends/netlify-git/implementation.js +++ b/src/backends/netlify-git/implementation.js @@ -33,12 +33,15 @@ export default class NetlifyGit { const sem = semaphore(MAX_CONCURRENT_DOWNLOADS); const promises = []; files.map((file) => { - sem.take(() => { - promises.push(this.api.readFile(file.path, file.sha).then((data) => { + promises.push(new Promise((resolve, reject) => { + return sem.take(() => this.api.readFile(file.path, file.sha).then((data) => { + resolve(createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data)); sem.leave(); - return createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data); + }).catch((err) => { + sem.leave(); + reject(err); })); - }); + })); }); return Promise.all(promises); }).then((entries) => ({ From e852991954f477d6d1a55db29dbdd7c84d2f5f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 12:12:38 -0300 Subject: [PATCH 19/77] Storing more complete commit information on branch metadata --- src/actions/config.js | 5 +++++ src/backends/backend.js | 13 ++++++++++--- src/backends/github/API.js | 25 ++++++++++++++++++------- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/actions/config.js b/src/actions/config.js index 00082c97..57a51510 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -70,6 +70,11 @@ function parseConfig(data) { } } + if (!('publish_mode' in config.backend)) { + // Make sure there is a publish mode + config.backend['publish_mode'] = 'simple'; + } + if (!('public_folder' in config)) { // Make sure there is a public folder config.public_folder = config.media_folder; diff --git a/src/backends/backend.js b/src/backends/backend.js index 43e00126..cafcf668 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -21,7 +21,7 @@ class Backend { constructor(implementation, authStore = null) { this.implementation = implementation; this.authStore = authStore; - if (this.implementation == null) { + if (this.implementation === null) { throw 'Cannot instantiate a Backend with no implementation'; } } @@ -103,8 +103,13 @@ class Backend { } persistEntry(config, collection, entryDraft, MediaFiles) { - const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; + + const parsedData = { + title: entryDraft.getIn(['entry', 'data', 'title'], 'No Title'), + description: entryDraft.getIn(['entry', 'data', 'description'], 'No Description'), + }; + const entryData = entryDraft.getIn(['entry', 'data']).toJS(); let entryObj; if (newEntry) { @@ -130,7 +135,9 @@ class Backend { const collectionName = collection.get('name'); - return this.implementation.persistEntry(entryObj, MediaFiles, { newEntry, commitMessage, collectionName, mode }); + return this.implementation.persistEntry(entryObj, MediaFiles, { + newEntry, parsedData, commitMessage, collectionName, mode + }); } entryToRaw(collection, entry) { diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 13025037..7c4de494 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -101,7 +101,7 @@ export default class API { }); return result; }); - }); + }).catch(error => null); } readFile(path, sha) { @@ -132,10 +132,13 @@ export default class API { persistFiles(entry, mediaFiles, options) { let filename, part, parts, subtree; const fileTree = {}; - const files = []; - mediaFiles.concat(entry).forEach((file) => { + const uploadPromises = []; + + const files = mediaFiles.concat(entry); + + files.forEach((file) => { if (file.uploaded) { return; } - files.push(this.uploadBlob(file)); + uploadPromises.push(this.uploadBlob(file)); parts = file.path.split('/').filter((part) => part); filename = parts.pop(); subtree = fileTree; @@ -146,15 +149,23 @@ export default class API { subtree[filename] = file; file.file = true; }); - return Promise.all(files) + return Promise.all(uploadPromises) .then(() => this.getBranch()) .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) .then(changeTree => this.commit(options.commitMessage, changeTree)) .then((response) => { if (options.mode && options.mode === BRANCH) { const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; - return this.createBranch(`cms/${contentKey}`, response.sha) - .then(this.storeMetadata(contentKey, { status: 'draft' })) + const branchName = `cms/${contentKey}`; + return this.createBranch(branchName, response.sha) + .then(this.storeMetadata(contentKey, { + type: 'PR', + status: 'draft', + branch: branchName, + title: options.parsedData.title, + description: options.parsedData.description, + objects: files.map(file => file.path) + })) .then(this.createPR(options.commitMessage, `cms/${contentKey}`)); } else { return this.patchBranch(this.branch, response.sha); From 7d2ab1a45ebe4099b2352898df915005decdcd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 16:12:37 -0300 Subject: [PATCH 20/77] Use urlfor params --- src/backends/github/API.js | 3 ++- src/backends/netlify-git/API.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/backends/github/API.js b/src/backends/github/API.js index a49a2585..453f3e89 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -105,7 +105,8 @@ export default class API { return cache.then((cached) => { if (cached && cached.expires > Date.now()) { return cached.data; } - return this.request(`${this.repoURL}/contents/${key}.json?ref=refs/meta/_netlify_cms`, { + return this.request(`${this.repoURL}/contents/${key}.json`, { + params: { ref: 'refs/meta/_netlify_cms' }, headers: { Accept: 'application/vnd.github.VERSION.raw' }, cache: 'no-store', }).then((result) => { diff --git a/src/backends/netlify-git/API.js b/src/backends/netlify-git/API.js index 8c072a30..ef16c56d 100644 --- a/src/backends/netlify-git/API.js +++ b/src/backends/netlify-git/API.js @@ -99,7 +99,8 @@ export default class API { return cache.then((cached) => { if (cached && cached.expires > Date.now()) { return cached.data; } - return this.request(`${this.repoURL}/files/${key}.json?ref=refs/meta/_netlify_cms`, { + return this.request(`${this.repoURL}/files/${key}.json`, { + params: { ref: 'refs/meta/_netlify_cms' }, headers: { Accept: 'application/vnd.github.VERSION.raw' }, cache: 'no-store', }).then((result) => { From 626164a2f8ff81c8afb6cc16896f8ebeb0a545c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 16:13:41 -0300 Subject: [PATCH 21/77] Lint --- src/backends/netlify-git/implementation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backends/netlify-git/implementation.js b/src/backends/netlify-git/implementation.js index cd52f5ff..cf7f21ab 100644 --- a/src/backends/netlify-git/implementation.js +++ b/src/backends/netlify-git/implementation.js @@ -1,5 +1,5 @@ import semaphore from 'semaphore'; -import {createEntry} from '../../valueObjects/Entry'; +import { createEntry } from '../../valueObjects/Entry'; import AuthenticationPage from './AuthenticationPage'; import API from './API'; From 1c464743849a40e63add7732ccc9f7bbb4d543bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 16:15:03 -0300 Subject: [PATCH 22/77] Loader component --- src/components/UI/index.js | 1 + src/components/UI/loader/Loader.css | 95 +++++++++++++++++++++++++++++ src/components/UI/loader/Loader.js | 21 +++++++ 3 files changed, 117 insertions(+) create mode 100644 src/components/UI/loader/Loader.css create mode 100644 src/components/UI/loader/Loader.js diff --git a/src/components/UI/index.js b/src/components/UI/index.js index fae9d19b..7f538b2a 100644 --- a/src/components/UI/index.js +++ b/src/components/UI/index.js @@ -1,2 +1,3 @@ export { default as Card } from './card/Card'; +export { default as Loader } from './loader/Loader'; export { default as Icon } from './icon/Icon'; diff --git a/src/components/UI/loader/Loader.css b/src/components/UI/loader/Loader.css new file mode 100644 index 00000000..c46da16c --- /dev/null +++ b/src/components/UI/loader/Loader.css @@ -0,0 +1,95 @@ +.loader { + display: none; + position: absolute; + top: 50%; + left: 50%; + margin: 0px; + text-align: center; + z-index: 1000; + -webkit-transform: translateX(-50%) translateY(-50%); + -ms-transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%) translateY(-50%); +} + +/* Static Shape */ + +.loader:before { + position: absolute; + content: ''; + top: 0%; + left: 50%; + width: 100%; + height: 100%; + border-radius: 500rem; + border: 0.2em solid rgba(0, 0, 0, 0.1); +} + +/* Active Shape */ + +.loader:after { + position: absolute; + content: ''; + top: 0%; + left: 50%; + width: 100%; + height: 100%; + -webkit-animation: loader 0.6s linear; + animation: loader 0.6s linear; + -webkit-animation-iteration-count: infinite; + animation-iteration-count: infinite; + border-radius: 500rem; + border-color: #767676 transparent transparent; + border-style: solid; + border-width: 0.2em; + box-shadow: 0px 0px 0px 1px transparent; +} + +/* Active Animation */ + +@-webkit-keyframes loader { + from { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + + to { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +@keyframes loader { + from { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + + to { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +.loader:before, +.loader:after { + width: 2.28571429rem; + height: 2.28571429rem; + margin: 0em 0em 0em -1.14285714rem; +} + + +.text { + width: auto !important; + height: auto !important; + text-align: center; + color: #767676; + margin-top: 35px; +} + +.active { + display: block; +} + +.disabled { + display: none; +} diff --git a/src/components/UI/loader/Loader.js b/src/components/UI/loader/Loader.js new file mode 100644 index 00000000..6b7ecb42 --- /dev/null +++ b/src/components/UI/loader/Loader.js @@ -0,0 +1,21 @@ +import React from 'react'; +import styles from './Loader.css'; + +export default function Loader({ active, style, className = '', children }) { + // Class names + let classNames = styles.loader; + if (active) { + classNames += ` ${styles.active}`; + } + if (className.length > 0) { + classNames += ` ${className}`; + } + + // Render child text + let child; + if (children) { + child =
{children}
; + } + + return
{child}
; +} From a76aade7c38d58e36f69685ad5d4b70fa096af32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 17:24:24 -0300 Subject: [PATCH 23/77] Loading Animation --- package.json | 1 + src/components/UI/loader/Loader.css | 24 +++++++++- src/components/UI/loader/Loader.js | 74 ++++++++++++++++++++++++----- src/containers/CollectionPage.js | 7 ++- 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index c1db9538..0ac77f63 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "markup-it": "git+https://github.com/cassiozen/markup-it.git", "pluralize": "^3.0.0", "prismjs": "^1.5.1", + "react-addons-css-transition-group": "^15.3.1", "react-portal": "^2.2.1", "selection-position": "^1.0.0", "semaphore": "^1.0.5", diff --git a/src/components/UI/loader/Loader.css b/src/components/UI/loader/Loader.css index c46da16c..69d3b9ad 100644 --- a/src/components/UI/loader/Loader.css +++ b/src/components/UI/loader/Loader.css @@ -33,9 +33,7 @@ left: 50%; width: 100%; height: 100%; - -webkit-animation: loader 0.6s linear; animation: loader 0.6s linear; - -webkit-animation-iteration-count: infinite; animation-iteration-count: infinite; border-radius: 500rem; border-color: #767676 transparent transparent; @@ -93,3 +91,25 @@ .disabled { display: none; } + +/*Animations*/ +.animateItem{ + position: absolute; + white-space: nowrap; + transform: translateX(-50%); +} + +.enter { + opacity: 0.01; +} +.enter.enterActive { + opacity: 1; + transition: opacity 500ms ease-in; +} +.leave { + opacity: 1; +} +.leave.leaveActive { + opacity: 0.01; + transition: opacity 300ms ease-in; +} diff --git a/src/components/UI/loader/Loader.js b/src/components/UI/loader/Loader.js index 6b7ecb42..4cd84b14 100644 --- a/src/components/UI/loader/Loader.js +++ b/src/components/UI/loader/Loader.js @@ -1,21 +1,69 @@ import React from 'react'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import styles from './Loader.css'; -export default function Loader({ active, style, className = '', children }) { - // Class names - let classNames = styles.loader; - if (active) { - classNames += ` ${styles.active}`; - } - if (className.length > 0) { - classNames += ` ${className}`; +export default class Loader extends React.Component { + constructor(props) { + super(props); + this.state = { + currentItem: 0, + }; + this.setAnimation = this.setAnimation.bind(this); + this.renderChild = this.renderChild.bind(this); } - // Render child text - let child; - if (children) { - child =
{children}
; + componengWillUnmount() { + if (this.interval) { + clearInterval(this.interval); + } } - return
{child}
; + setAnimation() { + if (this.interval) return; + console.log("Passed"); + const { children } = this.props; + + this.interval = setInterval(() => { + + const nextItem = (this.state.currentItem === children.length - 1) ? 0 : this.state.currentItem + 1; + this.setState({ currentItem: nextItem }); + }, 5000); + } + + renderChild() { + const { children } = this.props; + const { currentItem } = this.state; + if (!children) { + return null; + } else if (typeof children == 'string') { + return
{children}
; + } else if (Array.isArray(children)) { + this.setAnimation(); + return
+ +
{children[currentItem]}
+
+
; + } + } + + render() { + const { active, style, className = '' } = this.props; + + // Class names + let classNames = styles.loader; + if (active) { + classNames += ` ${styles.active}`; + } + if (className.length > 0) { + classNames += ` ${className}`; + } + + return
{this.renderChild()}
; + + } } diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js index 8c10703e..a04a1a21 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { loadEntries } from '../actions/entries'; import { selectEntries } from '../reducers'; +import { Loader } from '../components/UI'; import EntryListing from '../components/EntryListing'; class DashboardPage extends React.Component { @@ -28,7 +29,11 @@ class DashboardPage extends React.Component { } return
- {entries ? : 'Loading entries...'} + {entries ? + + : + {['Loading Entries', 'Caching Entries', 'This might take several minutes']} + }
; } } From 04db90710bee56d821d7cbfaac845355da097d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 17:24:24 -0300 Subject: [PATCH 24/77] Loading Animation --- package.json | 1 + src/backends/backend.js | 1 - src/components/UI/loader/Loader.css | 24 +++++++++- src/components/UI/loader/Loader.js | 73 ++++++++++++++++++++++++----- src/containers/CollectionPage.js | 7 ++- 5 files changed, 89 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index c1db9538..0ac77f63 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "markup-it": "git+https://github.com/cassiozen/markup-it.git", "pluralize": "^3.0.0", "prismjs": "^1.5.1", + "react-addons-css-transition-group": "^15.3.1", "react-portal": "^2.2.1", "selection-position": "^1.0.0", "semaphore": "^1.0.5", diff --git a/src/backends/backend.js b/src/backends/backend.js index 0104866d..23584799 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -49,7 +49,6 @@ class Backend { entries(collection, page, perPage) { return this.implementation.entries(collection, page, perPage).then((response) => { - console.log("Got %s entries", response.entries.length); return { pagination: response.pagination, entries: response.entries.map(this.entryWithFormat(collection)) diff --git a/src/components/UI/loader/Loader.css b/src/components/UI/loader/Loader.css index c46da16c..69d3b9ad 100644 --- a/src/components/UI/loader/Loader.css +++ b/src/components/UI/loader/Loader.css @@ -33,9 +33,7 @@ left: 50%; width: 100%; height: 100%; - -webkit-animation: loader 0.6s linear; animation: loader 0.6s linear; - -webkit-animation-iteration-count: infinite; animation-iteration-count: infinite; border-radius: 500rem; border-color: #767676 transparent transparent; @@ -93,3 +91,25 @@ .disabled { display: none; } + +/*Animations*/ +.animateItem{ + position: absolute; + white-space: nowrap; + transform: translateX(-50%); +} + +.enter { + opacity: 0.01; +} +.enter.enterActive { + opacity: 1; + transition: opacity 500ms ease-in; +} +.leave { + opacity: 1; +} +.leave.leaveActive { + opacity: 0.01; + transition: opacity 300ms ease-in; +} diff --git a/src/components/UI/loader/Loader.js b/src/components/UI/loader/Loader.js index 6b7ecb42..b5e4e442 100644 --- a/src/components/UI/loader/Loader.js +++ b/src/components/UI/loader/Loader.js @@ -1,21 +1,68 @@ import React from 'react'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import styles from './Loader.css'; -export default function Loader({ active, style, className = '', children }) { - // Class names - let classNames = styles.loader; - if (active) { - classNames += ` ${styles.active}`; - } - if (className.length > 0) { - classNames += ` ${className}`; +export default class Loader extends React.Component { + constructor(props) { + super(props); + this.state = { + currentItem: 0, + }; + this.setAnimation = this.setAnimation.bind(this); + this.renderChild = this.renderChild.bind(this); } - // Render child text - let child; - if (children) { - child =
{children}
; + componengWillUnmount() { + if (this.interval) { + clearInterval(this.interval); + } } - return
{child}
; + setAnimation() { + if (this.interval) return; + const { children } = this.props; + + this.interval = setInterval(() => { + + const nextItem = (this.state.currentItem === children.length - 1) ? 0 : this.state.currentItem + 1; + this.setState({ currentItem: nextItem }); + }, 5000); + } + + renderChild() { + const { children } = this.props; + const { currentItem } = this.state; + if (!children) { + return null; + } else if (typeof children == 'string') { + return
{children}
; + } else if (Array.isArray(children)) { + this.setAnimation(); + return
+ +
{children[currentItem]}
+
+
; + } + } + + render() { + const { active, style, className = '' } = this.props; + + // Class names + let classNames = styles.loader; + if (active) { + classNames += ` ${styles.active}`; + } + if (className.length > 0) { + classNames += ` ${className}`; + } + + return
{this.renderChild()}
; + + } } diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js index 8c10703e..a04a1a21 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { loadEntries } from '../actions/entries'; import { selectEntries } from '../reducers'; +import { Loader } from '../components/UI'; import EntryListing from '../components/EntryListing'; class DashboardPage extends React.Component { @@ -28,7 +29,11 @@ class DashboardPage extends React.Component { } return
- {entries ? : 'Loading entries...'} + {entries ? + + : + {['Loading Entries', 'Caching Entries', 'This might take several minutes']} + }
; } } From b0e62d1ca99b21910d1faca431679c75bb53e31a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 18:56:03 -0300 Subject: [PATCH 25/77] Refactor: Publish mode to publish 'workflow' --- src/actions/config.js | 6 +++--- src/backends/backend.js | 8 ++++---- src/backends/constants.js | 4 ++-- src/backends/github/API.js | 4 ++-- src/backends/netlify-git/API.js | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/actions/config.js b/src/actions/config.js index 57a51510..ff6581e9 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -70,9 +70,9 @@ function parseConfig(data) { } } - if (!('publish_mode' in config.backend)) { - // Make sure there is a publish mode - config.backend['publish_mode'] = 'simple'; + if (!('publish_workflow' in config)) { + // Make sure there is a publish workflow mode set + config['publish_workflow'] = 'simple'; } if (!('public_folder' in config)) { diff --git a/src/backends/backend.js b/src/backends/backend.js index 23584799..b734108e 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -3,7 +3,7 @@ import GitHubBackend from './github/implementation'; import NetlifyGitBackend from './netlify-git/implementation'; import { resolveFormat } from '../formats/formats'; import { createEntry } from '../valueObjects/Entry'; -import { SIMPLE, BRANCH } from './constants'; +import { SIMPLE, EDITORIAL } from './constants'; class LocalStorageAuthStore { storageKey = 'nf-cms-user'; @@ -94,9 +94,9 @@ class Backend { } getPublishMode(config) { - const publish_modes = [SIMPLE, BRANCH]; - const mode = config.getIn(['backend', 'publish_mode']); - if (publish_modes.indexOf(mode) !== -1) { + const publish_workflows = [SIMPLE, EDITORIAL]; + const mode = config.get('publish_workflow'); + if (publish_workflows.indexOf(mode) !== -1) { return mode; } else { return SIMPLE; diff --git a/src/backends/constants.js b/src/backends/constants.js index 6f6c6852..e4203a35 100644 --- a/src/backends/constants.js +++ b/src/backends/constants.js @@ -1,3 +1,3 @@ -// Create/edit modes +// Create/edit workflows export const SIMPLE = 'simple'; -export const BRANCH = 'branch'; +export const EDITORIAL = 'editorial'; diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 453f3e89..2cd4ac3d 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -1,7 +1,7 @@ import LocalForage from 'localforage'; import MediaProxy from '../../valueObjects/MediaProxy'; import { Base64 } from 'js-base64'; -import { BRANCH } from '../constants'; +import { EDITORIAL } from '../constants'; const API_ROOT = 'https://api.github.com'; @@ -169,7 +169,7 @@ export default class API { .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) .then(changeTree => this.commit(options.commitMessage, changeTree)) .then((response) => { - if (options.mode && options.mode === BRANCH) { + if (options.mode && options.mode === EDITORIAL) { const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; const branchName = `cms/${contentKey}`; return this.createBranch(branchName, response.sha) diff --git a/src/backends/netlify-git/API.js b/src/backends/netlify-git/API.js index ef16c56d..e5e8d28c 100644 --- a/src/backends/netlify-git/API.js +++ b/src/backends/netlify-git/API.js @@ -1,7 +1,7 @@ import LocalForage from 'localforage'; import MediaProxy from '../../valueObjects/MediaProxy'; import { Base64 } from 'js-base64'; -import { BRANCH } from '../constants'; +import { EDITORIAL } from '../constants'; export default class API { constructor(token, url, branch) { @@ -161,7 +161,7 @@ export default class API { .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) .then(changeTree => this.commit(options.commitMessage, changeTree)) .then((response) => { - if (options.mode && options.mode === BRANCH) { + if (options.mode && options.mode === EDITORIAL) { const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; return this.createBranch(`cms/${contentKey}`, response.sha) .then(this.storeMetadata(contentKey, { status: 'draft' })) From f0e608a209114d96f1caf902103d5b43e11ae05b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 6 Sep 2016 13:04:17 -0300 Subject: [PATCH 26/77] Editorial Workflow skeleton --- src/actions/config.js | 6 ++- src/actions/editorialWorkflow.js | 53 +++++++++++++++++++ src/actions/entries.js | 1 - src/backends/backend.js | 26 +++++---- src/backends/github/API.js | 5 +- src/backends/github/implementation.js | 9 +++- src/backends/netlify-git/API.js | 4 +- src/backends/test-repo/implementation.js | 1 + .../publishModes.js} | 2 +- src/containers/CollectionPage.js | 3 +- src/formats/formats.js | 2 +- src/reducers/editorialWorkflow.js | 37 +++++++++++++ src/reducers/entries.js | 4 ++ src/reducers/index.js | 12 ++++- 14 files changed, 139 insertions(+), 26 deletions(-) create mode 100644 src/actions/editorialWorkflow.js rename src/{backends/constants.js => constants/publishModes.js} (50%) create mode 100644 src/reducers/editorialWorkflow.js diff --git a/src/actions/config.js b/src/actions/config.js index ff6581e9..8b286142 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -1,6 +1,8 @@ import yaml from 'js-yaml'; +import _ from 'lodash'; import { currentBackend } from '../backends/backend'; import { authenticate } from '../actions/auth'; +import * as publishModes from '../constants/publishModes'; import * as MediaProxy from '../valueObjects/MediaProxy'; export const CONFIG_REQUEST = 'CONFIG_REQUEST'; @@ -70,9 +72,9 @@ function parseConfig(data) { } } - if (!('publish_workflow' in config)) { + if (!('publish_mode' in config) || _.values(publishModes).indexOf(config.publish_mode) === -1) { // Make sure there is a publish workflow mode set - config['publish_workflow'] = 'simple'; + config.publish_mode = publishModes.SIMPLE; } if (!('public_folder' in config)) { diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js new file mode 100644 index 00000000..6f478892 --- /dev/null +++ b/src/actions/editorialWorkflow.js @@ -0,0 +1,53 @@ +import { currentBackend } from '../backends/backend'; +import { EDITORIAL_WORKFLOW } from '../constants/publishModes'; +/* + * Contant Declarations + */ +export const UNPUBLISHED_ENTRIES_REQUEST = 'UNPUBLISHED_ENTRIES_REQUEST'; +export const UNPUBLISHED_ENTRIES_SUCCESS = 'UNPUBLISHED_ENTRIES_SUCCESS'; +export const UNPUBLISHED_ENTRIES_FAILURE = 'UNPUBLISHED_ENTRIES_FAILURE'; + + +/* + * Simple Action Creators (Internal) + */ +function unpublishedEntriesLoading() { + return { + type: UNPUBLISHED_ENTRIES_REQUEST + }; +} + +function unpublishedEntriesLoaded(entries, pagination) { + return { + type: UNPUBLISHED_ENTRIES_SUCCESS, + payload: { + entries: entries, + pages: pagination + } + }; +} + +function unpublishedEntriesFailed(error) { + return { + type: UNPUBLISHED_ENTRIES_FAILURE, + error: 'Failed to load entries', + payload: error.toString(), + }; +} + +/* + * Exported Thunk Action Creators + */ +export function loadUnpublishedEntries() { + return (dispatch, getState) => { + const state = getState(); + if (state.publish_mode !== EDITORIAL_WORKFLOW) return; + + const backend = currentBackend(state.config); + dispatch(unpublishedEntriesLoading()); + backend.unpublishedEntries().then( + (response) => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination)), + (error) => dispatch(unpublishedEntriesFailed(error)) + ); + }; +} diff --git a/src/actions/entries.js b/src/actions/entries.js index 4f239d2f..9c9cffd6 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -17,7 +17,6 @@ export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY'; export const DRAFT_DISCARD = 'DRAFT_DISCARD'; export const DRAFT_CHANGE = 'DRAFT_CHANGE'; - export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST'; export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS'; export const ENTRY_PERSIST_FAILURE = 'ENTRY_PERSIST_FAILURE'; diff --git a/src/backends/backend.js b/src/backends/backend.js index b734108e..66fbd33c 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -3,7 +3,6 @@ import GitHubBackend from './github/implementation'; import NetlifyGitBackend from './netlify-git/implementation'; import { resolveFormat } from '../formats/formats'; import { createEntry } from '../valueObjects/Entry'; -import { SIMPLE, EDITORIAL } from './constants'; class LocalStorageAuthStore { storageKey = 'nf-cms-user'; @@ -65,9 +64,9 @@ class Backend { return this.entryWithFormat(collection)(newEntry); } - entryWithFormat(collection) { + entryWithFormat(collectionOrEntity) { return (entry) => { - const format = resolveFormat(collection, entry); + const format = resolveFormat(collectionOrEntity, entry); if (entry && entry.raw) { entry.data = format && format.fromFile(entry.raw); } @@ -75,6 +74,15 @@ class Backend { }; } + unpublishedEntries(page, perPage) { + return this.implementation.unpublishedEntries(page, perPage).then((response) => { + return { + pagination: response.pagination, + entries: response.entries.map(this.entryWithFormat('editorialWorkflow')) + }; + }); + } + slugFormatter(template, entry) { var date = new Date(); return template.replace(/\{\{([^\}]+)\}\}/g, function(_, name) { @@ -93,16 +101,6 @@ class Backend { }); } - getPublishMode(config) { - const publish_workflows = [SIMPLE, EDITORIAL]; - const mode = config.get('publish_workflow'); - if (publish_workflows.indexOf(mode) !== -1) { - return mode; - } else { - return SIMPLE; - } - } - persistEntry(config, collection, entryDraft, MediaFiles) { const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; @@ -132,7 +130,7 @@ class Backend { collection.get('label') + ' “' + entryDraft.getIn(['entry', 'data', 'title']) + '”'; - const mode = this.getPublishMode(config); + const mode = config.get('publish_mode'); const collectionName = collection.get('name'); diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 2cd4ac3d..3fe28ea5 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -1,7 +1,7 @@ import LocalForage from 'localforage'; import MediaProxy from '../../valueObjects/MediaProxy'; import { Base64 } from 'js-base64'; -import { EDITORIAL } from '../constants'; +import { EDITORIAL_WORKFLOW } from '../../constants/publishModes'; const API_ROOT = 'https://api.github.com'; @@ -169,7 +169,7 @@ export default class API { .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) .then(changeTree => this.commit(options.commitMessage, changeTree)) .then((response) => { - if (options.mode && options.mode === EDITORIAL) { + if (options.mode && options.mode === EDITORIAL_WORKFLOW) { const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; const branchName = `cms/${contentKey}`; return this.createBranch(branchName, response.sha) @@ -177,6 +177,7 @@ export default class API { type: 'PR', status: 'draft', branch: branchName, + collection: options.collectionName, title: options.parsedData.title, description: options.parsedData.description, objects: files.map(file => file.path) diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 9cb11f45..d2dd26d1 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -1,5 +1,5 @@ import semaphore from 'semaphore'; -import {createEntry} from '../../valueObjects/Entry'; +import { createEntry } from '../../valueObjects/Entry'; import AuthenticationPage from './AuthenticationPage'; import API from './API'; @@ -62,4 +62,11 @@ export default class GitHub { persistEntry(entry, mediaFiles = [], options = {}) { return this.api.persistFiles(entry, mediaFiles, options); } + + unpublishedEntries() { + return Promise.resolve({ + pagination: {}, + entries: [] + }); + } } diff --git a/src/backends/netlify-git/API.js b/src/backends/netlify-git/API.js index e5e8d28c..25e288c8 100644 --- a/src/backends/netlify-git/API.js +++ b/src/backends/netlify-git/API.js @@ -1,7 +1,7 @@ import LocalForage from 'localforage'; import MediaProxy from '../../valueObjects/MediaProxy'; import { Base64 } from 'js-base64'; -import { EDITORIAL } from '../constants'; +import { EDITORIAL_WORKFLOW } from '../../constants/publishModes'; export default class API { constructor(token, url, branch) { @@ -161,7 +161,7 @@ export default class API { .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) .then(changeTree => this.commit(options.commitMessage, changeTree)) .then((response) => { - if (options.mode && options.mode === EDITORIAL) { + if (options.mode && options.mode === EDITORIAL_WORKFLOW) { const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; return this.createBranch(`cms/${contentKey}`, response.sha) .then(this.storeMetadata(contentKey, { status: 'draft' })) diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index fce8d3d8..b8cf56cc 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -57,4 +57,5 @@ export default class TestRepo { mediaFiles.forEach(media => media.uploaded = true); return Promise.resolve(); } + } diff --git a/src/backends/constants.js b/src/constants/publishModes.js similarity index 50% rename from src/backends/constants.js rename to src/constants/publishModes.js index e4203a35..308f5696 100644 --- a/src/backends/constants.js +++ b/src/constants/publishModes.js @@ -1,3 +1,3 @@ // Create/edit workflows export const SIMPLE = 'simple'; -export const EDITORIAL = 'editorial'; +export const EDITORIAL_WORKFLOW = 'editorial_workflow'; diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js index a04a1a21..9fe0a622 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -2,6 +2,7 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { loadEntries } from '../actions/entries'; +import { loadUnpublishedEntries } from '../actions/editorialWorkflow'; import { selectEntries } from '../reducers'; import { Loader } from '../components/UI'; import EntryListing from '../components/EntryListing'; @@ -9,7 +10,7 @@ import EntryListing from '../components/EntryListing'; class DashboardPage extends React.Component { componentDidMount() { const { collection, dispatch } = this.props; - + dispatch(loadUnpublishedEntries); if (collection) { dispatch(loadEntries(collection)); } diff --git a/src/formats/formats.js b/src/formats/formats.js index 9d6f72ad..854a3505 100644 --- a/src/formats/formats.js +++ b/src/formats/formats.js @@ -1,5 +1,5 @@ import YAMLFrontmatter from './yaml-frontmatter'; -export function resolveFormat(collection, entry) { +export function resolveFormat(collectionOrEntity, entry) { return new YAMLFrontmatter(); } diff --git a/src/reducers/editorialWorkflow.js b/src/reducers/editorialWorkflow.js new file mode 100644 index 00000000..23f071f1 --- /dev/null +++ b/src/reducers/editorialWorkflow.js @@ -0,0 +1,37 @@ +import { Map, List, fromJS } from 'immutable'; +import { + UNPUBLISHED_ENTRIES_REQUEST, UNPUBLISHED_ENTRIES_SUCCESS +} from '../actions/editorialWorkflow'; + +const unpublishedEntries = (state = Map({ entities: Map(), pages: Map() }), action) => { + switch (action.type) { + case UNPUBLISHED_ENTRIES_REQUEST: + return state.setIn(['pages', 'isFetching'], true); + + case UNPUBLISHED_ENTRIES_SUCCESS: + const { entries, pages } = action.payload; + return state.withMutations((map) => { + entries.forEach((entry) => ( + map.setIn(['entities', `${entry.metadata.status}.${entry.slug}`], fromJS(entry).set('isFetching', false)) + )); + map.set('pages', Map({ + ...pages, + ids: List(entries.map((entry) => entry.slug)) + })); + }); + default: + return state; + } +}; + +export const selectUnpublishedEntry = (state, status, slug) => ( + state.getIn(['entities', `${status}.${slug}`], null) +); + +export const selectUnpublishedEntries = (state, status) => { + const slugs = state.getIn(['pages', 'ids']); + return slugs && slugs.map((slug) => selectUnpublishedEntry(state, status, slug)); +}; + + +export default unpublishedEntries; diff --git a/src/reducers/entries.js b/src/reducers/entries.js index 70e73cd2..6ae5e554 100644 --- a/src/reducers/entries.js +++ b/src/reducers/entries.js @@ -7,13 +7,16 @@ const 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) => { @@ -25,6 +28,7 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => { ids: List(entries.map((entry) => entry.slug)) })); }); + default: return state; } diff --git a/src/reducers/index.js b/src/reducers/index.js index f7199111..62615bdc 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -2,6 +2,7 @@ import auth from './auth'; import config from './config'; import editor from './editor'; import entries, * as fromEntries from './entries'; +import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow'; import entryDraft from './entryDraft'; import collections from './collections'; import medias, * as fromMedias from './medias'; @@ -12,18 +13,27 @@ const reducers = { collections, editor, entries, + editorialWorkflow, entryDraft, medias }; export default reducers; +/* + * Selectors + */ export const selectEntry = (state, collection, slug) => fromEntries.selectEntry(state.entries, collection, slug); - export const selectEntries = (state, collection) => fromEntries.selectEntries(state.entries, collection); +export const selectUnpublishedEntry = (state, status, slug) => + fromEditorialWorkflow.selectUnpublishedEntry(state.editorialWorkflow, status, slug); + +export const selectUnpublishedEntries = (state, status) => + fromEditorialWorkflow.selectUnpublishedEntries(state.editorialWorkflow, status); + export const getMedia = (state, path) => fromMedias.getMedia(state.medias, path); From 76693c71bd945a7c67c51f54b7b847e70febebee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 6 Sep 2016 15:18:48 -0300 Subject: [PATCH 27/77] typo --- src/components/UI/loader/Loader.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/UI/loader/Loader.js b/src/components/UI/loader/Loader.js index b5e4e442..c2b8ec5d 100644 --- a/src/components/UI/loader/Loader.js +++ b/src/components/UI/loader/Loader.js @@ -12,7 +12,7 @@ export default class Loader extends React.Component { this.renderChild = this.renderChild.bind(this); } - componengWillUnmount() { + componentWillUnmount() { if (this.interval) { clearInterval(this.interval); } From 90d4b39fc15a344916b35b575c140375be700cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 6 Sep 2016 17:18:27 -0300 Subject: [PATCH 28/77] Load unpublished entries --- src/actions/editorialWorkflow.js | 3 +-- src/backends/github/API.js | 32 ++++++++++++++++++++++----- src/backends/github/implementation.js | 27 +++++++++++++++++++--- src/containers/CollectionPage.js | 2 +- src/reducers/editorialWorkflow.js | 2 +- src/valueObjects/Entry.js | 1 + 6 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js index 6f478892..0c6d6817 100644 --- a/src/actions/editorialWorkflow.js +++ b/src/actions/editorialWorkflow.js @@ -41,8 +41,7 @@ function unpublishedEntriesFailed(error) { export function loadUnpublishedEntries() { return (dispatch, getState) => { const state = getState(); - if (state.publish_mode !== EDITORIAL_WORKFLOW) return; - + if (state.config.get('publish_mode') !== EDITORIAL_WORKFLOW) return; const backend = currentBackend(state.config); dispatch(unpublishedEntriesLoading()); backend.unpublishedEntries().then( diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 3fe28ea5..c4cbc03c 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -100,7 +100,7 @@ export default class API { }); } - retrieveMetadata(key, data) { + retrieveMetadata(key) { const cache = LocalForage.getItem(`gh.meta.${key}`); return cache.then((cached) => { if (cached && cached.expires > Date.now()) { return cached.data; } @@ -109,7 +109,9 @@ export default class API { params: { ref: 'refs/meta/_netlify_cms' }, headers: { Accept: 'application/vnd.github.VERSION.raw' }, cache: 'no-store', - }).then((result) => { + }) + .then(response => JSON.parse(response)) + .then((result) => { LocalForage.setItem(`gh.meta.${key}`, { expires: Date.now() + 300000, // In 5 minutes data: result, @@ -119,20 +121,19 @@ export default class API { }).catch(error => null); } - readFile(path, sha) { + readFile(path, sha, branch = this.branch) { const cache = sha ? LocalForage.getItem(`gh.${sha}`) : Promise.resolve(null); return cache.then((cached) => { if (cached) { return cached; } return this.request(`${this.repoURL}/contents/${path}`, { headers: { Accept: 'application/vnd.github.VERSION.raw' }, - params: { ref: this.branch }, + params: { ref: branch }, cache: false }).then((result) => { if (sha) { LocalForage.setItem(`gh.${sha}`, result); } - return result; }); }); @@ -144,6 +145,22 @@ export default class API { }); } + readUnpublishedBranchFile(contentKey) { + let metaData; + return this.retrieveMetadata(contentKey) + .then(data => { + metaData = data; + return this.readFile(data.objects.entry, null, data.branch); + }) + .then(file => { + return { metaData, file }; + }); + } + + listUnpublishedBranches() { + return this.request(`${this.repoURL}/git/refs/heads/cms`); + } + persistFiles(entry, mediaFiles, options) { let filename, part, parts, subtree; const fileTree = {}; @@ -180,7 +197,10 @@ export default class API { collection: options.collectionName, title: options.parsedData.title, description: options.parsedData.description, - objects: files.map(file => file.path) + objects: { + entry: entry.path, + files: mediaFiles.map(file => file.path) + } })) .then(this.createPR(options.commitMessage, `cms/${contentKey}`)); } else { diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index d2dd26d1..a4540ce1 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -64,9 +64,30 @@ export default class GitHub { } unpublishedEntries() { - return Promise.resolve({ - pagination: {}, - entries: [] + return this.api.listUnpublishedBranches().then((branches) => { + const sem = semaphore(MAX_CONCURRENT_DOWNLOADS); + const promises = []; + branches.map((branch) => { + promises.push(new Promise((resolve, reject) => { + const contentKey = branch.ref.split('refs/heads/cms/').pop(); + return sem.take(() => this.api.readUnpublishedBranchFile(contentKey).then((data) => { + const entryPath = data.metaData.objects.entry; + const entry = createEntry(entryPath, entryPath.split('/').pop().replace(/\.[^\.]+$/, ''), data.file); + entry.metaData = data.metaData; + resolve(entry); + sem.leave(); + }).catch((err) => { + sem.leave(); + reject(err); + })); + })); + }); + return Promise.all(promises); + }).then((entries) => { + return { + pagination: {}, + entries + }; }); } } diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js index 9fe0a622..cad69722 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -10,7 +10,7 @@ import EntryListing from '../components/EntryListing'; class DashboardPage extends React.Component { componentDidMount() { const { collection, dispatch } = this.props; - dispatch(loadUnpublishedEntries); + dispatch(loadUnpublishedEntries()); if (collection) { dispatch(loadEntries(collection)); } diff --git a/src/reducers/editorialWorkflow.js b/src/reducers/editorialWorkflow.js index 23f071f1..aef48f0d 100644 --- a/src/reducers/editorialWorkflow.js +++ b/src/reducers/editorialWorkflow.js @@ -12,7 +12,7 @@ const unpublishedEntries = (state = Map({ entities: Map(), pages: Map() }), acti const { entries, pages } = action.payload; return state.withMutations((map) => { entries.forEach((entry) => ( - map.setIn(['entities', `${entry.metadata.status}.${entry.slug}`], fromJS(entry).set('isFetching', false)) + map.setIn(['entities', `${entry.metaData.status}.${entry.slug}`], fromJS(entry).set('isFetching', false)) )); map.set('pages', Map({ ...pages, diff --git a/src/valueObjects/Entry.js b/src/valueObjects/Entry.js index 36ce0de0..ab247a6c 100644 --- a/src/valueObjects/Entry.js +++ b/src/valueObjects/Entry.js @@ -4,5 +4,6 @@ export function createEntry(path = '', slug = '', raw = '') { returnObj.slug = slug; returnObj.raw = raw; returnObj.data = {}; + returnObj.metaData = {}; return returnObj; } From 769d2bd2844abe661fc47458f86efad1cab318dc Mon Sep 17 00:00:00 2001 From: Mathias Biilmann Christensen Date: Thu, 8 Sep 2016 20:05:45 +0200 Subject: [PATCH 29/77] Fix content type for file requests --- src/backends/netlify-git/API.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backends/netlify-git/API.js b/src/backends/netlify-git/API.js index 8c072a30..d0bad38c 100644 --- a/src/backends/netlify-git/API.js +++ b/src/backends/netlify-git/API.js @@ -100,7 +100,7 @@ export default class API { if (cached && cached.expires > Date.now()) { return cached.data; } return this.request(`${this.repoURL}/files/${key}.json?ref=refs/meta/_netlify_cms`, { - headers: { Accept: 'application/vnd.github.VERSION.raw' }, + headers: { 'Content-Type': 'application/vnd.netlify.raw' }, cache: 'no-store', }).then((result) => { LocalForage.setItem(`gh.meta.${key}`, { @@ -118,7 +118,7 @@ export default class API { if (cached) { return cached; } return this.request(`${this.repoURL}/files/${path}`, { - headers: { Accept: 'application/vnd.github.VERSION.raw' }, + headers: { 'Content-Type': 'application/vnd.netlify.raw' }, params: { ref: this.branch }, cache: false, raw: true From 04c50d8defd1bec8402e8c03ac677f639f74a8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Thu, 8 Sep 2016 16:18:38 -0300 Subject: [PATCH 30/77] editorial workflow HoC --- src/actions/editorialWorkflow.js | 11 ++++++ src/backends/github/API.js | 6 ++- src/constants/publishModes.js | 17 ++++++++- src/containers/CollectionPage.js | 11 ++++-- src/containers/EditorialWorkflowHoC.js | 52 ++++++++++++++++++++++++++ src/reducers/editorialWorkflow.js | 9 +++-- 6 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 src/containers/EditorialWorkflowHoC.js diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js index 0c6d6817..5c906f1c 100644 --- a/src/actions/editorialWorkflow.js +++ b/src/actions/editorialWorkflow.js @@ -3,6 +3,7 @@ import { EDITORIAL_WORKFLOW } from '../constants/publishModes'; /* * Contant Declarations */ +export const INIT = 'init'; export const UNPUBLISHED_ENTRIES_REQUEST = 'UNPUBLISHED_ENTRIES_REQUEST'; export const UNPUBLISHED_ENTRIES_SUCCESS = 'UNPUBLISHED_ENTRIES_SUCCESS'; export const UNPUBLISHED_ENTRIES_FAILURE = 'UNPUBLISHED_ENTRIES_FAILURE'; @@ -35,6 +36,16 @@ function unpublishedEntriesFailed(error) { }; } +/* + * Exported simple Action Creators + */ +export function init() { + return { + type: INIT + }; +} + + /* * Exported Thunk Action Creators */ diff --git a/src/backends/github/API.js b/src/backends/github/API.js index c4cbc03c..9fe61b71 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -1,11 +1,13 @@ import LocalForage from 'localforage'; import MediaProxy from '../../valueObjects/MediaProxy'; import { Base64 } from 'js-base64'; -import { EDITORIAL_WORKFLOW } from '../../constants/publishModes'; +import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes'; const API_ROOT = 'https://api.github.com'; export default class API { + + constructor(token, repo, branch) { this.token = token; this.repo = repo; @@ -192,7 +194,7 @@ export default class API { return this.createBranch(branchName, response.sha) .then(this.storeMetadata(contentKey, { type: 'PR', - status: 'draft', + status: status.DRAFT, branch: branchName, collection: options.collectionName, title: options.parsedData.title, diff --git a/src/constants/publishModes.js b/src/constants/publishModes.js index 308f5696..9e7256d9 100644 --- a/src/constants/publishModes.js +++ b/src/constants/publishModes.js @@ -1,3 +1,18 @@ -// Create/edit workflows +import { Map } from 'immutable'; + +// Create/edit workflow modes export const SIMPLE = 'simple'; export const EDITORIAL_WORKFLOW = 'editorial_workflow'; + +// Available status +export const status = { + DRAFT: 'draft', + PENDING_REVIEW: 'pending_review', + PENDING_PUBLISH: 'pending_publish', +}; + +export const statusDescriptions = Map({ + [status.DRAFT]: 'Draft', + [status.PENDING_REVIEW]: 'Waiting for Review', + [status.PENDING_PUBLISH]: 'Waiting to go live', +}); diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js index cad69722..5790fbf0 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -2,15 +2,14 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { loadEntries } from '../actions/entries'; -import { loadUnpublishedEntries } from '../actions/editorialWorkflow'; import { selectEntries } from '../reducers'; import { Loader } from '../components/UI'; import EntryListing from '../components/EntryListing'; +import EditorialWorkflow from './EditorialWorkflowHoC'; class DashboardPage extends React.Component { componentDidMount() { const { collection, dispatch } = this.props; - dispatch(loadUnpublishedEntries()); if (collection) { dispatch(loadEntries(collection)); } @@ -38,7 +37,6 @@ class DashboardPage extends React.Component { ; } } - DashboardPage.propTypes = { collection: ImmutablePropTypes.map.isRequired, collections: ImmutablePropTypes.orderedMap.isRequired, @@ -46,6 +44,13 @@ DashboardPage.propTypes = { entries: ImmutablePropTypes.list, }; +/* + * Instead of checking the publish mode everywhere to dispatch & render the additional editorial workflow stuff, + * We delegate it to a Higher Order Component + */ +DashboardPage = EditorialWorkflow(DashboardPage); + + function mapStateToProps(state, ownProps) { const { collections } = state; const { name, slug } = ownProps.params; diff --git a/src/containers/EditorialWorkflowHoC.js b/src/containers/EditorialWorkflowHoC.js new file mode 100644 index 00000000..3c626d85 --- /dev/null +++ b/src/containers/EditorialWorkflowHoC.js @@ -0,0 +1,52 @@ +import React, { PropTypes } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { init, loadUnpublishedEntries } from '../actions/editorialWorkflow'; +import { selectUnpublishedEntries } from '../reducers'; +import { EDITORIAL_WORKFLOW } from '../constants/publishModes'; +import { connect } from 'react-redux'; + +export default function EditorialWorkflow(WrappedComponent) { + class EditorialWorkflow extends WrappedComponent { + + componentDidMount() { + const { dispatch, isEditorialWorkflow } = this.props; + if (isEditorialWorkflow) { + dispatch(init()); + dispatch(loadUnpublishedEntries()); + } + super.componentDidMount(); + } + + render() { + const { isEditorialWorkflow } = this.props; + if (!isEditorialWorkflow) return super.render(); + + return ( +
+

HOC

+ {super.render()} +
+ ); + } + } + + EditorialWorkflow.propTypes = { + dispatch: PropTypes.func.isRequired, + isEditorialWorkflow: PropTypes.bool.isRequired, + unpublishedEntries: ImmutablePropTypes.list, + }; + + function mapStateToProps(state) { + const publish_mode = state.config.get('publish_mode'); + const isEditorialWorkflow = (publish_mode === EDITORIAL_WORKFLOW); + const returnObj = { isEditorialWorkflow }; + + if (isEditorialWorkflow) { + returnObj.unpublishedEntries = selectUnpublishedEntries(state, 'draft'); + } + + return returnObj; + } + + return connect(mapStateToProps)(EditorialWorkflow); +} diff --git a/src/reducers/editorialWorkflow.js b/src/reducers/editorialWorkflow.js index aef48f0d..10307bfd 100644 --- a/src/reducers/editorialWorkflow.js +++ b/src/reducers/editorialWorkflow.js @@ -1,10 +1,13 @@ import { Map, List, fromJS } from 'immutable'; import { - UNPUBLISHED_ENTRIES_REQUEST, UNPUBLISHED_ENTRIES_SUCCESS + INIT, UNPUBLISHED_ENTRIES_REQUEST, UNPUBLISHED_ENTRIES_SUCCESS } from '../actions/editorialWorkflow'; -const unpublishedEntries = (state = Map({ entities: Map(), pages: Map() }), action) => { +const unpublishedEntries = (state = null, action) => { switch (action.type) { + case INIT: + // Editorial workflow must be explicitly initiated. + return Map({ entities: Map(), pages: Map() }); case UNPUBLISHED_ENTRIES_REQUEST: return state.setIn(['pages', 'isFetching'], true); @@ -29,9 +32,9 @@ export const selectUnpublishedEntry = (state, status, slug) => ( ); export const selectUnpublishedEntries = (state, status) => { + if (!state) return; const slugs = state.getIn(['pages', 'ids']); return slugs && slugs.map((slug) => selectUnpublishedEntry(state, status, slug)); }; - export default unpublishedEntries; From b6874152d92a8b4b6865076acd3d2fa78efd9050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Thu, 8 Sep 2016 19:04:54 -0300 Subject: [PATCH 31/77] unpublished items dashboard --- src/components/UnpublishedListing.js | 45 ++++++++++++++++++++++++++ src/containers/EditorialWorkflowHoC.js | 15 ++++++--- src/reducers/editorialWorkflow.js | 13 ++++++-- 3 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 src/components/UnpublishedListing.js diff --git a/src/components/UnpublishedListing.js b/src/components/UnpublishedListing.js new file mode 100644 index 00000000..0ef0cdfa --- /dev/null +++ b/src/components/UnpublishedListing.js @@ -0,0 +1,45 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { Card } from './UI'; +import { statusDescriptions } from '../constants/publishModes'; + +export default class UnpublishedListing extends React.Component { + renderColumn(entries) { + if (!entries) return; + return ( +
+ {entries.map(entry => { + return

{entry.getIn(['data', 'title'])}

; + } + )} +
+ ); + } + + render() { + const { entries } = this.props; + const columns = entries.entrySeq().map(([key, currEntries]) => ( +
+

{statusDescriptions.get(key)}

+ {this.renderColumn(currEntries)} +
+ )); + + return ( +
+ {columns} +
+ ); + } +} + +UnpublishedListing.propTypes = { + entries: ImmutablePropTypes.map, +}; + + + +
+

Drafts

+ Cool Recipe +
diff --git a/src/containers/EditorialWorkflowHoC.js b/src/containers/EditorialWorkflowHoC.js index 3c626d85..a7ffcc74 100644 --- a/src/containers/EditorialWorkflowHoC.js +++ b/src/containers/EditorialWorkflowHoC.js @@ -1,9 +1,12 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { Map } from 'immutable'; import { init, loadUnpublishedEntries } from '../actions/editorialWorkflow'; import { selectUnpublishedEntries } from '../reducers'; -import { EDITORIAL_WORKFLOW } from '../constants/publishModes'; +import { EDITORIAL_WORKFLOW, status } from '../constants/publishModes'; +import UnpublishedListing from '../components/UnpublishedListing'; import { connect } from 'react-redux'; +import _ from 'lodash'; export default function EditorialWorkflow(WrappedComponent) { class EditorialWorkflow extends WrappedComponent { @@ -18,12 +21,12 @@ export default function EditorialWorkflow(WrappedComponent) { } render() { - const { isEditorialWorkflow } = this.props; + const { isEditorialWorkflow, unpublishedEntries } = this.props; if (!isEditorialWorkflow) return super.render(); return (
-

HOC

+ {super.render()}
); @@ -33,7 +36,7 @@ export default function EditorialWorkflow(WrappedComponent) { EditorialWorkflow.propTypes = { dispatch: PropTypes.func.isRequired, isEditorialWorkflow: PropTypes.bool.isRequired, - unpublishedEntries: ImmutablePropTypes.list, + unpublishedEntries: ImmutablePropTypes.map, }; function mapStateToProps(state) { @@ -42,7 +45,9 @@ export default function EditorialWorkflow(WrappedComponent) { const returnObj = { isEditorialWorkflow }; if (isEditorialWorkflow) { - returnObj.unpublishedEntries = selectUnpublishedEntries(state, 'draft'); + returnObj.unpublishedEntries = _.reduce(status, (acc, currStatus) => { + return acc.set(currStatus, selectUnpublishedEntries(state, currStatus)); + }, Map()); } return returnObj; diff --git a/src/reducers/editorialWorkflow.js b/src/reducers/editorialWorkflow.js index 10307bfd..95cf9e28 100644 --- a/src/reducers/editorialWorkflow.js +++ b/src/reducers/editorialWorkflow.js @@ -28,13 +28,22 @@ const unpublishedEntries = (state = null, action) => { }; export const selectUnpublishedEntry = (state, status, slug) => ( - state.getIn(['entities', `${status}.${slug}`], null) + state.getIn(['entities', `${status}.${slug}`]) ); export const selectUnpublishedEntries = (state, status) => { if (!state) return; const slugs = state.getIn(['pages', 'ids']); - return slugs && slugs.map((slug) => selectUnpublishedEntry(state, status, slug)); + + return slugs && slugs.reduce((acc, slug) => { + const entry = selectUnpublishedEntry(state, status, slug); + if (entry) { + return acc.push(entry); + } else { + return acc; + } + }, List()); }; + export default unpublishedEntries; From 08596e906f9cb64eb41c3970c6b795dcedf03b56 Mon Sep 17 00:00:00 2001 From: Mathias Biilmann Christensen Date: Fri, 9 Sep 2016 16:57:28 +0200 Subject: [PATCH 32/77] Better css module classnames --- webpack.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpack.config.js b/webpack.config.js index b312cf01..b5dbcf83 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -13,7 +13,7 @@ module.exports = { { test: /\.json$/, loader: 'json-loader' }, { test: /\.css$/, - loader: ExtractTextPlugin.extract("style", "css?modules&importLoaders=1!postcss"), + loader: ExtractTextPlugin.extract("style", "css?modules&importLoaders=1&&localIdentName=cms__[name]__[local]!postcss"), }, { loader: 'babel', From 102429aa5b8f3c56f2aa64ab5cc09b4c6bb1e7de Mon Sep 17 00:00:00 2001 From: Mathias Biilmann Christensen Date: Fri, 9 Sep 2016 17:31:59 +0200 Subject: [PATCH 33/77] Make preview pane render to an iframe --- src/components/PreviewPane.js | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/components/PreviewPane.js b/src/components/PreviewPane.js index 2964ae93..731f9c90 100644 --- a/src/components/PreviewPane.js +++ b/src/components/PreviewPane.js @@ -1,8 +1,9 @@ import React, { PropTypes } from 'react'; +import { render } from 'react-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Widgets from './Widgets'; -export default class PreviewPane extends React.Component { +class Preview extends React.Component { previewFor(field) { const { entry, getMedia } = this.props; const widget = Widgets[field.get('widget')] || Widgets._unknown; @@ -17,13 +18,43 @@ export default class PreviewPane extends React.Component { const { collection } = this.props; if (!collection) { return null; } - return
{collection.get('fields').map((field) =>
{this.previewFor(field)}
)}
; } } +export default class PreviewPane extends React.Component { + constructor(props) { + super(props); + this.handleIframeRef = this.handleIframeRef.bind(this); + } + + componentDidUpdate() { + this.renderPreview(); + } + + renderPreview() { + const props = this.props; + render(, this.previewEl); + } + + handleIframeRef(ref) { + if (ref) { + this.previewEl = document.createElement('div'); + ref.contentDocument.body.appendChild(this.previewEl); + this.renderPreview(); + } + } + + render() { + const { collection } = this.props; + if (!collection) { return null; } + + return + } +} + PreviewPane.propTypes = { collection: ImmutablePropTypes.map.isRequired, entry: ImmutablePropTypes.map.isRequired, From c51f42658e667c4420b3668f43b0cfd162fec972 Mon Sep 17 00:00:00 2001 From: Mathias Biilmann Christensen Date: Fri, 9 Sep 2016 17:32:23 +0200 Subject: [PATCH 34/77] Get rid of double connect in markdown control --- src/components/Widgets/MarkdownControl.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/Widgets/MarkdownControl.js b/src/components/Widgets/MarkdownControl.js index a315cb80..13cc4759 100644 --- a/src/components/Widgets/MarkdownControl.js +++ b/src/components/Widgets/MarkdownControl.js @@ -64,8 +64,6 @@ class MarkdownControl extends React.Component { } } -export default MarkdownControl; - MarkdownControl.propTypes = { editor: PropTypes.object.isRequired, onChange: PropTypes.func.isRequired, From c84d538eb6364e313e4950d5f4d164c9f7eec1cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Fri, 9 Sep 2016 17:15:58 -0300 Subject: [PATCH 35/77] editor workflow ui adjustments --- package.json | 1 + src/backends/github/API.js | 69 ++++++++++++++------------ src/components/UnpublishedListing.css | 29 +++++++++++ src/components/UnpublishedListing.js | 47 ++++++++++-------- src/constants/publishModes.js | 12 ++--- src/containers/EditorialWorkflowHoC.js | 13 +++-- 6 files changed, 107 insertions(+), 64 deletions(-) create mode 100644 src/components/UnpublishedListing.css diff --git a/package.json b/package.json index 0ac77f63..c4432c59 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ }, "dependencies": { "bricks.js": "^1.7.0", + "dateformat": "^1.0.12", "fuzzy": "^0.1.1", "js-base64": "^2.1.9", "json-loader": "^0.5.4", diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 9fe61b71..210a229f 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -103,24 +103,12 @@ export default class API { } retrieveMetadata(key) { - const cache = LocalForage.getItem(`gh.meta.${key}`); - return cache.then((cached) => { - if (cached && cached.expires > Date.now()) { return cached.data; } - - return this.request(`${this.repoURL}/contents/${key}.json`, { - params: { ref: 'refs/meta/_netlify_cms' }, - headers: { Accept: 'application/vnd.github.VERSION.raw' }, - cache: 'no-store', - }) - .then(response => JSON.parse(response)) - .then((result) => { - LocalForage.setItem(`gh.meta.${key}`, { - expires: Date.now() + 300000, // In 5 minutes - data: result, - }); - return result; - }); - }).catch(error => null); + return this.request(`${this.repoURL}/contents/${key}.json`, { + params: { ref: 'refs/meta/_netlify_cms' }, + headers: { Accept: 'application/vnd.github.VERSION.raw' }, + cache: 'no-store', + }) + .then(response => JSON.parse(response)); } readFile(path, sha, branch = this.branch) { @@ -148,14 +136,26 @@ export default class API { } readUnpublishedBranchFile(contentKey) { - let metaData; - return this.retrieveMetadata(contentKey) - .then(data => { - metaData = data; - return this.readFile(data.objects.entry, null, data.branch); - }) - .then(file => { - return { metaData, file }; + const cache = LocalForage.getItem(`gh.unpublished.${contentKey}`); + return cache.then((cached) => { + if (cached && cached.expires > Date.now()) { return cached.data; } + + let metaData; + return this.retrieveMetadata(contentKey) + .then(data => { + metaData = data; + return this.readFile(data.objects.entry, null, data.branch); + }) + .then(file => { + return { metaData, file }; + }) + .then((result) => { + LocalForage.setItem(`gh.unpublished.${contentKey}`, { + expires: Date.now() + 300000, // In 5 minutes + data: result, + }); + return result; + }); }); } @@ -191,19 +191,24 @@ export default class API { if (options.mode && options.mode === EDITORIAL_WORKFLOW) { const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; const branchName = `cms/${contentKey}`; - return this.createBranch(branchName, response.sha) - .then(this.storeMetadata(contentKey, { + return this.user().then(user => { + return user.name ? user.name : user.login; + }) + .then(username => this.storeMetadata(contentKey, { type: 'PR', - status: status.DRAFT, + user: username, + status: status.first(), branch: branchName, collection: options.collectionName, - title: options.parsedData.title, - description: options.parsedData.description, + title: options.parsedData && options.parsedData.title, + description: options.parsedData && options.parsedData.description, objects: { entry: entry.path, files: mediaFiles.map(file => file.path) - } + }, + timeStamp: new Date().toISOString() })) + .then(this.createBranch(branchName, response.sha)) .then(this.createPR(options.commitMessage, `cms/${contentKey}`)); } else { return this.patchBranch(this.branch, response.sha); diff --git a/src/components/UnpublishedListing.css b/src/components/UnpublishedListing.css new file mode 100644 index 00000000..a4b97b67 --- /dev/null +++ b/src/components/UnpublishedListing.css @@ -0,0 +1,29 @@ +.column { + position: relative; + display: inline-block; + vertical-align: top; + text-align: center; + width: 28%; +} + +.column:not(:last-child) { + margin-right: 8%; +} + +.card { + width: 100% !important; + margin: 7px 0; + + & h1 { + font-size: 17px; + & small { + font-weight: normal; + } + } + + & p { + color: #555; + font-size: 12px; + margin-top: 5px; + } +} diff --git a/src/components/UnpublishedListing.js b/src/components/UnpublishedListing.js index 0ef0cdfa..e43c8b07 100644 --- a/src/components/UnpublishedListing.js +++ b/src/components/UnpublishedListing.js @@ -1,29 +1,41 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import dateFormat from 'dateFormat'; import { Card } from './UI'; import { statusDescriptions } from '../constants/publishModes'; +import styles from './UnpublishedListing.css'; export default class UnpublishedListing extends React.Component { - renderColumn(entries) { + renderColumns(entries, column) { if (!entries) return; - return ( -
+ + if (!column) { + return entries.entrySeq().map(([currColumn, currEntries]) => ( +
+

{statusDescriptions.get(currColumn)}

+ {this.renderColumns(currEntries, currColumn)} +
+ )); + } else { + return
{entries.map(entry => { - return

{entry.getIn(['data', 'title'])}

; + // Look for an "author" field. Fallback to username on backend implementation; + const author = entry.getIn(['data', 'author'], entry.getIn(['metaData', 'user'])); + const timeStamp = dateFormat(Date.parse(entry.getIn(['metaData', 'timeStamp'])), 'longDate'); + return ( + +

{entry.getIn(['data', 'title'])} by {author}

+

Last updated: {timeStamp} by {entry.getIn(['metaData', 'user'])}

+
+ ); } )} -
- ); +
; + } } render() { - const { entries } = this.props; - const columns = entries.entrySeq().map(([key, currEntries]) => ( -
-

{statusDescriptions.get(key)}

- {this.renderColumn(currEntries)} -
- )); + const columns = this.renderColumns(this.props.entries); return (
@@ -34,12 +46,5 @@ export default class UnpublishedListing extends React.Component { } UnpublishedListing.propTypes = { - entries: ImmutablePropTypes.map, + entries: ImmutablePropTypes.orderedMap, }; - - - -
-

Drafts

- Cool Recipe -
diff --git a/src/constants/publishModes.js b/src/constants/publishModes.js index 9e7256d9..2cf4e443 100644 --- a/src/constants/publishModes.js +++ b/src/constants/publishModes.js @@ -1,18 +1,18 @@ -import { Map } from 'immutable'; +import { Map, OrderedMap } from 'immutable'; // Create/edit workflow modes export const SIMPLE = 'simple'; export const EDITORIAL_WORKFLOW = 'editorial_workflow'; // Available status -export const status = { +export const status = OrderedMap({ DRAFT: 'draft', PENDING_REVIEW: 'pending_review', PENDING_PUBLISH: 'pending_publish', -}; +}); export const statusDescriptions = Map({ - [status.DRAFT]: 'Draft', - [status.PENDING_REVIEW]: 'Waiting for Review', - [status.PENDING_PUBLISH]: 'Waiting to go live', + [status.get('DRAFT')]: 'Draft', + [status.get('PENDING_REVIEW')]: 'Waiting for Review', + [status.get('PENDING_PUBLISH')]: 'Waiting to go live', }); diff --git a/src/containers/EditorialWorkflowHoC.js b/src/containers/EditorialWorkflowHoC.js index a7ffcc74..603f7819 100644 --- a/src/containers/EditorialWorkflowHoC.js +++ b/src/containers/EditorialWorkflowHoC.js @@ -1,12 +1,11 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { Map } from 'immutable'; +import { OrderedMap } from 'immutable'; import { init, loadUnpublishedEntries } from '../actions/editorialWorkflow'; import { selectUnpublishedEntries } from '../reducers'; import { EDITORIAL_WORKFLOW, status } from '../constants/publishModes'; import UnpublishedListing from '../components/UnpublishedListing'; import { connect } from 'react-redux'; -import _ from 'lodash'; export default function EditorialWorkflow(WrappedComponent) { class EditorialWorkflow extends WrappedComponent { @@ -45,11 +44,15 @@ export default function EditorialWorkflow(WrappedComponent) { const returnObj = { isEditorialWorkflow }; if (isEditorialWorkflow) { - returnObj.unpublishedEntries = _.reduce(status, (acc, currStatus) => { + /* + * Generates an ordered Map of the available status as keys. + * Each key containing a List of available unpubhlished entries + * Eg.: OrderedMap{'draft':List(), 'pending_review':List(), 'pending_publish':List()} + */ + returnObj.unpublishedEntries = status.reduce((acc, currStatus) => { return acc.set(currStatus, selectUnpublishedEntries(state, currStatus)); - }, Map()); + }, OrderedMap()); } - return returnObj; } From 8d63ff0a8804ba7d9f3c144c3688e8abe31ee960 Mon Sep 17 00:00:00 2001 From: Mathias Biilmann Christensen Date: Sun, 11 Sep 2016 17:53:44 +0200 Subject: [PATCH 36/77] Expose methods for installations to create custom preview components --- package.json | 1 + src/components/ControlPane.js | 19 ++++++----- src/components/PreviewPane.css | 6 ++++ src/components/PreviewPane.js | 34 +++++++++++++++++-- .../VisualEditor/index.js | 1 + src/index.css | 2 +- src/index.js | 6 ++++ src/lib/registry.js | 20 +++++++++++ 8 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 src/components/PreviewPane.css create mode 100644 src/lib/registry.js diff --git a/package.json b/package.json index c1db9538..a0a84167 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "dependencies": { "bricks.js": "^1.7.0", "fuzzy": "^0.1.1", + "html-to-react": "^1.0.0", "js-base64": "^2.1.9", "json-loader": "^0.5.4", "localforage": "^1.4.2", diff --git a/src/components/ControlPane.js b/src/components/ControlPane.js index a86ecaf5..59beb698 100644 --- a/src/components/ControlPane.js +++ b/src/components/ControlPane.js @@ -6,14 +6,17 @@ export default class ControlPane extends React.Component { controlFor(field) { const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props; const widget = Widgets[field.get('widget')] || Widgets._unknown; - return React.createElement(widget.Control, { - field: field, - value: entry.getIn(['data', field.get('name')]), - onChange: (value) => onChange(entry.setIn(['data', field.get('name')], value)), - onAddMedia: onAddMedia, - onRemoveMedia: onRemoveMedia, - getMedia: getMedia - }); + return
+ + {React.createElement(widget.Control, { + field: field, + value: entry.getIn(['data', field.get('name')]), + onChange: (value) => onChange(entry.setIn(['data', field.get('name')], value)), + onAddMedia: onAddMedia, + onRemoveMedia: onRemoveMedia, + getMedia: getMedia + })} +
; } render() { diff --git a/src/components/PreviewPane.css b/src/components/PreviewPane.css new file mode 100644 index 00000000..6bf62a0a --- /dev/null +++ b/src/components/PreviewPane.css @@ -0,0 +1,6 @@ +.frame { + width: 100%; + height: 100%; + border: none; + background: #fff; +} diff --git a/src/components/PreviewPane.js b/src/components/PreviewPane.js index 731f9c90..24af9229 100644 --- a/src/components/PreviewPane.js +++ b/src/components/PreviewPane.js @@ -1,7 +1,9 @@ import React, { PropTypes } from 'react'; import { render } from 'react-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { getPreviewTemplate, getPreviewStyles } from '../lib/registry'; import Widgets from './Widgets'; +import styles from './PreviewPane.css'; class Preview extends React.Component { previewFor(field) { @@ -24,23 +26,49 @@ class Preview extends React.Component { } } +Preview.propTypes = { + collection: ImmutablePropTypes.map.isRequired, + entry: ImmutablePropTypes.map.isRequired, + getMedia: PropTypes.func.isRequired, +}; + export default class PreviewPane extends React.Component { constructor(props) { super(props); this.handleIframeRef = this.handleIframeRef.bind(this); + this.widgetFor = this.widgetFor.bind(this); } componentDidUpdate() { this.renderPreview(); } + widgetFor(name) { + const { collection, entry, getMedia } = this.props; + const field = collection.get('fields').find((field) => field.get('name') === name); + const widget = Widgets[field.get('widget')] || Widgets._unknown; + return React.createElement(widget.Preview, { + field: field, + value: entry.getIn(['data', field.get('name')]), + getMedia: getMedia, + }); + } + renderPreview() { - const props = this.props; - render(, this.previewEl); + const props = Object.assign({}, this.props, {widgetFor: this.widgetFor}); + const component = getPreviewTemplate(props.collection.get('name')) || Preview; + + render(React.createElement(component, props), this.previewEl); } handleIframeRef(ref) { if (ref) { + getPreviewStyles().forEach((style) => { + const linkEl = document.createElement('link'); + linkEl.setAttribute('rel', 'stylesheet'); + linkEl.setAttribute('href', style); + ref.contentDocument.head.appendChild(linkEl); + }); this.previewEl = document.createElement('div'); ref.contentDocument.body.appendChild(this.previewEl); this.renderPreview(); @@ -51,7 +79,7 @@ export default class PreviewPane extends React.Component { const { collection } = this.props; if (!collection) { return null; } - return + return } } diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js index 7525bcb5..0f9eb696 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js @@ -42,6 +42,7 @@ class VisualEditor extends React.Component { let rawJson; if (props.value !== undefined) { const content = this.markdown.toContent(props.value); + console.log('md: %o', content); rawJson = SlateUtils.encode(content, null, ['mediaproxy'].concat(getPlugins().map(plugin => plugin.id))); } else { rawJson = emptyParagraphBlock; diff --git a/src/index.css b/src/index.css index cd5e3545..4bc2c468 100644 --- a/src/index.css +++ b/src/index.css @@ -59,4 +59,4 @@ button{ line-height: 18px; background-color:#fff; cursor: pointer; -} \ No newline at end of file +} diff --git a/src/index.js b/src/index.js index 13dc95ca..5ef69bb4 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,7 @@ import React from 'react'; import { render } from 'react-dom'; import { Provider } from 'react-redux'; import { Router } from 'react-router'; +import { registerPreviewStyle,registerPreviewTemplate } from './lib/registry'; import configureStore from './store/configureStore'; import routes from './routing/routes'; import history, { syncHistory } from './routing/history'; @@ -29,3 +30,8 @@ render(( ), el); + +window.CMS = { + registerPreviewStyle: registerPreviewStyle, + registerPreviewTemplate: registerPreviewTemplate +}; diff --git a/src/lib/registry.js b/src/lib/registry.js new file mode 100644 index 00000000..809b9784 --- /dev/null +++ b/src/lib/registry.js @@ -0,0 +1,20 @@ +const registry = { + templates: {}, + previewStyles: [] +}; + +export function registerPreviewStyle(style) { + registry.previewStyles.push(style); +} + +export function registerPreviewTemplate(name, component) { + registry.templates[name] = component; +} + +export function getPreviewTemplate(name) { + return registry.templates[name]; +} + +export function getPreviewStyles() { + return registry.previewStyles; +} From bbbf3c5621de4598ce37cbc7cab4a5d238427ad0 Mon Sep 17 00:00:00 2001 From: Mathias Biilmann Christensen Date: Sun, 11 Sep 2016 23:07:48 +0200 Subject: [PATCH 37/77] Add datetime widget --- package.json | 1 + src/components/Widgets/DateTimeControl.js | 22 ++++++++++++++++++++++ src/components/Widgets/DateTimePreview.js | 9 +++++++++ 3 files changed, 32 insertions(+) create mode 100644 src/components/Widgets/DateTimeControl.js create mode 100644 src/components/Widgets/DateTimePreview.js diff --git a/package.json b/package.json index a0a84167..f9371cdb 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "markup-it": "git+https://github.com/cassiozen/markup-it.git", "pluralize": "^3.0.0", "prismjs": "^1.5.1", + "react-datetime": "^2.6.0", "react-portal": "^2.2.1", "selection-position": "^1.0.0", "semaphore": "^1.0.5", diff --git a/src/components/Widgets/DateTimeControl.js b/src/components/Widgets/DateTimeControl.js new file mode 100644 index 00000000..82476a25 --- /dev/null +++ b/src/components/Widgets/DateTimeControl.js @@ -0,0 +1,22 @@ +import React, { PropTypes } from 'react'; +import DateTime from 'react-datetime'; + +export default class DateTimeControl extends React.Component { + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + } + + handleChange(datetime) { + this.props.onChange(datetime); + } + + render() { + return ; + } +} + +DateTimeControl.propTypes = { + onChange: PropTypes.func.isRequired, + value: PropTypes.node, +}; diff --git a/src/components/Widgets/DateTimePreview.js b/src/components/Widgets/DateTimePreview.js new file mode 100644 index 00000000..972e068c --- /dev/null +++ b/src/components/Widgets/DateTimePreview.js @@ -0,0 +1,9 @@ +import React, { PropTypes } from 'react'; + +export default function StringPreview({ value }) { + return {value}; +} + +StringPreview.propTypes = { + value: PropTypes.node, +}; From fcd0ce718a3160b8572ffd83a0c730aac0eb8e81 Mon Sep 17 00:00:00 2001 From: Mathias Biilmann Christensen Date: Sun, 11 Sep 2016 23:08:18 +0200 Subject: [PATCH 38/77] Support for YAML content --- src/components/ControlPane.js | 6 +- src/components/EntryEditor.css | 10 ++ src/components/EntryEditor.js | 21 +--- src/components/EntryListing.js | 3 +- src/components/PreviewPane.js | 18 +-- src/components/Widgets.js | 31 ++--- src/formats/formats.js | 12 +- src/index.css | 212 +++++++++++++++++++++++++++++++++ src/index.js | 11 +- src/lib/registry.js | 40 ++++--- 10 files changed, 291 insertions(+), 73 deletions(-) create mode 100644 src/components/EntryEditor.css diff --git a/src/components/ControlPane.js b/src/components/ControlPane.js index 59beb698..fc85243b 100644 --- a/src/components/ControlPane.js +++ b/src/components/ControlPane.js @@ -1,14 +1,14 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Widgets from './Widgets'; +import {resolveWidget} from './Widgets'; export default class ControlPane extends React.Component { controlFor(field) { const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props; - const widget = Widgets[field.get('widget')] || Widgets._unknown; + const widget = resolveWidget(field.get('widget')); return
- {React.createElement(widget.Control, { + {React.createElement(widget.control, { field: field, value: entry.getIn(['data', field.get('name')]), onChange: (value) => onChange(entry.setIn(['data', field.get('name')], value)), diff --git a/src/components/EntryEditor.css b/src/components/EntryEditor.css new file mode 100644 index 00000000..b77e517f --- /dev/null +++ b/src/components/EntryEditor.css @@ -0,0 +1,10 @@ +.container { + display: flex +} +.controlPane { + width: 50%; + padding: 0 10px; +} +.previewPane { + width: 50%; +} diff --git a/src/components/EntryEditor.js b/src/components/EntryEditor.js index 6ae93edd..c4a6353b 100644 --- a/src/components/EntryEditor.js +++ b/src/components/EntryEditor.js @@ -2,13 +2,14 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ControlPane from './ControlPane'; import PreviewPane from './PreviewPane'; +import styles from './EntryEditor.css'; export default function EntryEditor({ collection, entry, getMedia, onChange, onAddMedia, onRemoveMedia, onPersist }) { return

Entry in {collection.get('label')}

{entry && entry.get('title')}

-
-
+
+
-
+
@@ -26,20 +27,6 @@ export default function EntryEditor({ collection, entry, getMedia, onChange, onA
; } -const styles = { - container: { - display: 'flex' - }, - controlPane: { - width: '50%', - paddingLeft: '10px', - paddingRight: '10px' - }, - pane: { - width: '50%' - } -}; - EntryEditor.propTypes = { collection: ImmutablePropTypes.map.isRequired, entry: ImmutablePropTypes.map.isRequired, diff --git a/src/components/EntryListing.js b/src/components/EntryListing.js index 28baa927..7ca45393 100644 --- a/src/components/EntryListing.js +++ b/src/components/EntryListing.js @@ -60,7 +60,8 @@ export default class EntryListing extends React.Component { cardFor(collection, entry, link) { //const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props; - const card = Cards[collection.getIn(['card', 'type'])] || Cards._unknown; + const cartType = collection.getIn(['card', 'type']) || 'alltype'; + const card = Cards[cartType] || Cards._unknown; return React.createElement(card, { key: entry.get('slug'), collection: collection, diff --git a/src/components/PreviewPane.js b/src/components/PreviewPane.js index 24af9229..9ce76ab5 100644 --- a/src/components/PreviewPane.js +++ b/src/components/PreviewPane.js @@ -1,15 +1,15 @@ import React, { PropTypes } from 'react'; import { render } from 'react-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { getPreviewTemplate, getPreviewStyles } from '../lib/registry'; -import Widgets from './Widgets'; +import registry from '../lib/registry'; +import { resolveWidget } from './Widgets'; import styles from './PreviewPane.css'; class Preview extends React.Component { previewFor(field) { const { entry, getMedia } = this.props; - const widget = Widgets[field.get('widget')] || Widgets._unknown; - return React.createElement(widget.Preview, { + const widget = resolveWidget(field.get('widget')); + return React.createElement(widget.preview, { field: field, value: entry.getIn(['data', field.get('name')]), getMedia: getMedia, @@ -46,8 +46,8 @@ export default class PreviewPane extends React.Component { widgetFor(name) { const { collection, entry, getMedia } = this.props; const field = collection.get('fields').find((field) => field.get('name') === name); - const widget = Widgets[field.get('widget')] || Widgets._unknown; - return React.createElement(widget.Preview, { + const widget = resolveWidget(field.get('widget')); + return React.createElement(widget.preview, { field: field, value: entry.getIn(['data', field.get('name')]), getMedia: getMedia, @@ -56,14 +56,14 @@ export default class PreviewPane extends React.Component { renderPreview() { const props = Object.assign({}, this.props, {widgetFor: this.widgetFor}); - const component = getPreviewTemplate(props.collection.get('name')) || Preview; + const component = registry.getPreviewTemplate(props.collection.get('name')) || Preview; render(React.createElement(component, props), this.previewEl); } handleIframeRef(ref) { if (ref) { - getPreviewStyles().forEach((style) => { + registry.getPreviewStyles().forEach((style) => { const linkEl = document.createElement('link'); linkEl.setAttribute('rel', 'stylesheet'); linkEl.setAttribute('href', style); @@ -79,7 +79,7 @@ export default class PreviewPane extends React.Component { const { collection } = this.props; if (!collection) { return null; } - return + return ; } } diff --git a/src/components/Widgets.js b/src/components/Widgets.js index 0a86b6b7..e03bfa8f 100644 --- a/src/components/Widgets.js +++ b/src/components/Widgets.js @@ -1,3 +1,4 @@ +import registry from '../lib/registry'; import UnknownControl from './Widgets/UnknownControl'; import UnknownPreview from './Widgets/UnknownPreview'; import StringControl from './Widgets/StringControl'; @@ -6,25 +7,15 @@ import MarkdownControl from './Widgets/MarkdownControl'; import MarkdownPreview from './Widgets/MarkdownPreview'; import ImageControl from './Widgets/ImageControl'; import ImagePreview from './Widgets/ImagePreview'; +import DateTimeControl from './Widgets/DateTimeControl'; +import DateTimePreview from './Widgets/DateTimePreview'; +registry.registerWidget('string', StringControl, StringPreview); +registry.registerWidget('markdown', MarkdownControl, MarkdownPreview); +registry.registerWidget('image', ImageControl, ImagePreview); +registry.registerWidget('datetime', DateTimeControl, DateTimePreview); +registry.registerWidget('_unknown', UnknownControl, UnknownPreview); -const Widgets = { - _unknown: { - Control: UnknownControl, - Preview: UnknownPreview - }, - string: { - Control: StringControl, - Preview: StringPreview - }, - markdown: { - Control: MarkdownControl, - Preview: MarkdownPreview - }, - image: { - Control: ImageControl, - Preview: ImagePreview - } -}; - -export default Widgets; +export function resolveWidget(name) { + return registry.getWidget(name) || registry.getWidget('_unknown'); +} diff --git a/src/formats/formats.js b/src/formats/formats.js index 9d6f72ad..8e3679b5 100644 --- a/src/formats/formats.js +++ b/src/formats/formats.js @@ -1,5 +1,15 @@ +import YAML from './yaml'; import YAMLFrontmatter from './yaml-frontmatter'; +const yamlFormatter = new YAML(); +const YamlFrontmatterFormatter = new YAMLFrontmatter(); + export function resolveFormat(collection, entry) { - return new YAMLFrontmatter(); + const extension = entry.path.split('.').pop(); + switch (extension) { + case 'yml': + return yamlFormatter; + default: + return YamlFrontmatterFormatter; + } } diff --git a/src/index.css b/src/index.css index 4bc2c468..21a60d09 100644 --- a/src/index.css +++ b/src/index.css @@ -60,3 +60,215 @@ button{ background-color:#fff; cursor: pointer; } + +:global { + & .rdt { + position: relative; + } + & .rdtPicker { + display: none; + position: absolute; + width: 250px; + padding: 4px; + margin-top: 1px; + z-index: 99999 !important; + background: #fff; + box-shadow: 0 1px 3px rgba(0,0,0,.1); + border: 1px solid #f9f9f9; + } + & .rdtOpen .rdtPicker { + display: block; + } + & .rdtStatic .rdtPicker { + box-shadow: none; + position: static; + } + + & .rdtPicker .rdtTimeToggle { + text-align: center; + } + + & .rdtPicker table { + width: 100%; + margin: 0; + } + & .rdtPicker td, + & .rdtPicker th { + text-align: center; + height: 28px; + } + & .rdtPicker td { + cursor: pointer; + } + & .rdtPicker td.rdtDay:hover, + & .rdtPicker td.rdtHour:hover, + & .rdtPicker td.rdtMinute:hover, + & .rdtPicker td.rdtSecond:hover, + & .rdtPicker .rdtTimeToggle:hover { + background: #eeeeee; + cursor: pointer; + } + & .rdtPicker td.rdtOld, + & .rdtPicker td.rdtNew { + color: #999999; + } + & .rdtPicker td.rdtToday { + position: relative; + } + & .rdtPicker td.rdtToday:before { + content: ''; + display: inline-block; + border-left: 7px solid transparent; + border-bottom: 7px solid #428bca; + border-top-color: rgba(0, 0, 0, 0.2); + position: absolute; + bottom: 4px; + right: 4px; + } + & .rdtPicker td.rdtActive, + & .rdtPicker td.rdtActive:hover { + background-color: #428bca; + color: #fff; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); + } + & .rdtPicker td.rdtActive.rdtToday:before { + border-bottom-color: #fff; + } + & .rdtPicker td.rdtDisabled, + & .rdtPicker td.rdtDisabled:hover { + background: none; + color: #999999; + cursor: not-allowed; + } + + & .rdtPicker td span.rdtOld { + color: #999999; + } + & .rdtPicker td span.rdtDisabled, + & .rdtPicker td span.rdtDisabled:hover { + background: none; + color: #999999; + cursor: not-allowed; + } + & .rdtPicker th { + border-bottom: 1px solid #f9f9f9; + } + & .rdtPicker .dow { + width: 14.2857%; + border-bottom: none; + } + & .rdtPicker th.rdtSwitch { + width: 100px; + } + & .rdtPicker th.rdtNext, + & .rdtPicker th.rdtPrev { + font-size: 21px; + vertical-align: top; + } + + & .rdtPrev span, + & .rdtNext span { + display: block; + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Chrome/Safari/Opera */ + -khtml-user-select: none; /* Konqueror */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; + } + + & .rdtPicker th.rdtDisabled, + & .rdtPicker th.rdtDisabled:hover { + background: none; + color: #999999; + cursor: not-allowed; + } + & .rdtPicker thead tr:first-child th { + cursor: pointer; + } + & .rdtPicker thead tr:first-child th:hover { + background: #eeeeee; + } + + & .rdtPicker tfoot { + border-top: 1px solid #f9f9f9; + } + + & .rdtPicker button { + border: none; + background: none; + cursor: pointer; + } + & .rdtPicker button:hover { + background-color: #eee; + } + + & .rdtPicker thead button { + width: 100%; + height: 100%; + } + + & td.rdtMonth, + & td.rdtYear { + height: 50px; + width: 25%; + cursor: pointer; + } + & td.rdtMonth:hover, + & td.rdtYear:hover { + background: #eee; + } + + & .rdtCounters { + display: inline-block; + } + + & .rdtCounters > div { + float: left; + } + + & .rdtCounter { + height: 100px; + } + + & .rdtCounter { + width: 40px; + } + + & .rdtCounterSeparator { + line-height: 100px; + } + + & .rdtCounter .rdtBtn { + height: 40%; + line-height: 40px; + cursor: pointer; + display: block; + + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Chrome/Safari/Opera */ + -khtml-user-select: none; /* Konqueror */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; + } + & .rdtCounter .rdtBtn:hover { + background: #eee; + } + & .rdtCounter .rdtCount { + height: 20%; + font-size: 1.2em; + } + + & .rdtMilli { + vertical-align: middle; + padding-left: 8px; + width: 48px; + } + + & .rdtMilli input { + width: 100%; + font-size: 1.2em; + margin-top: 37px; + } +} diff --git a/src/index.js b/src/index.js index 5ef69bb4..e93eaea1 100644 --- a/src/index.js +++ b/src/index.js @@ -2,7 +2,7 @@ import React from 'react'; import { render } from 'react-dom'; import { Provider } from 'react-redux'; import { Router } from 'react-router'; -import { registerPreviewStyle,registerPreviewTemplate } from './lib/registry'; +import registry from './lib/registry'; import configureStore from './store/configureStore'; import routes from './routing/routes'; import history, { syncHistory } from './routing/history'; @@ -31,7 +31,8 @@ render(( ), el); -window.CMS = { - registerPreviewStyle: registerPreviewStyle, - registerPreviewTemplate: registerPreviewTemplate -}; +window.CMS = {}; +console.log('reg: ', registry); +for (const method in registry) { + window.CMS[method] = registry[method]; +} diff --git a/src/lib/registry.js b/src/lib/registry.js index 809b9784..84a1eb94 100644 --- a/src/lib/registry.js +++ b/src/lib/registry.js @@ -1,20 +1,26 @@ -const registry = { +const _registry = { templates: {}, - previewStyles: [] + previewStyles: [], + widgets: {} }; -export function registerPreviewStyle(style) { - registry.previewStyles.push(style); -} - -export function registerPreviewTemplate(name, component) { - registry.templates[name] = component; -} - -export function getPreviewTemplate(name) { - return registry.templates[name]; -} - -export function getPreviewStyles() { - return registry.previewStyles; -} +export default { + registerPreviewStyle(style) { + _registry.previewStyles.push(style); + }, + registerPreviewTemplate(name, component) { + _registry.templates[name] = component; + }, + getPreviewTemplate(name) { + return _registry.templates[name]; + }, + getPreviewStyles() { + return _registry.previewStyles; + }, + registerWidget(name, control, preview) { + _registry.widgets[name] = { control, preview }; + }, + getWidget(name) { + return _registry.widgets[name]; + } +}; From 8221c9c170b63d01c304ea7a566901203e4787b8 Mon Sep 17 00:00:00 2001 From: Mathias Biilmann Christensen Date: Mon, 12 Sep 2016 11:14:21 +0200 Subject: [PATCH 39/77] Maor UI polish for editing with live preview --- package.json | 1 - src/components/ControlPane.js | 2 +- src/components/EntryEditor.css | 19 +++++- src/components/EntryEditor.js | 68 +++++++++++++------ src/components/Widgets.js | 3 + src/components/Widgets/DateTimeControl.js | 2 +- src/components/Widgets/MarkdownControl.js | 12 ++-- .../MarkdownControlElements/plugins.js} | 35 ++++------ src/components/Widgets/TextControl.js | 37 ++++++++++ src/components/Widgets/TextPreview.js | 4 ++ src/components/Widgets/richText.js | 2 - src/containers/App.css | 42 +----------- src/containers/CollectionPage.css | 39 +++++++++++ src/containers/CollectionPage.js | 3 +- src/index.css | 59 ++++++++++++++-- src/index.js | 11 +-- src/lib/registry.js | 12 +++- 17 files changed, 242 insertions(+), 109 deletions(-) rename src/{plugins/index.js => components/Widgets/MarkdownControlElements/plugins.js} (52%) create mode 100644 src/components/Widgets/TextControl.js create mode 100644 src/components/Widgets/TextPreview.js create mode 100644 src/containers/CollectionPage.css diff --git a/package.json b/package.json index f9371cdb..a3c0dd05 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "dependencies": { "bricks.js": "^1.7.0", "fuzzy": "^0.1.1", - "html-to-react": "^1.0.0", "js-base64": "^2.1.9", "json-loader": "^0.5.4", "localforage": "^1.4.2", diff --git a/src/components/ControlPane.js b/src/components/ControlPane.js index fc85243b..435aaffa 100644 --- a/src/components/ControlPane.js +++ b/src/components/ControlPane.js @@ -23,7 +23,7 @@ export default class ControlPane extends React.Component { const { collection } = this.props; 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.css b/src/components/EntryEditor.css index b77e517f..03c128bd 100644 --- a/src/components/EntryEditor.css +++ b/src/components/EntryEditor.css @@ -1,9 +1,24 @@ +.entryEditor { + display: flex; + flex-direction: column; + height: 100%; +} .container { - display: flex + display: flex; + height: 100%; +} +.footer { + background: #fff; + height: 45px; + border-top: 1px solid #e8eae8; + padding: 10px 20px; } .controlPane { width: 50%; - padding: 0 10px; + max-height: 100%; + overflow: auto; + padding: 0 20px; + border-right: 1px solid #e8eae8; } .previewPane { width: 50%; diff --git a/src/components/EntryEditor.js b/src/components/EntryEditor.js index c4a6353b..ea83f7bb 100644 --- a/src/components/EntryEditor.js +++ b/src/components/EntryEditor.js @@ -4,27 +4,57 @@ import ControlPane from './ControlPane'; import PreviewPane from './PreviewPane'; import styles from './EntryEditor.css'; -export default function EntryEditor({ collection, entry, getMedia, onChange, onAddMedia, onRemoveMedia, onPersist }) { - return
-

Entry in {collection.get('label')}

-

{entry && entry.get('title')}

-
-
- +export default class EntryEditor extends React.Component { + constructor(props) { + super(props); + this.state = {}; + this.handleResize = this.handleResize.bind(this); + } + + componentDidMount() { + this.calculateHeight(); + window.addEventListener('resize', this.handleResize, false); + } + + componengWillUnmount() { + window.removeEventListener('resize', this.handleResize); + } + + handleResize() { + this.calculateHeight(); + } + + calculateHeight() { + const height = window.innerHeight - 54; + console.log('setting height to %s', height); + this.setState({height}); + } + + render() { + const { collection, entry, getMedia, onChange, onAddMedia, onRemoveMedia, onPersist } = this.props; + const {height} = this.state; + + return
+
+
+ +
+
+ +
-
- +
+
-
- -
; +
; + } } EntryEditor.propTypes = { diff --git a/src/components/Widgets.js b/src/components/Widgets.js index e03bfa8f..e731ed78 100644 --- a/src/components/Widgets.js +++ b/src/components/Widgets.js @@ -3,6 +3,8 @@ import UnknownControl from './Widgets/UnknownControl'; import UnknownPreview from './Widgets/UnknownPreview'; import StringControl from './Widgets/StringControl'; import StringPreview from './Widgets/StringPreview'; +import TextControl from './Widgets/TextControl'; +import TextPreview from './Widgets/TextPreview'; import MarkdownControl from './Widgets/MarkdownControl'; import MarkdownPreview from './Widgets/MarkdownPreview'; import ImageControl from './Widgets/ImageControl'; @@ -11,6 +13,7 @@ import DateTimeControl from './Widgets/DateTimeControl'; import DateTimePreview from './Widgets/DateTimePreview'; registry.registerWidget('string', StringControl, StringPreview); +registry.registerWidget('text', TextControl, TextPreview); registry.registerWidget('markdown', MarkdownControl, MarkdownPreview); registry.registerWidget('image', ImageControl, ImagePreview); registry.registerWidget('datetime', DateTimeControl, DateTimePreview); diff --git a/src/components/Widgets/DateTimeControl.js b/src/components/Widgets/DateTimeControl.js index 82476a25..7f49868a 100644 --- a/src/components/Widgets/DateTimeControl.js +++ b/src/components/Widgets/DateTimeControl.js @@ -18,5 +18,5 @@ export default class DateTimeControl extends React.Component { DateTimeControl.propTypes = { onChange: PropTypes.func.isRequired, - value: PropTypes.node, + value: PropTypes.object, }; diff --git a/src/components/Widgets/MarkdownControl.js b/src/components/Widgets/MarkdownControl.js index 13cc4759..54abac8b 100644 --- a/src/components/Widgets/MarkdownControl.js +++ b/src/components/Widgets/MarkdownControl.js @@ -1,4 +1,5 @@ import React, { PropTypes } from 'react'; +import registry from '../../lib/registry'; import RawEditor from './MarkdownControlElements/RawEditor'; import VisualEditor from './MarkdownControlElements/VisualEditor'; import { processEditorPlugins } from './richText'; @@ -13,7 +14,8 @@ class MarkdownControl extends React.Component { } componentWillMount() { - processEditorPlugins(this.context.plugins.editor); + this.useRawEditor(); + processEditorPlugins(registry.getEditorComponents()); } useVisualEditor() { @@ -28,8 +30,8 @@ class MarkdownControl extends React.Component { const { editor, onChange, onAddMedia, getMedia, value } = this.props; if (editor.get('useVisualMode')) { return ( -
- +
+ {null && } - +
+ {null && } { - const configObj = new EditorComponent({ - id: config.id || config.label.replace(/[^A-Z0-9]+/ig, '_'), - label: config.label, - icon: config.icon, - fields: config.fields, - pattern: config.pattern, - fromBlock: _.isFunction(config.fromBlock) ? config.fromBlock.bind(null) : null, - toBlock: _.isFunction(config.toBlock) ? config.toBlock.bind(null) : null, - toPreview: _.isFunction(config.toPreview) ? config.toPreview.bind(null) : config.toBlock.bind(null) - }); - - plugins.editor = plugins.editor.push(configObj); - }; -} - class Plugin extends Component { getChildContext() { @@ -51,8 +34,18 @@ Plugin.childContextTypes = { plugins: PropTypes.object }; +export function newEditorPlugin(config) { + const configObj = new EditorComponent({ + id: config.id || config.label.replace(/[^A-Z0-9]+/ig, '_'), + label: config.label, + icon: config.icon, + fields: config.fields, + pattern: config.pattern, + fromBlock: _.isFunction(config.fromBlock) ? config.fromBlock.bind(null) : null, + toBlock: _.isFunction(config.toBlock) ? config.toBlock.bind(null) : null, + toPreview: _.isFunction(config.toPreview) ? config.toPreview.bind(null) : config.toBlock.bind(null) + }); -export const initPluginAPI = () => { - window.CMS = new CMS(); - return Plugin; -}; + + return configObj; +} diff --git a/src/components/Widgets/TextControl.js b/src/components/Widgets/TextControl.js new file mode 100644 index 00000000..aaeec4e3 --- /dev/null +++ b/src/components/Widgets/TextControl.js @@ -0,0 +1,37 @@ +import React, { PropTypes } from 'react'; + +export default class StringControl extends React.Component { + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + this.handleRef = this.handleRef.bind(this); + } + + componentDidMount() { + this.updateHeight(); + } + + handleChange(e) { + this.props.onChange(e.target.value); + this.updateHeight(); + } + + updateHeight() { + if (this.element.scrollHeight > this.element.clientHeight) { + this.element.style.height = this.element.scrollHeight + 'px'; + } + } + + handleRef(ref) { + this.element = ref; + } + + render() { + return